git.iko.soy/opencode-android
on master help sign in
Fix cancellation bugs, DraftStore race, cache visibility, draft debounce, and duplicate imports
be4df91iko
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+3−3
@@ -46,9 +46,9 @@ class OpencodeApiClient(private val client: HttpClient) {
46 // between sessions on the same server.
46 // between sessions on the same server.
47 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
47 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
48 private val cacheMutex = Mutex()
48 private val cacheMutex = Mutex()
49− private var cachedProviders: CachedEntry<ProvidersResponse>? = null
49+ @Volatile private var cachedProviders: CachedEntry<ProvidersResponse>? = null
50− private var cachedAgents: CachedEntry<List<Agent>>? = null
50+ @Volatile private var cachedAgents: CachedEntry<List<Agent>>? = null
51− private var cachedCommands: CachedEntry<List<Command>>? = null
51+ @Volatile private var cachedCommands: CachedEntry<List<Command>>? = null
52
52
53 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
53 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
54 suspend fun ping() {
54 suspend fun ping() {
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+11−6
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.flow.MutableStateFlow
6import kotlinx.coroutines.flow.MutableStateFlow
7import kotlinx.coroutines.flow.StateFlow
7import kotlinx.coroutines.flow.StateFlow
8import kotlinx.coroutines.flow.asStateFlow
8import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.flow.update
9import kotlinx.coroutines.launch
10import kotlinx.coroutines.launch
10import kotlinx.coroutines.withContext
11import kotlinx.coroutines.withContext
11
12
@@ -51,9 +52,11 @@ class DraftStore(context: Context, scope: CoroutineScope) {
51 fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
52 fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
52
53
53 suspend fun set(sessionId: String, text: String) {
54 suspend fun set(sessionId: String, text: String) {
55+ _drafts.update { current ->
54− val current = _drafts.value.toMutableMap()
56+ val updated = current.toMutableMap()
55− if (text.isBlank()) current.remove(sessionId) else current[sessionId] = text
57+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
56− _drafts.value = current
58+ updated
59+ }
57 withContext(Dispatchers.IO) {
60 withContext(Dispatchers.IO) {
58 prefs.edit().apply {
61 prefs.edit().apply {
59 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
62 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
@@ -63,9 +66,11 @@ class DraftStore(context: Context, scope: CoroutineScope) {
63
66
64 /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
67 /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
65 suspend fun remove(sessionId: String) {
68 suspend fun remove(sessionId: String) {
69+ _drafts.update { current ->
66− val current = _drafts.value.toMutableMap()
70+ val updated = current.toMutableMap()
67− current.remove(sessionId)
71+ updated.remove(sessionId)
68− _drafts.value = current
72+ updated
73+ }
69 withContext(Dispatchers.IO) {
74 withContext(Dispatchers.IO) {
70 prefs.edit().remove(sessionId).apply()
75 prefs.edit().remove(sessionId).apply()
71 }
76 }
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt−1
@@ -27,7 +27,6 @@ import androidx.compose.ui.semantics.selected
27import androidx.compose.ui.semantics.semantics
27import androidx.compose.ui.semantics.semantics
28import androidx.compose.ui.unit.dp
28import androidx.compose.ui.unit.dp
29import androidx.compose.foundation.layout.Box
29import androidx.compose.foundation.layout.Box
30−import androidx.compose.foundation.layout.fillMaxWidth
31import soy.iko.opencode.data.model.Agent
30import soy.iko.opencode.data.model.Agent
32import soy.iko.opencode.R
31import soy.iko.opencode.R
33
32
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+17−1
@@ -27,6 +27,8 @@ import kotlinx.coroutines.flow.SharingStarted
27import kotlinx.coroutines.flow.StateFlow
27import kotlinx.coroutines.flow.StateFlow
28import kotlinx.coroutines.flow.asStateFlow
28import kotlinx.coroutines.flow.asStateFlow
29import kotlinx.coroutines.flow.collectLatest
29import kotlinx.coroutines.flow.collectLatest
30+import kotlinx.coroutines.flow.debounce
31+import kotlinx.coroutines.flow.drop
30import kotlinx.coroutines.flow.first
32import kotlinx.coroutines.flow.first
31import kotlinx.coroutines.flow.flatMapLatest
33import kotlinx.coroutines.flow.flatMapLatest
32import kotlinx.coroutines.flow.flowOf
34import kotlinx.coroutines.flow.flowOf
@@ -120,10 +122,15 @@ class ChatViewModel(
120
122
121 fun updateDraft(text: String) {
123 fun updateDraft(text: String) {
122 _draft.value = text
124 _draft.value = text
123− viewModelScope.launch { container.draftStore.set(sessionId, text) }
124 }
125 }
125
126
126 init {
127 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+ }
127 // Reset per-connection state when the active server changes so stale spinners,
134 // Reset per-connection state when the active server changes so stale spinners,
128 // permission dialogs, errors, and agent selections from the old server don't
135 // permission dialogs, errors, and agent selections from the old server don't
129 // persist into the new one.
136 // persist into the new one.
@@ -317,4 +324,13 @@ class ChatViewModel(
317 }
324 }
318 }
325 }
319 }
326 }
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+ }
320}
336}
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt−1
@@ -27,7 +27,6 @@ import androidx.compose.ui.semantics.selected
27import androidx.compose.ui.semantics.semantics
27import androidx.compose.ui.semantics.semantics
28import androidx.compose.ui.unit.dp
28import androidx.compose.ui.unit.dp
29import androidx.compose.foundation.layout.Box
29import androidx.compose.foundation.layout.Box
30−import androidx.compose.foundation.layout.fillMaxWidth
31import soy.iko.opencode.data.model.ModelOption
30import soy.iko.opencode.data.model.ModelOption
32import soy.iko.opencode.R
31import soy.iko.opencode.R
33
32
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+2−1
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.flow.asStateFlow
12import kotlinx.coroutines.flow.collectLatest
12import kotlinx.coroutines.flow.collectLatest
13import kotlinx.coroutines.launch
13import kotlinx.coroutines.launch
14+import soy.iko.opencode.util.runCatchingCancellable
14
15
15data class FileViewState(
16data class FileViewState(
16 val loading: Boolean = true,
17 val loading: Boolean = true,
@@ -39,7 +40,7 @@ class FileViewModel(
39 return@collectLatest
40 return@collectLatest
40 }
41 }
41 _state.value = FileViewState(loading = true)
42 _state.value = FileViewState(loading = true)
42− runCatching { conn.api.readFile(path) }
43+ runCatchingCancellable { conn.api.readFile(path) }
43 .onSuccess { _state.value = FileViewState(loading = false, content = it) }
44 .onSuccess { _state.value = FileViewState(loading = false, content = it) }
44 .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
45 .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
45 }
46 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+12−2
@@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.asStateFlow
10import kotlinx.coroutines.flow.first
10import kotlinx.coroutines.flow.first
11import kotlinx.coroutines.launch
11import kotlinx.coroutines.launch
12import kotlinx.coroutines.withTimeoutOrNull
12import kotlinx.coroutines.withTimeoutOrNull
13+import soy.iko.opencode.R
14+import soy.iko.opencode.util.runCatchingCancellable
13import java.util.UUID
15import java.util.UUID
14
16
15data class ServerEditState(
17data class ServerEditState(
@@ -51,6 +53,14 @@ class ServerEditViewModel(
51 )
53 )
52 return@launch
54 return@launch
53 }
55 }
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
54 }
64 }
55 _state.value = _state.value.copy(loaded = true)
65 _state.value = _state.value.copy(loaded = true)
56 }
66 }
@@ -65,7 +75,7 @@ class ServerEditViewModel(
65 if (!s.canSave || s.saving) return
75 if (!s.canSave || s.saving) return
66 _state.value = s.copy(saving = true)
76 _state.value = s.copy(saving = true)
67 viewModelScope.launch {
77 viewModelScope.launch {
68− val result = runCatching {
78+ val result = runCatchingCancellable {
69 val existingLastUsed = if (s.id != null) {
79 val existingLastUsed = if (s.id != null) {
70 withTimeoutOrNull(5_000) {
80 withTimeoutOrNull(5_000) {
71 container.profileStore.profiles.first()
81 container.profileStore.profiles.first()
@@ -84,7 +94,7 @@ class ServerEditViewModel(
84 )
94 )
85 }
95 }
86 result.onSuccess { onDone() }
96 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) }
88 }
98 }
89 }
99 }
90}
100}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+3−2
@@ -46,6 +46,7 @@ import kotlinx.coroutines.launch
46import soy.iko.opencode.data.repo.ThemeMode
46import soy.iko.opencode.data.repo.ThemeMode
47import soy.iko.opencode.di.AppContainer
47import soy.iko.opencode.di.AppContainer
48import soy.iko.opencode.R
48import soy.iko.opencode.R
49+import soy.iko.opencode.util.runCatchingCancellable
49
50
50@OptIn(ExperimentalMaterial3Api::class)
51@OptIn(ExperimentalMaterial3Api::class)
51@Composable
52@Composable
@@ -93,7 +94,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
93 ThemeRow(
94 ThemeRow(
94 mode = mode,
95 mode = mode,
95 selected = themeMode == mode,
96 selected = themeMode == mode,
96− onSelect = { scope.launch { runCatching { container.settingsStore.setThemeMode(mode) } } },
97+ onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
97 )
98 )
98 }
99 }
99
100
@@ -112,7 +113,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
112 }
113 }
113 Switch(
114 Switch(
114 checked = dynamicColor,
115 checked = dynamicColor,
115− onCheckedChange = { scope.launch { runCatching { container.settingsStore.setDynamicColor(it) } } },
116+ onCheckedChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
116 )
117 )
117 }
118 }
118
119
app/src/main/res/values/strings.xml+1
@@ -92,6 +92,7 @@
92 <string name="error_not_reachable">Could not reach %1$s</string>
92 <string name="error_not_reachable">Could not reach %1$s</string>
93 <string name="error_timeout">The request timed out. Check the connection and try again.</string>
93 <string name="error_timeout">The request timed out. Check the connection and try again.</string>
94 <string name="error_server">The server reported an error. Try again later.</string>
94 <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>
95
96
96 <!-- Part renderer -->
97 <!-- Part renderer -->
97 <string name="thinking">Thinking…</string>
98 <string name="thinking">Thinking…</string>
config/detekt/detekt.yml+3
@@ -21,6 +21,9 @@ complexity:
21 NestedBlockDepth:
21 NestedBlockDepth:
22 active: true
22 active: true
23 threshold: 6
23 threshold: 6
24+ TooManyFunctions:
25+ active: true
26+ ignoreOverridden: true
24
27
25style:
28style:
26 active: true
29 active: true