git.iko.soy/opencode-android
on master help sign in
Remove QR code import/share feature and zxing dependency
commit1a92af4660051b7b5b45b08e6a6f000d04c6ea54 authoriko <mail@iko.soy> dateSun 5 Jul 2026, 16:50 (3 months ago) parentba033ba
10 files changed+7−384
app/build.gradle.kts−1 app/src/main/java/soy/iko/opencode/ui/components/EmptyState.kt−22 app/src/main/java/soy/iko/opencode/ui/components/QrCode.kt−175 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+1−10 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt−29 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+5−108 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt−16 app/src/main/res/values-es/strings.xml+1−11 app/src/main/res/values/strings.xml−10 gradle/libs.versions.toml−2
app/build.gradle.kts−1
@@ -153,7 +153,6 @@ dependencies {
153153 implementation(libs.coil.network.okhttp)
154154 implementation(libs.markdown.renderer)
155155 implementation(libs.markdown.renderer.m3)
156− implementation(libs.zxing.core)
157156
158157 testImplementation(libs.junit)
159158 testImplementation(libs.kotlinx.coroutines.test)
app/src/main/java/soy/iko/opencode/ui/components/EmptyState.kt−22
@@ -2,13 +2,11 @@ package soy.iko.opencode.ui.components
22
33import androidx.compose.foundation.layout.Arrangement
44import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
65import androidx.compose.foundation.layout.padding
76import androidx.compose.foundation.layout.size
87import androidx.compose.material3.Button
98import androidx.compose.material3.Icon
109import androidx.compose.material3.MaterialTheme
11−import androidx.compose.material3.OutlinedButton
1210import androidx.compose.material3.Text
1311import androidx.compose.runtime.Composable
1412import androidx.compose.ui.Alignment
@@ -24,10 +22,6 @@ import androidx.compose.ui.unit.dp
2422 * Shared empty/placeholder state: a centered icon, a title, an optional supporting line,
2523 * and an optional call-to-action button. Extracted so every screen's "nothing here yet"
2624 * state reads and behaves the same (see EmptySessions/EmptyServers/etc. which predate this).
27− *
28− * An optional [secondaryActionLabel] / [onSecondaryAction] pair renders a secondary
29− * OutlinedButton below the primary CTA, for cases where two recovery paths are equally
30− * valid (e.g. "Add server" vs "Import from QR image" on a first-run server list).
3125 */
3226@Composable
3327fun EmptyState(
@@ -38,9 +32,6 @@ fun EmptyState(
3832 actionIcon: ImageVector? = null,
3933 actionLabel: String? = null,
4034 onAction: (() -> Unit)? = null,
41− secondaryActionIcon: ImageVector? = null,
42− secondaryActionLabel: String? = null,
43− onSecondaryAction: (() -> Unit)? = null,
4435) {
4536 Column(
4637 modifier = modifier.padding(32.dp),
@@ -82,18 +73,5 @@ fun EmptyState(
8273 }
8374 }
8475 }
85− if (secondaryActionLabel != null && onSecondaryAction != null) {
86− OutlinedButton(
87− onClick = onSecondaryAction,
88− modifier = Modifier.testTag("empty_state_secondary_action"),
89− ) {
90− if (secondaryActionIcon != null) {
91− Icon(secondaryActionIcon, contentDescription = null, modifier = Modifier.size(18.dp))
92− Text(secondaryActionLabel, modifier = Modifier.padding(start = 6.dp))
93− } else {
94− Text(secondaryActionLabel)
95− }
96− }
97− }
9876 }
9977}
app/src/main/java/soy/iko/opencode/ui/components/QrCode.ktdeleted−175
@@ -1,175 +0,0 @@
1−@file:Suppress("MatchingDeclarationName")
2−
3−package soy.iko.opencode.ui.components
4−
5−import android.graphics.Bitmap
6−import androidx.compose.foundation.Image
7−import androidx.compose.foundation.background
8−import androidx.compose.foundation.layout.Column
9−import androidx.compose.foundation.layout.Spacer
10−import androidx.compose.foundation.layout.fillMaxWidth
11−import androidx.compose.foundation.layout.padding
12−import androidx.compose.foundation.layout.size
13−import androidx.compose.material3.AlertDialog
14−import androidx.compose.material3.MaterialTheme
15−import androidx.compose.material3.Text
16−import androidx.compose.material3.TextButton
17−import androidx.compose.runtime.Composable
18−import androidx.compose.runtime.remember
19−import androidx.compose.ui.Alignment
20−import androidx.compose.ui.Modifier
21−import androidx.compose.ui.graphics.Color
22−import androidx.compose.ui.graphics.asImageBitmap
23−import androidx.compose.ui.res.stringResource
24−import androidx.compose.ui.text.style.TextAlign
25−import androidx.compose.ui.unit.dp
26−import com.google.zxing.BarcodeFormat
27−import com.google.zxing.BinaryBitmap
28−import com.google.zxing.DecodeHintType
29−import com.google.zxing.MultiFormatReader
30−import com.google.zxing.MultiFormatWriter
31−import com.google.zxing.RGBLuminanceSource
32−import com.google.zxing.common.HybridBinarizer
33−import kotlinx.serialization.Serializable
34−import kotlinx.serialization.json.Json
35−import soy.iko.opencode.R
36−import soy.iko.opencode.data.model.ServerProfile
37−
38−/**
39− * Wire format for sharing a [ServerProfile] as a QR code (and re-importing it). Only the
40− * connection-relevant fields travel — [ServerProfile.id] and [ServerProfile.lastUsed] are
41− * device-local and excluded. Marked with a [v] version tag so a future format change can be
42− * detected and rejected instead of mis-parsed.
43− */
44−@Serializable
45−data class ServerProfileQr(
46− val v: Int = 1,
47− val label: String = "",
48− val baseUrl: String,
49− val username: String? = null,
50− val password: String? = null,
51− val requireHttps: Boolean = false,
52− val certPin: String? = null,
53−)
54−
55−/** Lenient JSON for QR encode/decode (independent of [soy.iko.opencode.data.network.OpencodeJson],
56− * which is tuned for the server's polymorphic payloads). */
57−private val qrJson = Json { ignoreUnknownKeys = true; isLenient = true }
58−
59−/** Serialize a [ServerProfile] to the QR payload string. */
60−fun ServerProfile.toQrPayload(): String = qrJson.encodeToString(
61− ServerProfileQr.serializer(),
62− ServerProfileQr(
63− label = label,
64− baseUrl = baseUrl,
65− username = username,
66− password = password,
67− requireHttps = requireHttps,
68− certPin = certPin,
69− ),
70−)
71−
72−/** Parse a scanned/decoded QR payload back into a [ServerProfileQr], or null if it isn't one of
73− * our payloads (so an arbitrary QR doesn't blow up the import flow). */
74−fun parseQrPayload(payload: String): ServerProfileQr? = runCatching {
75− qrJson.decodeFromString(ServerProfileQr.serializer(), payload)
76−}.getOrNull()
77−
78−/** Render a QR code carrying [content] at [sizePx] pixels, or null if encoding fails. Uses ZXing's
79− * core (no camera/UI deps) so it stays a pure-Java encode. */
80−fun encodeQrBitmap(content: String, sizePx: Int): Bitmap? = runCatching {
81− val matrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, sizePx, sizePx)
82− val width = matrix.width
83− val height = matrix.height
84− val pixels = IntArray(width * height)
85− for (y in 0 until height) {
86− val offset = y * width
87− for (x in 0 until width) {
88− pixels[offset + x] = if (matrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
89− }
90− }
91− Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
92− setPixels(pixels, 0, width, 0, 0, width, height)
93− }
94−}.getOrNull()
95−
96−/** Decode a QR code from a decoded [Bitmap] (e.g. an image the user picked to import a server),
97− * returning its text content or null if no QR was found. */
98−fun decodeQrFromBitmap(bitmap: Bitmap): String? = runCatching {
99− val width = bitmap.width
100− val height = bitmap.height
101− val pixels = IntArray(width * height)
102− bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
103− val source = RGBLuminanceSource(width, height, pixels)
104− val binary = BinaryBitmap(HybridBinarizer(source))
105− val reader = MultiFormatReader()
106− reader.setHints(mapOf(DecodeHintType.TRY_HARDER to true))
107− reader.decodeWithState(binary).text
108−}.getOrNull()
109−
110−/** Decode a server-profile QR from an image [uri] (the result of an image picker). Runs Bitmap
111− * decode + ZXing scan on the caller's dispatcher. Returns null if the image couldn't be read or
112− * its QR isn't one of our server payloads. */
113−suspend fun decodeServerQr(context: android.content.Context, uri: android.net.Uri): ServerProfileQr? =
114− kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
115− runCatching {
116− val bitmap = android.provider.MediaStore.Images.Media.getBitmap(context.contentResolver, uri)
117− ?: return@runCatching null
118− decodeQrFromBitmap(bitmap)?.let { parseQrPayload(it) }
119− }.getOrNull()
120− }
121−
122−/** A Compose QR code image. The QR is generated off the composition; encoding a few hundred
123− * modules is sub-millisecond so no background dispatching is warranted. Drawn on a white
124− * background (QR scanners need the light module) with a small inner pad so the code isn't flush
125− * with the dialog edges. */
126−@Composable
127−fun QrImage(content: String, sizePx: Int, modifier: Modifier = Modifier, label: String? = null) {
128− val bitmap = remember(content, sizePx) { encodeQrBitmap(content, sizePx) }
129− if (bitmap != null) {
130− val description = label?.let { stringResource(R.string.qr_image_for, it) }
131− Image(
132− bitmap = bitmap.asImageBitmap(),
133− contentDescription = description,
134− modifier = modifier
135− .size(sizePx.dp)
136− .background(Color.White)
137− .padding(8.dp),
138− )
139− }
140−}
141−
142−/** Dialog that shows a server profile as a scannable QR code, for transferring a profile (URL +
143− * credentials + cert pin) to another device without re-typing. The payload includes the password
144− * when present; a warning makes that explicit. */
145−@Composable
146−fun QrShareDialog(profile: ServerProfile, onDismiss: () -> Unit) {
147− val payload = remember(profile.id, profile.baseUrl, profile.password) { profile.toQrPayload() }
148− AlertDialog(
149− onDismissRequest = onDismiss,
150− confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.ok)) } },
151− title = { Text(profile.displayLabel) },
152− text = {
153− Column(
154− horizontalAlignment = Alignment.CenterHorizontally,
155− modifier = Modifier.fillMaxWidth(),
156− ) {
157− QrImage(content = payload, sizePx = 256, label = profile.displayLabel)
158− Spacer(Modifier.size(8.dp))
159− Text(
160− profile.baseUrl,
161− style = MaterialTheme.typography.bodySmall,
162− color = MaterialTheme.colorScheme.onSurfaceVariant,
163− textAlign = TextAlign.Center,
164− )
165− if (profile.password != null) {
166− Text(
167− stringResource(R.string.qr_includes_password_warning),
168− style = MaterialTheme.typography.labelSmall,
169− color = MaterialTheme.colorScheme.error,
170− )
171− }
172− }
173− },
174− )
175−}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+1−10
@@ -235,12 +235,7 @@ private fun ServerEditForm(
235235 // fresh add form (via baseUrlFocus above).
236236 OutlinedTextField(
237237 value = state.baseUrl,
238− onValueChange = { v ->
239− // A paste of a full QR/JSON config into the URL field populates the whole form
240− // (so a user can paste a shared server config instead of scanning an image).
241− // Falls through to a normal URL edit when it isn't a recognized payload.
242− if (!vm.applyQrPayload(v)) vm.update { it.copy(baseUrl = v) }
243− },
238+ onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
244239 label = { Text(stringResource(R.string.base_url)) },
245240 placeholder = { Text(stringResource(R.string.base_url_hint)) },
246241 singleLine = true,
@@ -271,10 +266,6 @@ private fun ServerEditForm(
271266 } else {
272267 Text(stringResource(R.string.invalid_url))
273268 }
274− } else if (state.baseUrl.isBlank()) {
275− // Surface the QR-paste shortcut so a user with a shared server config knows
276− // they can paste it here instead of scanning an image.
277− Text(stringResource(R.string.qr_paste_hint))
278269 }
279270 },
280271 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt−29
@@ -259,35 +259,6 @@ class ServerEditViewModel(
259259 _state.update { transform(it).copy(credentialsResult = null, error = null) }
260260 }
261261
262− /**
263− * Populate the form from a pasted/decoded QR payload (a JSON blob the Share-as-QR flow
264− * produces) or an `opencode://server/...` link. Returns true when [text] was recognized and
265− * applied so the caller can surface a "fields filled" confirmation. Lets a user transfer a
266− * server config by pasting instead of scanning an image.
267− */
268− fun applyQrPayload(text: String): Boolean {
269− val qr = soy.iko.opencode.ui.components.parseQrPayload(text.trim()) ?: return false
270− _state.update {
271− it.copy(
272− // Carry the label from the QR payload (the image-scan import path does this too;
273− // omitting it here left the Label field blank when pasting a shared config).
274− label = qr.label.takeIf { it.isNotBlank() } ?: it.label,
275− baseUrl = qr.baseUrl,
276− username = qr.username.orEmpty(),
277− password = qr.password.orEmpty(),
278− requireHttps = qr.requireHttps,
279− certPin = qr.certPin.orEmpty(),
280− // Reveal the auth fields when the payload carries credentials so the user can
281− // see/verify them — otherwise they're populated in state but hidden behind
282− // AnimatedVisibility(authFieldsVisible), with no visual confirmation.
283− authFieldsVisible = !qr.username.isNullOrBlank() || !qr.password.isNullOrEmpty(),
284− credentialsResult = null,
285− error = null,
286− )
287− }
288− return true
289− }
290−
291262 /**
292263 * Primary action: save the profile and connect to it. When the server needs auth and the
293264 * user hasn't supplied credentials yet, a connectivity probe reveals the username/password
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+5−108
@@ -23,7 +23,6 @@ import androidx.compose.material.icons.filled.Error
2323import androidx.compose.material.icons.filled.Delete
2424import androidx.compose.material.icons.filled.Dns
2525import androidx.compose.material.icons.filled.MoreVert
26−import androidx.compose.material.icons.filled.PhotoLibrary
2726import androidx.compose.material.icons.filled.Search
2827import androidx.compose.material3.AlertDialog
2928import androidx.compose.material3.Button
@@ -65,13 +64,11 @@ import androidx.compose.ui.platform.LocalHapticFeedback
6564import androidx.compose.ui.platform.testTag
6665import androidx.compose.ui.semantics.Role
6766import androidx.compose.ui.semantics.contentDescription
68−import androidx.compose.ui.platform.LocalContext
6967import androidx.compose.ui.semantics.semantics
7068import androidx.compose.ui.text.style.TextOverflow
7169import androidx.compose.ui.unit.dp
7270import androidx.compose.ui.res.stringResource
7371import androidx.activity.compose.BackHandler
74−import androidx.activity.compose.rememberLauncherForActivityResult
7572import androidx.compose.animation.Crossfade
7673import androidx.compose.animation.core.tween
7774import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -118,10 +115,10 @@ fun ServerListScreen(
118115 .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected)
119116 val haptics = LocalHapticFeedback.current
120117 val snackbar = remember { SnackbarHostState() }
121− // Separate host for transient error messages (connect/probe failures, QR import results)
122− // so a transient error can't preempt an undo snackbar's full window — the undo and error
123− // channels don't share a queue, so a connect error can't cancel an in-flight undo and
124− // strand a pending server delete.
118+ // Separate host for transient error messages (connect/probe failures) so a transient
119+ // error can't preempt an undo snackbar's full window — the undo and error channels
120+ // don't share a queue, so a connect error can't cancel an in-flight undo and strand a
121+ // pending server delete.
125122 val errorSnackbar = remember { SnackbarHostState() }
126123 val retryLabel = stringResource(R.string.retry)
127124 val undoLabel = stringResource(R.string.undo)
@@ -132,19 +129,6 @@ fun ServerListScreen(
132129 // threshold logic the file/session pickers use for their search fields.
133130 var serverQuery by rememberSaveable { mutableStateOf("") }
134131 val connectedId = activeConnection?.profile?.id
135− // Profile being shown as a scannable QR (Share as QR overflow action), and a decoded QR
136− // payload awaiting the user's confirm before it's saved (Import from image action).
137− var pendingQrProfile by remember { mutableStateOf<ServerProfile?>(null) }
138− val context = androidx.compose.ui.platform.LocalContext.current
139− val scope = androidx.compose.runtime.rememberCoroutineScope()
140− // QR import (image pick → decode → confirm → save) is encapsulated in its own composable so the
141− // branches don't inflate ServerListScreen's complexity. It renders the confirm dialog inline.
142− val qrImportLauncher = ServerQrImportHandler(
143− vm = vm,
144− scope = scope,
145− snackbar = errorSnackbar,
146− context = context,
147− )
148132
149133 LaunchedEffect(Unit) {
150134 vm.errorEvents.collect { event ->
@@ -259,13 +243,6 @@ fun ServerListScreen(
259243 )
260244 }
261245 }
262− // Import a server by decoding a QR code from a saved image (e.g. a screenshot
263− // of another device's Share-as-QR dialog). Camera scanning would need extra
264− // permissions; an image picker reuses the system gallery and covers the
265− // cross-device transfer use case.
266− IconButton(onClick = { qrImportLauncher.launch(arrayOf("image/*")) }) {
267− Icon(Icons.Filled.PhotoLibrary, contentDescription = stringResource(R.string.import_from_image))
268− }
269246 },
270247 )
271248 },
@@ -345,7 +322,6 @@ fun ServerListScreen(
345322 }
346323 "empty" -> EmptyServers(
347324 onAdd = onAddProfile,
348− onQrImport = { qrImportLauncher.launch(arrayOf("image/*")) },
349325 modifier = Modifier.fillMaxSize(),
350326 )
351327 else -> {
@@ -416,7 +392,6 @@ fun ServerListScreen(
416392 onEdit = { onEditProfile(profile.id) },
417393 onDuplicate = { onDuplicateProfile(profile.id) },
418394 onPendingDelete = { pendingDeleteId = profile.id },
419− onShareQr = { pendingQrProfile = profile },
420395 onTestConnection = { vm.testConnection(profile) },
421396 isProbing = probingId == profile.id,
422397 )
@@ -470,7 +445,6 @@ fun ServerListScreen(
470445 onEdit = { onEditProfile(profile.id) },
471446 onDuplicate = { onDuplicateProfile(profile.id) },
472447 onPendingDelete = { pendingDeleteId = profile.id },
473− onShareQr = { pendingQrProfile = profile },
474448 onTestConnection = { vm.testConnection(profile) },
475449 isProbing = probingId == profile.id,
476450 )
@@ -510,12 +484,6 @@ fun ServerListScreen(
510484 },
511485 )
512486 }
513−
514− // Share-as-QR dialog for the selected profile. (The import confirm dialog is rendered by
515− // ServerQrImportHandler above.)
516− pendingQrProfile?.let { profile ->
517− soy.iko.opencode.ui.components.QrShareDialog(profile = profile, onDismiss = { pendingQrProfile = null })
518− }
519487}
520488
521489/** Shared content for a server profile card — used by both the active-row (plain Card)
@@ -530,7 +498,6 @@ private fun ServerCardContent(
530498 onEdit: () -> Unit,
531499 onDuplicate: () -> Unit,
532500 onPendingDelete: () -> Unit,
533− onShareQr: () -> Unit = {},
534501 onTestConnection: () -> Unit = {},
535502 isProbing: Boolean = false,
536503 activeState: soy.iko.opencode.data.network.EventStreamClient.ConnectionState? = null,
@@ -665,10 +632,6 @@ private fun ServerCardContent(
665632 text = { Text(testConnLabel) },
666633 onClick = { showRowMenu = false; onTestConnection() },
667634 )
668− DropdownMenuItem(
669− text = { Text(stringResource(R.string.share_qr)) },
670− onClick = { showRowMenu = false; onShareQr() },
671− )
672635 DropdownMenuItem(
673636 text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
674637 onClick = { showRowMenu = false; onPendingDelete() },
@@ -703,7 +666,7 @@ private fun LastUsedText(lastUsed: Long) {
703666}
704667
705668@Composable
706−private fun EmptyServers(onAdd: () -> Unit, onQrImport: () -> Unit, modifier: Modifier = Modifier) {
669+private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
707670 Box(modifier = modifier, contentAlignment = Alignment.Center) {
708671 EmptyState(
709672 icon = Icons.Filled.Dns,
@@ -712,74 +675,8 @@ private fun EmptyServers(onAdd: () -> Unit, onQrImport: () -> Unit, modifier: Mo
712675 actionIcon = Icons.Filled.Add,
713676 actionLabel = stringResource(R.string.add_server),
714677 onAction = onAdd,
715− secondaryActionIcon = Icons.Filled.PhotoLibrary,
716− secondaryActionLabel = stringResource(R.string.import_from_image),
717− onSecondaryAction = onQrImport,
718− )
719− }
720−}
721−
722−/** Owns the QR-import flow: an image-picker launcher plus the confirm dialog for a decoded
723− * server payload. Extracted from [ServerListScreen] so its branches (null checks, decode
724− * result, confirm) don't push the parent composable over the cyclomatic-complexity threshold.
725− * Renders the confirm dialog inline. */
726−@Composable
727−private fun ServerQrImportHandler(
728− vm: ServerListViewModel,
729− scope: kotlinx.coroutines.CoroutineScope,
730− snackbar: SnackbarHostState,
731− context: android.content.Context,
732−): androidx.activity.result.ActivityResultLauncher<Array<String>> {
733− var pendingImport by remember { mutableStateOf<soy.iko.opencode.ui.components.ServerProfileQr?>(null) }
734− val qrAddedMsg = stringResource(R.string.qr_added)
735− val qrImportFailedMsg = stringResource(R.string.qr_import_failed)
736− val launcher = rememberLauncherForActivityResult(
737− androidx.activity.result.contract.ActivityResultContracts.OpenDocument(),
738− ) { uri ->
739− if (uri == null) return@rememberLauncherForActivityResult
740− scope.launch {
741− val qr = soy.iko.opencode.ui.components.decodeServerQr(context, uri)
742− if (qr != null) pendingImport = qr else snackbar.showSnackbar(qrImportFailedMsg)
743− }
744− }
745− pendingImport?.let { qr ->
746− AlertDialog(
747− onDismissRequest = { pendingImport = null },
748− title = { Text(stringResource(R.string.qr_import_title)) },
749− text = {
750− Column {
751− Text(qr.baseUrl, style = MaterialTheme.typography.bodyLarge)
752− if (qr.label.isNotBlank()) {
753− Text(
754− qr.label,
755− style = MaterialTheme.typography.bodySmall,
756− color = MaterialTheme.colorScheme.onSurfaceVariant,
757− )
758− }
759− if (qr.password != null) {
760− Text(
761− stringResource(R.string.qr_includes_password),
762− style = MaterialTheme.typography.labelSmall,
763− color = MaterialTheme.colorScheme.error,
764− )
765− }
766− }
767− },
768− confirmButton = {
769− TextButton(onClick = {
770− pendingImport = null
771− scope.launch {
772− vm.importFromQr(qr)
773− snackbar.showSnackbar(qrAddedMsg)
774− }
775− }) { Text(stringResource(R.string.add_server)) }
776− },
777− dismissButton = {
778− TextButton(onClick = { pendingImport = null }) { Text(stringResource(R.string.cancel)) }
779− },
780678 )
781679 }
782− return launcher
783680}
784681
785682/** Filter server profiles by label/base URL. Extracted from [ServerListScreen] to keep its
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt−16
@@ -253,20 +253,4 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
253253 if (!container.cancelProfileDelete(profileId)) return
254254 _hiddenIds.update { it - profileId }
255255 }
256−
257− /** Save a new profile decoded from a scanned/imported QR payload, returning the created
258− * profile so the caller can connect or route to the editor. Does not connect. */
259− suspend fun importFromQr(qr: soy.iko.opencode.ui.components.ServerProfileQr): ServerProfile {
260− val profile = ServerProfile(
261− id = java.util.UUID.randomUUID().toString(),
262− label = qr.label,
263− baseUrl = qr.baseUrl,
264− username = qr.username,
265− password = qr.password,
266− requireHttps = qr.requireHttps,
267− certPin = qr.certPin,
268− )
269− container.profileStore.save(profile)
270− return profile
271− }
272256}
app/src/main/res/values-es/strings.xml+1−11
@@ -669,17 +669,9 @@
669669 <string name="connecting_to">Conectando a %1$s…</string>
670670 <string name="scroll_to_top">Ir arriba</string>
671671
672− <!-- UI/UX improvements: QR sharing, app-lock re-lock, sort, server state -->
672+ <!-- UI/UX improvements: app-lock re-lock, sort, server state -->
673673 <string name="connection_failed_short">Conexión fallida</string>
674674 <string name="sort_name">Nombre</string>
675− <string name="share_qr">Compartir como código QR</string>
676− <string name="import_from_image">Importar desde imagen</string>
677− <string name="qr_import_failed">No se encontró un código QR de servidor opencode en esa imagen.</string>
678− <string name="qr_import_title">¿Añadir este servidor?</string>
679− <string name="qr_added">Servidor añadido</string>
680− <string name="ok">OK</string>
681− <string name="qr_includes_password">Incluye la contraseña</string>
682− <string name="qr_includes_password_warning">Incluye la contraseña — comparte con cuidado</string>
683675 <string name="app_lock_relock">Bloquear de nuevo tras</string>
684676 <string name="app_lock_relock_immediately">Inmediatamente</string>
685677 <plurals name="app_lock_relock_seconds">
@@ -797,7 +789,6 @@
797789 <string name="usage_open_session">Abrir sesión</string>
798790 <string name="usage_cost_fraction">%1$d%% del total</string>
799791 <string name="app_lock_disabled_warning">El bloqueo de la app está activado pero no hay biometría o credencial del dispositivo disponible; la app no está bloqueada.</string>
800− <string name="qr_paste_hint">Consejo: pega una configuración de servidor compartida aquí para importarla.</string>
801792 <string name="server_url_clear">Borrar URL</string>
802793 <string name="long_press_for_timestamp">Mantén pulsado para la marca de tiempo completa</string>
803794 <string name="search_always_available">Buscar</string>
@@ -812,7 +803,6 @@
812803
813804 <!-- UI/UX batch 7 -->
814805 <string name="markdown_image_placeholder">[imagen]</string>
815− <string name="qr_image_for">Código QR de %1$s</string>
816806 <string name="reconnecting_to">Reconectando a %1$s…</string>
817807 <string name="run_stopped">Ejecución detenida</string>
818808 <string name="preview_attachment">Vista previa del adjunto</string>
app/src/main/res/values/strings.xml−10
@@ -83,14 +83,6 @@
8383 <string name="server_auth_short"> • auth</string>
8484 <string name="server_auth_basic"> • Basic auth</string>
8585 <string name="server_removed">Server removed</string>
86− <string name="share_qr">Share as QR code</string>
87− <string name="import_from_image">Import from image</string>
88− <string name="qr_import_failed">No opencode server QR found in that image.</string>
89− <string name="qr_import_title">Add this server?</string>
90− <string name="qr_added">Server added</string>
91− <string name="ok">OK</string>
92− <string name="qr_includes_password">Includes password</string>
93− <string name="qr_includes_password_warning">Includes password — share carefully</string>
9486
9587 <!-- Server edit -->
9688 <string name="label_optional">Label (optional)</string>
@@ -798,7 +790,6 @@
798790 <string name="usage_open_session">Open session</string>
799791 <string name="usage_cost_fraction">%1$d%% of total</string>
800792 <string name="app_lock_disabled_warning">App lock is on but no biometric or device credential is available — the app isn\'t locked.</string>
801− <string name="qr_paste_hint">Tip: paste a shared server config here to import it.</string>
802793 <string name="server_url_clear">Clear URL</string>
803794 <string name="long_press_for_timestamp">Long-press for the full timestamp</string>
804795 <string name="search_always_available">Search</string>
@@ -812,7 +803,6 @@
812803
813804 <!-- UI/UX batch 7: shared components, navigation, files, mcp, usage -->
814805 <string name="markdown_image_placeholder">[image]</string>
815− <string name="qr_image_for">QR code for %1$s</string>
816806 <string name="reconnecting_to">Reconnecting to %1$s…</string>
817807 <string name="run_stopped">Run stopped</string>
818808 <string name="preview_attachment">Preview attachment</string>
gradle/libs.versions.toml−2
@@ -13,7 +13,6 @@ securityCrypto = "1.1.0"
1313coil = "3.5.0"
1414biometric = "1.1.0"
1515markdownRenderer = "0.43.0"
16−zxingCore = "3.5.4"
1716coreKtx = "1.19.0"
1817splashscreen = "1.2.0"
1918window = "1.5.1"
@@ -60,7 +59,6 @@ coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp"
6059androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
6160markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
6261markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
63−zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" }
6462
6563junit = { group = "junit", name = "junit", version.ref = "junit" }
6664androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }