git.iko.soy/opencode-android
on master help sign in
Fix 6 bugs: broken share-image FileProvider, chat delete-undo race, orphaned MediaStore rows, missing backup version check, leaked encrypted passwords, stale nav callback capture
commit56dd3f3a3c27eb0987ce745997318e2353d12b20 authoriko <mail@iko.soy> dateMon 6 Jul 2026, 02:22 (3 months ago) parent6dcf3ed
6 files changed+135−34
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+17−2 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+52−18 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+27−4 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+18−4 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+18−6 app/src/main/res/xml/file_paths.xml+3
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+17−2
@@ -50,7 +50,9 @@ data class BackupSettings(
5050 val swipeRightAction: String = SwipeAction.ARCHIVE.name,
5151)
5252
53−/** The full backup document. [version] guards against future format changes. */
53+/** The full backup document. [version] guards against future format changes: [import] rejects
54+ * an unknown major version before any writes so a newer-schema backup can't silently
55+ * mis-import or leave a partial restore. */
5456@Serializable
5557data class BackupData(
5658 val version: Int = 1,
@@ -73,6 +75,13 @@ class BackupManager(
7375) {
7476 private val json = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true }
7577
78+ /** Backup format versions [import] knows how to restore. Bump when the schema changes
79+ * incompatibly, and add the new value here once the import path handles it. An unknown
80+ * version is rejected before any writes so a newer-schema backup can't silently
81+ * mis-import or leave a partial restore (servers upserted, then a missing required field
82+ * throws mid-settings). */
83+ private val supportedVersions = setOf(1)
84+
7685 /** Build the backup JSON. Reads the current profiles/settings/flags. */
7786 suspend fun export(includePasswords: Boolean): String {
7887 val servers = profileStore.profiles.first().map {
@@ -119,9 +128,15 @@ class BackupManager(
119128 }
120129
121130 /** Apply a backup document. Throws on malformed JSON so the caller can report it. Servers
122− * are upserted (existing ids overwritten); settings and flags are restored best-effort. */
131+ * are upserted (existing ids overwritten); settings and flags are restored best-effort.
132+ * An unknown [BackupData.version] is rejected BEFORE any writes so a newer-schema backup
133+ * can't silently mis-import or leave a partial restore (servers upserted, then a missing
134+ * required field throws mid-settings). */
123135 suspend fun import(text: String) {
124136 val data = json.decodeFromString<BackupData>(text)
137+ require(data.version in supportedVersions) {
138+ "Unsupported backup version ${data.version}; supported: $supportedVersions"
139+ }
125140 for (s in data.servers) {
126141 val username = s.username?.takeIf { it.isNotBlank() }
127142 val incomingPassword = s.password?.takeIf { it.isNotEmpty() }
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+52−18
@@ -81,9 +81,13 @@ open class ProfileStore private constructor(
8181 securePrefs ?: fallbackPrefs
8282
8383 /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
84− * then clear the fallback. Runs at most once per process — subsequent calls
85− * are no-ops — so DataStore re-emissions don't repeatedly scan SharedPreferences. */
86− private suspend fun migrateFallbackPasswords() {
84+ * then clear the fallback. Also reaps orphaned password keys from BOTH stores whose
85+ * `pw_<id>` matches no profile in [knownProfileIds] — a prior [delete] that raced a
86+ * process death (DataStore write landed, password removal didn't) would otherwise leave
87+ * the encrypted password stranded in `server_secrets` forever, since nothing else cleans
88+ * it up. Runs at most once per process — subsequent calls are no-ops — so DataStore
89+ * re-emissions don't repeatedly scan SharedPreferences. */
90+ private suspend fun migrateFallbackPasswords(knownProfileIds: Set<String>) {
8791 if (migrationDone) return
8892 migrationMutex.withLock {
8993 // Re-check inside the lock: a concurrent caller may have completed the migration
@@ -92,14 +96,9 @@ open class ProfileStore private constructor(
9296 // secure store before the first caller's write has populated it.
9397 if (migrationDone) return
9498 val secure = securePrefs
95− if (secure == null) {
96− // No secure store to migrate into; nothing to do, and no point retrying.
97− migrationDone = true
98− return
99− }
10099 withContext(Dispatchers.IO) {
101100 val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
102− if (fallbackKeys.isNotEmpty()) {
101+ if (secure != null && fallbackKeys.isNotEmpty()) {
103102 // Write passwords to the secure store FIRST, then remove them from the
104103 // plaintext fallback. SharedPreferences.apply() persists to disk
105104 // asynchronously, so removing from fallback before the secure batch lands
@@ -116,6 +115,12 @@ open class ProfileStore private constructor(
116115 for (key in fallbackKeys) remove(key)
117116 }.apply()
118117 }
118+ // Reap orphaned password keys: a prior delete whose password removal raced a
119+ // process death leaves the encrypted entry stranded forever (nothing else
120+ // cleans it up, and the id may never be reused). Keyed by pw_<id>, so an orphan
121+ // is any key whose id isn't in the current profile set. Reap from both stores
122+ // since the fallback is written alongside secure in save().
123+ reapOrphanPasswords(secure, fallbackPrefs, knownProfileIds)
119124 }
120125 // Only mark done once the writes are in the secure store's in-memory cache (apply()
121126 // updates it synchronously), so the next collector past the mutex reads real values.
@@ -123,6 +128,29 @@ open class ProfileStore private constructor(
123128 }
124129 }
125130
131+ /** Remove `pw_<id>` keys from [secure] and [fallback] whose id matches no profile in
132+ * [knownProfileIds]. A prior [delete] whose password removal raced a process death leaves
133+ * the encrypted password stranded forever; this sweep (called once per process from
134+ * [migrateFallbackPasswords]) reaps them so the secure/fallback stores don't accumulate
135+ * orphaned credentials for deleted profiles. No-op when a store is null or has no orphans. */
136+ private fun reapOrphanPasswords(
137+ secure: SharedPreferences?,
138+ fallback: SharedPreferences,
139+ knownProfileIds: Set<String>,
140+ ) {
141+ val secureOrphans = secure?.all?.keys
142+ ?.filter { it.startsWith("pw_") && it.removePrefix("pw_") !in knownProfileIds }
143+ ?: emptyList()
144+ if (secureOrphans.isNotEmpty()) {
145+ secure!!.edit().apply { secureOrphans.forEach { remove(it) } }.apply()
146+ }
147+ val fallbackOrphans = fallback.all.keys
148+ .filter { it.startsWith("pw_") && it.removePrefix("pw_") !in knownProfileIds }
149+ if (fallbackOrphans.isNotEmpty()) {
150+ fallback.edit().apply { fallbackOrphans.forEach { remove(it) } }.apply()
151+ }
152+ }
153+
126154 /** Stored shape on DataStore (everything except the secret password). */
127155 @Serializable
128156 private data class StoredProfile(
@@ -138,20 +166,26 @@ open class ProfileStore private constructor(
138166 )
139167
140168 open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
141− // Migrate any orphaned plaintext passwords to secure prefs on first load.
142− migrateFallbackPasswords()
143− val json = prefs[profilesKey] ?: return@map emptyList()
144− val stored = runCatching {
145− OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
146− }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
147− .getOrDefault(emptyList())
148− .sortedByDescending { it.lastUsed }
169+ // Decode the stored profiles first so the migration sweep can reap orphaned password
170+ // keys (whose pw_<id> matches no stored profile) in the same once-per-process pass.
171+ val json = prefs[profilesKey]
172+ val stored = json?.let {
173+ runCatching {
174+ OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it)
175+ }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
176+ .getOrDefault(emptyList())
177+ } ?: emptyList()
178+ // Migrate any orphaned plaintext passwords to secure prefs, and reap any secure-pref
179+ // password keys whose profile was deleted while the process was gone (see
180+ // [migrateFallbackPasswords] for the sweep rationale).
181+ migrateFallbackPasswords(stored.mapTo(mutableSetOf()) { it.id })
182+ val sorted = stored.sortedByDescending { it.lastUsed }
149183 // Batch all password lookups into a single IO dispatch instead of one
150184 // withContext round-trip per profile (N dispatches → 1). DataStore already
151185 // runs this map block on IO, but each passwordFor() call still pays the
152186 // suspend + redispatch overhead.
153187 val pwPrefs = prefsForPasswords()
154− stored.map { it.toProfile(pwPrefs) }
188+ sorted.map { it.toProfile(pwPrefs) }
155189 }
156190 // The .map above runs in the *collector's* context, and every consumer collects on
157191 // viewModelScope (Main). Without this, kotlinx JSON decode + EncryptedSharedPreferences
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+27−4
@@ -155,6 +155,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
155155import kotlinx.coroutines.Dispatchers
156156import kotlinx.coroutines.coroutineScope
157157import kotlinx.coroutines.delay
158+import kotlinx.coroutines.flow.collectLatest
158159import kotlinx.coroutines.flow.distinctUntilChanged
159160import kotlinx.coroutines.launch
160161import kotlinx.coroutines.withContext
@@ -384,10 +385,24 @@ fun ChatScreen(
384385 // window on the container; show the snackbar over the conversation the user is viewing so
385386 // they can cancel without being navigated to the session list first. If the window expires
386387 // the scheduled delete sets `sessionDeleted` and the effect below navigates away.
388+ //
389+ // collectLatest (not collect): a serialized collect queues each snackbar behind the
390+ // previous one's full window, so under a rapid re-delete (delete A, then delete the same
391+ // session again, or a delete timed just after the first) the VM delete timer (started at
392+ // emit time) fires before the second snackbar is ever shown — a dead Undo button.
393+ // collectLatest cancels the current snackbar and shows the newest immediately, keeping its
394+ // Undo window aligned with its timer. Tracking `pending` mirrors SessionListScreen: when a
395+ // newer delete supersedes the prior snackbar mid-window, the prior session's deferred REST
396+ // delete (still scheduled on the container's scope) would fire with no reachable Undo.
397+ // Withdrawing it via cancelSessionDelete cancels the timer (no-op if it already fired), so
398+ // no session is silently deleted without a chance to undo.
387399 val undoLabel = stringResource(R.string.undo)
388400 val sessionDeletedLabel = stringResource(R.string.session_deleted_chat)
389401 LaunchedEffect(Unit) {
390− vm.deleteUndoEvents.collect {
402+ var pending: String? = null
403+ vm.deleteUndoEvents.collectLatest { id ->
404+ pending?.let { vm.cancelSessionDelete(it) }
405+ pending = id
391406 coroutineScope {
392407 val dismisser = launch {
393408 delay(NetworkConfig.undoDeleteDelayMs)
@@ -399,7 +414,10 @@ fun ChatScreen(
399414 duration = SnackbarDuration.Indefinite,
400415 )
401416 dismisser.cancel()
402− if (result == SnackbarResult.ActionPerformed) vm.cancelSessionDelete()
417+ if (result == SnackbarResult.ActionPerformed) {
418+ vm.cancelSessionDelete(id)
419+ pending = null
420+ }
403421 }
404422 }
405423 }
@@ -494,12 +512,17 @@ fun ChatScreen(
494512 // Navigate to a session freshly branched from a message (branchFrom), once it's created.
495513 // Keyed on Unit (like the sibling one-shot collectors above): keying on the onOpenSession
496514 // lambda would cancel/restart this collector whenever the lambda's identity changes, risking
497− // a dropped branch-navigation event in the gap.
515+ // a dropped branch-navigation event in the gap. The lambda is wrapped in rememberUpdatedState
516+ // so the Unit-keyed collector invokes the LATEST lambda instead of the one captured at first
517+ // composition — a latent footgun if a future change makes onOpenSession depend on current
518+ // Compose state (e.g. pane-aware routing). The collector itself is never cancelled, so no
519+ // event is dropped.
498520 val branchedMsg = stringResource(R.string.branch_created)
521+ val currentOnOpenSession by rememberUpdatedState(onOpenSession)
499522 LaunchedEffect(Unit) {
500523 vm.branchEvents.collect { newId ->
501524 showToast(shareContext, branchedMsg)
502− onOpenSession?.invoke(newId)
525+ currentOnOpenSession?.invoke(newId)
503526 }
504527 }
505528
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+18−4
@@ -429,12 +429,15 @@ class ChatViewModel(
429429
430430 /** One-shot events signaling a user-initiated delete was scheduled and is undoable in-place.
431431 * The ChatScreen collects these to show an Undo snackbar over the conversation the user is
432− * already viewing, instead of navigating away first and surfacing Undo on the session list. */
433− private val _deleteUndoEvents = MutableSharedFlow<Unit>(
432+ * already viewing, instead of navigating away first and surfacing Undo on the session list.
433+ * Carries the session id so a collector using `collectLatest` can withdraw a superseded
434+ * delete (see [cancelSessionDelete]) — a `Unit` event would leave the prior session's
435+ * deferred REST delete firing with no reachable Undo. */
436+ private val _deleteUndoEvents = MutableSharedFlow<String>(
434437 extraBufferCapacity = 1,
435438 onBufferOverflow = BufferOverflow.DROP_OLDEST,
436439 )
437− val deleteUndoEvents: SharedFlow<Unit> = _deleteUndoEvents.asSharedFlow()
440+ val deleteUndoEvents: SharedFlow<String> = _deleteUndoEvents.asSharedFlow()
438441
439442 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
440443 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
@@ -1846,13 +1849,24 @@ class ChatViewModel(
18461849 onDeleted = { _sessionDeleted.value = true },
18471850 onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
18481851 )
1849− _deleteUndoEvents.tryEmit(Unit)
1852+ _deleteUndoEvents.tryEmit(sessionId)
18501853 }
18511854
18521855 /** Cancel a delete scheduled by [deleteSession] (the in-chat Undo action). Returns true if the
18531856 * delete was still pending (Undo succeeded). */
18541857 fun cancelSessionDelete(): Boolean = container.cancelSessionDelete(sessionId)
18551858
1859+ /** Cancel a delete scheduled for [id] — the `collectLatest` undo path in ChatScreen calls
1860+ * this with the tracked `pending` id when a newer delete supersedes an in-flight one, so
1861+ * the prior session's deferred REST delete is withdrawn (no-op if it already fired)
1862+ * instead of being left to fire with no reachable Undo button. Mirrors
1863+ * [soy.iko.opencode.ui.session.SessionListViewModel.undoDelete]. Returns false when [id]
1864+ * isn't this VM's session (defensive — a ChatScreen only ever deletes its own session). */
1865+ fun cancelSessionDelete(id: String): Boolean {
1866+ if (id != sessionId) return false
1867+ return container.cancelSessionDelete(id)
1868+ }
1869+
18561870 /** Reconnect to the most recently used server profile (used when the connection is gone). */
18571871 fun reconnect() {
18581872 if (container.activeConnection.value != null) return
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+18−6
@@ -531,17 +531,29 @@ private suspend fun saveImageToGallery(
531531 }
532532 val uri = resolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
533533 ?: return false
534− return runCatching {
534+ // Write the bitmap, then finalize the pending row on success or DELETE it on failure. A null
535+ // stream or a compress throw previously left the row stranded with IS_PENDING=1 — invisible to
536+ // other apps and never cleaned up by this code, accumulating one orphaned entry per failed save.
537+ var wrote = false
538+ runCatching {
535539 resolver.openOutputStream(uri)?.use { out ->
536540 bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
537− } ?: error("no output stream")
538− if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
541+ } ?: return@runCatching
542+ wrote = true
543+ }
544+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
545+ if (wrote) {
539546 values.clear()
540547 values.put(android.provider.MediaStore.Images.Media.IS_PENDING, 0)
541− resolver.update(uri, values, null, null)
548+ runCatching { resolver.update(uri, values, null, null) }
549+ } else {
550+ // Delete the orphaned pending row so a failed save doesn't leak an invisible
551+ // MediaStore entry. Best-effort — a delete failure leaves a stray row, but the OS
552+ // may eventually garbage-collect it.
553+ runCatching { resolver.delete(uri, null, null) }
542554 }
543− true
544− }.getOrDefault(false)
555+ }
556+ return wrote
545557}
546558
547559/** Decode the image behind [request] to a Bitmap via Coil's ImageLoader. Returns null on
app/src/main/res/xml/file_paths.xml+3
@@ -6,4 +6,7 @@
66 <!-- "Open externally" writes a viewed file's content to cache/external and hands a
77 content:// URI to another app via ACTION_VIEW (see FileViewScreen). -->
88 <cache-path name="external" path="external/" />
9+ <!-- "Share image" writes a viewed image's PNG to cache/share and hands a content:// URI
10+ to the chooser via ACTION_SEND (see RemoteImage.shareImage). -->
11+ <cache-path name="share" path="share/" />
912</paths>