git.iko.soy/opencode-android
on master help sign in
Move crash-report reads off the main thread, fix orphaned coroutine scope, add permission-response retry, predictive back, and accessibility fixes
commit6839df9d2e576c5cd99b498f6e0c1fd62cc76987 authoriko <mail@iko.soy> dateMon 29 Jun 2026, 19:46 (3 months ago) parent5832dcc
14 files changed+190−27
README.md+26−1 app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.kt+49 app/src/main/AndroidManifest.xml+2−1 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+1−1 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+1−1 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+2−4 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+1−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+2−2 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+10−6 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+2−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+5−2 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+21−7 app/src/main/res/values/strings.xml+2 app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.kt+66
README.md+26−1
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
66
77## Status
88
9−Milestones M0–M11 complete:
9+Milestones M0–M12 complete:
1010
1111- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
1212 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
@@ -97,6 +97,31 @@ Milestones M0–M11 complete:
9797 signed-output path. Instrumented tests (androidTest) cover the crash logger and
9898 app bootstrap.
9999
100+- **M12:** **Reliability, a11y & test-coverage pass.**
101+ - **Diagnostics disk I/O off the main thread** — crash report reads in the
102+ Diagnostics screen were moved from synchronous `remember` blocks (composition
103+ / main thread) into coroutines with `Dispatchers.IO`, eliminating potential
104+ jank on large reports.
105+ - **Orphaned coroutine scope fix** — `DraftStore` no longer creates an
106+ unscoped `CoroutineScope` that leaks; it now uses the app-scoped
107+ `CoroutineScope` from `AppContainer`.
108+ - **Predictive back** — `android:enableOnBackInvokedCallback="true"` added to
109+ the manifest so the Android 14+ predictive back gesture integrates properly
110+ with the app's `BackHandler` usage.
111+ - **Crash report filename uniqueness** — crash report timestamps now include
112+ milliseconds so two crashes within the same second don't overwrite each other.
113+ - **Permission response retry** — `POST /session/:id/permissions/:permissionID`
114+ now uses the same retry-with-backoff as other REST calls, so a transient
115+ network failure no longer leaves a tool run stuck without automatic retry.
116+ - **Accessibility: space-prefix text patterns** — six `" " + text` patterns
117+ that screen readers would announce with extra spaces were replaced with
118+ `Modifier.padding(start = ...)` on the Text composables.
119+ - **Accessibility: unread badge** — the unread activity dot on session cards
120+ now carries a `contentDescription` so TalkBack announces it.
121+ - **Test coverage** — added `OpencodeApiClientTest` (3 tests covering the
122+ permission-response retry path) and `DraftStoreTest` (instrumented, covering
123+ set/get/remove/blank-clears round-trip).
124+
100125## Architecture
101126
102127- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.ktnew file+49
@@ -0,0 +1,49 @@
1+package soy.iko.opencode.data.repo
2+
3+import androidx.test.core.app.ApplicationProvider
4+import androidx.test.ext.junit.runners.AndroidJUnit4
5+import kotlinx.coroutines.CoroutineScope
6+import kotlinx.coroutines.Dispatchers
7+import kotlinx.coroutines.SupervisorJob
8+import org.junit.Assert.assertEquals
9+import org.junit.Test
10+import org.junit.runner.RunWith
11+
12+@RunWith(AndroidJUnit4::class)
13+class DraftStoreTest {
14+
15+ private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
16+
17+ @Test
18+ fun setGetRemoveRoundTrip() {
19+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
20+ val store = DraftStore(context, scope)
21+
22+ // Use a unique session ID so it doesn't collide with other tests.
23+ val sid = "test-${System.nanoTime()}"
24+
25+ store.set(sid, "hello world")
26+ assertEquals("hello world", store.get(sid))
27+
28+ // Overwrite
29+ store.set(sid, "updated")
30+ assertEquals("updated", store.get(sid))
31+
32+ // Blank clears the entry
33+ store.set(sid, " ")
34+ assertEquals("", store.get(sid))
35+
36+ // Explicit remove
37+ store.set(sid, "text")
38+ store.remove(sid)
39+ assertEquals("", store.get(sid))
40+
41+ scope.cancel()
42+ }
43+
44+ @Test
45+ fun getReturnsEmptyForUnknownSession() {
46+ val store = DraftStore(context, CoroutineScope(SupervisorJob()))
47+ assertEquals("", store.get("never-set-${System.nanoTime()}"))
48+ }
49+}
app/src/main/AndroidManifest.xml+2−1
@@ -22,7 +22,8 @@
2222 android:name=".MainActivity"
2323 android:exported="true"
2424 android:launchMode="singleTop"
25− android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode"
25+ android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
26+ android:enableOnBackInvokedCallback="true"
2627 android:theme="@style/Theme.OpencodeAndroid.Splash">
2728 <intent-filter>
2829 <action android:name="android.intent.action.MAIN" />
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+1−1
@@ -115,7 +115,7 @@ class OpencodeApiClient(private val client: HttpClient) {
115115 sessionId: String,
116116 permissionId: String,
117117 response: PermissionResponse,
118− ) {
118+ ) = withRetry {
119119 client.post("session/$sessionId/permissions/$permissionId") {
120120 contentType(ContentType.Application.Json)
121121 setBody(PermissionReplyBody(response.wire))
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+1−1
@@ -80,7 +80,7 @@ class CrashLogger private constructor(private val appContext: Context) {
8080
8181 private fun writeReport(thread: Thread, throwable: Throwable) {
8282 val now = Date()
83− val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(now)
83+ val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
8484 val file = File(crashDir, "crash-$stamp.txt")
8585 val sw = StringWriter()
8686 PrintWriter(sw).use { pw ->
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+2−4
@@ -2,8 +2,6 @@ package soy.iko.opencode.data.repo
22
33import android.content.Context
44import kotlinx.coroutines.CoroutineScope
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.SupervisorJob
75import kotlinx.coroutines.launch
86
97/**
@@ -14,7 +12,7 @@ import kotlinx.coroutines.launch
1412 * The prefs file is loaded on a background thread at construction time so the
1513 * first [get] call on the main thread doesn't trigger a synchronous disk read.
1614 */
17−class DraftStore(context: Context) {
15+class DraftStore(context: Context, scope: CoroutineScope) {
1816
1917 private val appContext = context.applicationContext
2018 private val prefs by lazy {
@@ -24,7 +22,7 @@ class DraftStore(context: Context) {
2422 init {
2523 // Eagerly load the prefs file on a background thread so the first main-thread
2624 // get() doesn't block on disk I/O. The lazy delegate ensures the same instance.
27− CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { prefs.all }
25+ scope.launch { prefs.all }
2826 }
2927
3028 fun get(sessionId: String): String = prefs.getString(sessionId, "").orEmpty()
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+1−1
@@ -45,7 +45,7 @@ class AppContainer(context: Context) {
4545 val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
4646 val profileStore = ProfileStore(context)
4747 val settingsStore = SettingsStore(context)
48− val draftStore = DraftStore(context)
48+ val draftStore = DraftStore(context, appScope)
4949
5050 private val appContext = context.applicationContext
5151
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+2−2
@@ -212,7 +212,7 @@ fun ChatScreen(
212212 Text(
213213 buildString {
214214 append(selectedModel?.modelLabel ?: stringResource(R.string.default_model))
215− selectedAgent?.let { append(" • $it") }
215+ selectedAgent?.let { append(" · $it") }
216216 },
217217 style = MaterialTheme.typography.labelSmall,
218218 color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -305,7 +305,7 @@ fun ChatScreen(
305305 item(key = "__typing") {
306306 Row(verticalAlignment = Alignment.CenterVertically) {
307307 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
308− Text(" " + stringResource(R.string.working), style = MaterialTheme.typography.labelMedium)
308+ Text(stringResource(R.string.working), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
309309 }
310310 }
311311 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+10−6
@@ -115,9 +115,10 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
115115 )
116116 }
117117 Text(
118− " ${if (streaming) stringResource(R.string.thinking) else stringResource(R.string.thoughts)}",
118+ if (streaming) stringResource(R.string.thinking) else stringResource(R.string.thoughts),
119119 style = MaterialTheme.typography.labelMedium,
120120 color = MaterialTheme.colorScheme.onSurfaceVariant,
121+ modifier = Modifier.padding(start = 6.dp),
121122 )
122123 }
123124 AnimatedVisibility(visible = expanded) {
@@ -132,7 +133,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
132133 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
133134 ) {
134135 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
135− Text(" " + stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall)
136+ Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
136137 }
137138 }
138139 }
@@ -152,9 +153,10 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
152153 Row(verticalAlignment = Alignment.CenterVertically) {
153154 ToolStatusIcon(part.state)
154155 Text(
155− " ${part.tool}",
156+ part.tool,
156157 style = MaterialTheme.typography.labelLarge,
157158 fontFamily = FontFamily.Monospace,
159+ modifier = Modifier.padding(start = 6.dp),
158160 )
159161 }
160162 val detail = when (val s = part.state) {
@@ -187,8 +189,9 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
187189 modifier = Modifier.size(16.dp),
188190 )
189191 Text(
190− " ${if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more)}",
192+ if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more),
191193 style = MaterialTheme.typography.labelSmall,
194+ modifier = Modifier.padding(start = 4.dp),
192195 )
193196 }
194197 }
@@ -197,7 +200,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
197200 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
198201 ) {
199202 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
200− Text(" " + stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall)
203+ Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
201204 }
202205 }
203206 }
@@ -233,8 +236,9 @@ private fun FileChip(part: FilePart, modifier: Modifier) {
233236 tint = MaterialTheme.colorScheme.onSurfaceVariant,
234237 )
235238 Text(
236− " ${part.filename ?: part.url ?: stringResource(R.string.file)}",
239+ part.filename ?: part.url ?: stringResource(R.string.file),
237240 style = MaterialTheme.typography.bodyMedium,
241+ modifier = Modifier.padding(start = 6.dp),
238242 )
239243 }
240244}
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+2−1
@@ -43,9 +43,10 @@ fun ConnectionBanner(
4343 ) {
4444 CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
4545 Text(
46− " $text",
46+ text,
4747 style = MaterialTheme.typography.labelMedium,
4848 color = MaterialTheme.colorScheme.onTertiaryContainer,
49+ modifier = Modifier.padding(start = 6.dp),
4950 )
5051 }
5152 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+5−2
@@ -59,6 +59,7 @@ import androidx.compose.ui.Modifier
5959import androidx.compose.ui.draw.clip
6060import androidx.compose.ui.platform.LocalHapticFeedback
6161import androidx.compose.ui.semantics.Role
62+import androidx.compose.ui.semantics.contentDescription
6263import androidx.compose.ui.semantics.role
6364import androidx.compose.ui.semantics.semantics
6465import androidx.compose.ui.text.input.ImeAction
@@ -345,12 +346,14 @@ private fun SessionCard(
345346 verticalAlignment = Alignment.CenterVertically,
346347 ) {
347348 if (unread) {
349+ val unreadLabel = stringResource(R.string.unread)
348350 Box(
349351 modifier = Modifier
350352 .padding(end = 8.dp)
351353 .size(8.dp)
352354 .clip(androidx.compose.foundation.shape.CircleShape)
353− .background(MaterialTheme.colorScheme.primary),
355+ .background(MaterialTheme.colorScheme.primary)
356+ .semantics { contentDescription = unreadLabel },
354357 )
355358 }
356359 Text(
@@ -433,7 +436,7 @@ private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
433436 Spacer(Modifier.size(20.dp))
434437 Button(onClick = onCreate) {
435438 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
436− Text(" " + stringResource(R.string.new_session))
439+ Text(stringResource(R.string.new_session), modifier = Modifier.padding(start = 6.dp))
437440 }
438441 }
439442}
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+21−7
@@ -28,10 +28,15 @@ import androidx.compose.material3.Text
2828import androidx.compose.material3.TextButton
2929import androidx.compose.material3.TopAppBar
3030import androidx.compose.runtime.Composable
31+import androidx.compose.runtime.LaunchedEffect
3132import androidx.compose.runtime.getValue
3233import androidx.compose.runtime.mutableStateOf
3334import androidx.compose.runtime.remember
35+import androidx.compose.runtime.rememberCoroutineScope
3436import androidx.compose.runtime.setValue
37+import kotlinx.coroutines.Dispatchers
38+import kotlinx.coroutines.launch
39+import kotlinx.coroutines.withContext
3540import androidx.compose.ui.Alignment
3641import androidx.compose.ui.Modifier
3742import androidx.compose.ui.platform.LocalContext
@@ -120,14 +125,19 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
120125 color = MaterialTheme.colorScheme.onSurfaceVariant,
121126 )
122127 }
123− val content = remember(report.fileName) { logger.readReport(report.fileName).orEmpty() }
128+ val shareScope = rememberCoroutineScope()
124129 IconButton(onClick = {
125− val send = Intent(Intent.ACTION_SEND).apply {
126− type = "text/plain"
127− putExtra(Intent.EXTRA_SUBJECT, shareLabel)
128− putExtra(Intent.EXTRA_TEXT, content)
130+ shareScope.launch {
131+ val content = withContext(Dispatchers.IO) {
132+ logger.readReport(report.fileName).orEmpty()
133+ }
134+ val send = Intent(Intent.ACTION_SEND).apply {
135+ type = "text/plain"
136+ putExtra(Intent.EXTRA_SUBJECT, shareLabel)
137+ putExtra(Intent.EXTRA_TEXT, content)
138+ }
139+ context.startActivity(Intent.createChooser(send, shareLabel))
129140 }
130− context.startActivity(Intent.createChooser(send, shareLabel))
131141 }) {
132142 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
133143 }
@@ -141,7 +151,11 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
141151
142152 val reportName = viewing
143153 if (reportName != null) {
144− val content = remember(reportName) { logger.readReport(reportName) }
154+ var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
155+ LaunchedEffect(reportName) {
156+ reportContent = withContext(Dispatchers.IO) { logger.readReport(reportName) }
157+ }
158+ val content = reportContent
145159 AlertDialog(
146160 onDismissRequest = { viewing = null },
147161 confirmButton = {
app/src/main/res/values/strings.xml+2
@@ -5,6 +5,7 @@
55
66 <!-- Common -->
77 <string name="back">Back</string>
8+ <string name="loading">Loading</string>
89 <string name="cancel">Cancel</string>
910 <string name="save">Save</string>
1011 <string name="delete">Delete</string>
@@ -56,6 +57,7 @@
5657
5758 <!-- Session list -->
5859 <string name="new_session">New session</string>
60+ <string name="unread">Unread</string>
5961 <string name="no_sessions_yet">No sessions yet</string>
6062 <string name="no_sessions_hint">Start a conversation with opencode.</string>
6163 <string name="search_sessions">Search sessions…</string>
app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.ktnew file+66
@@ -0,0 +1,66 @@
1+package soy.iko.opencode.data.network
2+
3+import io.ktor.client.HttpClient
4+import io.ktor.client.engine.mock.MockEngine
5+import io.ktor.client.engine.mock.respond
6+import io.ktor.client.engine.mock.respondError
7+import io.ktor.client.plugins.ClientRequestException
8+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
9+import io.ktor.http.HttpStatusCode
10+import io.ktor.http.headersOf
11+import io.ktor.serialization.kotlinx.json.json
12+import kotlinx.coroutines.test.runTest
13+import org.junit.Assert.assertEquals
14+import org.junit.Assert.assertTrue
15+import org.junit.Test
16+import soy.iko.opencode.data.model.PermissionResponse
17+import java.io.IOException
18+
19+class OpencodeApiClientTest {
20+
21+ private fun makeClient(engine: MockEngine): HttpClient =
22+ HttpClient(engine) {
23+ expectSuccess = true
24+ install(ContentNegotiation) { json(OpencodeJson) }
25+ }
26+
27+ @Test
28+ fun respondPermissionRetriesOnTransientFailure() = runTest {
29+ var calls = 0
30+ val engine = MockEngine {
31+ calls++
32+ if (calls < 3) throw IOException("transient")
33+ respond("", HttpStatusCode.OK, headersOf("Content-Length", "0"))
34+ }
35+ val api = OpencodeApiClient(makeClient(engine))
36+ api.respondPermission("s1", "p1", PermissionResponse.ALWAYS)
37+ assertEquals("transient failures should be retried", 3, calls)
38+ }
39+
40+ @Test
41+ fun respondPermissionDoesNotRetryClientError() = runTest {
42+ var calls = 0
43+ val engine = MockEngine {
44+ calls++
45+ respondError(HttpStatusCode.BadRequest)
46+ }
47+ val api = OpencodeApiClient(makeClient(engine))
48+ val error = runCatching {
49+ api.respondPermission("s1", "p1", PermissionResponse.REJECT)
50+ }.exceptionOrNull()!!
51+ assertEquals("4xx must not be retried", 1, calls)
52+ assertTrue("expected ClientRequestException, got $error", error is ClientRequestException)
53+ }
54+
55+ @Test
56+ fun respondPermissionSucceedsOnFirstTry() = runTest {
57+ var calls = 0
58+ val engine = MockEngine {
59+ calls++
60+ respond("", HttpStatusCode.OK, headersOf("Content-Length", "0"))
61+ }
62+ val api = OpencodeApiClient(makeClient(engine))
63+ api.respondPermission("s1", "p1", PermissionResponse.ONCE)
64+ assertEquals(1, calls)
65+ }
66+}