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
6416394iko
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+38−13
@@ -19,6 +19,8 @@ import soy.iko.opencode.data.model.UpdateSessionRequest
19import io.ktor.client.HttpClient
19import io.ktor.client.HttpClient
20import io.ktor.client.call.body
20import io.ktor.client.call.body
21import io.ktor.client.plugins.ClientRequestException
21import io.ktor.client.plugins.ClientRequestException
22+import io.ktor.client.plugins.ResponseException
23+import io.ktor.client.plugins.ServerResponseException
22import io.ktor.client.request.delete
24import io.ktor.client.request.delete
23import io.ktor.client.request.get
25import io.ktor.client.request.get
24import io.ktor.client.request.header
26import io.ktor.client.request.header
@@ -238,28 +240,51 @@ internal suspend fun <T> withRetryInternal(
238 return block()
240 return block()
239 } catch (c: kotlinx.coroutines.CancellationException) {
241 } catch (c: kotlinx.coroutines.CancellationException) {
240 throw c
242 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
242 lastError = t
247 lastError = t
243− if (t is ClientRequestException) {
248+ if (attempt < maxAttempts) {
244− // 429 (Too Many Requests) is transient — retry with backoff.
249+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
245− if (t.response.status.value != 429) throw t
246 }
250 }
251+ } catch (t: ServerResponseException) {
252+ // 5xx server errors are retryable with backoff.
253+ lastError = t
247 if (attempt < maxAttempts) {
254 if (attempt < maxAttempts) {
248− // Guard against Int overflow from the shift and Long overflow from
255+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
249− // the multiplication at high attempt counts.
256+ }
250− val shift = (attempt - 1).coerceAtMost(30)
257+ } catch (t: ResponseException) {
251− val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
258+ // 3xx (redirect limit exceeded) is non-retryable.
252− // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
259+ throw t
253− // shorter, sometimes longer — spreads concurrent client retries and
260+ } catch (t: Exception) {
254− // prevents thundering-herd reconnection storms.
261+ lastError = t
255− val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
262+ if (attempt < maxAttempts) {
256− delay((baseDelay + jitter).coerceAtLeast(0))
263+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
257 }
264 }
258 }
265 }
259 }
266 }
260 throw lastError ?: IllegalStateException("withRetry failed without error")
267 throw lastError ?: IllegalStateException("withRetry failed without error")
261}
268}
262
269
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+
263/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
288/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
264private fun encode(segment: String): String =
289private fun encode(segment: String): String =
265 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
290 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(
99 .getOrDefault(emptyList())
99 .getOrDefault(emptyList())
100 lock.withLock {
100 lock.withLock {
101 if (generation == seedGeneration) {
101 if (generation == seedGeneration) {
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.
102− store.seed(fresh, prune = true)
108+ store.seed(fresh, prune = false)
103 }
109 }
104 }
110 }
105 publish()
111 publish()
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+1−1
@@ -181,7 +181,7 @@ open class AppContainer private constructor(
181 }
181 }
182 val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
182 val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
183 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
183 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()
185 draftStore.let { if (!skipInit) it.shutdown() }
185 draftStore.let { if (!skipInit) it.shutdown() }
186 }
186 }
187
187
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+4−4
@@ -228,7 +228,7 @@ fun ChatScreen(
228 }
228 }
229 }
229 }
230
230
231− BackHandler(enabled = running) { showExitConfirm = true }
231+ BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
232
232
233 Scaffold(
233 Scaffold(
234 topBar = {
234 topBar = {
@@ -267,7 +267,7 @@ fun ChatScreen(
267 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
267 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
268 putExtra(android.content.Intent.EXTRA_TEXT, md)
268 putExtra(android.content.Intent.EXTRA_TEXT, md)
269 }
269 }
270− runCatching { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
270+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
271 }
271 }
272 },
272 },
273 enabled = messages.isNotEmpty(),
273 enabled = messages.isNotEmpty(),
@@ -292,14 +292,14 @@ fun ChatScreen(
292 value = draft,
292 value = draft,
293 onValueChange = vm::updateDraft,
293 onValueChange = vm::updateDraft,
294 running = running,
294 running = running,
295− enabled = vm.connected,
295+ enabled = activeConnection != null,
296 onSend = ::doSend,
296 onSend = ::doSend,
297 onAbort = { vm.abort() },
297 onAbort = { vm.abort() },
298 )
298 )
299 },
299 },
300 ) { padding ->
300 ) { padding ->
301 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
301 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
302− if (!vm.connected) {
302+ if (activeConnection == null) {
303 Column(
303 Column(
304 modifier = Modifier.align(Alignment.Center).padding(24.dp),
304 modifier = Modifier.align(Alignment.Center).padding(24.dp),
305 horizontalAlignment = Alignment.CenterHorizontally,
305 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) {
176 }
176 }
177 if (detail != null) {
177 if (detail != null) {
178 val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
178 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) }
180 val expandedState = stringResource(R.string.state_expanded)
180 val expandedState = stringResource(R.string.state_expanded)
181 val collapsedState = stringResource(R.string.state_collapsed)
181 val collapsedState = stringResource(R.string.state_collapsed)
182 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
182 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
18import androidx.compose.material3.IconButton
18import androidx.compose.material3.IconButton
19import androidx.compose.material3.MaterialTheme
19import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.Text
20import androidx.compose.material3.Text
21+import androidx.compose.material3.TextButton
21import androidx.compose.runtime.Composable
22import androidx.compose.runtime.Composable
23+import androidx.compose.runtime.getValue
24+import androidx.compose.runtime.mutableStateOf
22import androidx.compose.runtime.remember
25import androidx.compose.runtime.remember
26+import androidx.compose.runtime.saveable.rememberSaveable
27+import androidx.compose.runtime.setValue
23import androidx.compose.ui.Alignment
28import androidx.compose.ui.Alignment
24import androidx.compose.ui.Modifier
29import androidx.compose.ui.Modifier
25import androidx.compose.ui.draw.clip
30import androidx.compose.ui.draw.clip
@@ -84,12 +89,18 @@ fun looksLikeDiff(text: String): Boolean {
84 return false
89 return false
85}
90}
86
91
92+private const val COLLAPSED_DIFF_LINES = 500
93+
87/**
94/**
88 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
95 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
89 * safe to embed inside another vertically scrolling container (e.g. the chat message
96 * safe to embed inside another vertically scrolling container (e.g. the chat message
90 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
97 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
91 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
98 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
92 * [Modifier.verticalScroll] in [modifier].
99 * [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.
93 */
104 */
94@Composable
105@Composable
95fun DiffView(diff: String, modifier: Modifier = Modifier) {
106fun DiffView(diff: String, modifier: Modifier = Modifier) {
@@ -100,6 +111,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
100 val removeText = MaterialTheme.colorScheme.error
111 val removeText = MaterialTheme.colorScheme.error
101 val context = LocalContext.current
112 val context = LocalContext.current
102 val hScrollState = rememberScrollState()
113 val hScrollState = rememberScrollState()
114+ var expanded by rememberSaveable(diff) { mutableStateOf(false) }
103
115
104 Column(
116 Column(
105 modifier = modifier
117 modifier = modifier
@@ -115,7 +127,9 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
115 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
127 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
116 }
128 }
117 }
129 }
130+ val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
131+ else lines.subList(0, COLLAPSED_DIFF_LINES)
118− lines.forEach { line ->
132+ visibleLines.forEach { line ->
119 Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
133 Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
120 when (line) {
134 when (line) {
121 is DiffLine.Hunk -> Text(
135 is DiffLine.Hunk -> Text(
@@ -145,6 +159,14 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
145 }
159 }
146 }
160 }
147 }
161 }
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+ }
148 Spacer(Modifier.height(10.dp))
170 Spacer(Modifier.height(10.dp))
149 }
171 }
150}
172}
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+17−12
@@ -1,13 +1,15 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3import androidx.compose.runtime.Composable
3import androidx.compose.runtime.Composable
4−import androidx.compose.runtime.LaunchedEffect
5import androidx.compose.runtime.derivedStateOf
4import androidx.compose.runtime.derivedStateOf
6import androidx.compose.runtime.getValue
5import androidx.compose.runtime.getValue
7import androidx.compose.runtime.mutableLongStateOf
6import androidx.compose.runtime.mutableLongStateOf
8import androidx.compose.runtime.remember
7import androidx.compose.runtime.remember
9import androidx.compose.runtime.setValue
8import androidx.compose.runtime.setValue
9+import androidx.lifecycle.Lifecycle
10import androidx.lifecycle.compose.LocalLifecycleOwner
10import androidx.lifecycle.compose.LocalLifecycleOwner
11+import androidx.lifecycle.compose.LifecycleResumeEffect
12+import androidx.lifecycle.repeatOnLifecycle
11import kotlinx.coroutines.delay
13import kotlinx.coroutines.delay
12import java.util.concurrent.TimeUnit
14import java.util.concurrent.TimeUnit
13
15
@@ -42,24 +44,27 @@ fun relativeTime(epochMillis: Long?): String {
42fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String {
44fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String {
43 var tick by remember { mutableLongStateOf(0L) }
45 var tick by remember { mutableLongStateOf(0L) }
44 val lifecycleOwner = LocalLifecycleOwner.current
46 val lifecycleOwner = LocalLifecycleOwner.current
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.
45− LaunchedEffect(epochMillis, lifecycleOwner) {
58+ androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
46 if (epochMillis == null) return@LaunchedEffect
59 if (epochMillis == null) return@LaunchedEffect
47− // Observe lifecycle to pause the timer when the screen isn't visible.
60+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
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 {
55 while (true) {
61 while (true) {
56 delay(intervalMs)
62 delay(intervalMs)
57 tick++
63 tick++
58 }
64 }
59− } finally {
60− lifecycleOwner.lifecycle.removeObserver(observer)
61 }
65 }
62 }
66 }
67+
63 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
68 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
64 // the relative-time string from the current wall clock on each tick.
69 // the relative-time string from the current wall clock on each tick.
65 // Keying remember on epochMillis ensures a new derivedStateOf is created when the
70 // 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
41import kotlinx.coroutines.Dispatchers
41import kotlinx.coroutines.Dispatchers
42import kotlinx.coroutines.launch
42import kotlinx.coroutines.launch
43import kotlinx.coroutines.withContext
43import kotlinx.coroutines.withContext
44+import soy.iko.opencode.util.runCatchingCancellable
44import androidx.compose.ui.Alignment
45import androidx.compose.ui.Alignment
45import androidx.compose.ui.Modifier
46import androidx.compose.ui.Modifier
46import androidx.compose.ui.platform.LocalContext
47import androidx.compose.ui.platform.LocalContext
@@ -143,7 +144,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
143 putExtra(Intent.EXTRA_SUBJECT, shareLabel)
144 putExtra(Intent.EXTRA_SUBJECT, shareLabel)
144 putExtra(Intent.EXTRA_TEXT, content)
145 putExtra(Intent.EXTRA_TEXT, content)
145 }
146 }
146− runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
147+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
147 .onFailure { Log.w("Diagnostics", "Failed to share crash report", it) }
148 .onFailure { Log.w("Diagnostics", "Failed to share crash report", it) }
148 }
149 }
149 }) {
150 }) {
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 {
23 * from a Ktor HTTP call.
23 * from a Ktor HTTP call.
24 */
24 */
25fun safeExceptionSummary(e: Throwable): String {
25fun safeExceptionSummary(e: Throwable): String {
26+ var current: Throwable? = e
27+ var hops = 0
28+ while (current != null && hops < 8) {
26− val status = (e as? io.ktor.client.plugins.ClientRequestException)?.response?.status?.value
29+ val status = (current as? io.ktor.client.plugins.ClientRequestException)?.response?.status?.value
27− return if (status != null) "${e.javaClass.simpleName}($status)" else e.javaClass.simpleName
30+ if (status != null) return "${current.javaClass.simpleName}($status)"
31+ current = current.cause
32+ hops++
33+ }
34+ return e.javaClass.simpleName
28}
35}
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+41
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
4import io.ktor.client.engine.mock.MockEngine
4import io.ktor.client.engine.mock.MockEngine
5import io.ktor.client.engine.mock.respond
5import io.ktor.client.engine.mock.respond
6import io.ktor.client.plugins.ClientRequestException
6import io.ktor.client.plugins.ClientRequestException
7+import io.ktor.client.plugins.RedirectResponseException
7import io.ktor.client.plugins.ServerResponseException
8import io.ktor.client.plugins.ServerResponseException
8import io.ktor.client.request.get
9import io.ktor.client.request.get
9import io.ktor.http.HttpStatusCode
10import io.ktor.http.HttpStatusCode
@@ -176,4 +177,44 @@ class WithRetryTest {
176 assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
177 assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
177 }
178 }
178 }
179 }
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+ }
179}
220}