git.iko.soy/opencode-android
on master help sign in
Fix swallowed server-switch error, add retry to file viewer/browser errors, and skip redundant ProfileStore migration scans
commit85cf05920c68991681514664524e6abc8d9388fb authoriko <mail@iko.soy> dateTue 30 Jun 2026, 16:16 (4 months ago) parentfb38d12
5 files changed+47−12
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+6−1 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+12−4 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+10−2 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+16−4 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+3−1
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+6−1
@@ -33,6 +33,9 @@ open class ProfileStore private constructor(
3333
3434 private val profilesKey = stringPreferencesKey("profiles_json")
3535
36+ /** Guards [migrateFallbackPasswords] so it runs at most once per process, not on every DataStore emission. */
37+ private val migrationDone = java.util.concurrent.atomic.AtomicBoolean(false)
38+
3639 private val securePrefs: SharedPreferences? by lazy {
3740 val ctx = appContext ?: return@lazy null
3841 runCatching {
@@ -65,8 +68,10 @@ open class ProfileStore private constructor(
6568 securePrefs ?: fallbackPrefs
6669
6770 /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
68− * then clear the fallback. Called lazily once when secure prefs are available. */
71+ * then clear the fallback. Runs at most once per process — subsequent calls
72+ * are no-ops — so DataStore re-emissions don't repeatedly scan SharedPreferences. */
6973 private suspend fun migrateFallbackPasswords() {
74+ if (!migrationDone.compareAndSet(false, true)) return
7075 val secure = securePrefs ?: return
7176 withContext(Dispatchers.IO) {
7277 val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+12−4
@@ -116,11 +116,19 @@ fun FileBrowserScreen(
116116 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
117117 )
118118 }
119− state.error != null -> Text(
120− state.error ?: "",
119+ state.error != null -> Column(
121120 modifier = Modifier.align(Alignment.Center).padding(24.dp),
122− color = MaterialTheme.colorScheme.error,
123− )
121+ horizontalAlignment = Alignment.CenterHorizontally,
122+ ) {
123+ Text(
124+ state.error ?: "",
125+ color = MaterialTheme.colorScheme.error,
126+ )
127+ androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
128+ androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
129+ Text(stringResource(R.string.retry))
130+ }
131+ }
124132 state.isSearching -> SearchResults(state.results, onOpenFile)
125133 else -> DirectoryListing(
126134 state = state,
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+10−2
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
1111import kotlinx.coroutines.flow.StateFlow
1212import kotlinx.coroutines.flow.asStateFlow
1313import kotlinx.coroutines.flow.collectLatest
14+import kotlinx.coroutines.flow.merge
1415import kotlinx.coroutines.launch
1516import soy.iko.opencode.util.runCatchingCancellable
1617
@@ -30,13 +31,20 @@ class FileViewModel(
3031 private val _state = MutableStateFlow(FileViewState())
3132 val state: StateFlow<FileViewState> = _state.asStateFlow()
3233
34+ /** Incremented by [reload] to trigger a fresh fetch without waiting for a connection change. */
35+ private val _reload = MutableStateFlow(0)
36+
37+ fun reload() { _reload.value++ }
38+
3339 init {
3440 // Observe the active connection so the file loads (or reloads) when a connection
3541 // becomes available — including when the view opens during a reconnect window
3642 // where activeConnection.value was momentarily null. collectLatest cancels the
37− // in-flight load if the connection is replaced mid-read.
43+ // in-flight load if the connection is replaced mid-read. The reload trigger is
44+ // merged in so a manual retry (e.g. after a transient error) re-fetches.
3845 viewModelScope.launch {
39− container.activeConnection.collectLatest { conn ->
46+ merge(container.activeConnection, _reload).collectLatest {
47+ val conn = container.activeConnection.value
4048 if (conn == null) {
4149 _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
4250 return@collectLatest
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+16−4
@@ -4,9 +4,12 @@ import android.content.Intent
44import soy.iko.opencode.ui.components.showToast
55import androidx.compose.foundation.horizontalScroll
66import androidx.compose.foundation.layout.Box
7+import androidx.compose.foundation.layout.Column
78import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.Spacer
810import androidx.compose.foundation.layout.fillMaxSize
911import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
1013import androidx.compose.foundation.layout.width
1114import androidx.compose.foundation.lazy.LazyColumn
1215import androidx.compose.foundation.lazy.itemsIndexed
@@ -23,6 +26,7 @@ import androidx.compose.material3.IconButton
2326import androidx.compose.material3.MaterialTheme
2427import androidx.compose.material3.Scaffold
2528import androidx.compose.material3.Text
29+import androidx.compose.material3.TextButton
2630import androidx.compose.material3.TopAppBar
2731import androidx.compose.runtime.Composable
2832import androidx.compose.runtime.getValue
@@ -99,11 +103,19 @@ fun FileViewScreen(
99103 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
100104 )
101105 }
102− state.error != null -> Text(
103− state.error ?: "",
106+ state.error != null -> Column(
104107 modifier = Modifier.align(Alignment.Center).padding(24.dp),
105− color = MaterialTheme.colorScheme.error,
106− )
108+ horizontalAlignment = Alignment.CenterHorizontally,
109+ ) {
110+ Text(
111+ state.error ?: "",
112+ color = MaterialTheme.colorScheme.error,
113+ )
114+ Spacer(Modifier.size(12.dp))
115+ TextButton(onClick = { vm.reload() }) {
116+ Text(stringResource(R.string.retry))
117+ }
118+ }
107119 state.content?.isBinary == true -> Text(
108120 stringResource(R.string.binary_file),
109121 modifier = Modifier.align(Alignment.Center).padding(24.dp),
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+3−1
@@ -324,9 +324,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
324324 sessions = emptyList(),
325325 previews = emptyMap(),
326326 loading = false,
327− error = container.friendlyError(error),
328327 )
329328 }
329+ // Surface the switch failure as a transient snackbar — refresh()
330+ // would clear a state.error, so use _transientError which survives it.
331+ _transientError.value = container.friendlyError(error)
330332 refresh()
331333 } else {
332334 // Restore also failed — the user is now disconnected from