@@ -81,9 +81,13 @@ open class ProfileStore private constructor(
81 securePrefs ?: fallbackPrefs
81 securePrefs ?: fallbackPrefs
83 /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
83 /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
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
84− * then clear the fallback. Runs at most once per process — subsequent calls
88+ * it up. Runs at most once per process — subsequent calls are no-ops — so DataStore
85− * are no-ops — so DataStore re-emissions don't repeatedly scan SharedPreferences. */
89+ * re-emissions don't repeatedly scan SharedPreferences. */
86− private suspend fun migrateFallbackPasswords() {
90+ private suspend fun migrateFallbackPasswords(knownProfileIds: Set<String>) {
87 if (migrationDone) return
91 if (migrationDone) return
88 migrationMutex.withLock {
92 migrationMutex.withLock {
89 // Re-check inside the lock: a concurrent caller may have completed the migration
93 // Re-check inside the lock: a concurrent caller may have completed the migration
@@ -92,14 +96,9 @@ open class ProfileStore private constructor(
92 // secure store before the first caller's write has populated it.
96 // secure store before the first caller's write has populated it.
93 if (migrationDone) return
97 if (migrationDone) return
94 val secure = securePrefs
98 val secure = securePrefs
95− if (secure == null) {
96− // No secure store to migrate into; nothing to do, and no point retrying.
100 withContext(Dispatchers.IO) {
99 withContext(Dispatchers.IO) {
101 val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
100 val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
102− if (fallbackKeys.isNotEmpty()) {
101+ if (secure != null && fallbackKeys.isNotEmpty()) {
103 // Write passwords to the secure store FIRST, then remove them from the
102 // Write passwords to the secure store FIRST, then remove them from the
104 // plaintext fallback. SharedPreferences.apply() persists to disk
103 // plaintext fallback. SharedPreferences.apply() persists to disk
105 // asynchronously, so removing from fallback before the secure batch lands
104 // asynchronously, so removing from fallback before the secure batch lands
@@ -116,6 +115,12 @@ open class ProfileStore private constructor(
116 for (key in fallbackKeys) remove(key)
115 for (key in fallbackKeys) remove(key)
117 }.apply()
116 }.apply()
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)
120 // Only mark done once the writes are in the secure store's in-memory cache (apply()
125 // Only mark done once the writes are in the secure store's in-memory cache (apply()
121 // updates it synchronously), so the next collector past the mutex reads real values.
126 // updates it synchronously), so the next collector past the mutex reads real values.
@@ -123,6 +128,29 @@ open class ProfileStore private constructor(
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>,
141+ val secureOrphans = secure?.all?.keys
142+ ?.filter { it.startsWith("pw_") && it.removePrefix("pw_") !in knownProfileIds }
144+ if (secureOrphans.isNotEmpty()) {
145+ secure!!.edit().apply { secureOrphans.forEach { remove(it) } }.apply()
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()
126 /** Stored shape on DataStore (everything except the secret password). */
154 /** Stored shape on DataStore (everything except the secret password). */
127 @Serializable
155 @Serializable
128 private data class StoredProfile(
156 private data class StoredProfile(
@@ -138,20 +166,26 @@ open class ProfileStore private constructor(
140 open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
168 open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
141− // Migrate any orphaned plaintext passwords to secure prefs on first load.
169+ // Decode the stored profiles first so the migration sweep can reap orphaned password
142− migrateFallbackPasswords()
170+ // keys (whose pw_<id> matches no stored profile) in the same once-per-process pass.
143− val json = prefs[profilesKey] ?: return@map emptyList()
171+ val json = prefs[profilesKey]
172+ val stored = json?.let {
144− val stored = runCatching {
173+ runCatching {
145− OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
174+ OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it)
146− }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
175+ }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
147− .getOrDefault(emptyList())
176+ .getOrDefault(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 })
148− .sortedByDescending { it.lastUsed }
182+ val sorted = stored.sortedByDescending { it.lastUsed }
149 // Batch all password lookups into a single IO dispatch instead of one
183 // Batch all password lookups into a single IO dispatch instead of one
150 // withContext round-trip per profile (N dispatches → 1). DataStore already
184 // withContext round-trip per profile (N dispatches → 1). DataStore already
151 // runs this map block on IO, but each passwordFor() call still pays the
185 // runs this map block on IO, but each passwordFor() call still pays the
152 // suspend + redispatch overhead.
186 // suspend + redispatch overhead.
153 val pwPrefs = prefsForPasswords()
187 val pwPrefs = prefsForPasswords()
154− stored.map { it.toProfile(pwPrefs) }
188+ sorted.map { it.toProfile(pwPrefs) }
156 // The .map above runs in the *collector's* context, and every consumer collects on
190 // The .map above runs in the *collector's* context, and every consumer collects on
157 // viewModelScope (Main). Without this, kotlinx JSON decode + EncryptedSharedPreferences
191 // viewModelScope (Main). Without this, kotlinx JSON decode + EncryptedSharedPreferences