git.iko.soy/opencode-android
on master help sign in
Fix 13 concurrency and data-loss bugs: outbox race/idempotency, attac… (#1)
5d56632iko
app/src/main/java/soy/iko/opencode/MainActivity.kt+17−12
@@ -51,32 +51,37 @@ class MainActivity : FragmentActivity() {
51 val container = (application as OpencodeApp).container
51 val container = (application as OpencodeApp).container
52 handleIntent(intent)
52 handleIntent(intent)
53 if (savedInstanceState == null) maybeRequestNotificationPermission()
53 if (savedInstanceState == null) maybeRequestNotificationPermission()
54− // Hold the splash until the persisted theme settings load, so we never paint a
54+ // Hold the splash until the persisted theme and app-lock settings load, so we never
55− // frame with the default (dynamicColor=true / SYSTEM) that then snaps to the
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
56− // user's real choice — the flash the SettingsScreen's null-gate already avoids.
57+ // before app lock resolves. Mirrors the SettingsScreen's null-gate.
57− var themeLoaded = false
58+ var settingsLoaded = false
58− splash.setKeepOnScreenCondition { !themeLoaded }
59+ splash.setKeepOnScreenCondition { !settingsLoaded }
59 setContent {
60 setContent {
60 val theme by remember(container) {
61 val theme by remember(container) {
61 container.settingsStore.themeMode
62 container.settingsStore.themeMode
62 .combine(container.settingsStore.dynamicColor, ::Pair)
63 .combine(container.settingsStore.dynamicColor, ::Pair)
63 }.collectAsStateWithLifecycle(initialValue = null)
64 }.collectAsStateWithLifecycle(initialValue = null)
64− val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = false)
65+ val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
65− LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
66+ LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
66− // Hide the app's content from screenshots / the recents thumbnail while app lock
67+ // Hide the app's content from screenshots / the recents thumbnail while app lock is
67− // is enabled, so protected server details don't leak there.
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.
68 LaunchedEffect(appLock) {
70 LaunchedEffect(appLock) {
69− if (appLock) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
71+ if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
70 else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
72 else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
71 }
73 }
74+ val themePair = theme
75+ val locked = appLock
76+ if (themePair != null && locked != null) {
72− theme?.let { (themeMode, dynamicColor) ->
77+ val (themeMode, dynamicColor) = themePair
73 val dark = when (themeMode) {
78 val dark = when (themeMode) {
74 ThemeMode.SYSTEM -> isSystemInDarkTheme()
79 ThemeMode.SYSTEM -> isSystemInDarkTheme()
75 ThemeMode.LIGHT -> false
80 ThemeMode.LIGHT -> false
76 ThemeMode.DARK -> true
81 ThemeMode.DARK -> true
77 }
82 }
78 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
83 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
79− AppLockGate(enabled = appLock) {
84+ AppLockGate(enabled = locked) {
80 OpencodeAppUi(container = container)
85 OpencodeAppUi(container = container)
81 }
86 }
82 }
87 }
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+77
@@ -5,6 +5,13 @@ import coil.ImageLoader
5import coil.ImageLoaderFactory
5import coil.ImageLoaderFactory
6import coil.disk.DiskCache
6import coil.disk.DiskCache
7import coil.memory.MemoryCache
7import 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
8import soy.iko.opencode.data.repo.CrashLogger
15import soy.iko.opencode.data.repo.CrashLogger
9import soy.iko.opencode.di.AppContainer
16import soy.iko.opencode.di.AppContainer
10import java.util.concurrent.atomic.AtomicBoolean
17import java.util.concurrent.atomic.AtomicBoolean
@@ -31,6 +38,11 @@ class OpencodeApp : Application(), ImageLoaderFactory {
31 // disk cache so scrolling back to an image-bearing message doesn't re-fetch it over
38 // disk cache so scrolling back to an image-bearing message doesn't re-fetch it over
32 // the (auth-protected) network every time. Data-URI (base64) images skip the disk
39 // the (auth-protected) network every time. Data-URI (base64) images skip the disk
33 // cache automatically; only resolved HTTP/S URLs are cached, keyed by URL.
40 // 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.
34 override fun newImageLoader(): ImageLoader = ImageLoader.Builder(this)
46 override fun newImageLoader(): ImageLoader = ImageLoader.Builder(this)
35 .memoryCache {
47 .memoryCache {
36 MemoryCache.Builder(this)
48 MemoryCache.Builder(this)
@@ -43,6 +55,7 @@ class OpencodeApp : Application(), ImageLoaderFactory {
43 .maxSizeBytes(50L * 1024 * 1024)
55 .maxSizeBytes(50L * 1024 * 1024)
44 .build()
56 .build()
45 }
57 }
58+ .callFactory { PinnedImageCallFactory(container) }
46 .build()
59 .build()
47
60
48 override fun onTerminate() {
61 override fun onTerminate() {
@@ -57,3 +70,67 @@ class OpencodeApp : Application(), ImageLoaderFactory {
57 container.shutdown()
70 container.shutdown()
58 }
71 }
59}
72}
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 {
23@Serializable
23@Serializable
24@SerialName("user")
24@SerialName("user")
25data class UserMessage(
25data class UserMessage(
26− override val id: String,
26+ override val id: String = "",
27− override val sessionID: String,
27+ override val sessionID: String = "",
28 override val time: TimeInfo? = null,
28 override val time: TimeInfo? = null,
29) : MessageInfo
29) : MessageInfo
30
30
@@ -32,8 +32,8 @@ data class UserMessage(
32@Serializable
32@Serializable
33@SerialName("assistant")
33@SerialName("assistant")
34data class AssistantMessage(
34data class AssistantMessage(
35− override val id: String,
35+ override val id: String = "",
36− override val sessionID: String,
36+ override val sessionID: String = "",
37 val parentID: String? = null,
37 val parentID: String? = null,
38 val providerID: String? = null,
38 val providerID: String? = null,
39 val modelID: String? = null,
39 val modelID: String? = null,
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+30−10
@@ -68,12 +68,16 @@ open class EventStreamClient(
68 /** Conflated channel used to cut the reconnect backoff short when network returns. */
68 /** Conflated channel used to cut the reconnect backoff short when network returns. */
69 private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
69 private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
70
70
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).
72 * ktor's SSE builder wraps establishment failures in [io.ktor.client.plugins.sse.SSEClientException]
76 * ktor's SSE builder wraps establishment failures in [io.ktor.client.plugins.sse.SSEClientException]
73 * (an IllegalStateException, NOT a ClientRequestException), attaching the response and keeping any
77 * (an IllegalStateException, NOT a ClientRequestException), attaching the response and keeping any
74 * underlying ClientRequestException only as the cause — so we inspect the wrapper's response status
78 * underlying ClientRequestException only as the cause — so we inspect the wrapper's response status
75 * and walk the whole cause chain rather than matching a single exception type. */
79 * 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 =
77 generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
81 generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
78 .take(16)
82 .take(16)
79 .mapNotNull { t ->
83 .mapNotNull { t ->
@@ -83,7 +87,8 @@ open class EventStreamClient(
83 else -> null
87 else -> null
84 }
88 }
85 }
89 }
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 }
87
92
88 /** Request an immediate reconnect, skipping any in-progress backoff. */
93 /** Request an immediate reconnect, skipping any in-progress backoff. */
89 open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
94 open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
@@ -106,8 +111,14 @@ open class EventStreamClient(
106 val result: ChannelResult<ServerSentEvent>? = select {
111 val result: ChannelResult<ServerSentEvent>? = select {
107 events.onReceiveCatching { it }
112 events.onReceiveCatching { it }
108 onTimeout(idleTimeoutMs) { null }
113 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 }
109 }
120 }
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")
111 val sse = result.getOrNull()
122 val sse = result.getOrNull()
112 if (sse == null) {
123 if (sse == null) {
113 // The producer channel is closed. A non-null cause means `incoming`
124 // The producer channel is closed. A non-null cause means `incoming`
@@ -228,17 +239,26 @@ open class EventStreamClient(
228 } catch (e: Exception) {
239 } catch (e: Exception) {
229 // If the scope was cancelled (e.g. connection close), the closed-client
240 // If the scope was cancelled (e.g. connection close), the closed-client
230 // exception is expected — don't log a spurious "stream error" warning.
241 // exception is expected — don't log a spurious "stream error" warning.
231− if (isSseAuthFailure(e)) {
242+ if (isNonRetryableSseFailure(e)) {
243+ // The server rejected the request with a non-retryable 4xx (bad
232− // Credentials are wrong — retrying won't help. Log a scrubbed
244+ // credentials, missing endpoint, ...) — retrying won't help. Log a
233− // summary (class + status) instead of the full exception, whose
245+ // scrubbed summary (class + status) instead of the full exception, whose
234 // message carries the request URL and may include auth or paths.
246 // 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)}")
236 _state.value = ConnectionState.Failed
248 _state.value = ConnectionState.Failed
237 if (!isActive) break
249 if (!isActive) break
238 // Don't complete the flow: a subscriber may fix the profile and call
250 // Don't complete the flow: a subscriber may fix the profile and call
239 // triggerReconnect(). Suspend until an explicit reconnect signal
251 // triggerReconnect(). Suspend until an explicit reconnect signal
240− // arrives (no backoff delay), then re-attempt the connection.
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 {
241− reconnectSignal.receive()
257+ reconnectSignal.receive()
258+ } catch (c: CancellationException) {
259+ _state.value = ConnectionState.Disconnected
260+ throw c
261+ }
242 continue
262 continue
243 } else if (isActive) {
263 } else if (isActive) {
244 Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
264 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
15import io.ktor.http.takeFrom
15import io.ktor.http.takeFrom
16import io.ktor.serialization.kotlinx.json.json
16import io.ktor.serialization.kotlinx.json.json
17import okhttp3.CertificatePinner
17import okhttp3.CertificatePinner
18+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
18import java.util.concurrent.TimeUnit
19import java.util.concurrent.TimeUnit
19
20
20/**
21/**
@@ -30,8 +31,17 @@ object HttpClientFactory {
30 // and pin the server certificate(s) for HTTPS connections.
31 // and pin the server certificate(s) for HTTPS connections.
31 val normalizedUrl = effectiveBaseUrl(profile)
32 val normalizedUrl = effectiveBaseUrl(profile)
32 val isHttps = normalizedUrl.lowercase().startsWith("https://")
33 val isHttps = normalizedUrl.lowercase().startsWith("https://")
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.
33− val pinHost = runCatching { java.net.URI(normalizedUrl).host }.getOrNull()
38+ val pinHost = normalizedUrl.toHttpUrlOrNull()?.host
34 val pins = parsePins(profile.certPin)
39 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+ }
35
45
36 return HttpClient(OkHttp) {
46 return HttpClient(OkHttp) {
37 expectSuccess = true
47 expectSuccess = true
@@ -80,11 +90,15 @@ object HttpClientFactory {
80 password = profile.password.orEmpty(),
90 password = profile.password.orEmpty(),
81 )
91 )
82 }
92 }
83− // Send eagerly so opencode doesn't need a 401 challenge round-trip.
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
84− // The Auth plugin is only installed for HTTPS profiles — over HTTP,
96+ // the real host reactively). The Auth plugin is only installed for HTTPS
85− // even reactive (401-challenge) credential sending would leak
97+ // profiles — over HTTP, even reactive credential sending would leak
86 // passwords in cleartext, so we don't install it at all.
98 // 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+ }
88 }
102 }
89 }
103 }
90 } else {
104 } else {
@@ -113,10 +127,14 @@ object HttpClientFactory {
113 }
127 }
114
128
115 /** The base URL after applying [ServerProfile.requireHttps]: a cleartext http:// URL is
129 /** 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. */
117 private fun effectiveBaseUrl(profile: ServerProfile): String {
134 private fun effectiveBaseUrl(profile: ServerProfile): String {
118 val normalized = normalizeBaseUrl(profile.baseUrl)
135 val normalized = normalizeBaseUrl(profile.baseUrl)
136+ val forceHttps = profile.requireHttps || parsePins(profile.certPin).isNotEmpty()
119− return if (profile.requireHttps && normalized.lowercase().startsWith("http://")) {
137+ return if (forceHttps && normalized.lowercase().startsWith("http://")) {
120 "https://" + normalized.substring("http://".length)
138 "https://" + normalized.substring("http://".length)
121 } else {
139 } else {
122 normalized
140 normalized
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+12
@@ -114,6 +114,12 @@ object NetworkConfig {
114 /** Max number of attachments staged for one prompt, to bound memory and request size. */
114 /** Max number of attachments staged for one prompt, to bound memory and request size. */
115 const val maxAttachments = 8
115 const val maxAttachments = 8
116
116
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+
117 // --- StateFlow sharing (WhileSubscribed) ---
123 // --- StateFlow sharing (WhileSubscribed) ---
118
124
119 /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
125 /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
@@ -149,6 +155,12 @@ object NetworkConfig {
149 const val observerRetryDelayMs = 5_000L
155 const val observerRetryDelayMs = 5_000L
150 /** Max concurrent assistant runs to track for completion notifications. */
156 /** Max concurrent assistant runs to track for completion notifications. */
151 const val activeRunsLimit = 200
157 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
152
164
153 // --- Markdown rendering (MarkdownText) ---
165 // --- Markdown rendering (MarkdownText) ---
154
166
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+6−1
@@ -14,7 +14,12 @@ import kotlin.coroutines.resume
14
14
15/** A server discovered on the local network via mDNS/DNS-SD. */
15/** A server discovered on the local network via mDNS/DNS-SD. */
16data class DiscoveredServer(val name: String, val host: String, val port: Int) {
16data 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+ }
18}
23}
19
24
20/**
25/**
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+97−41
@@ -62,14 +62,22 @@ open class OpencodeApiClient private constructor(
62 // between sessions on the same server.
62 // between sessions on the same server.
63 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
63 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
64 // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
64 // 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
65+ // The fetch runs OUTSIDE the lock (holding it across the network call would serialize
66− // multiple coroutines miss the cache simultaneously.
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.
67 private val providersMutex = Mutex()
71 private val providersMutex = Mutex()
68 private val agentsMutex = Mutex()
72 private val agentsMutex = Mutex()
69 private val commandsMutex = Mutex()
73 private val commandsMutex = Mutex()
70 private var cachedProviders: CachedEntry<ProvidersResponse>? = null
74 private var cachedProviders: CachedEntry<ProvidersResponse>? = null
71 private var cachedAgents: CachedEntry<List<Agent>>? = null
75 private var cachedAgents: CachedEntry<List<Agent>>? = null
72 private var cachedCommands: CachedEntry<List<Command>>? = null
76 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
73
81
74 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
82 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
75 open suspend fun ping() {
83 open suspend fun ping() {
@@ -142,12 +150,18 @@ open class OpencodeApiClient private constructor(
142 attachments: List<FilePromptPart> = emptyList(),
150 attachments: List<FilePromptPart> = emptyList(),
143 model: ModelRef? = null,
151 model: ModelRef? = null,
144 agent: String? = null,
152 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,
145 ): MessageWithParts {
160 ): MessageWithParts {
146− // Generate the idempotency key *before* entering withRetry so all retry
161+ // Resolve the key *before* entering withRetry so all retry attempts share it. If the
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
162+ // key were generated inside withRetry, each attempt would get a fresh UUID and the
149− // request that reached it but whose response was lost (e.g. timeout).
163+ // server couldn't deduplicate a request that reached it but whose response was lost.
150− val idempotencyKey = java.util.UUID.randomUUID().toString()
164+ val resolvedKey = idempotencyKey ?: java.util.UUID.randomUUID().toString()
151 // A text part is only included when there's actual text, so an image-only prompt
165 // A text part is only included when there's actual text, so an image-only prompt
152 // (attachments, empty text) doesn't send an empty text part. Attachments follow
166 // (attachments, empty text) doesn't send an empty text part. Attachments follow
153 // the text so the model reads the instruction first.
167 // the text so the model reads the instruction first.
@@ -158,7 +172,7 @@ open class OpencodeApiClient private constructor(
158 return withRetry {
172 return withRetry {
159 client!!.post("session/${encode(sessionId)}/message") {
173 client!!.post("session/${encode(sessionId)}/message") {
160 contentType(ContentType.Application.Json)
174 contentType(ContentType.Application.Json)
161− header("Idempotency-Key", idempotencyKey)
175+ header("Idempotency-Key", resolvedKey)
162 setBody(
176 setBody(
163 PromptRequest(
177 PromptRequest(
164 parts = parts,
178 parts = parts,
@@ -256,15 +270,25 @@ open class OpencodeApiClient private constructor(
256 }
270 }
257 }
271 }
258
272
259− open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
273+ open suspend fun providers(): ProvidersResponse {
260− val now = System.currentTimeMillis()
274+ val generation: Int
275+ providersMutex.withLock {
261− val cached = cachedProviders
276+ val cached = cachedProviders
262− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
277+ if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
263− return@withLock cached.value
278+ return cached.value
279+ }
280+ generation = providersGeneration
264 }
281 }
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.
265 val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
285 val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
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.
266− cachedProviders = CachedEntry(value, System.currentTimeMillis())
289+ if (providersGeneration == generation) cachedProviders = CachedEntry(value, System.currentTimeMillis())
290+ }
267− value
291+ return value
268 }
292 }
269
293
270 /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
294 /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
@@ -273,45 +297,62 @@ open class OpencodeApiClient private constructor(
273 client!!.get("config").body()
297 client!!.get("config").body()
274 }
298 }
275
299
276− open suspend fun agents(): List<Agent> = agentsMutex.withLock {
300+ open suspend fun agents(): List<Agent> {
277− val now = System.currentTimeMillis()
301+ val generation: Int
302+ agentsMutex.withLock {
278− val cached = cachedAgents
303+ val cached = cachedAgents
279− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
304+ if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
280− return@withLock cached.value
305+ return cached.value
306+ }
307+ generation = agentsGeneration
281 }
308 }
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.
282 val value = withRetry { client!!.get("agent").body<List<Agent>>() }
311 val value = withRetry { client!!.get("agent").body<List<Agent>>() }
312+ agentsMutex.withLock {
313+ // FIX 15: skip the store if an invalidateAgentsCache() raced this fetch (see providers()).
283− cachedAgents = CachedEntry(value, System.currentTimeMillis())
314+ if (agentsGeneration == generation) cachedAgents = CachedEntry(value, System.currentTimeMillis())
315+ }
284− value
316+ return value
285 }
317 }
286
318
287− open suspend fun commands(): List<Command> = commandsMutex.withLock {
319+ open suspend fun commands(): List<Command> {
288− val now = System.currentTimeMillis()
320+ val generation: Int
321+ commandsMutex.withLock {
289− val cached = cachedCommands
322+ val cached = cachedCommands
290− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
323+ if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
291− return@withLock cached.value
324+ return cached.value
325+ }
326+ generation = commandsGeneration
292 }
327 }
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.
293 val value = withRetry { client!!.get("command").body<List<Command>>() }
330 val value = withRetry { client!!.get("command").body<List<Command>>() }
331+ commandsMutex.withLock {
332+ // FIX 15: skip the store if an invalidateCommandsCache() raced this fetch (see providers()).
294− cachedCommands = CachedEntry(value, System.currentTimeMillis())
333+ if (commandsGeneration == generation) cachedCommands = CachedEntry(value, System.currentTimeMillis())
334+ }
295− value
335+ return value
296 }
336 }
297
337
298 /** Invalidate the catalog cache (e.g. on server switch). */
338 /** Invalidate the catalog cache (e.g. on server switch). */
299 open suspend fun invalidateCache() {
339 open suspend fun invalidateCache() {
340+ // FIX 15: bump each generation so an in-flight fetch drops its now-stale result.
300− providersMutex.withLock { cachedProviders = null }
341+ providersMutex.withLock { cachedProviders = null; providersGeneration++ }
301− agentsMutex.withLock { cachedAgents = null }
342+ agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
302− commandsMutex.withLock { cachedCommands = null }
343+ commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
303 }
344 }
304
345
305 open suspend fun invalidateProvidersCache() {
346 open suspend fun invalidateProvidersCache() {
306− providersMutex.withLock { cachedProviders = null }
347+ providersMutex.withLock { cachedProviders = null; providersGeneration++ }
307 }
348 }
308
349
309 open suspend fun invalidateAgentsCache() {
350 open suspend fun invalidateAgentsCache() {
310− agentsMutex.withLock { cachedAgents = null }
351+ agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
311 }
352 }
312
353
313 open suspend fun invalidateCommandsCache() {
354 open suspend fun invalidateCommandsCache() {
314− commandsMutex.withLock { cachedCommands = null }
355+ commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
315 }
356 }
316
357
317 /** Respond to a permission request so a paused tool run can proceed. */
358 /** Respond to a permission request so a paused tool run can proceed. */
@@ -391,9 +432,10 @@ internal suspend fun <T> withRetryInternal(
391 } catch (c: kotlinx.coroutines.CancellationException) {
432 } catch (c: kotlinx.coroutines.CancellationException) {
392 throw c
433 throw c
393 } catch (t: ClientRequestException) {
434 } catch (t: ClientRequestException) {
394− // 429 (Too Many Requests) is transient — retry with backoff.
435+ // 408 (Request Timeout) and 429 (Too Many Requests) are transient — retry with
395− // All other 4xx are non-retryable.
436+ // backoff. All other 4xx are non-retryable (a 401/404 won't succeed on retry).
396− if (t.response.status.value != 429) throw t
437+ val status = t.response.status.value
438+ if (status != 408 && status != 429) throw t
397 lastError = t
439 lastError = t
398 if (attempt < maxAttempts) {
440 if (attempt < maxAttempts) {
399 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
441 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
@@ -407,6 +449,16 @@ internal suspend fun <T> withRetryInternal(
407 } catch (t: ResponseException) {
449 } catch (t: ResponseException) {
408 // 3xx (redirect limit exceeded) is non-retryable.
450 // 3xx (redirect limit exceeded) is non-retryable.
409 throw t
451 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
410 } catch (t: Exception) {
462 } catch (t: Exception) {
411 lastError = t
463 lastError = t
412 if (attempt < maxAttempts) {
464 if (attempt < maxAttempts) {
@@ -435,6 +487,10 @@ internal fun jitteredBackoff(
435 return (baseDelay + jitter).coerceAtLeast(0)
487 return (baseDelay + jitter).coerceAtLeast(0)
436}
488}
437
489
438−/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
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. */
439−private fun encode(segment: String): String =
493+private fun encode(segment: String): String {
440− URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
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
3import android.content.Context
3import android.content.Context
4import android.util.Log
4import android.util.Log
5import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.sync.Mutex
7+import kotlinx.coroutines.sync.withLock
6import kotlinx.coroutines.withContext
8import kotlinx.coroutines.withContext
7import kotlinx.serialization.Serializable
9import kotlinx.serialization.Serializable
8import kotlinx.serialization.decodeFromString
10import kotlinx.serialization.decodeFromString
@@ -55,26 +57,50 @@ open class AttachmentDraftStore private constructor(
55 private fun fileFor(sessionId: String): File? =
57 private fun fileFor(sessionId: String): File? =
56 dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
58 dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
57
59
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+
58 /** Load the staged attachments for [sessionId], or empty if none / unreadable. */
67 /** Load the staged attachments for [sessionId], or empty if none / unreadable. */
59− open suspend fun load(sessionId: String): List<PersistedAttachment> = withContext(Dispatchers.IO) {
68+ open suspend fun load(sessionId: String): List<PersistedAttachment> = lockFor(sessionId).withLock {
69+ withContext(Dispatchers.IO) {
60− val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
70+ val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
61− runCatchingCancellable { json.decodeFromString<List<PersistedAttachment>>(file.readText()) }
71+ runCatchingCancellable { json.decodeFromString<List<PersistedAttachment>>(file.readText()) }
62− .getOrDefault(emptyList())
72+ .getOrDefault(emptyList())
73+ }
63 }
74 }
64
75
65 /** Persist [attachments] for [sessionId] (an empty list deletes the file). */
76 /** Persist [attachments] for [sessionId] (an empty list deletes the file). */
66− open suspend fun save(sessionId: String, attachments: List<PersistedAttachment>): Unit = withContext(Dispatchers.IO) {
77+ open suspend fun save(sessionId: String, attachments: List<PersistedAttachment>): Unit = lockFor(sessionId).withLock {
78+ withContext(Dispatchers.IO) {
67− val file = fileFor(sessionId) ?: return@withContext
79+ val file = fileFor(sessionId) ?: return@withContext
68− runCatchingCancellable {
80+ runCatchingCancellable {
69− if (attachments.isEmpty()) file.delete()
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.
70− else file.writeText(json.encodeToString(attachments))
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+ }
71− }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
94+ }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
72− Unit
95+ Unit
96+ }
73 }
97 }
74
98
75 /** Remove staged attachments for a session (call on session deletion). */
99 /** Remove staged attachments for a session (call on session deletion). */
76− open suspend fun remove(sessionId: String): Unit = withContext(Dispatchers.IO) {
100+ open suspend fun remove(sessionId: String): Unit = lockFor(sessionId).withLock {
101+ withContext(Dispatchers.IO) {
77− runCatchingCancellable { fileFor(sessionId)?.delete() }
102+ runCatchingCancellable { fileFor(sessionId)?.delete() }
78− Unit
103+ Unit
104+ }
79 }
105 }
80}
106}
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+26−24
@@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.asStateFlow
9import kotlinx.coroutines.flow.update
9import kotlinx.coroutines.flow.update
10import android.util.Log
10import android.util.Log
11import kotlinx.coroutines.launch
11import kotlinx.coroutines.launch
12−import kotlinx.coroutines.withContext
13import java.util.concurrent.Executors
12import java.util.concurrent.Executors
14
13
15/**
14/**
@@ -154,7 +153,11 @@ open class DraftStore private constructor(
154 }.apply()
153 }.apply()
155 }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
154 }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
156 }
155 }
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().
157− if (prefsInitialized.get()) write() else flushExecutor.execute { write() }
160+ runCatching { flushExecutor.execute { write() } }
158 }
161 }
159
162
160 /** Update the in-memory draft immediately without persisting to disk.
163 /** Update the in-memory draft immediately without persisting to disk.
@@ -176,13 +179,11 @@ open class DraftStore private constructor(
176 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
179 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
177 updated
180 updated
178 }
181 }
179− withContext(Dispatchers.IO) {
182+ // Route persistence through the single-thread flushExecutor (like the other writers)
180− runCatching {
183+ // rather than the shared IO pool, so this write can't race ahead of an earlier queued
181− prefs.edit().apply {
184+ // flush and land stale. writeAsync persists sessionId=text (or removes it when blank),
182− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
185+ // which is exactly this draft's persistence.
183− }.apply()
186+ writeAsync(sessionId, text)
184− }.onFailure { Log.w("DraftStore", "Failed to persist draft for $sessionId", it) }
185− }
186 }
187 }
187
188
188 /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
189 /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
@@ -200,10 +201,15 @@ open class DraftStore private constructor(
200 updated.remove(sessionId)
201 updated.remove(sessionId)
201 updated
202 updated
202 }
203 }
203− withContext(Dispatchers.IO) {
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 {
204− runCatching {
209+ runCatching {
205− prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
210+ prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
206− }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
211+ }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
212+ }
207 }
213 }
208 }
214 }
209
215
@@ -228,17 +234,13 @@ open class DraftStore private constructor(
228 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
234 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
229 updated
235 updated
230 }
236 }
231− if (prefsInitialized.get()) {
237+ // FIX: always route the persistence through the single-thread flushExecutor so writes
232− // Prefs already opened — the lazy resolves instantly and apply() is async.
238+ // land in submission order. Previously, once prefs were open this applied synchronously
233− runCatching {
239+ // on the calling (main) thread, so a newer direct write could persist BEFORE an older
234− prefs.edit().apply {
240+ // still-queued write completed, clobbering the draft with stale text. The executor task
235− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
241+ // resolves the prefs lazy and uses apply() (async) exactly as before; the write may be
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.
242+ // lost only if the process exits before the queued task runs (as previously).
243+ runCatching {
242 flushExecutor.execute {
244 flushExecutor.execute {
243 runCatching {
245 runCatching {
244 prefs.edit().apply {
246 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
3import android.content.Context
3import android.content.Context
4import android.util.Log
4import android.util.Log
5import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.sync.Mutex
7+import kotlinx.coroutines.sync.withLock
6import kotlinx.coroutines.withContext
8import kotlinx.coroutines.withContext
7import kotlinx.serialization.builtins.ListSerializer
9import kotlinx.serialization.builtins.ListSerializer
8import soy.iko.opencode.data.model.MessageWithParts
10import soy.iko.opencode.data.model.MessageWithParts
@@ -41,26 +43,50 @@ open class MessageCacheStore private constructor(
41 private fun fileFor(sessionId: String): File? =
43 private fun fileFor(sessionId: String): File? =
42 dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
44 dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
43
45
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+
44 /** Load the cached messages for [sessionId], or empty if none / unreadable. */
53 /** Load the cached messages for [sessionId], or empty if none / unreadable. */
45− open suspend fun load(sessionId: String): List<MessageWithParts> = withContext(Dispatchers.IO) {
54+ open suspend fun load(sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
55+ withContext(Dispatchers.IO) {
46− val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
56+ val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
47− runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
57+ runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
48− .getOrDefault(emptyList())
58+ .getOrDefault(emptyList())
59+ }
49 }
60 }
50
61
51 /** Persist [messages] for [sessionId] (an empty list deletes the file). */
62 /** Persist [messages] for [sessionId] (an empty list deletes the file). */
52− open suspend fun save(sessionId: String, messages: List<MessageWithParts>): Unit = withContext(Dispatchers.IO) {
63+ open suspend fun save(sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
64+ withContext(Dispatchers.IO) {
53− val file = fileFor(sessionId) ?: return@withContext
65+ val file = fileFor(sessionId) ?: return@withContext
54− runCatchingCancellable {
66+ runCatchingCancellable {
55− if (messages.isEmpty()) file.delete()
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.
56− else file.writeText(OpencodeJson.encodeToString(serializer, messages))
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+ }
57− }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
80+ }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
58− Unit
81+ Unit
82+ }
59 }
83 }
60
84
61 /** Remove a session's cached messages (call on deletion). */
85 /** Remove a session's cached messages (call on deletion). */
62− open suspend fun remove(sessionId: String): Unit = withContext(Dispatchers.IO) {
86+ open suspend fun remove(sessionId: String): Unit = lockFor(sessionId).withLock {
87+ withContext(Dispatchers.IO) {
63− runCatchingCancellable { fileFor(sessionId)?.delete() }
88+ runCatchingCancellable { fileFor(sessionId)?.delete() }
64− Unit
89+ Unit
90+ }
65 }
91 }
66}
92}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+28−5
@@ -75,7 +75,13 @@ open class OutboxStore private constructor(
75 _messages.value = loaded.sortedBy { it.createdAt }
75 _messages.value = loaded.sortedBy { it.createdAt }
76 } else {
76 } else {
77 val known = _messages.value.mapTo(mutableSetOf()) { it.id }
77 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)
79 }
85 }
80 }
86 }
81 }
87 }
@@ -92,19 +98,36 @@ open class OutboxStore private constructor(
92 mutate { current -> current.filterNot { it.sessionId == sessionId } }
98 mutate { current -> current.filterNot { it.sessionId == sessionId } }
93
99
94 private suspend fun mutate(transform: (List<OutboxMessage>) -> List<OutboxMessage>) {
100 private suspend fun mutate(transform: (List<OutboxMessage>) -> List<OutboxMessage>) {
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.
95− val snapshot = ioMutex.withLock {
106+ ioMutex.withLock {
96 val updated = transform(_messages.value).sortedBy { it.createdAt }
107 val updated = transform(_messages.value).sortedBy { it.createdAt }
97 _messages.value = updated
108 _messages.value = updated
98− updated
109+ persist(updated)
99 }
110 }
100− persist(snapshot)
101 }
111 }
102
112
103 private suspend fun persist(list: List<OutboxMessage>) {
113 private suspend fun persist(list: List<OutboxMessage>) {
104 val f = file ?: return
114 val f = file ?: return
105 withContext(Dispatchers.IO) {
115 withContext(Dispatchers.IO) {
106 runCatchingCancellable {
116 runCatchingCancellable {
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.
107− if (list.isEmpty()) f.delete() else f.writeText(json.encodeToString(list))
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+ }
108 }.onFailure { Log.w("OutboxStore", "Failed to persist outbox", it) }
131 }.onFailure { Log.w("OutboxStore", "Failed to persist outbox", it) }
109 }
132 }
110 }
133 }
app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+23−2
@@ -2,6 +2,8 @@ package soy.iko.opencode.data.repo
2
2
3import android.content.Context
3import android.content.Context
4import kotlinx.coroutines.Dispatchers
4import kotlinx.coroutines.Dispatchers
5+import kotlinx.coroutines.sync.Mutex
6+import kotlinx.coroutines.sync.withLock
5import kotlinx.coroutines.withContext
7import kotlinx.coroutines.withContext
6import kotlinx.serialization.Serializable
8import kotlinx.serialization.Serializable
7import kotlinx.serialization.decodeFromString
9import kotlinx.serialization.decodeFromString
@@ -26,6 +28,12 @@ object RecentSessionsStore {
26
28
27 private val json = Json { ignoreUnknownKeys = true }
29 private val json = Json { ignoreUnknownKeys = true }
28
30
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+
29 private fun file(context: Context) = File(context.applicationContext.filesDir, FILE)
37 private fun file(context: Context) = File(context.applicationContext.filesDir, FILE)
30
38
31 /** Synchronous read — safe to call from the widget factory (bounded, tiny file). */
39 /** Synchronous read — safe to call from the widget factory (bounded, tiny file). */
@@ -37,7 +45,20 @@ object RecentSessionsStore {
37
45
38 /** Persist [sessions] (capped to [MAX]); a no-op empty list writes an empty file. */
46 /** Persist [sessions] (capped to [MAX]); a no-op empty list writes an empty file. */
39 suspend fun write(context: Context, sessions: List<RecentSession>) = withContext(Dispatchers.IO) {
47 suspend fun write(context: Context, sessions: List<RecentSession>) = withContext(Dispatchers.IO) {
48+ writeMutex.withLock {
49+ runCatching {
50+ val target = file(context)
40− runCatching { file(context).writeText(json.encodeToString(sessions.take(MAX))) }
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+ }
41− Unit
61+ Unit
62+ }
42 }
63 }
43}
64}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+135−28
@@ -1,5 +1,6 @@
1package soy.iko.opencode.data.repo
1package soy.iko.opencode.data.repo
2
2
3+import soy.iko.opencode.data.model.AssistantMessage
3import soy.iko.opencode.data.model.BusEvent
4import soy.iko.opencode.data.model.BusEvent
4import soy.iko.opencode.data.model.FilePromptPart
5import soy.iko.opencode.data.model.FilePromptPart
5import soy.iko.opencode.data.model.MessageInfo
6import soy.iko.opencode.data.model.MessageInfo
@@ -20,6 +21,10 @@ import soy.iko.opencode.util.runCatchingCancellable
20import soy.iko.opencode.util.safeExceptionSummary
21import soy.iko.opencode.util.safeExceptionSummary
21import android.util.Log
22import android.util.Log
22import kotlinx.coroutines.Dispatchers
23import kotlinx.coroutines.Dispatchers
24+import kotlinx.coroutines.Job
25+import kotlinx.coroutines.NonCancellable
26+import kotlinx.coroutines.delay
27+import kotlinx.coroutines.withContext
23import kotlinx.coroutines.channels.Channel
28import kotlinx.coroutines.channels.Channel
24import kotlinx.coroutines.flow.Flow
29import kotlinx.coroutines.flow.Flow
25import kotlinx.coroutines.flow.channelFlow
30import kotlinx.coroutines.flow.channelFlow
@@ -54,7 +59,10 @@ open class SessionRepository(
54 attachments: List<FilePromptPart> = emptyList(),
59 attachments: List<FilePromptPart> = emptyList(),
55 model: ModelRef?,
60 model: ModelRef?,
56 agent: String? = null,
61 agent: String? = null,
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,
57− ) = api.sendPrompt(sessionId, text, attachments, model, agent)
65+ ) = api.sendPrompt(sessionId, text, attachments, model, agent, idempotencyKey)
58
66
59 open suspend fun runCommand(
67 open suspend fun runCommand(
60 sessionId: String,
68 sessionId: String,
@@ -98,6 +106,13 @@ open class SessionRepository(
98 // initial load) clobbering a newer seed. Only mutated/read under [lock].
106 // initial load) clobbering a newer seed. Only mutated/read under [lock].
99 var seedGeneration = 0
107 var seedGeneration = 0
100
108
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+
101 // Seed from the on-disk cache first so the conversation renders instantly — and stays
116 // Seed from the on-disk cache first so the conversation renders instantly — and stays
102 // readable offline — before the network responds. Guarded on generation 0 so a
117 // readable offline — before the network responds. Guarded on generation 0 so a
103 // reconnect re-seed that somehow raced ahead isn't clobbered; the initial REST seed
118 // reconnect re-seed that somehow raced ahead isn't clobbered; the initial REST seed
@@ -105,7 +120,12 @@ open class SessionRepository(
105 messageCache?.let { cache ->
120 messageCache?.let { cache ->
106 val cached = cache.load(sessionId)
121 val cached = cache.load(sessionId)
107 if (cached.isNotEmpty()) {
122 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+ }
109 if (changed) publish()
129 if (changed) publish()
110 }
130 }
111 }
131 }
@@ -159,22 +179,33 @@ open class SessionRepository(
159 }
179 }
160 }
180 }
161
181
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 {
162− val initialResult = runCatchingCancellable { api.listMessages(sessionId) }
187+ val initialResult = runCatchingCancellable { api.listMessages(sessionId) }
163− .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on cache/SSE: ${safeExceptionSummary(it)}") }
188+ .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).
189+ // 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
190+ // 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
191+ // 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 —
192+ // 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
193+ // 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.
194+ // (e.g. offline) keep whatever's shown (the cache seed above) rather than clearing it.
170− val seeded = lock.withLock {
195+ val seeded = lock.withLock {
171− val list = initialResult.getOrNull()
196+ val list = initialResult.getOrNull()
172− if (seedGeneration == 0 && list != null) {
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)
173− store.seed(list)
203+ val changed = store.seed(list)
174− true
204+ pruned || changed
175− } else false
205+ } else false
206+ }
207+ if (seeded) publish()
176 }
208 }
177− if (seeded) publish()
178
209
179 // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
210 // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
180 // taken under the lock so a reader never sees a half-reduced state; send() runs
211 // taken under the lock so a reader never sees a half-reduced state; send() runs
@@ -182,19 +213,61 @@ open class SessionRepository(
182 // loop also keeps the flow alive until the collector cancels; the launched jobs
213 // loop also keeps the flow alive until the collector cancels; the launched jobs
183 // above are children of this scope and are torn down with it.
214 // above are children of this scope and are torn down with it.
184 var lastCacheWrite = 0L
215 var lastCacheWrite = 0L
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 {
185− dirty.consumeAsFlow().collect {
228+ dirty.consumeAsFlow().collect {
186− val snapshot = lock.withLock { store.snapshot() }
229+ val snapshot = lock.withLock { store.snapshot() }
187− send(snapshot)
230+ send(snapshot)
188− // Persist the snapshot to the on-disk cache, throttled so a fast token stream
231+ // 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
232+ // 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.
233+ // instant/offline first paint; the network corrects it on the next open.
191− if (messageCache != null && snapshot.isNotEmpty()) {
234+ if (messageCache != null && snapshot.isNotEmpty()) {
235+ latestSnapshot = snapshot
192− val now = System.currentTimeMillis()
236+ val now = System.currentTimeMillis()
237+ pendingCacheSave?.cancel()
193− if (now - lastCacheWrite >= NetworkConfig.messageCacheWriteThrottleMs) {
238+ if (now - lastCacheWrite >= NetworkConfig.messageCacheWriteThrottleMs) {
194− lastCacheWrite = now
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)
195− launch { messageCache.save(sessionId, snapshot) }
252+ messageCache.save(sessionId, snapshot)
253+ savedSnapshot = snapshot
254+ }
255+ }
196 }
256 }
197 }
257 }
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+ }
198 }
271 }
199 // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
272 // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
200 // snapshot copy — on Dispatchers.Default rather than the collector's context. The
273 // snapshot copy — on Dispatchers.Default rather than the collector's context. The
@@ -213,6 +286,23 @@ open class SessionRepository(
213
286
214 fun isError(event: BusEvent, sessionId: String): Boolean =
287 fun isError(event: BusEvent, sessionId: String): Boolean =
215 event is SessionError && event.properties.sessionID == sessionId
288 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+ }
216 }
306 }
217}
307}
218
308
@@ -347,6 +437,23 @@ internal class MessageStore {
347 return changed
437 return changed
348 }
438 }
349
439
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+
350 /** Reorder the message map by message creation time (stable). Ties and entries with no
457 /** Reorder the message map by message creation time (stable). Ties and entries with no
351 * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
458 * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
352 * relative order, so such a message stays last. Preserves each entry's map key so the
459 * 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
24import soy.iko.opencode.data.repo.DraftStore
24import soy.iko.opencode.data.repo.DraftStore
25import soy.iko.opencode.data.repo.ErrorKind
25import soy.iko.opencode.data.repo.ErrorKind
26import soy.iko.opencode.data.repo.MessageCacheStore
26import soy.iko.opencode.data.repo.MessageCacheStore
27+import soy.iko.opencode.data.repo.OutboxMessage
27import soy.iko.opencode.data.repo.OutboxStore
28import soy.iko.opencode.data.repo.OutboxStore
28import soy.iko.opencode.data.repo.SessionPrefsStore
29import soy.iko.opencode.data.repo.SessionPrefsStore
29import soy.iko.opencode.data.repo.ProfileStore
30import soy.iko.opencode.data.repo.ProfileStore
30import soy.iko.opencode.data.repo.SettingsStore
31import soy.iko.opencode.data.repo.SettingsStore
31import soy.iko.opencode.data.repo.classifyError
32import soy.iko.opencode.data.repo.classifyError
32import soy.iko.opencode.data.repo.responseStatusCode
33import soy.iko.opencode.data.repo.responseStatusCode
34+import soy.iko.opencode.data.network.EventStreamClient
33import soy.iko.opencode.data.network.HttpClientFactory
35import soy.iko.opencode.data.network.HttpClientFactory
34import soy.iko.opencode.data.network.NetworkConfig
36import soy.iko.opencode.data.network.NetworkConfig
35import soy.iko.opencode.data.network.OpencodeApiClient
37import soy.iko.opencode.data.network.OpencodeApiClient
36import soy.iko.opencode.notification.NotificationChannels
38import soy.iko.opencode.notification.NotificationChannels
39+import soy.iko.opencode.notification.RunForegroundService
37import soy.iko.opencode.util.safeExceptionSummary
40import soy.iko.opencode.util.safeExceptionSummary
38import soy.iko.opencode.notification.SessionNotifications
41import soy.iko.opencode.notification.SessionNotifications
39import soy.iko.opencode.R
42import soy.iko.opencode.R
@@ -47,7 +50,10 @@ import kotlinx.coroutines.delay
47import kotlinx.coroutines.flow.MutableStateFlow
50import kotlinx.coroutines.flow.MutableStateFlow
48import kotlinx.coroutines.flow.StateFlow
51import kotlinx.coroutines.flow.StateFlow
49import kotlinx.coroutines.flow.asStateFlow
52import kotlinx.coroutines.flow.asStateFlow
53+import kotlinx.coroutines.flow.collectLatest
50import kotlinx.coroutines.flow.combine
54import kotlinx.coroutines.flow.combine
55+import kotlinx.coroutines.flow.debounce
56+import kotlinx.coroutines.flow.distinctUntilChanged
51import kotlinx.coroutines.flow.emptyFlow
57import kotlinx.coroutines.flow.emptyFlow
52import kotlinx.coroutines.flow.first
58import kotlinx.coroutines.flow.first
53import kotlinx.coroutines.Job
59import kotlinx.coroutines.Job
@@ -215,6 +221,7 @@ open class AppContainer private constructor(
215 appContext?.let {
221 appContext?.let {
216 SessionNotifications.cancel(it, id)
222 SessionNotifications.cancel(it, id)
217 SessionNotifications.cancelPermission(it, id)
223 SessionNotifications.cancelPermission(it, id)
224+ SessionNotifications.cancelError(it, id)
218 }
225 }
219 }
226 }
220 }
227 }
@@ -262,13 +269,62 @@ open class AppContainer private constructor(
262 ErrorKind.UNKNOWN -> string(R.string.error_generic)
269 ErrorKind.UNKNOWN -> string(R.string.error_generic)
263 }
270 }
264
271
265− init {
272+ /**
266− if (!skipInit) {
273+ * Drive the [RunForegroundService] from the process-lived [appScope] rather than from the
267− NotificationChannels.create(appContext!!)
274+ * Activity's composition. Previously the start/stop was wired to a
268− observeMessageActivity()
275+ * `collectAsStateWithLifecycle` collector in the root composable, which pauses while the
269− appScope.launch { runCatchingCancellable { outboxStore.load() } }
276+ * Activity is STOPPED — so a run *started while backgrounded* (e.g. via the notification
270− observeOutbox()
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() {
271− appScope.launch { autoConnect() }
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+ }
272 }
328 }
273 }
329 }
274
330
@@ -277,9 +333,9 @@ open class AppContainer private constructor(
277 * online (and on any manual [flushOutbox] nudge). Queued messages are only sent to the
333 * online (and on any manual [flushOutbox] nudge). Queued messages are only sent to the
278 * server they were composed for (matched by profile id), so a reconnect to a different
334 * server they were composed for (matched by profile id), so a reconnect to a different
279 * server never misfires them against a session id that doesn't exist there. Uses a plain
335 * 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
336+ * `collect` (not collectLatest) so an in-flight send is never cancelled mid-flight and
281− * cancelled+retried POST could double-send since each attempt carries a fresh idempotency
337+ * send order is preserved; each message carries a stable idempotency key (its persistent
282− * key.
338+ * outbox id) so even a re-send across flushes is deduplicated server-side.
283 */
339 */
284 private fun observeOutbox() {
340 private fun observeOutbox() {
285 appScope.launch {
341 appScope.launch {
@@ -299,7 +355,7 @@ open class AppContainer private constructor(
299 _outboxSending.value = true
355 _outboxSending.value = true
300 try {
356 try {
301 for (msg in pending) {
357 for (msg in pending) {
302− val sent = runCatchingCancellable {
358+ val result = runCatchingCancellable {
303 conn.repository.sendPrompt(
359 conn.repository.sendPrompt(
304 msg.sessionId,
360 msg.sessionId,
305 msg.text,
361 msg.text,
@@ -308,14 +364,38 @@ open class AppContainer private constructor(
308 },
364 },
309 model = msg.model,
365 model = msg.model,
310 agent = msg.agent,
366 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,
311 )
372 )
312 }.onFailure {
373 }.onFailure {
313 Log.w("AppContainer", "Outbox flush failed for ${msg.sessionId}: ${safeExceptionSummary(it)}")
374 Log.w("AppContainer", "Outbox flush failed for ${msg.sessionId}: ${safeExceptionSummary(it)}")
375+ }
314− }.isSuccess
376+ if (result.isSuccess) {
315− // Stop on the first failure — the connection likely dropped again; the
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)
316− // remaining messages stay queued and retry on the next trigger, preserving
385+ // keep the queue intact and retry on the next trigger.
317− // send order.
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)")
318− if (sent) outboxStore.remove(msg.id) else break
395+ outboxStore.remove(msg.id)
396+ } else {
397+ break
398+ }
319 }
399 }
320 } finally {
400 } finally {
321 _outboxSending.value = false
401 _outboxSending.value = false
@@ -451,15 +531,18 @@ open class AppContainer private constructor(
451 if (isLiveRunActivity(event)) {
531 if (isLiveRunActivity(event)) {
452 // The add-under-lock is required both for thread-safety and for the
532 // The add-under-lock is required both for thread-safety and for the
453 // LRU access-order refresh (keeps a continuously-streaming session
533 // LRU access-order refresh (keeps a continuously-streaming session
454− // from being evicted), so it runs per event. Only the flag write is
534+ // from being evicted), so it runs per event.
455− // skippable once it's already set.
456 synchronized(activeRuns) {
535 synchronized(activeRuns) {
457 if (activeRuns.size >= activeRunsLimit) {
536 if (activeRuns.size >= activeRunsLimit) {
458 activeRuns.remove(activeRuns.iterator().next())
537 activeRuns.remove(activeRuns.iterator().next())
459 }
538 }
460 activeRuns.add(sid)
539 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()
461 }
545 }
462− if (!_anyRunActive.value) _anyRunActive.value = true
463 }
546 }
464 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
547 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
465 if (!isActive) break
548 if (!isActive) break
@@ -486,8 +569,15 @@ open class AppContainer private constructor(
486 return true
569 return true
487 }
570 }
488
571
489− /** Session ids currently streaming an assistant run (best-effort, in-process). */
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. */
490− private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
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+ )
491
581
492 /** Per non-viewed session, the set of message ids already reflected in its unread
582 /** Per non-viewed session, the set of message ids already reflected in its unread
493 * count. De-duplicates the many streaming events that share one message id so the
583 * count. De-duplicates the many streaming events that share one message id so the
@@ -533,29 +623,63 @@ open class AppContainer private constructor(
533
623
534 /** Respond to a permission from a notification action (Allow once / Always / Reject),
624 /** Respond to a permission from a notification action (Allow once / Always / Reject),
535 * off any UI. Runs on the process-lived app scope; [onDone] fires when the call resolves
625 * 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. */
537 open fun respondToPermissionFromNotification(
631 open fun respondToPermissionFromNotification(
538 sessionId: String,
632 sessionId: String,
539 permissionId: String,
633 permissionId: String,
540 response: PermissionResponse,
634 response: PermissionResponse,
541− onDone: () -> Unit,
635+ onDone: (Boolean) -> Unit,
542 ) {
636 ) {
543 appScope.launch {
637 appScope.launch {
544− runCatchingCancellable {
638+ val success = runCatchingCancellable {
639+ val api = activeConnection.value?.api ?: return@runCatchingCancellable false
545− activeConnection.value?.api?.respondPermission(sessionId, permissionId, response)
640+ api.respondPermission(sessionId, permissionId, response)
641+ true
642+ }.onFailure {
546− }.onFailure { Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}") }
643+ Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
644+ }.getOrDefault(false)
547− onDone()
645+ onDone(success)
548 }
646 }
549 }
647 }
550
648
551 /** Send a follow-up prompt from a notification's inline reply, off any UI. Uses the
649 /** 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. */
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. */
553− open fun sendPromptFromNotification(sessionId: String, text: String, onDone: () -> Unit) {
657+ open fun sendPromptFromNotification(sessionId: String, text: String, onDone: (Boolean) -> Unit) {
554 appScope.launch {
658 appScope.launch {
555− runCatchingCancellable {
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.
556− activeConnection.value?.repository?.sendPrompt(sessionId, text, model = null)
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 {
557− }.onFailure { Log.w("AppContainer", "notif reply send failed: ${safeExceptionSummary(it)}") }
678+ Log.w("AppContainer", "notif reply enqueue failed: ${safeExceptionSummary(it)}")
679+ }.isSuccess
680+ }
681+ if (enqueued) flushOutbox()
558− onDone()
682+ onDone(enqueued)
559 }
683 }
560 }
684 }
561
685
@@ -571,12 +695,22 @@ open class AppContainer private constructor(
571 .getOrDefault(emptyList())
695 .getOrDefault(emptyList())
572 .firstOrNull() ?: return
696 .firstOrNull() ?: return
573 _reconnecting.value = true
697 _reconnecting.value = true
698+ var conn: OpencodeConnection? = null
574 val ok = runCatchingCancellable {
699 val ok = runCatchingCancellable {
575− val conn = connect(recent)
700+ conn = connect(recent)
576− conn.api.ping()
701+ conn!!.api.ping()
577 }.isSuccess
702 }.isSuccess
578 _reconnecting.value = false
703 _reconnecting.value = false
704+ if (ok) {
579− if (ok) _autoConnectDone.value = true else disconnect()
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+ }
580 }
714 }
581
715
582 /**
716 /**
@@ -631,19 +765,33 @@ open class AppContainer private constructor(
631 // Previously this was fire-and-forget (launch on appScope), which raced with
765 // Previously this was fire-and-forget (launch on appScope), which raced with
632 // a subsequent connect(): connect() could acquire the mutex, create a new
766 // a subsequent connect(): connect() could acquire the mutex, create a new
633 // connection, and then the pending disconnect() would close it.
767 // 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) {
634 connectionMutex.withLock {
777 connectionMutex.withLock {
635− _activeConnection.value?.close()
636− _activeConnection.value = null
778+ if (_activeConnection.value === expected) teardownActiveLocked()
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()
644 }
779 }
645 }
780 }
646
781
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+
647 /** Deferred profile deletions keyed by id, run on the process-lived [appScope] (not a
795 /** Deferred profile deletions keyed by id, run on the process-lived [appScope] (not a
648 * ViewModel scope) so navigating away during the undo window still commits the delete
796 * ViewModel scope) so navigating away during the undo window still commits the delete
649 * instead of silently cancelling it — mirroring [CrashLogger.scheduleDelete]. */
797 * instead of silently cancelling it — mirroring [CrashLogger.scheduleDelete]. */
@@ -815,6 +963,22 @@ open class AppContainer private constructor(
815 runCatching { client.close() }
963 runCatching { client.close() }
816 }
964 }
817 }
965 }
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+ }
818}
982}
819
983
820/**
984/**
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+11−8
@@ -31,11 +31,13 @@ class NotificationActionReceiver : BroadcastReceiver() {
31 val response = PermissionResponse.entries.firstOrNull {
31 val response = PermissionResponse.entries.firstOrNull {
32 it.wire == intent.getStringExtra(EXTRA_RESPONSE)
32 it.wire == intent.getStringExtra(EXTRA_RESPONSE)
33 } ?: return
33 } ?: return
34− // Dismiss the notification immediately for responsive feedback; the reply
35− // itself continues in the background.
36− SessionNotifications.cancelPermission(context, sessionId)
37 val pending = goAsync()
34 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)
39 pending.finish()
41 pending.finish()
40 }
42 }
41 }
43 }
@@ -44,10 +46,11 @@ class NotificationActionReceiver : BroadcastReceiver() {
44 ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
46 ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
45 ?.takeIf { it.isNotEmpty() } ?: return
47 ?.takeIf { it.isNotEmpty() } ?: return
46 val pending = goAsync()
48 val pending = goAsync()
47− container.sendPromptFromNotification(sessionId, text) {
49+ container.sendPromptFromNotification(sessionId, text) { enqueued ->
50+ // The reply is durably queued (and flushed now if online); clear the
48− // The follow-up is on its way; clear the "session ready" notification.
51+ // "session ready" notification. If the enqueue failed, leave it up so the
49− // A fresh run (and its foreground indicator) now signals progress.
52+ // reply isn't silently lost and the user can retry.
50− SessionNotifications.cancel(context, sessionId)
53+ if (enqueued) SessionNotifications.cancel(context, sessionId)
51 pending.finish()
54 pending.finish()
52 }
55 }
53 }
56 }
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+15
@@ -75,6 +75,21 @@ class RunForegroundService : Service() {
75 .build()
75 .build()
76 }
76 }
77
77
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+
78 override fun onDestroy() {
93 override fun onDestroy() {
79 // Safety net: if the process is being killed while a run is in progress,
94 // Safety net: if the process is being killed while a run is in progress,
80 // ensure the notification is removed rather than lingering.
95 // 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 {
212 fun cancelPermission(context: Context, sessionId: String) {
212 fun cancelPermission(context: Context, sessionId: String) {
213 NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
213 NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
214 }
214 }
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+ }
215}
222}
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.kt+5−1
@@ -42,6 +42,10 @@ private class SessionsWidgetFactory(private val context: Context) : RemoteViewsS
42
42
43 override fun getLoadingView(): RemoteViews? = null
43 override fun getLoadingView(): RemoteViews? = null
44 override fun getViewTypeCount(): Int = 1
44 override fun getViewTypeCount(): Int = 1
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.
45− override fun getItemId(position: Int): Long = position.toLong()
48+ override fun getItemId(position: Int): Long =
49+ items.getOrNull(position)?.id?.hashCode()?.toLong() ?: position.toLong()
46 override fun hasStableIds(): Boolean = true
50 override fun hasStableIds(): Boolean = true
47}
51}
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+26−13
@@ -22,7 +22,6 @@ import androidx.compose.material3.Surface
22import androidx.compose.material3.Text
22import androidx.compose.material3.Text
23import androidx.compose.runtime.Composable
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.DisposableEffect
24import androidx.compose.runtime.DisposableEffect
25−import androidx.compose.runtime.LaunchedEffect
26import androidx.compose.runtime.getValue
25import androidx.compose.runtime.getValue
27import androidx.compose.runtime.mutableStateOf
26import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
27import androidx.compose.runtime.remember
@@ -85,21 +84,18 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
85 content()
84 content()
86 return
85 return
87 }
86 }
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+ }
88
94
89 // Survives config changes / process-death restore so a rotation doesn't re-prompt, but
95 // Survives config changes / process-death restore so a rotation doesn't re-prompt, but
90 // defaults to locked on a fresh start.
96 // defaults to locked on a fresh start.
91 var unlocked by rememberSaveable { mutableStateOf(false) }
97 var unlocked by rememberSaveable { mutableStateOf(false) }
92
98
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−
103 val title = stringResource(R.string.app_lock_prompt_title)
99 val title = stringResource(R.string.app_lock_prompt_title)
104 val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
100 val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
105
101
@@ -136,8 +132,25 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
136 .onFailure { authInFlight = false }
132 .onFailure { authInFlight = false }
137 }
133 }
138
134
139− // Auto-prompt on first show and whenever we return to the locked state.
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
140− LaunchedEffect(unlocked) { if (!unlocked) authenticate() }
147+ Lifecycle.Event.ON_START -> if (!unlocked) authenticate()
148+ else -> {}
149+ }
150+ }
151+ lifecycleOwner.lifecycle.addObserver(observer)
152+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
153+ }
141
154
142 Box(modifier = Modifier.fillMaxSize()) {
155 Box(modifier = Modifier.fillMaxSize()) {
143 content()
156 content()
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+21−26
@@ -54,50 +54,45 @@ fun OpencodeApp(container: AppContainer) {
54 val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
54 val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
55 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
55 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
56
56
57− // Hold a foreground priority for as long as ANY session is actively running — not just
57+ // The foreground service that holds priority while any run is active is driven from the
58− // while a running chat is on screen — so a run started and then backgrounded (or left
58+ // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
59− // for the session list) still keeps the SSE stream alive to deliver its completion
59+ // from here — a composition-scoped collector pauses while backgrounded, which is exactly
60− // notification. Doze/app-standby would otherwise choke the socket once priority drops.
60+ // when a run started via the notification reply needs the service to start.
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− }
70
61
71 // When text is shared into the app, surface the session list so the user can pick
62 // When text is shared into the app, surface the session list so the user can pick
72 // (or create) a conversation to drop it into. The chosen session's draft is set
63 // (or create) a conversation to drop it into. The chosen session's draft is set
73 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
64 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
74 // before auto-reconnect completes is retried once the connection is established.
65 // 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
66+ // Track the last-handled share payloads (there's no share nonce on the container) so a
76− // change) doesn't re-bounce the user to the session list for a share they've
67+ // *new* share arriving while already connected — a warm start where onNewIntent re-fires
77− // already decided to abandon; only the null -> non-null initial connect retries.
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.
78− var lastConnectionId by remember { mutableStateOf<String?>(null) }
71+ var handledShareText by remember { mutableStateOf<String?>(null) }
72+ var handledSharedMedia by remember { mutableStateOf<List<String>>(emptyList()) }
79 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
73 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
80 if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
74 if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
81− if (connection == null) { lastConnectionId = null; return@LaunchedEffect }
75+ if (connection == null) return@LaunchedEffect
82− val currentId = connection?.profile?.id
76+ val newShare = (pendingShare != null && pendingShare != handledShareText) ||
83− // Only (re)navigate on the initial null -> non-null connect. A subsequent
77+ (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
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
78+ if (!newShare) return@LaunchedEffect
87− lastConnectionId = currentId
79+ handledShareText = pendingShare
80+ handledSharedMedia = pendingSharedMedia
88 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
81 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
89 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
82 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
90 }
83 }
91 }
84 }
92
85
93 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
86 // "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.
95 LaunchedEffect(pendingNewSession, connection) {
90 LaunchedEffect(pendingNewSession, connection) {
96 if (!pendingNewSession) return@LaunchedEffect
91 if (!pendingNewSession) return@LaunchedEffect
97 val conn = connection ?: return@LaunchedEffect
92 val conn = connection ?: return@LaunchedEffect
98− if (!container.consumePendingNewSession()) return@LaunchedEffect
99 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
93 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
100 ?: return@LaunchedEffect
94 ?: return@LaunchedEffect
95+ if (!container.consumePendingNewSession()) return@LaunchedEffect
101 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
96 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
102 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
97 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
103 }
98 }
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+7−1
@@ -172,7 +172,13 @@ fun AgentPickerSheet(
172 }
172 }
173 }
173 }
174 }
174 }
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).
175− items(filtered, key = { it.name }) { agent ->
181+ items(filtered.distinctBy { it.name }, key = { "agent_" + it.name }) { agent ->
176 val isSelected = agent.name == selected
182 val isSelected = agent.name == selected
177 Column(
183 Column(
178 modifier = Modifier
184 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
20 val resolver = context.contentResolver
20 val resolver = context.contentResolver
21 val mime = resolver.getType(this@toAttachmentResult) ?: "application/octet-stream"
21 val mime = resolver.getType(this@toAttachmentResult) ?: "application/octet-stream"
22 val name = displayName(context) ?: defaultName(mime)
22 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.
23 val bytes = runCatching {
31 val bytes = runCatching {
24− resolver.openInputStream(this@toAttachmentResult)?.use { it.readBytes() }
32+ resolver.openInputStream(this@toAttachmentResult)?.use { it.readAtMost(max + 1) }
25 }.getOrNull() ?: return@withContext AttachmentResult.Failed
33 }.getOrNull() ?: return@withContext AttachmentResult.Failed
26− if (bytes.size > NetworkConfig.maxAttachmentBytes) return@withContext AttachmentResult.TooLarge
34+ if (bytes.size > max) return@withContext AttachmentResult.TooLarge
27 val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
35 val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
28 val dataUrl = "data:$mime;base64,$b64"
36 val dataUrl = "data:$mime;base64,$b64"
29 AttachmentResult.Ok(
37 AttachmentResult.Ok(
@@ -37,6 +45,50 @@ suspend fun Uri.toAttachmentResult(context: Context): AttachmentResult = withCon
37 )
45 )
38}
46}
39
47
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+
40/** Resolve a human-readable display name for the content [uri], if the provider exposes one. */
92/** Resolve a human-readable display name for the content [uri], if the provider exposes one. */
41private fun Uri.displayName(context: Context): String? = runCatching {
93private fun Uri.displayName(context: Context): String? = runCatching {
42 context.contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
94 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(
1207 }
1207 }
1208 },
1208 },
1209 keyboardOptions = KeyboardOptions(
1209 keyboardOptions = KeyboardOptions(
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.
1210− imeAction = ImeAction.Send,
1214+ imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
1211 capitalization = KeyboardCapitalization.Sentences,
1215 capitalization = KeyboardCapitalization.Sentences,
1212 ),
1216 ),
1213 keyboardActions = KeyboardActions(onSend = {
1217 keyboardActions = KeyboardActions(onSend = {
@@ -1515,8 +1519,7 @@ private sealed interface MessageListItem {
1515 val key: Any
1519 val key: Any
1516 val contentType: Any
1520 val contentType: Any
1517
1521
1518− data class Message(val message: MessageWithParts) : MessageListItem {
1522+ data class Message(val message: MessageWithParts, override val key: Any) : MessageListItem {
1519− override val key: Any get() = message.info.id
1520 override val contentType: Any get() = message.info::class
1523 override val contentType: Any get() = message.info::class
1521 }
1524 }
1522
1525
@@ -1552,8 +1555,10 @@ private fun buildMessageListItems(
1552 val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
1555 val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
1553 val dateFmt = mediumDateFormat()
1556 val dateFmt = mediumDateFormat()
1554 val result = ArrayList<MessageListItem>(messages.size + 4)
1557 val result = ArrayList<MessageListItem>(messages.size + 4)
1558+ val seenKeys = HashSet<String>(messages.size * 2)
1555 var lastDayKey: String? = null
1559 var lastDayKey: String? = null
1556 var sepOrdinal = 0
1560 var sepOrdinal = 0
1561+ var emptyIdOrdinal = 0
1557 for (message in messages) {
1562 for (message in messages) {
1558 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1563 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1559 val dayKey = ts?.let { dayKey(it) } ?: ""
1564 val dayKey = ts?.let { dayKey(it) } ?: ""
@@ -1561,7 +1566,17 @@ private fun buildMessageListItems(
1561 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
1566 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
1562 lastDayKey = dayKey
1567 lastDayKey = dayKey
1563 }
1568 }
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+ }
1564− result.add(MessageListItem.Message(message))
1579+ result.add(MessageListItem.Message(message, key))
1565 }
1580 }
1566 return result
1581 return result
1567}
1582}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+93−24
@@ -161,27 +161,37 @@ class ChatViewModel(
161 // A value flowed through: the stream recovered, so allow the next distinct
161 // A value flowed through: the stream recovered, so allow the next distinct
162 // failure to surface a fresh snackbar again.
162 // failure to surface a fresh snackbar again.
163 loadErrorSnackbarShown = false
163 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
164 if (it.isNotEmpty()) {
170 if (it.isNotEmpty()) {
165 hasShownMessages = true
171 hasShownMessages = true
166− _loadError.value = false
167 }
172 }
168 }
173 }
169− .retryWhen { cause, _ ->
174+ .retryWhen { cause, attempt ->
170 _loading.value = false
175 _loading.value = false
171 // Only surface the persistent error state when there's nothing to
176 // Only surface the persistent error state when there's nothing to
172 // show — if we already have messages, a transient re-fetch failure
177 // show — if we already have messages, a transient re-fetch failure
173 // is better surfaced as a snackbar (via errorEvents) than by
178 // is better surfaced as a snackbar (via errorEvents) than by
174 // replacing the visible conversation with an error screen.
179 // replacing the visible conversation with an error screen.
175 if (!hasShownMessages) _loadError.value = true
180 if (!hasShownMessages) _loadError.value = true
176− // Surface the snackbar only once per failure streak — retryWhen loops
181+ // Surface the snackbar only once per failure streak — retryWhen loops on a
177− // every retryInitialDelayMs, so emitting here unconditionally would spam a
182+ // backoff, so emitting here unconditionally would spam a new snackbar every
178− // new snackbar every few seconds on a persistent load failure.
183+ // retry on a persistent load failure.
179 if (!loadErrorSnackbarShown) {
184 if (!loadErrorSnackbarShown) {
180 _errorEvents.trySend(ChatError(container.friendlyError(cause)))
185 _errorEvents.trySend(ChatError(container.friendlyError(cause)))
181 loadErrorSnackbarShown = true
186 loadErrorSnackbarShown = true
182 }
187 }
183− // Delay before retrying to avoid a tight loop on persistent errors.
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.
184− delay(NetworkConfig.retryInitialDelayMs)
192+ val backoffMs = (NetworkConfig.retryInitialDelayMs shl attempt.coerceAtMost(16L).toInt())
193+ .coerceAtMost(NetworkConfig.retryMaxDelayMs)
194+ delay(backoffMs)
185 true
195 true
186 }
196 }
187 .stateIn(
197 .stateIn(
@@ -263,6 +273,28 @@ class ChatViewModel(
263 private val _pendingPermission = MutableStateFlow<Permission?>(null)
273 private val _pendingPermission = MutableStateFlow<Permission?>(null)
264 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
274 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
265
275
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+
266 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
298 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
267 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
299 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
268
300
@@ -295,6 +327,11 @@ class ChatViewModel(
295 private val _failedDraft = MutableStateFlow<String?>(null)
327 private val _failedDraft = MutableStateFlow<String?>(null)
296 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
328 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
297
329
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+
298 /** Per-session draft, persisted so it survives navigation/process death. */
335 /** Per-session draft, persisted so it survives navigation/process death. */
299 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
336 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
300 val draft: StateFlow<String> = _draft.asStateFlow()
337 val draft: StateFlow<String> = _draft.asStateFlow()
@@ -504,11 +541,11 @@ class ChatViewModel(
504 // returns with no connection) until a later reconnect happens to clear it.
541 // returns with no connection) until a later reconnect happens to clear it.
505 _running.value = false
542 _running.value = false
506 _aborting.value = false
543 _aborting.value = false
507− _pendingPermission.value = null
544+ clearPermissions()
508 return@collectLatest
545 return@collectLatest
509 }
546 }
510 _running.value = false
547 _running.value = false
511− _pendingPermission.value = null
548+ clearPermissions()
512 _failedDraft.value = null
549 _failedDraft.value = null
513 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
550 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
514 // user intent that is now persisted; wiping it on every (re)connect — which
551 // user intent that is now persisted; wiping it on every (re)connect — which
@@ -540,11 +577,17 @@ class ChatViewModel(
540 setQueuedFollowUp(null)
577 setQueuedFollowUp(null)
541 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
578 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
542 }
579 }
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+ }
543 when (event) {
586 when (event) {
544 is PermissionUpdated ->
587 is PermissionUpdated ->
545− if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
588+ if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
546 is PermissionReplied ->
589 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) }
548 is SessionUpdated ->
591 is SessionUpdated ->
549 if (event.properties.info.id == sessionId) {
592 if (event.properties.info.id == sessionId) {
550 val info = event.properties.info
593 val info = event.properties.info
@@ -654,7 +697,7 @@ class ChatViewModel(
654 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
697 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
655 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
698 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
656 */
699 */
657− private fun send(text: String, includeAttachments: Boolean): Boolean {
700+ private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean {
658 val trimmed = text.trim()
701 val trimmed = text.trim()
659 val attachments = if (includeAttachments) _attachments.value else emptyList()
702 val attachments = if (includeAttachments) _attachments.value else emptyList()
660 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
703 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
@@ -670,6 +713,10 @@ class ChatViewModel(
670 }
713 }
671 if (!_running.compareAndSet(false, true)) return false
714 if (!_running.compareAndSet(false, true)) return false
672 _failedDraft.value = null
715 _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()
673 // Clear the staged attachments optimistically so the composer empties immediately;
720 // Clear the staged attachments optimistically so the composer empties immediately;
674 // restore them if the send fails (mirrors the draft handling below).
721 // restore them if the send fails (mirrors the draft handling below).
675 if (attachments.isNotEmpty()) _attachments.value = emptyList()
722 if (attachments.isNotEmpty()) _attachments.value = emptyList()
@@ -694,10 +741,12 @@ class ChatViewModel(
694 attachments = attachments.map { it.part },
741 attachments = attachments.map { it.part },
695 model = _selectedModel.value?.ref,
742 model = _selectedModel.value?.ref,
696 agent = _selectedAgent.value,
743 agent = _selectedAgent.value,
744+ idempotencyKey = key,
697 )
745 )
698 }.onFailure {
746 }.onFailure {
699 suppressDraftPersist.set(false)
747 suppressDraftPersist.set(false)
700 _failedDraft.value = trimmed
748 _failedDraft.value = trimmed
749+ failedIdempotencyKey = key
701 // Only restore the draft if the user hasn't typed anything new since.
750 // Only restore the draft if the user hasn't typed anything new since.
702 if (_draft.value.isBlank()) updateDraft(trimmed)
751 if (_draft.value.isBlank()) updateDraft(trimmed)
703 // Restore the staged attachments too, so a failed send doesn't lose them —
752 // Restore the staged attachments too, so a failed send doesn't lose them —
@@ -808,12 +857,16 @@ class ChatViewModel(
808 /** Re-send the last draft whose send failed, if any. */
857 /** Re-send the last draft whose send failed, if any. */
809 fun retryFailed() {
858 fun retryFailed() {
810 val draft = _failedDraft.value ?: return
859 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
811 // Don't clear _failedDraft until send() accepts the text — if _running is
863 // Don't clear _failedDraft until send() accepts the text — if _running is
812 // already true, send() returns false and the draft would be lost forever.
864 // already true, send() returns false and the draft would be lost forever.
813 // Attachments aren't re-sent on retry (they were optimistically restored to the
865 // Attachments aren't re-sent on retry (they were optimistically restored to the
814 // composer on the original failure, so they'll ride the next manual send).
866 // 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)) {
816 _failedDraft.value = null
868 _failedDraft.value = null
869+ failedIdempotencyKey = null
817 }
870 }
818 }
871 }
819
872
@@ -903,6 +956,17 @@ class ChatViewModel(
903 }
956 }
904
957
905 fun addAttachment(attachment: PendingAttachment) {
958 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+ }
906 _attachments.update { it + attachment }
970 _attachments.update { it + attachment }
907 persistAttachments()
971 persistAttachments()
908 }
972 }
@@ -1143,16 +1207,15 @@ class ChatViewModel(
1143
1207
1144 fun respondPermission(permission: Permission, response: PermissionResponse) {
1208 fun respondPermission(permission: Permission, response: PermissionResponse) {
1145 val conn = connection ?: return
1209 val conn = connection ?: return
1146− // Clear optimistically; permission.replied will confirm. If the call fails we
1210+ // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1147− // restore the pending permission so the user can retry instead of being stuck
1211+ // If the call fails we re-surface this request so the user can retry instead of being stuck
1148− // with a dismissed dialog and a paused tool run.
1212+ // with a dismissed dialog and a tool run that's still paused server-side.
1149− _pendingPermission.value = null
1213+ resolvePermission(permission.id)
1150 viewModelScope.launch {
1214 viewModelScope.launch {
1151 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1215 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1152 .onFailure {
1216 .onFailure {
1153 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1217 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1154− // Only restore if no new permission has arrived in the meantime.
1218+ enqueuePermission(permission)
1155− if (_pendingPermission.value == null) _pendingPermission.value = permission
1156 }
1219 }
1157 }
1220 }
1158 }
1221 }
@@ -1169,11 +1232,17 @@ class ChatViewModel(
1169 // asynchronous apply() (not a synchronous commit) so the main thread isn't
1232 // asynchronous apply() (not a synchronous commit) so the main thread isn't
1170 // blocked on disk I/O — Android's SharedPreferences framework guarantees
1233 // blocked on disk I/O — Android's SharedPreferences framework guarantees
1171 // pending apply() writes are flushed before the process exits.
1234 // pending apply() writes are flushed before the process exits.
1172− // Always flush — including an empty draft. Persistence is otherwise debounced, so if
1235+ // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
1173− // the user cleared the input and navigated back within the debounce window, the
1236+ // so if the user cleared the input and navigated back within the debounce window, the
1174 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1237 // 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
1238+ // previous non-empty draft. flushDraft() removes the key when the text is blank, so
1176− // unconditional flush commits the clear instead of resurrecting the deleted text.
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).
1177− container.draftStore.flushDraft(sessionId, _draft.value)
1246+ if (!suppressDraftPersist.get()) container.draftStore.flushDraft(sessionId, _draft.value)
1178 }
1247 }
1179}
1248}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+3−1
@@ -130,7 +130,9 @@ fun CommandPickerSheet(
130 )
130 )
131 } else {
131 } else {
132 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
132 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
133+ // distinctBy the name so duplicate command names from the server can't
134+ // crash the list with LazyColumn's "Key was already used".
133− items(filtered, key = { it.name }) { cmd ->
135+ items(filtered.distinctBy { it.name }, key = { it.name }) { cmd ->
134 Column(
136 Column(
135 modifier = Modifier
137 modifier = Modifier
136 .fillMaxWidth()
138 .fillMaxWidth()
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+3−1
@@ -139,7 +139,9 @@ fun ModelPickerSheet(
139 modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
139 modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
140 )
140 )
141 }
141 }
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".
142− items(opts, key = { it.providerID to it.modelID }) { option ->
144+ items(opts.distinctBy { it.providerID to it.modelID }, key = { it.providerID to it.modelID }) { option ->
143 val isSelected = option.providerID == selected?.providerID &&
145 val isSelected = option.providerID == selected?.providerID &&
144 option.modelID == selected?.modelID
146 option.modelID == selected?.modelID
145 Column(
147 Column(
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+61−6
@@ -6,7 +6,7 @@ import android.os.Looper
6import android.speech.tts.TextToSpeech
6import android.speech.tts.TextToSpeech
7import android.speech.tts.UtteranceProgressListener
7import android.speech.tts.UtteranceProgressListener
8import androidx.compose.runtime.Composable
8import androidx.compose.runtime.Composable
9−import androidx.compose.runtime.DisposableEffect
9+import androidx.compose.runtime.RememberObserver
10import androidx.compose.runtime.State
10import androidx.compose.runtime.State
11import androidx.compose.runtime.mutableStateOf
11import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.remember
12import androidx.compose.runtime.remember
@@ -15,13 +15,13 @@ import androidx.compose.ui.platform.LocalContext
15/**
15/**
16 * Remember a [TtsController] scoped to the current composition; it shuts the engine down
16 * Remember a [TtsController] scoped to the current composition; it shuts the engine down
17 * when the composition leaves (so the process isn't left holding a TextToSpeech instance).
17 * 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.
18 */
20 */
19@Composable
21@Composable
20fun rememberTtsController(): TtsController {
22fun rememberTtsController(): TtsController {
21 val context = LocalContext.current
23 val context = LocalContext.current
22− val controller = remember { TtsController(context.applicationContext) }
24+ return remember { TtsController(context.applicationContext) }
23− DisposableEffect(Unit) { onDispose { controller.shutdown() } }
24− return controller
25}
25}
26
26
27/**
27/**
@@ -33,10 +33,11 @@ fun rememberTtsController(): TtsController {
33 * TTS is an optional convenience). Utterance-progress callbacks arrive on a binder thread,
33 * TTS is an optional convenience). Utterance-progress callbacks arrive on a binder thread,
34 * so state is cleared on the main thread via [mainHandler].
34 * so state is cleared on the main thread via [mainHandler].
35 */
35 */
36−class TtsController(context: Context) {
36+class TtsController(context: Context) : RememberObserver {
37
37
38 private val mainHandler = Handler(Looper.getMainLooper())
38 private val mainHandler = Handler(Looper.getMainLooper())
39 private var ready = false
39 private var ready = false
40+ private var shutDown = false
40
41
41 private val _speakingId = mutableStateOf<String?>(null)
42 private val _speakingId = mutableStateOf<String?>(null)
42 val speakingId: State<String?> = _speakingId
43 val speakingId: State<String?> = _speakingId
@@ -62,18 +63,72 @@ class TtsController(context: Context) {
62 fun toggle(id: String, text: String) {
63 fun toggle(id: String, text: String) {
63 if (_speakingId.value == id) { stop(); return }
64 if (_speakingId.value == id) { stop(); return }
64 if (!ready || text.isBlank()) return
65 if (!ready || text.isBlank()) return
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 ->
65− tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, id)
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+ }
66 _speakingId.value = id
81 _speakingId.value = id
67 }
82 }
68
83
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+
69 fun stop() {
116 fun stop() {
70 tts.stop()
117 tts.stop()
71 _speakingId.value = null
118 _speakingId.value = null
72 }
119 }
73
120
74 fun shutdown() {
121 fun shutdown() {
122+ if (shutDown) return
123+ shutDown = true
75 tts.stop()
124 tts.stop()
76 tts.shutdown()
125 tts.shutdown()
77 _speakingId.value = null
126 _speakingId.value = null
78 }
127 }
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()
79}
134}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+1−1
@@ -290,5 +290,5 @@ private var lastToast: Toast? = null
290
290
291internal fun showToast(context: Context, message: String) {
291internal fun showToast(context: Context, message: String) {
292 lastToast?.cancel()
292 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() }
294}
294}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+62−14
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.heightIn
7import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.runtime.Composable
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.Immutable
9import androidx.compose.runtime.Immutable
10+import androidx.compose.runtime.produceState
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.remember
11import androidx.compose.ui.Alignment
12import androidx.compose.ui.Alignment
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.Modifier
@@ -19,6 +20,8 @@ import androidx.compose.ui.semantics.semantics
19import androidx.compose.ui.unit.dp
20import androidx.compose.ui.unit.dp
20import coil.compose.SubcomposeAsyncImage
21import coil.compose.SubcomposeAsyncImage
21import coil.request.ImageRequest
22import coil.request.ImageRequest
23+import kotlinx.coroutines.Dispatchers
24+import kotlinx.coroutines.withContext
22import androidx.compose.material3.CircularProgressIndicator
25import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.Icon
26import androidx.compose.material3.Icon
24import androidx.compose.material.icons.Icons
27import androidx.compose.material.icons.Icons
@@ -46,26 +49,55 @@ fun ServerProfile.toImageContext(): ImageLoadContext {
46 } else {
49 } else {
47 null
50 null
48 }
51 }
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.
49− return ImageLoadContext(baseUrl = baseUrl, basicAuthHeader = auth)
56+ return ImageLoadContext(baseUrl = effectiveImageBaseUrl(), basicAuthHeader = auth)
50}
57}
51
58
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+
52/** True if the part references a raster/vector image we can render. */
80/** True if the part references a raster/vector image we can render. */
53val FilePart.isImage: Boolean
81val FilePart.isImage: Boolean
54 get() = mime?.startsWith("image/") == true
82 get() = mime?.startsWith("image/") == true
55
83
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+
56/**
91/**
57 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
92 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
58 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
93 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
59 */
94 */
60private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
95private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
61 val src = source
96 val src = source
62− if (!src.isNullOrBlank() && src.startsWith("data:")) {
97+ if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
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− }
68 val url = url ?: return null
98 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)
69 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
101 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
70 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
102 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
71 // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
103 // 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? {
78 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
110 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
79 // sending the request (with its Basic auth) to another host. A server-absolute path
111 // sending the request (with its Basic auth) to another host. A server-absolute path
80 // resolves to a different base *path* but the same origin, so it must still be allowed.
112 // 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).
81 val sameOrigin = resolved.host != null &&
117 val sameOrigin = resolved.host != null &&
82 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
118 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
83 resolved.host.equals(baseUri.host, ignoreCase = true) &&
119 resolved.host.equals(baseUri.host, ignoreCase = true) &&
84− resolved.port == baseUri.port
120+ effectivePort(resolved) == effectivePort(baseUri)
85 return if (sameOrigin) resolved.toString() else null
121 return if (sameOrigin) resolved.toString() else null
86}
122}
87
123
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+
88/**
134/**
89 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
135 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
90 * and server-relative URLs (fetched with Basic auth when the server requires it).
136 * and server-relative URLs (fetched with Basic auth when the server requires it).
@@ -93,15 +139,17 @@ private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
93@Composable
139@Composable
94fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
140fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
95 val context = LocalContext.current
141 val context = LocalContext.current
96− val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
142+ val model = produceState<Any?>(initialValue = null, part.source, part.url, ctx.baseUrl) {
143+ value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
144+ }.value ?: return
97− val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
145+ val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model) {
98 ImageRequest.Builder(context)
146 ImageRequest.Builder(context)
99 .data(model)
147 .data(model)
100 .apply {
148 .apply {
101− // Only send Basic auth over HTTPS; sending credentials over
149+ // resolveModel already verified same-origin, so a String model can only point at
102− // cleartext HTTP would expose them on the network.
150+ // the user's own server — attaching Basic auth over http (not just https) is safe.
103− val isHttps = (model as? String)?.startsWith("https://") == true
151+ // A data-URI model is a ByteArray, so gate on the model being a URL String.
104− if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
152+ if (model is String) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
105 }
153 }
106 .crossfade(true)
154 .crossfade(true)
107 .build()
155 .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) {
243 return
243 return
244 }
244 }
245 LazyColumn(modifier = Modifier.fillMaxSize()) {
245 LazyColumn(modifier = Modifier.fillMaxSize()) {
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.
246− items(results, key = { it }) { path ->
248+ itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
247 // Split the path into directory (muted) + filename (emphasized) so results
249 // Split the path into directory (muted) + filename (emphasized) so results
248 // are scannable instead of a wall of identical-looking full paths.
250 // are scannable instead of a wall of identical-looking full paths.
249 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
251 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(
187 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
187 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
188 label = "fileViewState",
188 label = "fileViewState",
189 ) { s ->
189 ) { s ->
190− // Derive match indices from THIS layer's content (s), not the outer/newest
190+ // Single source of truth for matches: the off-thread `matchIndices` produceState
191− // state. During the crossfade the exiting layer still renders the old
191+ // (keyed on the file content + debounced query). Both navigation (onPrev/onNext +
192− // content; passing the outer matchIndices (computed from the new content)
192+ // scroll) and display (counter + highlight) read from it, so the counter can't show
193− // would highlight and scroll the wrong lines until the animation settles.
193+ // a new query while next/prev act on a stale set. The earlier in-composition
194− val sMatchIndices = remember(s.content?.content, debouncedFind, lines, rawText) {
194+ // per-layer scan re-ran the whole-file O(n) match on the UI thread each keystroke
195− val q = debouncedFind
195+ // (the exact work produceState was moved off-thread to avoid); it's acceptable for
196− if (q.isEmpty()) emptyList()
196+ // the crossfade's exiting layer to briefly use this unified set until it settles.
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− }
206 FileViewStateContent(
197 FileViewStateContent(
207 state = s,
198 state = s,
208 filename = filename,
199 filename = filename,
@@ -219,7 +210,7 @@ fun FileViewScreen(
219 findActive = findActive,
210 findActive = findActive,
220 findQuery = findQuery,
211 findQuery = findQuery,
221 onQueryChange = { findQuery = it; matchPos = 0 },
212 onQueryChange = { findQuery = it; matchPos = 0 },
222− matchIndices = sMatchIndices,
213+ matchIndices = matchIndices,
223 matchPos = matchPos,
214 matchPos = matchPos,
224 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
215 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
225 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
216 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
3import androidx.lifecycle.ViewModel
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.Job
6import kotlinx.coroutines.flow.MutableStateFlow
7import kotlinx.coroutines.flow.MutableStateFlow
7import kotlinx.coroutines.flow.StateFlow
8import kotlinx.coroutines.flow.StateFlow
8import kotlinx.coroutines.flow.asStateFlow
9import kotlinx.coroutines.flow.asStateFlow
@@ -24,15 +25,21 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
24 private val _state = MutableStateFlow<State>(State.Loading)
25 private val _state = MutableStateFlow<State>(State.Loading)
25 val state: StateFlow<State> = _state.asStateFlow()
26 val state: StateFlow<State> = _state.asStateFlow()
26
27
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+
27 init { load() }
32 init { load() }
28
33
29 fun load() {
34 fun load() {
30 val conn = container.activeConnection.value ?: run {
35 val conn = container.activeConnection.value ?: run {
36+ loadJob?.cancel()
31 _state.value = State.Disconnected
37 _state.value = State.Disconnected
32 return
38 return
33 }
39 }
34 _state.value = State.Loading
40 _state.value = State.Loading
41+ loadJob?.cancel()
35− viewModelScope.launch {
42+ loadJob = viewModelScope.launch {
36 runCatchingCancellable {
43 runCatchingCancellable {
37 val config = conn.api.config()
44 val config = conn.api.config()
38 withContext(Dispatchers.Default) { parseMcpServers(config) }
45 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() {
59 _state.update { it.copy(searching = false, results = emptyList(), hasSearched = false, error = null) }
59 _state.update { it.copy(searching = false, results = emptyList(), hasSearched = false, error = null) }
60 return
60 return
61 }
61 }
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) }
62 searchJob = viewModelScope.launch {
65 searchJob = viewModelScope.launch {
63 delay(NetworkConfig.searchDebounceMs)
66 delay(NetworkConfig.searchDebounceMs)
64 runSearch(trimmed)
67 runSearch(trimmed)
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+2−2
@@ -152,7 +152,7 @@ private fun ServerEditForm(
152 // canSave parses the base URL with java.net.URI (which throws — and fills a stack trace —
152 // canSave parses the base URL with java.net.URI (which throws — and fills a stack trace —
153 // on the common half-typed URL). Memoize on baseUrl so it runs once per edit instead of
153 // on the common half-typed URL). Memoize on baseUrl so it runs once per edit instead of
154 // being re-parsed at each of the enabled= call sites on every keystroke's recomposition.
154 // 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 }
156 Column(
156 Column(
157 modifier = Modifier
157 modifier = Modifier
158 .padding(padding)
158 .padding(padding)
@@ -303,7 +303,7 @@ private fun AuthFields(
303 val hidePasswordLabel = stringResource(R.string.hide_password)
303 val hidePasswordLabel = stringResource(R.string.hide_password)
304 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
304 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
305 // every keystroke's recomposition (see ServerEditForm).
305 // every keystroke's recomposition (see ServerEditForm).
306− val canSave = remember(state.baseUrl) { state.canSave }
306+ val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
307 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
307 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
308 Text(
308 Text(
309 stringResource(R.string.auth_required),
309 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
14import kotlinx.coroutines.flow.update
14import kotlinx.coroutines.flow.update
15import kotlinx.coroutines.launch
15import kotlinx.coroutines.launch
16import kotlinx.coroutines.withTimeoutOrNull
16import kotlinx.coroutines.withTimeoutOrNull
17+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
17import soy.iko.opencode.R
18import soy.iko.opencode.R
18import soy.iko.opencode.util.runCatchingCancellable
19import soy.iko.opencode.util.runCatchingCancellable
19import java.util.UUID
20import java.util.UUID
@@ -78,15 +79,22 @@ data class InitialProfile(
78fun isValidCertPin(raw: String): Boolean {
79fun isValidCertPin(raw: String): Boolean {
79 val entries = raw.split(Regex("[\\s,]+")).map { it.trim() }.filter { it.isNotEmpty() }
80 val entries = raw.split(Regex("[\\s,]+")).map { it.trim() }.filter { it.isNotEmpty() }
80 if (entries.isEmpty()) return true
81 if (entries.isEmpty()) return true
82+ // FIX 21(c): require a well-formed sha256/<base64>= pin (at least one base64 char
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.
81− val pinRegex = Regex("sha256/[A-Za-z0-9+/=]+")
85+ val pinRegex = Regex("sha256/[A-Za-z0-9+/]+=")
82 return entries.all { it.matches(pinRegex) }
86 return entries.all { it.matches(pinRegex) }
83}
87}
84
88
85−fun isValidUrl(url: String): Boolean = try {
89+fun isValidUrl(url: String): Boolean {
86− val u = java.net.URI(url.trim())
90+ val trimmed = url.trim()
87− u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
91+ if (trimmed.isBlank()) return false
88−} catch (_: Exception) {
92+ // Parse with OkHttp's own parser (like HttpClientFactory) — java.net.URI rejects hosts
89− false
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
90}
98}
91
99
92/**
100/**
@@ -119,11 +127,15 @@ class ServerEditViewModel(
119 .firstOrNull { it.id == profileId }
127 .firstOrNull { it.id == profileId }
120 }
128 }
121 if (existing != null) {
129 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).
122 val init = InitialProfile(
134 val init = InitialProfile(
123− label = existing.label,
135+ label = existing.label.trim(),
124− baseUrl = existing.baseUrl,
136+ baseUrl = existing.baseUrl.trim(),
125− username = existing.username.orEmpty(),
137+ username = existing.username.orEmpty().trim(),
126− password = existing.password.orEmpty(),
138+ password = existing.password.orEmpty().trim(),
127 requireHttps = existing.requireHttps,
139 requireHttps = existing.requireHttps,
128 certPin = existing.certPin.orEmpty().trim(),
140 certPin = existing.certPin.orEmpty().trim(),
129 )
141 )
@@ -207,10 +219,17 @@ class ServerEditViewModel(
207 val s = _state.value
219 val s = _state.value
208 if (!s.canSave || s.probing) return
220 if (!s.canSave || s.probing) return
209 _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
221 _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()
210 viewModelScope.launch {
226 viewModelScope.launch {
211− val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
227+ val result = runCatchingCancellable { container.probeServer(probedUrl) }
212 result.onSuccess { pr ->
228 result.onSuccess { pr ->
213 _state.update {
229 _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)
214 when (pr) {
233 when (pr) {
215 is ProbeResult.Reachable -> it.copy(
234 is ProbeResult.Reachable -> it.copy(
216 probing = false,
235 probing = false,
@@ -233,7 +252,10 @@ class ServerEditViewModel(
233 }
252 }
234 }
253 }
235 }.onFailure { e ->
254 }.onFailure { e ->
255+ _state.update {
256+ if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
236− _state.update { it.copy(probing = false, error = container.friendlyError(e), probeReachable = false) }
257+ it.copy(probing = false, error = container.friendlyError(e), probeReachable = false)
258+ }
237 }
259 }
238 }
260 }
239 }
261 }
@@ -249,10 +271,18 @@ class ServerEditViewModel(
249 if (!s.canSave || s.testingCredentials) return
271 if (!s.canSave || s.testingCredentials) return
250 if (s.username.isBlank() && s.password.isBlank()) return
272 if (s.username.isBlank() && s.password.isBlank()) return
251 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
273 _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()
252 viewModelScope.launch {
280 viewModelScope.launch {
253− val result = runCatchingCancellable { container.probeWithCredentials(s.baseUrl, s.username, s.password) }
281+ val result = runCatchingCancellable { container.probeWithCredentials(probedUrl, probedUser, probedPass) }
254 result.onSuccess { ok ->
282 result.onSuccess { ok ->
255 _state.update {
283 _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)
256 it.copy(
286 it.copy(
257 testingCredentials = false,
287 testingCredentials = false,
258 credentialsResult = ok,
288 credentialsResult = ok,
@@ -261,6 +291,7 @@ class ServerEditViewModel(
261 }
291 }
262 }.onFailure { e ->
292 }.onFailure { e ->
263 _state.update {
293 _state.update {
294+ if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
264 it.copy(
295 it.copy(
265 testingCredentials = false,
296 testingCredentials = false,
266 credentialsResult = false,
297 credentialsResult = false,
@@ -303,6 +334,25 @@ class ServerEditViewModel(
303 certPin = s.certPin.trim().takeIf { it.isNotBlank() },
334 certPin = s.certPin.trim().takeIf { it.isNotBlank() },
304 )
335 )
305 container.profileStore.save(saved)
336 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+ }
306 if (connectAfter) {
356 if (connectAfter) {
307 container.connect(saved)
357 container.connect(saved)
308 container.activeConnection.value?.api?.ping()
358 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
5import soy.iko.opencode.data.model.ServerProfile
5import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.data.network.NetworkConfig
6import soy.iko.opencode.data.network.NetworkConfig
7import soy.iko.opencode.di.AppContainer
7import soy.iko.opencode.di.AppContainer
8+import soy.iko.opencode.di.OpencodeConnection
8import soy.iko.opencode.R
9import soy.iko.opencode.R
9import soy.iko.opencode.util.runCatchingCancellable
10import soy.iko.opencode.util.runCatchingCancellable
10import kotlinx.coroutines.channels.BufferOverflow
11import kotlinx.coroutines.channels.BufferOverflow
@@ -64,13 +65,17 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
64 _connecting.value = profile.id
65 _connecting.value = profile.id
65 viewModelScope.launch {
66 viewModelScope.launch {
66 try {
67 try {
68+ var conn: OpencodeConnection? = null
67 val result = runCatchingCancellable {
69 val result = runCatchingCancellable {
68− val conn = container.connect(profile)
70+ conn = container.connect(profile)
69− conn.api.ping()
71+ conn!!.api.ping()
70 }
72 }
71 result.onSuccess { onConnected() }
73 result.onSuccess { onConnected() }
72 .onFailure {
74 .onFailure {
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.
73− container.disconnect()
78+ if (conn != null && container.activeConnection.value === conn) container.disconnect()
74 _errorEvents.tryEmit(ConnectError(container.friendlyError(it), profile))
79 _errorEvents.tryEmit(ConnectError(container.friendlyError(it), profile))
75 }
80 }
76 } finally {
81 } 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(
457 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
457 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
458 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
458 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
459 )
459 )
460+ val sessions = remember(
460− val sessions = remember(state.sessions, state.query, state.previews) {
461+ state.sessions, state.query, state.previews,
461− // Fast-path an empty query: filtered just returns sessions, so skip
462+ state.pinnedIds, state.archivedIds, state.showArchived,
462− // the recompute (and the fresh list reference) when only previews
463+ ) { state.filtered }
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− }
467 if (sessions.isEmpty()) {
464 if (sessions.isEmpty()) {
468 Box(
465 Box(
469 modifier = Modifier.fillMaxSize(),
466 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() {
198 private val pendingSessionUpdates = java.util.concurrent.ConcurrentHashMap<String, Session>()
198 private val pendingSessionUpdates = java.util.concurrent.ConcurrentHashMap<String, Session>()
199 private var sessionUpdateJob: Job? = null
199 private var sessionUpdateJob: Job? = null
200
200
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+
201 private val activeProfileId: String?
216 private val activeProfileId: String?
202 get() = container.activeConnection.value?.profile?.id
217 get() = container.activeConnection.value?.profile?.id
203
218
@@ -257,6 +272,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
257 }
272 }
258 sessionUpdateJob?.cancel()
273 sessionUpdateJob?.cancel()
259 pendingSessionUpdates.clear()
274 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()
260 // Cancel any deferred deletes still pending from the previous connection.
279 // Cancel any deferred deletes still pending from the previous connection.
261 // Their captured connection is now closed, so they can only fail; letting
280 // Their captured connection is now closed, so they can only fail; letting
262 // them run would fire onError and re-inject a session belonging to the old
281 // 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() {
508 }
527 }
509 }
528 }
510
529
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−
521 /**
530 /**
522 * Load the directory options for the new-session picker: the server's known projects
531 * Load the directory options for the new-session picker: the server's known projects
523 * (`GET /project`) plus its default working directory (`GET /path`). Best-effort — a
532 * (`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
26import androidx.compose.ui.res.stringResource
26import androidx.compose.ui.res.stringResource
27import androidx.compose.ui.unit.dp
27import androidx.compose.ui.unit.dp
28import androidx.lifecycle.compose.collectAsStateWithLifecycle
28import androidx.lifecycle.compose.collectAsStateWithLifecycle
29+import androidx.lifecycle.viewmodel.compose.viewModel
29import soy.iko.opencode.R
30import soy.iko.opencode.R
30import soy.iko.opencode.data.network.NetworkConfig
31import soy.iko.opencode.data.network.NetworkConfig
31import soy.iko.opencode.di.AppContainer
32import soy.iko.opencode.di.AppContainer
32import soy.iko.opencode.ui.chat.ChatScreen
33import soy.iko.opencode.ui.chat.ChatScreen
34+import soy.iko.opencode.ui.vmFactory
33
35
34/**
36/**
35 * Master–detail layout for wide screens (≥ 840dp, e.g. tablets / unfolded foldables):
37 * Master–detail layout for wide screens (≥ 840dp, e.g. tablets / unfolded foldables):
@@ -56,6 +58,10 @@ fun TwoPaneSessionChat(
56 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
58 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
57 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
59 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
58 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
60 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()
59
65
60 // A notification tap / deep link requests a session: open it in the detail pane.
66 // A notification tap / deep link requests a session: open it in the detail pane.
61 LaunchedEffect(pendingOpenSession) {
67 LaunchedEffect(pendingOpenSession) {
@@ -78,6 +84,23 @@ fun TwoPaneSessionChat(
78 lastProfileId = currentId
84 lastProfileId = currentId
79 }
85 }
80
86
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+
81 // Inject a pending share into the currently selected session's draft (if any).
104 // Inject a pending share into the currently selected session's draft (if any).
82 // Unlike single-pane mode where the session list is navigated to specifically for
105 // Unlike single-pane mode where the session list is navigated to specifically for
83 // the share, in two-pane mode the list is always visible — so we inject into the
106 // 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 @@
1package soy.iko.opencode.ui.settings
1package soy.iko.opencode.ui.settings
2
2
3+import android.content.ActivityNotFoundException
3import android.content.Intent
4import android.content.Intent
4import android.util.Log
5import android.util.Log
5import androidx.compose.foundation.background
6import androidx.compose.foundation.background
@@ -240,7 +241,11 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
240 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
241 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
241 .onFailure {
242 .onFailure {
242 Log.w("Diagnostics", "Failed to share crash report", it)
243 Log.w("Diagnostics", "Failed to share crash report", it)
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
243− showToast(context, context.getString(R.string.no_share_app))
248+ showToast(context, context.getString(msg))
244 }
249 }
245 }
250 }
246 }) {
251 }) {
@@ -291,7 +296,11 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
291 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
296 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
292 .onFailure {
297 .onFailure {
293 Log.w("Diagnostics", "Failed to share crash report", it)
298 Log.w("Diagnostics", "Failed to share crash report", it)
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
294− showToast(context, context.getString(R.string.no_share_app))
303+ showToast(context, context.getString(msg))
295 }
304 }
296 },
305 },
297 ) { Text(stringResource(R.string.share)) }
306 ) { 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
8/** Aggregated usage for one model across the scanned sessions. */
8/** Aggregated usage for one model across the scanned sessions. */
9@Immutable
9@Immutable
10data class ModelUsage(
10data class ModelUsage(
11+ val provider: String,
11 val model: String,
12 val model: String,
12 val messages: Int,
13 val messages: Int,
13 val cost: Double,
14 val cost: Double,
@@ -48,7 +49,7 @@ internal operator fun Tokens.plus(other: Tokens): Tokens = Tokens(
48/** Total tokens across all sub-counters, for a single "N tokens" headline. */
49/** Total tokens across all sub-counters, for a single "N tokens" headline. */
49internal val Tokens.total: Long get() = input + output + reasoning + cache.read + cache.write
50internal val Tokens.total: Long get() = input + output + reasoning + cache.read + cache.write
50
51
51−private class MutableAgg {
52+private class MutableAgg(val provider: String, val model: String) {
52 var messages = 0
53 var messages = 0
53 var cost = 0.0
54 var cost = 0.0
54 var tokens = Tokens()
55 var tokens = Tokens()
@@ -82,8 +83,12 @@ fun aggregateUsage(sessions: List<Triple<String, String, List<MessageWithParts>>
82 totalTokens += tokens
83 totalTokens += tokens
83 sessionCost += cost
84 sessionCost += cost
84 sessionTokens += tokens
85 sessionTokens += tokens
85− val key = info.modelID?.takeIf { it.isNotBlank() } ?: "unknown"
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"
86− val agg = byModel.getOrPut(key) { MutableAgg() }
91+ val agg = byModel.getOrPut(key) { MutableAgg(provider, model) }
87 agg.messages++
92 agg.messages++
88 agg.cost += cost
93 agg.cost += cost
89 agg.tokens += tokens
94 agg.tokens += tokens
@@ -99,7 +104,7 @@ fun aggregateUsage(sessions: List<Triple<String, String, List<MessageWithParts>>
99 messageCount = messageCount,
104 messageCount = messageCount,
100 sessionCount = bySession.size,
105 sessionCount = bySession.size,
101 byModel = byModel.entries
106 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) }
103 .sortedByDescending { it.cost },
108 .sortedByDescending { it.cost },
104 bySession = bySession.sortedByDescending { it.cost },
109 bySession = bySession.sortedByDescending { it.cost },
105 )
110 )
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+4−2
@@ -112,8 +112,10 @@ private fun UsageContent(report: UsageReport) {
112 item { TokenBreakdownCard(report.totalTokens) }
112 item { TokenBreakdownCard(report.totalTokens) }
113 if (report.byModel.isNotEmpty()) {
113 if (report.byModel.isNotEmpty()) {
114 item { SectionHeader(stringResource(R.string.usage_by_model)) }
114 item { SectionHeader(stringResource(R.string.usage_by_model)) }
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.
115− items(report.byModel, key = { it.model }) { m ->
117+ items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
116− UsageRow(title = m.model, cost = m.cost, tokens = m.tokens, messages = m.messages)
118+ UsageRow(title = m.provider + " / " + m.model, cost = m.cost, tokens = m.tokens, messages = m.messages)
117 }
119 }
118 }
120 }
119 if (report.bySession.isNotEmpty()) {
121 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
3import androidx.lifecycle.ViewModel
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.Job
6import kotlinx.coroutines.async
7import kotlinx.coroutines.async
7import kotlinx.coroutines.awaitAll
8import kotlinx.coroutines.awaitAll
8import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.MutableStateFlow
@@ -33,15 +34,22 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
33 private val _state = MutableStateFlow<State>(State.Loading)
34 private val _state = MutableStateFlow<State>(State.Loading)
34 val state: StateFlow<State> = _state.asStateFlow()
35 val state: StateFlow<State> = _state.asStateFlow()
35
36
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+
36 init { load() }
42 init { load() }
37
43
38 fun load() {
44 fun load() {
39 val conn = container.activeConnection.value ?: run {
45 val conn = container.activeConnection.value ?: run {
46+ loadJob?.cancel()
40 _state.value = State.Disconnected
47 _state.value = State.Disconnected
41 return
48 return
42 }
49 }
43 _state.value = State.Loading
50 _state.value = State.Loading
51+ loadJob?.cancel()
44− viewModelScope.launch {
52+ loadJob = viewModelScope.launch {
45 runCatchingCancellable {
53 runCatchingCancellable {
46 val sessions = conn.repository.listSessions()
54 val sessions = conn.repository.listSessions()
47 // Cap concurrent message fetches so a large history doesn't open dozens of
55 // 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
6import io.ktor.client.engine.mock.respondError
6import io.ktor.client.engine.mock.respondError
7import io.ktor.client.plugins.ClientRequestException
7import io.ktor.client.plugins.ClientRequestException
8import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
8import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
9+import io.ktor.http.HttpHeaders
9import io.ktor.http.HttpStatusCode
10import io.ktor.http.HttpStatusCode
10import io.ktor.http.headersOf
11import io.ktor.http.headersOf
11import io.ktor.serialization.kotlinx.json.json
12import io.ktor.serialization.kotlinx.json.json
@@ -63,4 +64,52 @@ class OpencodeApiClientTest {
63 api.respondPermission("s1", "p1", PermissionResponse.ONCE)
64 api.respondPermission("s1", "p1", PermissionResponse.ONCE)
64 assertEquals(1, calls)
65 assertEquals(1, calls)
65 }
66 }
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+ }
66}
115}
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+17−1
@@ -77,7 +77,7 @@ class WithRetryTest {
77
77
78 @Test
78 @Test
79 fun tooManyRequestsIsRetried() = runTest {
79 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
81 // surface immediately. Verify the special case isn't silently lost.
81 // surface immediately. Verify the special case isn't silently lost.
82 val rateLimited = httpError(HttpStatusCode.TooManyRequests)
82 val rateLimited = httpError(HttpStatusCode.TooManyRequests)
83 assertTrue("expected ClientRequestException, got $rateLimited", rateLimited is ClientRequestException)
83 assertTrue("expected ClientRequestException, got $rateLimited", rateLimited is ClientRequestException)
@@ -92,6 +92,22 @@ class WithRetryTest {
92 assertEquals("429 must be retried like a transient failure", 3, calls)
92 assertEquals("429 must be retried like a transient failure", 3, calls)
93 }
93 }
94
94
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+
95 @Test
111 @Test
96 fun serverErrorIsRetried() = runTest {
112 fun serverErrorIsRetried() = runTest {
97 val serverError = httpError(HttpStatusCode.InternalServerError)
113 val serverError = httpError(HttpStatusCode.InternalServerError)
app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+5−2
@@ -78,10 +78,13 @@ class ChatViewModelTest {
78 // --- send() ---
78 // --- send() ---
79
79
80 @Test
80 @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.)
82 val container = FakeAppContainer()
85 val container = FakeAppContainer()
83 val vm = makeVm(container)
86 val vm = makeVm(container)
84− assertFalse(vm.send("hello"))
87+ assertTrue(vm.send("hello"))
85 }
88 }
86
89
87 @Test
90 @Test
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+8
@@ -175,14 +175,21 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
175 return messages
175 return messages
176 }
176 }
177
177
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+
178 override suspend fun sendPrompt(
183 override suspend fun sendPrompt(
179 sessionId: String,
184 sessionId: String,
180 text: String,
185 text: String,
181 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
186 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
182 model: ModelRef?,
187 model: ModelRef?,
183 agent: String?,
188 agent: String?,
189+ idempotencyKey: String?,
184 ): MessageWithParts {
190 ): MessageWithParts {
185 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
191 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
192+ sendPromptKeys = sendPromptKeys + idempotencyKey
186 sendPromptThrows?.let { throw it }
193 sendPromptThrows?.let { throw it }
187 return MessageWithParts(
194 return MessageWithParts(
188 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
195 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
@@ -285,6 +292,7 @@ class FakeSessionRepository(
285 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
292 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
286 model: ModelRef?,
293 model: ModelRef?,
287 agent: String?,
294 agent: String?,
295+ idempotencyKey: String?,
288 ): MessageWithParts {
296 ): MessageWithParts {
289 sendPromptThrows?.let { throw it }
297 sendPromptThrows?.let { throw it }
290 return MessageWithParts(
298 return MessageWithParts(