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
2ea1094iko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+3−3
@@ -40,7 +40,7 @@ class EventStreamClient(
40 private val client: HttpClient,
40 private val client: HttpClient,
41 scope: CoroutineScope,
41 scope: CoroutineScope,
42) {
42) {
43− enum class ConnectionState { Disconnected, Connecting, Connected }
43+ enum class ConnectionState { Disconnected, Connecting, Connected, Failed }
44
44
45 private val _state = MutableStateFlow(ConnectionState.Disconnected)
45 private val _state = MutableStateFlow(ConnectionState.Disconnected)
46 val state: StateFlow<ConnectionState> = _state.asStateFlow()
46 val state: StateFlow<ConnectionState> = _state.asStateFlow()
@@ -160,7 +160,7 @@ class EventStreamClient(
160 if (isSseAuthFailure(e)) {
160 if (isSseAuthFailure(e)) {
161 // Credentials are wrong — retrying won't help. Log and stop.
161 // Credentials are wrong — retrying won't help. Log and stop.
162 Log.w("EventStream", "SSE auth failed (401/403), stopping retries", e)
162 Log.w("EventStream", "SSE auth failed (401/403), stopping retries", e)
163− _state.value = ConnectionState.Disconnected
163+ _state.value = ConnectionState.Failed
164 break
164 break
165 } else if (isActive) {
165 } else if (isActive) {
166 Log.w("EventStream", "SSE stream error, will retry", e)
166 Log.w("EventStream", "SSE stream error, will retry", e)
@@ -170,7 +170,7 @@ class EventStreamClient(
170 if (!isActive) break
170 if (!isActive) break
171 // 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.
172 var signaled = false
172 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()
174 select {
174 select {
175 reconnectSignal.onReceive { signaled = true }
175 reconnectSignal.onReceive { signaled = true }
176 onTimeout(backoffMs + jitter) { /* normal backoff elapsed */ }
176 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) {
158 }
158 }
159
159
160 /** Invalidate the catalog cache (e.g. on server switch). */
160 /** Invalidate the catalog cache (e.g. on server switch). */
161− fun invalidateCache() {
161+ suspend fun invalidateCache() = cacheMutex.withLock {
162 cachedProviders = null
162 cachedProviders = null
163 cachedAgents = null
163 cachedAgents = null
164 cachedCommands = null
164 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) {
93 private fun writeReport(thread: Thread, throwable: Throwable) {
93 private fun writeReport(thread: Thread, throwable: Throwable) {
94 val now = Date()
94 val now = Date()
95 val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
95 val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
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())
96− val file = File(crashDir, "crash-$stamp.txt")
99+ val file = File(crashDir, "crash-$stamp-$suffix.txt")
97 val sw = StringWriter()
100 val sw = StringWriter()
98 PrintWriter(sw).use { pw ->
101 PrintWriter(sw).use { pw ->
99 pw.println("opencode-android crash report")
102 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) {
82 }
82 }
83
83
84 /**
84 /**
85− * Synchronously persist a draft using [commit] instead of [apply]. Intended for
85+ * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
86− * [ViewModel.onCleared] where the viewModelScope is already cancelled and a
86+ * viewModelScope is already cancelled and a suspending write can't be used.
87− * suspending write would require [runBlocking], risking an ANR. Safe to call
87+ * Uses [apply] (asynchronous) rather than [commit] (synchronous) to avoid
88− * from the main thread — [commit] blocks briefly on disk I/O but the data is
88+ * blocking the main thread — Android's SharedPreferences framework guarantees
89− * small (a single key-value pair).
89+ * pending `apply()` writes are flushed to disk before the process exits.
90 */
90 */
91 fun flushDraft(sessionId: String, text: String) {
91 fun flushDraft(sessionId: String, text: String) {
92 _drafts.update { current ->
92 _drafts.update { current ->
@@ -97,7 +97,7 @@ class DraftStore(context: Context, scope: CoroutineScope) {
97 runCatching {
97 runCatching {
98 prefs.edit().apply {
98 prefs.edit().apply {
99 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
99 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
100− }.commit()
100+ }.apply()
101 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
101 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
102 }
102 }
103}
103}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+2−1
@@ -86,7 +86,8 @@ class SessionRepository(
86 publish()
86 publish()
87 }
87 }
88 wasConnected = true
88 wasConnected = true
89− } else if (state == EventStreamClient.ConnectionState.Disconnected) {
89+ } else if (state == EventStreamClient.ConnectionState.Disconnected ||
90+ state == EventStreamClient.ConnectionState.Failed) {
90 wasConnected = false
91 wasConnected = false
91 }
92 }
92 }
93 }
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+7−4
@@ -19,6 +19,7 @@ import soy.iko.opencode.data.repo.responseStatusCode
19import soy.iko.opencode.notification.NotificationChannels
19import soy.iko.opencode.notification.NotificationChannels
20import soy.iko.opencode.notification.SessionNotifications
20import soy.iko.opencode.notification.SessionNotifications
21import soy.iko.opencode.R
21import soy.iko.opencode.R
22+import soy.iko.opencode.util.runCatchingCancellable
22import kotlinx.coroutines.CoroutineScope
23import kotlinx.coroutines.CoroutineScope
23import kotlinx.coroutines.Dispatchers
24import kotlinx.coroutines.Dispatchers
24import kotlinx.coroutines.ExperimentalCoroutinesApi
25import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -178,8 +179,10 @@ class AppContainer(context: Context) {
178 // Track sessions actively streaming so we know which idle events
179 // Track sessions actively streaming so we know which idle events
179 // represent a finished run worth notifying about.
180 // represent a finished run worth notifying about.
180 if (event is MessagePartUpdated || event is MessageUpdated) {
181 if (event is MessagePartUpdated || event is MessageUpdated) {
182+ synchronized(activeRuns) {
181− if (activeRuns.size >= activeRunsLimit) activeRuns.clear()
183+ if (activeRuns.size >= activeRunsLimit) activeRuns.clear()
182− activeRuns.add(sid)
184+ activeRuns.add(sid)
185+ }
183 }
186 }
184 }
187 }
185 }
188 }
@@ -203,7 +206,7 @@ class AppContainer(context: Context) {
203 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
206 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
204 private suspend fun notifySessionCompleted(sessionId: String) {
207 private suspend fun notifySessionCompleted(sessionId: String) {
205 val conn = activeConnection.value ?: return
208 val conn = activeConnection.value ?: return
206− val title = runCatching {
209+ val title = runCatchingCancellable {
207 conn.repository.listSessions().firstOrNull { it.id == sessionId }?.displayTitle
210 conn.repository.listSessions().firstOrNull { it.id == sessionId }?.displayTitle
208 }.getOrNull() ?: sessionId
211 }.getOrNull() ?: sessionId
209 SessionNotifications.postCompleted(appContext, sessionId, title)
212 SessionNotifications.postCompleted(appContext, sessionId, title)
@@ -221,7 +224,7 @@ class AppContainer(context: Context) {
221 .getOrDefault(emptyList())
224 .getOrDefault(emptyList())
222 .firstOrNull() ?: return
225 .firstOrNull() ?: return
223 _reconnecting.value = true
226 _reconnecting.value = true
224− val ok = runCatching {
227+ val ok = runCatchingCancellable {
225 val conn = connect(recent)
228 val conn = connect(recent)
226 conn.api.ping()
229 conn.api.ping()
227 }.isSuccess
230 }.isSuccess
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+8−1
@@ -72,7 +72,14 @@ class RunForegroundService : Service() {
72 private const val NOTIF_ID = 1
72 private const val NOTIF_ID = 1
73
73
74 fun start(context: Context) {
74 fun start(context: Context) {
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 {
75− context.startForegroundService(Intent(context, RunForegroundService::class.java))
79+ context.startForegroundService(Intent(context, RunForegroundService::class.java))
80+ }.onFailure {
81+ Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
82+ }
76 }
83 }
77
84
78 fun stop(context: Context) {
85 fun stop(context: Context) {
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+3−4
@@ -34,10 +34,9 @@ object SessionNotifications {
34 (digest[1].toInt() and 0xFF) shl 16 or
34 (digest[1].toInt() and 0xFF) shl 16 or
35 (digest[2].toInt() and 0xFF) shl 8 or
35 (digest[2].toInt() and 0xFF) shl 8 or
36 (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
36 (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
37− // Offset by NOTIF_ID_PREFIX, wrapping within the positive Int range so the
37+ // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
38− // prefix is preserved (not masked away) and ids don't collide with the
38+ // Using modulo ensures the prefix is preserved without Int overflow.
39− // foreground service's NOTIF_ID = 1.
40− return (NOTIF_ID_PREFIX + hash).and(0x7FFFFFFF)
39+ return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
41 }
40 }
42
41
43 fun postCompleted(context: Context, sessionId: String, title: String) {
42 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(
308 }
308 }
309 if (running) {
309 if (running) {
310 item(key = "__typing") {
310 item(key = "__typing") {
311+ val workingText = stringResource(R.string.working)
311 Row(
312 Row(
312 verticalAlignment = Alignment.CenterVertically,
313 verticalAlignment = Alignment.CenterVertically,
313 modifier = Modifier.semantics {
314 modifier = Modifier.semantics {
314− contentDescription = shareContext.getString(R.string.working)
315+ contentDescription = workingText
315 },
316 },
316 ) {
317 ) {
317 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
318 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))
319 }
320 }
320 }
321 }
321 }
322 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+15−1
@@ -114,6 +114,19 @@ class ChatViewModel(
114 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
114 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
115 val draft: StateFlow<String> = _draft.asStateFlow()
115 val draft: StateFlow<String> = _draft.asStateFlow()
116
116
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+
117 fun clearError() { _error.value = null }
130 fun clearError() { _error.value = null }
118
131
119 fun selectModel(option: ModelOption) { _selectedModel.value = option }
132 fun selectModel(option: ModelOption) { _selectedModel.value = option }
@@ -218,7 +231,8 @@ class ChatViewModel(
218 container.activeConnection.collectLatest { conn ->
231 container.activeConnection.collectLatest { conn ->
219 if (conn == null) return@collectLatest
232 if (conn == null) return@collectLatest
220 conn.events.state.collect { state ->
233 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) {
222 _running.value = false
236 _running.value = false
223 }
237 }
224 }
238 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+1−1
@@ -223,7 +223,7 @@ private fun ToolStatusIcon(state: ToolState) {
223 when (state) {
223 when (state) {
224 is ToolPending, is ToolRunning ->
224 is ToolPending, is ToolRunning ->
225 CircularProgressIndicator(
225 CircularProgressIndicator(
226− modifier = Modifier.size(16.dp).semantics { contentDescription = "" },
226+ modifier = Modifier.size(16.dp),
227 strokeWidth = 2.dp,
227 strokeWidth = 2.dp,
228 )
228 )
229 is ToolCompleted ->
229 is ToolCompleted ->
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+1
@@ -29,6 +29,7 @@ fun ConnectionBanner(
29 val text = when (state) {
29 val text = when (state) {
30 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
30 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
31 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
31 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
32+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
32 EventStreamClient.ConnectionState.Connected -> null
33 EventStreamClient.ConnectionState.Connected -> null
33 }
34 }
34 if (text != null) {
35 if (text != null) {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−1
@@ -26,10 +26,17 @@ data class ServerEditState(
26 val saving: Boolean = false,
26 val saving: Boolean = false,
27 val error: String? = null,
27 val error: String? = null,
28) {
28) {
29− val canSave: Boolean get() = baseUrl.isNotBlank()
29+ val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
30 val isNew: Boolean get() = id == null
30 val isNew: Boolean get() = id == null
31}
31}
32
32
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+
33class ServerEditViewModel(
40class ServerEditViewModel(
34 private val container: AppContainer,
41 private val container: AppContainer,
35 private val profileId: String?,
42 private val profileId: String?,
app/src/main/res/values/strings.xml+1
@@ -31,6 +31,7 @@
31 <!-- Connection banner -->
31 <!-- Connection banner -->
32 <string name="connecting">Connecting…</string>
32 <string name="connecting">Connecting…</string>
33 <string name="reconnecting">Reconnecting…</string>
33 <string name="reconnecting">Reconnecting…</string>
34+ <string name="connection_failed">Connection failed — check credentials</string>
34 <string name="reconnect">Reconnect</string>
35 <string name="reconnect">Reconnect</string>
35 <string name="not_connected">Not connected.</string>
36 <string name="not_connected">Not connected.</string>
36
37