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
f6efdb3iko
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.kt+3−1
@@ -5,6 +5,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
5import kotlinx.coroutines.CoroutineScope
5import kotlinx.coroutines.CoroutineScope
6import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.Dispatchers
7import kotlinx.coroutines.SupervisorJob
7import kotlinx.coroutines.SupervisorJob
8+import kotlinx.coroutines.cancel
9+import kotlinx.coroutines.runBlocking
8import org.junit.Assert.assertEquals
10import org.junit.Assert.assertEquals
9import org.junit.Test
11import org.junit.Test
10import org.junit.runner.RunWith
12import org.junit.runner.RunWith
@@ -15,7 +17,7 @@ class DraftStoreTest {
15 private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
17 private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
16
18
17 @Test
19 @Test
18− fun setGetRemoveRoundTrip() {
20+ fun setGetRemoveRoundTrip() = runBlocking {
19 val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
21 val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
20 val store = DraftStore(context, scope)
22 val store = DraftStore(context, scope)
21
23
app/src/main/java/soy/iko/opencode/data/model/Part.kt+19−1
@@ -3,6 +3,9 @@ package soy.iko.opencode.data.model
3import androidx.compose.runtime.Immutable
3import androidx.compose.runtime.Immutable
4import kotlinx.serialization.SerialName
4import kotlinx.serialization.SerialName
5import kotlinx.serialization.Serializable
5import kotlinx.serialization.Serializable
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonObject
8+import kotlinx.serialization.json.JsonPrimitive
6
9
7/**
10/**
8 * A message part — a discriminated union on `type` (the global class discriminator).
11 * A message part — a discriminated union on `type` (the global class discriminator).
@@ -65,9 +68,24 @@ data class FilePart(
65 val mime: String? = null,
68 val mime: String? = null,
66 val filename: String? = null,
69 val filename: String? = null,
67 val url: String? = null,
70 val url: 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.
68− val source: String? = null,
76+ val source: JsonElement? = null,
69) : Part
77) : Part
70
78
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+
71@Immutable
89@Immutable
72@Serializable
90@Serializable
73@SerialName("step-start")
91@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
10import kotlinx.coroutines.flow.callbackFlow
10import kotlinx.coroutines.flow.callbackFlow
11import kotlinx.coroutines.launch
11import kotlinx.coroutines.launch
12import kotlinx.coroutines.suspendCancellableCoroutine
12import kotlinx.coroutines.suspendCancellableCoroutine
13+import kotlinx.coroutines.withTimeoutOrNull
13import kotlin.coroutines.resume
14import kotlin.coroutines.resume
14
15
15/** A server discovered on the local network via mDNS/DNS-SD. */
16/** A server discovered on the local network via mDNS/DNS-SD. */
@@ -53,7 +54,11 @@ open class NsdDiscovery(context: Context?) {
53
54
54 val resolver = launch {
55 val resolver = launch {
55 for (info in toResolve) {
56 for (info in toResolve) {
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.
56− val resolved = resolveService(nsd, info) ?: continue
61+ val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(nsd, info) } ?: continue
57 val name = resolved.serviceName.orEmpty()
62 val name = resolved.serviceName.orEmpty()
58 // Keep only opencode's own advertisements, not every _http._tcp service
63 // Keep only opencode's own advertisements, not every _http._tcp service
59 // (printers, routers, other web servers) on the network.
64 // (printers, routers, other web servers) on the network.
@@ -110,5 +115,8 @@ open class NsdDiscovery(context: Context?) {
110 const val TAG = "NsdDiscovery"
115 const val TAG = "NsdDiscovery"
111 // opencode advertises bonjour type "http" → DNS-SD "_http._tcp.".
116 // opencode advertises bonjour type "http" → DNS-SD "_http._tcp.".
112 const val SERVICE_TYPE = "_http._tcp."
117 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
113 }
121 }
114}
122}
app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.kt+10−4
@@ -97,10 +97,16 @@ open class AttachmentDraftStore private constructor(
97 }
97 }
98
98
99 /** Remove staged attachments for a session (call on session deletion). */
99 /** Remove staged attachments for a session (call on session deletion). */
100− open suspend fun remove(sessionId: String): Unit = lockFor(sessionId).withLock {
100+ open suspend fun remove(sessionId: String) {
101+ lockFor(sessionId).withLock {
101− withContext(Dispatchers.IO) {
102+ withContext(Dispatchers.IO) {
102− runCatchingCancellable { fileFor(sessionId)?.delete() }
103+ runCatchingCancellable { fileFor(sessionId)?.delete() }
103− Unit
104+ Unit
105+ }
104 }
106 }
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)
105 }
111 }
106}
112}
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+14−2
@@ -88,13 +88,25 @@ class BackupManager(
88 suspend fun import(text: String) {
88 suspend fun import(text: String) {
89 val data = json.decodeFromString<BackupData>(text)
89 val data = json.decodeFromString<BackupData>(text)
90 for (s in data.servers) {
90 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+ }
91 profileStore.save(
103 profileStore.save(
92 ServerProfile(
104 ServerProfile(
93 id = s.id,
105 id = s.id,
94 label = s.label,
106 label = s.label,
95 baseUrl = s.baseUrl,
107 baseUrl = s.baseUrl,
96− username = s.username?.takeIf { it.isNotBlank() },
108+ username = username,
97− password = s.password?.takeIf { it.isNotEmpty() },
109+ password = password,
98 lastUsed = 0,
110 lastUsed = 0,
99 requireHttps = s.requireHttps,
111 requireHttps = s.requireHttps,
100 certPin = s.certPin?.takeIf { it.isNotBlank() },
112 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) {
179 // in those messages, which may contain auth or internal paths. Scrubbing the
179 // in those messages, which may contain auth or internal paths. Scrubbing the
180 // assembled text guarantees no URL survives anywhere in the stored report.
180 // assembled text guarantees no URL survives anywhere in the stored report.
181 file.writeText(scrubUrls(sw.toString()))
181 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() } }
182 }
194 }
183
195
184 private fun appVersion(): String = runCatching {
196 private fun appVersion(): String = runCatching {
@@ -195,6 +207,11 @@ class CrashLogger private constructor(private val appContext: Context) {
195 }.getOrDefault("unknown")
207 }.getOrDefault("unknown")
196
208
197 companion object {
209 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+
198 /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
215 /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
199 internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
216 internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
200
217
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+10−4
@@ -83,10 +83,16 @@ open class MessageCacheStore private constructor(
83 }
83 }
84
84
85 /** Remove a session's cached messages (call on deletion). */
85 /** Remove a session's cached messages (call on deletion). */
86− open suspend fun remove(sessionId: String): Unit = lockFor(sessionId).withLock {
86+ open suspend fun remove(sessionId: String) {
87+ lockFor(sessionId).withLock {
87− withContext(Dispatchers.IO) {
88+ withContext(Dispatchers.IO) {
88− runCatchingCancellable { fileFor(sessionId)?.delete() }
89+ runCatchingCancellable { fileFor(sessionId)?.delete() }
89− Unit
90+ Unit
91+ }
90 }
92 }
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)
91 }
97 }
92}
98}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+30−14
@@ -57,32 +57,44 @@ open class OutboxStore private constructor(
57 /** All queued messages across sessions/servers, in enqueue order. */
57 /** All queued messages across sessions/servers, in enqueue order. */
58 open val messages: StateFlow<List<OutboxMessage>> = _messages.asStateFlow()
58 open val messages: StateFlow<List<OutboxMessage>> = _messages.asStateFlow()
59
59
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+
60 private val file: File? by lazy {
66 private val file: File? by lazy {
61 appContext?.let { File(it.filesDir, "outbox.json") }
67 appContext?.let { File(it.filesDir, "outbox.json") }
62 }
68 }
63
69
64 /** Read the persisted queue into [messages]. Call once at startup. Idempotent. */
70 /** Read the persisted queue into [messages]. Call once at startup. Idempotent. */
65 open suspend fun load() {
71 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
66 val f = file?.takeIf { it.exists() } ?: return
81 val f = file?.takeIf { it.exists() } ?: return
67− val loaded = withContext(Dispatchers.IO) {
82+ val diskList = withContext(Dispatchers.IO) {
68 runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
83 runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
69 .getOrDefault(emptyList())
84 .getOrDefault(emptyList())
70 }
85 }
71 // Don't clobber items enqueued while the load was in flight: merge by id, keeping
86 // Don't clobber items enqueued while the load was in flight: merge by id, keeping
72 // the in-memory version on conflict (it's newer).
87 // the in-memory version on conflict (it's newer).
73− ioMutex.withLock {
74− if (_messages.value.isEmpty()) {
88+ if (_messages.value.isEmpty()) {
75− _messages.value = loaded.sortedBy { it.createdAt }
89+ _messages.value = diskList.sortedBy { it.createdAt }
76− } else {
90+ } else {
77− val known = _messages.value.mapTo(mutableSetOf()) { it.id }
91+ val known = _messages.value.mapTo(mutableSetOf()) { it.id }
78− val merged = (loaded.filter { it.id !in known } + _messages.value).sortedBy { it.createdAt }
92+ val merged = (diskList.filter { it.id !in known } + _messages.value).sortedBy { it.createdAt }
79− _messages.value = merged
93+ _messages.value = merged
80− // FIX: the merge reconciled items enqueued while this load was in flight with
94+ // The merge reconciled items enqueued while this load was in flight with the
81− // the on-disk set, but previously updated only memory — so a raced enqueue's
95+ // on-disk set; write the merged set back so a later process kill can't drop the
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.
96+ // raced enqueue. persist() doesn't take ioMutex, so this won't deadlock.
84− persist(merged)
97+ persist(merged)
85− }
86 }
98 }
87 }
99 }
88
100
@@ -104,6 +116,10 @@ open class OutboxStore private constructor(
104 // overlapping writeText/delete calls could tear the file. The lock serializes both the
116 // overlapping writeText/delete calls could tear the file. The lock serializes both the
105 // in-memory update and the write, guaranteeing on-disk order matches memory order.
117 // in-memory update and the write, guaranteeing on-disk order matches memory order.
106 ioMutex.withLock {
118 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()
107 val updated = transform(_messages.value).sortedBy { it.createdAt }
123 val updated = transform(_messages.value).sortedBy { it.createdAt }
108 _messages.value = updated
124 _messages.value = updated
109 persist(updated)
125 persist(updated)
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+8
@@ -224,6 +224,14 @@ open class SessionRepository(
224 // makes a duplicate write of identical data harmless) — never data loss.
224 // makes a duplicate write of identical data harmless) — never data loss.
225 var latestSnapshot: List<MessageWithParts>? = null
225 var latestSnapshot: List<MessageWithParts>? = null
226 var savedSnapshot: List<MessageWithParts>? = null
226 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()
227 try {
235 try {
228 dirty.consumeAsFlow().collect {
236 dirty.consumeAsFlow().collect {
229 val snapshot = lock.withLock { store.snapshot() }
237 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(
748 connectionMutex.withLock {
748 connectionMutex.withLock {
749 _activeConnection.value?.close()
749 _activeConnection.value?.close()
750 _activeConnection.value = null
750 _activeConnection.value = null
751− activeRuns.clear()
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()
752− _anyRunActive.value = false
757+ _anyRunActive.value = false
758+ }
753 _unread.value = emptyMap()
759 _unread.value = emptyMap()
754 unreadMessageIds.clear()
760 unreadMessageIds.clear()
755 val now = System.currentTimeMillis()
761 val now = System.currentTimeMillis()
@@ -786,8 +792,12 @@ open class AppContainer private constructor(
786 // Reset run/unread state on disconnect too. With no SSE stream there's no
792 // Reset run/unread state on disconnect too. With no SSE stream there's no
787 // SessionIdle to drain activeRuns, so without this the working indicator
793 // SessionIdle to drain activeRuns, so without this the working indicator
788 // (anyRunActive) and unread badges would stay pinned until the next connect().
794 // (anyRunActive) and unread badges would stay pinned until the next connect().
795+ // Mutate under activeRuns' monitor (see connect()) so a concurrent SSE add() can't
796+ // re-pin _anyRunActive after the clear.
789− activeRuns.clear()
797+ synchronized(activeRuns) {
798+ activeRuns.clear()
790− _anyRunActive.value = false
799+ _anyRunActive.value = false
800+ }
791 _unread.value = emptyMap()
801 _unread.value = emptyMap()
792 unreadMessageIds.clear()
802 unreadMessageIds.clear()
793 }
803 }
@@ -974,7 +984,15 @@ open class AppContainer private constructor(
974 observeMessageActivity()
984 observeMessageActivity()
975 observeRunReconcileOnReconnect()
985 observeRunReconcileOnReconnect()
976 observeRunForegroundService()
986 observeRunForegroundService()
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 {
977− appScope.launch { runCatchingCancellable { outboxStore.load() } }
993+ runCatchingCancellable { outboxStore.load() }
994+ flushOutbox()
995+ }
978 observeOutbox()
996 observeOutbox()
979 appScope.launch { autoConnect() }
997 appScope.launch { autoConnect() }
980 }
998 }
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+7−4
@@ -25,7 +25,6 @@ import androidx.compose.runtime.DisposableEffect
25import androidx.compose.runtime.getValue
25import androidx.compose.runtime.getValue
26import androidx.compose.runtime.mutableStateOf
26import androidx.compose.runtime.mutableStateOf
27import androidx.compose.runtime.remember
27import androidx.compose.runtime.remember
28−import androidx.compose.runtime.saveable.rememberSaveable
29import androidx.compose.runtime.setValue
28import androidx.compose.runtime.setValue
30import androidx.compose.ui.Alignment
29import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
30import androidx.compose.ui.Modifier
@@ -92,9 +91,13 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
92 return
91 return
93 }
92 }
94
93
95− // Survives config changes / process-death restore so a rotation doesn't re-prompt, but
94+ // Deliberately NOT rememberSaveable: the unlocked flag must never be persisted into the
96− // defaults to locked on a fresh start.
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.
97− var unlocked by rememberSaveable { mutableStateOf(false) }
100+ var unlocked by remember { mutableStateOf(false) }
98
101
99 val title = stringResource(R.string.app_lock_prompt_title)
102 val title = stringResource(R.string.app_lock_prompt_title)
100 val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
103 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
45import kotlinx.coroutines.flow.filterIsInstance
45import kotlinx.coroutines.flow.filterIsInstance
46import kotlinx.coroutines.flow.flatMapLatest
46import kotlinx.coroutines.flow.flatMapLatest
47import kotlinx.coroutines.flow.flowOf
47import kotlinx.coroutines.flow.flowOf
48+import kotlinx.coroutines.flow.flowOn
48import kotlinx.coroutines.flow.map
49import kotlinx.coroutines.flow.map
49import kotlinx.coroutines.flow.merge
50import kotlinx.coroutines.flow.merge
50import kotlinx.coroutines.flow.onEach
51import kotlinx.coroutines.flow.onEach
51import kotlinx.coroutines.flow.retryWhen
52import kotlinx.coroutines.flow.retryWhen
52import kotlinx.coroutines.flow.stateIn
53import kotlinx.coroutines.flow.stateIn
53import kotlinx.coroutines.flow.update
54import kotlinx.coroutines.flow.update
55+import kotlinx.coroutines.Dispatchers
54import kotlinx.coroutines.delay
56import kotlinx.coroutines.delay
55import kotlinx.coroutines.launch
57import kotlinx.coroutines.launch
56
58
@@ -217,6 +219,11 @@ class ChatViewModel(
217 * emission that doesn't change the plan doesn't recompose the bar. */
219 * emission that doesn't change the plan doesn't recompose the bar. */
218 val todoPlan: StateFlow<List<TodoItem>> = messages
220 val todoPlan: StateFlow<List<TodoItem>> = messages
219 .map { currentTodoPlan(it) }
221 .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)
220 .distinctUntilChanged()
227 .distinctUntilChanged()
221 .stateIn(
228 .stateIn(
222 scope = viewModelScope,
229 scope = viewModelScope,
@@ -227,6 +234,14 @@ class ChatViewModel(
227 private val _running = MutableStateFlow(false)
234 private val _running = MutableStateFlow(false)
228 val running: StateFlow<Boolean> = _running.asStateFlow()
235 val running: StateFlow<Boolean> = _running.asStateFlow()
229
236
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+
230 /** True while an abort REST call is in flight, so the Stop button can show a
245 /** True while an abort REST call is in flight, so the Stop button can show a
231 * spinner and prevent double-taps from firing a second abort. */
246 * spinner and prevent double-taps from firing a second abort. */
232 private val _aborting = MutableStateFlow(false)
247 private val _aborting = MutableStateFlow(false)
@@ -545,6 +560,10 @@ class ChatViewModel(
545 return@collectLatest
560 return@collectLatest
546 }
561 }
547 _running.value = false
562 _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
548 clearPermissions()
567 clearPermissions()
549 _failedDraft.value = null
568 _failedDraft.value = null
550 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
569 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
@@ -557,6 +576,7 @@ class ChatViewModel(
557 conn.events.events.collect { event ->
576 conn.events.events.collect { event ->
558 if (SessionRepository.isIdle(event, sessionId)) {
577 if (SessionRepository.isIdle(event, sessionId)) {
559 _running.value = false
578 _running.value = false
579+ runEndedByIdle = true
560 // Auto-send a queued follow-up once the previous run finishes,
580 // Auto-send a queued follow-up once the previous run finishes,
561 // so the user's drafted-while-running message isn't lost.
581 // so the user's drafted-while-running message isn't lost.
562 val queued = _queuedFollowUp.value
582 val queued = _queuedFollowUp.value
@@ -567,6 +587,7 @@ class ChatViewModel(
567 }
587 }
568 if (SessionRepository.isError(event, sessionId)) {
588 if (SessionRepository.isError(event, sessionId)) {
569 _running.value = false
589 _running.value = false
590+ runEndedByIdle = true
570 // The run errored, so the queued follow-up won't auto-send.
591 // The run errored, so the queued follow-up won't auto-send.
571 // Restore it to the input (if free) so the user's typed text
592 // Restore it to the input (if free) so the user's typed text
572 // isn't silently lost; otherwise just clear the queue.
593 // isn't silently lost; otherwise just clear the queue.
@@ -580,7 +601,7 @@ class ChatViewModel(
580 // Restore the run indicator if a live streaming event arrives while it's
601 // Restore the run indicator if a live streaming event arrives while it's
581 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
602 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
582 // agent is still working. Don't revive it once the user has hit Stop.
603 // 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) {
584 _running.value = true
605 _running.value = true
585 }
606 }
586 when (event) {
607 when (event) {
@@ -712,6 +733,9 @@ class ChatViewModel(
712 return enqueueOffline(trimmed, attachments)
733 return enqueueOffline(trimmed, attachments)
713 }
734 }
714 if (!_running.compareAndSet(false, true)) return false
735 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
715 _failedDraft.value = null
739 _failedDraft.value = null
716 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
740 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
717 // while retryFailed() re-submits with the key it stashed on the original failure — so a
741 // 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
63import soy.iko.opencode.data.model.UnknownPart
63import soy.iko.opencode.data.model.UnknownPart
64import soy.iko.opencode.data.model.inputElement
64import soy.iko.opencode.data.model.inputElement
65import soy.iko.opencode.data.model.parseTodos
65import soy.iko.opencode.data.model.parseTodos
66+import soy.iko.opencode.data.model.sourcePath
66import soy.iko.opencode.ui.components.DiffView
67import soy.iko.opencode.ui.components.DiffView
67import soy.iko.opencode.ui.components.ImageLoadContext
68import soy.iko.opencode.ui.components.ImageLoadContext
68import soy.iko.opencode.ui.components.MarkdownText
69import soy.iko.opencode.ui.components.MarkdownText
@@ -444,7 +445,7 @@ private fun ToolStatusIcon(state: ToolState) {
444@Composable
445@Composable
445private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
446private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
446 val context = LocalContext.current
447 val context = LocalContext.current
447− val path = part.source ?: part.url ?: part.filename
448+ val path = part.sourcePath ?: part.url ?: part.filename
448 val copyLabel = stringResource(R.string.copy_path)
449 val copyLabel = stringResource(R.string.copy_path)
449 val openLabel = stringResource(R.string.open_file)
450 val openLabel = stringResource(R.string.open_file)
450 // When a source path is available and a navigation callback is wired, tapping the
451 // 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) -
452 // most likely expects). Long-press still copies the path to the clipboard, so the
453 // most likely expects). Long-press still copies the path to the clipboard, so the
453 // copy affordance is preserved without being the default tap action. When no
454 // copy affordance is preserved without being the default tap action. When no
454 // navigation is wired (e.g. tests), falls back to copy-path on tap.
455 // navigation is wired (e.g. tests), falls back to copy-path on tap.
455− val source = part.source
456+ val source = part.sourcePath
456 val opener = onOpenFile
457 val opener = onOpenFile
457 val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
458 val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
458 val canOpen = openPath != null
459 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
29import soy.iko.opencode.R
29import soy.iko.opencode.R
30import soy.iko.opencode.data.model.FilePart
30import soy.iko.opencode.data.model.FilePart
31import soy.iko.opencode.data.model.ServerProfile
31import soy.iko.opencode.data.model.ServerProfile
32+import soy.iko.opencode.data.model.sourcePath
32import soy.iko.opencode.data.network.HttpClientFactory
33import soy.iko.opencode.data.network.HttpClientFactory
33
34
34/**
35/**
@@ -93,7 +94,7 @@ private fun decodeDataUri(data: String): ByteArray? {
93 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
94 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
94 */
95 */
95private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
96private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
96− val src = source
97+ val src = sourcePath
97 if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
98 if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
98 val url = url ?: return null
99 val url = url ?: return null
99 // Attachments are persisted as a data URL in `part.url`, so a data URI can arrive here too.
100 // 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
2
2
3import androidx.lifecycle.ViewModel
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5+import kotlinx.coroutines.ExperimentalCoroutinesApi
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.Job
7import kotlinx.coroutines.flow.MutableStateFlow
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.StateFlow
8import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.asStateFlow
9import kotlinx.coroutines.flow.asStateFlow
10+import kotlinx.coroutines.flow.collectLatest
11+import kotlinx.coroutines.flow.merge
10import kotlinx.coroutines.launch
12import kotlinx.coroutines.launch
11import kotlinx.coroutines.withContext
13import kotlinx.coroutines.withContext
12import soy.iko.opencode.di.AppContainer
14import soy.iko.opencode.di.AppContainer
13import soy.iko.opencode.util.runCatchingCancellable
15import soy.iko.opencode.util.runCatchingCancellable
14
16
15/** Loads the configured MCP servers from the active connection's `config` (read-only view). */
17/** Loads the configured MCP servers from the active connection's `config` (read-only view). */
18+@OptIn(ExperimentalCoroutinesApi::class)
16class McpViewModel(private val container: AppContainer) : ViewModel() {
19class McpViewModel(private val container: AppContainer) : ViewModel() {
17
20
18 sealed interface State {
21 sealed interface State {
@@ -25,27 +28,33 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
25 private val _state = MutableStateFlow<State>(State.Loading)
28 private val _state = MutableStateFlow<State>(State.Loading)
26 val state: StateFlow<State> = _state.asStateFlow()
29 val state: StateFlow<State> = _state.asStateFlow()
27
30
28− // Tracks the in-flight load so refresh/retry taps cancel the previous load instead of
31+ /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
29− // racing it (last-to-complete would otherwise win regardless of start order).
32+ private val _reload = MutableStateFlow(0)
30− private var loadJob: Job? = null
31
33
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++ }
33
46
34− fun load() {
47+ private suspend fun doLoad() {
35 val conn = container.activeConnection.value ?: run {
48 val conn = container.activeConnection.value ?: run {
36− loadJob?.cancel()
37 _state.value = State.Disconnected
49 _state.value = State.Disconnected
38 return
50 return
39 }
51 }
40 _state.value = State.Loading
52 _state.value = State.Loading
41− loadJob?.cancel()
42− loadJob = viewModelScope.launch {
43− runCatchingCancellable {
53+ runCatchingCancellable {
44− val config = conn.api.config()
54+ val config = conn.api.config()
45− withContext(Dispatchers.Default) { parseMcpServers(config) }
55+ withContext(Dispatchers.Default) { parseMcpServers(config) }
46− }
47− .onSuccess { _state.value = State.Ready(it) }
48− .onFailure { _state.value = State.Error }
49 }
56 }
57+ .onSuccess { _state.value = State.Ready(it) }
58+ .onFailure { _state.value = State.Error }
50 }
59 }
51}
60}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−5
@@ -75,14 +75,17 @@ data class InitialProfile(
75)
75)
76
76
77/** True when the certificate-pin field is empty (feature off) or every whitespace/comma
77/** 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. */
79fun isValidCertPin(raw: String): Boolean {
79fun isValidCertPin(raw: String): Boolean {
80 val entries = raw.split(Regex("[\\s,]+")).map { it.trim() }.filter { it.isNotEmpty() }
80 val entries = raw.split(Regex("[\\s,]+")).map { it.trim() }.filter { it.isNotEmpty() }
81 if (entries.isEmpty()) return true
81 if (entries.isEmpty()) return true
82− // FIX 21(c): require a well-formed sha256/<base64>= pin (at least one base64 char
82+ // Require a well-formed sha256/ or sha1/ <base64>= pin (at least one base64 char before the
83− // before the padding). This rejects obviously malformed entries like "sha256/=" or "sha256/===".
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
84− // A real SHA-256 pin is 43 base64 chars + '=', but we accept any length for demo/test pins.
86+ // sha1 pin would be un-saveable. A real pin is 43 base64 chars + '=', but we accept any
87+ // length for demo/test pins.
85− val pinRegex = Regex("sha256/[A-Za-z0-9+/]+=")
88+ val pinRegex = Regex("(sha256|sha1)/[A-Za-z0-9+/]+=")
86 return entries.all { it.matches(pinRegex) }
89 return entries.all { it.matches(pinRegex) }
87}
90}
88
91
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+63−19
@@ -396,7 +396,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
396 val conn = container.activeConnection.value
396 val conn = container.activeConnection.value
397 if (conn == null) {
397 if (conn == null) {
398 if (_state.value.sessions.isEmpty()) {
398 if (_state.value.sessions.isEmpty()) {
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,
399− _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
407+ error = container.string(R.string.not_connected),
408+ )
409+ }
400 } else {
410 } else {
401 _state.update { it.copy(loading = false) }
411 _state.update { it.copy(loading = false) }
402 _transientErrors.tryEmit(container.string(R.string.not_connected))
412 _transientErrors.tryEmit(container.string(R.string.not_connected))
@@ -425,12 +435,22 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
425 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
435 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
426 loadPreviews(sorted)
436 loadPreviews(sorted)
427 }
437 }
428− .onFailure {
438+ .onFailure { err ->
429 if (_state.value.sessions.isNotEmpty()) {
439 if (_state.value.sessions.isNotEmpty()) {
430 _state.update { it.copy(loading = false) }
440 _state.update { it.copy(loading = false) }
431− _transientErrors.tryEmit(container.friendlyError(it))
441+ _transientErrors.tryEmit(container.friendlyError(err))
432 } else {
442 } 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+ }
434 }
454 }
435 }
455 }
436 } finally {
456 } finally {
@@ -579,19 +599,29 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
579 container.scheduleSessionDelete(
599 container.scheduleSessionDelete(
580 id = session.id,
600 id = session.id,
581 delayMs = NetworkConfig.undoDeleteDelayMs,
601 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).
582 onDeleted = {
608 onDeleted = {
609+ viewModelScope.launch {
583− pendingDeletes.remove(session.id)
610+ pendingDeletes.remove(session.id)
584− refresh()
611+ refresh()
612+ }
585 },
613 },
586− onError = {
614+ onError = { err ->
615+ viewModelScope.launch {
587− // Restore the session since the delete failed (or there was no connection to
616+ // 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
617+ // delete on). Guard against a concurrent refresh having already re-added it so
589− // we never produce two rows with the same id.
618+ // we never produce two rows with the same id.
590− pendingDeletes.remove(session.id)
619+ pendingDeletes.remove(session.id)
591− _state.update { s ->
620+ _state.update { s ->
592− s.copy(sessions = (s.sessions.filterNot { it.id == session.id } + session).sortedByMode(s.sortMode))
621+ s.copy(sessions = (s.sessions.filterNot { it.id == session.id } + session).sortedByMode(s.sortMode))
622+ }
623+ _transientErrors.tryEmit(container.friendlyError(err))
593 }
624 }
594− _transientErrors.tryEmit(container.friendlyError(it))
595 },
625 },
596 )
626 )
597 }
627 }
@@ -681,14 +711,28 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
681 // both servers. Surface both failures so the user understands
711 // both servers. Surface both failures so the user understands
682 // why they're looking at an empty list.
712 // why they're looking at an empty list.
683 val restoreMsg = container.friendlyError(restored.exceptionOrNull() ?: error)
713 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(),
685 loading = false,
732 loading = false,
686− error = container.string(R.string.error_switch_and_restore_failed,
733+ error = msg,
687− container.friendlyError(error), restoreMsg),
688 )
734 )
689 }
735 }
690− } else {
691− _state.value = SessionListState(loading = false, error = container.friendlyError(error))
692 }
736 }
693 }
737 }
694 }
738 }
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+34−20
@@ -2,13 +2,16 @@ package soy.iko.opencode.ui.usage
2
2
3import androidx.lifecycle.ViewModel
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5+import kotlinx.coroutines.ExperimentalCoroutinesApi
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.Job
7import kotlinx.coroutines.async
7import kotlinx.coroutines.async
8import kotlinx.coroutines.awaitAll
8import kotlinx.coroutines.awaitAll
9+import kotlinx.coroutines.coroutineScope
9import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.asStateFlow
12import kotlinx.coroutines.flow.asStateFlow
13+import kotlinx.coroutines.flow.collectLatest
14+import kotlinx.coroutines.flow.merge
12import kotlinx.coroutines.launch
15import kotlinx.coroutines.launch
13import kotlinx.coroutines.sync.Semaphore
16import kotlinx.coroutines.sync.Semaphore
14import kotlinx.coroutines.sync.withPermit
17import kotlinx.coroutines.sync.withPermit
@@ -22,6 +25,7 @@ import soy.iko.opencode.util.runCatchingCancellable
22 * There's no server-side usage endpoint, so this reconstructs totals from the per-message
25 * There's no server-side usage endpoint, so this reconstructs totals from the per-message
23 * cost/token fields the API already returns.
26 * cost/token fields the API already returns.
24 */
27 */
28+@OptIn(ExperimentalCoroutinesApi::class)
25class UsageViewModel(private val container: AppContainer) : ViewModel() {
29class UsageViewModel(private val container: AppContainer) : ViewModel() {
26
30
27 sealed interface State {
31 sealed interface State {
@@ -34,29 +38,39 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
34 private val _state = MutableStateFlow<State>(State.Loading)
38 private val _state = MutableStateFlow<State>(State.Loading)
35 val state: StateFlow<State> = _state.asStateFlow()
39 val state: StateFlow<State> = _state.asStateFlow()
36
40
37− // Tracks the in-flight load so a second trigger (refresh + retry are both wired to
41+ /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
38− // load()) cancels the first instead of racing it — otherwise the later-*completing* load
42+ private val _reload = MutableStateFlow(0)
39− // wins regardless of which started first, e.g. a stale failure clobbering fresh data.
40− private var loadJob: Job? = null
41
43
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++ }
43
56
44− fun load() {
57+ private suspend fun doLoad() {
45 val conn = container.activeConnection.value ?: run {
58 val conn = container.activeConnection.value ?: run {
46− loadJob?.cancel()
47 _state.value = State.Disconnected
59 _state.value = State.Disconnected
48 return
60 return
49 }
61 }
50 _state.value = State.Loading
62 _state.value = State.Loading
51− loadJob?.cancel()
52− loadJob = viewModelScope.launch {
53− runCatchingCancellable {
63+ runCatchingCancellable {
54− val sessions = conn.repository.listSessions()
64+ val sessions = conn.repository.listSessions()
55− // Cap concurrent message fetches so a large history doesn't open dozens of
65+ // 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
66+ // sockets at once; a per-session fetch failure yields an empty list rather
57− // than aborting the whole report.
67+ // than aborting the whole report.
58− val limiter = Semaphore(MAX_CONCURRENT_FETCHES)
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 {
59− val perSession = sessions.map { session ->
73+ sessions.map { session ->
60 async {
74 async {
61 limiter.withPermit {
75 limiter.withPermit {
62 val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
76 val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
@@ -65,11 +79,11 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
65 }
79 }
66 }
80 }
67 }.awaitAll()
81 }.awaitAll()
68− withContext(Dispatchers.Default) { aggregateUsage(perSession) }
69 }
82 }
70− .onSuccess { _state.value = State.Ready(it) }
83+ withContext(Dispatchers.Default) { aggregateUsage(perSession) }
71− .onFailure { _state.value = State.Error }
72 }
84 }
85+ .onSuccess { _state.value = State.Ready(it) }
86+ .onFailure { _state.value = State.Error }
73 }
87 }
74
88
75 private companion object {
89 private companion object {
app/src/test/java/soy/iko/opencode/OpencodeJsonTest.kt+34
@@ -2,6 +2,7 @@ package soy.iko.opencode
2
2
3import soy.iko.opencode.data.model.AssistantMessage
3import soy.iko.opencode.data.model.AssistantMessage
4import soy.iko.opencode.data.model.BusEvent
4import soy.iko.opencode.data.model.BusEvent
5+import soy.iko.opencode.data.model.FilePart
5import soy.iko.opencode.data.model.MessagePartUpdated
6import soy.iko.opencode.data.model.MessagePartUpdated
6import soy.iko.opencode.data.model.MessageWithParts
7import soy.iko.opencode.data.model.MessageWithParts
7import soy.iko.opencode.data.model.Part
8import soy.iko.opencode.data.model.Part
@@ -11,8 +12,10 @@ import soy.iko.opencode.data.model.ToolCompleted
11import soy.iko.opencode.data.model.ToolPart
12import soy.iko.opencode.data.model.ToolPart
12import soy.iko.opencode.data.model.UnknownEvent
13import soy.iko.opencode.data.model.UnknownEvent
13import soy.iko.opencode.data.model.UnknownPart
14import soy.iko.opencode.data.model.UnknownPart
15+import soy.iko.opencode.data.model.sourcePath
14import soy.iko.opencode.data.network.OpencodeJson
16import soy.iko.opencode.data.network.OpencodeJson
15import org.junit.Assert.assertEquals
17import org.junit.Assert.assertEquals
18+import org.junit.Assert.assertNull
16import org.junit.Assert.assertTrue
19import org.junit.Assert.assertTrue
17import org.junit.Test
20import org.junit.Test
18
21
@@ -85,4 +88,35 @@ class OpencodeJsonTest {
85 val part = decode(Part.serializer(), """{"type":"future-part","id":"p9","foo":true}""")
88 val part = decode(Part.serializer(), """{"type":"future-part","id":"p9","foo":true}""")
86 assertTrue(part is UnknownPart)
89 assertTrue(part is UnknownPart)
87 }
90 }
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+ }
88}
122}
app/src/test/java/soy/iko/opencode/ui/server/CertPinValidationTest.kt+7
@@ -23,10 +23,17 @@ class CertPinValidationTest {
23 assertTrue(isValidCertPin("sha256/AAAA=,sha256/BBBB="))
23 assertTrue(isValidCertPin("sha256/AAAA=,sha256/BBBB="))
24 }
24 }
25
25
26+ @Test
27+ fun acceptsSha1PinLikeOkHttp() {
28+ assertTrue(isValidCertPin("sha1/AAAAAAAAAAAAAAAAAAAAAAAAAAA="))
29+ assertTrue(isValidCertPin("sha256/AAAA= sha1/BBBB="))
30+ }
31+
26 @Test
32 @Test
27 fun rejectsMalformed() {
33 fun rejectsMalformed() {
28 assertFalse(isValidCertPin("not-a-pin"))
34 assertFalse(isValidCertPin("not-a-pin"))
29 assertFalse(isValidCertPin("md5/AAAA="))
35 assertFalse(isValidCertPin("md5/AAAA="))
30 assertFalse(isValidCertPin("sha256/"))
36 assertFalse(isValidCertPin("sha256/"))
37+ assertFalse(isValidCertPin("sha1/"))
31 }
38 }
32}
39}