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
94b6990iko
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+5
@@ -91,6 +91,11 @@ object NetworkConfig {
91 /** Throttle delay (~60fps frame) to coalesce streaming tokens into one re-parse. */
91 /** Throttle delay (~60fps frame) to coalesce streaming tokens into one re-parse. */
92 const val streamingThrottleMs = 16L
92 const val streamingThrottleMs = 16L
93
93
94+ // --- Snackbar one-shot events (ViewModels) ---
95+
96+ /** Buffer capacity for transient error SharedFlows that drive snackbars. */
97+ const val snackbarEventBufferCapacity = 16
98+
94 // --- UI layout constants ---
99 // --- UI layout constants ---
95
100
96 /** Minimum window width (dp) for two-pane layout on tablets / unfolded foldables. */
101 /** 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) {
39 val preview: String,
39 val preview: String,
40 )
40 )
41
41
42− /** Matches http(s) URLs so they can be redacted from crash report exception messages. */
43− private val urlScrubRegex = Regex("https?://[^\\s\"']+")
44−
45 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
42 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
46 private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
43 private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
47
44
@@ -123,14 +120,15 @@ class CrashLogger private constructor(private val appContext: Context) {
123 pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
120 pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
124 pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
121 pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
125 pw.println()
122 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)
131 throwable.printStackTrace(pw)
124 throwable.printStackTrace(pw)
132 }
125 }
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.
133− file.writeText(sw.toString())
131+ file.writeText(scrubUrls(sw.toString()))
134 }
132 }
135
133
136 private fun appVersion(): String = runCatching {
134 private fun appVersion(): String = runCatching {
@@ -147,6 +145,16 @@ class CrashLogger private constructor(private val appContext: Context) {
147 }.getOrDefault("unknown")
145 }.getOrDefault("unknown")
148
146
149 companion object {
147 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+
150 @Volatile
158 @Volatile
151 private var instance: CrashLogger? = null
159 private var instance: CrashLogger? = null
152
160
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+9−1
@@ -76,13 +76,21 @@ open class ProfileStore private constructor(
76 withContext(Dispatchers.IO) {
76 withContext(Dispatchers.IO) {
77 val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
77 val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
78 if (fallbackKeys.isEmpty()) return@withContext
78 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.
79 secure.edit().apply {
85 secure.edit().apply {
80 for (key in fallbackKeys) {
86 for (key in fallbackKeys) {
81 val pw = fallbackPrefs.getString(key, null) ?: continue
87 val pw = fallbackPrefs.getString(key, null) ?: continue
82 putString(key, pw)
88 putString(key, pw)
83− fallbackPrefs.edit().remove(key).apply()
84 }
89 }
85 }.apply()
90 }.apply()
91+ fallbackPrefs.edit().apply {
92+ for (key in fallbackKeys) remove(key)
93+ }.apply()
86 }
94 }
87 }
95 }
88
96
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+6−9
@@ -90,6 +90,7 @@ import soy.iko.opencode.data.network.EventStreamClient
90import soy.iko.opencode.di.AppContainer
90import soy.iko.opencode.di.AppContainer
91import soy.iko.opencode.R
91import soy.iko.opencode.R
92import soy.iko.opencode.ui.components.ConnectionBanner
92import soy.iko.opencode.ui.components.ConnectionBanner
93+import soy.iko.opencode.ui.components.showToast
93import soy.iko.opencode.ui.components.toImageContext
94import soy.iko.opencode.ui.components.toImageContext
94import soy.iko.opencode.ui.vmFactory
95import soy.iko.opencode.ui.vmFactory
95import soy.iko.opencode.util.runCatchingCancellable
96import soy.iko.opencode.util.runCatchingCancellable
@@ -104,7 +105,6 @@ fun ChatScreen(
104 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
105 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
105 val messages by vm.messages.collectAsStateWithLifecycle()
106 val messages by vm.messages.collectAsStateWithLifecycle()
106 val running by vm.running.collectAsStateWithLifecycle()
107 val running by vm.running.collectAsStateWithLifecycle()
107− val error by vm.error.collectAsStateWithLifecycle()
108 val loading by vm.loading.collectAsStateWithLifecycle()
108 val loading by vm.loading.collectAsStateWithLifecycle()
109 val models by vm.models.collectAsStateWithLifecycle()
109 val models by vm.models.collectAsStateWithLifecycle()
110 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
110 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
@@ -121,7 +121,6 @@ fun ChatScreen(
121 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
121 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
122 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
122 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
123 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
123 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
124− val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
125 val draft by vm.draft.collectAsStateWithLifecycle()
124 val draft by vm.draft.collectAsStateWithLifecycle()
126 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
125 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
127 val haptics = LocalHapticFeedback.current
126 val haptics = LocalHapticFeedback.current
@@ -152,6 +151,7 @@ fun ChatScreen(
152
151
153 val listState = rememberLazyListState()
152 val listState = rememberLazyListState()
154 val snackbar = remember { SnackbarHostState() }
153 val snackbar = remember { SnackbarHostState() }
154+ val retryLabel = stringResource(R.string.retry)
155 var showModelPicker by rememberSaveable { mutableStateOf(false) }
155 var showModelPicker by rememberSaveable { mutableStateOf(false) }
156 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
156 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
157 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
157 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
@@ -208,18 +208,14 @@ fun ChatScreen(
208 }
208 }
209 }
209 }
210 }
210 }
211− val retryLabel = stringResource(R.string.retry)
212− LaunchedEffect(error) {
211+ LaunchedEffect(Unit) {
213− val msg = error ?: return@LaunchedEffect
212+ vm.errorEvents.collect { msg ->
214− try {
215− val result = if (failedDraft != null) {
213+ val result = if (vm.failedDraft.value != null) {
216 snackbar.showSnackbar(message = msg, actionLabel = retryLabel)
214 snackbar.showSnackbar(message = msg, actionLabel = retryLabel)
217 } else {
215 } else {
218 snackbar.showSnackbar(msg)
216 snackbar.showSnackbar(msg)
219 }
217 }
220 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
218 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
221− } finally {
222− vm.clearError()
223 }
219 }
224 }
220 }
225
221
@@ -269,6 +265,7 @@ fun ChatScreen(
269 putExtra(android.content.Intent.EXTRA_TEXT, md)
265 putExtra(android.content.Intent.EXTRA_TEXT, md)
270 }
266 }
271 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
267 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
268+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
272 }
269 }
273 },
270 },
274 enabled = messages.isNotEmpty(),
271 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
21import soy.iko.opencode.di.AppContainer
21import soy.iko.opencode.di.AppContainer
22import soy.iko.opencode.R
22import soy.iko.opencode.R
23import soy.iko.opencode.util.runCatchingCancellable
23import soy.iko.opencode.util.runCatchingCancellable
24+import soy.iko.opencode.util.safeExceptionSummary
24import android.util.Log
25import android.util.Log
25import kotlinx.coroutines.ExperimentalCoroutinesApi
26import kotlinx.coroutines.ExperimentalCoroutinesApi
27+import kotlinx.coroutines.channels.BufferOverflow
28+import kotlinx.coroutines.flow.MutableSharedFlow
26import kotlinx.coroutines.flow.MutableStateFlow
29import kotlinx.coroutines.flow.MutableStateFlow
30+import kotlinx.coroutines.flow.SharedFlow
27import kotlinx.coroutines.flow.SharingStarted
31import kotlinx.coroutines.flow.SharingStarted
28import kotlinx.coroutines.flow.StateFlow
32import kotlinx.coroutines.flow.StateFlow
33+import kotlinx.coroutines.flow.asSharedFlow
29import kotlinx.coroutines.flow.asStateFlow
34import kotlinx.coroutines.flow.asStateFlow
30import kotlinx.coroutines.CancellationException
35import kotlinx.coroutines.CancellationException
31import kotlinx.coroutines.flow.collectLatest
36import kotlinx.coroutines.flow.collectLatest
@@ -78,14 +83,10 @@ class ChatViewModel(
78 }
83 }
79 .onEach {
84 .onEach {
80 _loading.value = false
85 _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
85 }
86 }
86 .retryWhen { cause, _ ->
87 .retryWhen { cause, _ ->
87 _loading.value = false
88 _loading.value = false
88− _error.value = container.friendlyError(cause)
89+ _errorEvents.tryEmit(container.friendlyError(cause))
89 // Delay before retrying to avoid a tight loop on persistent errors.
90 // Delay before retrying to avoid a tight loop on persistent errors.
90 delay(NetworkConfig.retryInitialDelayMs)
91 delay(NetworkConfig.retryInitialDelayMs)
91 true
92 true
@@ -99,8 +100,14 @@ class ChatViewModel(
99 private val _running = MutableStateFlow(false)
100 private val _running = MutableStateFlow(false)
100 val running: StateFlow<Boolean> = _running.asStateFlow()
101 val running: StateFlow<Boolean> = _running.asStateFlow()
101
102
102− private val _error = MutableStateFlow<String?>(null)
103+ /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
103− val error: StateFlow<String?> = _error.asStateFlow()
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()
104
111
105 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
112 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
106 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
113 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
@@ -175,8 +182,6 @@ class ChatViewModel(
175 }
182 }
176 }
183 }
177
184
178− fun clearError() { _error.value = null }
179−
180 fun selectModel(option: ModelOption) { _selectedModel.value = option }
185 fun selectModel(option: ModelOption) { _selectedModel.value = option }
181
186
182 fun selectAgent(name: String?) { _selectedAgent.value = name }
187 fun selectAgent(name: String?) { _selectedAgent.value = name }
@@ -230,7 +235,10 @@ class ChatViewModel(
230 loading.value = true
235 loading.value = true
231 error.value = false
236 error.value = false
232 runCatchingCancellable { fetch(conn.api) }
237 runCatchingCancellable { fetch(conn.api) }
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.
233− .onFailure { Log.w("ChatViewModel", "Failed to load $tag", it); error.value = true }
241+ .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
234 .getOrNull()?.let(onSuccess)
242 .getOrNull()?.let(onSuccess)
235 loading.value = false
243 loading.value = false
236 }
244 }
@@ -262,7 +270,6 @@ class ChatViewModel(
262 if (conn == null) return@collectLatest
270 if (conn == null) return@collectLatest
263 _running.value = false
271 _running.value = false
264 _pendingPermission.value = null
272 _pendingPermission.value = null
265− _error.value = null
266 _failedDraft.value = null
273 _failedDraft.value = null
267 _selectedAgent.value = null
274 _selectedAgent.value = null
268 _sessionTitle.value = null
275 _sessionTitle.value = null
@@ -271,7 +278,7 @@ class ChatViewModel(
271 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
278 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
272 if (SessionRepository.isError(event, sessionId)) {
279 if (SessionRepository.isError(event, sessionId)) {
273 _running.value = false
280 _running.value = false
274− _error.value = container.string(R.string.error_agent_reported)
281+ _errorEvents.tryEmit(container.string(R.string.error_agent_reported))
275 }
282 }
276 when (event) {
283 when (event) {
277 is PermissionUpdated ->
284 is PermissionUpdated ->
@@ -365,7 +372,6 @@ class ChatViewModel(
365 val conn = connection ?: return false
372 val conn = connection ?: return false
366 val trimmed = text.trim()
373 val trimmed = text.trim()
367 if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
374 if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
368− _error.value = null
369 _failedDraft.value = null
375 _failedDraft.value = null
370 // Clear the in-memory draft for the UI immediately, but don't persist the clear
376 // Clear the in-memory draft for the UI immediately, but don't persist the clear
371 // yet — if the send fails and the process dies before we restore, the draft
377 // yet — if the send fails and the process dies before we restore, the draft
@@ -387,7 +393,7 @@ class ChatViewModel(
387 _failedDraft.value = trimmed
393 _failedDraft.value = trimmed
388 // Only restore the draft if the user hasn't typed anything new since.
394 // Only restore the draft if the user hasn't typed anything new since.
389 if (_draft.value.isBlank()) updateDraft(trimmed)
395 if (_draft.value.isBlank()) updateDraft(trimmed)
390− _error.value = container.friendlyError(it)
396+ _errorEvents.tryEmit(container.friendlyError(it))
391 _running.value = false
397 _running.value = false
392 }.onSuccess {
398 }.onSuccess {
393 suppressDraftPersist.set(false)
399 suppressDraftPersist.set(false)
@@ -415,12 +421,11 @@ class ChatViewModel(
415 fun runCommand(command: Command) {
421 fun runCommand(command: Command) {
416 val conn = connection ?: return
422 val conn = connection ?: return
417 if (!_running.compareAndSet(false, true)) return
423 if (!_running.compareAndSet(false, true)) return
418− _error.value = null
419 viewModelScope.launch {
424 viewModelScope.launch {
420 runCatchingCancellable {
425 runCatchingCancellable {
421 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
426 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
422 }.onFailure {
427 }.onFailure {
423− _error.value = container.friendlyError(it)
428+ _errorEvents.tryEmit(container.friendlyError(it))
424 _running.value = false
429 _running.value = false
425 }
430 }
426 }
431 }
@@ -431,7 +436,7 @@ class ChatViewModel(
431 viewModelScope.launch {
436 viewModelScope.launch {
432 runCatchingCancellable { conn.repository.abort(sessionId) }
437 runCatchingCancellable { conn.repository.abort(sessionId) }
433 .onSuccess { _running.value = false }
438 .onSuccess { _running.value = false }
434− .onFailure { _error.value = container.friendlyError(it) }
439+ .onFailure { _errorEvents.tryEmit(container.friendlyError(it)) }
435 }
440 }
436 }
441 }
437
442
@@ -449,7 +454,7 @@ class ChatViewModel(
449 .getOrDefault(emptyList())
454 .getOrDefault(emptyList())
450 .firstOrNull()
455 .firstOrNull()
451 if (recent == null) {
456 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))
453 return@launch
458 return@launch
454 }
459 }
455 runCatchingCancellable {
460 runCatchingCancellable {
@@ -457,7 +462,7 @@ class ChatViewModel(
457 conn.api.ping()
462 conn.api.ping()
458 }.onFailure {
463 }.onFailure {
459 container.disconnect()
464 container.disconnect()
460− _error.value = container.friendlyError(it)
465+ _errorEvents.tryEmit(container.friendlyError(it))
461 }
466 }
462 } finally {
467 } finally {
463 _reconnecting.value = false
468 _reconnecting.value = false
@@ -474,7 +479,7 @@ class ChatViewModel(
474 viewModelScope.launch {
479 viewModelScope.launch {
475 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
480 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
476 .onFailure {
481 .onFailure {
477− _error.value = container.friendlyError(it)
482+ _errorEvents.tryEmit(container.friendlyError(it))
478 // Only restore if no new permission has arrived in the meantime.
483 // Only restore if no new permission has arrived in the meantime.
479 if (_pendingPermission.value == null) _pendingPermission.value = permission
484 if (_pendingPermission.value == null) _pendingPermission.value = permission
480 }
485 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+2−6
@@ -65,15 +65,11 @@ fun FileBrowserScreen(
65) {
65) {
66 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
66 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
67 val state by vm.state.collectAsStateWithLifecycle()
67 val state by vm.state.collectAsStateWithLifecycle()
68− val transientError by vm.transientError.collectAsStateWithLifecycle()
69 val snackbar = remember { SnackbarHostState() }
68 val snackbar = remember { SnackbarHostState() }
70
69
71− LaunchedEffect(transientError) {
70+ LaunchedEffect(Unit) {
72− val msg = transientError ?: return@LaunchedEffect
71+ vm.transientErrors.collect { msg ->
73− try {
74 snackbar.showSnackbar(msg)
72 snackbar.showSnackbar(msg)
75− } finally {
76− vm.clearTransientError()
77 }
73 }
78 }
74 }
79
75
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+12−5
@@ -12,8 +12,12 @@ import soy.iko.opencode.util.runCatchingCancellable
12import kotlinx.coroutines.ExperimentalCoroutinesApi
12import kotlinx.coroutines.ExperimentalCoroutinesApi
13import kotlinx.coroutines.Job
13import kotlinx.coroutines.Job
14import kotlinx.coroutines.delay
14import kotlinx.coroutines.delay
15+import kotlinx.coroutines.channels.BufferOverflow
16+import kotlinx.coroutines.flow.MutableSharedFlow
15import kotlinx.coroutines.flow.MutableStateFlow
17import kotlinx.coroutines.flow.MutableStateFlow
18+import kotlinx.coroutines.flow.SharedFlow
16import kotlinx.coroutines.flow.StateFlow
19import kotlinx.coroutines.flow.StateFlow
20+import kotlinx.coroutines.flow.asSharedFlow
17import kotlinx.coroutines.flow.asStateFlow
21import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.flow.collectLatest
22import kotlinx.coroutines.flow.collectLatest
19import kotlinx.coroutines.flow.update
23import kotlinx.coroutines.flow.update
@@ -41,10 +45,13 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
41 private val _state = MutableStateFlow(FileBrowserState())
45 private val _state = MutableStateFlow(FileBrowserState())
42 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
46 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
43
47
44− /** Transient errors (failed VCS status fetch, etc.) surfaced without hiding the file list. */
48+ /** One-shot transient errors (failed VCS status fetch, etc.) surfaced as snackbars without
45− private val _transientError = MutableStateFlow<String?>(null)
49+ * hiding the file list. A SharedFlow (not StateFlow) so each emission is delivered independently. */
46− val transientError: StateFlow<String?> = _transientError.asStateFlow()
50+ private val _transientErrors = MutableSharedFlow<String>(
47− fun clearTransientError() { _transientError.value = null }
51+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
52+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
53+ )
54+ val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
48
55
49 private var searchJob: Job? = null
56 private var searchJob: Job? = null
50 private var openJob: Job? = null
57 private var openJob: Job? = null
@@ -82,7 +89,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
82 .onFailure {
89 .onFailure {
83 // VCS status is best-effort: don't block the file browser, but surface
90 // VCS status is best-effort: don't block the file browser, but surface
84 // a transient error so the user knows why badges are missing.
91 // a transient error so the user knows why badges are missing.
85− _transientError.value = container.friendlyError(it)
92+ _transientErrors.tryEmit(container.friendlyError(it))
86 }
93 }
87 }
94 }
88 }
95 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+2−6
@@ -68,7 +68,6 @@ fun ServerListScreen(
68 val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
68 val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
69 val profiles by vm.profiles.collectAsStateWithLifecycle()
69 val profiles by vm.profiles.collectAsStateWithLifecycle()
70 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
70 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
71− val error by vm.error.collectAsStateWithLifecycle()
72 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
71 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
73 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
72 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
74 val haptics = LocalHapticFeedback.current
73 val haptics = LocalHapticFeedback.current
@@ -76,12 +75,9 @@ fun ServerListScreen(
76 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
75 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
77 val connectedId = activeConnection?.profile?.id
76 val connectedId = activeConnection?.profile?.id
78
77
79− LaunchedEffect(error) {
78+ LaunchedEffect(Unit) {
80− val msg = error ?: return@LaunchedEffect
79+ vm.errorEvents.collect { msg ->
81− try {
82 snackbar.showSnackbar(msg)
80 snackbar.showSnackbar(msg)
83− } finally {
84− vm.clearError()
85 }
81 }
86 }
82 }
87
83
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+13−7
@@ -7,9 +7,13 @@ import soy.iko.opencode.data.network.NetworkConfig
7import soy.iko.opencode.di.AppContainer
7import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.R
8import soy.iko.opencode.R
9import soy.iko.opencode.util.runCatchingCancellable
9import soy.iko.opencode.util.runCatchingCancellable
10+import kotlinx.coroutines.channels.BufferOverflow
11+import kotlinx.coroutines.flow.MutableSharedFlow
10import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.MutableStateFlow
13+import kotlinx.coroutines.flow.SharedFlow
11import kotlinx.coroutines.flow.SharingStarted
14import kotlinx.coroutines.flow.SharingStarted
12import kotlinx.coroutines.flow.StateFlow
15import kotlinx.coroutines.flow.StateFlow
16+import kotlinx.coroutines.flow.asSharedFlow
13import kotlinx.coroutines.flow.asStateFlow
17import kotlinx.coroutines.flow.asStateFlow
14import kotlinx.coroutines.flow.stateIn
18import kotlinx.coroutines.flow.stateIn
15import kotlinx.coroutines.launch
19import kotlinx.coroutines.launch
@@ -26,15 +30,17 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
26 private val _connecting = MutableStateFlow<String?>(null)
30 private val _connecting = MutableStateFlow<String?>(null)
27 val connectingId: StateFlow<String?> = _connecting.asStateFlow()
31 val connectingId: StateFlow<String?> = _connecting.asStateFlow()
28
32
29− private val _error = MutableStateFlow<String?>(null)
33+ /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
30− val error: StateFlow<String?> = _error.asStateFlow()
34+ * emission is delivered independently. */
31−
35+ private val _errorEvents = MutableSharedFlow<String>(
32− fun clearError() { _error.value = null }
36+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
37+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
38+ )
39+ val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
33
40
34 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
41 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
35 if (_connecting.value != null) return
42 if (_connecting.value != null) return
36 _connecting.value = profile.id
43 _connecting.value = profile.id
37− _error.value = null
38 viewModelScope.launch {
44 viewModelScope.launch {
39 try {
45 try {
40 val result = runCatchingCancellable {
46 val result = runCatchingCancellable {
@@ -44,7 +50,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
44 result.onSuccess { onConnected() }
50 result.onSuccess { onConnected() }
45 .onFailure {
51 .onFailure {
46 container.disconnect()
52 container.disconnect()
47− _error.value = container.friendlyError(it)
53+ _errorEvents.tryEmit(container.friendlyError(it))
48 }
54 }
49 } finally {
55 } finally {
50 _connecting.value = null
56 _connecting.value = null
@@ -60,7 +66,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
60 container.disconnect()
66 container.disconnect()
61 }
67 }
62 runCatchingCancellable { container.profileStore.delete(profile.id) }
68 runCatchingCancellable { container.profileStore.delete(profile.id) }
63− .onFailure { _error.value = container.friendlyError(it) }
69+ .onFailure { _errorEvents.tryEmit(container.friendlyError(it)) }
64 }
70 }
65 }
71 }
66}
72}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+2−6
@@ -89,7 +89,6 @@ fun SessionListScreen(
89 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
89 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
90 val state by vm.state.collectAsStateWithLifecycle()
90 val state by vm.state.collectAsStateWithLifecycle()
91 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
91 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
92− val transientError by vm.transientError.collectAsStateWithLifecycle()
93 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
92 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
94 val profiles by vm.profiles.collectAsStateWithLifecycle()
93 val profiles by vm.profiles.collectAsStateWithLifecycle()
95 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
94 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
@@ -106,12 +105,9 @@ fun SessionListScreen(
106 // Close the server dropdown on back press instead of navigating away.
105 // Close the server dropdown on back press instead of navigating away.
107 BackHandler(enabled = showServerMenu) { showServerMenu = false }
106 BackHandler(enabled = showServerMenu) { showServerMenu = false }
108
107
109− LaunchedEffect(transientError) {
108+ LaunchedEffect(Unit) {
110− val msg = transientError ?: return@LaunchedEffect
109+ vm.transientErrors.collect { msg ->
111− try {
112 snackbar.showSnackbar(msg)
110 snackbar.showSnackbar(msg)
113− } finally {
114− vm.clearTransientError()
115 }
111 }
116 }
112 }
117
113
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+25−11
@@ -17,9 +17,13 @@ import android.util.Log
17import kotlinx.coroutines.CancellationException
17import kotlinx.coroutines.CancellationException
18import kotlinx.coroutines.ExperimentalCoroutinesApi
18import kotlinx.coroutines.ExperimentalCoroutinesApi
19import kotlinx.coroutines.Job
19import kotlinx.coroutines.Job
20+import kotlinx.coroutines.channels.BufferOverflow
21+import kotlinx.coroutines.flow.MutableSharedFlow
20import kotlinx.coroutines.flow.MutableStateFlow
22import kotlinx.coroutines.flow.MutableStateFlow
23+import kotlinx.coroutines.flow.SharedFlow
21import kotlinx.coroutines.flow.SharingStarted
24import kotlinx.coroutines.flow.SharingStarted
22import kotlinx.coroutines.flow.StateFlow
25import kotlinx.coroutines.flow.StateFlow
26+import kotlinx.coroutines.flow.asSharedFlow
23import kotlinx.coroutines.flow.asStateFlow
27import kotlinx.coroutines.flow.asStateFlow
24import kotlinx.coroutines.flow.collectLatest
28import kotlinx.coroutines.flow.collectLatest
25import kotlinx.coroutines.flow.flatMapLatest
29import kotlinx.coroutines.flow.flatMapLatest
@@ -90,9 +94,14 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
90 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
94 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
91
95
92 /** Transient errors (failed create/delete/rename) surfaced as a snackbar, not by hiding the list. */
96 /** Transient errors (failed create/delete/rename) surfaced as a snackbar, not by hiding the list. */
93− private val _transientError = MutableStateFlow<String?>(null)
97+ /** One-shot transient errors surfaced as snackbars. A SharedFlow (not StateFlow) so
94− val transientError: StateFlow<String?> = _transientError.asStateFlow()
98+ * each emission is delivered independently — two rapid failures both get a snackbar
95− fun clearTransientError() { _transientError.value = null }
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()
96
105
97 private var previewJob: Job? = null
106 private var previewJob: Job? = null
98 private var refreshJob: Job? = null
107 private var refreshJob: Job? = null
@@ -180,7 +189,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
180 _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
189 _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
181 } else {
190 } else {
182 _state.update { it.copy(loading = false) }
191 _state.update { it.copy(loading = false) }
183− _transientError.value = container.string(R.string.not_connected)
192+ _transientErrors.tryEmit(container.string(R.string.not_connected))
184 }
193 }
185 _refreshing.value = false
194 _refreshing.value = false
186 return
195 return
@@ -190,6 +199,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
190 _refreshing.value = true
199 _refreshing.value = true
191 refreshJob?.cancel()
200 refreshJob?.cancel()
192 refreshJob = viewModelScope.launch {
201 refreshJob = viewModelScope.launch {
202+ val self = coroutineContext[Job]
193 try {
203 try {
194 runCatchingCancellable { conn.repository.listSessions() }
204 runCatchingCancellable { conn.repository.listSessions() }
195 .onSuccess { list ->
205 .onSuccess { list ->
@@ -200,13 +210,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
200 .onFailure {
210 .onFailure {
201 if (_state.value.sessions.isNotEmpty()) {
211 if (_state.value.sessions.isNotEmpty()) {
202 _state.update { it.copy(loading = false) }
212 _state.update { it.copy(loading = false) }
203− _transientError.value = container.friendlyError(it)
213+ _transientErrors.tryEmit(container.friendlyError(it))
204 } else {
214 } else {
205 _state.value = SessionListState(loading = false, error = container.friendlyError(it))
215 _state.value = SessionListState(loading = false, error = container.friendlyError(it))
206 }
216 }
207 }
217 }
208 } finally {
218 } finally {
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.
209− _refreshing.value = false
223+ if (refreshJob === self) _refreshing.value = false
210 }
224 }
211 }
225 }
212 }
226 }
@@ -266,7 +280,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
266 .onSuccess { onCreated(it.id); refresh() }
280 .onSuccess { onCreated(it.id); refresh() }
267 .onFailure {
281 .onFailure {
268 _state.update { it.copy(error = null) }
282 _state.update { it.copy(error = null) }
269− _transientError.value = container.friendlyError(it)
283+ _transientErrors.tryEmit(container.friendlyError(it))
270 }
284 }
271 }
285 }
272 }
286 }
@@ -281,7 +295,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
281 }
295 }
282 .onFailure {
296 .onFailure {
283 _state.update { it.copy(error = null) }
297 _state.update { it.copy(error = null) }
284− _transientError.value = container.friendlyError(it)
298+ _transientErrors.tryEmit(container.friendlyError(it))
285 }
299 }
286 }
300 }
287 }
301 }
@@ -295,7 +309,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
295 .onSuccess { refresh() }
309 .onSuccess { refresh() }
296 .onFailure {
310 .onFailure {
297 _state.update { it.copy(error = null) }
311 _state.update { it.copy(error = null) }
298− _transientError.value = container.friendlyError(it)
312+ _transientErrors.tryEmit(container.friendlyError(it))
299 }
313 }
300 }
314 }
301 }
315 }
@@ -336,8 +350,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
336 )
350 )
337 }
351 }
338 // Surface the switch failure as a transient snackbar — refresh()
352 // Surface the switch failure as a transient snackbar — refresh()
339− // would clear a state.error, so use _transientError which survives it.
353+ // would clear a state.error, so use transientErrors which survives it.
340− _transientError.value = container.friendlyError(error)
354+ _transientErrors.tryEmit(container.friendlyError(error))
341 refresh()
355 refresh()
342 } else {
356 } else {
343 // Restore also failed — the user is now disconnected from
357 // 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
5import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.ExperimentalCoroutinesApi
6import kotlinx.coroutines.ExperimentalCoroutinesApi
7import kotlinx.coroutines.launch
7import kotlinx.coroutines.launch
8+import kotlinx.coroutines.flow.toList
8import kotlinx.coroutines.test.StandardTestDispatcher
9import kotlinx.coroutines.test.StandardTestDispatcher
9import kotlinx.coroutines.test.TestScope
10import kotlinx.coroutines.test.TestScope
10import kotlinx.coroutines.test.resetMain
11import kotlinx.coroutines.test.resetMain
@@ -118,7 +119,6 @@ class ChatViewModelTest {
118 testScope.testScheduler.advanceUntilIdle()
119 testScope.testScheduler.advanceUntilIdle()
119 assertEquals("", container.fakeDraftStore.get("session1"))
120 assertEquals("", container.fakeDraftStore.get("session1"))
120 assertNull(vm.failedDraft.value)
121 assertNull(vm.failedDraft.value)
121− assertNull(vm.error.value)
122 }
122 }
123
123
124 @Test
124 @Test
@@ -135,7 +135,12 @@ class ChatViewModelTest {
135 assertEquals("hello", vm.draft.value)
135 assertEquals("hello", vm.draft.value)
136 assertEquals("hello", vm.failedDraft.value)
136 assertEquals("hello", vm.failedDraft.value)
137 assertFalse(vm.running.value)
137 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())
139 }
144 }
140
145
141 @Test
146 @Test
@@ -247,11 +252,14 @@ class ChatViewModelTest {
247 testScope.testScheduler.advanceUntilIdle()
252 testScope.testScheduler.advanceUntilIdle()
248 assertEquals("p1", vm.pendingPermission.value?.id)
253 assertEquals("p1", vm.pendingPermission.value?.id)
249
254
255+ val errors = mutableListOf<String>()
256+ val collectJob = launch { vm.errorEvents.toList(errors) }
250 vm.respondPermission(perm, PermissionResponse.ONCE)
257 vm.respondPermission(perm, PermissionResponse.ONCE)
251 assertNull(vm.pendingPermission.value)
258 assertNull(vm.pendingPermission.value)
252 testScope.testScheduler.advanceUntilIdle()
259 testScope.testScheduler.advanceUntilIdle()
260+ collectJob.cancel()
253 assertEquals("p1", vm.pendingPermission.value?.id)
261 assertEquals("p1", vm.pendingPermission.value?.id)
254− assertNotNull(vm.error.value)
262+ assertTrue(errors.isNotEmpty())
255 }
263 }
256
264
257 // --- SSE event handling ---
265 // --- SSE event handling ---
@@ -269,16 +277,19 @@ class ChatViewModelTest {
269 }
277 }
270
278
271 @Test
279 @Test
272− fun sseSessionError_resetsRunningAndSetsError() = testScope.runTest {
280+ fun sseSessionError_resetsRunningAndEmitsError() = testScope.runTest {
273 val events = FakeEventStreamClient()
281 val events = FakeEventStreamClient()
274 val container = makeContainer(events = events)
282 val container = makeContainer(events = events)
275 val vm = makeVm(container)
283 val vm = makeVm(container)
284+ val errors = mutableListOf<String>()
285+ val collectJob = launch { vm.errorEvents.toList(errors) }
276 assertTrue(vm.send("hello"))
286 assertTrue(vm.send("hello"))
277 testScope.testScheduler.advanceUntilIdle()
287 testScope.testScheduler.advanceUntilIdle()
278 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
288 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
279 testScope.testScheduler.advanceUntilIdle()
289 testScope.testScheduler.advanceUntilIdle()
290+ collectJob.cancel()
280 assertFalse(vm.running.value)
291 assertFalse(vm.running.value)
281− assertNotNull(vm.error.value)
292+ assertTrue(errors.isNotEmpty())
282 }
293 }
283
294
284 @Test
295 @Test
@@ -460,30 +471,36 @@ class ChatViewModelTest {
460 container.setActiveConnection(conn2)
471 container.setActiveConnection(conn2)
461 testScope.testScheduler.advanceUntilIdle()
472 testScope.testScheduler.advanceUntilIdle()
462 assertFalse(vm.running.value)
473 assertFalse(vm.running.value)
463− assertNull(vm.error.value)
464 assertNull(vm.pendingPermission.value)
474 assertNull(vm.pendingPermission.value)
465 }
475 }
466
476
467− // --- clearError ---
477+ // --- rapid successive errors ---
468
478
469 @Test
479 @Test
470− fun clearError_clearsError() = testScope.runTest {
480+ fun rapidSuccessiveErrors_bothDeliveredAsEvents() = testScope.runTest {
471 val api = FakeOpencodeApiClient()
481 val api = FakeOpencodeApiClient()
472 val events = FakeEventStreamClient()
482 val events = FakeEventStreamClient()
473 val repo = FakeSessionRepository(api, events)
483 val repo = FakeSessionRepository(api, events)
474 repo.sendPromptThrows = IOException("err")
484 repo.sendPromptThrows = IOException("err")
475 val container = makeContainer(api = api, events = events, repo = repo)
485 val container = makeContainer(api = api, events = events, repo = repo)
476 val vm = makeVm(container)
486 val vm = makeVm(container)
477− vm.updateDraft("hello")
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).
478− assertTrue(vm.send("hello"))
493+ assertTrue(vm.send("first"))
479− testScope.testScheduler.advanceUntilIdle()
494+ testScheduler.advanceUntilIdle()
480− assertNotNull(vm.error.value)
495+ assertTrue(vm.send("second"))
481− vm.clearError()
496+ testScheduler.advanceUntilIdle()
482− assertNull(vm.error.value)
497+
498+ collectJob.cancel()
499+ assertEquals(2, errors.size)
483 }
500 }
484
501
485 @Test
502 @Test
486− fun messagesFlowRetry_clearsStaleErrorOnSuccess() = testScope.runTest {
503+ fun messagesFlowRetry_emitsErrorOnFailure() = testScope.runTest {
487 val api = FakeOpencodeApiClient()
504 val api = FakeOpencodeApiClient()
488 val events = FakeEventStreamClient()
505 val events = FakeEventStreamClient()
489 // Use a controllable flow so we can simulate a re-emission after an error.
506 // Use a controllable flow so we can simulate a re-emission after an error.
@@ -494,20 +511,17 @@ class ChatViewModelTest {
494 repo.observeMessagesOverride = messagesFlow
511 repo.observeMessagesOverride = messagesFlow
495 val container = makeContainer(api = api, events = events, repo = repo)
512 val container = makeContainer(api = api, events = events, repo = repo)
496 val vm = makeVm(container)
513 val vm = makeVm(container)
514+ val errors = mutableListOf<String>()
515+ val collectJob = launch { vm.errorEvents.toList(errors) }
497− // Subscribe to messages so the flow's onEach is active and can clear errors.
516+ // Subscribe to messages so the flow's retryWhen is active.
498 val collector = launch { vm.messages.collect { /* keep subscribed */ } }
517 val collector = launch { vm.messages.collect { /* keep subscribed */ } }
499 testScope.testScheduler.advanceUntilIdle()
518 testScope.testScheduler.advanceUntilIdle()
500 // Set an error via SSE
519 // Set an error via SSE
501 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
520 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
502 testScope.testScheduler.advanceUntilIdle()
521 testScope.testScheduler.advanceUntilIdle()
503− assertNotNull(vm.error.value)
522+ collectJob.cancel()
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)
510 collector.cancel()
523 collector.cancel()
524+ assertTrue(errors.isNotEmpty())
511 }
525 }
512
526
513 // --- Connection switch clears session title ---
527 // --- Connection switch clears session title ---
app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.kt+1−2
@@ -189,8 +189,7 @@ class FileBrowserViewModelTest {
189 // (the fake doesn't throw, so we just verify the happy path doesn't set error)
189 // (the fake doesn't throw, so we just verify the happy path doesn't set error)
190 val container = makeContainer(api)
190 val container = makeContainer(api)
191 val vm = makeVm(container)
191 val vm = makeVm(container)
192− // With empty status, no error should be set
192+ // With empty status, no transient error event is emitted.
193− assertNull(vm.transientError.value)
194 }
193 }
195
194
196 // --- connection availability ---
195 // --- connection availability ---
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+40−3
@@ -3,8 +3,11 @@
3package soy.iko.opencode.ui.session
3package soy.iko.opencode.ui.session
4
4
5import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.CompletableDeferred
6import kotlinx.coroutines.ExperimentalCoroutinesApi
7import kotlinx.coroutines.ExperimentalCoroutinesApi
7import kotlinx.coroutines.flow.first
8import kotlinx.coroutines.flow.first
9+import kotlinx.coroutines.flow.toList
10+import kotlinx.coroutines.launch
8import kotlinx.coroutines.test.StandardTestDispatcher
11import kotlinx.coroutines.test.StandardTestDispatcher
9import kotlinx.coroutines.test.TestScope
12import kotlinx.coroutines.test.TestScope
10import kotlinx.coroutines.test.resetMain
13import kotlinx.coroutines.test.resetMain
@@ -99,12 +102,15 @@ class SessionListViewModelTest {
99
102
100 // Now make listSessions fail — the existing list should remain and a
103 // Now make listSessions fail — the existing list should remain and a
101 // transient error should be surfaced (not replace the whole list).
104 // transient error should be surfaced (not replace the whole list).
105+ val errors = mutableListOf<String>()
106+ val collectJob = launch { vm.transientErrors.toList(errors) }
102 repo.listSessionsThrows = IOException("network error")
107 repo.listSessionsThrows = IOException("network error")
103 vm.refresh()
108 vm.refresh()
104 testScheduler.advanceUntilIdle()
109 testScheduler.advanceUntilIdle()
110+ collectJob.cancel()
105 assertTrue(vm.state.value.sessions.isNotEmpty())
111 assertTrue(vm.state.value.sessions.isNotEmpty())
106 assertFalse(vm.state.value.loading)
112 assertFalse(vm.state.value.loading)
107− assertNotNull(vm.transientError.value)
113+ assertTrue(errors.isNotEmpty())
108 }
114 }
109
115
110 @Test
116 @Test
@@ -121,6 +127,34 @@ class SessionListViewModelTest {
121 assertTrue(vm.state.value.sessions.isEmpty())
127 assertTrue(vm.state.value.sessions.isEmpty())
122 }
128 }
123
129
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+
124 // --- SSE events ---
158 // --- SSE events ---
125
159
126 @Test
160 @Test
@@ -241,15 +275,18 @@ class SessionListViewModelTest {
241 ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"),
275 ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"),
242 )
276 )
243
277
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) }
244 vm.switchServer(ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
281 vm.switchServer(ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
245 testScheduler.advanceUntilIdle()
282 testScheduler.advanceUntilIdle()
283+ collectJob.cancel()
246
284
247 // connect attempted for the new server, then again to restore the previous one.
285 // connect attempted for the new server, then again to restore the previous one.
248 assertEquals(2, container.connectCalls.size)
286 assertEquals(2, container.connectCalls.size)
249 assertEquals("s2", container.connectCalls[0].id)
287 assertEquals("s2", container.connectCalls[0].id)
250 assertEquals("s1", container.connectCalls[1].id)
288 assertEquals("s1", container.connectCalls[1].id)
251− // The switch failure is surfaced as a transient error, not a list-replacing error.
289+ assertTrue(errors.isNotEmpty())
252− assertNotNull(vm.transientError.value)
253 assertNull(vm.state.value.error)
290 assertNull(vm.state.value.error)
254 assertEquals(1, container.disconnectCalls)
291 assertEquals(1, container.disconnectCalls)
255 assertNull(vm.switchingId.value)
292 assertNull(vm.switchingId.value)
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+3
@@ -224,6 +224,8 @@ class FakeSessionRepository(
224 var messages: List<MessageWithParts> = emptyList()
224 var messages: List<MessageWithParts> = emptyList()
225 var sendPromptThrows: Throwable? = null
225 var sendPromptThrows: Throwable? = null
226 var listSessionsThrows: Throwable? = null
226 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
227 var abortCalls: List<String> = emptyList()
229 var abortCalls: List<String> = emptyList()
228 private set
230 private set
229
231
@@ -231,6 +233,7 @@ class FakeSessionRepository(
231 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
233 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
232
234
233 override suspend fun listSessions(): List<Session> {
235 override suspend fun listSessions(): List<Session> {
236+ listSessionsGate?.await()
234 listSessionsThrows?.let { throw it }
237 listSessionsThrows?.let { throw it }
235 return sessions
238 return sessions
236 }
239 }