Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions app/src/main/java/com/pombo/android/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> get() = settingsStore.blockedPeers
Expand All @@ -217,16 +217,27 @@ 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
// 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)
Expand Down Expand Up @@ -2369,6 +2380,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
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/pombo/android/core/PushRegistry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/pombo/android/data/ChannelStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/pombo/android/data/ContactsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
10 changes: 10 additions & 0 deletions app/src/main/java/com/pombo/android/data/EpochKeyStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
9 changes: 9 additions & 0 deletions app/src/main/java/com/pombo/android/data/FailedOutboxStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<JSONObject>) {
if (entries.isEmpty()) {
prefs.edit().remove(key(streamId)).apply()
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/pombo/android/data/InviteStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/com/pombo/android/data/SentDmStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/com/pombo/android/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/pombo/android/data/SyncStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/com/pombo/android/data/UnreadStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/pombo/android/data/WalletTokenStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
prefs.edit().putString(key(address), JSONArray(tokens).toString()).apply()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions app/src/test/java/com/pombo/android/DeleteAccountWiringTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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,
* and only then does disconnect() move the scope to the account that remains;
* 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)
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)
}
}

@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)
}

@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)"""))
}
}
Loading
Loading