git.iko.soy/opencode-android
on master help sign in
Fix stale connection UI, CancellationException swallowing, re-seed message loss, BackHandler sheet conflict, shutdown NPE, redirect retry, background timer drain, and large diff ANR
commit6416394f99e7293d58073c185c4657ddb931f1cf authoriko <mail@iko.soy> dateTue 30 Jun 2026, 13:44 (3 months ago) parent598c306
10 files changed+143−36
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+38−13 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+7−1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+1−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+4−4 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+1−1 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+23−1 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+17−12 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+2−1 app/src/main/java/soy/iko/opencode/util/Coroutines.kt+9−2 app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+41
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+38−13
@@ -19,6 +19,8 @@ import soy.iko.opencode.data.model.UpdateSessionRequest
1919import io.ktor.client.HttpClient
2020import io.ktor.client.call.body
2121import io.ktor.client.plugins.ClientRequestException
22+import io.ktor.client.plugins.ResponseException
23+import io.ktor.client.plugins.ServerResponseException
2224import io.ktor.client.request.delete
2325import io.ktor.client.request.get
2426import io.ktor.client.request.header
@@ -238,28 +240,51 @@ internal suspend fun <T> withRetryInternal(
238240 return block()
239241 } catch (c: kotlinx.coroutines.CancellationException) {
240242 throw c
241− } catch (t: Exception) {
243+ } catch (t: ClientRequestException) {
244+ // 429 (Too Many Requests) is transient — retry with backoff.
245+ // All other 4xx are non-retryable.
246+ if (t.response.status.value != 429) throw t
242247 lastError = t
243− if (t is ClientRequestException) {
244− // 429 (Too Many Requests) is transient — retry with backoff.
245− if (t.response.status.value != 429) throw t
248+ if (attempt < maxAttempts) {
249+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
246250 }
251+ } catch (t: ServerResponseException) {
252+ // 5xx server errors are retryable with backoff.
253+ lastError = t
247254 if (attempt < maxAttempts) {
248− // Guard against Int overflow from the shift and Long overflow from
249− // the multiplication at high attempt counts.
250− val shift = (attempt - 1).coerceAtMost(30)
251− val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
252− // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
253− // shorter, sometimes longer — spreads concurrent client retries and
254− // prevents thundering-herd reconnection storms.
255− val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
256− delay((baseDelay + jitter).coerceAtLeast(0))
255+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
256+ }
257+ } catch (t: ResponseException) {
258+ // 3xx (redirect limit exceeded) is non-retryable.
259+ throw t
260+ } catch (t: Exception) {
261+ lastError = t
262+ if (attempt < maxAttempts) {
263+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
257264 }
258265 }
259266 }
260267 throw lastError ?: IllegalStateException("withRetry failed without error")
261268}
262269
270+/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
271+ * so the backoff formula is unit-testable without an HTTP server. */
272+internal fun jitteredBackoff(
273+ attempt: Int,
274+ initialDelayMs: Long,
275+ jitterFactor: Double,
276+): Long {
277+ // Guard against Int overflow from the shift and Long overflow from
278+ // the multiplication at high attempt counts.
279+ val shift = (attempt - 1).coerceAtMost(30)
280+ val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
281+ // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
282+ // shorter, sometimes longer — spreads concurrent client retries and
283+ // prevents thundering-herd reconnection storms.
284+ val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
285+ return (baseDelay + jitter).coerceAtLeast(0)
286+}
287+
263288/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
264289private fun encode(segment: String): String =
265290 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+7−1
@@ -99,7 +99,13 @@ open class SessionRepository(
9999 .getOrDefault(emptyList())
100100 lock.withLock {
101101 if (generation == seedGeneration) {
102− store.seed(fresh, prune = true)
102+ // Merge without pruning: SSE events arriving during the
103+ // REST fetch may have added messages not yet indexed by
104+ // the REST endpoint. Pruning would silently delete them.
105+ // Stale messages from deletions during the disconnect
106+ // gap are eventually removed via MessageRemoved events
107+ // or on the next app restart.
108+ store.seed(fresh, prune = false)
103109 }
104110 }
105111 publish()
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+1−1
@@ -181,7 +181,7 @@ open class AppContainer private constructor(
181181 }
182182 val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
183183 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
184− soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
184+ if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
185185 draftStore.let { if (!skipInit) it.shutdown() }
186186 }
187187
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+4−4
@@ -228,7 +228,7 @@ fun ChatScreen(
228228 }
229229 }
230230
231− BackHandler(enabled = running) { showExitConfirm = true }
231+ BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
232232
233233 Scaffold(
234234 topBar = {
@@ -267,7 +267,7 @@ fun ChatScreen(
267267 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
268268 putExtra(android.content.Intent.EXTRA_TEXT, md)
269269 }
270− runCatching { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
270+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
271271 }
272272 },
273273 enabled = messages.isNotEmpty(),
@@ -292,14 +292,14 @@ fun ChatScreen(
292292 value = draft,
293293 onValueChange = vm::updateDraft,
294294 running = running,
295− enabled = vm.connected,
295+ enabled = activeConnection != null,
296296 onSend = ::doSend,
297297 onAbort = { vm.abort() },
298298 )
299299 },
300300 ) { padding ->
301301 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
302− if (!vm.connected) {
302+ if (activeConnection == null) {
303303 Column(
304304 modifier = Modifier.align(Alignment.Center).padding(24.dp),
305305 horizontalAlignment = Alignment.CenterHorizontally,
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+1−1
@@ -176,7 +176,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
176176 }
177177 if (detail != null) {
178178 val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
179− var expanded by rememberSaveable(detail) { mutableStateOf(false) }
179+ var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
180180 val expandedState = stringResource(R.string.state_expanded)
181181 val collapsedState = stringResource(R.string.state_collapsed)
182182 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+23−1
@@ -18,8 +18,13 @@ import androidx.compose.material3.Icon
1818import androidx.compose.material3.IconButton
1919import androidx.compose.material3.MaterialTheme
2020import androidx.compose.material3.Text
21+import androidx.compose.material3.TextButton
2122import androidx.compose.runtime.Composable
23+import androidx.compose.runtime.getValue
24+import androidx.compose.runtime.mutableStateOf
2225import androidx.compose.runtime.remember
26+import androidx.compose.runtime.saveable.rememberSaveable
27+import androidx.compose.runtime.setValue
2328import androidx.compose.ui.Alignment
2429import androidx.compose.ui.Modifier
2530import androidx.compose.ui.draw.clip
@@ -84,12 +89,18 @@ fun looksLikeDiff(text: String): Boolean {
8489 return false
8590}
8691
92+private const val COLLAPSED_DIFF_LINES = 500
93+
8794/**
8895 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
8996 * safe to embed inside another vertically scrolling container (e.g. the chat message
9097 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
9198 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
9299 * [Modifier.verticalScroll] in [modifier].
100+ *
101+ * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
102+ * diffs), only the first [COLLAPSED_DIFF_LINES] lines are rendered unless the user
103+ * expands the view.
93104 */
94105@Composable
95106fun DiffView(diff: String, modifier: Modifier = Modifier) {
@@ -100,6 +111,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
100111 val removeText = MaterialTheme.colorScheme.error
101112 val context = LocalContext.current
102113 val hScrollState = rememberScrollState()
114+ var expanded by rememberSaveable(diff) { mutableStateOf(false) }
103115
104116 Column(
105117 modifier = modifier
@@ -115,7 +127,9 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
115127 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
116128 }
117129 }
118− lines.forEach { line ->
130+ val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
131+ else lines.subList(0, COLLAPSED_DIFF_LINES)
132+ visibleLines.forEach { line ->
119133 Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
120134 when (line) {
121135 is DiffLine.Hunk -> Text(
@@ -145,6 +159,14 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
145159 }
146160 }
147161 }
162+ if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
163+ TextButton(
164+ onClick = { expanded = true },
165+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
166+ ) {
167+ Text(stringResource(R.string.show_more))
168+ }
169+ }
148170 Spacer(Modifier.height(10.dp))
149171 }
150172}
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+17−12
@@ -1,13 +1,15 @@
11package soy.iko.opencode.ui.components
22
33import androidx.compose.runtime.Composable
4−import androidx.compose.runtime.LaunchedEffect
54import androidx.compose.runtime.derivedStateOf
65import androidx.compose.runtime.getValue
76import androidx.compose.runtime.mutableLongStateOf
87import androidx.compose.runtime.remember
98import androidx.compose.runtime.setValue
9+import androidx.lifecycle.Lifecycle
1010import androidx.lifecycle.compose.LocalLifecycleOwner
11+import androidx.lifecycle.compose.LifecycleResumeEffect
12+import androidx.lifecycle.repeatOnLifecycle
1113import kotlinx.coroutines.delay
1214import java.util.concurrent.TimeUnit
1315
@@ -42,24 +44,27 @@ fun relativeTime(epochMillis: Long?): String {
4244fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String {
4345 var tick by remember { mutableLongStateOf(0L) }
4446 val lifecycleOwner = LocalLifecycleOwner.current
45− LaunchedEffect(epochMillis, lifecycleOwner) {
47+
48+ // Tick immediately on resume so a stale label is refreshed without waiting for
49+ // the first delay interval to elapse.
50+ LifecycleResumeEffect(epochMillis, lifecycleOwner) {
51+ tick++
52+ onPauseOrDispose { /* nothing to clean up */ }
53+ }
54+
55+ // Drive the periodic refresh with repeatOnLifecycle so the delay loop is
56+ // suspended (not running) when the screen isn't at least resumed. This avoids
57+ // unnecessary CPU usage from timer coroutines while the app is backgrounded.
58+ androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
4659 if (epochMillis == null) return@LaunchedEffect
47− // Observe lifecycle to pause the timer when the screen isn't visible.
48− val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
49− if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
50− tick++
51− }
52− }
53− lifecycleOwner.lifecycle.addObserver(observer)
54− try {
60+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
5561 while (true) {
5662 delay(intervalMs)
5763 tick++
5864 }
59− } finally {
60− lifecycleOwner.lifecycle.removeObserver(observer)
6165 }
6266 }
67+
6368 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
6469 // the relative-time string from the current wall clock on each tick.
6570 // Keying remember on epochMillis ensures a new derivedStateOf is created when the
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+2−1
@@ -41,6 +41,7 @@ import androidx.compose.runtime.setValue
4141import kotlinx.coroutines.Dispatchers
4242import kotlinx.coroutines.launch
4343import kotlinx.coroutines.withContext
44+import soy.iko.opencode.util.runCatchingCancellable
4445import androidx.compose.ui.Alignment
4546import androidx.compose.ui.Modifier
4647import androidx.compose.ui.platform.LocalContext
@@ -143,7 +144,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
143144 putExtra(Intent.EXTRA_SUBJECT, shareLabel)
144145 putExtra(Intent.EXTRA_TEXT, content)
145146 }
146− runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
147+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
147148 .onFailure { Log.w("Diagnostics", "Failed to share crash report", it) }
148149 }
149150 }) {
app/src/main/java/soy/iko/opencode/util/Coroutines.kt+9−2
@@ -23,6 +23,13 @@ inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
2323 * from a Ktor HTTP call.
2424 */
2525fun safeExceptionSummary(e: Throwable): String {
26− val status = (e as? io.ktor.client.plugins.ClientRequestException)?.response?.status?.value
27− return if (status != null) "${e.javaClass.simpleName}($status)" else e.javaClass.simpleName
26+ var current: Throwable? = e
27+ var hops = 0
28+ while (current != null && hops < 8) {
29+ val status = (current as? io.ktor.client.plugins.ClientRequestException)?.response?.status?.value
30+ if (status != null) return "${current.javaClass.simpleName}($status)"
31+ current = current.cause
32+ hops++
33+ }
34+ return e.javaClass.simpleName
2835}
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+41
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
44import io.ktor.client.engine.mock.MockEngine
55import io.ktor.client.engine.mock.respond
66import io.ktor.client.plugins.ClientRequestException
7+import io.ktor.client.plugins.RedirectResponseException
78import io.ktor.client.plugins.ServerResponseException
89import io.ktor.client.request.get
910import io.ktor.http.HttpStatusCode
@@ -176,4 +177,44 @@ class WithRetryTest {
176177 assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
177178 }
178179 }
180+
181+ @Test
182+ fun redirectErrorIsNotRetried() = runTest {
183+ // RedirectResponseException is thrown when expectSuccess=true and the status is 3xx.
184+ // We use 300 (MultipleChoices) since MockEngine follows 301/302 by default.
185+ val client = HttpClient(MockEngine {
186+ respond("", HttpStatusCode.MultipleChoices, headersOf("Content-Length", "0"))
187+ }) {
188+ expectSuccess = true
189+ followRedirects = false
190+ }
191+ val redirectError = client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
192+ assertTrue("expected RedirectResponseException, got $redirectError", redirectError is RedirectResponseException)
193+ var calls = 0
194+ val error = runCatching {
195+ withRetryInternal(initialDelayMs = 0) {
196+ calls++
197+ throw redirectError
198+ }
199+ }.exceptionOrNull()!!
200+ assertEquals("3xx must not be retried", 1, calls)
201+ assertTrue(error is RedirectResponseException)
202+ }
203+
204+ @Test
205+ fun jitteredBackoffDoublesWithAttempt() {
206+ // With no jitter, backoff = initial * 2^(attempt-1)
207+ assertEquals(100L, jitteredBackoff(1, 100L, 0.0))
208+ assertEquals(200L, jitteredBackoff(2, 100L, 0.0))
209+ assertEquals(400L, jitteredBackoff(3, 100L, 0.0))
210+ assertEquals(800L, jitteredBackoff(4, 100L, 0.0))
211+ }
212+
213+ @Test
214+ fun jitteredBackoffClampsToMaxDelay() {
215+ // At high attempts, the delay should be clamped to the configured max.
216+ val maxDelay = NetworkConfig.retryMaxDelayMs
217+ val delay = jitteredBackoff(40, 100L, 0.0)
218+ assertTrue("delay $delay should be clamped to max $maxDelay", delay <= maxDelay)
219+ }
179220}