git.iko.soy/opencode-android
on master help sign in
Fix 13 concurrency and data-loss bugs: outbox race/idempotency, attac… (#1)

* Fix 13 concurrency and data-loss bugs: outbox race/idempotency, attachment OOM, FGS lifecycle, concurrent loads, file-store sync, LazyColumn dedup, port normalization, retry classification, LRU, TTS leak, app-lock re-prompt, timeout handling * Fix all 30 bugs: offline paint, archive/pin UI, app-lock crash, duplicate-key chat, cert-pin fail-open, Save stale, auto-reconnect race, FGS stuck, share nav, permissions, data-URL images, cache flush, send-on-Enter, run indicator, message defaults, Toast leak, encodings, query UI, permissions, and more * Fix 21 bugs: cert-pinning fail-open, app-lock lockout, data loss, stale probes, notification silent-drops * Fix more audit bugs: pin Coil image loads, stable send idempotency key, exponential message-load retry backoff, cumulative attachment-size cap, plus TTS/draft/usage/notification fixes

commit5d56632b497b42c4473dc87e1fe30a17e3ec9575 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 15:18 (3 months ago) parent5eaa0b7
48 files changed+1410−375
app/src/main/java/soy/iko/opencode/MainActivity.kt+17−12 app/src/main/java/soy/iko/opencode/OpencodeApp.kt+77 app/src/main/java/soy/iko/opencode/data/model/Message.kt+4−4 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+30−10 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+25−7 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+12 app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+6−1 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+97−41 app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.kt+40−14 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+26−24 app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+40−14 app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+28−5 app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+23−2 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+135−28 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+209−45 app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+11−8 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+15 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+7 app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.kt+5−1 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+26−13 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+21−26 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+7−1 app/src/main/java/soy/iko/opencode/ui/chat/Attachments.kt+54−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+19−4 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+93−24 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+3−1 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+3−1 app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+61−6 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+1−1 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+62−14 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+3−1 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+8−17 app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+8−1 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+3 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+2−2 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+63−13 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+8−3 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+4−7 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+19−10 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+23 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+11−2 app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.kt+9−4 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+4−2 app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+9−1 app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.kt+49 app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+17−1 app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+5−2 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+8
app/src/main/java/soy/iko/opencode/MainActivity.kt+17−12
@@ -51,32 +51,37 @@ class MainActivity : FragmentActivity() {
5151 val container = (application as OpencodeApp).container
5252 handleIntent(intent)
5353 if (savedInstanceState == null) maybeRequestNotificationPermission()
54− // Hold the splash until the persisted theme settings load, so we never paint a
55− // frame with the default (dynamicColor=true / SYSTEM) that then snaps to the
56− // user's real choice — the flash the SettingsScreen's null-gate already avoids.
57− var themeLoaded = false
58− splash.setKeepOnScreenCondition { !themeLoaded }
54+ // Hold the splash until the persisted theme and app-lock settings load, so we never
55+ // paint a frame with the defaults (dynamicColor=true / SYSTEM, app lock off) that then
56+ // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
57+ // before app lock resolves. Mirrors the SettingsScreen's null-gate.
58+ var settingsLoaded = false
59+ splash.setKeepOnScreenCondition { !settingsLoaded }
5960 setContent {
6061 val theme by remember(container) {
6162 container.settingsStore.themeMode
6263 .combine(container.settingsStore.dynamicColor, ::Pair)
6364 }.collectAsStateWithLifecycle(initialValue = null)
64− val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = false)
65− LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
66− // Hide the app's content from screenshots / the recents thumbnail while app lock
67− // is enabled, so protected server details don't leak there.
65+ val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
66+ LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
67+ // Hide the app's content from screenshots / the recents thumbnail while app lock is
68+ // enabled — and while its value is still unknown (null) at cold start, so a protected
69+ // launch can't flash into the recents thumbnail before the setting resolves.
6870 LaunchedEffect(appLock) {
69− if (appLock) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
71+ if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
7072 else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
7173 }
72− theme?.let { (themeMode, dynamicColor) ->
74+ val themePair = theme
75+ val locked = appLock
76+ if (themePair != null && locked != null) {
77+ val (themeMode, dynamicColor) = themePair
7378 val dark = when (themeMode) {
7479 ThemeMode.SYSTEM -> isSystemInDarkTheme()
7580 ThemeMode.LIGHT -> false
7681 ThemeMode.DARK -> true
7782 }
7883 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
79− AppLockGate(enabled = appLock) {
84+ AppLockGate(enabled = locked) {
8085 OpencodeAppUi(container = container)
8186 }
8287 }
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+77
@@ -5,6 +5,13 @@ import coil.ImageLoader
55import coil.ImageLoaderFactory
66import coil.disk.DiskCache
77import coil.memory.MemoryCache
8+import okhttp3.Call
9+import okhttp3.CertificatePinner
10+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
11+import okhttp3.OkHttpClient
12+import okhttp3.Request
13+import soy.iko.opencode.data.model.ServerProfile
14+import soy.iko.opencode.data.network.HttpClientFactory
815import soy.iko.opencode.data.repo.CrashLogger
916import soy.iko.opencode.di.AppContainer
1017import java.util.concurrent.atomic.AtomicBoolean
@@ -31,6 +38,11 @@ class OpencodeApp : Application(), ImageLoaderFactory {
3138 // disk cache so scrolling back to an image-bearing message doesn't re-fetch it over
3239 // the (auth-protected) network every time. Data-URI (base64) images skip the disk
3340 // cache automatically; only resolved HTTP/S URLs are cached, keyed by URL.
41+ //
42+ // Route fetches through a Call.Factory that applies the SAME per-profile certificate
43+ // pinning as the REST/SSE client (see HttpClientFactory). Server-hosted images carry the
44+ // server's HTTP Basic auth header (see RemoteImage), so without this they would travel over
45+ // an UNPINNED TLS connection, silently defeating the pin the user opted into for that host.
3446 override fun newImageLoader(): ImageLoader = ImageLoader.Builder(this)
3547 .memoryCache {
3648 MemoryCache.Builder(this)
@@ -43,6 +55,7 @@ class OpencodeApp : Application(), ImageLoaderFactory {
4355 .maxSizeBytes(50L * 1024 * 1024)
4456 .build()
4557 }
58+ .callFactory { PinnedImageCallFactory(container) }
4659 .build()
4760
4861 override fun onTerminate() {
@@ -57,3 +70,67 @@ class OpencodeApp : Application(), ImageLoaderFactory {
5770 container.shutdown()
5871 }
5972}
73+
74+/**
75+ * A Coil [Call.Factory] that pins image loads exactly like [HttpClientFactory] pins the
76+ * REST/SSE channel. The [ImageLoader] is a process singleton built once, but the active
77+ * [ServerProfile] — and therefore its pins — changes when the user switches servers, so the
78+ * certificate pinner is resolved *per request* from [AppContainer.activeConnection] rather than
79+ * baked in at build time. This fails closed: a profile that configures pins always gets a pinned
80+ * connection for its images (an unparseable pinned host throws, matching HttpClientFactory), so
81+ * image loads for a pinned host are never silently downgraded to an unpinned channel — and a
82+ * later server switch is picked up immediately instead of retaining the first profile's pins.
83+ */
84+private class PinnedImageCallFactory(private val container: AppContainer) : Call.Factory {
85+ // A single shared base client; each derived pinned client reuses this one's connection pool
86+ // and dispatcher via newBuilder(), so per-profile pinning doesn't spin up parallel resources.
87+ private val base = OkHttpClient()
88+
89+ // Cache the last derived client keyed by (host, pins) so we don't rebuild it on every request;
90+ // a server switch changes the signature and triggers a rebuild. Guarded because Coil invokes
91+ // newCall from arbitrary background threads, possibly concurrently.
92+ private val lock = Any()
93+ private var cachedSignature: String? = null
94+ private var cachedClient: OkHttpClient = base
95+
96+ override fun newCall(request: Request): Call =
97+ clientFor(container.activeConnection.value?.profile).newCall(request)
98+
99+ private fun clientFor(profile: ServerProfile?): OkHttpClient {
100+ val pins = parsePins(profile?.certPin)
101+ if (pins.isEmpty()) return base
102+ // effectiveBaseUrl upgrades http->https when pinning is on, so the pinned host matches the
103+ // https origin RemoteImage resolves image URLs against. Fail closed on an unparseable host
104+ // (never fall back to an unpinned client), mirroring HttpClientFactory.create().
105+ val effectiveUrl = effectiveBaseUrl(profile!!)
106+ val host = effectiveUrl.toHttpUrlOrNull()?.host
107+ ?: throw IllegalStateException(
108+ "Certificate pinning is configured but the server host could not be parsed from $effectiveUrl",
109+ )
110+ val signature = "$host\n${pins.sorted().joinToString(" ")}"
111+ synchronized(lock) {
112+ if (signature != cachedSignature) {
113+ val pinner = CertificatePinner.Builder().apply { pins.forEach { add(host, it) } }.build()
114+ cachedClient = base.newBuilder().certificatePinner(pinner).build()
115+ cachedSignature = signature
116+ }
117+ return cachedClient
118+ }
119+ }
120+
121+ // parsePins / effectiveBaseUrl mirror the private helpers in HttpClientFactory (the source of
122+ // truth). This file may not edit HttpClientFactory and those helpers aren't exposed, so the
123+ // pin-parsing and http->https upgrade logic is replicated here and must be kept in sync.
124+ private fun parsePins(raw: String?): List<String> =
125+ raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
126+
127+ private fun effectiveBaseUrl(profile: ServerProfile): String {
128+ val normalized = HttpClientFactory.normalizeBaseUrl(profile.baseUrl)
129+ val forceHttps = profile.requireHttps || parsePins(profile.certPin).isNotEmpty()
130+ return if (forceHttps && normalized.lowercase().startsWith("http://")) {
131+ "https://" + normalized.substring("http://".length)
132+ } else {
133+ normalized
134+ }
135+ }
136+}
app/src/main/java/soy/iko/opencode/data/model/Message.kt+4−4
@@ -23,8 +23,8 @@ sealed interface MessageInfo {
2323@Serializable
2424@SerialName("user")
2525data class UserMessage(
26− override val id: String,
27− override val sessionID: String,
26+ override val id: String = "",
27+ override val sessionID: String = "",
2828 override val time: TimeInfo? = null,
2929) : MessageInfo
3030
@@ -32,8 +32,8 @@ data class UserMessage(
3232@Serializable
3333@SerialName("assistant")
3434data class AssistantMessage(
35− override val id: String,
36− override val sessionID: String,
35+ override val id: String = "",
36+ override val sessionID: String = "",
3737 val parentID: String? = null,
3838 val providerID: String? = null,
3939 val modelID: String? = null,
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+30−10
@@ -68,12 +68,16 @@ open class EventStreamClient(
6868 /** Conflated channel used to cut the reconnect backoff short when network returns. */
6969 private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
7070
71− /** Returns true if the exception is an SSE auth failure (401/403) that should stop retrying.
71+ /** Returns true if the exception is a non-retryable SSE failure that should stop the retry
72+ * loop and park until an explicit reconnect. This covers any 4xx except 408 (Request Timeout)
73+ * and 429 (Too Many Requests), which are transient — mirroring the REST layer's withRetry
74+ * classification (401/403 bad credentials, 404 renamed/removed endpoint, 400 bad request, ...
75+ * won't succeed on retry).
7276 * ktor's SSE builder wraps establishment failures in [io.ktor.client.plugins.sse.SSEClientException]
7377 * (an IllegalStateException, NOT a ClientRequestException), attaching the response and keeping any
7478 * underlying ClientRequestException only as the cause — so we inspect the wrapper's response status
7579 * and walk the whole cause chain rather than matching a single exception type. */
76− private fun isSseAuthFailure(e: Throwable): Boolean =
80+ private fun isNonRetryableSseFailure(e: Throwable): Boolean =
7781 generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
7882 .take(16)
7983 .mapNotNull { t ->
@@ -83,7 +87,8 @@ open class EventStreamClient(
8387 else -> null
8488 }
8589 }
86− .any { it == 401 || it == 403 }
90+ // Non-408/429 4xx = permanent; 408 and 429 stay in the transient retry path.
91+ .any { it in 400..499 && it != 408 && it != 429 }
8792
8893 /** Request an immediate reconnect, skipping any in-progress backoff. */
8994 open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
@@ -106,8 +111,14 @@ open class EventStreamClient(
106111 val result: ChannelResult<ServerSentEvent>? = select {
107112 events.onReceiveCatching { it }
108113 onTimeout(idleTimeoutMs) { null }
114+ // A triggerReconnect() issued while a read is active must interrupt it now:
115+ // return the same null sentinel as the idle timeout so a half-open socket
116+ // (no TCP RST) is dropped and reconnected promptly instead of waiting out
117+ // idleTimeoutMs (~90s). Consuming the signal here also stops the backoff
118+ // select below from re-triggering on the stale request.
119+ reconnectSignal.onReceive { null }
109120 }
110− if (result == null) throw IOException("SSE idle timeout, reconnecting")
121+ if (result == null) throw IOException("SSE read dropped (idle timeout or reconnect requested), reconnecting")
111122 val sse = result.getOrNull()
112123 if (sse == null) {
113124 // The producer channel is closed. A non-null cause means `incoming`
@@ -228,17 +239,26 @@ open class EventStreamClient(
228239 } catch (e: Exception) {
229240 // If the scope was cancelled (e.g. connection close), the closed-client
230241 // exception is expected — don't log a spurious "stream error" warning.
231− if (isSseAuthFailure(e)) {
232− // Credentials are wrong — retrying won't help. Log a scrubbed
233− // summary (class + status) instead of the full exception, whose
242+ if (isNonRetryableSseFailure(e)) {
243+ // The server rejected the request with a non-retryable 4xx (bad
244+ // credentials, missing endpoint, ...) — retrying won't help. Log a
245+ // scrubbed summary (class + status) instead of the full exception, whose
234246 // message carries the request URL and may include auth or paths.
235− Log.w("EventStream", "SSE auth failed (401/403), awaiting explicit reconnect: ${safeExceptionSummary(e)}")
247+ Log.w("EventStream", "SSE non-retryable 4xx, awaiting explicit reconnect: ${safeExceptionSummary(e)}")
236248 _state.value = ConnectionState.Failed
237249 if (!isActive) break
238250 // Don't complete the flow: a subscriber may fix the profile and call
239251 // triggerReconnect(). Suspend until an explicit reconnect signal
240− // arrives (no backoff delay), then re-attempt the connection.
241− reconnectSignal.receive()
252+ // arrives (no backoff delay), then re-attempt the connection. This receive
253+ // is outside the try above, so reset state to Disconnected if the last
254+ // subscriber leaves (cancellation) while we're parked here — otherwise the
255+ // stream would be torn down with state stuck at Failed.
256+ try {
257+ reconnectSignal.receive()
258+ } catch (c: CancellationException) {
259+ _state.value = ConnectionState.Disconnected
260+ throw c
261+ }
242262 continue
243263 } else if (isActive) {
244264 Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+25−7
@@ -15,6 +15,7 @@ import io.ktor.http.URLBuilder
1515import io.ktor.http.takeFrom
1616import io.ktor.serialization.kotlinx.json.json
1717import okhttp3.CertificatePinner
18+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
1819import java.util.concurrent.TimeUnit
1920
2021/**
@@ -30,8 +31,17 @@ object HttpClientFactory {
3031 // and pin the server certificate(s) for HTTPS connections.
3132 val normalizedUrl = effectiveBaseUrl(profile)
3233 val isHttps = normalizedUrl.lowercase().startsWith("https://")
33− val pinHost = runCatching { java.net.URI(normalizedUrl).host }.getOrNull()
34+ // Parse the host with OkHttp's own parser (not java.net.URI, which rejects hosts OkHttp
35+ // accepts, e.g. underscores in "my_server.local"). If pinning is configured over HTTPS
36+ // but the host can't be parsed, fail closed: never build an unpinned client — that would
37+ // be a silent security downgrade for a user who opted into pinning.
38+ val pinHost = normalizedUrl.toHttpUrlOrNull()?.host
3439 val pins = parsePins(profile.certPin)
40+ if (pins.isNotEmpty() && isHttps && pinHost == null) {
41+ throw IllegalStateException(
42+ "Certificate pinning is configured but the server host could not be parsed from $normalizedUrl",
43+ )
44+ }
3545
3646 return HttpClient(OkHttp) {
3747 expectSuccess = true
@@ -80,11 +90,15 @@ object HttpClientFactory {
8090 password = profile.password.orEmpty(),
8191 )
8292 }
83− // Send eagerly so opencode doesn't need a 401 challenge round-trip.
84− // The Auth plugin is only installed for HTTPS profiles — over HTTP,
85− // even reactive (401-challenge) credential sending would leak
93+ // Send eagerly so opencode doesn't need a 401 challenge round-trip, but
94+ // only to the configured host: a cross-host redirect must not carry the
95+ // credentials off-origin (the plugin would still answer a genuine 401 from
96+ // the real host reactively). The Auth plugin is only installed for HTTPS
97+ // profiles — over HTTP, even reactive credential sending would leak
8698 // passwords in cleartext, so we don't install it at all.
87− sendWithoutRequest { true }
99+ sendWithoutRequest { request ->
100+ pinHost != null && request.url.host.equals(pinHost, ignoreCase = true)
101+ }
88102 }
89103 }
90104 } else {
@@ -113,10 +127,14 @@ object HttpClientFactory {
113127 }
114128
115129 /** The base URL after applying [ServerProfile.requireHttps]: a cleartext http:// URL is
116− * upgraded to https:// so the "Require HTTPS" choice is enforced at the transport layer. */
130+ * upgraded to https:// so the "Require HTTPS" choice is enforced at the transport layer.
131+ * A configured certificate pin also forces TLS: a pin is meaningless over cleartext and
132+ * would otherwise be silently dropped, so we honor the opt-in security control by upgrading
133+ * rather than connecting unpinned in the clear. */
117134 private fun effectiveBaseUrl(profile: ServerProfile): String {
118135 val normalized = normalizeBaseUrl(profile.baseUrl)
119− return if (profile.requireHttps && normalized.lowercase().startsWith("http://")) {
136+ val forceHttps = profile.requireHttps || parsePins(profile.certPin).isNotEmpty()
137+ return if (forceHttps && normalized.lowercase().startsWith("http://")) {
120138 "https://" + normalized.substring("http://".length)
121139 } else {
122140 normalized
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+12
@@ -114,6 +114,12 @@ object NetworkConfig {
114114 /** Max number of attachments staged for one prompt, to bound memory and request size. */
115115 const val maxAttachments = 8
116116
117+ /** Max cumulative size of all attachments staged for one prompt. The per-file
118+ * [maxAttachmentBytes] cap alone allows [maxAttachments] max-size files (~80MB raw,
119+ * ~106MB as base64) to pile up in memory and be re-serialized on every add/remove and
120+ * again when the request body is built — a realistic OOM. This bounds the whole set. */
121+ const val maxTotalAttachmentBytes = 25L * 1024 * 1024
122+
117123 // --- StateFlow sharing (WhileSubscribed) ---
118124
119125 /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
@@ -149,6 +155,12 @@ object NetworkConfig {
149155 const val observerRetryDelayMs = 5_000L
150156 /** Max concurrent assistant runs to track for completion notifications. */
151157 const val activeRunsLimit = 200
158+ /** Debounce before the run foreground-service is started/stopped in response to
159+ * run-activity changes. A run that starts and then fails/idles almost immediately
160+ * would otherwise dispatch startForegroundService() then stopService() back-to-back;
161+ * if the stop wins the race before startForeground() runs, Android raises the
162+ * "did not then call startForeground()" crash. Debouncing collapses that window. */
163+ const val runForegroundDebounceMs = 500L
152164
153165 // --- Markdown rendering (MarkdownText) ---
154166
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+6−1
@@ -14,7 +14,12 @@ import kotlin.coroutines.resume
1414
1515/** A server discovered on the local network via mDNS/DNS-SD. */
1616data class DiscoveredServer(val name: String, val host: String, val port: Int) {
17− val baseUrl: String get() = "http://$host:$port"
17+ val baseUrl: String get() {
18+ // FIX 14: IPv6 literals (e.g. "fe80::1%wlan0", "2001:db8::1") must be bracketed and
19+ // have their scope id dropped to form a valid URL authority; IPv4 hosts are unchanged.
20+ val formattedHost = if (host.contains(':')) "[${host.substringBefore('%')}]" else host
21+ return "http://$formattedHost:$port"
22+ }
1823}
1924
2025/**
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+97−41
@@ -62,14 +62,22 @@ open class OpencodeApiClient private constructor(
6262 // between sessions on the same server.
6363 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
6464 // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
65− // The lock is held during the fetch to prevent a thundering-herd stampede when
66− // multiple coroutines miss the cache simultaneously.
65+ // The fetch runs OUTSIDE the lock (holding it across the network call would serialize
66+ // all callers and stall invalidation). To keep that correct, each catalog carries a
67+ // generation counter, bumped under the mutex by its invalidateXCache(): a fetch captures
68+ // the generation before it starts and, when it completes, only writes its result back if
69+ // the generation is unchanged — so an invalidation that raced the in-flight fetch is not
70+ // defeated by the pre-invalidation result being stored afterwards.
6771 private val providersMutex = Mutex()
6872 private val agentsMutex = Mutex()
6973 private val commandsMutex = Mutex()
7074 private var cachedProviders: CachedEntry<ProvidersResponse>? = null
7175 private var cachedAgents: CachedEntry<List<Agent>>? = null
7276 private var cachedCommands: CachedEntry<List<Command>>? = null
77+ // Generation counters (guarded by the matching mutex above) for the invalidation-race guard.
78+ private var providersGeneration = 0
79+ private var agentsGeneration = 0
80+ private var commandsGeneration = 0
7381
7482 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
7583 open suspend fun ping() {
@@ -142,12 +150,18 @@ open class OpencodeApiClient private constructor(
142150 attachments: List<FilePromptPart> = emptyList(),
143151 model: ModelRef? = null,
144152 agent: String? = null,
153+ // A caller-stable idempotency key. Callers that may re-invoke sendPrompt across
154+ // *separate* calls for the same logical message (the offline outbox, which re-sends
155+ // a message that stayed queued after an earlier flush failed) must pass a stable key
156+ // derived from the persistent message id — otherwise each attempt mints a fresh key
157+ // and a POST that reached the server but whose response was lost is re-created as a
158+ // duplicate run. When null, a fresh key is generated (correct for one-shot sends).
159+ idempotencyKey: String? = null,
145160 ): MessageWithParts {
146− // Generate the idempotency key *before* entering withRetry so all retry
147− // attempts share the same key. If the key were generated inside withRetry,
148− // each attempt would get a fresh UUID and the server couldn't deduplicate a
149− // request that reached it but whose response was lost (e.g. timeout).
150− val idempotencyKey = java.util.UUID.randomUUID().toString()
161+ // Resolve the key *before* entering withRetry so all retry attempts share it. If the
162+ // key were generated inside withRetry, each attempt would get a fresh UUID and the
163+ // server couldn't deduplicate a request that reached it but whose response was lost.
164+ val resolvedKey = idempotencyKey ?: java.util.UUID.randomUUID().toString()
151165 // A text part is only included when there's actual text, so an image-only prompt
152166 // (attachments, empty text) doesn't send an empty text part. Attachments follow
153167 // the text so the model reads the instruction first.
@@ -158,7 +172,7 @@ open class OpencodeApiClient private constructor(
158172 return withRetry {
159173 client!!.post("session/${encode(sessionId)}/message") {
160174 contentType(ContentType.Application.Json)
161− header("Idempotency-Key", idempotencyKey)
175+ header("Idempotency-Key", resolvedKey)
162176 setBody(
163177 PromptRequest(
164178 parts = parts,
@@ -256,15 +270,25 @@ open class OpencodeApiClient private constructor(
256270 }
257271 }
258272
259− open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
260− val now = System.currentTimeMillis()
261− val cached = cachedProviders
262− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
263− return@withLock cached.value
273+ open suspend fun providers(): ProvidersResponse {
274+ val generation: Int
275+ providersMutex.withLock {
276+ val cached = cachedProviders
277+ if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
278+ return cached.value
279+ }
280+ generation = providersGeneration
264281 }
282+ // Fetch outside the lock so a slow/retrying request (and its backoff) can't stall other
283+ // callers or cache invalidation. Concurrent cold callers may double-fetch — acceptable
284+ // for a rarely-fetched catalog — and the last store wins.
265285 val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
266− cachedProviders = CachedEntry(value, System.currentTimeMillis())
267− value
286+ providersMutex.withLock {
287+ // FIX 15: skip the store if an invalidateProvidersCache() raced this fetch — the
288+ // result predates the invalidation and would otherwise resurrect stale data.
289+ if (providersGeneration == generation) cachedProviders = CachedEntry(value, System.currentTimeMillis())
290+ }
291+ return value
268292 }
269293
270294 /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
@@ -273,45 +297,62 @@ open class OpencodeApiClient private constructor(
273297 client!!.get("config").body()
274298 }
275299
276− open suspend fun agents(): List<Agent> = agentsMutex.withLock {
277− val now = System.currentTimeMillis()
278− val cached = cachedAgents
279− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
280− return@withLock cached.value
300+ open suspend fun agents(): List<Agent> {
301+ val generation: Int
302+ agentsMutex.withLock {
303+ val cached = cachedAgents
304+ if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
305+ return cached.value
306+ }
307+ generation = agentsGeneration
281308 }
309+ // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
310+ // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
282311 val value = withRetry { client!!.get("agent").body<List<Agent>>() }
283− cachedAgents = CachedEntry(value, System.currentTimeMillis())
284− value
312+ agentsMutex.withLock {
313+ // FIX 15: skip the store if an invalidateAgentsCache() raced this fetch (see providers()).
314+ if (agentsGeneration == generation) cachedAgents = CachedEntry(value, System.currentTimeMillis())
315+ }
316+ return value
285317 }
286318
287− open suspend fun commands(): List<Command> = commandsMutex.withLock {
288− val now = System.currentTimeMillis()
289− val cached = cachedCommands
290− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
291− return@withLock cached.value
319+ open suspend fun commands(): List<Command> {
320+ val generation: Int
321+ commandsMutex.withLock {
322+ val cached = cachedCommands
323+ if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
324+ return cached.value
325+ }
326+ generation = commandsGeneration
292327 }
328+ // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
329+ // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
293330 val value = withRetry { client!!.get("command").body<List<Command>>() }
294− cachedCommands = CachedEntry(value, System.currentTimeMillis())
295− value
331+ commandsMutex.withLock {
332+ // FIX 15: skip the store if an invalidateCommandsCache() raced this fetch (see providers()).
333+ if (commandsGeneration == generation) cachedCommands = CachedEntry(value, System.currentTimeMillis())
334+ }
335+ return value
296336 }
297337
298338 /** Invalidate the catalog cache (e.g. on server switch). */
299339 open suspend fun invalidateCache() {
300− providersMutex.withLock { cachedProviders = null }
301− agentsMutex.withLock { cachedAgents = null }
302− commandsMutex.withLock { cachedCommands = null }
340+ // FIX 15: bump each generation so an in-flight fetch drops its now-stale result.
341+ providersMutex.withLock { cachedProviders = null; providersGeneration++ }
342+ agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
343+ commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
303344 }
304345
305346 open suspend fun invalidateProvidersCache() {
306− providersMutex.withLock { cachedProviders = null }
347+ providersMutex.withLock { cachedProviders = null; providersGeneration++ }
307348 }
308349
309350 open suspend fun invalidateAgentsCache() {
310− agentsMutex.withLock { cachedAgents = null }
351+ agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
311352 }
312353
313354 open suspend fun invalidateCommandsCache() {
314− commandsMutex.withLock { cachedCommands = null }
355+ commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
315356 }
316357
317358 /** Respond to a permission request so a paused tool run can proceed. */
@@ -391,9 +432,10 @@ internal suspend fun <T> withRetryInternal(
391432 } catch (c: kotlinx.coroutines.CancellationException) {
392433 throw c
393434 } catch (t: ClientRequestException) {
394− // 429 (Too Many Requests) is transient — retry with backoff.
395− // All other 4xx are non-retryable.
396− if (t.response.status.value != 429) throw t
435+ // 408 (Request Timeout) and 429 (Too Many Requests) are transient — retry with
436+ // backoff. All other 4xx are non-retryable (a 401/404 won't succeed on retry).
437+ val status = t.response.status.value
438+ if (status != 408 && status != 429) throw t
397439 lastError = t
398440 if (attempt < maxAttempts) {
399441 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
@@ -407,6 +449,16 @@ internal suspend fun <T> withRetryInternal(
407449 } catch (t: ResponseException) {
408450 // 3xx (redirect limit exceeded) is non-retryable.
409451 throw t
452+ } catch (t: io.ktor.serialization.ContentConvertException) {
453+ // A malformed/unexpected response body (deserialization failure via content
454+ // negotiation) is deterministic — it fails identically on every attempt — so
455+ // retrying only burns the whole backoff budget re-parsing the same bytes. Fail
456+ // fast instead.
457+ throw t
458+ } catch (t: IllegalArgumentException) {
459+ // Also deterministic: a bad argument, or a raw kotlinx SerializationException
460+ // (which extends IllegalArgumentException) thrown outside content negotiation.
461+ throw t
410462 } catch (t: Exception) {
411463 lastError = t
412464 if (attempt < maxAttempts) {
@@ -435,6 +487,10 @@ internal fun jitteredBackoff(
435487 return (baseDelay + jitter).coerceAtLeast(0)
436488}
437489
438−/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
439−private fun encode(segment: String): String =
440− URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
490+/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding).
491+ * URLEncoder leaves '.' unencoded, so a "." or ".." segment would otherwise survive as a real
492+ * path-traversal component; percent-encode a segment that is nothing but dots. */
493+private fun encode(segment: String): String {
494+ val encoded = URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
495+ return if (encoded.isNotEmpty() && encoded.all { it == '.' }) encoded.replace(".", "%2E") else encoded
496+}
app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.kt+40−14
@@ -3,6 +3,8 @@ package soy.iko.opencode.data.repo
33import android.content.Context
44import android.util.Log
55import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.sync.Mutex
7+import kotlinx.coroutines.sync.withLock
68import kotlinx.coroutines.withContext
79import kotlinx.serialization.Serializable
810import kotlinx.serialization.decodeFromString
@@ -55,26 +57,50 @@ open class AttachmentDraftStore private constructor(
5557 private fun fileFor(sessionId: String): File? =
5658 dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
5759
60+ /** Per-session write lock so rapid add/remove/clear mutations (each launched as an
61+ * independent coroutine by the ViewModel) serialize their writes to the same file
62+ * instead of racing — otherwise an older snapshot could land last and overwrite the
63+ * newer one on disk, or two writes could tear the file. */
64+ private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
65+ private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
66+
5867 /** Load the staged attachments for [sessionId], or empty if none / unreadable. */
59− open suspend fun load(sessionId: String): List<PersistedAttachment> = withContext(Dispatchers.IO) {
60− val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
61− runCatchingCancellable { json.decodeFromString<List<PersistedAttachment>>(file.readText()) }
62− .getOrDefault(emptyList())
68+ open suspend fun load(sessionId: String): List<PersistedAttachment> = lockFor(sessionId).withLock {
69+ withContext(Dispatchers.IO) {
70+ val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
71+ runCatchingCancellable { json.decodeFromString<List<PersistedAttachment>>(file.readText()) }
72+ .getOrDefault(emptyList())
73+ }
6374 }
6475
6576 /** Persist [attachments] for [sessionId] (an empty list deletes the file). */
66− open suspend fun save(sessionId: String, attachments: List<PersistedAttachment>): Unit = withContext(Dispatchers.IO) {
67− val file = fileFor(sessionId) ?: return@withContext
68− runCatchingCancellable {
69− if (attachments.isEmpty()) file.delete()
70− else file.writeText(json.encodeToString(attachments))
71− }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
72− Unit
77+ open suspend fun save(sessionId: String, attachments: List<PersistedAttachment>): Unit = lockFor(sessionId).withLock {
78+ withContext(Dispatchers.IO) {
79+ val file = fileFor(sessionId) ?: return@withContext
80+ runCatchingCancellable {
81+ if (attachments.isEmpty()) file.delete()
82+ else {
83+ // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
84+ // process kill mid-write can't leave a truncated file that fails to decode
85+ // and silently drops the staged attachments on reload.
86+ val encoded = json.encodeToString(attachments)
87+ val tmp = File(file.parentFile, file.name + ".tmp")
88+ tmp.writeText(encoded)
89+ if (!tmp.renameTo(file)) {
90+ file.writeText(encoded)
91+ tmp.delete()
92+ }
93+ }
94+ }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
95+ Unit
96+ }
7397 }
7498
7599 /** Remove staged attachments for a session (call on session deletion). */
76− open suspend fun remove(sessionId: String): Unit = withContext(Dispatchers.IO) {
77− runCatchingCancellable { fileFor(sessionId)?.delete() }
78− Unit
100+ open suspend fun remove(sessionId: String): Unit = lockFor(sessionId).withLock {
101+ withContext(Dispatchers.IO) {
102+ runCatchingCancellable { fileFor(sessionId)?.delete() }
103+ Unit
104+ }
79105 }
80106}
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+26−24
@@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.asStateFlow
99import kotlinx.coroutines.flow.update
1010import android.util.Log
1111import kotlinx.coroutines.launch
12−import kotlinx.coroutines.withContext
1312import java.util.concurrent.Executors
1413
1514/**
@@ -154,7 +153,11 @@ open class DraftStore private constructor(
154153 }.apply()
155154 }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
156155 }
157− if (prefsInitialized.get()) write() else flushExecutor.execute { write() }
156+ // FIX: always submit to the single-thread flushExecutor (never apply directly) so
157+ // writes persist in submission order. Previously the post-init fast path applied
158+ // synchronously on the caller's thread, which could land BEFORE an earlier still-queued
159+ // write, persisting stale text. The task resolves the prefs lazy and calls apply().
160+ runCatching { flushExecutor.execute { write() } }
158161 }
159162
160163 /** Update the in-memory draft immediately without persisting to disk.
@@ -176,13 +179,11 @@ open class DraftStore private constructor(
176179 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
177180 updated
178181 }
179− withContext(Dispatchers.IO) {
180− runCatching {
181− prefs.edit().apply {
182− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
183− }.apply()
184− }.onFailure { Log.w("DraftStore", "Failed to persist draft for $sessionId", it) }
185− }
182+ // Route persistence through the single-thread flushExecutor (like the other writers)
183+ // rather than the shared IO pool, so this write can't race ahead of an earlier queued
184+ // flush and land stale. writeAsync persists sessionId=text (or removes it when blank),
185+ // which is exactly this draft's persistence.
186+ writeAsync(sessionId, text)
186187 }
187188
188189 /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
@@ -200,10 +201,15 @@ open class DraftStore private constructor(
200201 updated.remove(sessionId)
201202 updated
202203 }
203− withContext(Dispatchers.IO) {
204− runCatching {
205− prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
206− }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
204+ // Submit the (combined draft + follow-up) removal to the same single-thread
205+ // flushExecutor the other writers use, so it can't race ahead of an earlier queued
206+ // write and resurrect stale text. Mirrors flushDraft's submission pattern.
207+ runCatching {
208+ flushExecutor.execute {
209+ runCatching {
210+ prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
211+ }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
212+ }
207213 }
208214 }
209215
@@ -228,17 +234,13 @@ open class DraftStore private constructor(
228234 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
229235 updated
230236 }
231− if (prefsInitialized.get()) {
232− // Prefs already opened — the lazy resolves instantly and apply() is async.
233− runCatching {
234− prefs.edit().apply {
235− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
236− }.apply()
237− }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
238− } else {
239− // Prefs not yet opened — dispatch to a background thread to avoid a
240− // synchronous disk read on the main thread (which would ANR). The draft
241− // may be lost if the process exits before the write completes.
237+ // FIX: always route the persistence through the single-thread flushExecutor so writes
238+ // land in submission order. Previously, once prefs were open this applied synchronously
239+ // on the calling (main) thread, so a newer direct write could persist BEFORE an older
240+ // still-queued write completed, clobbering the draft with stale text. The executor task
241+ // resolves the prefs lazy and uses apply() (async) exactly as before; the write may be
242+ // lost only if the process exits before the queued task runs (as previously).
243+ runCatching {
242244 flushExecutor.execute {
243245 runCatching {
244246 prefs.edit().apply {
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+40−14
@@ -3,6 +3,8 @@ package soy.iko.opencode.data.repo
33import android.content.Context
44import android.util.Log
55import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.sync.Mutex
7+import kotlinx.coroutines.sync.withLock
68import kotlinx.coroutines.withContext
79import kotlinx.serialization.builtins.ListSerializer
810import soy.iko.opencode.data.model.MessageWithParts
@@ -41,26 +43,50 @@ open class MessageCacheStore private constructor(
4143 private fun fileFor(sessionId: String): File? =
4244 dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
4345
46+ /** Per-session write lock so concurrent save/remove/load on the same file (e.g. both
47+ * panes of two-pane mode saving the same session, or a save racing a delete's remove)
48+ * can't tear or half-write the cache file. Keyed by session id; the map grows only with
49+ * the number of distinct sessions touched, and each [Mutex] is a few bytes. */
50+ private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
51+ private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
52+
4453 /** Load the cached messages for [sessionId], or empty if none / unreadable. */
45− open suspend fun load(sessionId: String): List<MessageWithParts> = withContext(Dispatchers.IO) {
46− val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
47− runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
48− .getOrDefault(emptyList())
54+ open suspend fun load(sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
55+ withContext(Dispatchers.IO) {
56+ val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
57+ runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
58+ .getOrDefault(emptyList())
59+ }
4960 }
5061
5162 /** Persist [messages] for [sessionId] (an empty list deletes the file). */
52− open suspend fun save(sessionId: String, messages: List<MessageWithParts>): Unit = withContext(Dispatchers.IO) {
53− val file = fileFor(sessionId) ?: return@withContext
54− runCatchingCancellable {
55− if (messages.isEmpty()) file.delete()
56− else file.writeText(OpencodeJson.encodeToString(serializer, messages))
57− }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
58− Unit
63+ open suspend fun save(sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
64+ withContext(Dispatchers.IO) {
65+ val file = fileFor(sessionId) ?: return@withContext
66+ runCatchingCancellable {
67+ if (messages.isEmpty()) file.delete()
68+ else {
69+ // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
70+ // process kill mid-write can't leave a truncated file that fails to decode,
71+ // wiping the cached conversation on the next open.
72+ val encoded = OpencodeJson.encodeToString(serializer, messages)
73+ val tmp = File(file.parentFile, file.name + ".tmp")
74+ tmp.writeText(encoded)
75+ if (!tmp.renameTo(file)) {
76+ file.writeText(encoded)
77+ tmp.delete()
78+ }
79+ }
80+ }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
81+ Unit
82+ }
5983 }
6084
6185 /** Remove a session's cached messages (call on deletion). */
62− open suspend fun remove(sessionId: String): Unit = withContext(Dispatchers.IO) {
63− runCatchingCancellable { fileFor(sessionId)?.delete() }
64− Unit
86+ open suspend fun remove(sessionId: String): Unit = lockFor(sessionId).withLock {
87+ withContext(Dispatchers.IO) {
88+ runCatchingCancellable { fileFor(sessionId)?.delete() }
89+ Unit
90+ }
6591 }
6692}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+28−5
@@ -75,7 +75,13 @@ open class OutboxStore private constructor(
7575 _messages.value = loaded.sortedBy { it.createdAt }
7676 } else {
7777 val known = _messages.value.mapTo(mutableSetOf()) { it.id }
78− _messages.value = (loaded.filter { it.id !in known } + _messages.value).sortedBy { it.createdAt }
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)
7985 }
8086 }
8187 }
@@ -92,19 +98,36 @@ open class OutboxStore private constructor(
9298 mutate { current -> current.filterNot { it.sessionId == sessionId } }
9399
94100 private suspend fun mutate(transform: (List<OutboxMessage>) -> List<OutboxMessage>) {
95− val snapshot = ioMutex.withLock {
101+ // Persist *under* the lock so concurrent mutations (e.g. the flusher removing sent
102+ // messages while the UI enqueues a new one) can't race their disk writes: without
103+ // this, the older snapshot could land last and drop/resurrect a queued prompt, or two
104+ // 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.
106+ ioMutex.withLock {
96107 val updated = transform(_messages.value).sortedBy { it.createdAt }
97108 _messages.value = updated
98− updated
109+ persist(updated)
99110 }
100− persist(snapshot)
101111 }
102112
103113 private suspend fun persist(list: List<OutboxMessage>) {
104114 val f = file ?: return
105115 withContext(Dispatchers.IO) {
106116 runCatchingCancellable {
107− if (list.isEmpty()) f.delete() else f.writeText(json.encodeToString(list))
117+ if (list.isEmpty()) {
118+ f.delete()
119+ } else {
120+ // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
121+ // process kill mid-write can't leave a truncated file — a torn file fails to
122+ // decode and load() would then drop ALL queued offline messages.
123+ val encoded = json.encodeToString(list)
124+ val tmp = File(f.parentFile, f.name + ".tmp")
125+ tmp.writeText(encoded)
126+ if (!tmp.renameTo(f)) {
127+ f.writeText(encoded)
128+ tmp.delete()
129+ }
130+ }
108131 }.onFailure { Log.w("OutboxStore", "Failed to persist outbox", it) }
109132 }
110133 }
app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+23−2
@@ -2,6 +2,8 @@ package soy.iko.opencode.data.repo
22
33import android.content.Context
44import kotlinx.coroutines.Dispatchers
5+import kotlinx.coroutines.sync.Mutex
6+import kotlinx.coroutines.sync.withLock
57import kotlinx.coroutines.withContext
68import kotlinx.serialization.Serializable
79import kotlinx.serialization.decodeFromString
@@ -26,6 +28,12 @@ object RecentSessionsStore {
2628
2729 private val json = Json { ignoreUnknownKeys = true }
2830
31+ /** Serializes concurrent [write]s. The synchronous [read] (called from the widget's
32+ * RemoteViewsService) can't take a suspend lock, so [write] instead swaps the file in
33+ * atomically via a temp-file rename — the reader always sees either the old or the new
34+ * complete file, never a half-written one. */
35+ private val writeMutex = Mutex()
36+
2937 private fun file(context: Context) = File(context.applicationContext.filesDir, FILE)
3038
3139 /** Synchronous read — safe to call from the widget factory (bounded, tiny file). */
@@ -37,7 +45,20 @@ object RecentSessionsStore {
3745
3846 /** Persist [sessions] (capped to [MAX]); a no-op empty list writes an empty file. */
3947 suspend fun write(context: Context, sessions: List<RecentSession>) = withContext(Dispatchers.IO) {
40− runCatching { file(context).writeText(json.encodeToString(sessions.take(MAX))) }
41− Unit
48+ writeMutex.withLock {
49+ runCatching {
50+ val target = file(context)
51+ val encoded = json.encodeToString(sessions.take(MAX))
52+ val tmp = File(target.parentFile, "$FILE.tmp")
53+ tmp.writeText(encoded)
54+ // Atomic replace on POSIX filesystems (app's internal storage); the reader
55+ // never observes a torn file. Fall back to a direct write if rename fails.
56+ if (!tmp.renameTo(target)) {
57+ target.writeText(encoded)
58+ tmp.delete()
59+ }
60+ }
61+ Unit
62+ }
4263 }
4364}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+135−28
@@ -1,5 +1,6 @@
11package soy.iko.opencode.data.repo
22
3+import soy.iko.opencode.data.model.AssistantMessage
34import soy.iko.opencode.data.model.BusEvent
45import soy.iko.opencode.data.model.FilePromptPart
56import soy.iko.opencode.data.model.MessageInfo
@@ -20,6 +21,10 @@ import soy.iko.opencode.util.runCatchingCancellable
2021import soy.iko.opencode.util.safeExceptionSummary
2122import android.util.Log
2223import kotlinx.coroutines.Dispatchers
24+import kotlinx.coroutines.Job
25+import kotlinx.coroutines.NonCancellable
26+import kotlinx.coroutines.delay
27+import kotlinx.coroutines.withContext
2328import kotlinx.coroutines.channels.Channel
2429import kotlinx.coroutines.flow.Flow
2530import kotlinx.coroutines.flow.channelFlow
@@ -54,7 +59,10 @@ open class SessionRepository(
5459 attachments: List<FilePromptPart> = emptyList(),
5560 model: ModelRef?,
5661 agent: String? = null,
57− ) = api.sendPrompt(sessionId, text, attachments, model, agent)
62+ // Stable key for retriable/re-sendable messages (the offline outbox). See
63+ // [OpencodeApiClient.sendPrompt]. Null generates a fresh key per call.
64+ idempotencyKey: String? = null,
65+ ) = api.sendPrompt(sessionId, text, attachments, model, agent, idempotencyKey)
5866
5967 open suspend fun runCommand(
6068 sessionId: String,
@@ -98,6 +106,13 @@ open class SessionRepository(
98106 // initial load) clobbering a newer seed. Only mutated/read under [lock].
99107 var seedGeneration = 0
100108
109+ // Message ids introduced by the on-disk cache seed below. The initial REST seed uses
110+ // these to prune ghosts: a cache-seeded message absent from the authoritative REST
111+ // snapshot was deleted server-side while the app was closed. Restricting the prune to
112+ // cache-seeded ids means live SSE messages added since subscribe (not yet indexed by
113+ // REST) are never dropped. Written/read only in this collector coroutine.
114+ var cacheSeededIds: Set<String> = emptySet()
115+
101116 // Seed from the on-disk cache first so the conversation renders instantly — and stays
102117 // readable offline — before the network responds. Guarded on generation 0 so a
103118 // reconnect re-seed that somehow raced ahead isn't clobbered; the initial REST seed
@@ -105,7 +120,12 @@ open class SessionRepository(
105120 messageCache?.let { cache ->
106121 val cached = cache.load(sessionId)
107122 if (cached.isNotEmpty()) {
108− val changed = lock.withLock { if (seedGeneration == 0) store.seed(cached) else false }
123+ val changed = lock.withLock {
124+ if (seedGeneration == 0) {
125+ cacheSeededIds = cached.mapTo(mutableSetOf()) { it.info.id }
126+ store.seed(cached)
127+ } else false
128+ }
109129 if (changed) publish()
110130 }
111131 }
@@ -159,22 +179,33 @@ open class SessionRepository(
159179 }
160180 }
161181
162− val initialResult = runCatchingCancellable { api.listMessages(sessionId) }
163− .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on cache/SSE: ${safeExceptionSummary(it)}") }
164− // Apply the initial seed only if no reconnect re-seed has run yet (generation still 0).
165− // A re-seed is always a fresher full snapshot, so if the (possibly slow) initial fetch
166− // returns after one has applied, seeding this older REST data would clobber the newer
167− // state — reverting a completed run to "running", regressing cost/token/part state —
168− // until the next SSE event or reconnect healed it. Only seed on SUCCESS: on failure
169− // (e.g. offline) keep whatever's shown (the cache seed above) rather than clearing it.
170− val seeded = lock.withLock {
171− val list = initialResult.getOrNull()
172− if (seedGeneration == 0 && list != null) {
173− store.seed(list)
174− true
175− } else false
182+ // Load the authoritative message list concurrently with the drain loop below so the
183+ // cache seed and any early SSE-reduced parts are emitted immediately. Running it inline
184+ // here (before the drain starts) would withhold the already-reduced cached conversation
185+ // until this fetch resolved — a blank screen for the whole withRetry backoff when offline.
186+ launch {
187+ val initialResult = runCatchingCancellable { api.listMessages(sessionId) }
188+ .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on cache/SSE: ${safeExceptionSummary(it)}") }
189+ // Apply the initial seed only if no reconnect re-seed has run yet (generation still 0).
190+ // A re-seed is always a fresher full snapshot, so if the (possibly slow) initial fetch
191+ // returns after one has applied, seeding this older REST data would clobber the newer
192+ // state — reverting a completed run to "running", regressing cost/token/part state —
193+ // until the next SSE event or reconnect healed it. Only seed on SUCCESS: on failure
194+ // (e.g. offline) keep whatever's shown (the cache seed above) rather than clearing it.
195+ val seeded = lock.withLock {
196+ val list = initialResult.getOrNull()
197+ if (seedGeneration == 0 && list != null) {
198+ // Prune ghosts first: cache-seeded messages absent from this authoritative
199+ // REST snapshot were deleted server-side while the app was closed. Without
200+ // this they'd survive restart forever (re-seeded from cache, never pruned by
201+ // the reconnect path, and re-persisted by the drain loop).
202+ val pruned = store.pruneStaleCacheSeeded(cacheSeededIds, list)
203+ val changed = store.seed(list)
204+ pruned || changed
205+ } else false
206+ }
207+ if (seeded) publish()
176208 }
177− if (seeded) publish()
178209
179210 // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
180211 // taken under the lock so a reader never sees a half-reduced state; send() runs
@@ -182,19 +213,61 @@ open class SessionRepository(
182213 // loop also keeps the flow alive until the collector cancels; the launched jobs
183214 // above are children of this scope and are torn down with it.
184215 var lastCacheWrite = 0L
185− dirty.consumeAsFlow().collect {
186− val snapshot = lock.withLock { store.snapshot() }
187− send(snapshot)
188− // Persist the snapshot to the on-disk cache, throttled so a fast token stream
189− // doesn't hammer the disk — the cache only needs to be "recent enough" for an
190− // instant/offline first paint; the network corrects it on the next open.
191− if (messageCache != null && snapshot.isNotEmpty()) {
192− val now = System.currentTimeMillis()
193− if (now - lastCacheWrite >= NetworkConfig.messageCacheWriteThrottleMs) {
194− lastCacheWrite = now
195− launch { messageCache.save(sessionId, snapshot) }
216+ var pendingCacheSave: Job? = null
217+ // Most recent published snapshot, and the most recent one whose on-disk write has
218+ // actually completed. The teardown flush in the finally below persists a pending
219+ // trailing snapshot exactly once. savedSnapshot is assigned from inside the launched
220+ // saves *after* messageCache.save() returns, so a save cancelled mid-IO by teardown is
221+ // NOT marked saved and the finally re-persists it under NonCancellable. It's written
222+ // from the save coroutines and read from the collector/finally; the only possible race
223+ // is a stale read causing one redundant, idempotent re-save (atomic temp-file+rename
224+ // makes a duplicate write of identical data harmless) — never data loss.
225+ var latestSnapshot: List<MessageWithParts>? = null
226+ var savedSnapshot: List<MessageWithParts>? = null
227+ try {
228+ dirty.consumeAsFlow().collect {
229+ val snapshot = lock.withLock { store.snapshot() }
230+ send(snapshot)
231+ // Persist the snapshot to the on-disk cache, throttled so a fast token stream
232+ // doesn't hammer the disk — the cache only needs to be "recent enough" for an
233+ // instant/offline first paint; the network corrects it on the next open.
234+ if (messageCache != null && snapshot.isNotEmpty()) {
235+ latestSnapshot = snapshot
236+ val now = System.currentTimeMillis()
237+ pendingCacheSave?.cancel()
238+ if (now - lastCacheWrite >= NetworkConfig.messageCacheWriteThrottleMs) {
239+ lastCacheWrite = now
240+ pendingCacheSave = launch {
241+ messageCache.save(sessionId, snapshot)
242+ savedSnapshot = snapshot
243+ }
244+ } else {
245+ // Trailing-edge write: a burst that ends inside the throttle window (a run's
246+ // final tokens right after a write) would otherwise never persist its last
247+ // snapshot, so an offline reopen would show the conversation missing its tail.
248+ // Write the latest snapshot once the window elapses; a newer snapshot cancels
249+ // and replaces this, so continuous streaming still coalesces to one write/window.
250+ pendingCacheSave = launch {
251+ delay(NetworkConfig.messageCacheWriteThrottleMs)
252+ messageCache.save(sessionId, snapshot)
253+ savedSnapshot = snapshot
254+ }
255+ }
196256 }
197257 }
258+ } finally {
259+ // FIX: both cache-saves above run as children of this inner flow scope, so when
260+ // ChatViewModel's flatMapLatest switches (an SSE reconnect changing activeConnection)
261+ // or onCleared cancels this flow, a pending trailing save is torn down before its delay
262+ // elapses — a run's final tokens then never reach the on-disk cache. Flush the latest
263+ // un-persisted snapshot here under NonCancellable so it survives the cancellation.
264+ // savedSnapshot tracks the snapshot an immediate write already handled, so the common
265+ // case (nothing pending) writes nothing extra — at most one write per teardown, never
266+ // one per emission.
267+ val pending = latestSnapshot
268+ if (messageCache != null && pending != null && pending !== savedSnapshot) {
269+ withContext(NonCancellable) { messageCache.save(sessionId, pending) }
270+ }
198271 }
199272 // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
200273 // snapshot copy — on Dispatchers.Default rather than the collector's context. The
@@ -213,6 +286,23 @@ open class SessionRepository(
213286
214287 fun isError(event: BusEvent, sessionId: String): Boolean =
215288 event is SessionError && event.properties.sessionID == sessionId
289+
290+ /** Does this event indicate an in-progress run for [sessionId]? A live streamed part or
291+ * an assistant message that isn't yet complete/errored means the agent is still working.
292+ * Used to restore the "running" indicator after an SSE reconnect: if the run is still
293+ * going, such live events keep arriving; a run that finished during the outage produces
294+ * none (its completion arrives via the REST re-seed, not the event stream). */
295+ fun isRunActivity(event: BusEvent, sessionId: String): Boolean = when (event) {
296+ // The session id rides on the part for message.part.updated; properties.sessionID
297+ // is null on the wire. Mirror reduce()'s `part.sessionID ?: properties.sessionID`
298+ // so a run that's only streaming parts still re-lights the indicator on reconnect.
299+ is MessagePartUpdated -> (event.properties.part.sessionID ?: event.properties.sessionID) == sessionId
300+ is MessageUpdated -> {
301+ val info = event.properties.info
302+ info.sessionID == sessionId && info is AssistantMessage && !info.isComplete && info.error == null
303+ }
304+ else -> false
305+ }
216306 }
217307}
218308
@@ -347,6 +437,23 @@ internal class MessageStore {
347437 return changed
348438 }
349439
440+ /** Remove messages that were seeded from the on-disk cache ([cacheSeededIds]) but are
441+ * absent from the authoritative REST snapshot [rest] — i.e. deleted server-side while the
442+ * app was closed. Restricted to cache-seeded ids so a message added by a live SSE event
443+ * since subscribe (which the REST endpoint may not have indexed yet) is never pruned.
444+ * This is what actually removes ghosts on restart; the reconnect re-seed deliberately
445+ * keeps prune=false to avoid racing just-arrived SSE. Call under the same lock as [seed].
446+ * Returns true if anything was removed. */
447+ fun pruneStaleCacheSeeded(cacheSeededIds: Set<String>, rest: List<MessageWithParts>): Boolean {
448+ if (cacheSeededIds.isEmpty()) return false
449+ val restIds = rest.mapTo(mutableSetOf()) { it.info.id }
450+ var changed = false
451+ for (id in cacheSeededIds) {
452+ if (id !in restIds && messages.remove(id) != null) changed = true
453+ }
454+ return changed
455+ }
456+
350457 /** Reorder the message map by message creation time (stable). Ties and entries with no
351458 * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
352459 * relative order, so such a message stays last. Preserves each entry's map key so the
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+209−45
@@ -24,16 +24,19 @@ import soy.iko.opencode.data.repo.BackupManager
2424import soy.iko.opencode.data.repo.DraftStore
2525import soy.iko.opencode.data.repo.ErrorKind
2626import soy.iko.opencode.data.repo.MessageCacheStore
27+import soy.iko.opencode.data.repo.OutboxMessage
2728import soy.iko.opencode.data.repo.OutboxStore
2829import soy.iko.opencode.data.repo.SessionPrefsStore
2930import soy.iko.opencode.data.repo.ProfileStore
3031import soy.iko.opencode.data.repo.SettingsStore
3132import soy.iko.opencode.data.repo.classifyError
3233import soy.iko.opencode.data.repo.responseStatusCode
34+import soy.iko.opencode.data.network.EventStreamClient
3335import soy.iko.opencode.data.network.HttpClientFactory
3436import soy.iko.opencode.data.network.NetworkConfig
3537import soy.iko.opencode.data.network.OpencodeApiClient
3638import soy.iko.opencode.notification.NotificationChannels
39+import soy.iko.opencode.notification.RunForegroundService
3740import soy.iko.opencode.util.safeExceptionSummary
3841import soy.iko.opencode.notification.SessionNotifications
3942import soy.iko.opencode.R
@@ -47,7 +50,10 @@ import kotlinx.coroutines.delay
4750import kotlinx.coroutines.flow.MutableStateFlow
4851import kotlinx.coroutines.flow.StateFlow
4952import kotlinx.coroutines.flow.asStateFlow
53+import kotlinx.coroutines.flow.collectLatest
5054import kotlinx.coroutines.flow.combine
55+import kotlinx.coroutines.flow.debounce
56+import kotlinx.coroutines.flow.distinctUntilChanged
5157import kotlinx.coroutines.flow.emptyFlow
5258import kotlinx.coroutines.flow.first
5359import kotlinx.coroutines.Job
@@ -215,6 +221,7 @@ open class AppContainer private constructor(
215221 appContext?.let {
216222 SessionNotifications.cancel(it, id)
217223 SessionNotifications.cancelPermission(it, id)
224+ SessionNotifications.cancelError(it, id)
218225 }
219226 }
220227 }
@@ -262,13 +269,62 @@ open class AppContainer private constructor(
262269 ErrorKind.UNKNOWN -> string(R.string.error_generic)
263270 }
264271
265− init {
266− if (!skipInit) {
267− NotificationChannels.create(appContext!!)
268− observeMessageActivity()
269− appScope.launch { runCatchingCancellable { outboxStore.load() } }
270− observeOutbox()
271− appScope.launch { autoConnect() }
272+ /**
273+ * Drive the [RunForegroundService] from the process-lived [appScope] rather than from the
274+ * Activity's composition. Previously the start/stop was wired to a
275+ * `collectAsStateWithLifecycle` collector in the root composable, which pauses while the
276+ * Activity is STOPPED — so a run *started while backgrounded* (e.g. via the notification
277+ * inline-reply) never acquired foreground priority and its long-lived SSE stream could be
278+ * killed by Doze, and a run that *ended* while backgrounded left the "working…"
279+ * notification lingering until the app was reopened. Collecting here reacts to
280+ * [anyRunActive] regardless of Activity state.
281+ */
282+ private fun observeRunForegroundService() {
283+ val ctx = appContext ?: return
284+ appScope.launch {
285+ // Debounce so a run that starts then fails/idles almost immediately doesn't
286+ // dispatch startForegroundService() then stopService() back-to-back — if the stop
287+ // wins that race before the service calls startForeground(), Android raises the
288+ // "did not then call startForeground()" crash ~5s later. Collapsing the window
289+ // emits only the settled state; distinctUntilChanged drops a no-op re-emit when a
290+ // brief true→false→true flurry settles back to the value already applied.
291+ anyRunActive
292+ .debounce(NetworkConfig.runForegroundDebounceMs)
293+ .distinctUntilChanged()
294+ .collect { active ->
295+ if (active) RunForegroundService.start(ctx) else RunForegroundService.stop(ctx)
296+ }
297+ }
298+ }
299+
300+ /**
301+ * Reconcile active-run tracking on an SSE *reconnect*. A run that completes during a stream
302+ * outage never delivers its `SessionIdle`, so its id would stay in [activeRuns] and pin
303+ * [anyRunActive] (and the foreground service + "working…" notification) on indefinitely. On a
304+ * reconnect we clear the tracking and let live streaming events re-populate it: a genuinely
305+ * in-progress run re-adds itself within moments via [isLiveRunActivity], while a run that
306+ * finished during the outage produces no further live events (its completion arrives only via
307+ * the REST re-seed), so it correctly stays cleared. The first connect of a connection is not a
308+ * reconnect; `connect()` already resets [activeRuns], so only subsequent Connected transitions
309+ * trigger the sweep.
310+ */
311+ private fun observeRunReconcileOnReconnect() {
312+ appScope.launch {
313+ activeConnection.collectLatest { conn ->
314+ if (conn == null) return@collectLatest
315+ var hasConnectedBefore = false
316+ conn.events.state.collect { state ->
317+ if (state == EventStreamClient.ConnectionState.Connected) {
318+ if (hasConnectedBefore) {
319+ synchronized(activeRuns) {
320+ activeRuns.clear()
321+ _anyRunActive.value = false
322+ }
323+ }
324+ hasConnectedBefore = true
325+ }
326+ }
327+ }
272328 }
273329 }
274330
@@ -277,9 +333,9 @@ open class AppContainer private constructor(
277333 * online (and on any manual [flushOutbox] nudge). Queued messages are only sent to the
278334 * server they were composed for (matched by profile id), so a reconnect to a different
279335 * server never misfires them against a session id that doesn't exist there. Uses a plain
280− * `collect` (not collectLatest) so an in-flight send is never cancelled mid-flight — a
281− * cancelled+retried POST could double-send since each attempt carries a fresh idempotency
282− * key.
336+ * `collect` (not collectLatest) so an in-flight send is never cancelled mid-flight and
337+ * send order is preserved; each message carries a stable idempotency key (its persistent
338+ * outbox id) so even a re-send across flushes is deduplicated server-side.
283339 */
284340 private fun observeOutbox() {
285341 appScope.launch {
@@ -299,7 +355,7 @@ open class AppContainer private constructor(
299355 _outboxSending.value = true
300356 try {
301357 for (msg in pending) {
302− val sent = runCatchingCancellable {
358+ val result = runCatchingCancellable {
303359 conn.repository.sendPrompt(
304360 msg.sessionId,
305361 msg.text,
@@ -308,14 +364,38 @@ open class AppContainer private constructor(
308364 },
309365 model = msg.model,
310366 agent = msg.agent,
367+ // Reuse the persistent outbox id as a stable idempotency key so a
368+ // later flush of a message whose earlier POST reached the server (but
369+ // whose response was lost) is deduplicated server-side instead of
370+ // starting a duplicate agent run.
371+ idempotencyKey = msg.id,
311372 )
312373 }.onFailure {
313374 Log.w("AppContainer", "Outbox flush failed for ${msg.sessionId}: ${safeExceptionSummary(it)}")
314− }.isSuccess
315− // Stop on the first failure — the connection likely dropped again; the
316− // remaining messages stay queued and retry on the next trigger, preserving
317− // send order.
318− if (sent) outboxStore.remove(msg.id) else break
375+ }
376+ if (result.isSuccess) {
377+ outboxStore.remove(msg.id)
378+ continue
379+ }
380+ // A permanently-undeliverable message (a non-408/429 4xx — e.g. the target
381+ // session was deleted server-side, yielding a 404) must be dropped, not left at
382+ // the head of the queue. The loop stops at the first failure to preserve send
383+ // order, so a poison message would otherwise block every later queued message
384+ // for this profile forever. Transient failures (offline again, 5xx, 408/429)
385+ // keep the queue intact and retry on the next trigger.
386+ val status = result.exceptionOrNull()?.let { responseStatusCode(it) }
387+ // Permanent = a non-408/429 4xx, matching withRetry's non-retryable rule.
388+ val permanent = when (status) {
389+ null, 408, 429 -> false
390+ in 400..499 -> true
391+ else -> false
392+ }
393+ if (permanent) {
394+ Log.w("AppContainer", "Dropping undeliverable outbox message ${msg.id} for ${msg.sessionId} (HTTP $status)")
395+ outboxStore.remove(msg.id)
396+ } else {
397+ break
398+ }
319399 }
320400 } finally {
321401 _outboxSending.value = false
@@ -451,15 +531,18 @@ open class AppContainer private constructor(
451531 if (isLiveRunActivity(event)) {
452532 // The add-under-lock is required both for thread-safety and for the
453533 // LRU access-order refresh (keeps a continuously-streaming session
454− // from being evicted), so it runs per event. Only the flag write is
455− // skippable once it's already set.
534+ // from being evicted), so it runs per event.
456535 synchronized(activeRuns) {
457536 if (activeRuns.size >= activeRunsLimit) {
458537 activeRuns.remove(activeRuns.iterator().next())
459538 }
460539 activeRuns.add(sid)
540+ // Derive the flag from the set state *inside* the same lock that
541+ // guards the set, so a concurrent reconnect sweep (which clears
542+ // both under this lock) can't interleave and leave the flag pinned
543+ // true with an empty set — a stuck foreground service + "working…".
544+ _anyRunActive.value = activeRuns.isNotEmpty()
461545 }
462− if (!_anyRunActive.value) _anyRunActive.value = true
463546 }
464547 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
465548 if (!isActive) break
@@ -486,8 +569,15 @@ open class AppContainer private constructor(
486569 return true
487570 }
488571
489− /** Session ids currently streaming an assistant run (best-effort, in-process). */
490− private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
572+ /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
573+ * an access-order [java.util.LinkedHashMap] so re-adding an already-present id on each
574+ * streaming event refreshes its recency (a plain LinkedHashSet.add is a no-op that does
575+ * NOT reorder). This keeps a long, continuously-streaming session from being evicted as
576+ * the eldest once [activeRunsLimit] other sessions appear — which would drop its
577+ * completion notification when its SessionIdle finally arrives. */
578+ private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(
579+ java.util.Collections.newSetFromMap(java.util.LinkedHashMap<String, Boolean>(16, 0.75f, true)),
580+ )
491581
492582 /** Per non-viewed session, the set of message ids already reflected in its unread
493583 * count. De-duplicates the many streaming events that share one message id so the
@@ -533,29 +623,63 @@ open class AppContainer private constructor(
533623
534624 /** Respond to a permission from a notification action (Allow once / Always / Reject),
535625 * off any UI. Runs on the process-lived app scope; [onDone] fires when the call resolves
536− * so the receiver's goAsync() result can finish. No-ops if the connection is gone. */
626+ * so the receiver's goAsync() result can finish. Its Boolean is true only when a live
627+ * connection existed AND respondPermission succeeded — the receiver uses it to decide
628+ * whether to dismiss the notification. A missing connection makes the respond a no-op,
629+ * so it must report failure (not success) rather than let the notification vanish with
630+ * the tool left unanswered. */
537631 open fun respondToPermissionFromNotification(
538632 sessionId: String,
539633 permissionId: String,
540634 response: PermissionResponse,
541− onDone: () -> Unit,
635+ onDone: (Boolean) -> Unit,
542636 ) {
543637 appScope.launch {
544− runCatchingCancellable {
545− activeConnection.value?.api?.respondPermission(sessionId, permissionId, response)
546− }.onFailure { Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}") }
547− onDone()
638+ val success = runCatchingCancellable {
639+ val api = activeConnection.value?.api ?: return@runCatchingCancellable false
640+ api.respondPermission(sessionId, permissionId, response)
641+ true
642+ }.onFailure {
643+ Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
644+ }.getOrDefault(false)
645+ onDone(success)
548646 }
549647 }
550648
551649 /** Send a follow-up prompt from a notification's inline reply, off any UI. Uses the
552− * server's default model/agent. [onDone] fires when the send resolves. */
553− open fun sendPromptFromNotification(sessionId: String, text: String, onDone: () -> Unit) {
650+ * server's default model/agent. Routes through the durable [outboxStore] — the same path
651+ * the in-app composer uses ([soy.iko.opencode.ui.chat.ChatViewModel.enqueueOffline]) —
652+ * rather than a direct repository send, so a reply composed while disconnected survives to
653+ * be flushed on reconnect instead of being silently dropped. [flushOutbox] nudges an
654+ * immediate send when a connection is live, so an online reply still goes out now. [onDone]
655+ * reports whether the reply was durably enqueued so the receiver only dismisses the
656+ * notification on success. */
657+ open fun sendPromptFromNotification(sessionId: String, text: String, onDone: (Boolean) -> Unit) {
554658 appScope.launch {
555− runCatchingCancellable {
556− activeConnection.value?.repository?.sendPrompt(sessionId, text, model = null)
557− }.onFailure { Log.w("AppContainer", "notif reply send failed: ${safeExceptionSummary(it)}") }
558− onDone()
659+ // Attribute to the active (or most-recent) profile so a reconnect to a different
660+ // server doesn't misfire this against a session id that only exists elsewhere.
661+ val profileId = activeConnection.value?.profile?.id
662+ ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
663+ val enqueued = if (profileId == null) {
664+ false
665+ } else {
666+ runCatchingCancellable {
667+ outboxStore.enqueue(
668+ OutboxMessage(
669+ id = java.util.UUID.randomUUID().toString(),
670+ profileId = profileId,
671+ sessionId = sessionId,
672+ text = text,
673+ model = null,
674+ createdAt = System.currentTimeMillis(),
675+ ),
676+ )
677+ }.onFailure {
678+ Log.w("AppContainer", "notif reply enqueue failed: ${safeExceptionSummary(it)}")
679+ }.isSuccess
680+ }
681+ if (enqueued) flushOutbox()
682+ onDone(enqueued)
559683 }
560684 }
561685
@@ -571,12 +695,22 @@ open class AppContainer private constructor(
571695 .getOrDefault(emptyList())
572696 .firstOrNull() ?: return
573697 _reconnecting.value = true
698+ var conn: OpencodeConnection? = null
574699 val ok = runCatchingCancellable {
575− val conn = connect(recent)
576− conn.api.ping()
700+ conn = connect(recent)
701+ conn!!.api.ping()
577702 }.isSuccess
578703 _reconnecting.value = false
579− if (ok) _autoConnectDone.value = true else disconnect()
704+ if (ok) {
705+ _autoConnectDone.value = true
706+ } else {
707+ val created = conn
708+ // Only tear down the connection we created: a manual connect to a different server
709+ // during the ping window may have already replaced it, and disconnect() closes
710+ // whatever is currently active — which would silently drop that new connection.
711+ // disconnectIf re-checks identity under the mutex to close the check-then-close race.
712+ if (created != null) disconnectIf(created)
713+ }
580714 }
581715
582716 /**
@@ -631,19 +765,33 @@ open class AppContainer private constructor(
631765 // Previously this was fire-and-forget (launch on appScope), which raced with
632766 // a subsequent connect(): connect() could acquire the mutex, create a new
633767 // connection, and then the pending disconnect() would close it.
768+ connectionMutex.withLock { teardownActiveLocked() }
769+ }
770+
771+ /** Tear down [expected] only if it is still the active connection, with the identity
772+ * check performed *under* the mutex. A caller that checks `activeConnection.value === x`
773+ * itself and then calls [disconnect] has a TOCTOU race: a concurrent connect() to a
774+ * different server can replace the active connection in the gap, and [disconnect] would
775+ * then close that new connection. autoConnect()'s failed-ping teardown uses this. */
776+ private suspend fun disconnectIf(expected: OpencodeConnection) {
634777 connectionMutex.withLock {
635− _activeConnection.value?.close()
636− _activeConnection.value = null
637− // Reset run/unread state on disconnect too. With no SSE stream there's no
638− // SessionIdle to drain activeRuns, so without this the working indicator
639− // (anyRunActive) and unread badges would stay pinned until the next connect().
640− activeRuns.clear()
641− _anyRunActive.value = false
642− _unread.value = emptyMap()
643− unreadMessageIds.clear()
778+ if (_activeConnection.value === expected) teardownActiveLocked()
644779 }
645780 }
646781
782+ /** Close and reset the active connection. Caller MUST hold [connectionMutex]. */
783+ private suspend fun teardownActiveLocked() {
784+ _activeConnection.value?.close()
785+ _activeConnection.value = null
786+ // 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
788+ // (anyRunActive) and unread badges would stay pinned until the next connect().
789+ activeRuns.clear()
790+ _anyRunActive.value = false
791+ _unread.value = emptyMap()
792+ unreadMessageIds.clear()
793+ }
794+
647795 /** Deferred profile deletions keyed by id, run on the process-lived [appScope] (not a
648796 * ViewModel scope) so navigating away during the undo window still commits the delete
649797 * instead of silently cancelling it — mirroring [CrashLogger.scheduleDelete]. */
@@ -815,6 +963,22 @@ open class AppContainer private constructor(
815963 runCatching { client.close() }
816964 }
817965 }
966+
967+ // Declared last so every property above is fully initialized before these observers start.
968+ // They only launch coroutines into appScope (Dispatchers.IO) — nothing here runs
969+ // synchronously during construction — so placing init at the end removes any dependence on
970+ // the textual declaration order of the fields those observers reference.
971+ init {
972+ if (!skipInit) {
973+ NotificationChannels.create(appContext!!)
974+ observeMessageActivity()
975+ observeRunReconcileOnReconnect()
976+ observeRunForegroundService()
977+ appScope.launch { runCatchingCancellable { outboxStore.load() } }
978+ observeOutbox()
979+ appScope.launch { autoConnect() }
980+ }
981+ }
818982}
819983
820984/**
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+11−8
@@ -31,11 +31,13 @@ class NotificationActionReceiver : BroadcastReceiver() {
3131 val response = PermissionResponse.entries.firstOrNull {
3232 it.wire == intent.getStringExtra(EXTRA_RESPONSE)
3333 } ?: return
34− // Dismiss the notification immediately for responsive feedback; the reply
35− // itself continues in the background.
36− SessionNotifications.cancelPermission(context, sessionId)
3734 val pending = goAsync()
38− container.respondToPermissionFromNotification(sessionId, permissionId, response) {
35+ container.respondToPermissionFromNotification(sessionId, permissionId, response) { success ->
36+ // Only dismiss the notification once the tool was actually answered. If
37+ // there was no live connection (respond is a no-op) or the call failed,
38+ // leave it up so the user can retry — otherwise it vanishes with the
39+ // permission request left unanswered.
40+ if (success) SessionNotifications.cancelPermission(context, sessionId)
3941 pending.finish()
4042 }
4143 }
@@ -44,10 +46,11 @@ class NotificationActionReceiver : BroadcastReceiver() {
4446 ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
4547 ?.takeIf { it.isNotEmpty() } ?: return
4648 val pending = goAsync()
47− container.sendPromptFromNotification(sessionId, text) {
48− // The follow-up is on its way; clear the "session ready" notification.
49− // A fresh run (and its foreground indicator) now signals progress.
50− SessionNotifications.cancel(context, sessionId)
49+ container.sendPromptFromNotification(sessionId, text) { enqueued ->
50+ // The reply is durably queued (and flushed now if online); clear the
51+ // "session ready" notification. If the enqueue failed, leave it up so the
52+ // reply isn't silently lost and the user can retry.
53+ if (enqueued) SessionNotifications.cancel(context, sessionId)
5154 pending.finish()
5255 }
5356 }
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+15
@@ -75,6 +75,21 @@ class RunForegroundService : Service() {
7575 .build()
7676 }
7777
78+ // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
79+ // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
80+ // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
81+ // continues without foreground priority. Both overloads are covered (the 2-arg form is
82+ // API 35+).
83+ override fun onTimeout(startId: Int) {
84+ Log.w(TAG, "Foreground service timed out; stopping")
85+ stopSelf()
86+ }
87+
88+ override fun onTimeout(startId: Int, fgsType: Int) {
89+ Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
90+ stopSelf()
91+ }
92+
7893 override fun onDestroy() {
7994 // Safety net: if the process is being killed while a run is in progress,
8095 // ensure the notification is removed rather than lingering.
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+7
@@ -212,4 +212,11 @@ object SessionNotifications {
212212 fun cancelPermission(context: Context, sessionId: String) {
213213 NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
214214 }
215+
216+ /** Cancel a session's error notification (e.g. when the user opens it). Without this a
217+ * stale "run failed" notification lingers when the session is opened by any route other
218+ * than tapping the notification itself (setAutoCancel only clears it on a direct tap). */
219+ fun cancelError(context: Context, sessionId: String) {
220+ NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
221+ }
215222}
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.kt+5−1
@@ -42,6 +42,10 @@ private class SessionsWidgetFactory(private val context: Context) : RemoteViewsS
4242
4343 override fun getLoadingView(): RemoteViews? = null
4444 override fun getViewTypeCount(): Int = 1
45− override fun getItemId(position: Int): Long = position.toLong()
45+ // FIX 17: derive a stable id from the session id (not the position) so prepending a new
46+ // session doesn't mis-recycle RemoteViews and show stale row content. Falls back to the
47+ // position for an out-of-range index.
48+ override fun getItemId(position: Int): Long =
49+ items.getOrNull(position)?.id?.hashCode()?.toLong() ?: position.toLong()
4650 override fun hasStableIds(): Boolean = true
4751}
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+26−13
@@ -22,7 +22,6 @@ import androidx.compose.material3.Surface
2222import androidx.compose.material3.Text
2323import androidx.compose.runtime.Composable
2424import androidx.compose.runtime.DisposableEffect
25−import androidx.compose.runtime.LaunchedEffect
2625import androidx.compose.runtime.getValue
2726import androidx.compose.runtime.mutableStateOf
2827import androidx.compose.runtime.remember
@@ -85,21 +84,18 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
8584 content()
8685 return
8786 }
87+ // Fail open if device authentication is no longer possible (e.g. the user enabled app-lock
88+ // while a credential was enrolled and later removed their PIN/biometric): authenticate()
89+ // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
90+ if (!canAuthenticateForAppLock(context)) {
91+ content()
92+ return
93+ }
8894
8995 // Survives config changes / process-death restore so a rotation doesn't re-prompt, but
9096 // defaults to locked on a fresh start.
9197 var unlocked by rememberSaveable { mutableStateOf(false) }
9298
93− // Re-lock when the app is backgrounded so returning to it requires authentication again.
94− val lifecycleOwner = LocalLifecycleOwner.current
95− DisposableEffect(lifecycleOwner) {
96− val observer = LifecycleEventObserver { _, event ->
97− if (event == Lifecycle.Event.ON_STOP) unlocked = false
98− }
99− lifecycleOwner.lifecycle.addObserver(observer)
100− onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
101− }
102−
10399 val title = stringResource(R.string.app_lock_prompt_title)
104100 val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
105101
@@ -136,8 +132,25 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
136132 .onFailure { authInFlight = false }
137133 }
138134
139− // Auto-prompt on first show and whenever we return to the locked state.
140− LaunchedEffect(unlocked) { if (!unlocked) authenticate() }
135+ // Re-lock when the app is backgrounded, and re-prompt when it returns to the foreground.
136+ // The re-prompt fires on ON_START rather than by keying an effect on `unlocked`: ON_STOP
137+ // already cleared `unlocked` while backgrounded, so an effect keyed on it would see no
138+ // change on return and never re-fire, stranding the user on the manual "Unlock" button.
139+ // addObserver also delivers the current state's ON_START to a freshly-added observer, so
140+ // this covers the initial prompt on first show too; the authInFlight/unlocked guards in
141+ // authenticate() make the overlapping triggers safe.
142+ val lifecycleOwner = LocalLifecycleOwner.current
143+ DisposableEffect(lifecycleOwner) {
144+ val observer = LifecycleEventObserver { _, event ->
145+ when (event) {
146+ Lifecycle.Event.ON_STOP -> unlocked = false
147+ Lifecycle.Event.ON_START -> if (!unlocked) authenticate()
148+ else -> {}
149+ }
150+ }
151+ lifecycleOwner.lifecycle.addObserver(observer)
152+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
153+ }
141154
142155 Box(modifier = Modifier.fillMaxSize()) {
143156 content()
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+21−26
@@ -54,50 +54,45 @@ fun OpencodeApp(container: AppContainer) {
5454 val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
5555 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
5656
57− // Hold a foreground priority for as long as ANY session is actively running — not just
58− // while a running chat is on screen — so a run started and then backgrounded (or left
59− // for the session list) still keeps the SSE stream alive to deliver its completion
60− // notification. Doze/app-standby would otherwise choke the socket once priority drops.
61− val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
62− val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
63− LaunchedEffect(anyRunActive) {
64− if (anyRunActive) {
65− soy.iko.opencode.notification.RunForegroundService.start(appContext)
66− } else {
67− soy.iko.opencode.notification.RunForegroundService.stop(appContext)
68− }
69− }
57+ // The foreground service that holds priority while any run is active is driven from the
58+ // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
59+ // from here — a composition-scoped collector pauses while backgrounded, which is exactly
60+ // when a run started via the notification reply needs the service to start.
7061
7162 // When text is shared into the app, surface the session list so the user can pick
7263 // (or create) a conversation to drop it into. The chosen session's draft is set
7364 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
7465 // before auto-reconnect completes is retried once the connection is established.
75− // Tracks the last connection id seen so a server switch (a non-null -> non-null
76− // change) doesn't re-bounce the user to the session list for a share they've
77− // already decided to abandon; only the null -> non-null initial connect retries.
78− var lastConnectionId by remember { mutableStateOf<String?>(null) }
66+ // Track the last-handled share payloads (there's no share nonce on the container) so a
67+ // *new* share arriving while already connected — a warm start where onNewIntent re-fires
68+ // setPendingShare under an unchanged connection id — still navigates, while a re-trigger
69+ // carrying no new share (a bare server switch, which routes through a null connection, or
70+ // a share the user already navigated for) leaves them where they are.
71+ var handledShareText by remember { mutableStateOf<String?>(null) }
72+ var handledSharedMedia by remember { mutableStateOf<List<String>>(emptyList()) }
7973 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
8074 if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
81− if (connection == null) { lastConnectionId = null; return@LaunchedEffect }
82− val currentId = connection?.profile?.id
83− // Only (re)navigate on the initial null -> non-null connect. A subsequent
84− // server switch while a share is pending (rare) leaves the user where they
85− // are instead of yanking them back to the session list.
86− if (lastConnectionId != null && lastConnectionId == currentId) return@LaunchedEffect
87− lastConnectionId = currentId
75+ if (connection == null) return@LaunchedEffect
76+ val newShare = (pendingShare != null && pendingShare != handledShareText) ||
77+ (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
78+ if (!newShare) return@LaunchedEffect
79+ handledShareText = pendingShare
80+ handledSharedMedia = pendingSharedMedia
8881 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
8982 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
9083 }
9184 }
9285
9386 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
94− // session and open it. Consumed once (compareAndSet) so a recomposition can't re-create.
87+ // session and open it. The pending flag is consumed (compareAndSet) only *after*
88+ // createSession succeeds, so a transient failure leaves the request pending to retry
89+ // (on the next connection change / trigger) instead of silently dropping the tap.
9590 LaunchedEffect(pendingNewSession, connection) {
9691 if (!pendingNewSession) return@LaunchedEffect
9792 val conn = connection ?: return@LaunchedEffect
98− if (!container.consumePendingNewSession()) return@LaunchedEffect
9993 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
10094 ?: return@LaunchedEffect
95+ if (!container.consumePendingNewSession()) return@LaunchedEffect
10196 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
10297 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
10398 }
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+7−1
@@ -172,7 +172,13 @@ fun AgentPickerSheet(
172172 }
173173 }
174174 }
175− items(filtered, key = { it.name }) { agent ->
175+ // distinctBy the name so a server that returns two agents with the same name
176+ // (e.g. a built-in colliding with a project-level one) can't crash the list
177+ // with LazyColumn's "Key was already used".
178+ // Namespace the agent keys so an agent literally named "__default" can't
179+ // collide with the sentinel key of the default item above (a duplicate key
180+ // crashes LazyColumn).
181+ items(filtered.distinctBy { it.name }, key = { "agent_" + it.name }) { agent ->
176182 val isSelected = agent.name == selected
177183 Column(
178184 modifier = Modifier
app/src/main/java/soy/iko/opencode/ui/chat/Attachments.kt+54−2
@@ -20,10 +20,18 @@ suspend fun Uri.toAttachmentResult(context: Context): AttachmentResult = withCon
2020 val resolver = context.contentResolver
2121 val mime = resolver.getType(this@toAttachmentResult) ?: "application/octet-stream"
2222 val name = displayName(context) ?: defaultName(mime)
23+ val max = NetworkConfig.maxAttachmentBytes
24+ // Cheap pre-check: if the provider reports a size, reject an oversize file before reading
25+ // a single byte into memory. The picker can hand back arbitrarily large files (e.g. a
26+ // multi-hundred-MB video), so reading first and checking after would OOM the app.
27+ val declaredSize = fileSize(context)
28+ if (declaredSize != null && declaredSize > max) return@withContext AttachmentResult.TooLarge
29+ // Read with a hard cap so a provider that under-reports or omits its size still can't OOM
30+ // us: stop as soon as we've read one byte past the cap, then reject.
2331 val bytes = runCatching {
24− resolver.openInputStream(this@toAttachmentResult)?.use { it.readBytes() }
32+ resolver.openInputStream(this@toAttachmentResult)?.use { it.readAtMost(max + 1) }
2533 }.getOrNull() ?: return@withContext AttachmentResult.Failed
26− if (bytes.size > NetworkConfig.maxAttachmentBytes) return@withContext AttachmentResult.TooLarge
34+ if (bytes.size > max) return@withContext AttachmentResult.TooLarge
2735 val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
2836 val dataUrl = "data:$mime;base64,$b64"
2937 AttachmentResult.Ok(
@@ -37,6 +45,50 @@ suspend fun Uri.toAttachmentResult(context: Context): AttachmentResult = withCon
3745 )
3846}
3947
48+/** The provider-reported byte size of the content [uri], or null if it isn't exposed. */
49+private fun Uri.fileSize(context: Context): Long? = runCatching {
50+ context.contentResolver.query(this, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
51+ val idx = cursor.getColumnIndex(OpenableColumns.SIZE)
52+ if (idx >= 0 && cursor.moveToFirst() && !cursor.isNull(idx)) cursor.getLong(idx) else null
53+ }
54+}.getOrNull()
55+
56+/** Read up to [limit] bytes from the stream without materializing the whole file, so an
57+ * oversize source can be rejected on a bounded amount of memory. Reaching [limit] bytes is
58+ * the caller's signal that the source exceeds the cap. */
59+private fun java.io.InputStream.readAtMost(limit: Long): ByteArray {
60+ val out = java.io.ByteArrayOutputStream()
61+ val chunk = ByteArray(8 * 1024)
62+ var total = 0L
63+ while (total < limit) {
64+ val want = minOf(chunk.size.toLong(), limit - total).toInt()
65+ val read = read(chunk, 0, want)
66+ if (read < 0) break
67+ out.write(chunk, 0, read)
68+ total += read
69+ }
70+ return out.toByteArray()
71+}
72+
73+/** The raw (pre-base64) byte size encoded in a `data:<mime>;base64,<payload>` URL, derived from
74+ * the payload length (len / 4 * 3, less any '=' padding) without allocating or decoding the
75+ * payload — important since the payload can be many MB. Returns 0 when the URL has no base64
76+ * payload. Lets the cumulative attachment-size cap use the same raw-byte measure as the per-file
77+ * [NetworkConfig.maxAttachmentBytes] check above (which counts raw `bytes.size`). */
78+internal fun base64DataUrlByteSize(dataUrl: String): Long {
79+ val marker = "base64,"
80+ val start = dataUrl.indexOf(marker)
81+ if (start < 0) return 0L
82+ val payloadLen = dataUrl.length - (start + marker.length)
83+ if (payloadLen <= 0) return 0L
84+ val padding = when {
85+ dataUrl.endsWith("==") -> 2
86+ dataUrl.endsWith("=") -> 1
87+ else -> 0
88+ }
89+ return payloadLen.toLong() / 4 * 3 - padding
90+}
91+
4092/** Resolve a human-readable display name for the content [uri], if the provider exposes one. */
4193private fun Uri.displayName(context: Context): String? = runCatching {
4294 context.contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+19−4
@@ -1207,7 +1207,11 @@ private fun ChatInputBar(
12071207 }
12081208 },
12091209 keyboardOptions = KeyboardOptions(
1210− imeAction = ImeAction.Send,
1210+ // With send-on-Enter off, expose the platform default action so the soft
1211+ // keyboard shows a newline key (this is a multi-line field) instead of a
1212+ // Send action — otherwise the setting has no effect on soft keyboards and
1213+ // there's no way to type a newline. Hardware Enter is handled separately.
1214+ imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
12111215 capitalization = KeyboardCapitalization.Sentences,
12121216 ),
12131217 keyboardActions = KeyboardActions(onSend = {
@@ -1515,8 +1519,7 @@ private sealed interface MessageListItem {
15151519 val key: Any
15161520 val contentType: Any
15171521
1518− data class Message(val message: MessageWithParts) : MessageListItem {
1519− override val key: Any get() = message.info.id
1522+ data class Message(val message: MessageWithParts, override val key: Any) : MessageListItem {
15201523 override val contentType: Any get() = message.info::class
15211524 }
15221525
@@ -1552,8 +1555,10 @@ private fun buildMessageListItems(
15521555 val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
15531556 val dateFmt = mediumDateFormat()
15541557 val result = ArrayList<MessageListItem>(messages.size + 4)
1558+ val seenKeys = HashSet<String>(messages.size * 2)
15551559 var lastDayKey: String? = null
15561560 var sepOrdinal = 0
1561+ var emptyIdOrdinal = 0
15571562 for (message in messages) {
15581563 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
15591564 val dayKey = ts?.let { dayKey(it) } ?: ""
@@ -1561,7 +1566,17 @@ private fun buildMessageListItems(
15611566 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
15621567 lastDayKey = dayKey
15631568 }
1564− result.add(MessageListItem.Message(message))
1569+ // Most messages have a unique non-empty id. An unrecognized-role message can carry an
1570+ // empty id — and the reducer intentionally keeps several such messages as distinct
1571+ // holders — so give each a stable synthetic key and defensively de-collide any remaining
1572+ // duplicate; otherwise LazyColumn throws on a duplicate key and the whole screen crashes.
1573+ var key = message.info.id.ifEmpty { "msg-empty-${emptyIdOrdinal++}" }
1574+ if (!seenKeys.add(key)) {
1575+ var suffix = 1
1576+ while (!seenKeys.add("$key#$suffix")) suffix++
1577+ key = "$key#$suffix"
1578+ }
1579+ result.add(MessageListItem.Message(message, key))
15651580 }
15661581 return result
15671582}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+93−24
@@ -161,27 +161,37 @@ class ChatViewModel(
161161 // A value flowed through: the stream recovered, so allow the next distinct
162162 // failure to surface a fresh snackbar again.
163163 loadErrorSnackbarShown = false
164+ // Clear the error on ANY successful emission, including an empty list. An empty
165+ // session that hit one transient error would otherwise stay latched on the
166+ // "Failed to load / Retry" screen forever, because the later successful EMPTY
167+ // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
168+ // (an empty session has genuinely shown nothing yet).
169+ _loadError.value = false
164170 if (it.isNotEmpty()) {
165171 hasShownMessages = true
166− _loadError.value = false
167172 }
168173 }
169− .retryWhen { cause, _ ->
174+ .retryWhen { cause, attempt ->
170175 _loading.value = false
171176 // Only surface the persistent error state when there's nothing to
172177 // show — if we already have messages, a transient re-fetch failure
173178 // is better surfaced as a snackbar (via errorEvents) than by
174179 // replacing the visible conversation with an error screen.
175180 if (!hasShownMessages) _loadError.value = true
176− // Surface the snackbar only once per failure streak — retryWhen loops
177− // every retryInitialDelayMs, so emitting here unconditionally would spam a
178− // new snackbar every few seconds on a persistent load failure.
181+ // Surface the snackbar only once per failure streak — retryWhen loops on a
182+ // backoff, so emitting here unconditionally would spam a new snackbar every
183+ // retry on a persistent load failure.
179184 if (!loadErrorSnackbarShown) {
180185 _errorEvents.trySend(ChatError(container.friendlyError(cause)))
181186 loadErrorSnackbarShown = true
182187 }
183− // Delay before retrying to avoid a tight loop on persistent errors.
184− delay(NetworkConfig.retryInitialDelayMs)
188+ // Exponential backoff capped at retryMaxDelayMs so a persistently failing
189+ // server is retried with growing delays (retryInitialDelayMs, then doubling)
190+ // instead of hammered at a fixed interval forever. Clamp the shift so a long
191+ // failure streak can't overflow the Long delay.
192+ val backoffMs = (NetworkConfig.retryInitialDelayMs shl attempt.coerceAtMost(16L).toInt())
193+ .coerceAtMost(NetworkConfig.retryMaxDelayMs)
194+ delay(backoffMs)
185195 true
186196 }
187197 .stateIn(
@@ -263,6 +273,28 @@ class ChatViewModel(
263273 private val _pendingPermission = MutableStateFlow<Permission?>(null)
264274 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
265275
276+ // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
277+ // recent; when one is answered/replied — or a response fails — we fall back to the next so a
278+ // request that arrived while another was in flight isn't silently dropped (its tool run would
279+ // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
280+ // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
281+ private val pendingPermissions = LinkedHashMap<String, Permission>()
282+
283+ private fun enqueuePermission(permission: Permission) {
284+ pendingPermissions[permission.id] = permission
285+ _pendingPermission.value = pendingPermissions.values.lastOrNull()
286+ }
287+
288+ private fun resolvePermission(id: String) {
289+ pendingPermissions.remove(id)
290+ _pendingPermission.value = pendingPermissions.values.lastOrNull()
291+ }
292+
293+ private fun clearPermissions() {
294+ pendingPermissions.clear()
295+ _pendingPermission.value = null
296+ }
297+
266298 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
267299 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
268300
@@ -295,6 +327,11 @@ class ChatViewModel(
295327 private val _failedDraft = MutableStateFlow<String?>(null)
296328 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
297329
330+ /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
331+ * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
332+ * deduplicated server-side instead of starting a duplicate agent run. */
333+ @Volatile private var failedIdempotencyKey: String? = null
334+
298335 /** Per-session draft, persisted so it survives navigation/process death. */
299336 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
300337 val draft: StateFlow<String> = _draft.asStateFlow()
@@ -504,11 +541,11 @@ class ChatViewModel(
504541 // returns with no connection) until a later reconnect happens to clear it.
505542 _running.value = false
506543 _aborting.value = false
507− _pendingPermission.value = null
544+ clearPermissions()
508545 return@collectLatest
509546 }
510547 _running.value = false
511− _pendingPermission.value = null
548+ clearPermissions()
512549 _failedDraft.value = null
513550 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
514551 // user intent that is now persisted; wiping it on every (re)connect — which
@@ -540,11 +577,17 @@ class ChatViewModel(
540577 setQueuedFollowUp(null)
541578 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
542579 }
580+ // 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
582+ // agent is still working. Don't revive it once the user has hit Stop.
583+ if (SessionRepository.isRunActivity(event, sessionId) && !_aborting.value && !_running.value) {
584+ _running.value = true
585+ }
543586 when (event) {
544587 is PermissionUpdated ->
545− if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
588+ if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
546589 is PermissionReplied ->
547− if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
590+ if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
548591 is SessionUpdated ->
549592 if (event.properties.info.id == sessionId) {
550593 val info = event.properties.info
@@ -654,7 +697,7 @@ class ChatViewModel(
654697 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
655698 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
656699 */
657− private fun send(text: String, includeAttachments: Boolean): Boolean {
700+ private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean {
658701 val trimmed = text.trim()
659702 val attachments = if (includeAttachments) _attachments.value else emptyList()
660703 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
@@ -670,6 +713,10 @@ class ChatViewModel(
670713 }
671714 if (!_running.compareAndSet(false, true)) return false
672715 _failedDraft.value = null
716+ // 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
718+ // retry after a lost response is deduplicated server-side (no duplicate agent run).
719+ val key = idempotencyKey ?: java.util.UUID.randomUUID().toString()
673720 // Clear the staged attachments optimistically so the composer empties immediately;
674721 // restore them if the send fails (mirrors the draft handling below).
675722 if (attachments.isNotEmpty()) _attachments.value = emptyList()
@@ -694,10 +741,12 @@ class ChatViewModel(
694741 attachments = attachments.map { it.part },
695742 model = _selectedModel.value?.ref,
696743 agent = _selectedAgent.value,
744+ idempotencyKey = key,
697745 )
698746 }.onFailure {
699747 suppressDraftPersist.set(false)
700748 _failedDraft.value = trimmed
749+ failedIdempotencyKey = key
701750 // Only restore the draft if the user hasn't typed anything new since.
702751 if (_draft.value.isBlank()) updateDraft(trimmed)
703752 // Restore the staged attachments too, so a failed send doesn't lose them —
@@ -808,12 +857,16 @@ class ChatViewModel(
808857 /** Re-send the last draft whose send failed, if any. */
809858 fun retryFailed() {
810859 val draft = _failedDraft.value ?: return
860+ // Reuse the failed send's idempotency key so a retry after a lost response is
861+ // deduplicated server-side rather than starting a duplicate run.
862+ val key = failedIdempotencyKey
811863 // Don't clear _failedDraft until send() accepts the text — if _running is
812864 // already true, send() returns false and the draft would be lost forever.
813865 // Attachments aren't re-sent on retry (they were optimistically restored to the
814866 // composer on the original failure, so they'll ride the next manual send).
815− if (send(draft, includeAttachments = false)) {
867+ if (send(draft, includeAttachments = false, idempotencyKey = key)) {
816868 _failedDraft.value = null
869+ failedIdempotencyKey = null
817870 }
818871 }
819872
@@ -903,6 +956,17 @@ class ChatViewModel(
903956 }
904957
905958 fun addAttachment(attachment: PendingAttachment) {
959+ // Enforce the cumulative size cap across the whole staged set (the per-file size and the
960+ // count caps are checked upstream). The base64 data URLs are held in memory and
961+ // re-serialized on every add/remove and again when the request body is built, so an
962+ // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
963+ // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
964+ // per-file check, and includes attachments already staged (incl. restored ones).
965+ val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
966+ if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
967+ _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
968+ return
969+ }
906970 _attachments.update { it + attachment }
907971 persistAttachments()
908972 }
@@ -1143,16 +1207,15 @@ class ChatViewModel(
11431207
11441208 fun respondPermission(permission: Permission, response: PermissionResponse) {
11451209 val conn = connection ?: return
1146− // Clear optimistically; permission.replied will confirm. If the call fails we
1147− // restore the pending permission so the user can retry instead of being stuck
1148− // with a dismissed dialog and a paused tool run.
1149− _pendingPermission.value = null
1210+ // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1211+ // If the call fails we re-surface this request so the user can retry instead of being stuck
1212+ // with a dismissed dialog and a tool run that's still paused server-side.
1213+ resolvePermission(permission.id)
11501214 viewModelScope.launch {
11511215 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
11521216 .onFailure {
11531217 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1154− // Only restore if no new permission has arrived in the meantime.
1155− if (_pendingPermission.value == null) _pendingPermission.value = permission
1218+ enqueuePermission(permission)
11561219 }
11571220 }
11581221 }
@@ -1169,11 +1232,17 @@ class ChatViewModel(
11691232 // asynchronous apply() (not a synchronous commit) so the main thread isn't
11701233 // blocked on disk I/O — Android's SharedPreferences framework guarantees
11711234 // pending apply() writes are flushed before the process exits.
1172− // Always flush — including an empty draft. Persistence is otherwise debounced, so if
1173− // the user cleared the input and navigated back within the debounce window, the
1235+ // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
1236+ // so if the user cleared the input and navigated back within the debounce window, the
11741237 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1175− // previous non-empty draft. flushDraft() removes the key when the text is blank, so an
1176− // unconditional flush commits the clear instead of resurrecting the deleted text.
1177− container.draftStore.flushDraft(sessionId, _draft.value)
1238+ // previous non-empty draft. flushDraft() removes the key when the text is blank, so
1239+ // flushing commits the clear instead of resurrecting the deleted text.
1240+ //
1241+ // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
1242+ // persisting the clear so a failed send can restore the text. If the user taps Send then
1243+ // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
1244+ // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
1245+ // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
1246+ if (!suppressDraftPersist.get()) container.draftStore.flushDraft(sessionId, _draft.value)
11781247 }
11791248}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+3−1
@@ -130,7 +130,9 @@ fun CommandPickerSheet(
130130 )
131131 } else {
132132 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
133− items(filtered, key = { it.name }) { cmd ->
133+ // distinctBy the name so duplicate command names from the server can't
134+ // crash the list with LazyColumn's "Key was already used".
135+ items(filtered.distinctBy { it.name }, key = { it.name }) { cmd ->
134136 Column(
135137 modifier = Modifier
136138 .fillMaxWidth()
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+3−1
@@ -139,7 +139,9 @@ fun ModelPickerSheet(
139139 modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
140140 )
141141 }
142− items(opts, key = { it.providerID to it.modelID }) { option ->
142+ // distinctBy the (provider, model) pair so a duplicate entry from the
143+ // server can't crash the list with LazyColumn's "Key was already used".
144+ items(opts.distinctBy { it.providerID to it.modelID }, key = { it.providerID to it.modelID }) { option ->
143145 val isSelected = option.providerID == selected?.providerID &&
144146 option.modelID == selected?.modelID
145147 Column(
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+61−6
@@ -6,7 +6,7 @@ import android.os.Looper
66import android.speech.tts.TextToSpeech
77import android.speech.tts.UtteranceProgressListener
88import androidx.compose.runtime.Composable
9−import androidx.compose.runtime.DisposableEffect
9+import androidx.compose.runtime.RememberObserver
1010import androidx.compose.runtime.State
1111import androidx.compose.runtime.mutableStateOf
1212import androidx.compose.runtime.remember
@@ -15,13 +15,13 @@ import androidx.compose.ui.platform.LocalContext
1515/**
1616 * Remember a [TtsController] scoped to the current composition; it shuts the engine down
1717 * when the composition leaves (so the process isn't left holding a TextToSpeech instance).
18+ * The controller is a [RememberObserver], so an *abandoned* composition (one never committed)
19+ * also releases the engine — a plain DisposableEffect would leak it in that case.
1820 */
1921@Composable
2022fun rememberTtsController(): TtsController {
2123 val context = LocalContext.current
22− val controller = remember { TtsController(context.applicationContext) }
23− DisposableEffect(Unit) { onDispose { controller.shutdown() } }
24− return controller
24+ return remember { TtsController(context.applicationContext) }
2525}
2626
2727/**
@@ -33,10 +33,11 @@ fun rememberTtsController(): TtsController {
3333 * TTS is an optional convenience). Utterance-progress callbacks arrive on a binder thread,
3434 * so state is cleared on the main thread via [mainHandler].
3535 */
36−class TtsController(context: Context) {
36+class TtsController(context: Context) : RememberObserver {
3737
3838 private val mainHandler = Handler(Looper.getMainLooper())
3939 private var ready = false
40+ private var shutDown = false
4041
4142 private val _speakingId = mutableStateOf<String?>(null)
4243 val speakingId: State<String?> = _speakingId
@@ -62,18 +63,72 @@ class TtsController(context: Context) {
6263 fun toggle(id: String, text: String) {
6364 if (_speakingId.value == id) { stop(); return }
6465 if (!ready || text.isBlank()) return
65− tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, id)
66+ // TextToSpeech.speak() silently returns ERROR (enqueuing nothing, firing no callback)
67+ // when a single input exceeds getMaxSpeechInputLength(), which would leave the Stop
68+ // button stuck with no audio. Chunk long text and enqueue the parts back-to-back.
69+ val chunks = chunkForTts(text)
70+ if (chunks.isEmpty()) return
71+ chunks.forEachIndexed { index, chunk ->
72+ val queueMode = if (index == 0) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
73+ // Only the final chunk carries the tracked [id]; intermediate chunks get a
74+ // distinct id so their per-utterance onDone doesn't clear the Stop state early.
75+ val utteranceId = if (index == chunks.lastIndex) id else "$id#$index"
76+ val result = tts.speak(chunk, queueMode, null, utteranceId)
77+ // If even the first chunk can't be enqueued, nothing plays and no callback fires;
78+ // bail without marking this message as speaking so the button doesn't stick.
79+ if (index == 0 && result != TextToSpeech.SUCCESS) { _speakingId.value = null; return }
80+ }
6681 _speakingId.value = id
6782 }
6883
84+ /**
85+ * Split [text] into segments no longer than [TextToSpeech.getMaxSpeechInputLength] so
86+ * every part can actually be enqueued. Prefers sentence/paragraph then whitespace
87+ * boundaries, packing greedily, and hard-slices any single piece that still overflows.
88+ */
89+ private fun chunkForTts(text: String): List<String> {
90+ val max = TextToSpeech.getMaxSpeechInputLength().coerceAtLeast(1)
91+ if (text.length <= max) return listOf(text)
92+ val chunks = ArrayList<String>()
93+ val current = StringBuilder()
94+ fun flush() {
95+ if (current.isNotEmpty()) { chunks.add(current.toString()); current.setLength(0) }
96+ }
97+ for (piece in text.split(Regex("(?<=[.!?\\n])\\s+"))) {
98+ var p = piece
99+ // A single piece with no usable boundary can still exceed the limit: hard-slice it.
100+ while (p.length > max) {
101+ flush()
102+ chunks.add(p.substring(0, max))
103+ p = p.substring(max)
104+ }
105+ when {
106+ p.isEmpty() -> Unit
107+ current.isEmpty() -> current.append(p)
108+ current.length + 1 + p.length <= max -> current.append(' ').append(p)
109+ else -> { flush(); current.append(p) }
110+ }
111+ }
112+ flush()
113+ return chunks
114+ }
115+
69116 fun stop() {
70117 tts.stop()
71118 _speakingId.value = null
72119 }
73120
74121 fun shutdown() {
122+ if (shutDown) return
123+ shutDown = true
75124 tts.stop()
76125 tts.shutdown()
77126 _speakingId.value = null
78127 }
128+
129+ // RememberObserver: release the engine whenever this instance leaves the composition,
130+ // including an abandoned (never-committed) composition, which onForgotten doesn't cover.
131+ override fun onRemembered() { /* engine is created eagerly in the constructor */ }
132+ override fun onForgotten() = shutdown()
133+ override fun onAbandoned() = shutdown()
79134}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+1−1
@@ -290,5 +290,5 @@ private var lastToast: Toast? = null
290290
291291internal fun showToast(context: Context, message: String) {
292292 lastToast?.cancel()
293− lastToast = Toast.makeText(context, message, Toast.LENGTH_SHORT).also { it.show() }
293+ lastToast = Toast.makeText(context.applicationContext, message, Toast.LENGTH_SHORT).also { it.show() }
294294}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+62−14
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.heightIn
77import androidx.compose.material3.MaterialTheme
88import androidx.compose.runtime.Composable
99import androidx.compose.runtime.Immutable
10+import androidx.compose.runtime.produceState
1011import androidx.compose.runtime.remember
1112import androidx.compose.ui.Alignment
1213import androidx.compose.ui.Modifier
@@ -19,6 +20,8 @@ import androidx.compose.ui.semantics.semantics
1920import androidx.compose.ui.unit.dp
2021import coil.compose.SubcomposeAsyncImage
2122import coil.request.ImageRequest
23+import kotlinx.coroutines.Dispatchers
24+import kotlinx.coroutines.withContext
2225import androidx.compose.material3.CircularProgressIndicator
2326import androidx.compose.material3.Icon
2427import androidx.compose.material.icons.Icons
@@ -46,26 +49,55 @@ fun ServerProfile.toImageContext(): ImageLoadContext {
4649 } else {
4750 null
4851 }
49− return ImageLoadContext(baseUrl = baseUrl, basicAuthHeader = auth)
52+ // Resolve image URLs against the HTTPS-upgraded base URL, not the raw stored one, so a
53+ // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
54+ // header attached — over cleartext http even when the stored URL is http://. This mirrors
55+ // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
56+ return ImageLoadContext(baseUrl = effectiveImageBaseUrl(), basicAuthHeader = auth)
5057}
5158
59+/**
60+ * The base URL used to resolve image URLs, after applying the same http->https upgrade the
61+ * REST/SSE client uses (see HttpClientFactory.effectiveBaseUrl, the source of truth — that
62+ * helper and its pin parser are private there and this file may not edit HttpClientFactory, so
63+ * the logic is replicated here and must be kept in sync). A pin is meaningless over cleartext, so
64+ * a configured certificate pin forces HTTPS too, matching the REST channel.
65+ */
66+private fun ServerProfile.effectiveImageBaseUrl(): String {
67+ val normalized = HttpClientFactory.normalizeBaseUrl(baseUrl)
68+ val forceHttps = requireHttps || parseCertPins(certPin).isNotEmpty()
69+ return if (forceHttps && normalized.lowercase().startsWith("http://")) {
70+ "https://" + normalized.substring("http://".length)
71+ } else {
72+ normalized
73+ }
74+}
75+
76+/** Split a certificate-pin field into individual pins; mirrors HttpClientFactory.parsePins. */
77+private fun parseCertPins(raw: String?): List<String> =
78+ raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
79+
5280/** True if the part references a raster/vector image we can render. */
5381val FilePart.isImage: Boolean
5482 get() = mime?.startsWith("image/") == true
5583
84+private fun decodeDataUri(data: String): ByteArray? {
85+ val comma = data.indexOf(',')
86+ if (comma < 0) return null
87+ val payload = data.substring(comma + 1)
88+ return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
89+}
90+
5691/**
5792 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
5893 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
5994 */
6095private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
6196 val src = source
62− if (!src.isNullOrBlank() && src.startsWith("data:")) {
63− val comma = src.indexOf(',')
64− if (comma < 0) return null
65− val payload = src.substring(comma + 1)
66− return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
67− }
97+ if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
6898 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+ if (url.startsWith("data:")) return decodeDataUri(url)
69101 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
70102 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
71103 // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
@@ -78,13 +110,27 @@ private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
78110 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
79111 // sending the request (with its Basic auth) to another host. A server-absolute path
80112 // resolves to a different base *path* but the same origin, so it must still be allowed.
113+ // Compare *effective* ports (substituting the scheme default when unspecified) so an
114+ // absolute image URL that spells out the default port — "https://host:443/x.png" while
115+ // the base is "https://host/" — isn't wrongly rejected as cross-origin (URI.port is -1
116+ // when omitted, so a raw `==` would treat 443 and -1 as different).
81117 val sameOrigin = resolved.host != null &&
82118 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
83119 resolved.host.equals(baseUri.host, ignoreCase = true) &&
84− resolved.port == baseUri.port
120+ effectivePort(resolved) == effectivePort(baseUri)
85121 return if (sameOrigin) resolved.toString() else null
86122}
87123
124+/** The URI's port, or the scheme's default (80/443) when unspecified (`port == -1`). */
125+private fun effectivePort(uri: java.net.URI): Int {
126+ if (uri.port != -1) return uri.port
127+ return when (uri.scheme?.lowercase()) {
128+ "https" -> 443
129+ "http" -> 80
130+ else -> -1
131+ }
132+}
133+
88134/**
89135 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
90136 * and server-relative URLs (fetched with Basic auth when the server requires it).
@@ -93,15 +139,17 @@ private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
93139@Composable
94140fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
95141 val context = LocalContext.current
96− val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
97− val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
142+ val model = produceState<Any?>(initialValue = null, part.source, part.url, ctx.baseUrl) {
143+ value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
144+ }.value ?: return
145+ val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model) {
98146 ImageRequest.Builder(context)
99147 .data(model)
100148 .apply {
101− // Only send Basic auth over HTTPS; sending credentials over
102− // cleartext HTTP would expose them on the network.
103− val isHttps = (model as? String)?.startsWith("https://") == true
104− if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
149+ // resolveModel already verified same-origin, so a String model can only point at
150+ // the user's own server — attaching Basic auth over http (not just https) is safe.
151+ // A data-URI model is a ByteArray, so gate on the model being a URL String.
152+ if (model is String) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
105153 }
106154 .crossfade(true)
107155 .build()
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+3−1
@@ -243,7 +243,9 @@ private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
243243 return
244244 }
245245 LazyColumn(modifier = Modifier.fillMaxSize()) {
246− items(results, key = { it }) { path ->
246+ // Key on path + index (not the bare path): a duplicate path from findFiles would
247+ // otherwise crash LazyColumn with "Key already used". Mirrors TextResults/SymbolResults.
248+ itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
247249 // Split the path into directory (muted) + filename (emphasized) so results
248250 // are scannable instead of a wall of identical-looking full paths.
249251 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+8−17
@@ -187,22 +187,13 @@ fun FileViewScreen(
187187 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
188188 label = "fileViewState",
189189 ) { s ->
190− // Derive match indices from THIS layer's content (s), not the outer/newest
191− // state. During the crossfade the exiting layer still renders the old
192− // content; passing the outer matchIndices (computed from the new content)
193− // would highlight and scroll the wrong lines until the animation settles.
194− val sMatchIndices = remember(s.content?.content, debouncedFind, lines, rawText) {
195− val q = debouncedFind
196− if (q.isEmpty()) emptyList()
197− else {
198− // Reuse the already-split `lines` when this layer shows the current
199− // content (the common case); only re-split for the exiting layer's
200− // stale content during the crossfade.
201− val sContent = s.content?.content
202− val sLines = if (sContent == rawText) lines else sContent.orEmpty().split("\n")
203− sLines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
204− }
205− }
190+ // Single source of truth for matches: the off-thread `matchIndices` produceState
191+ // (keyed on the file content + debounced query). Both navigation (onPrev/onNext +
192+ // scroll) and display (counter + highlight) read from it, so the counter can't show
193+ // a new query while next/prev act on a stale set. The earlier in-composition
194+ // per-layer scan re-ran the whole-file O(n) match on the UI thread each keystroke
195+ // (the exact work produceState was moved off-thread to avoid); it's acceptable for
196+ // the crossfade's exiting layer to briefly use this unified set until it settles.
206197 FileViewStateContent(
207198 state = s,
208199 filename = filename,
@@ -219,7 +210,7 @@ fun FileViewScreen(
219210 findActive = findActive,
220211 findQuery = findQuery,
221212 onQueryChange = { findQuery = it; matchPos = 0 },
222− matchIndices = sMatchIndices,
213+ matchIndices = matchIndices,
223214 matchPos = matchPos,
224215 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
225216 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+8−1
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.mcp
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.Job
67import kotlinx.coroutines.flow.MutableStateFlow
78import kotlinx.coroutines.flow.StateFlow
89import kotlinx.coroutines.flow.asStateFlow
@@ -24,15 +25,21 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
2425 private val _state = MutableStateFlow<State>(State.Loading)
2526 val state: StateFlow<State> = _state.asStateFlow()
2627
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+
2732 init { load() }
2833
2934 fun load() {
3035 val conn = container.activeConnection.value ?: run {
36+ loadJob?.cancel()
3137 _state.value = State.Disconnected
3238 return
3339 }
3440 _state.value = State.Loading
35− viewModelScope.launch {
41+ loadJob?.cancel()
42+ loadJob = viewModelScope.launch {
3643 runCatchingCancellable {
3744 val config = conn.api.config()
3845 withContext(Dispatchers.Default) { parseMcpServers(config) }
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+3
@@ -59,6 +59,9 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
5959 _state.update { it.copy(searching = false, results = emptyList(), hasSearched = false, error = null) }
6060 return
6161 }
62+ // Show the spinner during the debounce (not just once runSearch starts) so the UI
63+ // doesn't briefly render an empty result set before the search begins.
64+ _state.update { it.copy(searching = true) }
6265 searchJob = viewModelScope.launch {
6366 delay(NetworkConfig.searchDebounceMs)
6467 runSearch(trimmed)
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+2−2
@@ -152,7 +152,7 @@ private fun ServerEditForm(
152152 // canSave parses the base URL with java.net.URI (which throws — and fills a stack trace —
153153 // on the common half-typed URL). Memoize on baseUrl so it runs once per edit instead of
154154 // being re-parsed at each of the enabled= call sites on every keystroke's recomposition.
155− val canSave = remember(state.baseUrl) { state.canSave }
155+ val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
156156 Column(
157157 modifier = Modifier
158158 .padding(padding)
@@ -303,7 +303,7 @@ private fun AuthFields(
303303 val hidePasswordLabel = stringResource(R.string.hide_password)
304304 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
305305 // every keystroke's recomposition (see ServerEditForm).
306− val canSave = remember(state.baseUrl) { state.canSave }
306+ val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
307307 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
308308 Text(
309309 stringResource(R.string.auth_required),
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+63−13
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.first
1414import kotlinx.coroutines.flow.update
1515import kotlinx.coroutines.launch
1616import kotlinx.coroutines.withTimeoutOrNull
17+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
1718import soy.iko.opencode.R
1819import soy.iko.opencode.util.runCatchingCancellable
1920import java.util.UUID
@@ -78,15 +79,22 @@ data class InitialProfile(
7879fun isValidCertPin(raw: String): Boolean {
7980 val entries = raw.split(Regex("[\\s,]+")).map { it.trim() }.filter { it.isNotEmpty() }
8081 if (entries.isEmpty()) return true
81− val pinRegex = Regex("sha256/[A-Za-z0-9+/=]+")
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+/]+=")
8286 return entries.all { it.matches(pinRegex) }
8387}
8488
85−fun isValidUrl(url: String): Boolean = try {
86− val u = java.net.URI(url.trim())
87− u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
88−} catch (_: Exception) {
89− false
89+fun isValidUrl(url: String): Boolean {
90+ val trimmed = url.trim()
91+ if (trimmed.isBlank()) return false
92+ // Parse with OkHttp's own parser (like HttpClientFactory) — java.net.URI rejects hosts
93+ // OkHttp accepts (e.g. underscores in "my_server.local") and demands an exact-lowercase
94+ // scheme ("HTTP://" fails). toHttpUrlOrNull only accepts http/https, so a successful parse
95+ // already implies a valid scheme; a bare host without a scheme returns null, keeping the
96+ // "suggest http:// prefix" flow in suggestUrlScheme() intact.
97+ return trimmed.toHttpUrlOrNull() != null
9098}
9199
92100/**
@@ -119,11 +127,15 @@ class ServerEditViewModel(
119127 .firstOrNull { it.id == profileId }
120128 }
121129 if (existing != null) {
130+ // FIX 21(a): normalize (trim) the dirty-snapshot fields the same way
131+ // save() stores them, so a stored value with surrounding whitespace
132+ // doesn't make the freshly-opened editor spuriously dirty (isDirty
133+ // compares field.trim() against these).
122134 val init = InitialProfile(
123− label = existing.label,
124− baseUrl = existing.baseUrl,
125− username = existing.username.orEmpty(),
126− password = existing.password.orEmpty(),
135+ label = existing.label.trim(),
136+ baseUrl = existing.baseUrl.trim(),
137+ username = existing.username.orEmpty().trim(),
138+ password = existing.password.orEmpty().trim(),
127139 requireHttps = existing.requireHttps,
128140 certPin = existing.certPin.orEmpty().trim(),
129141 )
@@ -207,10 +219,17 @@ class ServerEditViewModel(
207219 val s = _state.value
208220 if (!s.canSave || s.probing) return
209221 _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
222+ // FIX 21(b): probe the trimmed URL that save() would actually store.
223+ // FIX 11: remember which URL we probed so a stale result is dropped if the
224+ // user edited the base URL away while the probe was in flight.
225+ val probedUrl = s.baseUrl.trim()
210226 viewModelScope.launch {
211− val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
227+ val result = runCatchingCancellable { container.probeServer(probedUrl) }
212228 result.onSuccess { pr ->
213229 _state.update {
230+ // Stale result (URL edited away mid-probe): drop the reachability verdict but
231+ // still clear the spinner, else `probing` latches true and blocks re-probing.
232+ if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
214233 when (pr) {
215234 is ProbeResult.Reachable -> it.copy(
216235 probing = false,
@@ -233,7 +252,10 @@ class ServerEditViewModel(
233252 }
234253 }
235254 }.onFailure { e ->
236− _state.update { it.copy(probing = false, error = container.friendlyError(e), probeReachable = false) }
255+ _state.update {
256+ if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
257+ it.copy(probing = false, error = container.friendlyError(e), probeReachable = false)
258+ }
237259 }
238260 }
239261 }
@@ -249,10 +271,18 @@ class ServerEditViewModel(
249271 if (!s.canSave || s.testingCredentials) return
250272 if (s.username.isBlank() && s.password.isBlank()) return
251273 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
274+ // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
275+ // FIX 11: remember the probed URL so a stale result is dropped if the user
276+ // edited the base URL away while the test was in flight.
277+ val probedUrl = s.baseUrl.trim()
278+ val probedUser = s.username.trim()
279+ val probedPass = s.password.trim()
252280 viewModelScope.launch {
253− val result = runCatchingCancellable { container.probeWithCredentials(s.baseUrl, s.username, s.password) }
281+ val result = runCatchingCancellable { container.probeWithCredentials(probedUrl, probedUser, probedPass) }
254282 result.onSuccess { ok ->
255283 _state.update {
284+ // Stale result: clear the spinner but drop the verdict (see probe()).
285+ if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
256286 it.copy(
257287 testingCredentials = false,
258288 credentialsResult = ok,
@@ -261,6 +291,7 @@ class ServerEditViewModel(
261291 }
262292 }.onFailure { e ->
263293 _state.update {
294+ if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
264295 it.copy(
265296 testingCredentials = false,
266297 credentialsResult = false,
@@ -303,6 +334,25 @@ class ServerEditViewModel(
303334 certPin = s.certPin.trim().takeIf { it.isNotBlank() },
304335 )
305336 container.profileStore.save(saved)
337+ // FIX 3: persist the (possibly freshly-generated) id and the normalized
338+ // values back into state right after the save succeeds — before the
339+ // connect/ping that may throw. Otherwise a retry after a connect failure
340+ // would see id == null again, mint a new UUID and create a DUPLICATE
341+ // profile instead of upserting the same row. Also refresh the dirty
342+ // snapshot so the just-saved values no longer read as unsaved changes.
343+ _state.update {
344+ it.copy(
345+ id = saved.id,
346+ initial = InitialProfile(
347+ label = saved.label,
348+ baseUrl = saved.baseUrl,
349+ username = saved.username.orEmpty(),
350+ password = saved.password.orEmpty(),
351+ requireHttps = saved.requireHttps,
352+ certPin = saved.certPin.orEmpty(),
353+ ),
354+ )
355+ }
306356 if (connectAfter) {
307357 container.connect(saved)
308358 container.activeConnection.value?.api?.ping()
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+8−3
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
55import soy.iko.opencode.data.model.ServerProfile
66import soy.iko.opencode.data.network.NetworkConfig
77import soy.iko.opencode.di.AppContainer
8+import soy.iko.opencode.di.OpencodeConnection
89import soy.iko.opencode.R
910import soy.iko.opencode.util.runCatchingCancellable
1011import kotlinx.coroutines.channels.BufferOverflow
@@ -64,13 +65,17 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
6465 _connecting.value = profile.id
6566 viewModelScope.launch {
6667 try {
68+ var conn: OpencodeConnection? = null
6769 val result = runCatchingCancellable {
68− val conn = container.connect(profile)
69− conn.api.ping()
70+ conn = container.connect(profile)
71+ conn!!.api.ping()
7072 }
7173 result.onSuccess { onConnected() }
7274 .onFailure {
73− container.disconnect()
75+ // Only tear down the connection we created: a concurrent connect (or the
76+ // cold-start auto-connect) may have replaced the active one, and
77+ // disconnect() closes whatever is active — which would drop that one.
78+ if (conn != null && container.activeConnection.value === conn) container.disconnect()
7479 _errorEvents.tryEmit(ConnectError(container.friendlyError(it), profile))
7580 }
7681 } finally {
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+4−7
@@ -457,13 +457,10 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
457457 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
458458 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
459459 )
460− val sessions = remember(state.sessions, state.query, state.previews) {
461− // Fast-path an empty query: filtered just returns sessions, so skip
462− // the recompute (and the fresh list reference) when only previews
463− // churned in the background. With a non-empty query the previews
464− // map affects the match set, so recompute on any of the three.
465− if (state.query.isBlank()) state.sessions else state.filtered
466− }
460+ val sessions = remember(
461+ state.sessions, state.query, state.previews,
462+ state.pinnedIds, state.archivedIds, state.showArchived,
463+ ) { state.filtered }
467464 if (sessions.isEmpty()) {
468465 Box(
469466 modifier = Modifier.fillMaxSize(),
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+19−10
@@ -198,6 +198,21 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
198198 private val pendingSessionUpdates = java.util.concurrent.ConcurrentHashMap<String, Session>()
199199 private var sessionUpdateJob: Job? = null
200200
201+ // Declared ABOVE init: observeSessionEvents()'s activeConnection collector runs
202+ // synchronously during construction (viewModelScope uses Dispatchers.Main.immediate)
203+ // when a connection is already active, and its body touches directoryOptionsJob and
204+ // _directoryOptions. Declared after init, their backing fields would still be null at
205+ // that point, so constructing the VM against a live connection would NPE.
206+ /** The directory the user last created a session in (this VM lifetime), so the picker
207+ * can preselect it. Best-effort: not persisted across process death. */
208+ var lastChosenDirectory: String? = null
209+ private set
210+
211+ private val _directoryOptions = MutableStateFlow(DirectoryOptionsState())
212+ val directoryOptions: StateFlow<DirectoryOptionsState> = _directoryOptions.asStateFlow()
213+
214+ private var directoryOptionsJob: Job? = null
215+
201216 private val activeProfileId: String?
202217 get() = container.activeConnection.value?.profile?.id
203218
@@ -257,6 +272,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
257272 }
258273 sessionUpdateJob?.cancel()
259274 pendingSessionUpdates.clear()
275+ // Drop the previous server's directory-options load and result so a stale
276+ // fetch can't populate the new server's new-session picker.
277+ directoryOptionsJob?.cancel()
278+ _directoryOptions.value = DirectoryOptionsState()
260279 // Cancel any deferred deletes still pending from the previous connection.
261280 // Their captured connection is now closed, so they can only fail; letting
262281 // them run would fire onError and re-inject a session belonging to the old
@@ -508,16 +527,6 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
508527 }
509528 }
510529
511− /** The directory the user last created a session in (this VM lifetime), so the picker
512− * can preselect it. Best-effort: not persisted across process death. */
513− var lastChosenDirectory: String? = null
514− private set
515−
516− private val _directoryOptions = MutableStateFlow(DirectoryOptionsState())
517− val directoryOptions: StateFlow<DirectoryOptionsState> = _directoryOptions.asStateFlow()
518−
519− private var directoryOptionsJob: Job? = null
520−
521530 /**
522531 * Load the directory options for the new-session picker: the server's known projects
523532 * (`GET /project`) plus its default working directory (`GET /path`). Best-effort — a
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+23
@@ -26,10 +26,12 @@ import androidx.compose.ui.Modifier
2626import androidx.compose.ui.res.stringResource
2727import androidx.compose.ui.unit.dp
2828import androidx.lifecycle.compose.collectAsStateWithLifecycle
29+import androidx.lifecycle.viewmodel.compose.viewModel
2930import soy.iko.opencode.R
3031import soy.iko.opencode.data.network.NetworkConfig
3132import soy.iko.opencode.di.AppContainer
3233import soy.iko.opencode.ui.chat.ChatScreen
34+import soy.iko.opencode.ui.vmFactory
3335
3436/**
3537 * Master–detail layout for wide screens (≥ 840dp, e.g. tablets / unfolded foldables):
@@ -56,6 +58,10 @@ fun TwoPaneSessionChat(
5658 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
5759 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
5860 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
61+ // Same VM instance the left pane's SessionListScreen creates (shared ViewModelStoreOwner,
62+ // keyed by class), so its session list drives the detail pane's stale-selection cleanup.
63+ val sessionListVm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
64+ val sessionListState by sessionListVm.state.collectAsStateWithLifecycle()
5965
6066 // A notification tap / deep link requests a session: open it in the detail pane.
6167 LaunchedEffect(pendingOpenSession) {
@@ -78,6 +84,23 @@ fun TwoPaneSessionChat(
7884 lastProfileId = currentId
7985 }
8086
87+ // Clear the selection only when the open session is *deleted* — i.e. it was present
88+ // in the list and then dropped out. A freshly created session sets `selected` to the
89+ // new id before the async refresh lands, so the id is legitimately absent from the
90+ // (still-stale) list for a moment; a naive "not in list" check would wrongly clear it
91+ // and blank the detail pane. The `hasAppeared` latch (reset whenever `selected`
92+ // changes, via remember keyed to it) distinguishes "never appeared yet" (keep) from
93+ // "appeared, then removed" (a real deletion → clear).
94+ var hasAppeared by remember(selected) { mutableStateOf(false) }
95+ LaunchedEffect(selected, sessionListState.sessions) {
96+ val target = selected ?: return@LaunchedEffect
97+ if (sessionListState.sessions.any { it.id == target }) {
98+ hasAppeared = true
99+ } else if (hasAppeared) {
100+ selected = null
101+ }
102+ }
103+
81104 // Inject a pending share into the currently selected session's draft (if any).
82105 // Unlike single-pane mode where the session list is navigated to specifically for
83106 // the share, in two-pane mode the list is always visible — so we inject into the
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+11−2
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.settings
22
3+import android.content.ActivityNotFoundException
34import android.content.Intent
45import android.util.Log
56import androidx.compose.foundation.background
@@ -240,7 +241,11 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
240241 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
241242 .onFailure {
242243 Log.w("Diagnostics", "Failed to share crash report", it)
243− showToast(context, context.getString(R.string.no_share_app))
244+ // FIX 20: "no app to share" only fits ActivityNotFoundException; other
245+ // failures (e.g. an oversized report exceeding the Binder limit →
246+ // TransactionTooLargeException) get a generic toast, not that misleading one.
247+ val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
248+ showToast(context, context.getString(msg))
244249 }
245250 }
246251 }) {
@@ -291,7 +296,11 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
291296 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
292297 .onFailure {
293298 Log.w("Diagnostics", "Failed to share crash report", it)
294− showToast(context, context.getString(R.string.no_share_app))
299+ // FIX 20: "no app to share" only fits ActivityNotFoundException; other failures
300+ // (e.g. an oversized report exceeding the Binder limit → TransactionTooLargeException)
301+ // get a generic toast, not that misleading one.
302+ val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
303+ showToast(context, context.getString(msg))
295304 }
296305 },
297306 ) { Text(stringResource(R.string.share)) }
app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.kt+9−4
@@ -8,6 +8,7 @@ import soy.iko.opencode.data.model.Tokens
88/** Aggregated usage for one model across the scanned sessions. */
99@Immutable
1010data class ModelUsage(
11+ val provider: String,
1112 val model: String,
1213 val messages: Int,
1314 val cost: Double,
@@ -48,7 +49,7 @@ internal operator fun Tokens.plus(other: Tokens): Tokens = Tokens(
4849/** Total tokens across all sub-counters, for a single "N tokens" headline. */
4950internal val Tokens.total: Long get() = input + output + reasoning + cache.read + cache.write
5051
51−private class MutableAgg {
52+private class MutableAgg(val provider: String, val model: String) {
5253 var messages = 0
5354 var cost = 0.0
5455 var tokens = Tokens()
@@ -82,8 +83,12 @@ fun aggregateUsage(sessions: List<Triple<String, String, List<MessageWithParts>>
8283 totalTokens += tokens
8384 sessionCost += cost
8485 sessionTokens += tokens
85− val key = info.modelID?.takeIf { it.isNotBlank() } ?: "unknown"
86− val agg = byModel.getOrPut(key) { MutableAgg() }
86+ val model = info.modelID?.takeIf { it.isNotBlank() } ?: "unknown"
87+ val provider = info.providerID?.takeIf { it.isNotBlank() } ?: "unknown"
88+ // Key by provider+model so the same model id served by different providers
89+ // stays two rows instead of collapsing into one ambiguous total.
90+ val key = "$provider/$model"
91+ val agg = byModel.getOrPut(key) { MutableAgg(provider, model) }
8792 agg.messages++
8893 agg.cost += cost
8994 agg.tokens += tokens
@@ -99,7 +104,7 @@ fun aggregateUsage(sessions: List<Triple<String, String, List<MessageWithParts>>
99104 messageCount = messageCount,
100105 sessionCount = bySession.size,
101106 byModel = byModel.entries
102− .map { ModelUsage(it.key, it.value.messages, it.value.cost, it.value.tokens) }
107+ .map { ModelUsage(it.value.provider, it.value.model, it.value.messages, it.value.cost, it.value.tokens) }
103108 .sortedByDescending { it.cost },
104109 bySession = bySession.sortedByDescending { it.cost },
105110 )
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+4−2
@@ -112,8 +112,10 @@ private fun UsageContent(report: UsageReport) {
112112 item { TokenBreakdownCard(report.totalTokens) }
113113 if (report.byModel.isNotEmpty()) {
114114 item { SectionHeader(stringResource(R.string.usage_by_model)) }
115− items(report.byModel, key = { it.model }) { m ->
116− UsageRow(title = m.model, cost = m.cost, tokens = m.tokens, messages = m.messages)
115+ // Key on provider+model: the same model id served by two providers yields two
116+ // rows, so keying on the bare model would duplicate keys and crash the list.
117+ items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
118+ UsageRow(title = m.provider + " / " + m.model, cost = m.cost, tokens = m.tokens, messages = m.messages)
117119 }
118120 }
119121 if (report.bySession.isNotEmpty()) {
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+9−1
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.usage
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.Job
67import kotlinx.coroutines.async
78import kotlinx.coroutines.awaitAll
89import kotlinx.coroutines.flow.MutableStateFlow
@@ -33,15 +34,22 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
3334 private val _state = MutableStateFlow<State>(State.Loading)
3435 val state: StateFlow<State> = _state.asStateFlow()
3536
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+
3642 init { load() }
3743
3844 fun load() {
3945 val conn = container.activeConnection.value ?: run {
46+ loadJob?.cancel()
4047 _state.value = State.Disconnected
4148 return
4249 }
4350 _state.value = State.Loading
44− viewModelScope.launch {
51+ loadJob?.cancel()
52+ loadJob = viewModelScope.launch {
4553 runCatchingCancellable {
4654 val sessions = conn.repository.listSessions()
4755 // Cap concurrent message fetches so a large history doesn't open dozens of
app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.kt+49
@@ -6,6 +6,7 @@ import io.ktor.client.engine.mock.respond
66import io.ktor.client.engine.mock.respondError
77import io.ktor.client.plugins.ClientRequestException
88import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
9+import io.ktor.http.HttpHeaders
910import io.ktor.http.HttpStatusCode
1011import io.ktor.http.headersOf
1112import io.ktor.serialization.kotlinx.json.json
@@ -63,4 +64,52 @@ class OpencodeApiClientTest {
6364 api.respondPermission("s1", "p1", PermissionResponse.ONCE)
6465 assertEquals(1, calls)
6566 }
67+
68+ @Test
69+ fun sendPromptReusesSuppliedIdempotencyKeyForEveryRetry() = runTest {
70+ // Regression: the offline outbox re-sends a queued message across separate flushes
71+ // with a stable key (the outbox id) so a POST that reached the server but whose
72+ // response was lost is deduplicated instead of starting a duplicate run. Every retry
73+ // attempt must carry that exact key.
74+ val keys = mutableListOf<String?>()
75+ val engine = MockEngine { request ->
76+ keys += request.headers["Idempotency-Key"]
77+ throw IOException("transient") // force retries; the key must not change between them
78+ }
79+ val api = OpencodeApiClient(makeClient(engine))
80+ runCatching { api.sendPrompt("s1", "hello", idempotencyKey = "outbox-123") }
81+ assertTrue("expected more than one attempt, got ${keys.size}", keys.size > 1)
82+ assertTrue("every attempt must reuse the supplied key, saw $keys", keys.all { it == "outbox-123" })
83+ }
84+
85+ @Test
86+ fun sendPromptDefaultKeyIsStableAcrossRetries() = runTest {
87+ // Even without a caller-supplied key, all retries of a single call share one generated
88+ // key (so an in-call retry after a lost response is deduplicated server-side).
89+ val keys = mutableListOf<String?>()
90+ val engine = MockEngine { request ->
91+ keys += request.headers["Idempotency-Key"]
92+ throw IOException("transient")
93+ }
94+ val api = OpencodeApiClient(makeClient(engine))
95+ runCatching { api.sendPrompt("s1", "hello") }
96+ assertTrue("expected more than one attempt", keys.size > 1)
97+ assertTrue("generated key must be non-null", keys.all { !it.isNullOrBlank() })
98+ assertEquals("all attempts must share one key", 1, keys.toSet().size)
99+ }
100+
101+ @Test
102+ fun sendPromptDoesNotRetryMalformedResponseBody() = runTest {
103+ // A body that can't be deserialized is deterministic — retrying re-parses the same
104+ // bytes and fails identically, so it must fail fast (one attempt), not burn the whole
105+ // backoff budget.
106+ var calls = 0
107+ val engine = MockEngine {
108+ calls++
109+ respond("\"not a MessageWithParts\"", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json"))
110+ }
111+ val api = OpencodeApiClient(makeClient(engine))
112+ runCatching { api.sendPrompt("s1", "hello") }
113+ assertEquals("deserialization failure must not be retried", 1, calls)
114+ }
66115}
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+17−1
@@ -77,7 +77,7 @@ class WithRetryTest {
7777
7878 @Test
7979 fun tooManyRequestsIsRetried() = runTest {
80− // 429 (Too Many Requests) is the only 4xx that should retry — all other 4xx
80+ // 408 and 429 are the only transient 4xx that should retry — all other 4xx
8181 // surface immediately. Verify the special case isn't silently lost.
8282 val rateLimited = httpError(HttpStatusCode.TooManyRequests)
8383 assertTrue("expected ClientRequestException, got $rateLimited", rateLimited is ClientRequestException)
@@ -92,6 +92,22 @@ class WithRetryTest {
9292 assertEquals("429 must be retried like a transient failure", 3, calls)
9393 }
9494
95+ @Test
96+ fun requestTimeoutIsRetried() = runTest {
97+ // 408 (Request Timeout) is transient — the client MAY repeat the request.
98+ val timeout = httpError(HttpStatusCode.RequestTimeout)
99+ assertTrue("expected ClientRequestException, got $timeout", timeout is ClientRequestException)
100+ assertEquals(408, (timeout as ClientRequestException).response.status.value)
101+ var calls = 0
102+ runCatching {
103+ withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
104+ calls++
105+ throw timeout
106+ }
107+ }
108+ assertEquals("408 must be retried like a transient failure", 3, calls)
109+ }
110+
95111 @Test
96112 fun serverErrorIsRetried() = runTest {
97113 val serverError = httpError(HttpStatusCode.InternalServerError)
app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+5−2
@@ -78,10 +78,13 @@ class ChatViewModelTest {
7878 // --- send() ---
7979
8080 @Test
81− fun send_returnsFalseWhenNotConnected() = testScope.runTest {
81+ fun send_queuesToOutboxWhenNotConnected() = testScope.runTest {
82+ // The offline-outbox feature changed send()'s contract: when there's no active
83+ // connection the prompt is accepted for offline queueing (returns true) rather than
84+ // being rejected. (This test previously asserted the old "returns false" behavior.)
8285 val container = FakeAppContainer()
8386 val vm = makeVm(container)
84− assertFalse(vm.send("hello"))
87+ assertTrue(vm.send("hello"))
8588 }
8689
8790 @Test
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+8
@@ -175,14 +175,21 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
175175 return messages
176176 }
177177
178+ /** Idempotency keys seen across sendPrompt calls, in order, so tests can assert a
179+ * re-send reuses the same key. */
180+ var sendPromptKeys: List<String?> = emptyList()
181+ private set
182+
178183 override suspend fun sendPrompt(
179184 sessionId: String,
180185 text: String,
181186 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
182187 model: ModelRef?,
183188 agent: String?,
189+ idempotencyKey: String?,
184190 ): MessageWithParts {
185191 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
192+ sendPromptKeys = sendPromptKeys + idempotencyKey
186193 sendPromptThrows?.let { throw it }
187194 return MessageWithParts(
188195 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
@@ -285,6 +292,7 @@ class FakeSessionRepository(
285292 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
286293 model: ModelRef?,
287294 agent: String?,
295+ idempotencyKey: String?,
288296 ): MessageWithParts {
289297 sendPromptThrows?.let { throw it }
290298 return MessageWithParts(