git.iko.soy/opencode-android
on master help sign in
Fix 12 bugs: ServerProfile rememberSaveable crash, permission countdown reset, swallowed CancellationException, draft flush drops, password visibility leak, crash export overwrite, GoToLine truncation, RemoteImage retry no-op, AppLock grace inconsistency, toast TOCTOU, CrashLogger I/O on crash thread, FGS instance race
d518ef7iko
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+12−1
@@ -68,6 +68,11 @@ class CrashLogger private constructor(private val appContext: Context) {
68
68
69 fun refresh() {
69 fun refresh() {
70 val generation = refreshGeneration.incrementAndGet()
70 val generation = refreshGeneration.incrementAndGet()
71+ // Trim over-capacity reports here (off the crashing thread, on the IO dispatcher) so
72+ // the UncaughtExceptionHandler path does only the single writeText it must to land the
73+ // report before the OS kills the process. Trimming on refresh keeps the cap enforced
74+ // across normal app launches (install calls refresh, and every delete/clear calls it).
75+ trimOldReports()
71 val scanned = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
76 val scanned = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
72 ?.sortedByDescending { it.lastModified() }
77 ?.sortedByDescending { it.lastModified() }
73 ?.map { f ->
78 ?.map { f ->
@@ -231,8 +236,14 @@ class CrashLogger private constructor(private val appContext: Context) {
231 // top-level message above is futile — Ktor/OkHttp embed the full request URL
236 // top-level message above is futile — Ktor/OkHttp embed the full request URL
232 // in those messages, which may contain auth or internal paths. Scrubbing the
237 // in those messages, which may contain auth or internal paths. Scrubbing the
233 // assembled text guarantees no URL survives anywhere in the stored report.
238 // assembled text guarantees no URL survives anywhere in the stored report.
239+ //
240+ // NOTE: trimOldReports() is intentionally NOT called here. writeReport runs on the
241+ // crashing thread inside the UncaughtExceptionHandler, where the OS gives ~5s before
242+ // SIGKILL; trimOldReports does listFiles + sortedByDescending + N deletes that could
243+ // exceed that window on a slow device with many old reports, losing THIS report. The
244+ // trim is driven by refresh() instead (IO dispatcher, off the crashing thread), so the
245+ // crash path does only the single writeText it must to preserve the report.
234 file.writeText(scrubUrls(sw.toString()))
246 file.writeText(scrubUrls(sw.toString()))
235− trimOldReports()
236 }
247 }
237
248
238 /** Keep only the [MAX_REPORTS] most recent crash reports, deleting older ones. Runs inside
249 /** Keep only the [MAX_REPORTS] most recent crash reports, deleting older ones. Runs inside
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+26−8
@@ -150,12 +150,13 @@ open class DraftStore private constructor(
150 if (text.isBlank()) remove(key) else putString(key, text)
150 if (text.isBlank()) remove(key) else putString(key, text)
151 }.apply()
151 }.apply()
152 }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
152 }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
153+ Unit
153 }
154 }
154 // FIX: always submit to the single-thread flushExecutor (never apply directly) so
155 // FIX: always submit to the single-thread flushExecutor (never apply directly) so
155 // writes persist in submission order. Previously the post-init fast path applied
156 // writes persist in submission order. Previously the post-init fast path applied
156 // synchronously on the caller's thread, which could land BEFORE an earlier still-queued
157 // synchronously on the caller's thread, which could land BEFORE an earlier still-queued
157 // write, persisting stale text. The task resolves the prefs lazy and calls apply().
158 // write, persisting stale text. The task resolves the prefs lazy and calls apply().
158− runCatching { flushExecutor.execute { write() } }
159+ submitFlush(write, key)
159 }
160 }
160
161
161 /** Update the in-memory draft immediately without persisting to disk.
162 /** Update the in-memory draft immediately without persisting to disk.
@@ -202,13 +203,14 @@ open class DraftStore private constructor(
202 // Submit the (combined draft + follow-up) removal to the same single-thread
203 // Submit the (combined draft + follow-up) removal to the same single-thread
203 // flushExecutor the other writers use, so it can't race ahead of an earlier queued
204 // flushExecutor the other writers use, so it can't race ahead of an earlier queued
204 // write and resurrect stale text. Mirrors flushDraft's submission pattern.
205 // write and resurrect stale text. Mirrors flushDraft's submission pattern.
205− runCatching {
206+ submitFlush(
206− flushExecutor.execute {
207+ {
207 runCatching {
208 runCatching {
208 prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
209 prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
209 }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
210 }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
210− }
211+ },
211− }
212+ "remove($sessionId)",
213+ )
212 }
214 }
213
215
214 /**
216 /**
@@ -238,15 +240,31 @@ open class DraftStore private constructor(
238 // still-queued write completed, clobbering the draft with stale text. The executor task
240 // still-queued write completed, clobbering the draft with stale text. The executor task
239 // resolves the prefs lazy and uses apply() (async) exactly as before; the write may be
241 // resolves the prefs lazy and uses apply() (async) exactly as before; the write may be
240 // lost only if the process exits before the queued task runs (as previously).
242 // lost only if the process exits before the queued task runs (as previously).
241− runCatching {
243+ submitFlush(
242− flushExecutor.execute {
244+ {
243 runCatching {
245 runCatching {
244 prefs.edit().apply {
246 prefs.edit().apply {
245 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
247 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
246 }.apply()
248 }.apply()
247 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
249 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
248− }
250+ },
251+ "flushDraft($sessionId)",
252+ )
253+ }
254+
255+ /** Submit a flush task to the single-thread executor, surfacing a post-shutdown submit as a
256+ * logged warning instead of silently swallowing the RejectedExecutionException. Previously
257+ * the bare `runCatching { flushExecutor.execute { ... } }` swallowed RejectedExecutionException
258+ * after [shutdown], so the in-memory _drafts was already updated but the disk write never
259+ * landed with no signal — a draft silently lost. Logging makes the failure observable so
260+ * ordering bugs (shutdown before all VM onCleared flushes) are diagnosable. */
261+ private fun submitFlush(task: () -> Unit, label: String) {
262+ if (flushExecutor.isShutdown) {
263+ Log.w("DraftStore", "Dropping flush '$label': executor is shut down")
264+ return
249 }
265 }
266+ runCatching { flushExecutor.execute { task() } }
267+ .onFailure { Log.w("DraftStore", "Failed to submit flush '$label'", it) }
250 }
268 }
251
269
252 private companion object {
270 private companion object {
app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+6−1
@@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable
9import kotlinx.serialization.decodeFromString
9import kotlinx.serialization.decodeFromString
10import kotlinx.serialization.encodeToString
10import kotlinx.serialization.encodeToString
11import kotlinx.serialization.json.Json
11import kotlinx.serialization.json.Json
12+import soy.iko.opencode.util.runCatchingCancellable
12import java.io.File
13import java.io.File
13
14
14/** A minimal session summary persisted for the home-screen widget and launcher shortcuts.
15/** A minimal session summary persisted for the home-screen widget and launcher shortcuts.
@@ -55,7 +56,11 @@ object RecentSessionsStore {
55 /** Persist [sessions] (capped to [MAX]); a no-op empty list writes an empty file. */
56 /** Persist [sessions] (capped to [MAX]); a no-op empty list writes an empty file. */
56 suspend fun write(context: Context, sessions: List<RecentSession>) = withContext(Dispatchers.IO) {
57 suspend fun write(context: Context, sessions: List<RecentSession>) = withContext(Dispatchers.IO) {
57 writeMutex.withLock {
58 writeMutex.withLock {
58− runCatching {
59+ // runCatchingCancellable (not plain runCatching): this runs inside a coroutine, and
60+ // plain runCatching swallows CancellationException — if the caller is cancelled
61+ // mid-write, cancellation wouldn't propagate and the write could complete (or
62+ // partially complete) on a dying scope. Matches the AGENTS.md convention.
63+ runCatchingCancellable {
59 val target = file(context)
64 val target = file(context)
60 val encoded = json.encodeToString(sessions.take(MAX))
65 val encoded = json.encodeToString(sessions.take(MAX))
61 val tmp = File(target.parentFile, "$FILE.tmp")
66 val tmp = File(target.parentFile, "$FILE.tmp")
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+6
@@ -118,6 +118,12 @@ class RunForegroundService : Service() {
118 * service isn't alive yet, so the first progress update still lands. */
118 * service isn't alive yet, so the first progress update still lands. */
119 @SuppressLint("MissingPermission")
119 @SuppressLint("MissingPermission")
120 private fun updateProgress(progress: String?) {
120 private fun updateProgress(progress: String?) {
121+ // Guard against the companion start() → instance race: start() reads @Volatile instance
122+ // on a background dispatcher and calls updateProgress, but onDestroy (on the main thread)
123+ // can null instance and call stopForeground between the read and this notify(). isInForeground
124+ // is cleared in onDestroy, so checking it here covers the stopForeground-already-called case
125+ // and avoids touching NotificationManager during teardown.
126+ if (!isInForeground) return
121 lastProgress = progress
127 lastProgress = progress
122 val notification = buildNotification(lastTitle, lastSessionId, lastProfileId, progress)
128 val notification = buildNotification(lastTitle, lastSessionId, lastProfileId, progress)
123 runCatching {
129 runCatching {
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+9−7
@@ -138,13 +138,15 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
138 // common reason users disable biometric locks. A killed process resets unlocked anyway, so the
138 // common reason users disable biometric locks. A killed process resets unlocked anyway, so the
139 // grace period only governs the in-memory re-lock decision across a surviving background stint.
139 // grace period only governs the in-memory re-lock decision across a surviving background stint.
140 //
140 //
141+ // Plain remember (not rememberSaveable), matching `unlocked` above: the grace check gates on
142+ // `unlocked`, which is non-saveable, so persisting only the stop timestamp through the Bundle
141− // rememberSaveable (not plain remember): a config change NOT in the manifest's configChanges
143+ // is inconsistent — on a config change NOT in the manifest's configChanges (e.g. a locale
142− // (e.g. a locale change, which recreates the Activity) would otherwise reset this to 0L, and
144+ // change) `unlocked` resets to false while `lastStopTimeMs` would survive, and the grace
145+ // check `if (unlocked && ...)` short-circuits to false anyway, re-prompting the user despite
146+ // having unlocked seconds ago. Resetting both together on recreation is consistent: the new
143− // the new DisposableEffect observer gets ON_START delivered synchronously — the grace check
147+ // DisposableEffect observer gets ON_START synchronously, sees lastStopTimeMs == 0, and prompts
144− // sees lastStopTimeMs == 0 and re-locks even though the user unlocked 2s ago and only changed
148+ // cleanly (a recreation mid-grace re-prompts, which is the safe default for an app lock).
145− // the system language. The stop timestamp is not security-sensitive (it's a wall-clock value),
146− // so persisting it through the Bundle is safe and preserves the grace period across recreation.
147− val lastStopTimeMs = rememberSaveable { longArrayOf(0L) }
149+ val lastStopTimeMs = remember { longArrayOf(0L) }
148 val reLockDelayMs = reLockDelaySeconds * 1000L
150 val reLockDelayMs = reLockDelaySeconds * 1000L
149
151
150 val title = stringResource(R.string.app_lock_prompt_title)
152 val title = stringResource(R.string.app_lock_prompt_title)
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+11−1
@@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
27import androidx.compose.runtime.LaunchedEffect
27import androidx.compose.runtime.LaunchedEffect
28import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.remember
29import androidx.compose.runtime.remember
30+import androidx.compose.runtime.saveable.rememberSaveable
30import androidx.compose.runtime.getValue
31import androidx.compose.runtime.getValue
31import androidx.compose.runtime.setValue
32import androidx.compose.runtime.setValue
32import androidx.compose.ui.Alignment
33import androidx.compose.ui.Alignment
@@ -78,7 +79,16 @@ fun PermissionDialog(
78 var responded by remember(permission.id) { mutableStateOf(false) }
79 var responded by remember(permission.id) { mutableStateOf(false) }
79 // Elapsed ms since the dialog opened for this permission, used to surface a "still
80 // Elapsed ms since the dialog opened for this permission, used to surface a "still
80 // waiting" reminder after the threshold. Caps at the auto-reject deadline.
81 // waiting" reminder after the threshold. Caps at the auto-reject deadline.
82+ //
83+ // rememberSaveable (not plain remember): the countdown progress must survive an
84+ // Activity recreation (a config change NOT in the manifest's configChanges, e.g. a
85+ // system locale change) so the auto-reject deadline doesn't reset to 0 — otherwise a
86+ // permission the user walked away from would never auto-reject after a locale change,
87+ // leaving the run hanging open indefinitely and skipping the "auto-rejected"
88+ // notification. Long is Bundle-savable. Keyed on permission.id so a fresh prompt
89+ // resets the timer. (Unlike `responded`, restoring elapsed progress is safe: it only
90+ // advances the countdown, it doesn't lock out the buttons.)
81− var elapsedMs by remember(permission.id) { mutableStateOf(0L) }
91+ var elapsedMs by rememberSaveable(permission.id) { mutableStateOf(0L) }
82 val respond: (PermissionResponse) -> Unit = { response ->
92 val respond: (PermissionResponse) -> Unit = { response ->
83 if (!responded) {
93 if (!responded) {
84 responded = true
94 responded = true
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+11−2
@@ -847,9 +847,18 @@ internal fun showCopyToast(context: Context, message: String) {
847 showToast(context, message)
847 showToast(context, message)
848}
848}
849
849
850+// Self-suppressing toast: canceling the previous toast before showing the next so two rapid
851+// copy/share actions don't stack two toasts. Synchronized on a dedicated lock (lastToast is
852+// nullable, so it can't be the monitor itself) to close the TOCTOU window between cancel and
853+// assign — showToast is called from main-thread coroutine resumes and the unconfined save/share
854+// flows, so without the lock a second caller could read lastToast between the cancel and the
855+// assignment, leaving a toast uncancelled.
856+private val toastLock = Any()
850private var lastToast: Toast? = null
857private var lastToast: Toast? = null
851
858
852internal fun showToast(context: Context, message: String) {
859internal fun showToast(context: Context, message: String) {
860+ synchronized(toastLock) {
853− lastToast?.cancel()
861+ lastToast?.cancel()
854− lastToast = Toast.makeText(context.applicationContext, message, Toast.LENGTH_SHORT).also { it.show() }
862+ lastToast = Toast.makeText(context.applicationContext, message, Toast.LENGTH_SHORT).also { it.show() }
863+ }
855}
864}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+24−11
@@ -173,17 +173,23 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
173 // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
173 // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
174 // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
174 // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
175 // FileChip to fall through to).
175 // FileChip to fall through to).
176+ //
177+ // Bumping retryKey re-runs resolveModel (and re-builds the ImageRequest below) so a failed
178+ // load can be re-issued by tapping the error state. Without retryKey in these keys, a null
179+ // model (e.g. a cross-origin URL blocked at resolve time) would stay null forever and the
180+ // retry button would be a no-op that re-renders the broken-image icon — see the ImageRetry
181+ // callback at the SubcomposeAsyncImage error slot.
182+ var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
176− val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl) {
183+ val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl, retryKey) {
177 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
184 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
178 }.value
185 }.value
179 if (model === ImageResolving) return
186 if (model === ImageResolving) return
180 if (model == null) {
187 if (model == null) {
181− ImageStatusBox { BrokenImageIcon() }
188+ ImageStatusBox {
189+ BrokenImageIcon(onRetry = { retryKey++ })
190+ }
182 return
191 return
183 }
192 }
184− // Bumping retryKey re-builds the ImageRequest, so a failed load can be re-issued by tapping
185− // the error state instead of being a permanent broken-image icon.
186− var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
187 // Fullscreen zoomable viewer state: opened by tapping the inline image.
193 // Fullscreen zoomable viewer state: opened by tapping the inline image.
188 var showFullscreen by remember { mutableStateOf(false) }
194 var showFullscreen by remember { mutableStateOf(false) }
189 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
195 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
@@ -501,12 +507,19 @@ private fun ImageStatusBox(content: @Composable () -> Unit) {
501}
507}
502
508
503@Composable
509@Composable
504−private fun BrokenImageIcon() {
510+private fun BrokenImageIcon(onRetry: (() -> Unit)? = null) {
511+ if (onRetry == null) {
505− Icon(
512+ Icon(
506− Icons.Filled.BrokenImage,
513+ Icons.Filled.BrokenImage,
507− contentDescription = stringResource(R.string.image_failed),
514+ contentDescription = stringResource(R.string.image_failed),
508− tint = MaterialTheme.colorScheme.onSurfaceVariant,
515+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
509− )
516+ )
517+ } else {
518+ // Reuse the retry affordance so a null-model (e.g. a cross-origin URL blocked at resolve
519+ // time) is retryable just like a failed network fetch — tapping re-runs resolveModel
520+ // (retryKey is a produceState key) which may succeed if the block was transient.
521+ ImageRetry(onRetry = onRetry)
522+ }
510}
523}
511
524
512/** Decode the image behind [request] via Coil and write it to the system MediaStore
525/** Decode the image behind [request] via Coil and write it to the system MediaStore
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+15−4
@@ -253,10 +253,19 @@ fun FileViewScreen(
253 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
253 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
254 }
254 }
255 // "Go to line" dialog state. Clears the jump highlight and scrolls the LazyColumn to the
255 // "Go to line" dialog state. Clears the jump highlight and scrolls the LazyColumn to the
256− // requested line (clamped to the rendered range) when the user confirms.
256+ // requested line (clamped to the file's true line count) when the user confirms.
257 var showGoToLine by rememberSaveable(path) { mutableStateOf(false) }
257 var showGoToLine by rememberSaveable(path) { mutableStateOf(false) }
258 fun goToLine(n: Int) {
258 fun goToLine(n: Int) {
259− val target = (n - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
259+ // Clamp to the true file line count (allLines), not the rendered subset (lines), so a
260+ // number beyond the current render cap is still accepted. If the target is beyond the
261+ // rendered range, expand renderLimit first so the LazyColumn actually has that item to
262+ // scroll to — otherwise animateScrollToItem would clamp to the last rendered line and
263+ // the user would land nowhere near their requested line.
264+ val clamped = n.coerceIn(1, allLines.size)
265+ if (clamped - 1 >= lines.size) {
266+ renderLimit = (clamped + MAX_RENDERED_LINES - 1).coerceAtMost(allLines.size)
267+ }
268+ val target = (clamped - 1).coerceIn(0, lines.size - 1)
260 highlightLine.value = null
269 highlightLine.value = null
261 scope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
270 scope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
262 }
271 }
@@ -462,7 +471,7 @@ fun FileViewScreen(
462
471
463 GoToLineLauncher(
472 GoToLineLauncher(
464 visible = showGoToLine,
473 visible = showGoToLine,
465− maxLine = lines.size,
474+ maxLine = allLines.size,
466 onConfirm = { n -> showGoToLine = false; goToLine(n) },
475 onConfirm = { n -> showGoToLine = false; goToLine(n) },
467 onDismiss = { showGoToLine = false },
476 onDismiss = { showGoToLine = false },
468 )
477 )
@@ -488,7 +497,9 @@ private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: ()
488 // The dialog is conditionally composed (gated by `visible` in GoToLineLauncher); with
497 // The dialog is conditionally composed (gated by `visible` in GoToLineLauncher); with
489 // rememberSaveable the typed text would be restored from the saved-state registry on
498 // rememberSaveable the typed text would be restored from the saved-state registry on
490 // re-open, pre-filling whatever the user typed — and dismissed without — last time.
499 // re-open, pre-filling whatever the user typed — and dismissed without — last time.
500+ // Keyed on maxLine so a reload that shrinks the file (e.g. the user edited it externally)
501+ // resets the field instead of keeping a now-out-of-range number that would show as invalid.
491− var text by remember { mutableStateOf("") }
502+ var text by remember(maxLine) { mutableStateOf("") }
492 val parsed = text.trim().toIntOrNull()
503 val parsed = text.trim().toIntOrNull()
493 val valid = parsed != null && parsed in 1..maxLine
504 val valid = parsed != null && parsed in 1..maxLine
494 AlertDialog(
505 AlertDialog(
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+5−1
@@ -74,7 +74,11 @@ fun ServerEditScreen(
74 viewModel(factory = vmFactory { ServerEditViewModel(container) })
74 viewModel(factory = vmFactory { ServerEditViewModel(container) })
75 val state by vm.state.collectAsStateWithLifecycle()
75 val state by vm.state.collectAsStateWithLifecycle()
76 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
76 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
77+ // Keyed on state.id so the password-reveal flag resets when a different profile is loaded
78+ // into the same back-stack entry. Without the key, revealing server A's password and then
79+ // editing server B (which reuses this entry via nav back-and-forth) would show B's password
80+ // in cleartext without the user tapping the eye icon — a minor credential info-leak.
77− var passwordVisible by rememberSaveable { mutableStateOf(false) }
81+ var passwordVisible by rememberSaveable(state.id) { mutableStateOf(false) }
78
82
79 fun safeExit() {
83 fun safeExit() {
80 when {
84 when {
app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsScreen.kt+5−1
@@ -83,7 +83,11 @@ fun ServerSettingsScreen(
83 viewModel(factory = vmFactory { ServerSettingsViewModel(container, profileId) })
83 viewModel(factory = vmFactory { ServerSettingsViewModel(container, profileId) })
84 val state by vm.state.collectAsStateWithLifecycle()
84 val state by vm.state.collectAsStateWithLifecycle()
85 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
85 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
86+ // Keyed on profileId so the password-reveal flag resets when a different profile is loaded
87+ // into the same back-stack entry. Without the key, revealing server A's password and then
88+ // viewing server B (which reuses this entry via nav back-and-forth) would show B's password
89+ // in cleartext without the user tapping the eye icon — a minor credential info-leak.
86− var passwordVisible by rememberSaveable { mutableStateOf(false) }
90+ var passwordVisible by rememberSaveable(profileId) { mutableStateOf(false) }
87
91
88 fun safeExit() {
92 fun safeExit() {
89 when {
93 when {
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+13−4
@@ -1907,7 +1907,16 @@ private fun ServerSwitcherMenu(
1907 // Switching servers kills the SSE stream and any in-flight run (same hazard as disconnecting),
1907 // Switching servers kills the SSE stream and any in-flight run (same hazard as disconnecting),
1908 // so confirm first when a run is active. Kept inside the switcher so the call site stays a
1908 // so confirm first when a run is active. Kept inside the switcher so the call site stays a
1909 // plain onSelect and SessionListScreen's complexity doesn't grow.
1909 // plain onSelect and SessionListScreen's complexity doesn't grow.
1910+ //
1911+ // Persist only the profile id (a String) instead of the whole ServerProfile: ServerProfile is
1912+ // @Immutable but neither Parcelable nor java.io.Serializable (only kotlinx.serialization's
1913+ // @Serializable, which Compose's Bundle autoSaver can't use). rememberSaveable on a non-savable
1914+ // type throws IllegalStateException on a config change NOT in the manifest's configChanges
1915+ // (e.g. a system locale change) — a hard crash mid-confirm. The id survives in the Bundle and
1916+ // is re-resolved against the current profiles list; if the profile vanished mid-switch the
1917+ // confirm dialog simply doesn't re-show, which is the safe fallback.
1910− var pendingSwitch by rememberSaveable { mutableStateOf<ServerProfile?>(null) }
1918+ var pendingSwitchId by rememberSaveable { mutableStateOf<String?>(null) }
1919+ val pendingSwitch = pendingSwitchId?.let { id -> profiles.firstOrNull { it.id == id } }
1911 Column {
1920 Column {
1912 Box {
1921 Box {
1913 Row(
1922 Row(
@@ -1966,7 +1975,7 @@ private fun ServerSwitcherMenu(
1966 onClick = {
1975 onClick = {
1967 when {
1976 when {
1968 isActiveProfile -> onDismiss()
1977 isActiveProfile -> onDismiss()
1969− anyRunActive -> pendingSwitch = profile
1978+ anyRunActive -> pendingSwitchId = profile.id
1970 // Don't dismiss here: keep the switcher open so its per-row spinner
1979 // Don't dismiss here: keep the switcher open so its per-row spinner
1971 // (driven by switchingId) stays visible during the switch. The
1980 // (driven by switchingId) stays visible during the switch. The
1972 // LaunchedEffect in SessionListScreen dismisses it once it resolves.
1981 // LaunchedEffect in SessionListScreen dismisses it once it resolves.
@@ -1991,8 +2000,8 @@ private fun ServerSwitcherMenu(
1991 }
2000 }
1992 SwitchServerConfirmDialog(
2001 SwitchServerConfirmDialog(
1993 target = pendingSwitch,
2002 target = pendingSwitch,
1994− onSwitch = { profile -> pendingSwitch = null; onSelect(profile) },
2003+ onSwitch = { profile -> pendingSwitchId = null; onSelect(profile) },
1995− onDismiss = { pendingSwitch = null },
2004+ onDismiss = { pendingSwitchId = null },
1996 )
2005 )
1997}
2006}
1998
2007
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+6−1
@@ -236,7 +236,12 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
236 if (reports.isNotEmpty()) {
236 if (reports.isNotEmpty()) {
237 IconButton(onClick = {
237 IconButton(onClick = {
238 runCatching {
238 runCatching {
239+ // Timestamped export name so successive exports don't overwrite
240+ // one another when the user exports more than once (e.g. after new
241+ // crashes accumulate). Mirrors SettingsScreen.backupFilename().
242+ val now = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US)
243+ .format(java.util.Date())
239− exportAllLauncher.launch("opencode-crash-reports.txt")
244+ exportAllLauncher.launch("opencode-crash-reports-$now.txt")
240 }
245 }
241 }) {
246 }) {
242 Icon(Icons.Filled.IosShare, contentDescription = stringResource(R.string.export_all))
247 Icon(Icons.Filled.IosShare, contentDescription = stringResource(R.string.export_all))