git.iko.soy/opencode-android
on master help sign in
Fix 12 bugs across network, repo, DI, and UI layers including abort retry idempotency, widget profile routing, SSL failure classification, cleartext credential warnings, and usage key collision
commit6dcf3ed6a3021030fea4ad89a039f14fc914fe3c authoriko <mail@iko.soy> dateSun 5 Jul 2026, 19:58 (3 months ago) parenta0ce9ce
14 files changed+118−28
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+20−2 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+11−2 app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+11−2 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+11−4 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+13−6 app/src/main/java/soy/iko/opencode/platform/AppShortcuts.kt+7 app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.kt+9−5 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+1−1 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+12 app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsScreen.kt+9 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+7−2 app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.kt+5−4 app/src/main/res/values-es/strings.xml+1 app/src/main/res/values/strings.xml+1
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+20−2
@@ -78,7 +78,10 @@ open class EventStreamClient(
7878
7979 /** Classifies a non-retryable SSE failure so the UI can show the right recovery hint:
8080 * an auth failure (401/403) reads "check credentials", while other 4xx (400/404/405…)
81− * reads "check the server URL and version". Returns null for transient/retryable errors. */
81+ * reads "check the server URL and version". Returns null for transient/retryable errors.
82+ * SSL/TLS and certificate-pinning failures are permanent (a changed/rotated cert won't
83+ * heal on retry) and are classified as Other so the user sees "check the server URL and
84+ * version / cert pin" instead of an infinite "Reconnecting…" spinner. */
8285 private enum class SseFailureKind { Auth, Other }
8386
8487 private fun classifyNonRetryableSseFailure(e: Throwable): SseFailureKind? =
@@ -88,11 +91,21 @@ open class EventStreamClient(
8891 when (t) {
8992 is io.ktor.client.plugins.sse.SSEClientException -> t.response?.status?.value
9093 is io.ktor.client.plugins.ClientRequestException -> t.response.status.value
94+ // A TLS handshake / cert-pinning / SSL peer unverified failure happens
95+ // before any HTTP response, so it carries no status code. It is never
96+ // transient (the server's cert must change), so classify it directly
97+ // instead of falling through to the null (retryable) default — otherwise
98+ // the client retries forever with backoff against an unreachable pinned
99+ // server, showing "Reconnecting…" with no hint that the failure is fixed.
100+ is javax.net.ssl.SSLException -> -1
101+ is java.security.cert.CertificateException -> -1
91102 else -> null
92103 }
93104 }
94105 // Non-408/429 4xx = permanent; 408 and 429 stay in the transient retry path.
106+ // The -1 sentinel (SSL/cert) is also permanent → Other.
95107 .map { code -> when {
108+ code == -1 -> SseFailureKind.Other
96109 code in 400..499 && code != 408 && code != 429 ->
97110 if (code == 401 || code == 403) SseFailureKind.Auth else SseFailureKind.Other
98111 else -> null
@@ -326,9 +339,14 @@ open class EventStreamClient(
326339 if (reconnectConsumedDuringRead) return true
327340 var signaled = false
328341 val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
342+ // Floor the wait at half the initial backoff: symmetric jitter can otherwise pull a
343+ // small backoff down to 0ms, producing a sub-second reconnect storm right after a
344+ // triggerReconnect reset on a connect-RST loop. A non-zero floor preserves the
345+ // purpose of backoff (spacing retries) while still allowing jitter to shorten it.
346+ val minWait = (initialBackoffMs / 2).coerceAtLeast(1L)
329347 select {
330348 reconnectSignal.onReceive { signaled = true }
331− onTimeout((backoffMs + jitter).coerceAtLeast(0)) { /* normal backoff elapsed */ }
349+ onTimeout((backoffMs + jitter).coerceAtLeast(minWait)) { /* normal backoff elapsed */ }
332350 }
333351 return signaled
334352 }
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+11−2
@@ -186,8 +186,13 @@ open class OpencodeApiClient private constructor(
186186 }
187187 }
188188
189+ /** Abort the active run in a session. NOT retried: a lost response followed by a
190+ * retry would POST abort again, and if a new run started in the gap the retry
191+ * would kill that *new* run. Unlike sendPrompt/runCommand/summarize there's no
192+ * Idempotency-Key the server could use to dedupe, so a single attempt is the
193+ * safe choice — the SSE stream reflects the actual run state regardless. */
189194 open suspend fun abort(sessionId: String) {
190− withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
195+ withRetry(maxAttempts = 1) { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
191196 }
192197
193198 /** Invoke a slash-command by name via `POST /session/:id/command`. */
@@ -414,9 +419,13 @@ open class OpencodeApiClient private constructor(
414419 } catch (t: ClientRequestException) {
415420 // A response that reached the server but whose reply was lost gets retried; the
416421 // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
417− // the answer landed, so treat them as success instead of a spurious failure.
422+ // the answer landed (or the session/permission no longer exists, in which case the
423+ // response is moot anyway), so treat them as success instead of a spurious failure
424+ // that would re-queue the dialog against a gone session. Log the swallowed status
425+ // so a 404 caused by a deleted session is debuggable rather than fully silent.
418426 val code = t.response.status.value
419427 if (code != 404 && code != 409) throw t
428+ android.util.Log.d("OpencodeApi", "respondPermission swallowed $code for session=$sessionId permission=$permissionId")
420429 ""
421430 }
422431
app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+11−2
@@ -11,9 +11,18 @@ import kotlinx.serialization.encodeToString
1111import kotlinx.serialization.json.Json
1212import java.io.File
1313
14−/** A minimal session summary persisted for the home-screen widget and launcher shortcuts. */
14+/** A minimal session summary persisted for the home-screen widget and launcher shortcuts.
15+ * Carries the originating [profileId] so a widget tap routes back to the server that ran
16+ * the session even after the user has switched to a different connection — without it,
17+ * the session would open on whichever server is currently active and 404 or show empty. */
1518@Serializable
16−data class RecentSession(val id: String, val title: String)
19+data class RecentSession(
20+ val id: String,
21+ val title: String,
22+ // Defaulted so a pre-profileId persisted file (from an older app version) still decodes:
23+ // the field is absent → default "" → the widget falls back to the active connection.
24+ val profileId: String = "",
25+)
1726
1827/**
1928 * Persists a short list of recent sessions to a small JSON file under filesDir so app-external
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+11−4
@@ -410,9 +410,13 @@ internal class MessageStore {
410410 // accumulating stale messages forever. Skipped on the initial seed to avoid
411411 // racing with just-arrived SSE events whose messages may not yet be in REST.
412412 if (prune) {
413− val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
413+ // Build the retain-set from the synthetic key (matching the map's keying for
414+ // UnknownMessages with empty ids) so an entry stored under "unknown-c<created>"
415+ // isn't wrongly evicted and re-inserted under "" — which would lose its
416+ // insertion-order position and force a spurious snapshot emission.
417+ val incomingKeys = initial.mapTo(mutableSetOf()) { syntheticKeyFor(it) }
414418 val before = messages.size
415− messages.keys.retainAll(incomingIds)
419+ messages.keys.retainAll(incomingKeys)
416420 if (messages.size != before) changed = true
417421 }
418422
@@ -447,8 +451,11 @@ internal class MessageStore {
447451 }
448452 // Adopt the authoritative REST info unless the in-memory info was itself
449453 // updated live since the pivot (i.e. during this fetch), in which case it's
450− // newer than the snapshot. Otherwise the REST snapshot wins.
451− val info = if (existing.info is UnknownMessage || m.info.id !in messageInfoUpdatedSincePivot) m.info else existing.info
454+ // newer than the snapshot. Otherwise the REST snapshot wins. The pivot set is
455+ // keyed by the same synthetic key used in the map (see handleMessageUpdated),
456+ // so test that here instead of m.info.id — which is "" for an UnknownMessage
457+ // and would never match, leaving the pivot protection inert for that case.
458+ val info = if (existing.info is UnknownMessage || key !in messageInfoUpdatedSincePivot) m.info else existing.info
452459 val merged = MessageWithParts(info = info, parts = ordered)
453460 if (merged != existing.view()) {
454461 // Store under `key` (the synthetic key for an empty-id UnknownMessage),
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+13−6
@@ -330,15 +330,19 @@ open class AppContainer private constructor(
330330 }
331331
332332 /** Toggle a session's mute state. A muted session suppresses the unread badge and the
333− * completion notification so a noisy conversation doesn't keep interrupting. Persisted
333+ * completion notification so a noisy conversation doesn't interrupt. Persisted
334334 * via SessionPrefsStore so it survives process death. */
335335 fun toggleSessionMute(id: String) {
336− val muted = id in _mutedSessions.value
337− val next = if (muted) _mutedSessions.value - id else _mutedSessions.value + id
338− _mutedSessions.value = next
336+ // Use atomic update so a concurrent prefs reload (_mutedSessions.value = ids from
337+ // sessionPrefsStore.muted.collect) can't clobber this toggle mid read-modify-write.
338+ var nowMuted = false
339+ _mutedSessions.update { current ->
340+ nowMuted = id !in current
341+ if (nowMuted) current + id else current - id
342+ }
339343 appContext?.let {
340344 appScope.launch {
341− runCatchingCancellable { sessionPrefsStore.setMuted(id, !muted) }
345+ runCatchingCancellable { sessionPrefsStore.setMuted(id, nowMuted) }
342346 }
343347 }
344348 }
@@ -1509,9 +1513,12 @@ open class AppContainer private constructor(
15091513 // on activeConnection/isOnline/_outboxFlushTrigger — NOT on outboxStore.messages — so
15101514 // if connect() sets activeConnection before load() finishes reading a (possibly
15111515 // multi-MB) outbox.json, the connect-triggered flush sees an empty queue and nothing
1512− // re-triggers it. Nudging the flush here guarantees a loaded queue is sent.
1516+ // re-triggers it. Nudging the flush here guarantees a loaded queue is sent. Log a
1517+ // load failure so a corrupt outbox.json / IO error is debuggable rather than silently
1518+ // dropping every queued offline message until a future restart happens to succeed.
15131519 appScope.launch {
15141520 runCatchingCancellable { outboxStore.load() }
1521+ .onFailure { Log.w("AppContainer", "Outbox load failed; queued messages may be lost: ${safeExceptionSummary(it)}") }
15151522 flushOutbox()
15161523 }
15171524 observeOutbox()
app/src/main/java/soy/iko/opencode/platform/AppShortcuts.kt+7
@@ -9,6 +9,7 @@ import androidx.core.graphics.drawable.IconCompat
99import soy.iko.opencode.MainActivity
1010import soy.iko.opencode.R
1111import soy.iko.opencode.data.repo.RecentSession
12+import soy.iko.opencode.notification.NotificationActionReceiver
1213
1314/**
1415 * Maintains the app's dynamic launcher shortcuts (long-press the icon): "New session" and
@@ -47,6 +48,12 @@ object AppShortcuts {
4748 Intent(ctx, MainActivity::class.java).apply {
4849 action = Intent.ACTION_VIEW
4950 data = Uri.parse("opencode://session/$safeId")
51+ // Embed the profile id so the session opens under the server that
52+ // ran it, not whichever is active when the user taps — mirrors the
53+ // widget and notification action paths.
54+ if (lastSession.profileId.isNotBlank()) {
55+ putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, lastSession.profileId)
56+ }
5057 },
5158 )
5259 .build()
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.kt+9−5
@@ -8,6 +8,7 @@ import soy.iko.opencode.MainActivity
88import soy.iko.opencode.R
99import soy.iko.opencode.data.repo.RecentSession
1010import soy.iko.opencode.data.repo.RecentSessionsStore
11+import soy.iko.opencode.notification.NotificationActionReceiver
1112import java.security.MessageDigest
1213
1314/** Serves the list rows for [SessionsWidgetProvider] from [RecentSessionsStore]. */
@@ -33,11 +34,14 @@ private class SessionsWidgetFactory(private val context: Context) : RemoteViewsS
3334 val row = RemoteViews(context.packageName, R.layout.widget_session_item)
3435 val session = items.getOrNull(position) ?: return row
3536 row.setTextViewText(R.id.widget_item_title, session.title.ifBlank { context.getString(R.string.session) })
36− // Fill-in intent merged into the list's template: carries the session id the app opens.
37− row.setOnClickFillInIntent(
38− R.id.widget_item_root,
39− Intent().putExtra(MainActivity.EXTRA_SESSION_ID, session.id),
40− )
37+ // Fill-in intent merged into the list's template: carries the session id the app opens,
38+ // plus the profile id of the server that ran the session so it opens under THAT server
39+ // even after the user has switched connections — mirrors the notification action path.
40+ val fillIn = Intent().putExtra(MainActivity.EXTRA_SESSION_ID, session.id)
41+ if (session.profileId.isNotBlank()) {
42+ fillIn.putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, session.profileId)
43+ }
44+ row.setOnClickFillInIntent(R.id.widget_item_root, fillIn)
4145 return row
4246 }
4347
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+1−1
@@ -805,7 +805,7 @@ private fun BoxScope.FileViewStateContent(
805805 // display. Falls back to the binary-file label if decoding fails.
806806 state.content != null && state.content.isBinary && run {
807807 val ext = path.substringAfterLast('.', "").lowercase()
808− ext in setOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "svg")
808+ ext in setOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "heic")
809809 } -> {
810810 ImageFilePreview(
811811 content = state.content,
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+12
@@ -210,6 +210,18 @@ private fun ServerEditForm(
210210 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
211211 )
212212 }
213+ // Warn when credentials will travel over cleartext HTTP: an http:// URL with a
214+ // username sends Basic auth unencrypted on every request. Surface this prominently
215+ // so the user switches to https:// (or enables Require HTTPS) rather than silently
216+ // leaking the password on a hostile LAN.
217+ if (state.baseUrl.lowercase().startsWith("http://") && state.username.isNotBlank()) {
218+ Text(
219+ stringResource(R.string.cleartext_credentials_warning),
220+ color = MaterialTheme.colorScheme.error,
221+ style = MaterialTheme.typography.bodySmall,
222+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
223+ )
224+ }
213225 // Single primary action: probe-then-save-and-connect. The probe IS the connection
214226 // test — a failed probe keeps the user on this screen with the error shown below.
215227 Button(
app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsScreen.kt+9
@@ -224,6 +224,15 @@ private fun ServerSettingsForm(
224224 onImeDone = { if (canSave && !state.saving) vm.save(onDone) },
225225 )
226226 }
227+ // Warn when credentials will travel over cleartext HTTP (mirrors ServerEditScreen).
228+ if (state.baseUrl.lowercase().startsWith("http://") && state.username.isNotBlank()) {
229+ Text(
230+ stringResource(R.string.cleartext_credentials_warning),
231+ color = MaterialTheme.colorScheme.error,
232+ style = MaterialTheme.typography.bodySmall,
233+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
234+ )
235+ }
227236 Button(
228237 onClick = {
229238 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+7−2
@@ -235,7 +235,7 @@ fun SessionListScreen(
235235 // session list — the best source of session titles. Cheap, idempotent, and off the main
236236 // thread (the store write dispatches to IO); the widget refresh no-ops when none is placed.
237237 val platformContext = LocalContext.current.applicationContext
238− SyncShortcutsAndWidget(platformContext, state)
238+ SyncShortcutsAndWidget(platformContext, state, connectedId)
239239
240240 // Reflect total unread sessions as a launcher-icon badge (silent notification on a
241241 // badge-enabled channel). Skips itself when the session list isn't loading a server yet.
@@ -1240,6 +1240,7 @@ private fun DateGroupHeader(label: String) {
12401240private fun SyncShortcutsAndWidget(
12411241 platformContext: android.content.Context,
12421242 state: SessionListState,
1243+ profileId: String?,
12431244) {
12441245 val serverIsEmpty = state.sessions.isEmpty() && state.error == null && !state.loading
12451246 LaunchedEffect(serverIsEmpty) {
@@ -1252,7 +1253,11 @@ private fun SyncShortcutsAndWidget(
12521253 // involve binder IPC and file I/O; run them off the main thread so a debounced
12531254 // session-update burst (e.g. during multi-session streaming) doesn't jank the list.
12541255 withContext(Dispatchers.IO) {
1255− val recents = sessions.take(RecentSessionsStore.MAX).map { RecentSession(it.id, it.displayTitle) }
1256+ // Embed the active profile id so a widget tap routes back to THIS server even
1257+ // after the user switches connections — without it, the session would open on
1258+ // whichever server is active when the user taps, 404ing or showing the wrong chat.
1259+ val pid = profileId.orEmpty()
1260+ val recents = sessions.take(RecentSessionsStore.MAX).map { RecentSession(it.id, it.displayTitle, pid) }
12561261 RecentSessionsStore.write(platformContext, recents)
12571262 AppShortcuts.update(platformContext, recents.firstOrNull())
12581263 SessionsWidgetProvider.refresh(platformContext)
app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.kt+5−4
@@ -99,7 +99,7 @@ fun aggregateUsage(
9999 var totalCost = 0.0
100100 var totalTokens = Tokens()
101101 var messageCount = 0
102− val byModel = LinkedHashMap<String, MutableAgg>()
102+ val byModel = LinkedHashMap<Pair<String, String>, MutableAgg>()
103103 val bySession = ArrayList<SessionUsage>()
104104
105105 for (entry in sessions) {
@@ -125,9 +125,10 @@ fun aggregateUsage(
125125 sessionTokens += tokens
126126 val model = info.modelID?.takeIf { it.isNotBlank() } ?: "unknown"
127127 val provider = info.providerID?.takeIf { it.isNotBlank() } ?: "unknown"
128− // Key by provider+model so the same model id served by different providers
129− // stays two rows instead of collapsing into one ambiguous total.
130− val key = "$provider/$model"
128+ // Key by (provider, model) pair so a model id containing '/' (e.g. "org/model")
129+ // can't collide with a different provider/model split that would produce the same
130+ // concatenated "$provider/$model" string.
131+ val key = provider to model
131132 val agg = byModel.getOrPut(key) { MutableAgg(provider, model) }
132133 agg.messages++
133134 agg.cost += cost
app/src/main/res/values-es/strings.xml+1
@@ -572,6 +572,7 @@
572572 <string name="server_security">Seguridad</string>
573573 <string name="require_https">Requerir HTTPS</string>
574574 <string name="require_https_desc">Rechazar conexiones por HTTP sin cifrar</string>
575+ <string name="cleartext_credentials_warning">Este servidor usa HTTP sin cifrar con usuario/contraseña: tus credenciales se enviarán sin cifrar por la red. Usa HTTPS si el servidor lo admite.</string>
575576 <string name="cert_pin">Fijación de certificado</string>
576577 <string name="cert_pin_hint">sha256/AAAA… (opcional)</string>
577578 <string name="cert_pin_desc">Fija el certificado TLS del servidor (SHA-256)</string>
app/src/main/res/values/strings.xml+1
@@ -588,6 +588,7 @@
588588 <string name="server_security">Security</string>
589589 <string name="require_https">Require HTTPS</string>
590590 <string name="require_https_desc">Refuse to connect over cleartext HTTP</string>
591+ <string name="cleartext_credentials_warning">This server uses cleartext HTTP with a username/password — your credentials will be sent unencrypted over the network. Use HTTPS if the server supports it.</string>
591592 <string name="cert_pin">Certificate pin</string>
592593 <string name="cert_pin_hint">sha256/AAAA… (optional)</string>
593594 <string name="cert_pin_desc">Pin the server\'s TLS certificate (SHA-256)</string>