git.iko.soy/opencode-android
on master help sign in
Re-seed messages on SSE reconnect, fix _running send race, and guard uncaught UI coroutine launches
c95dd6ciko
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+1−1
@@ -53,7 +53,7 @@ class CrashLogger private constructor(private val appContext: Context) {
53 // Load crash reports off the main thread so startup isn't blocked by file I/O
53 // Load crash reports off the main thread so startup isn't blocked by file I/O
54 // (listFiles + reading the first line of each report). The StateFlow updates
54 // (listFiles + reading the first line of each report). The StateFlow updates
55 // whenever the scan completes, so the Diagnostics screen reflects the result.
55 // whenever the scan completes, so the Diagnostics screen reflects the result.
56− scope.launch { refresh() }
56+ scope.launch { runCatching { refresh() } }
57 }
57 }
58
58
59 fun refresh() {
59 fun refresh() {
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+1−1
@@ -27,7 +27,7 @@ class DraftStore(context: Context, scope: CoroutineScope) {
27 // get() doesn't block on disk I/O. The latch ensures get() waits for this
27 // get() doesn't block on disk I/O. The latch ensures get() waits for this
28 // background load instead of triggering its own lazy load on the main thread.
28 // background load instead of triggering its own lazy load on the main thread.
29 scope.launch {
29 scope.launch {
30− prefs.all
30+ runCatching { prefs.all }
31 prefsReady.countDown()
31 prefsReady.countDown()
32 }
32 }
33 }
33 }
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+20
@@ -71,6 +71,26 @@ class SessionRepository(
71 }
71 }
72 }
72 }
73
73
74+ // Re-seed from REST when the SSE stream reconnects after a drop. Events emitted
75+ // by the server during the disconnection gap are lost from the in-memory state;
76+ // re-fetching the current snapshot fills those holes. The seed merge logic
77+ // (see MessageStore.seed) handles interleaving with concurrently-arrived events.
78+ launch {
79+ var wasConnected = false
80+ eventStream.state.collect { state ->
81+ if (state == EventStreamClient.ConnectionState.Connected) {
82+ if (wasConnected) {
83+ val fresh = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
84+ lock.withLock { store.seed(fresh) }
85+ publish()
86+ }
87+ wasConnected = true
88+ } else if (state == EventStreamClient.ConnectionState.Disconnected) {
89+ wasConnected = false
90+ }
91+ }
92+ }
93+
74 val initial = runCatching { api.listMessages(sessionId) }
94 val initial = runCatching { api.listMessages(sessionId) }
75 .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE", it) }
95 .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE", it) }
76 .getOrDefault(emptyList())
96 .getOrDefault(emptyList())
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+2−4
@@ -215,8 +215,7 @@ class ChatViewModel(
215 fun send(text: String): Boolean {
215 fun send(text: String): Boolean {
216 val conn = connection ?: return false
216 val conn = connection ?: return false
217 val trimmed = text.trim()
217 val trimmed = text.trim()
218− if (trimmed.isEmpty() || _running.value) return false
218+ if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
219− _running.value = true
220 _error.value = null
219 _error.value = null
221 _failedDraft.value = null
220 _failedDraft.value = null
222 // Clear the in-memory draft for the UI immediately, but don't persist the clear
221 // Clear the in-memory draft for the UI immediately, but don't persist the clear
@@ -255,8 +254,7 @@ class ChatViewModel(
255 /** Invoke a slash-command by name via the server's /command endpoint. */
254 /** Invoke a slash-command by name via the server's /command endpoint. */
256 fun runCommand(command: Command) {
255 fun runCommand(command: Command) {
257 val conn = connection ?: return
256 val conn = connection ?: return
258− if (_running.value) return
257+ if (!_running.compareAndSet(false, true)) return
259− _running.value = true
260 _error.value = null
258 _error.value = null
261 viewModelScope.launch {
259 viewModelScope.launch {
262 runCatching {
260 runCatching {
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+2−2
@@ -73,7 +73,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
73 actions = {
73 actions = {
74 if (reports.isNotEmpty()) {
74 if (reports.isNotEmpty()) {
75 IconButton(onClick = {
75 IconButton(onClick = {
76− shareScope.launch(Dispatchers.IO) { logger.clearAll() }
76+ shareScope.launch(Dispatchers.IO) { runCatching { logger.clearAll() } }
77 }) {
77 }) {
78 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
78 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
79 }
79 }
@@ -176,7 +176,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
176 }) { Text(stringResource(R.string.share)) }
176 }) { Text(stringResource(R.string.share)) }
177 Spacer(Modifier.size(8.dp))
177 Spacer(Modifier.size(8.dp))
178 TextButton(onClick = {
178 TextButton(onClick = {
179− shareScope.launch(Dispatchers.IO) { logger.deleteReport(reportName) }
179+ shareScope.launch(Dispatchers.IO) { runCatching { logger.deleteReport(reportName) } }
180 viewing = null
180 viewing = null
181 }) { Text(stringResource(R.string.delete)) }
181 }) { Text(stringResource(R.string.delete)) }
182 }
182 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+2−2
@@ -93,7 +93,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
93 ThemeRow(
93 ThemeRow(
94 mode = mode,
94 mode = mode,
95 selected = themeMode == mode,
95 selected = themeMode == mode,
96− onSelect = { scope.launch { container.settingsStore.setThemeMode(mode) } },
96+ onSelect = { scope.launch { runCatching { container.settingsStore.setThemeMode(mode) } } },
97 )
97 )
98 }
98 }
99
99
@@ -112,7 +112,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
112 }
112 }
113 Switch(
113 Switch(
114 checked = dynamicColor,
114 checked = dynamicColor,
115− onCheckedChange = { scope.launch { container.settingsStore.setDynamicColor(it) } },
115+ onCheckedChange = { scope.launch { runCatching { container.settingsStore.setDynamicColor(it) } } },
116 )
116 )
117 }
117 }
118
118