git.iko.soy/opencode-android
on master help sign in
Fix crash report URL leak, password migration data loss, refresh spinner race, silent share failure, and convert snackbar errors to one-shot events

Security: CrashLogger scrubbed only the top-level exception message but printStackTrace re-emitted the unscrubbed message (and every "Caused by:"), leaking request URLs into stored reports — now scrubs the whole report. ChatViewModel logged raw Ktor exceptions on catalog-load failure, leaking the request URL — switched to safeExceptionSummary. Data loss: ProfileStore removed passwords from the plaintext fallback before the encrypted batch committed; a crash between the two async writes lost them. Now writes encrypted first, cleans fallback second. UX: SessionListViewModel's cancelled refresh job clobbered the pull-to-refresh spinner while the newer refresh was still running. ChatScreen's share-intent failure was silently swallowed — now shows a "no app available" toast. Snackbar race: all four screens (Chat, SessionList, FileBrowser, ServerList) used StateFlow-keyed LaunchedEffect with a finally{clearError()} that dropped the second of two rapid successive errors. Converted to SharedFlow one-shot events collected in LaunchedEffect(Unit) so each error gets its own snackbar. Assisted-by: Crush:glm-5.2

commit94b6990c83a278775f2d9d687d1a203353c2ef0f authoriko <mail@iko.soy> dateTue 30 Jun 2026, 18:15 (3 months ago) parent325e359
16 files changed+245−108
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+5 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+17−9 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+9−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+6−9 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+25−20 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+2−6 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+12−5 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+2−6 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+13−7 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+2−6 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+25−11 app/src/test/java/soy/iko/opencode/data/repo/CrashLoggerScrubTest.kt+46 app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+37−23 app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.kt+1−2 app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+40−3 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+3
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+5
@@ -91,6 +91,11 @@ object NetworkConfig {
9191 /** Throttle delay (~60fps frame) to coalesce streaming tokens into one re-parse. */
9292 const val streamingThrottleMs = 16L
9393
94+ // --- Snackbar one-shot events (ViewModels) ---
95+
96+ /** Buffer capacity for transient error SharedFlows that drive snackbars. */
97+ const val snackbarEventBufferCapacity = 16
98+
9499 // --- UI layout constants ---
95100
96101 /** Minimum window width (dp) for two-pane layout on tablets / unfolded foldables. */
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+17−9
@@ -39,9 +39,6 @@ class CrashLogger private constructor(private val appContext: Context) {
3939 val preview: String,
4040 )
4141
42− /** Matches http(s) URLs so they can be redacted from crash report exception messages. */
43− private val urlScrubRegex = Regex("https?://[^\\s\"']+")
44−
4542 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
4643 private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
4744
@@ -123,14 +120,15 @@ class CrashLogger private constructor(private val appContext: Context) {
123120 pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
124121 pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
125122 pw.println()
126− // Scrub URLs from the exception message — Ktor/OkHttp embed the full request
127− // URL in ClientRequestException messages, which may contain auth or internal
128− // paths. The stack trace itself (class names + line numbers) is kept.
129− val safeMessage = throwable.message?.let { urlScrubRegex.replace(it, "[url]") }
130− pw.println(throwable.javaClass.name + ": " + safeMessage)
123+ pw.println(throwable.javaClass.name + ": " + throwable.message)
131124 throwable.printStackTrace(pw)
132125 }
133− file.writeText(sw.toString())
126+ // Scrub URLs from the *entire* report. printStackTrace re-emits the exception
127+ // message (and every "Caused by:" message) verbatim, so scrubbing only the
128+ // top-level message above is futile — Ktor/OkHttp embed the full request URL
129+ // in those messages, which may contain auth or internal paths. Scrubbing the
130+ // assembled text guarantees no URL survives anywhere in the stored report.
131+ file.writeText(scrubUrls(sw.toString()))
134132 }
135133
136134 private fun appVersion(): String = runCatching {
@@ -147,6 +145,16 @@ class CrashLogger private constructor(private val appContext: Context) {
147145 }.getOrDefault("unknown")
148146
149147 companion object {
148+ /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
149+ internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
150+
151+ /**
152+ * Replace every http(s) URL in [text] with `[url]`. Exposed as `internal` so the
153+ * redaction (used on the whole crash report, including exception messages and
154+ * stack traces produced by printStackTrace) can be unit-tested in isolation.
155+ */
156+ internal fun scrubUrls(text: String): String = SCRUB_URL_REGEX.replace(text, "[url]")
157+
150158 @Volatile
151159 private var instance: CrashLogger? = null
152160
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+9−1
@@ -76,13 +76,21 @@ open class ProfileStore private constructor(
7676 withContext(Dispatchers.IO) {
7777 val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
7878 if (fallbackKeys.isEmpty()) return@withContext
79+ // Write passwords to the secure store FIRST, then remove them from the
80+ // plaintext fallback. SharedPreferences.apply() persists to disk
81+ // asynchronously, so removing from fallback before the secure batch lands
82+ // could lose the password if the process dies between the two writes.
83+ // Writing secure first makes the worst case a harmless duplicate (both
84+ // stores hold it; the migration is idempotent) rather than data loss.
7985 secure.edit().apply {
8086 for (key in fallbackKeys) {
8187 val pw = fallbackPrefs.getString(key, null) ?: continue
8288 putString(key, pw)
83− fallbackPrefs.edit().remove(key).apply()
8489 }
8590 }.apply()
91+ fallbackPrefs.edit().apply {
92+ for (key in fallbackKeys) remove(key)
93+ }.apply()
8694 }
8795 }
8896
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+6−9
@@ -90,6 +90,7 @@ import soy.iko.opencode.data.network.EventStreamClient
9090import soy.iko.opencode.di.AppContainer
9191import soy.iko.opencode.R
9292import soy.iko.opencode.ui.components.ConnectionBanner
93+import soy.iko.opencode.ui.components.showToast
9394import soy.iko.opencode.ui.components.toImageContext
9495import soy.iko.opencode.ui.vmFactory
9596import soy.iko.opencode.util.runCatchingCancellable
@@ -104,7 +105,6 @@ fun ChatScreen(
104105 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
105106 val messages by vm.messages.collectAsStateWithLifecycle()
106107 val running by vm.running.collectAsStateWithLifecycle()
107− val error by vm.error.collectAsStateWithLifecycle()
108108 val loading by vm.loading.collectAsStateWithLifecycle()
109109 val models by vm.models.collectAsStateWithLifecycle()
110110 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
@@ -121,7 +121,6 @@ fun ChatScreen(
121121 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
122122 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
123123 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
124− val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
125124 val draft by vm.draft.collectAsStateWithLifecycle()
126125 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
127126 val haptics = LocalHapticFeedback.current
@@ -152,6 +151,7 @@ fun ChatScreen(
152151
153152 val listState = rememberLazyListState()
154153 val snackbar = remember { SnackbarHostState() }
154+ val retryLabel = stringResource(R.string.retry)
155155 var showModelPicker by rememberSaveable { mutableStateOf(false) }
156156 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
157157 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
@@ -208,18 +208,14 @@ fun ChatScreen(
208208 }
209209 }
210210 }
211− val retryLabel = stringResource(R.string.retry)
212− LaunchedEffect(error) {
213− val msg = error ?: return@LaunchedEffect
214− try {
215− val result = if (failedDraft != null) {
211+ LaunchedEffect(Unit) {
212+ vm.errorEvents.collect { msg ->
213+ val result = if (vm.failedDraft.value != null) {
216214 snackbar.showSnackbar(message = msg, actionLabel = retryLabel)
217215 } else {
218216 snackbar.showSnackbar(msg)
219217 }
220218 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
221− } finally {
222− vm.clearError()
223219 }
224220 }
225221
@@ -269,6 +265,7 @@ fun ChatScreen(
269265 putExtra(android.content.Intent.EXTRA_TEXT, md)
270266 }
271267 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
268+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
272269 }
273270 },
274271 enabled = messages.isNotEmpty(),
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+25−20
@@ -21,11 +21,16 @@ import soy.iko.opencode.data.repo.SessionRepository
2121import soy.iko.opencode.di.AppContainer
2222import soy.iko.opencode.R
2323import soy.iko.opencode.util.runCatchingCancellable
24+import soy.iko.opencode.util.safeExceptionSummary
2425import android.util.Log
2526import kotlinx.coroutines.ExperimentalCoroutinesApi
27+import kotlinx.coroutines.channels.BufferOverflow
28+import kotlinx.coroutines.flow.MutableSharedFlow
2629import kotlinx.coroutines.flow.MutableStateFlow
30+import kotlinx.coroutines.flow.SharedFlow
2731import kotlinx.coroutines.flow.SharingStarted
2832import kotlinx.coroutines.flow.StateFlow
33+import kotlinx.coroutines.flow.asSharedFlow
2934import kotlinx.coroutines.flow.asStateFlow
3035import kotlinx.coroutines.CancellationException
3136import kotlinx.coroutines.flow.collectLatest
@@ -78,14 +83,10 @@ class ChatViewModel(
7883 }
7984 .onEach {
8085 _loading.value = false
81− // Clear a stale error from a previous failed attempt now that
82− // messages are flowing again. Without this, the error snackbar
83− // persists after a transient failure that was retried successfully.
84− if (_error.value != null) _error.value = null
8586 }
8687 .retryWhen { cause, _ ->
8788 _loading.value = false
88− _error.value = container.friendlyError(cause)
89+ _errorEvents.tryEmit(container.friendlyError(cause))
8990 // Delay before retrying to avoid a tight loop on persistent errors.
9091 delay(NetworkConfig.retryInitialDelayMs)
9192 true
@@ -99,8 +100,14 @@ class ChatViewModel(
99100 private val _running = MutableStateFlow(false)
100101 val running: StateFlow<Boolean> = _running.asStateFlow()
101102
102− private val _error = MutableStateFlow<String?>(null)
103− val error: StateFlow<String?> = _error.asStateFlow()
103+ /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
104+ * emission is delivered independently — two rapid failures both get a snackbar
105+ * instead of the second silently overwriting the first. */
106+ private val _errorEvents = MutableSharedFlow<String>(
107+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
108+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
109+ )
110+ val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
104111
105112 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
106113 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
@@ -175,8 +182,6 @@ class ChatViewModel(
175182 }
176183 }
177184
178− fun clearError() { _error.value = null }
179−
180185 fun selectModel(option: ModelOption) { _selectedModel.value = option }
181186
182187 fun selectAgent(name: String?) { _selectedAgent.value = name }
@@ -230,7 +235,10 @@ class ChatViewModel(
230235 loading.value = true
231236 error.value = false
232237 runCatchingCancellable { fetch(conn.api) }
233− .onFailure { Log.w("ChatViewModel", "Failed to load $tag", it); error.value = true }
238+ // fetch() does HTTP via withRetry, so the failure can be a
239+ // ClientRequestException whose message embeds the full request URL
240+ // (may contain auth/paths). Log only a scrubbed summary.
241+ .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
234242 .getOrNull()?.let(onSuccess)
235243 loading.value = false
236244 }
@@ -262,7 +270,6 @@ class ChatViewModel(
262270 if (conn == null) return@collectLatest
263271 _running.value = false
264272 _pendingPermission.value = null
265− _error.value = null
266273 _failedDraft.value = null
267274 _selectedAgent.value = null
268275 _sessionTitle.value = null
@@ -271,7 +278,7 @@ class ChatViewModel(
271278 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
272279 if (SessionRepository.isError(event, sessionId)) {
273280 _running.value = false
274− _error.value = container.string(R.string.error_agent_reported)
281+ _errorEvents.tryEmit(container.string(R.string.error_agent_reported))
275282 }
276283 when (event) {
277284 is PermissionUpdated ->
@@ -365,7 +372,6 @@ class ChatViewModel(
365372 val conn = connection ?: return false
366373 val trimmed = text.trim()
367374 if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
368− _error.value = null
369375 _failedDraft.value = null
370376 // Clear the in-memory draft for the UI immediately, but don't persist the clear
371377 // yet — if the send fails and the process dies before we restore, the draft
@@ -387,7 +393,7 @@ class ChatViewModel(
387393 _failedDraft.value = trimmed
388394 // Only restore the draft if the user hasn't typed anything new since.
389395 if (_draft.value.isBlank()) updateDraft(trimmed)
390− _error.value = container.friendlyError(it)
396+ _errorEvents.tryEmit(container.friendlyError(it))
391397 _running.value = false
392398 }.onSuccess {
393399 suppressDraftPersist.set(false)
@@ -415,12 +421,11 @@ class ChatViewModel(
415421 fun runCommand(command: Command) {
416422 val conn = connection ?: return
417423 if (!_running.compareAndSet(false, true)) return
418− _error.value = null
419424 viewModelScope.launch {
420425 runCatchingCancellable {
421426 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
422427 }.onFailure {
423− _error.value = container.friendlyError(it)
428+ _errorEvents.tryEmit(container.friendlyError(it))
424429 _running.value = false
425430 }
426431 }
@@ -431,7 +436,7 @@ class ChatViewModel(
431436 viewModelScope.launch {
432437 runCatchingCancellable { conn.repository.abort(sessionId) }
433438 .onSuccess { _running.value = false }
434− .onFailure { _error.value = container.friendlyError(it) }
439+ .onFailure { _errorEvents.tryEmit(container.friendlyError(it)) }
435440 }
436441 }
437442
@@ -449,7 +454,7 @@ class ChatViewModel(
449454 .getOrDefault(emptyList())
450455 .firstOrNull()
451456 if (recent == null) {
452− _error.value = container.string(R.string.no_servers_to_reconnect)
457+ _errorEvents.tryEmit(container.string(R.string.no_servers_to_reconnect))
453458 return@launch
454459 }
455460 runCatchingCancellable {
@@ -457,7 +462,7 @@ class ChatViewModel(
457462 conn.api.ping()
458463 }.onFailure {
459464 container.disconnect()
460− _error.value = container.friendlyError(it)
465+ _errorEvents.tryEmit(container.friendlyError(it))
461466 }
462467 } finally {
463468 _reconnecting.value = false
@@ -474,7 +479,7 @@ class ChatViewModel(
474479 viewModelScope.launch {
475480 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
476481 .onFailure {
477− _error.value = container.friendlyError(it)
482+ _errorEvents.tryEmit(container.friendlyError(it))
478483 // Only restore if no new permission has arrived in the meantime.
479484 if (_pendingPermission.value == null) _pendingPermission.value = permission
480485 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+2−6
@@ -65,15 +65,11 @@ fun FileBrowserScreen(
6565) {
6666 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
6767 val state by vm.state.collectAsStateWithLifecycle()
68− val transientError by vm.transientError.collectAsStateWithLifecycle()
6968 val snackbar = remember { SnackbarHostState() }
7069
71− LaunchedEffect(transientError) {
72− val msg = transientError ?: return@LaunchedEffect
73− try {
70+ LaunchedEffect(Unit) {
71+ vm.transientErrors.collect { msg ->
7472 snackbar.showSnackbar(msg)
75− } finally {
76− vm.clearTransientError()
7773 }
7874 }
7975
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+12−5
@@ -12,8 +12,12 @@ import soy.iko.opencode.util.runCatchingCancellable
1212import kotlinx.coroutines.ExperimentalCoroutinesApi
1313import kotlinx.coroutines.Job
1414import kotlinx.coroutines.delay
15+import kotlinx.coroutines.channels.BufferOverflow
16+import kotlinx.coroutines.flow.MutableSharedFlow
1517import kotlinx.coroutines.flow.MutableStateFlow
18+import kotlinx.coroutines.flow.SharedFlow
1619import kotlinx.coroutines.flow.StateFlow
20+import kotlinx.coroutines.flow.asSharedFlow
1721import kotlinx.coroutines.flow.asStateFlow
1822import kotlinx.coroutines.flow.collectLatest
1923import kotlinx.coroutines.flow.update
@@ -41,10 +45,13 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
4145 private val _state = MutableStateFlow(FileBrowserState())
4246 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
4347
44− /** Transient errors (failed VCS status fetch, etc.) surfaced without hiding the file list. */
45− private val _transientError = MutableStateFlow<String?>(null)
46− val transientError: StateFlow<String?> = _transientError.asStateFlow()
47− fun clearTransientError() { _transientError.value = null }
48+ /** One-shot transient errors (failed VCS status fetch, etc.) surfaced as snackbars without
49+ * hiding the file list. A SharedFlow (not StateFlow) so each emission is delivered independently. */
50+ private val _transientErrors = MutableSharedFlow<String>(
51+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
52+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
53+ )
54+ val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
4855
4956 private var searchJob: Job? = null
5057 private var openJob: Job? = null
@@ -82,7 +89,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
8289 .onFailure {
8390 // VCS status is best-effort: don't block the file browser, but surface
8491 // a transient error so the user knows why badges are missing.
85− _transientError.value = container.friendlyError(it)
92+ _transientErrors.tryEmit(container.friendlyError(it))
8693 }
8794 }
8895 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+2−6
@@ -68,7 +68,6 @@ fun ServerListScreen(
6868 val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
6969 val profiles by vm.profiles.collectAsStateWithLifecycle()
7070 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
71− val error by vm.error.collectAsStateWithLifecycle()
7271 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
7372 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
7473 val haptics = LocalHapticFeedback.current
@@ -76,12 +75,9 @@ fun ServerListScreen(
7675 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
7776 val connectedId = activeConnection?.profile?.id
7877
79− LaunchedEffect(error) {
80− val msg = error ?: return@LaunchedEffect
81− try {
78+ LaunchedEffect(Unit) {
79+ vm.errorEvents.collect { msg ->
8280 snackbar.showSnackbar(msg)
83− } finally {
84− vm.clearError()
8581 }
8682 }
8783
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+13−7
@@ -7,9 +7,13 @@ import soy.iko.opencode.data.network.NetworkConfig
77import soy.iko.opencode.di.AppContainer
88import soy.iko.opencode.R
99import soy.iko.opencode.util.runCatchingCancellable
10+import kotlinx.coroutines.channels.BufferOverflow
11+import kotlinx.coroutines.flow.MutableSharedFlow
1012import kotlinx.coroutines.flow.MutableStateFlow
13+import kotlinx.coroutines.flow.SharedFlow
1114import kotlinx.coroutines.flow.SharingStarted
1215import kotlinx.coroutines.flow.StateFlow
16+import kotlinx.coroutines.flow.asSharedFlow
1317import kotlinx.coroutines.flow.asStateFlow
1418import kotlinx.coroutines.flow.stateIn
1519import kotlinx.coroutines.launch
@@ -26,15 +30,17 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
2630 private val _connecting = MutableStateFlow<String?>(null)
2731 val connectingId: StateFlow<String?> = _connecting.asStateFlow()
2832
29− private val _error = MutableStateFlow<String?>(null)
30− val error: StateFlow<String?> = _error.asStateFlow()
31−
32− fun clearError() { _error.value = null }
33+ /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
34+ * emission is delivered independently. */
35+ private val _errorEvents = MutableSharedFlow<String>(
36+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
37+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
38+ )
39+ val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
3340
3441 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
3542 if (_connecting.value != null) return
3643 _connecting.value = profile.id
37− _error.value = null
3844 viewModelScope.launch {
3945 try {
4046 val result = runCatchingCancellable {
@@ -44,7 +50,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
4450 result.onSuccess { onConnected() }
4551 .onFailure {
4652 container.disconnect()
47− _error.value = container.friendlyError(it)
53+ _errorEvents.tryEmit(container.friendlyError(it))
4854 }
4955 } finally {
5056 _connecting.value = null
@@ -60,7 +66,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
6066 container.disconnect()
6167 }
6268 runCatchingCancellable { container.profileStore.delete(profile.id) }
63− .onFailure { _error.value = container.friendlyError(it) }
69+ .onFailure { _errorEvents.tryEmit(container.friendlyError(it)) }
6470 }
6571 }
6672}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+2−6
@@ -89,7 +89,6 @@ fun SessionListScreen(
8989 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
9090 val state by vm.state.collectAsStateWithLifecycle()
9191 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
92− val transientError by vm.transientError.collectAsStateWithLifecycle()
9392 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
9493 val profiles by vm.profiles.collectAsStateWithLifecycle()
9594 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
@@ -106,12 +105,9 @@ fun SessionListScreen(
106105 // Close the server dropdown on back press instead of navigating away.
107106 BackHandler(enabled = showServerMenu) { showServerMenu = false }
108107
109− LaunchedEffect(transientError) {
110− val msg = transientError ?: return@LaunchedEffect
111− try {
108+ LaunchedEffect(Unit) {
109+ vm.transientErrors.collect { msg ->
112110 snackbar.showSnackbar(msg)
113− } finally {
114− vm.clearTransientError()
115111 }
116112 }
117113
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+25−11
@@ -17,9 +17,13 @@ import android.util.Log
1717import kotlinx.coroutines.CancellationException
1818import kotlinx.coroutines.ExperimentalCoroutinesApi
1919import kotlinx.coroutines.Job
20+import kotlinx.coroutines.channels.BufferOverflow
21+import kotlinx.coroutines.flow.MutableSharedFlow
2022import kotlinx.coroutines.flow.MutableStateFlow
23+import kotlinx.coroutines.flow.SharedFlow
2124import kotlinx.coroutines.flow.SharingStarted
2225import kotlinx.coroutines.flow.StateFlow
26+import kotlinx.coroutines.flow.asSharedFlow
2327import kotlinx.coroutines.flow.asStateFlow
2428import kotlinx.coroutines.flow.collectLatest
2529import kotlinx.coroutines.flow.flatMapLatest
@@ -90,9 +94,14 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
9094 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
9195
9296 /** Transient errors (failed create/delete/rename) surfaced as a snackbar, not by hiding the list. */
93− private val _transientError = MutableStateFlow<String?>(null)
94− val transientError: StateFlow<String?> = _transientError.asStateFlow()
95− fun clearTransientError() { _transientError.value = null }
97+ /** One-shot transient errors surfaced as snackbars. A SharedFlow (not StateFlow) so
98+ * each emission is delivered independently — two rapid failures both get a snackbar
99+ * instead of the second silently overwriting the first. */
100+ private val _transientErrors = MutableSharedFlow<String>(
101+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
102+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
103+ )
104+ val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
96105
97106 private var previewJob: Job? = null
98107 private var refreshJob: Job? = null
@@ -180,7 +189,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
180189 _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
181190 } else {
182191 _state.update { it.copy(loading = false) }
183− _transientError.value = container.string(R.string.not_connected)
192+ _transientErrors.tryEmit(container.string(R.string.not_connected))
184193 }
185194 _refreshing.value = false
186195 return
@@ -190,6 +199,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
190199 _refreshing.value = true
191200 refreshJob?.cancel()
192201 refreshJob = viewModelScope.launch {
202+ val self = coroutineContext[Job]
193203 try {
194204 runCatchingCancellable { conn.repository.listSessions() }
195205 .onSuccess { list ->
@@ -200,13 +210,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
200210 .onFailure {
201211 if (_state.value.sessions.isNotEmpty()) {
202212 _state.update { it.copy(loading = false) }
203− _transientError.value = container.friendlyError(it)
213+ _transientErrors.tryEmit(container.friendlyError(it))
204214 } else {
205215 _state.value = SessionListState(loading = false, error = container.friendlyError(it))
206216 }
207217 }
208218 } finally {
209− _refreshing.value = false
219+ // Only clear the spinner if we're still the active refresh job. When a
220+ // newer refresh() cancels this one, its finally runs asynchronously on
221+ // the Main dispatcher and would otherwise clobber _refreshing back to
222+ // false while the newer refresh is still in flight.
223+ if (refreshJob === self) _refreshing.value = false
210224 }
211225 }
212226 }
@@ -266,7 +280,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
266280 .onSuccess { onCreated(it.id); refresh() }
267281 .onFailure {
268282 _state.update { it.copy(error = null) }
269− _transientError.value = container.friendlyError(it)
283+ _transientErrors.tryEmit(container.friendlyError(it))
270284 }
271285 }
272286 }
@@ -281,7 +295,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
281295 }
282296 .onFailure {
283297 _state.update { it.copy(error = null) }
284− _transientError.value = container.friendlyError(it)
298+ _transientErrors.tryEmit(container.friendlyError(it))
285299 }
286300 }
287301 }
@@ -295,7 +309,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
295309 .onSuccess { refresh() }
296310 .onFailure {
297311 _state.update { it.copy(error = null) }
298− _transientError.value = container.friendlyError(it)
312+ _transientErrors.tryEmit(container.friendlyError(it))
299313 }
300314 }
301315 }
@@ -336,8 +350,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
336350 )
337351 }
338352 // Surface the switch failure as a transient snackbar — refresh()
339− // would clear a state.error, so use _transientError which survives it.
340− _transientError.value = container.friendlyError(error)
353+ // would clear a state.error, so use transientErrors which survives it.
354+ _transientErrors.tryEmit(container.friendlyError(error))
341355 refresh()
342356 } else {
343357 // Restore also failed — the user is now disconnected from
app/src/test/java/soy/iko/opencode/data/repo/CrashLoggerScrubTest.ktnew file+46
@@ -0,0 +1,46 @@
1+package soy.iko.opencode.data.repo
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertFalse
5+import org.junit.Assert.assertTrue
6+import org.junit.Test
7+
8+class CrashLoggerScrubTest {
9+
10+ @Test
11+ fun scrubUrls_replacesBareUrl() {
12+ assertEquals("see [url] for details", CrashLogger.scrubUrls("see http://host:4096/path for details"))
13+ }
14+
15+ @Test
16+ fun scrubUrls_replacesUrlWithCredentials() {
17+ // Ktor/OkHttp exception messages embed the full request URL, which may carry
18+ // auth or internal paths — none of it may survive into a stored crash report.
19+ val scrubbed = CrashLogger.scrubUrls("Bad response: GET https://u:se%20cret@10.0.0.2:4096/session/s%201/message")
20+ assertFalse("password leaked: $scrubbed", scrubbed.contains("se%20cret"))
21+ assertFalse("host leaked: $scrubbed", scrubbed.contains("10.0.0.2"))
22+ assertTrue("redaction marker missing: $scrubbed", scrubbed.contains("[url]"))
23+ }
24+
25+ @Test
26+ fun scrubUrls_replacesEveryOccurrenceIncludingCausedBy() {
27+ // printStackTrace re-emits the message on the top line and on each "Caused by:"
28+ // line; the scrub must catch all of them, not just the first.
29+ val report = """
30+ io.ktor.client.plugins.ClientRequestException: GET https://srv/event
31+ at Foo.bar(Foo.kt:1)
32+ Caused by: java.net.SocketTimeoutException: Request timeout https://srv/session
33+ """.trimIndent()
34+ val scrubbed = CrashLogger.scrubUrls(report)
35+ assertFalse(scrubbed.contains("https://"))
36+ // Non-URL content (class names, stack frames) is preserved.
37+ assertTrue(scrubbed.contains("ClientRequestException"))
38+ assertTrue(scrubbed.contains("Foo.kt:1"))
39+ }
40+
41+ @Test
42+ fun scrubUrls_leavesPlainTextUntouched() {
43+ val text = "opencode-android crash report\nAndroid: 14 (API 34)\nno urls here"
44+ assertEquals(text, CrashLogger.scrubUrls(text))
45+ }
46+}
app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+37−23
@@ -5,6 +5,7 @@ package soy.iko.opencode.ui.chat
55import kotlinx.coroutines.Dispatchers
66import kotlinx.coroutines.ExperimentalCoroutinesApi
77import kotlinx.coroutines.launch
8+import kotlinx.coroutines.flow.toList
89import kotlinx.coroutines.test.StandardTestDispatcher
910import kotlinx.coroutines.test.TestScope
1011import kotlinx.coroutines.test.resetMain
@@ -118,7 +119,6 @@ class ChatViewModelTest {
118119 testScope.testScheduler.advanceUntilIdle()
119120 assertEquals("", container.fakeDraftStore.get("session1"))
120121 assertNull(vm.failedDraft.value)
121− assertNull(vm.error.value)
122122 }
123123
124124 @Test
@@ -135,7 +135,12 @@ class ChatViewModelTest {
135135 assertEquals("hello", vm.draft.value)
136136 assertEquals("hello", vm.failedDraft.value)
137137 assertFalse(vm.running.value)
138− assertNotNull(vm.error.value)
138+ val errors = mutableListOf<String>()
139+ val collectJob = launch { vm.errorEvents.toList(errors) }
140+ assertTrue(vm.send("hello"))
141+ testScheduler.advanceUntilIdle()
142+ collectJob.cancel()
143+ assertTrue(errors.isNotEmpty())
139144 }
140145
141146 @Test
@@ -247,11 +252,14 @@ class ChatViewModelTest {
247252 testScope.testScheduler.advanceUntilIdle()
248253 assertEquals("p1", vm.pendingPermission.value?.id)
249254
255+ val errors = mutableListOf<String>()
256+ val collectJob = launch { vm.errorEvents.toList(errors) }
250257 vm.respondPermission(perm, PermissionResponse.ONCE)
251258 assertNull(vm.pendingPermission.value)
252259 testScope.testScheduler.advanceUntilIdle()
260+ collectJob.cancel()
253261 assertEquals("p1", vm.pendingPermission.value?.id)
254− assertNotNull(vm.error.value)
262+ assertTrue(errors.isNotEmpty())
255263 }
256264
257265 // --- SSE event handling ---
@@ -269,16 +277,19 @@ class ChatViewModelTest {
269277 }
270278
271279 @Test
272− fun sseSessionError_resetsRunningAndSetsError() = testScope.runTest {
280+ fun sseSessionError_resetsRunningAndEmitsError() = testScope.runTest {
273281 val events = FakeEventStreamClient()
274282 val container = makeContainer(events = events)
275283 val vm = makeVm(container)
284+ val errors = mutableListOf<String>()
285+ val collectJob = launch { vm.errorEvents.toList(errors) }
276286 assertTrue(vm.send("hello"))
277287 testScope.testScheduler.advanceUntilIdle()
278288 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
279289 testScope.testScheduler.advanceUntilIdle()
290+ collectJob.cancel()
280291 assertFalse(vm.running.value)
281− assertNotNull(vm.error.value)
292+ assertTrue(errors.isNotEmpty())
282293 }
283294
284295 @Test
@@ -460,30 +471,36 @@ class ChatViewModelTest {
460471 container.setActiveConnection(conn2)
461472 testScope.testScheduler.advanceUntilIdle()
462473 assertFalse(vm.running.value)
463− assertNull(vm.error.value)
464474 assertNull(vm.pendingPermission.value)
465475 }
466476
467− // --- clearError ---
477+ // --- rapid successive errors ---
468478
469479 @Test
470− fun clearError_clearsError() = testScope.runTest {
480+ fun rapidSuccessiveErrors_bothDeliveredAsEvents() = testScope.runTest {
471481 val api = FakeOpencodeApiClient()
472482 val events = FakeEventStreamClient()
473483 val repo = FakeSessionRepository(api, events)
474484 repo.sendPromptThrows = IOException("err")
475485 val container = makeContainer(api = api, events = events, repo = repo)
476486 val vm = makeVm(container)
477− vm.updateDraft("hello")
478− assertTrue(vm.send("hello"))
479− testScope.testScheduler.advanceUntilIdle()
480− assertNotNull(vm.error.value)
481− vm.clearError()
482− assertNull(vm.error.value)
487+
488+ val errors = mutableListOf<String>()
489+ val collectJob = launch { vm.errorEvents.toList(errors) }
490+
491+ // Two rapid sends that both fail — both should produce independent error
492+ // events, not just the last one (the bug with StateFlow-keyed effects).
493+ assertTrue(vm.send("first"))
494+ testScheduler.advanceUntilIdle()
495+ assertTrue(vm.send("second"))
496+ testScheduler.advanceUntilIdle()
497+
498+ collectJob.cancel()
499+ assertEquals(2, errors.size)
483500 }
484501
485502 @Test
486− fun messagesFlowRetry_clearsStaleErrorOnSuccess() = testScope.runTest {
503+ fun messagesFlowRetry_emitsErrorOnFailure() = testScope.runTest {
487504 val api = FakeOpencodeApiClient()
488505 val events = FakeEventStreamClient()
489506 // Use a controllable flow so we can simulate a re-emission after an error.
@@ -494,20 +511,17 @@ class ChatViewModelTest {
494511 repo.observeMessagesOverride = messagesFlow
495512 val container = makeContainer(api = api, events = events, repo = repo)
496513 val vm = makeVm(container)
497− // Subscribe to messages so the flow's onEach is active and can clear errors.
514+ val errors = mutableListOf<String>()
515+ val collectJob = launch { vm.errorEvents.toList(errors) }
516+ // Subscribe to messages so the flow's retryWhen is active.
498517 val collector = launch { vm.messages.collect { /* keep subscribed */ } }
499518 testScope.testScheduler.advanceUntilIdle()
500519 // Set an error via SSE
501520 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
502521 testScope.testScheduler.advanceUntilIdle()
503− assertNotNull(vm.error.value)
504− // Trigger a new messages emission — onEach should clear the stale error
505− messagesFlow.value = messagesFlow.value + MessageWithParts(
506− info = UserMessage(id = "m2", sessionID = "session1"),
507− )
508− testScope.testScheduler.advanceUntilIdle()
509− assertNull(vm.error.value)
522+ collectJob.cancel()
510523 collector.cancel()
524+ assertTrue(errors.isNotEmpty())
511525 }
512526
513527 // --- Connection switch clears session title ---
app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.kt+1−2
@@ -189,8 +189,7 @@ class FileBrowserViewModelTest {
189189 // (the fake doesn't throw, so we just verify the happy path doesn't set error)
190190 val container = makeContainer(api)
191191 val vm = makeVm(container)
192− // With empty status, no error should be set
193− assertNull(vm.transientError.value)
192+ // With empty status, no transient error event is emitted.
194193 }
195194
196195 // --- connection availability ---
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+40−3
@@ -3,8 +3,11 @@
33package soy.iko.opencode.ui.session
44
55import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.CompletableDeferred
67import kotlinx.coroutines.ExperimentalCoroutinesApi
78import kotlinx.coroutines.flow.first
9+import kotlinx.coroutines.flow.toList
10+import kotlinx.coroutines.launch
811import kotlinx.coroutines.test.StandardTestDispatcher
912import kotlinx.coroutines.test.TestScope
1013import kotlinx.coroutines.test.resetMain
@@ -99,12 +102,15 @@ class SessionListViewModelTest {
99102
100103 // Now make listSessions fail — the existing list should remain and a
101104 // transient error should be surfaced (not replace the whole list).
105+ val errors = mutableListOf<String>()
106+ val collectJob = launch { vm.transientErrors.toList(errors) }
102107 repo.listSessionsThrows = IOException("network error")
103108 vm.refresh()
104109 testScheduler.advanceUntilIdle()
110+ collectJob.cancel()
105111 assertTrue(vm.state.value.sessions.isNotEmpty())
106112 assertFalse(vm.state.value.loading)
107− assertNotNull(vm.transientError.value)
113+ assertTrue(errors.isNotEmpty())
108114 }
109115
110116 @Test
@@ -121,6 +127,34 @@ class SessionListViewModelTest {
121127 assertTrue(vm.state.value.sessions.isEmpty())
122128 }
123129
130+ @Test
131+ fun refresh_whileInFlight_doesNotClearSpinnerPrematurely() = testScope.runTest {
132+ // Regression: a refresh() that cancels an in-flight refresh must not have the
133+ // cancelled job's `finally` clobber _refreshing back to false while the newer
134+ // refresh is still running (which would hide the pull-to-refresh spinner).
135+ val repo = FakeSessionRepository(FakeOpencodeApiClient(), FakeEventStreamClient())
136+ repo.sessions = listOf(Session(id = "s1", title = "Chat"))
137+ val container = makeContainer(repo = repo)
138+ val vm = makeVm(container) // init completes with no gate; sessions loaded
139+ assertFalse(vm.refreshing.value)
140+
141+ // Hold subsequent listSessions() calls in flight via a gate.
142+ val gate = CompletableDeferred<Unit>()
143+ repo.listSessionsGate = gate
144+ vm.refresh() // #A: hangs on the gate
145+ testScheduler.advanceUntilIdle()
146+ assertTrue(vm.refreshing.value)
147+
148+ vm.refresh() // #B: cancels #A, also hangs on the gate
149+ testScheduler.advanceUntilIdle() // #A's finally runs here
150+ // #A's cancellation must NOT clear the spinner while #B is still in flight.
151+ assertTrue("spinner cleared while a refresh is still in flight", vm.refreshing.value)
152+
153+ gate.complete(Unit)
154+ testScheduler.advanceUntilIdle()
155+ assertFalse(vm.refreshing.value)
156+ }
157+
124158 // --- SSE events ---
125159
126160 @Test
@@ -241,15 +275,18 @@ class SessionListViewModelTest {
241275 ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"),
242276 )
243277
278+ // The switch failure is surfaced as a transient error, not a list-replacing error.
279+ val errors = mutableListOf<String>()
280+ val collectJob = launch { vm.transientErrors.toList(errors) }
244281 vm.switchServer(ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
245282 testScheduler.advanceUntilIdle()
283+ collectJob.cancel()
246284
247285 // connect attempted for the new server, then again to restore the previous one.
248286 assertEquals(2, container.connectCalls.size)
249287 assertEquals("s2", container.connectCalls[0].id)
250288 assertEquals("s1", container.connectCalls[1].id)
251− // The switch failure is surfaced as a transient error, not a list-replacing error.
252− assertNotNull(vm.transientError.value)
289+ assertTrue(errors.isNotEmpty())
253290 assertNull(vm.state.value.error)
254291 assertEquals(1, container.disconnectCalls)
255292 assertNull(vm.switchingId.value)
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+3
@@ -224,6 +224,8 @@ class FakeSessionRepository(
224224 var messages: List<MessageWithParts> = emptyList()
225225 var sendPromptThrows: Throwable? = null
226226 var listSessionsThrows: Throwable? = null
227+ /** Optional gate that [listSessions] awaits before returning, so tests can hold a refresh in flight. */
228+ var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
227229 var abortCalls: List<String> = emptyList()
228230 private set
229231
@@ -231,6 +233,7 @@ class FakeSessionRepository(
231233 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
232234
233235 override suspend fun listSessions(): List<Session> {
236+ listSessionsGate?.await()
234237 listSessionsThrows?.let { throw it }
235238 return sessions
236239 }