git.iko.soy/opencode-android
on master help sign in
Fix cancellation bugs, DraftStore race, cache visibility, draft debounce, and duplicate imports
commitbe4df91b0f2424fd031b2670666ea96f37d94924 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 06:36 (3 months ago) parenta4d7040
10 files changed+52−17
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+3−3 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+11−6 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+17−1 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt−1 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+2−1 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+12−2 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+3−2 app/src/main/res/values/strings.xml+1 config/detekt/detekt.yml+3
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+3−3
@@ -46,9 +46,9 @@ class OpencodeApiClient(private val client: HttpClient) {
4646 // between sessions on the same server.
4747 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
4848 private val cacheMutex = Mutex()
49− private var cachedProviders: CachedEntry<ProvidersResponse>? = null
50− private var cachedAgents: CachedEntry<List<Agent>>? = null
51− private var cachedCommands: CachedEntry<List<Command>>? = null
49+ @Volatile private var cachedProviders: CachedEntry<ProvidersResponse>? = null
50+ @Volatile private var cachedAgents: CachedEntry<List<Agent>>? = null
51+ @Volatile private var cachedCommands: CachedEntry<List<Command>>? = null
5252
5353 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
5454 suspend fun ping() {
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+11−6
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
66import kotlinx.coroutines.flow.MutableStateFlow
77import kotlinx.coroutines.flow.StateFlow
88import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.flow.update
910import kotlinx.coroutines.launch
1011import kotlinx.coroutines.withContext
1112
@@ -51,9 +52,11 @@ class DraftStore(context: Context, scope: CoroutineScope) {
5152 fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
5253
5354 suspend fun set(sessionId: String, text: String) {
54− val current = _drafts.value.toMutableMap()
55− if (text.isBlank()) current.remove(sessionId) else current[sessionId] = text
56− _drafts.value = current
55+ _drafts.update { current ->
56+ val updated = current.toMutableMap()
57+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
58+ updated
59+ }
5760 withContext(Dispatchers.IO) {
5861 prefs.edit().apply {
5962 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
@@ -63,9 +66,11 @@ class DraftStore(context: Context, scope: CoroutineScope) {
6366
6467 /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
6568 suspend fun remove(sessionId: String) {
66− val current = _drafts.value.toMutableMap()
67− current.remove(sessionId)
68− _drafts.value = current
69+ _drafts.update { current ->
70+ val updated = current.toMutableMap()
71+ updated.remove(sessionId)
72+ updated
73+ }
6974 withContext(Dispatchers.IO) {
7075 prefs.edit().remove(sessionId).apply()
7176 }
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt−1
@@ -27,7 +27,6 @@ import androidx.compose.ui.semantics.selected
2727import androidx.compose.ui.semantics.semantics
2828import androidx.compose.ui.unit.dp
2929import androidx.compose.foundation.layout.Box
30−import androidx.compose.foundation.layout.fillMaxWidth
3130import soy.iko.opencode.data.model.Agent
3231import soy.iko.opencode.R
3332
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+17−1
@@ -27,6 +27,8 @@ import kotlinx.coroutines.flow.SharingStarted
2727import kotlinx.coroutines.flow.StateFlow
2828import kotlinx.coroutines.flow.asStateFlow
2929import kotlinx.coroutines.flow.collectLatest
30+import kotlinx.coroutines.flow.debounce
31+import kotlinx.coroutines.flow.drop
3032import kotlinx.coroutines.flow.first
3133import kotlinx.coroutines.flow.flatMapLatest
3234import kotlinx.coroutines.flow.flowOf
@@ -120,10 +122,15 @@ class ChatViewModel(
120122
121123 fun updateDraft(text: String) {
122124 _draft.value = text
123− viewModelScope.launch { container.draftStore.set(sessionId, text) }
124125 }
125126
126127 init {
128+ // Debounce draft persistence so we don't write to disk on every keystroke.
129+ viewModelScope.launch {
130+ _draft.drop(1).debounce(500).collect { text ->
131+ container.draftStore.set(sessionId, text)
132+ }
133+ }
127134 // Reset per-connection state when the active server changes so stale spinners,
128135 // permission dialogs, errors, and agent selections from the old server don't
129136 // persist into the new one.
@@ -317,4 +324,13 @@ class ChatViewModel(
317324 }
318325 }
319326 }
327+
328+ override fun onCleared() {
329+ super.onCleared()
330+ // Flush any pending debounced draft so it survives navigation.
331+ val pending = _draft.value
332+ if (pending.isNotEmpty()) {
333+ kotlinx.coroutines.runBlocking { container.draftStore.set(sessionId, pending) }
334+ }
335+ }
320336}
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt−1
@@ -27,7 +27,6 @@ import androidx.compose.ui.semantics.selected
2727import androidx.compose.ui.semantics.semantics
2828import androidx.compose.ui.unit.dp
2929import androidx.compose.foundation.layout.Box
30−import androidx.compose.foundation.layout.fillMaxWidth
3130import soy.iko.opencode.data.model.ModelOption
3231import soy.iko.opencode.R
3332
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+2−1
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow
1111import kotlinx.coroutines.flow.asStateFlow
1212import kotlinx.coroutines.flow.collectLatest
1313import kotlinx.coroutines.launch
14+import soy.iko.opencode.util.runCatchingCancellable
1415
1516data class FileViewState(
1617 val loading: Boolean = true,
@@ -39,7 +40,7 @@ class FileViewModel(
3940 return@collectLatest
4041 }
4142 _state.value = FileViewState(loading = true)
42− runCatching { conn.api.readFile(path) }
43+ runCatchingCancellable { conn.api.readFile(path) }
4344 .onSuccess { _state.value = FileViewState(loading = false, content = it) }
4445 .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
4546 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+12−2
@@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.asStateFlow
1010import kotlinx.coroutines.flow.first
1111import kotlinx.coroutines.launch
1212import kotlinx.coroutines.withTimeoutOrNull
13+import soy.iko.opencode.R
14+import soy.iko.opencode.util.runCatchingCancellable
1315import java.util.UUID
1416
1517data class ServerEditState(
@@ -51,6 +53,14 @@ class ServerEditViewModel(
5153 )
5254 return@launch
5355 }
56+ // Profile existed but timed out loading — show an error instead of
57+ // silently presenting an empty form that looks like "new server".
58+ _state.value = ServerEditState(
59+ id = profileId,
60+ loaded = true,
61+ error = container.string(R.string.error_load_timeout),
62+ )
63+ return@launch
5464 }
5565 _state.value = _state.value.copy(loaded = true)
5666 }
@@ -65,7 +75,7 @@ class ServerEditViewModel(
6575 if (!s.canSave || s.saving) return
6676 _state.value = s.copy(saving = true)
6777 viewModelScope.launch {
68− val result = runCatching {
78+ val result = runCatchingCancellable {
6979 val existingLastUsed = if (s.id != null) {
7080 withTimeoutOrNull(5_000) {
7181 container.profileStore.profiles.first()
@@ -84,7 +94,7 @@ class ServerEditViewModel(
8494 )
8595 }
8696 result.onSuccess { onDone() }
87− .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to save", saving = false) }
97+ .onFailure { _state.value = _state.value.copy(error = container.friendlyError(it), saving = false) }
8898 }
8999 }
90100}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+3−2
@@ -46,6 +46,7 @@ import kotlinx.coroutines.launch
4646import soy.iko.opencode.data.repo.ThemeMode
4747import soy.iko.opencode.di.AppContainer
4848import soy.iko.opencode.R
49+import soy.iko.opencode.util.runCatchingCancellable
4950
5051@OptIn(ExperimentalMaterial3Api::class)
5152@Composable
@@ -93,7 +94,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
9394 ThemeRow(
9495 mode = mode,
9596 selected = themeMode == mode,
96− onSelect = { scope.launch { runCatching { container.settingsStore.setThemeMode(mode) } } },
97+ onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
9798 )
9899 }
99100
@@ -112,7 +113,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
112113 }
113114 Switch(
114115 checked = dynamicColor,
115− onCheckedChange = { scope.launch { runCatching { container.settingsStore.setDynamicColor(it) } } },
116+ onCheckedChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
116117 )
117118 }
118119
app/src/main/res/values/strings.xml+1
@@ -92,6 +92,7 @@
9292 <string name="error_not_reachable">Could not reach %1$s</string>
9393 <string name="error_timeout">The request timed out. Check the connection and try again.</string>
9494 <string name="error_server">The server reported an error. Try again later.</string>
95+ <string name="error_load_timeout">Loading took too long. Try again.</string>
9596
9697 <!-- Part renderer -->
9798 <string name="thinking">Thinking…</string>
config/detekt/detekt.yml+3
@@ -21,6 +21,9 @@ complexity:
2121 NestedBlockDepth:
2222 active: true
2323 threshold: 6
24+ TooManyFunctions:
25+ active: true
26+ ignoreOverridden: true
2427
2528style:
2629 active: true