From 21d21d46b9d96658b16c81948bda125f4c7fd274 Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Tue, 6 Oct 2026 13:23:22 +0100 Subject: [PATCH 1/3] Erase every store of a deleted account - Deleting an account left its sent DMs, failed outbox, epoch keys, reactions, unread counts, invites, sync state, settings (Graph API key included), push registry and wallet tokens on disk - Per-stream keys are matched by prefix only: another account's epoch key for a channel the deleted one owns contains its address too Co-authored-by: Claude Opus 5.5 --- .../java/com/pombo/android/AppViewModel.kt | 17 +- .../com/pombo/android/core/PushRegistry.kt | 5 + .../com/pombo/android/data/ChannelStore.kt | 5 + .../com/pombo/android/data/ContactsStore.kt | 5 + .../com/pombo/android/data/EpochKeyStore.kt | 10 ++ .../pombo/android/data/FailedOutboxStore.kt | 9 + .../com/pombo/android/data/InviteStore.kt | 5 + .../com/pombo/android/data/SentDmStore.kt | 9 + .../pombo/android/data/SentReactionsStore.kt | 6 + .../com/pombo/android/data/SettingsStore.kt | 8 + .../java/com/pombo/android/data/SyncStore.kt | 5 + .../com/pombo/android/data/UnreadStore.kt | 7 + .../pombo/android/data/WalletTokenStore.kt | 5 + .../pombo/android/DeleteAccountWiringTest.kt | 39 +++++ .../pombo/android/data/AccountClearTest.kt | 155 ++++++++++++++++++ .../java/com/pombo/android/data/FakePrefs.kt | 57 +++++++ 16 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt create mode 100644 app/src/test/java/com/pombo/android/data/AccountClearTest.kt create mode 100644 app/src/test/java/com/pombo/android/data/FakePrefs.kt diff --git a/app/src/main/java/com/pombo/android/AppViewModel.kt b/app/src/main/java/com/pombo/android/AppViewModel.kt index b170402..abb57c1 100644 --- a/app/src/main/java/com/pombo/android/AppViewModel.kt +++ b/app/src/main/java/com/pombo/android/AppViewModel.kt @@ -221,12 +221,21 @@ class AppViewModel(app: Application) : AndroidViewModel(app), PomboBridge.Listen // Wipe the account-scoped data first, while the storage scope still // points at this account. disconnect() drops the keystore entry and // repoints everything, so doing it the other way round would leave - // this account's channels and contacts orphaned on disk. + // this account's data orphaned on disk. manager.replaceChannels(emptyList()) - contactsStore.save(emptyList()) _contacts.value = emptyList() - settingsStore.blockedPeers = emptySet() - settingsStore.syncBase = null + channelStore.clearAccount() + contactsStore.clearAccount() + inviteStore.clearAccount() + sentDmStore.clearAccount() + sentReactionsStore.clearAccount() + failedOutbox.clearAccount() + epochKeyStore.clearAccount() + unreadStore.clearAccount() + settingsStore.clearAccount() + syncStore.clearAccount() + pushRegistry.clearAccount() + walletTokenStore.clearAccount(store.address) blobStore.clearAccount() disconnect() toast("Account deleted", com.pombo.android.ui.ToastKind.INFO) diff --git a/app/src/main/java/com/pombo/android/core/PushRegistry.kt b/app/src/main/java/com/pombo/android/core/PushRegistry.kt index 3d1e481..8689a5b 100644 --- a/app/src/main/java/com/pombo/android/core/PushRegistry.kt +++ b/app/src/main/java/com/pombo/android/core/PushRegistry.kt @@ -127,6 +127,11 @@ class PushRegistry(context: Context) { private fun checkedKey(): String = if (scopeAddress.isNullOrEmpty()) CHECKED_KEY else "${CHECKED_KEY}_${scopeAddress!!.lowercase()}" + fun clearAccount() { + if (scopeAddress.isNullOrEmpty()) return + prefs.edit().remove(key()).remove(endpointsKey()).remove(checkedKey()).apply() + } + /** Advances the watermark so the same message never notifies twice. */ fun updateLastSeen(streamId: String, timestamp: Long) { val entries = all().map { diff --git a/app/src/main/java/com/pombo/android/data/ChannelStore.kt b/app/src/main/java/com/pombo/android/data/ChannelStore.kt index 0119125..b0042de 100644 --- a/app/src/main/java/com/pombo/android/data/ChannelStore.kt +++ b/app/src/main/java/com/pombo/android/data/ChannelStore.kt @@ -96,6 +96,11 @@ class ChannelStore(context: Context) { if (scopeAddress.isNullOrEmpty()) KEY_ORDER else "${KEY_ORDER}_${scopeAddress!!.lowercase()}" + fun clearAccount() { + if (memoryOnly || scopeAddress.isNullOrEmpty()) return + prefs.edit().remove(key()).remove(leftAtKey()).remove(orderKey()).apply() + } + private companion object { const val KEY_CHANNELS = "channels" const val KEY_LEFT_AT = "channels_left_at" diff --git a/app/src/main/java/com/pombo/android/data/ContactsStore.kt b/app/src/main/java/com/pombo/android/data/ContactsStore.kt index da0c7a0..5505170 100644 --- a/app/src/main/java/com/pombo/android/data/ContactsStore.kt +++ b/app/src/main/java/com/pombo/android/data/ContactsStore.kt @@ -84,5 +84,10 @@ class ContactsStore(context: Context) { prefs.edit().putString(key(), arr.toString()).apply() } + fun clearAccount() { + if (memoryOnly || scopeAddress.isNullOrEmpty()) return + prefs.edit().remove(key()).apply() + } + private companion object { const val KEY = "contacts" } } diff --git a/app/src/main/java/com/pombo/android/data/EpochKeyStore.kt b/app/src/main/java/com/pombo/android/data/EpochKeyStore.kt index 6f3f907..7b1e4fb 100644 --- a/app/src/main/java/com/pombo/android/data/EpochKeyStore.kt +++ b/app/src/main/java/com/pombo/android/data/EpochKeyStore.kt @@ -50,4 +50,14 @@ class EpochKeyStore(context: Context) { fun clear(messageStreamId: String) { prefs.edit().remove(key(messageStreamId)).apply() } + + fun clearAccount() { + val scope = scopeAddress?.lowercase()?.ifEmpty { null } ?: return + if (memoryOnly) return + // By prefix only: another account's key for a channel this one owns contains this address too. + val prefix = "${scope}_" + val edit = prefs.edit() + prefs.all.keys.filter { it.startsWith(prefix) }.forEach { edit.remove(it) } + edit.apply() + } } diff --git a/app/src/main/java/com/pombo/android/data/FailedOutboxStore.kt b/app/src/main/java/com/pombo/android/data/FailedOutboxStore.kt index c2e6f63..a2d68e2 100644 --- a/app/src/main/java/com/pombo/android/data/FailedOutboxStore.kt +++ b/app/src/main/java/com/pombo/android/data/FailedOutboxStore.kt @@ -61,6 +61,15 @@ class FailedOutboxStore(context: Context) { prefs.edit().remove(key(streamId)).apply() } + fun clearAccount() { + val scope = scopeAddress?.lowercase()?.ifEmpty { null } ?: return + if (memoryOnly) return + val prefix = "failed_${scope}_" + val edit = prefs.edit() + prefs.all.keys.filter { it.startsWith(prefix) }.forEach { edit.remove(it) } + edit.apply() + } + private fun save(streamId: String, entries: List) { if (entries.isEmpty()) { prefs.edit().remove(key(streamId)).apply() diff --git a/app/src/main/java/com/pombo/android/data/InviteStore.kt b/app/src/main/java/com/pombo/android/data/InviteStore.kt index f7e79cd..d46e23e 100644 --- a/app/src/main/java/com/pombo/android/data/InviteStore.kt +++ b/app/src/main/java/com/pombo/android/data/InviteStore.kt @@ -127,6 +127,11 @@ class InviteStore(context: Context) { private fun dismissedFullKey(): String = "${DISMISSED_FULL}_${scopeAddress!!.lowercase()}" + fun clearAccount() { + if (scopeAddress.isNullOrEmpty()) return + prefs.edit().remove(key()).remove(dismissedKey()).remove(dismissedFullKey()).apply() + } + data class StoredInvite( val inviteId: String, val from: String, diff --git a/app/src/main/java/com/pombo/android/data/SentDmStore.kt b/app/src/main/java/com/pombo/android/data/SentDmStore.kt index 09d2855..6979202 100644 --- a/app/src/main/java/com/pombo/android/data/SentDmStore.kt +++ b/app/src/main/java/com/pombo/android/data/SentDmStore.kt @@ -102,6 +102,15 @@ class SentDmStore(context: Context) { prefs.edit().remove(key(streamId)).apply() } + fun clearAccount() { + val scope = scopeAddress?.lowercase()?.ifEmpty { null } ?: return + if (memoryOnly) return + val prefix = "sent_${scope}_" + val edit = prefs.edit() + prefs.all.keys.filter { it.startsWith(prefix) }.forEach { edit.remove(it) } + edit.remove(deletedKey()).apply() + } + /** * Applies an edit in place so the stored copy matches what was published. * [at] is the edit's own timestamp: the sync keeps the latest edit. diff --git a/app/src/main/java/com/pombo/android/data/SentReactionsStore.kt b/app/src/main/java/com/pombo/android/data/SentReactionsStore.kt index 4892c34..8aff118 100644 --- a/app/src/main/java/com/pombo/android/data/SentReactionsStore.kt +++ b/app/src/main/java/com/pombo/android/data/SentReactionsStore.kt @@ -66,6 +66,12 @@ class SentReactionsStore(context: Context) { fun exportAll(): JSONObject = if (memoryOnly) JSONObject() else readAll() + @Synchronized + fun clearAccount() { + if (memoryOnly || scopeAddress.isNullOrEmpty()) return + prefs.edit().remove(key()).apply() + } + /** Replaces with a merged slice — post-SyncMerge it is a superset union. */ @Synchronized fun importAll(slice: JSONObject) { diff --git a/app/src/main/java/com/pombo/android/data/SettingsStore.kt b/app/src/main/java/com/pombo/android/data/SettingsStore.kt index e700844..e01f083 100644 --- a/app/src/main/java/com/pombo/android/data/SettingsStore.kt +++ b/app/src/main/java/com/pombo/android/data/SettingsStore.kt @@ -37,6 +37,14 @@ class SettingsStore(context: Context) { private fun scoped(name: String) = if (scopeAddress.isNullOrEmpty()) name else "${name}_${scopeAddress!!.lowercase()}" + fun clearAccount() { + val scope = scopeAddress?.lowercase()?.ifEmpty { null } ?: return + val suffix = "_$scope" + val edit = prefs.edit() + prefs.all.keys.filter { it.endsWith(suffix) }.forEach { edit.remove(it) } + edit.apply() + } + /** * The last merged sync payload, kept verbatim. Pushing a payload rebuilt * only from local state would drop the slices this client does not model diff --git a/app/src/main/java/com/pombo/android/data/SyncStore.kt b/app/src/main/java/com/pombo/android/data/SyncStore.kt index 41914e9..9e6f825 100644 --- a/app/src/main/java/com/pombo/android/data/SyncStore.kt +++ b/app/src/main/java/com/pombo/android/data/SyncStore.kt @@ -63,6 +63,11 @@ class SyncStore(context: Context) { .apply() } + fun clearAccount() { + if (scopeAddress.isNullOrEmpty()) return + clear() + } + private companion object { /** Web keeps 300 applied timestamps; 50 left a thinner margin against * a storage replica serving a deep page of old snapshots. */ diff --git a/app/src/main/java/com/pombo/android/data/UnreadStore.kt b/app/src/main/java/com/pombo/android/data/UnreadStore.kt index ca32f07..73d633f 100644 --- a/app/src/main/java/com/pombo/android/data/UnreadStore.kt +++ b/app/src/main/java/com/pombo/android/data/UnreadStore.kt @@ -128,6 +128,13 @@ class UnreadStore(context: Context) { persist(next) } + @Synchronized + fun clearAccount() { + if (scopeAddress.isNullOrEmpty()) return + prefs.edit().remove(key()).remove(watermarkKey()).apply() + _counts.value = emptyMap() + } + /** Drops counts for channels that no longer exist (left, blocked, deleted). */ @Synchronized fun retainOnly(streamIds: Set) { diff --git a/app/src/main/java/com/pombo/android/data/WalletTokenStore.kt b/app/src/main/java/com/pombo/android/data/WalletTokenStore.kt index 748ecf8..1ed2729 100644 --- a/app/src/main/java/com/pombo/android/data/WalletTokenStore.kt +++ b/app/src/main/java/com/pombo/android/data/WalletTokenStore.kt @@ -40,6 +40,11 @@ class WalletTokenStore(context: Context) { save(address, list(address).filterNot { it.equals(token, ignoreCase = true) }) } + fun clearAccount(address: String?) { + if (address.isNullOrEmpty()) return + prefs.edit().remove(key(address)).apply() + } + private fun save(address: String, tokens: List) { prefs.edit().putString(key(address), JSONArray(tokens).toString()).apply() } diff --git a/app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt b/app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt new file mode 100644 index 0000000..6d6a054 --- /dev/null +++ b/app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt @@ -0,0 +1,39 @@ +package com.pombo.android + +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Source guard: AppViewModel cannot be instantiated in a JVM test. + * + * Every store is cleared while the scope still points at the deleted account; + * disconnect() drops the account, and anything cleared after it is cleared + * under some other scope. + */ +class DeleteAccountWiringTest { + + private val vm = File("src/main/java/com/pombo/android/AppViewModel.kt").readText() + + private fun body(source: String, signature: String): String { + val start = source.indexOf(signature) + assertTrue("$signature is gone", start >= 0) + val end = source.indexOf("\n fun ", start + 1).let { if (it < 0) source.length else it } + return source.substring(start, end) + } + + @Test + fun `every store is cleared before disconnect`() { + val body = body(vm, "fun deleteAccount()") + val disconnect = body.lastIndexOf("disconnect()") + listOf( + "channelStore", "contactsStore", "inviteStore", "sentDmStore", "sentReactionsStore", + "failedOutbox", "epochKeyStore", "unreadStore", "settingsStore", "syncStore", + "pushRegistry", "walletTokenStore", "blobStore" + ).forEach { store -> + val clear = body.indexOf("$store.clearAccount(") + assertTrue("deleteAccount no longer clears $store", clear >= 0) + assertTrue("$store must be cleared before disconnect()", clear < disconnect) + } + } +} diff --git a/app/src/test/java/com/pombo/android/data/AccountClearTest.kt b/app/src/test/java/com/pombo/android/data/AccountClearTest.kt new file mode 100644 index 0000000..88b7a7e --- /dev/null +++ b/app/src/test/java/com/pombo/android/data/AccountClearTest.kt @@ -0,0 +1,155 @@ +package com.pombo.android.data + +import android.content.Context +import com.pombo.android.core.PushRegistry +import com.pombo.android.core.SecurePrefs +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Deleting an account erases what every store keeps for it and nothing else, + * least of all another account's epoch key for a channel the deleted one owns. + */ +class AccountClearTest { + + private val files = HashMap() + private fun file(name: String) = files.getOrPut(name) { FakePrefs() } + + private val context: Context = mockk(relaxed = true).also { ctx -> + every { ctx.applicationContext } returns ctx + every { ctx.getSharedPreferences(any(), any()) } answers { file(firstArg()) } + } + + private val aMixed = "0xAaAa000000000000000000000000000000000001" + private val a = aMixed.lowercase() + private val b = "0xbbbb000000000000000000000000000000000002" + private val channelOfA = "$a/0123456789abcdef-1" + private val channelOfB = "$b/fedcba9876543210-1" + + @Before fun setUp() { + mockkObject(SecurePrefs) + every { SecurePrefs.create(any(), any(), any()) } answers { file(secondArg()) } + } + + @After fun tearDown() = unmockkObject(SecurePrefs) + + private inner class Stores(private val scope: String?, guest: Boolean = false) { + val sentDm = SentDmStore(context).apply { scopeAddress = scope; memoryOnly = guest } + val failed = FailedOutboxStore(context).apply { scopeAddress = scope; memoryOnly = guest } + val epoch = EpochKeyStore(context).apply { scopeAddress = scope; memoryOnly = guest } + val reactions = SentReactionsStore(context).apply { scopeAddress = scope; memoryOnly = guest } + val channels = ChannelStore(context).apply { scopeAddress = scope; memoryOnly = guest } + val contacts = ContactsStore(context).apply { scopeAddress = scope; memoryOnly = guest } + val unread = UnreadStore(context).apply { scopeAddress = scope } + val invites = InviteStore(context).apply { scopeAddress = scope } + val sync = SyncStore(context).apply { scopeAddress = scope } + val settings = SettingsStore(context).apply { scopeAddress = scope } + val push = PushRegistry(context).apply { scopeAddress = scope } + val tokens = WalletTokenStore(context) + + fun fill(own: String, peer: String) { + sentDm.add(channelOfB, JSONObject().put("id", "m-$own").put("timestamp", 1L).put("text", "hi")) + sentDm.delete(channelOfB, "gone-$own") + failed.put(channelOfB, JSONObject().put("id", "f-$own").put("timestamp", 2L).put("text", "retry")) + epoch.save(channelOfA, JSONObject().put("currentEpoch", 1)) + epoch.save(channelOfB, JSONObject().put("currentEpoch", 2)) + reactions.record(channelOfB, "msg", "fire", own, add = true) + channels.save(emptyList()) + channels.markLeft(channelOfB) + channels.saveOrder(listOf(channelOfB)) + contacts.save(emptyList()) + unread.add(channelOfB, 2) + unread.setWatermark(channelOfB, 5L) + val invite = InviteStore.StoredInvite("i-$own", peer, channelOfB, "room", "public", null) + invites.save(listOf(invite)) + invites.markDismissed("d-$own") + invites.recordDismissed(invite) + sync.dirty = true + sync.lastSyncTs = 3L + sync.confirmedHash = "hash-$own" + sync.confirmedAt = 4L + sync.recordApplied(listOf(5L)) + settings.graphApiKey = "graph-$own" + settings.syncBase = "{}" + settings.blockedPeers = setOf(peer) + settings.nsfwEnabled = true + settings.syncMode = SyncMode.MANUAL_ONLY + push.add(PushRegistry.Entry(channelOfB, "ab", "public", "room", 0L)) + push.rememberProviders(channelOfB, listOf("https://1.storage.example")) + tokens.add(own, "0x" + "1".repeat(40)) + } + + fun clearAll() { + sentDm.clearAccount() + failed.clearAccount() + epoch.clearAccount() + reactions.clearAccount() + channels.clearAccount() + contacts.clearAccount() + unread.clearAccount() + invites.clearAccount() + sync.clearAccount() + settings.clearAccount() + push.clearAccount() + tokens.clearAccount(scope) + } + } + + private fun ownedBy(address: String, key: String) = + key.endsWith("_$address") || key.startsWith("${address}_") || key.contains("_${address}_") + + private fun snapshot(): Map> = files.mapValues { LinkedHashMap(it.value.values) } + + private fun fillBoth() { + Stores(aMixed).fill(a, b) + Stores(b).fill(b, a) + file("pombo_settings").values["rpc_selection"] = "device-wide" + } + + @Test + fun `deleting an account erases its keys in every store and leaves the other account's`() { + fillBoth() + val before = snapshot() + files.forEach { (name, prefs) -> + assertTrue("nothing of the account was written to $name", prefs.values.keys.any { ownedBy(a, it) }) + } + + Stores(aMixed).clearAll() + + val after = snapshot() + before.forEach { (name, keys) -> + assertEquals(name, keys.filterKeys { !ownedBy(a, it) }, after[name]) + } + assertEquals(1, Stores(b).epoch.load(channelOfA)?.optInt("currentEpoch")) + assertEquals("device-wide", file("pombo_settings").values["rpc_selection"]) + } + + @Test + fun `without an account in scope nothing is erased`() { + fillBoth() + val before = snapshot() + + Stores(null).clearAll() + Stores(null, guest = true).clearAll() + + assertEquals(before, snapshot()) + } + + @Test + fun `the deleted account's unread badges leave memory too`() { + val unread = Stores(aMixed).unread + unread.add(channelOfB, 3) + + unread.clearAccount() + + assertTrue(unread.counts.value.isEmpty()) + } +} diff --git a/app/src/test/java/com/pombo/android/data/FakePrefs.kt b/app/src/test/java/com/pombo/android/data/FakePrefs.kt new file mode 100644 index 0000000..fa6aed1 --- /dev/null +++ b/app/src/test/java/com/pombo/android/data/FakePrefs.kt @@ -0,0 +1,57 @@ +package com.pombo.android.data + +import android.content.SharedPreferences + +/** In-memory SharedPreferences with every type and getAll(); an editor's last call per key wins. */ +class FakePrefs : SharedPreferences { + + val values = LinkedHashMap() + + override fun getAll(): MutableMap = LinkedHashMap(values) + override fun getString(key: String, defValue: String?): String? = values[key] as? String ?: defValue + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String, defValues: MutableSet?): MutableSet? = + (values[key] as? Set)?.toMutableSet() ?: defValues + override fun getInt(key: String, defValue: Int): Int = values[key] as? Int ?: defValue + override fun getLong(key: String, defValue: Long): Long = values[key] as? Long ?: defValue + override fun getFloat(key: String, defValue: Float): Float = values[key] as? Float ?: defValue + override fun getBoolean(key: String, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue + override fun contains(key: String): Boolean = values.containsKey(key) + override fun edit(): SharedPreferences.Editor = Editor() + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {} + + private object Removed + + private inner class Editor : SharedPreferences.Editor { + private val changes = LinkedHashMap() + private var clearFirst = false + + private fun put(key: String, value: Any?): SharedPreferences.Editor { + changes[key] = value ?: Removed + return this + } + + override fun putString(key: String, value: String?) = put(key, value) + override fun putStringSet(key: String, values: MutableSet?) = put(key, values?.toSet()) + override fun putInt(key: String, value: Int) = put(key, value) + override fun putLong(key: String, value: Long) = put(key, value) + override fun putFloat(key: String, value: Float) = put(key, value) + override fun putBoolean(key: String, value: Boolean) = put(key, value) + override fun remove(key: String) = put(key, null) + override fun clear(): SharedPreferences.Editor { + clearFirst = true + return this + } + + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + if (clearFirst) values.clear() + changes.forEach { (key, value) -> if (value === Removed) values.remove(key) else values[key] = value } + } + } +} From 7a725b0375e6a2c5446d7f1e3d96419cb588dd27 Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Tue, 6 Oct 2026 13:23:44 +0100 Subject: [PATCH 2/3] Re-scope storage to the remaining account after a delete - disconnect() made the next account current without re-scoping, so it ran on the deleted account's keys until a cold start, and its sync pull refilled them Co-authored-by: Claude Opus 5.5 --- .../main/java/com/pombo/android/AppViewModel.kt | 1 + .../pombo/android/DeleteAccountWiringTest.kt | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/pombo/android/AppViewModel.kt b/app/src/main/java/com/pombo/android/AppViewModel.kt index abb57c1..03918fd 100644 --- a/app/src/main/java/com/pombo/android/AppViewModel.kt +++ b/app/src/main/java/com/pombo/android/AppViewModel.kt @@ -2378,6 +2378,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app), PomboBridge.Listen manager.closeCurrent() sync.cancelAutoPush() store.clear() + applyStorageScope(store.address, guest = false) _accounts.value = store.accounts() _address.value = store.address _username.value = store.username diff --git a/app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt b/app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt index 6d6a054..8c959c6 100644 --- a/app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt +++ b/app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt @@ -7,9 +7,9 @@ import org.junit.Test /** * Source guard: AppViewModel cannot be instantiated in a JVM test. * - * Every store is cleared while the scope still points at the deleted account; - * disconnect() drops the account, and anything cleared after it is cleared - * under some other scope. + * Every store is cleared while the scope still points at the deleted account, + * and only then does disconnect() move the scope to the account that remains; + * the other way round erases the remaining account's data. */ class DeleteAccountWiringTest { @@ -36,4 +36,15 @@ class DeleteAccountWiringTest { assertTrue("$store must be cleared before disconnect()", clear < disconnect) } } + + @Test + fun `disconnect moves the scope only after the account is dropped, before reconnecting`() { + val body = body(vm, "fun disconnect()") + val drop = body.indexOf("store.clear()") + val scope = body.indexOf("applyStorageScope(store.address, guest = false)") + val reconnect = body.indexOf("bridge.reconnect()") + assertTrue("disconnect no longer re-scopes the stores", scope >= 0) + assertTrue("the scope must move after store.clear()", drop in 0 until scope) + assertTrue("the scope must move before reconnecting", scope < reconnect) + } } From 5e3f32a97f2f06e19fa3a146896473b81f8fbfdb Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Tue, 6 Oct 2026 13:24:14 +0100 Subject: [PATCH 3/3] Keep a guest session from deleting or exporting the stored account - A guest leaves the last real account current in the wallet store, so Delete erased that account's key and Unlock Key showed it Co-authored-by: Claude Opus 5.5 --- .../java/com/pombo/android/AppViewModel.kt | 4 +++- .../pombo/android/ui/screens/SettingsScreen.kt | 5 +++-- .../pombo/android/DeleteAccountWiringTest.kt | 18 +++++++++++++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/pombo/android/AppViewModel.kt b/app/src/main/java/com/pombo/android/AppViewModel.kt index 03918fd..15a95ae 100644 --- a/app/src/main/java/com/pombo/android/AppViewModel.kt +++ b/app/src/main/java/com/pombo/android/AppViewModel.kt @@ -196,7 +196,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app), PomboBridge.Listen * exposed unguarded here only because the guard is a UI concern (it needs * an Activity to host the prompt). */ - fun exportPrivateKey(): String? = store.privateKey + fun exportPrivateKey(): String? = if (_isGuest.value) null else store.privateKey /** Blocked peers, for the Privacy panel. */ val blockedPeers: Set get() = settingsStore.blockedPeers @@ -217,6 +217,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app), PomboBridge.Listen * shown in the Security panel. The caller must have authenticated first. */ fun deleteAccount() { + // A guest session leaves the stored account current: deleting here would erase that account's key. + if (_isGuest.value) return viewModelScope.launch { // Wipe the account-scoped data first, while the storage scope still // points at this account. disconnect() drops the keystore entry and diff --git a/app/src/main/java/com/pombo/android/ui/screens/SettingsScreen.kt b/app/src/main/java/com/pombo/android/ui/screens/SettingsScreen.kt index a575059..d45ac94 100644 --- a/app/src/main/java/com/pombo/android/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/pombo/android/ui/screens/SettingsScreen.kt @@ -1473,6 +1473,7 @@ private fun SecurityPanel(vm: AppViewModel) { var keyVisible by remember { mutableStateOf(false) } var deleteVerified by remember { mutableStateOf(false) } val noDeviceLock = remember { !com.pombo.android.ui.DeviceAuth.canAuthenticate(context) } + val isGuest by vm.isGuest.collectAsState() // The unlocked private key renders on this panel — keep it out of // screenshots, recordings and the recents thumbnail (M-I1). @@ -1497,7 +1498,7 @@ private fun SecurityPanel(vm: AppViewModel) { DangerCard(title = "Private Key", hint = "Anyone with this key has full control of your account") { val key = revealedKey if (key == null) { - DangerButton("Unlock Key", enabled = !noDeviceLock) { + DangerButton("Unlock Key", enabled = !noDeviceLock && !isGuest) { activity?.let { com.pombo.android.ui.DeviceAuth.authenticate( it, "Unlock private key", @@ -1560,7 +1561,7 @@ private fun SecurityPanel(vm: AppViewModel) { DangerCard(title = "Delete Account", hint = "Permanently delete this account and all its data") { if (!deleteVerified) { - DangerButton("Verify", enabled = !noDeviceLock) { + DangerButton("Verify", enabled = !noDeviceLock && !isGuest) { activity?.let { com.pombo.android.ui.DeviceAuth.authenticate( it, "Delete account", diff --git a/app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt b/app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt index 8c959c6..e7dded6 100644 --- a/app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt +++ b/app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt @@ -9,11 +9,14 @@ import org.junit.Test * * Every store is cleared while the scope still points at the deleted account, * and only then does disconnect() move the scope to the account that remains; - * the other way round erases the remaining account's data. + * the other way round erases the remaining account's data. A guest session + * keeps the last real account stored as current, so neither deleting nor + * exporting the key may run there. */ class DeleteAccountWiringTest { private val vm = File("src/main/java/com/pombo/android/AppViewModel.kt").readText() + private val settings = File("src/main/java/com/pombo/android/ui/screens/SettingsScreen.kt").readText() private fun body(source: String, signature: String): String { val start = source.indexOf(signature) @@ -47,4 +50,17 @@ class DeleteAccountWiringTest { assertTrue("the scope must move after store.clear()", drop in 0 until scope) assertTrue("the scope must move before reconnecting", scope < reconnect) } + + @Test + fun `a guest can neither delete nor export the stored account`() { + val delete = body(vm, "fun deleteAccount()") + val guard = delete.indexOf("if (_isGuest.value) return") + assertTrue("deleteAccount runs in a guest session", guard in 0 until delete.indexOf("viewModelScope.launch")) + assertTrue( + "exportPrivateKey hands a guest the stored account's key", + Regex("""fun exportPrivateKey\(\): String\? = if \(_isGuest\.value\) null""").containsMatchIn(vm) + ) + assertTrue("Unlock Key is enabled for a guest", settings.contains("""DangerButton("Unlock Key", enabled = !noDeviceLock && !isGuest)""")) + assertTrue("Delete's Verify is enabled for a guest", settings.contains("""DangerButton("Verify", enabled = !noDeviceLock && !isGuest)""")) + } }