git.iko.soy/opencode-android
on master help sign in
Fix draft loss on cold start, permanent auth failure display, notification ID overflow, crash filename collision, cache invalidation race, foreground service crash, flushDraft ANR, URL validation, and accessibility gaps
commit2ea1094c4013787f922d7fd1472133fc5b60d0e7 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 07:07 (3 months ago) parent66d239c
14 files changed+63−26
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+3−3 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+1−1 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+4−1 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+6−6 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+2−1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+7−4 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+8−1 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+3−4 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+3−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+15−1 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+1−1 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+1 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−1 app/src/main/res/values/strings.xml+1
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+3−3
@@ -40,7 +40,7 @@ class EventStreamClient(
4040 private val client: HttpClient,
4141 scope: CoroutineScope,
4242) {
43− enum class ConnectionState { Disconnected, Connecting, Connected }
43+ enum class ConnectionState { Disconnected, Connecting, Connected, Failed }
4444
4545 private val _state = MutableStateFlow(ConnectionState.Disconnected)
4646 val state: StateFlow<ConnectionState> = _state.asStateFlow()
@@ -160,7 +160,7 @@ class EventStreamClient(
160160 if (isSseAuthFailure(e)) {
161161 // Credentials are wrong — retrying won't help. Log and stop.
162162 Log.w("EventStream", "SSE auth failed (401/403), stopping retries", e)
163− _state.value = ConnectionState.Disconnected
163+ _state.value = ConnectionState.Failed
164164 break
165165 } else if (isActive) {
166166 Log.w("EventStream", "SSE stream error, will retry", e)
@@ -170,7 +170,7 @@ class EventStreamClient(
170170 if (!isActive) break
171171 // Wait for the backoff, but allow a reconnect signal to cut it short.
172172 var signaled = false
173− val jitter = ((backoffMs * 0.2) * (Random.nextDouble() * 2 - 1)).toLong()
173+ val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
174174 select {
175175 reconnectSignal.onReceive { signaled = true }
176176 onTimeout(backoffMs + jitter) { /* normal backoff elapsed */ }
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+1−1
@@ -158,7 +158,7 @@ class OpencodeApiClient(private val client: HttpClient) {
158158 }
159159
160160 /** Invalidate the catalog cache (e.g. on server switch). */
161− fun invalidateCache() {
161+ suspend fun invalidateCache() = cacheMutex.withLock {
162162 cachedProviders = null
163163 cachedAgents = null
164164 cachedCommands = null
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+4−1
@@ -93,7 +93,10 @@ class CrashLogger private constructor(private val appContext: Context) {
9393 private fun writeReport(thread: Thread, throwable: Throwable) {
9494 val now = Date()
9595 val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
96− val file = File(crashDir, "crash-$stamp.txt")
96+ // Append a short random suffix so two crashes in the same millisecond don't
97+ // overwrite each other.
98+ val suffix = Integer.toHexString(System.nanoTime().toInt())
99+ val file = File(crashDir, "crash-$stamp-$suffix.txt")
97100 val sw = StringWriter()
98101 PrintWriter(sw).use { pw ->
99102 pw.println("opencode-android crash report")
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+6−6
@@ -82,11 +82,11 @@ class DraftStore(context: Context, scope: CoroutineScope) {
8282 }
8383
8484 /**
85− * Synchronously persist a draft using [commit] instead of [apply]. Intended for
86− * [ViewModel.onCleared] where the viewModelScope is already cancelled and a
87− * suspending write would require [runBlocking], risking an ANR. Safe to call
88− * from the main thread — [commit] blocks briefly on disk I/O but the data is
89− * small (a single key-value pair).
85+ * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
86+ * viewModelScope is already cancelled and a suspending write can't be used.
87+ * Uses [apply] (asynchronous) rather than [commit] (synchronous) to avoid
88+ * blocking the main thread — Android's SharedPreferences framework guarantees
89+ * pending `apply()` writes are flushed to disk before the process exits.
9090 */
9191 fun flushDraft(sessionId: String, text: String) {
9292 _drafts.update { current ->
@@ -97,7 +97,7 @@ class DraftStore(context: Context, scope: CoroutineScope) {
9797 runCatching {
9898 prefs.edit().apply {
9999 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
100− }.commit()
100+ }.apply()
101101 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
102102 }
103103}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+2−1
@@ -86,7 +86,8 @@ class SessionRepository(
8686 publish()
8787 }
8888 wasConnected = true
89− } else if (state == EventStreamClient.ConnectionState.Disconnected) {
89+ } else if (state == EventStreamClient.ConnectionState.Disconnected ||
90+ state == EventStreamClient.ConnectionState.Failed) {
9091 wasConnected = false
9192 }
9293 }
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+7−4
@@ -19,6 +19,7 @@ import soy.iko.opencode.data.repo.responseStatusCode
1919import soy.iko.opencode.notification.NotificationChannels
2020import soy.iko.opencode.notification.SessionNotifications
2121import soy.iko.opencode.R
22+import soy.iko.opencode.util.runCatchingCancellable
2223import kotlinx.coroutines.CoroutineScope
2324import kotlinx.coroutines.Dispatchers
2425import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -178,8 +179,10 @@ class AppContainer(context: Context) {
178179 // Track sessions actively streaming so we know which idle events
179180 // represent a finished run worth notifying about.
180181 if (event is MessagePartUpdated || event is MessageUpdated) {
181− if (activeRuns.size >= activeRunsLimit) activeRuns.clear()
182− activeRuns.add(sid)
182+ synchronized(activeRuns) {
183+ if (activeRuns.size >= activeRunsLimit) activeRuns.clear()
184+ activeRuns.add(sid)
185+ }
183186 }
184187 }
185188 }
@@ -203,7 +206,7 @@ class AppContainer(context: Context) {
203206 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
204207 private suspend fun notifySessionCompleted(sessionId: String) {
205208 val conn = activeConnection.value ?: return
206− val title = runCatching {
209+ val title = runCatchingCancellable {
207210 conn.repository.listSessions().firstOrNull { it.id == sessionId }?.displayTitle
208211 }.getOrNull() ?: sessionId
209212 SessionNotifications.postCompleted(appContext, sessionId, title)
@@ -221,7 +224,7 @@ class AppContainer(context: Context) {
221224 .getOrDefault(emptyList())
222225 .firstOrNull() ?: return
223226 _reconnecting.value = true
224− val ok = runCatching {
227+ val ok = runCatchingCancellable {
225228 val conn = connect(recent)
226229 conn.api.ping()
227230 }.isSuccess
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+8−1
@@ -72,7 +72,14 @@ class RunForegroundService : Service() {
7272 private const val NOTIF_ID = 1
7373
7474 fun start(context: Context) {
75− context.startForegroundService(Intent(context, RunForegroundService::class.java))
75+ // startForegroundService can throw ForegroundServiceStartNotAllowedException
76+ // on Android 12+ if the app is in the background. Wrap it so a backgrounded
77+ // start (e.g. the user navigates away at the wrong moment) doesn't crash.
78+ runCatching {
79+ context.startForegroundService(Intent(context, RunForegroundService::class.java))
80+ }.onFailure {
81+ Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
82+ }
7683 }
7784
7885 fun stop(context: Context) {
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+3−4
@@ -34,10 +34,9 @@ object SessionNotifications {
3434 (digest[1].toInt() and 0xFF) shl 16 or
3535 (digest[2].toInt() and 0xFF) shl 8 or
3636 (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
37− // Offset by NOTIF_ID_PREFIX, wrapping within the positive Int range so the
38− // prefix is preserved (not masked away) and ids don't collide with the
39− // foreground service's NOTIF_ID = 1.
40− return (NOTIF_ID_PREFIX + hash).and(0x7FFFFFFF)
37+ // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
38+ // Using modulo ensures the prefix is preserved without Int overflow.
39+ return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
4140 }
4241
4342 fun postCompleted(context: Context, sessionId: String, title: String) {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+3−2
@@ -308,14 +308,15 @@ fun ChatScreen(
308308 }
309309 if (running) {
310310 item(key = "__typing") {
311+ val workingText = stringResource(R.string.working)
311312 Row(
312313 verticalAlignment = Alignment.CenterVertically,
313314 modifier = Modifier.semantics {
314− contentDescription = shareContext.getString(R.string.working)
315+ contentDescription = workingText
315316 },
316317 ) {
317318 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
318− Text(stringResource(R.string.working), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
319+ Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
319320 }
320321 }
321322 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+15−1
@@ -114,6 +114,19 @@ class ChatViewModel(
114114 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
115115 val draft: StateFlow<String> = _draft.asStateFlow()
116116
117+ init {
118+ // DraftStore loads SharedPreferences asynchronously; on a cold start the
119+ // synchronous get() above returns "" until the background load completes.
120+ // Observe the ready signal and re-seed the draft so it appears in the UI.
121+ viewModelScope.launch {
122+ container.draftStore.ready.collect { ready ->
123+ if (ready && _draft.value.isEmpty()) {
124+ _draft.value = container.draftStore.get(sessionId)
125+ }
126+ }
127+ }
128+ }
129+
117130 fun clearError() { _error.value = null }
118131
119132 fun selectModel(option: ModelOption) { _selectedModel.value = option }
@@ -218,7 +231,8 @@ class ChatViewModel(
218231 container.activeConnection.collectLatest { conn ->
219232 if (conn == null) return@collectLatest
220233 conn.events.state.collect { state ->
221− if (state == EventStreamClient.ConnectionState.Disconnected && _running.value) {
234+ if ((state == EventStreamClient.ConnectionState.Disconnected ||
235+ state == EventStreamClient.ConnectionState.Failed) && _running.value) {
222236 _running.value = false
223237 }
224238 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+1−1
@@ -223,7 +223,7 @@ private fun ToolStatusIcon(state: ToolState) {
223223 when (state) {
224224 is ToolPending, is ToolRunning ->
225225 CircularProgressIndicator(
226− modifier = Modifier.size(16.dp).semantics { contentDescription = "" },
226+ modifier = Modifier.size(16.dp),
227227 strokeWidth = 2.dp,
228228 )
229229 is ToolCompleted ->
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+1
@@ -29,6 +29,7 @@ fun ConnectionBanner(
2929 val text = when (state) {
3030 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
3131 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
32+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
3233 EventStreamClient.ConnectionState.Connected -> null
3334 }
3435 if (text != null) {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−1
@@ -26,10 +26,17 @@ data class ServerEditState(
2626 val saving: Boolean = false,
2727 val error: String? = null,
2828) {
29− val canSave: Boolean get() = baseUrl.isNotBlank()
29+ val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
3030 val isNew: Boolean get() = id == null
3131}
3232
33+private fun isValidUrl(url: String): Boolean = try {
34+ val u = java.net.URI(url.trim())
35+ u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
36+} catch (_: Exception) {
37+ false
38+}
39+
3340class ServerEditViewModel(
3441 private val container: AppContainer,
3542 private val profileId: String?,
app/src/main/res/values/strings.xml+1
@@ -31,6 +31,7 @@
3131 <!-- Connection banner -->
3232 <string name="connecting">Connecting…</string>
3333 <string name="reconnecting">Reconnecting…</string>
34+ <string name="connection_failed">Connection failed — check credentials</string>
3435 <string name="reconnect">Reconnect</string>
3536 <string name="not_connected">Not connected.</string>
3637