git.iko.soy/opencode-android
on master help sign in
Remove QR code import/share feature and zxing dependency
1a92af4iko
app/build.gradle.kts−1
@@ -153,7 +153,6 @@ dependencies {
153 implementation(libs.coil.network.okhttp)
153 implementation(libs.coil.network.okhttp)
154 implementation(libs.markdown.renderer)
154 implementation(libs.markdown.renderer)
155 implementation(libs.markdown.renderer.m3)
155 implementation(libs.markdown.renderer.m3)
156− implementation(libs.zxing.core)
157
156
158 testImplementation(libs.junit)
157 testImplementation(libs.junit)
159 testImplementation(libs.kotlinx.coroutines.test)
158 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
2
2
3import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.padding
5import androidx.compose.foundation.layout.padding
7import androidx.compose.foundation.layout.size
6import androidx.compose.foundation.layout.size
8import androidx.compose.material3.Button
7import androidx.compose.material3.Button
9import androidx.compose.material3.Icon
8import androidx.compose.material3.Icon
10import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.MaterialTheme
11−import androidx.compose.material3.OutlinedButton
12import androidx.compose.material3.Text
10import androidx.compose.material3.Text
13import androidx.compose.runtime.Composable
11import androidx.compose.runtime.Composable
14import androidx.compose.ui.Alignment
12import androidx.compose.ui.Alignment
@@ -24,10 +22,6 @@ import androidx.compose.ui.unit.dp
24 * Shared empty/placeholder state: a centered icon, a title, an optional supporting line,
22 * Shared empty/placeholder state: a centered icon, a title, an optional supporting line,
25 * and an optional call-to-action button. Extracted so every screen's "nothing here yet"
23 * and an optional call-to-action button. Extracted so every screen's "nothing here yet"
26 * state reads and behaves the same (see EmptySessions/EmptyServers/etc. which predate this).
24 * 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).
31 */
25 */
32@Composable
26@Composable
33fun EmptyState(
27fun EmptyState(
@@ -38,9 +32,6 @@ fun EmptyState(
38 actionIcon: ImageVector? = null,
32 actionIcon: ImageVector? = null,
39 actionLabel: String? = null,
33 actionLabel: String? = null,
40 onAction: (() -> Unit)? = null,
34 onAction: (() -> Unit)? = null,
41− secondaryActionIcon: ImageVector? = null,
42− secondaryActionLabel: String? = null,
43− onSecondaryAction: (() -> Unit)? = null,
44) {
35) {
45 Column(
36 Column(
46 modifier = modifier.padding(32.dp),
37 modifier = modifier.padding(32.dp),
@@ -82,18 +73,5 @@ fun EmptyState(
82 }
73 }
83 }
74 }
84 }
75 }
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− }
98 }
76 }
99}
77}
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(
235 // fresh add form (via baseUrlFocus above).
235 // fresh add form (via baseUrlFocus above).
236 OutlinedTextField(
236 OutlinedTextField(
237 value = state.baseUrl,
237 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) }
238+ onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
243− },
244 label = { Text(stringResource(R.string.base_url)) },
239 label = { Text(stringResource(R.string.base_url)) },
245 placeholder = { Text(stringResource(R.string.base_url_hint)) },
240 placeholder = { Text(stringResource(R.string.base_url_hint)) },
246 singleLine = true,
241 singleLine = true,
@@ -271,10 +266,6 @@ private fun ServerEditForm(
271 } else {
266 } else {
272 Text(stringResource(R.string.invalid_url))
267 Text(stringResource(R.string.invalid_url))
273 }
268 }
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))
278 }
269 }
279 },
270 },
280 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
271 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(
259 _state.update { transform(it).copy(credentialsResult = null, error = null) }
259 _state.update { transform(it).copy(credentialsResult = null, error = null) }
260 }
260 }
261
261
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−
291 /**
262 /**
292 * Primary action: save the profile and connect to it. When the server needs auth and the
263 * Primary action: save the profile and connect to it. When the server needs auth and the
293 * user hasn't supplied credentials yet, a connectivity probe reveals the username/password
264 * 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
23import androidx.compose.material.icons.filled.Delete
23import androidx.compose.material.icons.filled.Delete
24import androidx.compose.material.icons.filled.Dns
24import androidx.compose.material.icons.filled.Dns
25import androidx.compose.material.icons.filled.MoreVert
25import androidx.compose.material.icons.filled.MoreVert
26−import androidx.compose.material.icons.filled.PhotoLibrary
27import androidx.compose.material.icons.filled.Search
26import androidx.compose.material.icons.filled.Search
28import androidx.compose.material3.AlertDialog
27import androidx.compose.material3.AlertDialog
29import androidx.compose.material3.Button
28import androidx.compose.material3.Button
@@ -65,13 +64,11 @@ import androidx.compose.ui.platform.LocalHapticFeedback
65import androidx.compose.ui.platform.testTag
64import androidx.compose.ui.platform.testTag
66import androidx.compose.ui.semantics.Role
65import androidx.compose.ui.semantics.Role
67import androidx.compose.ui.semantics.contentDescription
66import androidx.compose.ui.semantics.contentDescription
68−import androidx.compose.ui.platform.LocalContext
69import androidx.compose.ui.semantics.semantics
67import androidx.compose.ui.semantics.semantics
70import androidx.compose.ui.text.style.TextOverflow
68import androidx.compose.ui.text.style.TextOverflow
71import androidx.compose.ui.unit.dp
69import androidx.compose.ui.unit.dp
72import androidx.compose.ui.res.stringResource
70import androidx.compose.ui.res.stringResource
73import androidx.activity.compose.BackHandler
71import androidx.activity.compose.BackHandler
74−import androidx.activity.compose.rememberLauncherForActivityResult
75import androidx.compose.animation.Crossfade
72import androidx.compose.animation.Crossfade
76import androidx.compose.animation.core.tween
73import androidx.compose.animation.core.tween
77import androidx.lifecycle.compose.collectAsStateWithLifecycle
74import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -118,10 +115,10 @@ fun ServerListScreen(
118 .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected)
115 .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected)
119 val haptics = LocalHapticFeedback.current
116 val haptics = LocalHapticFeedback.current
120 val snackbar = remember { SnackbarHostState() }
117 val snackbar = remember { SnackbarHostState() }
121− // Separate host for transient error messages (connect/probe failures, QR import results)
118+ // Separate host for transient error messages (connect/probe failures) so a transient
122− // so a transient error can't preempt an undo snackbar's full window — the undo and error
119+ // error can't preempt an undo snackbar's full window — the undo and error channels
123− // channels don't share a queue, so a connect error can't cancel an in-flight undo and
120+ // don't share a queue, so a connect error can't cancel an in-flight undo and strand a
124− // strand a pending server delete.
121+ // pending server delete.
125 val errorSnackbar = remember { SnackbarHostState() }
122 val errorSnackbar = remember { SnackbarHostState() }
126 val retryLabel = stringResource(R.string.retry)
123 val retryLabel = stringResource(R.string.retry)
127 val undoLabel = stringResource(R.string.undo)
124 val undoLabel = stringResource(R.string.undo)
@@ -132,19 +129,6 @@ fun ServerListScreen(
132 // threshold logic the file/session pickers use for their search fields.
129 // threshold logic the file/session pickers use for their search fields.
133 var serverQuery by rememberSaveable { mutableStateOf("") }
130 var serverQuery by rememberSaveable { mutableStateOf("") }
134 val connectedId = activeConnection?.profile?.id
131 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− )
148
132
149 LaunchedEffect(Unit) {
133 LaunchedEffect(Unit) {
150 vm.errorEvents.collect { event ->
134 vm.errorEvents.collect { event ->
@@ -259,13 +243,6 @@ fun ServerListScreen(
259 )
243 )
260 }
244 }
261 }
245 }
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− }
269 },
246 },
270 )
247 )
271 },
248 },
@@ -345,7 +322,6 @@ fun ServerListScreen(
345 }
322 }
346 "empty" -> EmptyServers(
323 "empty" -> EmptyServers(
347 onAdd = onAddProfile,
324 onAdd = onAddProfile,
348− onQrImport = { qrImportLauncher.launch(arrayOf("image/*")) },
349 modifier = Modifier.fillMaxSize(),
325 modifier = Modifier.fillMaxSize(),
350 )
326 )
351 else -> {
327 else -> {
@@ -416,7 +392,6 @@ fun ServerListScreen(
416 onEdit = { onEditProfile(profile.id) },
392 onEdit = { onEditProfile(profile.id) },
417 onDuplicate = { onDuplicateProfile(profile.id) },
393 onDuplicate = { onDuplicateProfile(profile.id) },
418 onPendingDelete = { pendingDeleteId = profile.id },
394 onPendingDelete = { pendingDeleteId = profile.id },
419− onShareQr = { pendingQrProfile = profile },
420 onTestConnection = { vm.testConnection(profile) },
395 onTestConnection = { vm.testConnection(profile) },
421 isProbing = probingId == profile.id,
396 isProbing = probingId == profile.id,
422 )
397 )
@@ -470,7 +445,6 @@ fun ServerListScreen(
470 onEdit = { onEditProfile(profile.id) },
445 onEdit = { onEditProfile(profile.id) },
471 onDuplicate = { onDuplicateProfile(profile.id) },
446 onDuplicate = { onDuplicateProfile(profile.id) },
472 onPendingDelete = { pendingDeleteId = profile.id },
447 onPendingDelete = { pendingDeleteId = profile.id },
473− onShareQr = { pendingQrProfile = profile },
474 onTestConnection = { vm.testConnection(profile) },
448 onTestConnection = { vm.testConnection(profile) },
475 isProbing = probingId == profile.id,
449 isProbing = probingId == profile.id,
476 )
450 )
@@ -510,12 +484,6 @@ fun ServerListScreen(
510 },
484 },
511 )
485 )
512 }
486 }
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− }
519}
487}
520
488
521/** Shared content for a server profile card — used by both the active-row (plain Card)
489/** Shared content for a server profile card — used by both the active-row (plain Card)
@@ -530,7 +498,6 @@ private fun ServerCardContent(
530 onEdit: () -> Unit,
498 onEdit: () -> Unit,
531 onDuplicate: () -> Unit,
499 onDuplicate: () -> Unit,
532 onPendingDelete: () -> Unit,
500 onPendingDelete: () -> Unit,
533− onShareQr: () -> Unit = {},
534 onTestConnection: () -> Unit = {},
501 onTestConnection: () -> Unit = {},
535 isProbing: Boolean = false,
502 isProbing: Boolean = false,
536 activeState: soy.iko.opencode.data.network.EventStreamClient.ConnectionState? = null,
503 activeState: soy.iko.opencode.data.network.EventStreamClient.ConnectionState? = null,
@@ -665,10 +632,6 @@ private fun ServerCardContent(
665 text = { Text(testConnLabel) },
632 text = { Text(testConnLabel) },
666 onClick = { showRowMenu = false; onTestConnection() },
633 onClick = { showRowMenu = false; onTestConnection() },
667 )
634 )
668− DropdownMenuItem(
669− text = { Text(stringResource(R.string.share_qr)) },
670− onClick = { showRowMenu = false; onShareQr() },
671− )
672 DropdownMenuItem(
635 DropdownMenuItem(
673 text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
636 text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
674 onClick = { showRowMenu = false; onPendingDelete() },
637 onClick = { showRowMenu = false; onPendingDelete() },
@@ -703,7 +666,7 @@ private fun LastUsedText(lastUsed: Long) {
703}
666}
704
667
705@Composable
668@Composable
706−private fun EmptyServers(onAdd: () -> Unit, onQrImport: () -> Unit, modifier: Modifier = Modifier) {
669+private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
707 Box(modifier = modifier, contentAlignment = Alignment.Center) {
670 Box(modifier = modifier, contentAlignment = Alignment.Center) {
708 EmptyState(
671 EmptyState(
709 icon = Icons.Filled.Dns,
672 icon = Icons.Filled.Dns,
@@ -712,74 +675,8 @@ private fun EmptyServers(onAdd: () -> Unit, onQrImport: () -> Unit, modifier: Mo
712 actionIcon = Icons.Filled.Add,
675 actionIcon = Icons.Filled.Add,
713 actionLabel = stringResource(R.string.add_server),
676 actionLabel = stringResource(R.string.add_server),
714 onAction = onAdd,
677 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− },
780 )
678 )
781 }
679 }
782− return launcher
783}
680}
784
681
785/** Filter server profiles by label/base URL. Extracted from [ServerListScreen] to keep its
682/** 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() {
253 if (!container.cancelProfileDelete(profileId)) return
253 if (!container.cancelProfileDelete(profileId)) return
254 _hiddenIds.update { it - profileId }
254 _hiddenIds.update { it - profileId }
255 }
255 }
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− }
272}
256}
app/src/main/res/values-es/strings.xml+1−11
@@ -669,17 +669,9 @@
669 <string name="connecting_to">Conectando a %1$s…</string>
669 <string name="connecting_to">Conectando a %1$s…</string>
670 <string name="scroll_to_top">Ir arriba</string>
670 <string name="scroll_to_top">Ir arriba</string>
671
671
672− <!-- UI/UX improvements: QR sharing, app-lock re-lock, sort, server state -->
672+ <!-- UI/UX improvements: app-lock re-lock, sort, server state -->
673 <string name="connection_failed_short">Conexión fallida</string>
673 <string name="connection_failed_short">Conexión fallida</string>
674 <string name="sort_name">Nombre</string>
674 <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>
683 <string name="app_lock_relock">Bloquear de nuevo tras</string>
675 <string name="app_lock_relock">Bloquear de nuevo tras</string>
684 <string name="app_lock_relock_immediately">Inmediatamente</string>
676 <string name="app_lock_relock_immediately">Inmediatamente</string>
685 <plurals name="app_lock_relock_seconds">
677 <plurals name="app_lock_relock_seconds">
@@ -797,7 +789,6 @@
797 <string name="usage_open_session">Abrir sesión</string>
789 <string name="usage_open_session">Abrir sesión</string>
798 <string name="usage_cost_fraction">%1$d%% del total</string>
790 <string name="usage_cost_fraction">%1$d%% del total</string>
799 <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>
791 <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>
801 <string name="server_url_clear">Borrar URL</string>
792 <string name="server_url_clear">Borrar URL</string>
802 <string name="long_press_for_timestamp">Mantén pulsado para la marca de tiempo completa</string>
793 <string name="long_press_for_timestamp">Mantén pulsado para la marca de tiempo completa</string>
803 <string name="search_always_available">Buscar</string>
794 <string name="search_always_available">Buscar</string>
@@ -812,7 +803,6 @@
812
803
813 <!-- UI/UX batch 7 -->
804 <!-- UI/UX batch 7 -->
814 <string name="markdown_image_placeholder">[imagen]</string>
805 <string name="markdown_image_placeholder">[imagen]</string>
815− <string name="qr_image_for">Código QR de %1$s</string>
816 <string name="reconnecting_to">Reconectando a %1$s…</string>
806 <string name="reconnecting_to">Reconectando a %1$s…</string>
817 <string name="run_stopped">Ejecución detenida</string>
807 <string name="run_stopped">Ejecución detenida</string>
818 <string name="preview_attachment">Vista previa del adjunto</string>
808 <string name="preview_attachment">Vista previa del adjunto</string>
app/src/main/res/values/strings.xml−10
@@ -83,14 +83,6 @@
83 <string name="server_auth_short"> • auth</string>
83 <string name="server_auth_short"> • auth</string>
84 <string name="server_auth_basic"> • Basic auth</string>
84 <string name="server_auth_basic"> • Basic auth</string>
85 <string name="server_removed">Server removed</string>
85 <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>
94
86
95 <!-- Server edit -->
87 <!-- Server edit -->
96 <string name="label_optional">Label (optional)</string>
88 <string name="label_optional">Label (optional)</string>
@@ -798,7 +790,6 @@
798 <string name="usage_open_session">Open session</string>
790 <string name="usage_open_session">Open session</string>
799 <string name="usage_cost_fraction">%1$d%% of total</string>
791 <string name="usage_cost_fraction">%1$d%% of total</string>
800 <string name="app_lock_disabled_warning">App lock is on but no biometric or device credential is available — the app isn\'t locked.</string>
792 <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>
802 <string name="server_url_clear">Clear URL</string>
793 <string name="server_url_clear">Clear URL</string>
803 <string name="long_press_for_timestamp">Long-press for the full timestamp</string>
794 <string name="long_press_for_timestamp">Long-press for the full timestamp</string>
804 <string name="search_always_available">Search</string>
795 <string name="search_always_available">Search</string>
@@ -812,7 +803,6 @@
812
803
813 <!-- UI/UX batch 7: shared components, navigation, files, mcp, usage -->
804 <!-- UI/UX batch 7: shared components, navigation, files, mcp, usage -->
814 <string name="markdown_image_placeholder">[image]</string>
805 <string name="markdown_image_placeholder">[image]</string>
815− <string name="qr_image_for">QR code for %1$s</string>
816 <string name="reconnecting_to">Reconnecting to %1$s…</string>
806 <string name="reconnecting_to">Reconnecting to %1$s…</string>
817 <string name="run_stopped">Run stopped</string>
807 <string name="run_stopped">Run stopped</string>
818 <string name="preview_attachment">Preview attachment</string>
808 <string name="preview_attachment">Preview attachment</string>
gradle/libs.versions.toml−2
@@ -13,7 +13,6 @@ securityCrypto = "1.1.0"
13coil = "3.5.0"
13coil = "3.5.0"
14biometric = "1.1.0"
14biometric = "1.1.0"
15markdownRenderer = "0.43.0"
15markdownRenderer = "0.43.0"
16−zxingCore = "3.5.4"
17coreKtx = "1.19.0"
16coreKtx = "1.19.0"
18splashscreen = "1.2.0"
17splashscreen = "1.2.0"
19window = "1.5.1"
18window = "1.5.1"
@@ -60,7 +59,6 @@ coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp"
60androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
59androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
61markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
60markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
62markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
61markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
63−zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" }
64
62
65junit = { group = "junit", name = "junit", version.ref = "junit" }
63junit = { group = "junit", name = "junit", version.ref = "junit" }
66androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
64androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }