git.iko.soy/opencode-android
on master help sign in
Add sendPrompt retry with idempotency key, catalog caching, DraftStore async I/O, SSE keepalive handling, MessageStore eviction cap, symmetric backoff jitter, server-switch state reset, and accessibility, ProGuard, and lifecycle hardening fixes
a4d7040iko
app/build.gradle.kts−1
@@ -122,7 +122,6 @@ dependencies {
122 implementation(libs.ktor.client.content.negotiation)
122 implementation(libs.ktor.client.content.negotiation)
123 implementation(libs.ktor.serialization.kotlinx.json)
123 implementation(libs.ktor.serialization.kotlinx.json)
124 implementation(libs.ktor.client.auth)
124 implementation(libs.ktor.client.auth)
125− implementation(libs.ktor.client.logging)
126
125
127 implementation(libs.kotlinx.serialization.json)
126 implementation(libs.kotlinx.serialization.json)
128 implementation(libs.kotlinx.coroutines.android)
127 implementation(libs.kotlinx.coroutines.android)
app/proguard-rules.pro+18−1
@@ -17,14 +17,31 @@
17-keep @kotlinx.serialization.Serializable class soy.iko.opencode.data.model.** { *; }
17-keep @kotlinx.serialization.Serializable class soy.iko.opencode.data.model.** { *; }
18
18
19# --- Ktor / OkHttp (HTTP client + SSE) ---
19# --- Ktor / OkHttp (HTTP client + SSE) ---
20+# Don't warn about missing optional dependencies; keep only the classes that are
21+# accessed via reflection (Ktor engine registration, OkHttp internals that R8
22+# would otherwise strip). Narrow keeps instead of `-keep class io.ktor.** { *; }`
23+# so R8 can still eliminate dead code and shrink the APK.
20-dontwarn io.ktor.**
24-dontwarn io.ktor.**
21-dontwarn okhttp3.**
25-dontwarn okhttp3.**
22-dontwarn okio.**
26-dontwarn okio.**
23-dontwarn org.slf4j.**
27-dontwarn org.slf4j.**
28+-dontwarn org.jetbrains.annotations.**
29+# Ktor loads engines and plugins via ServiceLoader — keep the service files.
30+-keep class io.ktor.client.engine.** { *; }
24−-keep class io.ktor.** { *; }
31+-keep class io.ktor.client.plugins.** { *; }
32+-keep class io.ktor.serialization.** { *; }
33+-keep class io.ktor.http.** { *; }
34+# OkHttp uses reflection for platform detection and internal classes.
35+-keep class okhttp3.internal.platform.** { *; }
25-keep class okhttp3.** { *; }
36-keep class okhttp3.** { *; }
26-keep class okio.** { *; }
37-keep class okio.** { *; }
27
38
39+# --- Coil (image loading) ---
40+# Coil uses reflection for loadable decoders and the Compose integration.
41+-keep class coil.** { *; }
42+-keep class coil3.** { *; }
43+-dontwarn coil.**
44+
28# --- androidx.security.crypto pulls in Tink, which references compile-only
45# --- androidx.security.crypto pulls in Tink, which references compile-only
29# com.google.errorprone annotations. They aren't on the runtime classpath.
46# com.google.errorprone annotations. They aren't on the runtime classpath.
30-dontwarn com.google.errorprone.annotations.**
47-dontwarn com.google.errorprone.annotations.**
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+6
@@ -12,6 +12,12 @@ class OpencodeApp : Application() {
12 super.onCreate()
12 super.onCreate()
13 CrashLogger.get(this).install()
13 CrashLogger.get(this).install()
14 container = AppContainer(this)
14 container = AppContainer(this)
15+ // onTerminate() is only called in emulated process environments, not on real
16+ // devices. Register a JVM shutdown hook so cleanup (network callback unregister,
17+ // scope cancellation, connection close) runs when the process is actually killed.
18+ Runtime.getRuntime().addShutdownHook(Thread {
19+ container.shutdown()
20+ })
15 }
21 }
16
22
17 override fun onTerminate() {
23 override fun onTerminate() {
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+9−2
@@ -88,7 +88,14 @@ class EventStreamClient(
88 }
88 }
89 if (result == null) throw IOException("SSE idle timeout, reconnecting")
89 if (result == null) throw IOException("SSE idle timeout, reconnecting")
90 val sse = result.getOrNull() ?: break
90 val sse = result.getOrNull() ?: break
91− val data = sse.data ?: continue
91+ val data = sse.data
92+ if (data == null) {
93+ // A comment or keep-alive event (no data field) resets the idle watchdog
94+ // — servers send `: ping` comments to keep the connection alive through
95+ // proxies. Without this, a server that only sends comments would still
96+ // hit the idle timeout and reconnect unnecessarily.
97+ continue
98+ }
92 val event = try {
99 val event = try {
93 OpencodeJson.decodeFromString(BusEvent.serializer(), data)
100 OpencodeJson.decodeFromString(BusEvent.serializer(), data)
94 } catch (e: kotlinx.serialization.SerializationException) {
101 } catch (e: kotlinx.serialization.SerializationException) {
@@ -163,7 +170,7 @@ class EventStreamClient(
163 if (!isActive) break
170 if (!isActive) break
164 // Wait for the backoff, but allow a reconnect signal to cut it short.
171 // Wait for the backoff, but allow a reconnect signal to cut it short.
165 var signaled = false
172 var signaled = false
166− val jitter = (backoffMs * 0.2 * Random.nextDouble()).toLong()
173+ val jitter = ((backoffMs * 0.2) * (Random.nextDouble() * 2 - 1)).toLong()
167 select {
174 select {
168 reconnectSignal.onReceive { signaled = true }
175 reconnectSignal.onReceive { signaled = true }
169 onTimeout(backoffMs + jitter) { /* normal backoff elapsed */ }
176 onTimeout(backoffMs + jitter) { /* normal backoff elapsed */ }
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+11−3
@@ -24,7 +24,7 @@ object NetworkConfig {
24 const val retryMaxAttempts = 3
24 const val retryMaxAttempts = 3
25 /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
25 /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
26 const val retryInitialDelayMs = 500L
26 const val retryInitialDelayMs = 500L
27− /** Jitter factor for retry backoff (0.2 = ±20% random jitter added to each delay). */
27+ /** Jitter factor for retry backoff (0.2 = ±20% symmetric random jitter on each delay). */
28 const val retryJitterFactor = 0.2
28 const val retryJitterFactor = 0.2
29
29
30 // --- SSE stream (EventStreamClient) ---
30 // --- SSE stream (EventStreamClient) ---
@@ -37,8 +37,11 @@ object NetworkConfig {
37 const val sseIdleTimeoutMs = 90_000L
37 const val sseIdleTimeoutMs = 90_000L
38 /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
38 /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
39 const val sseEventBufferCapacity = 64
39 const val sseEventBufferCapacity = 64
40− /** Max time to wait for the first SSE event before treating the connection as stale. */
40+
41− const val sseHandshakeTimeoutMs = 30_000L
41+ // --- Catalog cache (OpencodeApiClient) ---
42+
43+ /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
44+ const val catalogCacheTtlMs = 60_000L
42
45
43 // --- Session list (SessionListViewModel) ---
46 // --- Session list (SessionListViewModel) ---
44
47
@@ -49,6 +52,11 @@ object NetworkConfig {
49 /** Max characters of a session's last message to keep as a list preview. */
52 /** Max characters of a session's last message to keep as a list preview. */
50 const val previewTextMaxLength = 200
53 const val previewTextMaxLength = 200
51
54
55+ // --- In-memory message store (SessionRepository.MessageStore) ---
56+
57+ /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
58+ const val maxInMemoryMessages = 500
59+
52 // --- File browser (FileBrowserViewModel) ---
60 // --- File browser (FileBrowserViewModel) ---
53
61
54 /** Debounce delay before firing a file-search request after the user stops typing. */
62 /** Debounce delay before firing a file-search request after the user stops typing. */
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+62−9
@@ -21,6 +21,7 @@ import io.ktor.client.call.body
21import io.ktor.client.plugins.ClientRequestException
21import io.ktor.client.plugins.ClientRequestException
22import io.ktor.client.request.delete
22import io.ktor.client.request.delete
23import io.ktor.client.request.get
23import io.ktor.client.request.get
24+import io.ktor.client.request.header
24import io.ktor.client.request.parameter
25import io.ktor.client.request.parameter
25import io.ktor.client.request.patch
26import io.ktor.client.request.patch
26import io.ktor.client.request.post
27import io.ktor.client.request.post
@@ -28,6 +29,8 @@ import io.ktor.client.request.setBody
28import io.ktor.http.ContentType
29import io.ktor.http.ContentType
29import io.ktor.http.contentType
30import io.ktor.http.contentType
30import kotlinx.coroutines.delay
31import kotlinx.coroutines.delay
32+import kotlinx.coroutines.sync.Mutex
33+import kotlinx.coroutines.sync.withLock
31import kotlin.random.Random
34import kotlin.random.Random
32import java.net.URLEncoder
35import java.net.URLEncoder
33
36
@@ -37,6 +40,16 @@ import java.net.URLEncoder
37 */
40 */
38class OpencodeApiClient(private val client: HttpClient) {
41class OpencodeApiClient(private val client: HttpClient) {
39
42
43+ // --- Catalog cache (providers/agents/commands) ---
44+ // These change rarely, but the ChatViewModel re-fetches them on every creation.
45+ // A short-lived in-memory cache avoids redundant network calls when navigating
46+ // between sessions on the same server.
47+ private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
48+ private val cacheMutex = Mutex()
49+ private var cachedProviders: CachedEntry<ProvidersResponse>? = null
50+ private var cachedAgents: CachedEntry<List<Agent>>? = null
51+ private var cachedCommands: CachedEntry<List<Command>>? = null
52+
40 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
53 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
41 suspend fun ping() {
54 suspend fun ping() {
42 client.get("global/health")
55 client.get("global/health")
@@ -71,9 +84,14 @@ class OpencodeApiClient(private val client: HttpClient) {
71 text: String,
84 text: String,
72 model: ModelRef? = null,
85 model: ModelRef? = null,
73 agent: String? = null,
86 agent: String? = null,
74− ): MessageWithParts =
87+ ): MessageWithParts = withRetry {
88+ // Send an idempotency key so the server can safely deduplicate if a transient
89+ // network failure causes this request to be retried. The key is unique per call
90+ // so a genuine re-send (e.g. retry-failed) still creates a new message.
91+ val idempotencyKey = java.util.UUID.randomUUID().toString()
75 client.post("session/${encode(sessionId)}/message") {
92 client.post("session/${encode(sessionId)}/message") {
76 contentType(ContentType.Application.Json)
93 contentType(ContentType.Application.Json)
94+ header("Idempotency-Key", idempotencyKey)
77 setBody(
95 setBody(
78 PromptRequest(
96 PromptRequest(
79 parts = listOf(PromptPart(text = text)),
97 parts = listOf(PromptPart(text = text)),
@@ -82,6 +100,7 @@ class OpencodeApiClient(private val client: HttpClient) {
82 ),
100 ),
83 )
101 )
84 }.body()
102 }.body()
103+ }
85
104
86 suspend fun abort(sessionId: String) {
105 suspend fun abort(sessionId: String) {
87 client.post("session/${encode(sessionId)}/abort")
106 client.post("session/${encode(sessionId)}/abort")
@@ -99,16 +118,47 @@ class OpencodeApiClient(private val client: HttpClient) {
99 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
118 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
100 }.body()
119 }.body()
101
120
102− suspend fun providers(): ProvidersResponse = withRetry {
121+ suspend fun providers(): ProvidersResponse = cacheMutex.withLock {
122+ val now = System.currentTimeMillis()
123+ val cached = cachedProviders
124+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
125+ cached.value
126+ } else {
103− client.get("config/providers").body()
127+ val value = withRetry { client.get("config/providers").body<ProvidersResponse>() }
128+ cachedProviders = CachedEntry(value, now)
129+ value
130+ }
131+ }
132+
133+ suspend fun agents(): List<Agent> = cacheMutex.withLock {
134+ val now = System.currentTimeMillis()
135+ val cached = cachedAgents
136+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
137+ cached.value
138+ } else {
139+ val value = withRetry { client.get("agent").body<List<Agent>>() }
140+ cachedAgents = CachedEntry(value, now)
141+ value
142+ }
104 }
143 }
105
144
106− suspend fun agents(): List<Agent> = withRetry {
145+ suspend fun commands(): List<Command> = cacheMutex.withLock {
107− client.get("agent").body()
146+ val now = System.currentTimeMillis()
147+ val cached = cachedCommands
148+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
149+ cached.value
150+ } else {
151+ val value = withRetry { client.get("command").body<List<Command>>() }
152+ cachedCommands = CachedEntry(value, now)
153+ value
154+ }
108 }
155 }
109
156
110− suspend fun commands(): List<Command> = withRetry {
157+ /** Invalidate the catalog cache (e.g. on server switch). */
111− client.get("command").body()
158+ fun invalidateCache() {
159+ cachedProviders = null
160+ cachedAgents = null
161+ cachedCommands = null
112 }
162 }
113
163
114 /** Respond to a permission request so a paused tool run can proceed. */
164 /** Respond to a permission request so a paused tool run can proceed. */
@@ -176,8 +226,11 @@ internal suspend fun <T> withRetryInternal(
176 }
226 }
177 if (attempt < maxAttempts) {
227 if (attempt < maxAttempts) {
178 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
228 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
229+ // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
230+ // shorter, sometimes longer — spreads concurrent client retries and
231+ // prevents thundering-herd reconnection storms.
179− val jitter = (baseDelay * jitterFactor * Random.nextDouble()).toLong()
232+ val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
180− delay(baseDelay + jitter)
233+ delay((baseDelay + jitter).coerceAtLeast(0))
181 }
234 }
182 }
235 }
183 }
236 }
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+43−22
@@ -1,52 +1,73 @@
1package soy.iko.opencode.data.repo
1package soy.iko.opencode.data.repo
2
2
3import android.content.Context
3import android.content.Context
4−import java.util.concurrent.CountDownLatch
5−import java.util.concurrent.TimeUnit
6import kotlinx.coroutines.CoroutineScope
4import kotlinx.coroutines.CoroutineScope
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
7import kotlinx.coroutines.launch
9import kotlinx.coroutines.launch
10+import kotlinx.coroutines.withContext
8
11
9/**
12/**
10 * Persists the in-progress message draft for each session so it survives
13 * Persists the in-progress message draft for each session so it survives
11 * back-navigation and process death. Backed by SharedPreferences because the
14 * back-navigation and process death. Backed by SharedPreferences because the
12 * UI needs the initial value synchronously and drafts are low-stakes data.
15 * UI needs the initial value synchronously and drafts are low-stakes data.
13 *
16 *
14− * The prefs file is loaded on a background thread at construction time so the
17+ * The prefs file is loaded on a background thread at construction time. Until
15− * first [get] call on the main thread doesn't trigger a synchronous disk read.
18+ * the load completes, [drafts] reports an empty map and [get] returns "" rather
19+ * than blocking the caller (previously a [CountDownLatch] could stall the main
20+ * thread for up to 2 seconds on a slow disk).
16 */
21 */
17class DraftStore(context: Context, scope: CoroutineScope) {
22class DraftStore(context: Context, scope: CoroutineScope) {
18
23
19 private val appContext = context.applicationContext
24 private val appContext = context.applicationContext
20− private val prefsReady = CountDownLatch(1)
21 private val prefs by lazy {
25 private val prefs by lazy {
22 appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
26 appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
23 }
27 }
24
28
29+ private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
30+ /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
31+ val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
32+
33+ private val _ready = MutableStateFlow(false)
34+ /** True once the background prefs load has completed. */
35+ val ready: StateFlow<Boolean> = _ready.asStateFlow()
36+
25 init {
37 init {
26 // Eagerly load the prefs file on a background thread so the first main-thread
38 // Eagerly load the prefs file on a background thread so the first main-thread
27− // get() doesn't block on disk I/O. The latch ensures get() waits for this
39+ // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
28− // background load instead of triggering its own lazy load on the main thread.
40+ // so callers can observe readiness without blocking.
29− scope.launch {
41+ scope.launch(Dispatchers.IO) {
30− runCatching { prefs.all }
42+ val snapshot = runCatching {
31− prefsReady.countDown()
43+ prefs.all.mapValues { it.value as? String ?: "" }
44+ }.getOrDefault(emptyMap())
45+ _drafts.value = snapshot
46+ _ready.value = true
32 }
47 }
33 }
48 }
34
49
50+ /** Returns the draft for [sessionId], or "" if not found or still loading. */
35− fun get(sessionId: String): String {
51+ fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
36− prefsReady.await(2, TimeUnit.SECONDS)
37− return prefs.getString(sessionId, "").orEmpty()
38− }
39
52
40− fun set(sessionId: String, text: String) {
53+ suspend fun set(sessionId: String, text: String) {
41− prefsReady.await(2, TimeUnit.SECONDS)
54+ val current = _drafts.value.toMutableMap()
55+ if (text.isBlank()) current.remove(sessionId) else current[sessionId] = text
56+ _drafts.value = current
57+ withContext(Dispatchers.IO) {
42− prefs.edit().apply {
58+ prefs.edit().apply {
43− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
59+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
44− }.apply()
60+ }.apply()
61+ }
45 }
62 }
46
63
47 /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
64 /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
48− fun remove(sessionId: String) {
65+ suspend fun remove(sessionId: String) {
49− prefsReady.await(2, TimeUnit.SECONDS)
66+ val current = _drafts.value.toMutableMap()
67+ current.remove(sessionId)
68+ _drafts.value = current
69+ withContext(Dispatchers.IO) {
50− prefs.edit().remove(sessionId).apply()
70+ prefs.edit().remove(sessionId).apply()
71+ }
51 }
72 }
52}
73}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+2−2
@@ -113,7 +113,7 @@ class ProfileStore(context: Context) {
113 } else {
113 } else {
114 remove(passwordKey(profile.id))
114 remove(passwordKey(profile.id))
115 }
115 }
116− }.commit()
116+ }.apply()
117 }
117 }
118 }
118 }
119
119
@@ -129,7 +129,7 @@ class ProfileStore(context: Context) {
129 )
129 )
130 }
130 }
131 withContext(Dispatchers.IO) {
131 withContext(Dispatchers.IO) {
132− securePrefs.edit().remove(passwordKey(id)).commit()
132+ securePrefs.edit().remove(passwordKey(id)).apply()
133 }
133 }
134 }
134 }
135
135
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+15
@@ -12,6 +12,7 @@ import soy.iko.opencode.data.model.SessionError
12import soy.iko.opencode.data.model.SessionIdle
12import soy.iko.opencode.data.model.SessionIdle
13import soy.iko.opencode.data.model.UnknownMessage
13import soy.iko.opencode.data.model.UnknownMessage
14import soy.iko.opencode.data.network.EventStreamClient
14import soy.iko.opencode.data.network.EventStreamClient
15+import soy.iko.opencode.data.network.NetworkConfig
15import soy.iko.opencode.data.network.OpencodeApiClient
16import soy.iko.opencode.data.network.OpencodeApiClient
16import android.util.Log
17import android.util.Log
17import kotlinx.coroutines.flow.Flow
18import kotlinx.coroutines.flow.Flow
@@ -119,6 +120,9 @@ internal class MessageStore {
119 // messageId -> (info + parts), insertion-ordered.
120 // messageId -> (info + parts), insertion-ordered.
120 private val messages = LinkedHashMap<String, MessageWithParts>()
121 private val messages = LinkedHashMap<String, MessageWithParts>()
121
122
123+ /** Maximum number of messages to keep in memory; oldest are evicted when exceeded. */
124+ internal val maxMessages = NetworkConfig.maxInMemoryMessages
125+
122 fun snapshot(): List<MessageWithParts> = messages.values.toList()
126 fun snapshot(): List<MessageWithParts> = messages.values.toList()
123
127
124 fun seed(initial: List<MessageWithParts>, prune: Boolean = false) {
128 fun seed(initial: List<MessageWithParts>, prune: Boolean = false) {
@@ -152,6 +156,7 @@ internal class MessageStore {
152 messages[m.info.id] = MessageWithParts(info = info, parts = ordered)
156 messages[m.info.id] = MessageWithParts(info = info, parts = ordered)
153 }
157 }
154 }
158 }
159+ evictOldMessages()
155 }
160 }
156
161
157 /** Returns true if the state changed (and a new snapshot should be published). */
162 /** Returns true if the state changed (and a new snapshot should be published). */
@@ -164,6 +169,7 @@ internal class MessageStore {
164 } else {
169 } else {
165 val existing = messages[info.id]
170 val existing = messages[info.id]
166 messages[info.id] = existing?.copy(info = info) ?: MessageWithParts(info)
171 messages[info.id] = existing?.copy(info = info) ?: MessageWithParts(info)
172+ if (existing == null) evictOldMessages()
167 true
173 true
168 }
174 }
169 }
175 }
@@ -208,6 +214,7 @@ internal class MessageStore {
208 info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
214 info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
209 parts = newParts,
215 parts = newParts,
210 )
216 )
217+ evictOldMessages()
211 return true
218 return true
212 }
219 }
213
220
@@ -218,4 +225,12 @@ internal class MessageStore {
218 messages[messageId] = current.copy(parts = newParts)
225 messages[messageId] = current.copy(parts = newParts)
219 return true
226 return true
220 }
227 }
228+
229+ /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded. */
230+ private fun evictOldMessages() {
231+ while (messages.size > maxMessages) {
232+ val oldestKey = messages.keys.iterator().next()
233+ messages.remove(oldestKey)
234+ }
235+ }
221}
236}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+4
@@ -178,6 +178,7 @@ class AppContainer(context: Context) {
178 // Track sessions actively streaming so we know which idle events
178 // Track sessions actively streaming so we know which idle events
179 // represent a finished run worth notifying about.
179 // represent a finished run worth notifying about.
180 if (event is MessagePartUpdated || event is MessageUpdated) {
180 if (event is MessagePartUpdated || event is MessageUpdated) {
181+ if (activeRuns.size >= activeRunsLimit) activeRuns.clear()
181 activeRuns.add(sid)
182 activeRuns.add(sid)
182 }
183 }
183 }
184 }
@@ -187,6 +188,9 @@ class AppContainer(context: Context) {
187 /** Session ids currently streaming an assistant run (best-effort, in-process). */
188 /** Session ids currently streaming an assistant run (best-effort, in-process). */
188 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
189 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
189
190
191+ /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
192+ private val activeRunsLimit = 200
193+
190 /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
194 /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
191 private val connectionMutex = Mutex()
195 private val connectionMutex = Mutex()
192
196
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+1−1
@@ -54,8 +54,8 @@ object SessionNotifications {
54 val notifId = notifIdFor(sessionId)
54 val notifId = notifIdFor(sessionId)
55 val openIntent = Intent(context, MainActivity::class.java).apply {
55 val openIntent = Intent(context, MainActivity::class.java).apply {
56 action = Intent.ACTION_VIEW
56 action = Intent.ACTION_VIEW
57+ data = android.net.Uri.parse("opencode://session/$sessionId")
57 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
58 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
58− putExtra(MainActivity.EXTRA_SESSION_ID, sessionId)
59 }
59 }
60 val pendingIntent = PendingIntent.getActivity(
60 val pendingIntent = PendingIntent.getActivity(
61 context, notifId, openIntent,
61 context, notifId, openIntent,
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+1−1
@@ -94,7 +94,7 @@ fun OpencodeApp(container: AppContainer) {
94 SessionListScreen(
94 SessionListScreen(
95 container = container,
95 container = container,
96 onOpenSession = { id ->
96 onOpenSession = { id ->
97− container.consumePendingShare()?.let { container.draftStore.set(id, it) }
97+ container.consumePendingShare()?.let { scope.launch { container.draftStore.set(id, it) } }
98 navController.navigate(Routes.chat(id))
98 navController.navigate(Routes.chat(id))
99 },
99 },
100 onDisconnect = {
100 onDisconnect = {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+8−2
@@ -72,6 +72,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback
72import androidx.compose.ui.platform.LocalView
72import androidx.compose.ui.platform.LocalView
73import androidx.compose.ui.res.stringResource
73import androidx.compose.ui.res.stringResource
74import androidx.compose.ui.semantics.Role
74import androidx.compose.ui.semantics.Role
75+import androidx.compose.ui.semantics.contentDescription
75import androidx.compose.ui.semantics.role
76import androidx.compose.ui.semantics.role
76import androidx.compose.ui.semantics.semantics
77import androidx.compose.ui.semantics.semantics
77import androidx.compose.ui.text.input.ImeAction
78import androidx.compose.ui.text.input.ImeAction
@@ -301,12 +302,17 @@ fun ChatScreen(
301 contentPadding = PaddingValues(16.dp),
302 contentPadding = PaddingValues(16.dp),
302 verticalArrangement = Arrangement.spacedBy(16.dp),
303 verticalArrangement = Arrangement.spacedBy(16.dp),
303 ) {
304 ) {
304− items(messages, key = { it.info.id }) { message ->
305+ items(messages, key = { it.info.id }, contentType = { it.info::class }) { message ->
305 MessageBubble(message, isRunning = running, imageContext = imageContext)
306 MessageBubble(message, isRunning = running, imageContext = imageContext)
306 }
307 }
307 if (running) {
308 if (running) {
308 item(key = "__typing") {
309 item(key = "__typing") {
310+ Row(
309− Row(verticalAlignment = Alignment.CenterVertically) {
311+ verticalAlignment = Alignment.CenterVertically,
312+ modifier = Modifier.semantics {
313+ contentDescription = shareContext.getString(R.string.working)
314+ },
315+ ) {
310 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
316 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
311 Text(stringResource(R.string.working), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
317 Text(stringResource(R.string.working), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
312 }
318 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+9−2
@@ -120,14 +120,21 @@ class ChatViewModel(
120
120
121 fun updateDraft(text: String) {
121 fun updateDraft(text: String) {
122 _draft.value = text
122 _draft.value = text
123− container.draftStore.set(sessionId, text)
123+ viewModelScope.launch { container.draftStore.set(sessionId, text) }
124 }
124 }
125
125
126 init {
126 init {
127− // Watch the bus for run completion / errors / permission asks for this session.
127+ // Reset per-connection state when the active server changes so stale spinners,
128+ // permission dialogs, errors, and agent selections from the old server don't
129+ // persist into the new one.
128 viewModelScope.launch {
130 viewModelScope.launch {
129 container.activeConnection.collectLatest { conn ->
131 container.activeConnection.collectLatest { conn ->
130 if (conn == null) return@collectLatest
132 if (conn == null) return@collectLatest
133+ _running.value = false
134+ _pendingPermission.value = null
135+ _error.value = null
136+ _failedDraft.value = null
137+ _selectedAgent.value = null
131 conn.events.events.collect { event ->
138 conn.events.events.collect { event ->
132 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
139 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
133 if (SessionRepository.isError(event, sessionId)) {
140 if (SessionRepository.isError(event, sessionId)) {
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+26−1
@@ -22,6 +22,11 @@ import androidx.compose.material3.MaterialTheme
22import androidx.compose.material3.Surface
22import androidx.compose.material3.Surface
23import androidx.compose.material3.Text
23import androidx.compose.material3.Text
24import androidx.compose.runtime.Composable
24import androidx.compose.runtime.Composable
25+import androidx.compose.runtime.LaunchedEffect
26+import androidx.compose.runtime.getValue
27+import androidx.compose.runtime.mutableStateOf
28+import androidx.compose.runtime.remember
29+import androidx.compose.runtime.setValue
25import androidx.compose.ui.Alignment
30import androidx.compose.ui.Alignment
26import androidx.compose.ui.Modifier
31import androidx.compose.ui.Modifier
27import androidx.compose.ui.draw.clip
32import androidx.compose.ui.draw.clip
@@ -29,6 +34,7 @@ import androidx.compose.ui.platform.LocalContext
29import androidx.compose.ui.text.TextStyle
34import androidx.compose.ui.text.TextStyle
30import androidx.compose.ui.text.font.FontFamily
35import androidx.compose.ui.text.font.FontFamily
31import androidx.compose.ui.unit.dp
36import androidx.compose.ui.unit.dp
37+import kotlinx.coroutines.delay
32import com.mikepenz.markdown.compose.components.MarkdownComponentModel
38import com.mikepenz.markdown.compose.components.MarkdownComponentModel
33import com.mikepenz.markdown.compose.components.markdownComponents
39import com.mikepenz.markdown.compose.components.markdownComponents
34import com.mikepenz.markdown.m3.Markdown
40import com.mikepenz.markdown.m3.Markdown
@@ -42,6 +48,11 @@ import soy.iko.opencode.R
42 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
48 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
43 * Long-press copies the raw markdown to the system clipboard. Code blocks/fences get an
49 * Long-press copies the raw markdown to the system clipboard. Code blocks/fences get an
44 * inline copy button.
50 * inline copy button.
51+ *
52+ * During streaming, the full markdown is re-parsed on every token (the library re-parses
53+ * whenever the content string changes). To avoid O(n²) work during long responses, the
54+ * rendered content is throttled — the latest [markdown] is committed to the renderer at
55+ * most once per frame (~16ms), so a burst of tokens coalesces into a single re-parse.
45 */
56 */
46@OptIn(ExperimentalFoundationApi::class)
57@OptIn(ExperimentalFoundationApi::class)
47@Composable
58@Composable
@@ -51,8 +62,22 @@ fun MarkdownText(
51 style: TextStyle = MaterialTheme.typography.bodyLarge,
62 style: TextStyle = MaterialTheme.typography.bodyLarge,
52) {
63) {
53 val context = LocalContext.current
64 val context = LocalContext.current
65+ // Throttle the rendered content so a rapid burst of streaming tokens coalesces
66+ // into a single markdown re-parse per frame instead of one per token. The delay
67+ // is only applied when the content is growing incrementally (streaming) — a
68+ // content switch or initial render proceeds immediately.
69+ var renderedContent by remember { mutableStateOf(markdown) }
70+ LaunchedEffect(markdown) {
71+ if (renderedContent.isNotEmpty() &&
72+ markdown.startsWith(renderedContent) &&
73+ markdown != renderedContent
74+ ) {
75+ delay(16)
76+ }
77+ renderedContent = markdown
78+ }
54 Markdown(
79 Markdown(
55− content = markdown,
80+ content = renderedContent,
56 modifier = modifier.combinedClickable(
81 modifier = modifier.combinedClickable(
57 onClick = {},
82 onClick = {},
58 onLongClick = { copyToClipboard(context, markdown) },
83 onLongClick = { copyToClipboard(context, markdown) },
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+1−1
@@ -96,7 +96,7 @@ fun SessionListScreen(
96 val unread by vm.unread.collectAsStateWithLifecycle()
96 val unread by vm.unread.collectAsStateWithLifecycle()
97 val haptics = LocalHapticFeedback.current
97 val haptics = LocalHapticFeedback.current
98 val snackbar = remember { SnackbarHostState() }
98 val snackbar = remember { SnackbarHostState() }
99− var showServerMenu by remember { mutableStateOf(false) }
99+ var showServerMenu by rememberSaveable { mutableStateOf(false) }
100 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
100 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
101 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
101 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
102 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
102 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+18
@@ -158,4 +158,22 @@ class WithRetryTest {
158 // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
158 // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
159 assertEquals(listOf(50L, 100L, 200L), delays)
159 assertEquals(listOf(50L, 100L, 200L), delays)
160 }
160 }
161+
162+ @Test
163+ fun symmetricJitterStaysWithinBounds() = runTest {
164+ // With jitterFactor = 0.2, the delay should be baseDelay ± 20%.
165+ // baseDelay = 100, so delay ∈ [80, 120]. Run many times to verify
166+ // the symmetric jitter never exceeds the bounds.
167+ repeat(100) {
168+ val start = testScheduler.currentTime
169+ runCatching {
170+ withRetryInternal(maxAttempts = 2, initialDelayMs = 100L, jitterFactor = 0.2) {
171+ throw IOException("always")
172+ }
173+ }
174+ val elapsed = testScheduler.currentTime - start
175+ assertTrue("elapsed $elapsed should be >= 80", elapsed >= 80)
176+ assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
177+ }
178+ }
161}
179}
app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.kt+22
@@ -295,4 +295,26 @@ class MessageStoreTest {
295 assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
295 assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
296 assertEquals("final", (a1.parts[0] as TextPart).text)
296 assertEquals("final", (a1.parts[0] as TextPart).text)
297 }
297 }
298+
299+ // --- eviction ---
300+
301+ @Test
302+ fun evictionCapsMessageCountKeepingNewest() {
303+ val store = MessageStore()
304+ // Seed exactly at the cap.
305+ val messages = (1..store.maxMessages).map { i ->
306+ MessageWithParts(UserMessage("m$i", session))
307+ }
308+ store.seed(messages)
309+ assertEquals(store.maxMessages, store.snapshot().size)
310+
311+ // Adding one more via reduce should evict the oldest.
312+ store.reduce(session, msgUpdated("m${store.maxMessages + 1}"))
313+ assertEquals(store.maxMessages, store.snapshot().size)
314+ // The oldest (m1) should be gone; the newest should be present.
315+ val ids = store.snapshot().map { it.info.id }
316+ assertFalse("m1 should have been evicted", ids.contains("m1"))
317+ assertTrue("m${store.maxMessages} should still be present", ids.contains("m${store.maxMessages}"))
318+ assertTrue("newest message should be present", ids.contains("m${store.maxMessages + 1}"))
319+ }
298}
320}