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
85cf059iko
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+6−1
@@ -33,6 +33,9 @@ open class ProfileStore private constructor(
33
33
34 private val profilesKey = stringPreferencesKey("profiles_json")
34 private val profilesKey = stringPreferencesKey("profiles_json")
35
35
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+
36 private val securePrefs: SharedPreferences? by lazy {
39 private val securePrefs: SharedPreferences? by lazy {
37 val ctx = appContext ?: return@lazy null
40 val ctx = appContext ?: return@lazy null
38 runCatching {
41 runCatching {
@@ -65,8 +68,10 @@ open class ProfileStore private constructor(
65 securePrefs ?: fallbackPrefs
68 securePrefs ?: fallbackPrefs
66
69
67 /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
70 /** 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. */
69 private suspend fun migrateFallbackPasswords() {
73 private suspend fun migrateFallbackPasswords() {
74+ if (!migrationDone.compareAndSet(false, true)) return
70 val secure = securePrefs ?: return
75 val secure = securePrefs ?: return
71 withContext(Dispatchers.IO) {
76 withContext(Dispatchers.IO) {
72 val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
77 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(
116 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
116 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
117 )
117 )
118 }
118 }
119− state.error != null -> Text(
119+ state.error != null -> Column(
120− state.error ?: "",
121 modifier = Modifier.align(Alignment.Center).padding(24.dp),
120 modifier = Modifier.align(Alignment.Center).padding(24.dp),
121+ horizontalAlignment = Alignment.CenterHorizontally,
122+ ) {
123+ Text(
124+ state.error ?: "",
122− color = MaterialTheme.colorScheme.error,
125+ color = MaterialTheme.colorScheme.error,
123− )
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+ }
124 state.isSearching -> SearchResults(state.results, onOpenFile)
132 state.isSearching -> SearchResults(state.results, onOpenFile)
125 else -> DirectoryListing(
133 else -> DirectoryListing(
126 state = state,
134 state = state,
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+10−2
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.asStateFlow
12import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.collectLatest
13import kotlinx.coroutines.flow.collectLatest
14+import kotlinx.coroutines.flow.merge
14import kotlinx.coroutines.launch
15import kotlinx.coroutines.launch
15import soy.iko.opencode.util.runCatchingCancellable
16import soy.iko.opencode.util.runCatchingCancellable
16
17
@@ -30,13 +31,20 @@ class FileViewModel(
30 private val _state = MutableStateFlow(FileViewState())
31 private val _state = MutableStateFlow(FileViewState())
31 val state: StateFlow<FileViewState> = _state.asStateFlow()
32 val state: StateFlow<FileViewState> = _state.asStateFlow()
32
33
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+
33 init {
39 init {
34 // Observe the active connection so the file loads (or reloads) when a connection
40 // Observe the active connection so the file loads (or reloads) when a connection
35 // becomes available — including when the view opens during a reconnect window
41 // becomes available — including when the view opens during a reconnect window
36 // where activeConnection.value was momentarily null. collectLatest cancels the
42 // 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.
38 viewModelScope.launch {
45 viewModelScope.launch {
39− container.activeConnection.collectLatest { conn ->
46+ merge(container.activeConnection, _reload).collectLatest {
47+ val conn = container.activeConnection.value
40 if (conn == null) {
48 if (conn == null) {
41 _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
49 _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
42 return@collectLatest
50 return@collectLatest
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+16−4
@@ -4,9 +4,12 @@ import android.content.Intent
4import soy.iko.opencode.ui.components.showToast
4import soy.iko.opencode.ui.components.showToast
5import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.horizontalScroll
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
7+import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.layout.width
13import androidx.compose.foundation.layout.width
11import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.itemsIndexed
15import androidx.compose.foundation.lazy.itemsIndexed
@@ -23,6 +26,7 @@ import androidx.compose.material3.IconButton
23import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.Scaffold
27import androidx.compose.material3.Scaffold
25import androidx.compose.material3.Text
28import androidx.compose.material3.Text
29+import androidx.compose.material3.TextButton
26import androidx.compose.material3.TopAppBar
30import androidx.compose.material3.TopAppBar
27import androidx.compose.runtime.Composable
31import androidx.compose.runtime.Composable
28import androidx.compose.runtime.getValue
32import androidx.compose.runtime.getValue
@@ -99,11 +103,19 @@ fun FileViewScreen(
99 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
103 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
100 )
104 )
101 }
105 }
102− state.error != null -> Text(
106+ state.error != null -> Column(
103− state.error ?: "",
104 modifier = Modifier.align(Alignment.Center).padding(24.dp),
107 modifier = Modifier.align(Alignment.Center).padding(24.dp),
108+ horizontalAlignment = Alignment.CenterHorizontally,
109+ ) {
110+ Text(
111+ state.error ?: "",
105− color = MaterialTheme.colorScheme.error,
112+ color = MaterialTheme.colorScheme.error,
106− )
113+ )
114+ Spacer(Modifier.size(12.dp))
115+ TextButton(onClick = { vm.reload() }) {
116+ Text(stringResource(R.string.retry))
117+ }
118+ }
107 state.content?.isBinary == true -> Text(
119 state.content?.isBinary == true -> Text(
108 stringResource(R.string.binary_file),
120 stringResource(R.string.binary_file),
109 modifier = Modifier.align(Alignment.Center).padding(24.dp),
121 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() {
324 sessions = emptyList(),
324 sessions = emptyList(),
325 previews = emptyMap(),
325 previews = emptyMap(),
326 loading = false,
326 loading = false,
327− error = container.friendlyError(error),
328 )
327 )
329 }
328 }
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)
330 refresh()
332 refresh()
331 } else {
333 } else {
332 // Restore also failed — the user is now disconnected from
334 // Restore also failed — the user is now disconnected from