git.iko.soy/opencode-android
on master help sign in
Fix 16 bugs: outbox/load/spinner/FilePart/applock/activeRuns/backup/state/todoPlan/running/usage/mcp/crash/certpin/deferred/nsd/cache-lock
commitf6efdb33819cda7108be47581ea7ce95e0b01cda authoriko <mail@iko.soy> dateThu 2 Jul 2026, 16:03 (3 months ago) parent5d56632
20 files changed+350−99
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.kt+3−1 app/src/main/java/soy/iko/opencode/data/model/Part.kt+19−1 app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+9−1 app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.kt+10−4 app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+14−2 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+17 app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+10−4 app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+30−14 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+8 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+23−5 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+7−4 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+25−1 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+3−2 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+2−1 app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+24−15 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−5 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+63−19 app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+34−20 app/src/test/java/soy/iko/opencode/OpencodeJsonTest.kt+34 app/src/test/java/soy/iko/opencode/ui/server/CertPinValidationTest.kt+7
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.kt+3−1
@@ -5,6 +5,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
55import kotlinx.coroutines.CoroutineScope
66import kotlinx.coroutines.Dispatchers
77import kotlinx.coroutines.SupervisorJob
8+import kotlinx.coroutines.cancel
9+import kotlinx.coroutines.runBlocking
810import org.junit.Assert.assertEquals
911import org.junit.Test
1012import org.junit.runner.RunWith
@@ -15,7 +17,7 @@ class DraftStoreTest {
1517 private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
1618
1719 @Test
18− fun setGetRemoveRoundTrip() {
20+ fun setGetRemoveRoundTrip() = runBlocking {
1921 val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
2022 val store = DraftStore(context, scope)
2123
app/src/main/java/soy/iko/opencode/data/model/Part.kt+19−1
@@ -3,6 +3,9 @@ package soy.iko.opencode.data.model
33import androidx.compose.runtime.Immutable
44import kotlinx.serialization.SerialName
55import kotlinx.serialization.Serializable
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonObject
8+import kotlinx.serialization.json.JsonPrimitive
69
710/**
811 * A message part — a discriminated union on `type` (the global class discriminator).
@@ -65,9 +68,24 @@ data class FilePart(
6568 val mime: String? = null,
6669 val filename: String? = null,
6770 val url: String? = null,
68− val source: String? = null,
71+ // opencode's message-v2 schema sends `source` as a structured FilePartSource OBJECT
72+ // ({ type, path, text, ... }), not a string, for @-file mentions. Typing it as String
73+ // threw a SerializationException on decode — which the SSE path silently swallows, so the
74+ // whole part vanished. Keep it as a tolerant JsonElement (mirroring Permission.pattern) and
75+ // read the path via [sourcePath]; a bare-string source is still tolerated.
76+ val source: JsonElement? = null,
6977) : Part
7078
79+/** The file path referenced by a [FilePart.source]. opencode sends `source` as a FilePartSource
80+ * object whose `path` field holds the file path; older/other shapes may send a bare string.
81+ * Returns null when there's no usable path. */
82+val FilePart.sourcePath: String?
83+ get() = when (val s = source) {
84+ is JsonPrimitive -> if (s.isString) s.content else null
85+ is JsonObject -> (s["path"] as? JsonPrimitive)?.let { if (it.isString) it.content else null }
86+ else -> null
87+ }
88+
7189@Immutable
7290@Serializable
7391@SerialName("step-start")
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+9−1
@@ -10,6 +10,7 @@ import kotlinx.coroutines.channels.awaitClose
1010import kotlinx.coroutines.flow.callbackFlow
1111import kotlinx.coroutines.launch
1212import kotlinx.coroutines.suspendCancellableCoroutine
13+import kotlinx.coroutines.withTimeoutOrNull
1314import kotlin.coroutines.resume
1415
1516/** A server discovered on the local network via mDNS/DNS-SD. */
@@ -53,7 +54,11 @@ open class NsdDiscovery(context: Context?) {
5354
5455 val resolver = launch {
5556 for (info in toResolve) {
56− val resolved = resolveService(nsd, info) ?: continue
57+ // NsdManager.resolveService occasionally never invokes its listener. Without a
58+ // timeout, one stuck resolve would hang this single serialized resolver forever,
59+ // so every later-discovered service queued behind it is never processed and the
60+ // emitted list silently stops updating. Abandon a stuck resolve and keep draining.
61+ val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(nsd, info) } ?: continue
5762 val name = resolved.serviceName.orEmpty()
5863 // Keep only opencode's own advertisements, not every _http._tcp service
5964 // (printers, routers, other web servers) on the network.
@@ -110,5 +115,8 @@ open class NsdDiscovery(context: Context?) {
110115 const val TAG = "NsdDiscovery"
111116 // opencode advertises bonjour type "http" → DNS-SD "_http._tcp.".
112117 const val SERVICE_TYPE = "_http._tcp."
118+ // Upper bound on a single mDNS resolve before it's abandoned so a stuck platform
119+ // callback can't wedge the serialized resolver. Local-network resolves are sub-second.
120+ const val RESOLVE_TIMEOUT_MS = 5_000L
113121 }
114122}
app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.kt+10−4
@@ -97,10 +97,16 @@ open class AttachmentDraftStore private constructor(
9797 }
9898
9999 /** Remove staged attachments for a session (call on session deletion). */
100− open suspend fun remove(sessionId: String): Unit = lockFor(sessionId).withLock {
101− withContext(Dispatchers.IO) {
102− runCatchingCancellable { fileFor(sessionId)?.delete() }
103− Unit
100+ open suspend fun remove(sessionId: String) {
101+ lockFor(sessionId).withLock {
102+ withContext(Dispatchers.IO) {
103+ runCatchingCancellable { fileFor(sessionId)?.delete() }
104+ Unit
105+ }
104106 }
107+ // Drop the per-session lock now the session is gone so the map doesn't accumulate one
108+ // entry per deleted session over the install's lifetime. Safe because remove() is the
109+ // terminal operation for a session id.
110+ locks.remove(sessionId)
105111 }
106112}
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+14−2
@@ -88,13 +88,25 @@ class BackupManager(
8888 suspend fun import(text: String) {
8989 val data = json.decodeFromString<BackupData>(text)
9090 for (s in data.servers) {
91+ val username = s.username?.takeIf { it.isNotBlank() }
92+ val incomingPassword = s.password?.takeIf { it.isNotEmpty() }
93+ // A backup exported *without* passwords carries null. This is an upsert, so don't let
94+ // that null wipe a password already stored for this server id — preserve the existing
95+ // secret when the backup didn't include one (only the backup explicitly carrying a
96+ // password should overwrite it). Re-importing a password-less backup would otherwise
97+ // silently break auth for every matching server.
98+ val password = incomingPassword ?: username?.let {
99+ profileStore.resolve(
100+ ServerProfile(id = s.id, label = s.label, baseUrl = s.baseUrl, username = it),
101+ ).password
102+ }
91103 profileStore.save(
92104 ServerProfile(
93105 id = s.id,
94106 label = s.label,
95107 baseUrl = s.baseUrl,
96− username = s.username?.takeIf { it.isNotBlank() },
97− password = s.password?.takeIf { it.isNotEmpty() },
108+ username = username,
109+ password = password,
98110 lastUsed = 0,
99111 requireHttps = s.requireHttps,
100112 certPin = s.certPin?.takeIf { it.isNotBlank() },
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+17
@@ -179,6 +179,18 @@ class CrashLogger private constructor(private val appContext: Context) {
179179 // in those messages, which may contain auth or internal paths. Scrubbing the
180180 // assembled text guarantees no URL survives anywhere in the stored report.
181181 file.writeText(scrubUrls(sw.toString()))
182+ trimOldReports()
183+ }
184+
185+ /** Keep only the [MAX_REPORTS] most recent crash reports, deleting older ones. Runs inside
186+ * the uncaught-exception handler (the whole call is wrapped in runCatching upstream), so it
187+ * must not throw; each delete is guarded. */
188+ private fun trimOldReports() {
189+ val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") } ?: return
190+ if (files.size <= MAX_REPORTS) return
191+ files.sortedByDescending { it.lastModified() }
192+ .drop(MAX_REPORTS)
193+ .forEach { runCatching { it.delete() } }
182194 }
183195
184196 private fun appVersion(): String = runCatching {
@@ -195,6 +207,11 @@ class CrashLogger private constructor(private val appContext: Context) {
195207 }.getOrDefault("unknown")
196208
197209 companion object {
210+ /** Cap on retained crash report files. A deterministic crash-loop (e.g. a crash on
211+ * startup) writes one report per crash with no other bound, so without this the
212+ * crashes/ dir grows without limit until the user manually clears it. */
213+ private const val MAX_REPORTS = 20
214+
198215 /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
199216 internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
200217
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+10−4
@@ -83,10 +83,16 @@ open class MessageCacheStore private constructor(
8383 }
8484
8585 /** Remove a session's cached messages (call on deletion). */
86− open suspend fun remove(sessionId: String): Unit = lockFor(sessionId).withLock {
87− withContext(Dispatchers.IO) {
88− runCatchingCancellable { fileFor(sessionId)?.delete() }
89− Unit
86+ open suspend fun remove(sessionId: String) {
87+ lockFor(sessionId).withLock {
88+ withContext(Dispatchers.IO) {
89+ runCatchingCancellable { fileFor(sessionId)?.delete() }
90+ Unit
91+ }
9092 }
93+ // Drop the per-session lock now the session is gone so the map doesn't accumulate one
94+ // entry per deleted session over the install's lifetime. Safe because remove() is the
95+ // terminal operation for a session id (no further save/load is expected after deletion).
96+ locks.remove(sessionId)
9197 }
9298}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+30−14
@@ -57,32 +57,44 @@ open class OutboxStore private constructor(
5757 /** All queued messages across sessions/servers, in enqueue order. */
5858 open val messages: StateFlow<List<OutboxMessage>> = _messages.asStateFlow()
5959
60+ /** Set once the on-disk queue has been read into [_messages] (under [ioMutex]). Until this
61+ * is true, a [mutate] must read disk first — otherwise a mutation that beats [load] would
62+ * persist from an empty in-memory snapshot and overwrite (or, for a removal, delete) the
63+ * persisted queue, losing every message the user composed offline in a previous run. */
64+ private var loaded = false
65+
6066 private val file: File? by lazy {
6167 appContext?.let { File(it.filesDir, "outbox.json") }
6268 }
6369
6470 /** Read the persisted queue into [messages]. Call once at startup. Idempotent. */
6571 open suspend fun load() {
72+ ioMutex.withLock { loadLocked() }
73+ }
74+
75+ /** Read the on-disk queue into memory. MUST be called while holding [ioMutex]. Idempotent
76+ * (guarded by [loaded]) so a [mutate] that arrives before [load] can call it to guarantee
77+ * disk is read before any write, then a later [load] is a no-op. */
78+ private suspend fun loadLocked() {
79+ if (loaded) return
80+ loaded = true
6681 val f = file?.takeIf { it.exists() } ?: return
67− val loaded = withContext(Dispatchers.IO) {
82+ val diskList = withContext(Dispatchers.IO) {
6883 runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
6984 .getOrDefault(emptyList())
7085 }
7186 // Don't clobber items enqueued while the load was in flight: merge by id, keeping
7287 // the in-memory version on conflict (it's newer).
73− ioMutex.withLock {
74− if (_messages.value.isEmpty()) {
75− _messages.value = loaded.sortedBy { it.createdAt }
76− } else {
77− val known = _messages.value.mapTo(mutableSetOf()) { it.id }
78− val merged = (loaded.filter { it.id !in known } + _messages.value).sortedBy { it.createdAt }
79− _messages.value = merged
80− // FIX: the merge reconciled items enqueued while this load was in flight with
81− // the on-disk set, but previously updated only memory — so a raced enqueue's
82− // item could be dropped on the next process kill. Write the merged set back so
83− // disk reflects it. persist() doesn't take ioMutex, so this won't deadlock.
84− persist(merged)
85− }
88+ if (_messages.value.isEmpty()) {
89+ _messages.value = diskList.sortedBy { it.createdAt }
90+ } else {
91+ val known = _messages.value.mapTo(mutableSetOf()) { it.id }
92+ val merged = (diskList.filter { it.id !in known } + _messages.value).sortedBy { it.createdAt }
93+ _messages.value = merged
94+ // The merge reconciled items enqueued while this load was in flight with the
95+ // on-disk set; write the merged set back so a later process kill can't drop the
96+ // raced enqueue. persist() doesn't take ioMutex, so this won't deadlock.
97+ persist(merged)
8698 }
8799 }
88100
@@ -104,6 +116,10 @@ open class OutboxStore private constructor(
104116 // overlapping writeText/delete calls could tear the file. The lock serializes both the
105117 // in-memory update and the write, guaranteeing on-disk order matches memory order.
106118 ioMutex.withLock {
119+ // Ensure the persisted queue has been read into memory before mutating: a mutation
120+ // that races ahead of load() would otherwise transform an empty in-memory snapshot
121+ // and persist()/delete() the file, destroying messages queued in a previous run.
122+ loadLocked()
107123 val updated = transform(_messages.value).sortedBy { it.createdAt }
108124 _messages.value = updated
109125 persist(updated)
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+8
@@ -224,6 +224,14 @@ open class SessionRepository(
224224 // makes a duplicate write of identical data harmless) — never data loss.
225225 var latestSnapshot: List<MessageWithParts>? = null
226226 var savedSnapshot: List<MessageWithParts>? = null
227+ // Guarantee at least one emission even when nothing seeds the store: with no on-disk
228+ // cache, a failing initial REST load (swallowed above), and a quiet SSE stream, none of
229+ // the seed paths publish, so the flow would emit nothing at all. The sole collector
230+ // (ChatViewModel) only clears its loading spinner on an emission, so without this a
231+ // first-open of an erroring/empty session spins forever with no way to reach the
232+ // "Failed to load / Retry" affordance. CONFLATED dirty coalesces this with any seed
233+ // publish into a single send.
234+ publish()
227235 try {
228236 dirty.consumeAsFlow().collect {
229237 val snapshot = lock.withLock { store.snapshot() }
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+23−5
@@ -748,8 +748,14 @@ open class AppContainer private constructor(
748748 connectionMutex.withLock {
749749 _activeConnection.value?.close()
750750 _activeConnection.value = null
751− activeRuns.clear()
752− _anyRunActive.value = false
751+ // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
752+ // event handler adds ids under the same lock, so an unlocked clear() here could
753+ // interleave with an add()+flag=true and leave _anyRunActive pinned true with a torn-
754+ // down connection and no SessionIdle to clear it — a stuck foreground service.
755+ synchronized(activeRuns) {
756+ activeRuns.clear()
757+ _anyRunActive.value = false
758+ }
753759 _unread.value = emptyMap()
754760 unreadMessageIds.clear()
755761 val now = System.currentTimeMillis()
@@ -786,8 +792,12 @@ open class AppContainer private constructor(
786792 // Reset run/unread state on disconnect too. With no SSE stream there's no
787793 // SessionIdle to drain activeRuns, so without this the working indicator
788794 // (anyRunActive) and unread badges would stay pinned until the next connect().
789− activeRuns.clear()
790− _anyRunActive.value = false
795+ // Mutate under activeRuns' monitor (see connect()) so a concurrent SSE add() can't
796+ // re-pin _anyRunActive after the clear.
797+ synchronized(activeRuns) {
798+ activeRuns.clear()
799+ _anyRunActive.value = false
800+ }
791801 _unread.value = emptyMap()
792802 unreadMessageIds.clear()
793803 }
@@ -974,7 +984,15 @@ open class AppContainer private constructor(
974984 observeMessageActivity()
975985 observeRunReconcileOnReconnect()
976986 observeRunForegroundService()
977− appScope.launch { runCatchingCancellable { outboxStore.load() } }
987+ // Flush right after load() populates the queue. observeOutbox()'s combine is keyed
988+ // on activeConnection/isOnline/_outboxFlushTrigger — NOT on outboxStore.messages — so
989+ // if connect() sets activeConnection before load() finishes reading a (possibly
990+ // multi-MB) outbox.json, the connect-triggered flush sees an empty queue and nothing
991+ // re-triggers it. Nudging the flush here guarantees a loaded queue is sent.
992+ appScope.launch {
993+ runCatchingCancellable { outboxStore.load() }
994+ flushOutbox()
995+ }
978996 observeOutbox()
979997 appScope.launch { autoConnect() }
980998 }
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+7−4
@@ -25,7 +25,6 @@ import androidx.compose.runtime.DisposableEffect
2525import androidx.compose.runtime.getValue
2626import androidx.compose.runtime.mutableStateOf
2727import androidx.compose.runtime.remember
28−import androidx.compose.runtime.saveable.rememberSaveable
2928import androidx.compose.runtime.setValue
3029import androidx.compose.ui.Alignment
3130import androidx.compose.ui.Modifier
@@ -92,9 +91,13 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
9291 return
9392 }
9493
95− // Survives config changes / process-death restore so a rotation doesn't re-prompt, but
96− // defaults to locked on a fresh start.
97− var unlocked by rememberSaveable { mutableStateOf(false) }
94+ // Deliberately NOT rememberSaveable: the unlocked flag must never be persisted into the
95+ // saved-instance-state Bundle. On API 26-27 onSaveInstanceState runs *before* onStop, so a
96+ // saveable `true` would be written to the Bundle before ON_STOP resets it; after a background
97+ // process-kill and restore the app would come back unlocked with no biometric prompt — an
98+ // app-lock bypass. Rotation is handled by the activity's configChanges (no recreation), so a
99+ // plain remember still avoids re-prompting on rotation while re-locking on real recreation.
100+ var unlocked by remember { mutableStateOf(false) }
98101
99102 val title = stringResource(R.string.app_lock_prompt_title)
100103 val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+25−1
@@ -45,12 +45,14 @@ import kotlinx.coroutines.flow.first
4545import kotlinx.coroutines.flow.filterIsInstance
4646import kotlinx.coroutines.flow.flatMapLatest
4747import kotlinx.coroutines.flow.flowOf
48+import kotlinx.coroutines.flow.flowOn
4849import kotlinx.coroutines.flow.map
4950import kotlinx.coroutines.flow.merge
5051import kotlinx.coroutines.flow.onEach
5152import kotlinx.coroutines.flow.retryWhen
5253import kotlinx.coroutines.flow.stateIn
5354import kotlinx.coroutines.flow.update
55+import kotlinx.coroutines.Dispatchers
5456import kotlinx.coroutines.delay
5557import kotlinx.coroutines.launch
5658
@@ -217,6 +219,11 @@ class ChatViewModel(
217219 * emission that doesn't change the plan doesn't recompose the bar. */
218220 val todoPlan: StateFlow<List<TodoItem>> = messages
219221 .map { currentTodoPlan(it) }
222+ // Run currentTodoPlan off the main thread: it's an O(messages×parts) scan (worst case
223+ // when the conversation has no todowrite) and runs on every conflated messages emission.
224+ // distinctUntilChanged is downstream so it dedupes the result but doesn't prevent the
225+ // scan — without flowOn this scans on Main.immediate per streamed token → jank.
226+ .flowOn(Dispatchers.Default)
220227 .distinctUntilChanged()
221228 .stateIn(
222229 scope = viewModelScope,
@@ -227,6 +234,14 @@ class ChatViewModel(
227234 private val _running = MutableStateFlow(false)
228235 val running: StateFlow<Boolean> = _running.asStateFlow()
229236
237+ /** True once the current run ended via SessionIdle/SessionError, until the next run starts
238+ * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
239+ * returns true for ANY message.part.updated, so without this a trailing/replayed part
240+ * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
241+ * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
242+ * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
243+ @Volatile private var runEndedByIdle = false
244+
230245 /** True while an abort REST call is in flight, so the Stop button can show a
231246 * spinner and prevent double-taps from firing a second abort. */
232247 private val _aborting = MutableStateFlow(false)
@@ -545,6 +560,10 @@ class ChatViewModel(
545560 return@collectLatest
546561 }
547562 _running.value = false
563+ // Allow the re-light below to recover a still-running run after this (re)connect;
564+ // a run that actually finished during the outage emits no live parts, so this
565+ // won't spuriously re-light.
566+ runEndedByIdle = false
548567 clearPermissions()
549568 _failedDraft.value = null
550569 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
@@ -557,6 +576,7 @@ class ChatViewModel(
557576 conn.events.events.collect { event ->
558577 if (SessionRepository.isIdle(event, sessionId)) {
559578 _running.value = false
579+ runEndedByIdle = true
560580 // Auto-send a queued follow-up once the previous run finishes,
561581 // so the user's drafted-while-running message isn't lost.
562582 val queued = _queuedFollowUp.value
@@ -567,6 +587,7 @@ class ChatViewModel(
567587 }
568588 if (SessionRepository.isError(event, sessionId)) {
569589 _running.value = false
590+ runEndedByIdle = true
570591 // The run errored, so the queued follow-up won't auto-send.
571592 // Restore it to the input (if free) so the user's typed text
572593 // isn't silently lost; otherwise just clear the queue.
@@ -580,7 +601,7 @@ class ChatViewModel(
580601 // Restore the run indicator if a live streaming event arrives while it's
581602 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
582603 // agent is still working. Don't revive it once the user has hit Stop.
583− if (SessionRepository.isRunActivity(event, sessionId) && !_aborting.value && !_running.value) {
604+ if (SessionRepository.isRunActivity(event, sessionId) && !_aborting.value && !_running.value && !runEndedByIdle) {
584605 _running.value = true
585606 }
586607 when (event) {
@@ -712,6 +733,9 @@ class ChatViewModel(
712733 return enqueueOffline(trimmed, attachments)
713734 }
714735 if (!_running.compareAndSet(false, true)) return false
736+ // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
737+ // indicator lit (and a later SessionIdle re-closes it).
738+ runEndedByIdle = false
715739 _failedDraft.value = null
716740 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
717741 // while retryFailed() re-submits with the key it stashed on the original failure — so a
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+3−2
@@ -63,6 +63,7 @@ import soy.iko.opencode.data.model.TODO_WRITE_TOOL
6363import soy.iko.opencode.data.model.UnknownPart
6464import soy.iko.opencode.data.model.inputElement
6565import soy.iko.opencode.data.model.parseTodos
66+import soy.iko.opencode.data.model.sourcePath
6667import soy.iko.opencode.ui.components.DiffView
6768import soy.iko.opencode.ui.components.ImageLoadContext
6869import soy.iko.opencode.ui.components.MarkdownText
@@ -444,7 +445,7 @@ private fun ToolStatusIcon(state: ToolState) {
444445@Composable
445446private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
446447 val context = LocalContext.current
447− val path = part.source ?: part.url ?: part.filename
448+ val path = part.sourcePath ?: part.url ?: part.filename
448449 val copyLabel = stringResource(R.string.copy_path)
449450 val openLabel = stringResource(R.string.open_file)
450451 // When a source path is available and a navigation callback is wired, tapping the
@@ -452,7 +453,7 @@ private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -
452453 // most likely expects). Long-press still copies the path to the clipboard, so the
453454 // copy affordance is preserved without being the default tap action. When no
454455 // navigation is wired (e.g. tests), falls back to copy-path on tap.
455− val source = part.source
456+ val source = part.sourcePath
456457 val opener = onOpenFile
457458 val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
458459 val canOpen = openPath != null
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+2−1
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.BrokenImage
2929import soy.iko.opencode.R
3030import soy.iko.opencode.data.model.FilePart
3131import soy.iko.opencode.data.model.ServerProfile
32+import soy.iko.opencode.data.model.sourcePath
3233import soy.iko.opencode.data.network.HttpClientFactory
3334
3435/**
@@ -93,7 +94,7 @@ private fun decodeDataUri(data: String): ByteArray? {
9394 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
9495 */
9596private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
96− val src = source
97+ val src = sourcePath
9798 if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
9899 val url = url ?: return null
99100 // Attachments are persisted as a data URL in `part.url`, so a data URI can arrive here too.
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+24−15
@@ -2,17 +2,20 @@ package soy.iko.opencode.ui.mcp
22
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
5+import kotlinx.coroutines.ExperimentalCoroutinesApi
56import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.Job
77import kotlinx.coroutines.flow.MutableStateFlow
88import kotlinx.coroutines.flow.StateFlow
99import kotlinx.coroutines.flow.asStateFlow
10+import kotlinx.coroutines.flow.collectLatest
11+import kotlinx.coroutines.flow.merge
1012import kotlinx.coroutines.launch
1113import kotlinx.coroutines.withContext
1214import soy.iko.opencode.di.AppContainer
1315import soy.iko.opencode.util.runCatchingCancellable
1416
1517/** Loads the configured MCP servers from the active connection's `config` (read-only view). */
18+@OptIn(ExperimentalCoroutinesApi::class)
1619class McpViewModel(private val container: AppContainer) : ViewModel() {
1720
1821 sealed interface State {
@@ -25,27 +28,33 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
2528 private val _state = MutableStateFlow<State>(State.Loading)
2629 val state: StateFlow<State> = _state.asStateFlow()
2730
28− // Tracks the in-flight load so refresh/retry taps cancel the previous load instead of
29− // racing it (last-to-complete would otherwise win regardless of start order).
30− private var loadJob: Job? = null
31+ /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
32+ private val _reload = MutableStateFlow(0)
3133
32− init { load() }
34+ init {
35+ // Observe the active connection so the servers load (or reload) once a connection is
36+ // available — including when the screen opens during a reconnect window where
37+ // activeConnection.value is momentarily null (else it would latch Disconnected until a
38+ // manual refresh). collectLatest cancels an in-flight load when the connection changes or
39+ // a manual reload arrives.
40+ viewModelScope.launch {
41+ merge(container.activeConnection, _reload).collectLatest { doLoad() }
42+ }
43+ }
44+
45+ fun load() { _reload.value++ }
3346
34− fun load() {
47+ private suspend fun doLoad() {
3548 val conn = container.activeConnection.value ?: run {
36− loadJob?.cancel()
3749 _state.value = State.Disconnected
3850 return
3951 }
4052 _state.value = State.Loading
41− loadJob?.cancel()
42− loadJob = viewModelScope.launch {
43− runCatchingCancellable {
44− val config = conn.api.config()
45− withContext(Dispatchers.Default) { parseMcpServers(config) }
46− }
47− .onSuccess { _state.value = State.Ready(it) }
48− .onFailure { _state.value = State.Error }
53+ runCatchingCancellable {
54+ val config = conn.api.config()
55+ withContext(Dispatchers.Default) { parseMcpServers(config) }
4956 }
57+ .onSuccess { _state.value = State.Ready(it) }
58+ .onFailure { _state.value = State.Error }
5059 }
5160}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−5
@@ -75,14 +75,17 @@ data class InitialProfile(
7575)
7676
7777/** True when the certificate-pin field is empty (feature off) or every whitespace/comma
78− * separated entry is a well-formed OkHttp "sha256/<base64>" pin. */
78+ * separated entry is a well-formed OkHttp "sha256/<base64>" (or legacy "sha1/<base64>") pin. */
7979fun isValidCertPin(raw: String): Boolean {
8080 val entries = raw.split(Regex("[\\s,]+")).map { it.trim() }.filter { it.isNotEmpty() }
8181 if (entries.isEmpty()) return true
82− // FIX 21(c): require a well-formed sha256/<base64>= pin (at least one base64 char
83− // before the padding). This rejects obviously malformed entries like "sha256/=" or "sha256/===".
84− // A real SHA-256 pin is 43 base64 chars + '=', but we accept any length for demo/test pins.
85− val pinRegex = Regex("sha256/[A-Za-z0-9+/]+=")
82+ // Require a well-formed sha256/ or sha1/ <base64>= pin (at least one base64 char before the
83+ // padding). This rejects obviously malformed entries like "sha256/=" or "sha256/===" and
84+ // unsupported algorithms like "md5/…". sha1 is accepted because OkHttp's CertificatePinner
85+ // accepts it too — the validator must match what the runtime accepts, or a well-formed
86+ // sha1 pin would be un-saveable. A real pin is 43 base64 chars + '=', but we accept any
87+ // length for demo/test pins.
88+ val pinRegex = Regex("(sha256|sha1)/[A-Za-z0-9+/]+=")
8689 return entries.all { it.matches(pinRegex) }
8790}
8891
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+63−19
@@ -396,7 +396,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
396396 val conn = container.activeConnection.value
397397 if (conn == null) {
398398 if (_state.value.sessions.isEmpty()) {
399− _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
399+ // copy() rather than a fresh SessionListState so pinned/archived ids, sort mode,
400+ // query and showArchived (all fed by once-in-init collectors of cold DataStore
401+ // flows) survive this error path instead of being silently reset to defaults.
402+ _state.update {
403+ it.copy(
404+ sessions = emptyList(),
405+ previews = emptyMap(),
406+ loading = false,
407+ error = container.string(R.string.not_connected),
408+ )
409+ }
400410 } else {
401411 _state.update { it.copy(loading = false) }
402412 _transientErrors.tryEmit(container.string(R.string.not_connected))
@@ -425,12 +435,22 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
425435 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
426436 loadPreviews(sorted)
427437 }
428− .onFailure {
438+ .onFailure { err ->
429439 if (_state.value.sessions.isNotEmpty()) {
430440 _state.update { it.copy(loading = false) }
431− _transientErrors.tryEmit(container.friendlyError(it))
441+ _transientErrors.tryEmit(container.friendlyError(err))
432442 } else {
433− _state.value = SessionListState(loading = false, error = container.friendlyError(it))
443+ // Preserve pinned/archived/sort/query via copy() (see refresh()'s
444+ // disconnected path) instead of resetting them on a failed load.
445+ val msg = container.friendlyError(err)
446+ _state.update {
447+ it.copy(
448+ sessions = emptyList(),
449+ previews = emptyMap(),
450+ loading = false,
451+ error = msg,
452+ )
453+ }
434454 }
435455 }
436456 } finally {
@@ -579,19 +599,29 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
579599 container.scheduleSessionDelete(
580600 id = session.id,
581601 delayMs = NetworkConfig.undoDeleteDelayMs,
602+ // scheduleSessionDelete runs these callbacks on the container's IO scope. Marshal the
603+ // VM work back onto viewModelScope (Main.immediate) so the non-@Volatile refreshJob/
604+ // previewJob fields and the _refreshing flag aren't read-modified-written from an IO
605+ // thread concurrently with a Main-thread refresh() — which could leak an uncancelled
606+ // load or strand the pull-to-refresh spinner. If the VM was cleared during the undo
607+ // window the launch is a no-op, which is fine (nothing left to refresh).
582608 onDeleted = {
583− pendingDeletes.remove(session.id)
584− refresh()
609+ viewModelScope.launch {
610+ pendingDeletes.remove(session.id)
611+ refresh()
612+ }
585613 },
586− onError = {
587− // Restore the session since the delete failed (or there was no connection to
588− // delete on). Guard against a concurrent refresh having already re-added it so
589− // we never produce two rows with the same id.
590− pendingDeletes.remove(session.id)
591− _state.update { s ->
592− s.copy(sessions = (s.sessions.filterNot { it.id == session.id } + session).sortedByMode(s.sortMode))
614+ onError = { err ->
615+ viewModelScope.launch {
616+ // Restore the session since the delete failed (or there was no connection to
617+ // delete on). Guard against a concurrent refresh having already re-added it so
618+ // we never produce two rows with the same id.
619+ pendingDeletes.remove(session.id)
620+ _state.update { s ->
621+ s.copy(sessions = (s.sessions.filterNot { it.id == session.id } + session).sortedByMode(s.sortMode))
622+ }
623+ _transientErrors.tryEmit(container.friendlyError(err))
593624 }
594− _transientErrors.tryEmit(container.friendlyError(it))
595625 },
596626 )
597627 }
@@ -681,14 +711,28 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
681711 // both servers. Surface both failures so the user understands
682712 // why they're looking at an empty list.
683713 val restoreMsg = container.friendlyError(restored.exceptionOrNull() ?: error)
684− _state.value = SessionListState(
714+ val switchMsg = container.string(R.string.error_switch_and_restore_failed,
715+ container.friendlyError(error), restoreMsg)
716+ _state.update {
717+ it.copy(
718+ sessions = emptyList(),
719+ previews = emptyMap(),
720+ loading = false,
721+ error = switchMsg,
722+ )
723+ }
724+ }
725+ } else {
726+ // Preserve pinned/archived/sort/query via copy() rather than a fresh state.
727+ val msg = container.friendlyError(error)
728+ _state.update {
729+ it.copy(
730+ sessions = emptyList(),
731+ previews = emptyMap(),
685732 loading = false,
686− error = container.string(R.string.error_switch_and_restore_failed,
687− container.friendlyError(error), restoreMsg),
733+ error = msg,
688734 )
689735 }
690− } else {
691− _state.value = SessionListState(loading = false, error = container.friendlyError(error))
692736 }
693737 }
694738 }
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+34−20
@@ -2,13 +2,16 @@ package soy.iko.opencode.ui.usage
22
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
5+import kotlinx.coroutines.ExperimentalCoroutinesApi
56import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.Job
77import kotlinx.coroutines.async
88import kotlinx.coroutines.awaitAll
9+import kotlinx.coroutines.coroutineScope
910import kotlinx.coroutines.flow.MutableStateFlow
1011import kotlinx.coroutines.flow.StateFlow
1112import kotlinx.coroutines.flow.asStateFlow
13+import kotlinx.coroutines.flow.collectLatest
14+import kotlinx.coroutines.flow.merge
1215import kotlinx.coroutines.launch
1316import kotlinx.coroutines.sync.Semaphore
1417import kotlinx.coroutines.sync.withPermit
@@ -22,6 +25,7 @@ import soy.iko.opencode.util.runCatchingCancellable
2225 * There's no server-side usage endpoint, so this reconstructs totals from the per-message
2326 * cost/token fields the API already returns.
2427 */
28+@OptIn(ExperimentalCoroutinesApi::class)
2529class UsageViewModel(private val container: AppContainer) : ViewModel() {
2630
2731 sealed interface State {
@@ -34,29 +38,39 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
3438 private val _state = MutableStateFlow<State>(State.Loading)
3539 val state: StateFlow<State> = _state.asStateFlow()
3640
37− // Tracks the in-flight load so a second trigger (refresh + retry are both wired to
38− // load()) cancels the first instead of racing it — otherwise the later-*completing* load
39− // wins regardless of which started first, e.g. a stale failure clobbering fresh data.
40− private var loadJob: Job? = null
41+ /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
42+ private val _reload = MutableStateFlow(0)
4143
42− init { load() }
44+ init {
45+ // Observe the active connection so the report loads (or reloads) once a connection is
46+ // available — including when the screen opens during a reconnect window where
47+ // activeConnection.value is momentarily null (else it would latch Disconnected until a
48+ // manual refresh). collectLatest cancels an in-flight load when the connection changes or
49+ // a manual reload arrives, so the later-*starting* trigger always wins.
50+ viewModelScope.launch {
51+ merge(container.activeConnection, _reload).collectLatest { doLoad() }
52+ }
53+ }
54+
55+ fun load() { _reload.value++ }
4356
44− fun load() {
57+ private suspend fun doLoad() {
4558 val conn = container.activeConnection.value ?: run {
46− loadJob?.cancel()
4759 _state.value = State.Disconnected
4860 return
4961 }
5062 _state.value = State.Loading
51− loadJob?.cancel()
52− loadJob = viewModelScope.launch {
53− runCatchingCancellable {
54− val sessions = conn.repository.listSessions()
55− // Cap concurrent message fetches so a large history doesn't open dozens of
56− // sockets at once; a per-session fetch failure yields an empty list rather
57− // than aborting the whole report.
58− val limiter = Semaphore(MAX_CONCURRENT_FETCHES)
59− val perSession = sessions.map { session ->
63+ runCatchingCancellable {
64+ val sessions = conn.repository.listSessions()
65+ // Cap concurrent message fetches so a large history doesn't open dozens of
66+ // sockets at once; a per-session fetch failure yields an empty list rather
67+ // than aborting the whole report.
68+ val limiter = Semaphore(MAX_CONCURRENT_FETCHES)
69+ // coroutineScope so the per-session fetches are children of THIS load: when
70+ // collectLatest cancels a stale load (connection changed / manual reload), the
71+ // in-flight fetches are cancelled with it instead of leaking.
72+ val perSession = coroutineScope {
73+ sessions.map { session ->
6074 async {
6175 limiter.withPermit {
6276 val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
@@ -65,11 +79,11 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
6579 }
6680 }
6781 }.awaitAll()
68− withContext(Dispatchers.Default) { aggregateUsage(perSession) }
6982 }
70− .onSuccess { _state.value = State.Ready(it) }
71− .onFailure { _state.value = State.Error }
83+ withContext(Dispatchers.Default) { aggregateUsage(perSession) }
7284 }
85+ .onSuccess { _state.value = State.Ready(it) }
86+ .onFailure { _state.value = State.Error }
7387 }
7488
7589 private companion object {
app/src/test/java/soy/iko/opencode/OpencodeJsonTest.kt+34
@@ -2,6 +2,7 @@ package soy.iko.opencode
22
33import soy.iko.opencode.data.model.AssistantMessage
44import soy.iko.opencode.data.model.BusEvent
5+import soy.iko.opencode.data.model.FilePart
56import soy.iko.opencode.data.model.MessagePartUpdated
67import soy.iko.opencode.data.model.MessageWithParts
78import soy.iko.opencode.data.model.Part
@@ -11,8 +12,10 @@ import soy.iko.opencode.data.model.ToolCompleted
1112import soy.iko.opencode.data.model.ToolPart
1213import soy.iko.opencode.data.model.UnknownEvent
1314import soy.iko.opencode.data.model.UnknownPart
15+import soy.iko.opencode.data.model.sourcePath
1416import soy.iko.opencode.data.network.OpencodeJson
1517import org.junit.Assert.assertEquals
18+import org.junit.Assert.assertNull
1619import org.junit.Assert.assertTrue
1720import org.junit.Test
1821
@@ -85,4 +88,35 @@ class OpencodeJsonTest {
8588 val part = decode(Part.serializer(), """{"type":"future-part","id":"p9","foo":true}""")
8689 assertTrue(part is UnknownPart)
8790 }
91+
92+ @Test
93+ fun decodesFilePartWithObjectSource() {
94+ // opencode sends `source` as a structured FilePartSource object, not a string. This must
95+ // decode without throwing (a SerializationException here is silently swallowed on the SSE
96+ // path, dropping the whole part), and sourcePath must extract the file path.
97+ val json = """
98+ { "type": "file", "id": "prt_f", "messageID": "msg_1", "mime": "text/plain",
99+ "filename": "a.kt",
100+ "source": { "type": "file", "path": "src/a.kt",
101+ "text": { "value": "x", "start": 0, "end": 1 } } }
102+ """.trimIndent()
103+ val part = decode(Part.serializer(), json) as FilePart
104+ assertEquals("src/a.kt", part.sourcePath)
105+ }
106+
107+ @Test
108+ fun decodesFilePartWithStringSource() {
109+ // A bare-string source is still tolerated for backward/other-shape compatibility.
110+ val part = decode(
111+ Part.serializer(),
112+ """{ "type": "file", "id": "prt_f", "source": "/abs/path.txt" }""",
113+ ) as FilePart
114+ assertEquals("/abs/path.txt", part.sourcePath)
115+ }
116+
117+ @Test
118+ fun filePartWithoutSourceHasNullPath() {
119+ val part = decode(Part.serializer(), """{ "type": "file", "id": "prt_f", "url": "/x.png" }""") as FilePart
120+ assertNull(part.sourcePath)
121+ }
88122}
app/src/test/java/soy/iko/opencode/ui/server/CertPinValidationTest.kt+7
@@ -23,10 +23,17 @@ class CertPinValidationTest {
2323 assertTrue(isValidCertPin("sha256/AAAA=,sha256/BBBB="))
2424 }
2525
26+ @Test
27+ fun acceptsSha1PinLikeOkHttp() {
28+ assertTrue(isValidCertPin("sha1/AAAAAAAAAAAAAAAAAAAAAAAAAAA="))
29+ assertTrue(isValidCertPin("sha256/AAAA= sha1/BBBB="))
30+ }
31+
2632 @Test
2733 fun rejectsMalformed() {
2834 assertFalse(isValidCertPin("not-a-pin"))
2935 assertFalse(isValidCertPin("md5/AAAA="))
3036 assertFalse(isValidCertPin("sha256/"))
37+ assertFalse(isValidCertPin("sha1/"))
3138 }
3239}