From d01120a2f0038b4ae731d14f85ccfde91cf13707 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:57:39 +0000 Subject: [PATCH] refactor(geohash): the identity's storage behind GeohashIdentityStore GeohashChatIdentityState read and wrote its seed and nickname through the app's accountSecretsStore, the legacy encrypted SharedPreferences (Amethyst.instance.encryptedStorage, LegacySharedPreferences) and LocalPreferences.LEGACY_WRITES_RETIRED, all from model/. - GeohashIdentityStore (model/) is the four storage calls: read, write, and the two legacy mirrors. InMemoryGeohashIdentityStore serves previews and tests. - AndroidGeohashIdentityStore (app root, beside AccountSecretsStore) holds exactly the code that left: the npub-keyed store, the first-read copy out of secret_keeper_, and the gated legacy mirrors. - The state keeps its mutex, cache and derivation unchanged; Account takes the store, AccountCacheState builds one per pubkey, AppModules supplies the Android one. Wave 4 seam cut from commons/plans/2026-09-27-one-ui-android-desktop.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P --- .../NotificationFeedFilterModeOverrideTest.kt | 2 + .../ThreadDualAxisChartAssemblerTest.kt | 2 + .../amethyst/AndroidGeohashIdentityStore.kt | 81 +++++++++++++++++++ .../com/vitorpamplona/amethyst/AppModules.kt | 1 + .../vitorpamplona/amethyst/model/Account.kt | 4 +- .../model/GeohashChatIdentityState.kt | 51 ++---------- .../amethyst/model/GeohashIdentityStore.kt | 53 ++++++++++++ .../model/accountsCache/AccountCacheState.kt | 4 + .../ui/screen/loggedIn/AccountViewModel.kt | 3 + 9 files changed, 155 insertions(+), 46 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/AndroidGeohashIdentityStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashIdentityStore.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt index 1e295c43a5..abd2af80fd 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.InMemoryGeohashIdentityStore import com.vitorpamplona.amethyst.model.marmot.MarmotGroupNotifier import com.vitorpamplona.amethyst.model.nip46Signer.Nip46ConsentPrompter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter @@ -99,6 +100,7 @@ class NotificationFeedFilterModeOverrideTest { saveSettings = {}, marmotNotifier = { MarmotGroupNotifier.None }, nip46Consent = Nip46ConsentPrompter.Unanswered, + geohashIdentityStore = InMemoryGeohashIdentityStore(), ) } diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 741c9c631c..dfc3608ef6 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.InMemoryGeohashIdentityStore import com.vitorpamplona.amethyst.model.marmot.MarmotGroupNotifier import com.vitorpamplona.amethyst.model.nip46Signer.Nip46ConsentPrompter import com.vitorpamplona.quartz.nip01Core.core.Event @@ -89,6 +90,7 @@ class ThreadDualAxisChartAssemblerTest { saveSettings = {}, marmotNotifier = { MarmotGroupNotifier.None }, nip46Consent = Nip46ConsentPrompter.Unanswered, + geohashIdentityStore = InMemoryGeohashIdentityStore(), ) val db = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AndroidGeohashIdentityStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AndroidGeohashIdentityStore.kt new file mode 100644 index 0000000000..ac5d005e69 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AndroidGeohashIdentityStore.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import androidx.core.content.edit +import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets +import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity +import com.vitorpamplona.amethyst.model.GeohashIdentityStore +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip19Bech32.toNpub + +/** + * The account's geohash identity in [accountSecretsStore], copied on first read out of the + * encrypted SharedPreferences file older versions wrote, and mirrored back there until + * [LocalPreferences.LEGACY_WRITES_RETIRED] (so a rollback keeps the seed and the nickname). + */ +class AndroidGeohashIdentityStore( + private val pubKeyHex: HexKey, +) : GeohashIdentityStore { + /** + * The npub the current store is keyed by. + * + * The legacy file is keyed by the pubkey *hex* — the old code passed + * `signer.pubKey` where every other caller passes an npub, so the identity + * lived in `secret_keeper_`, a different file from the account's own + * `secret_keeper_`. The copy below reads that file and writes the + * npub-keyed store, which is what folds this orphan back in with the rest. + */ + private val npub by lazy { pubKeyHex.hexToByteArray().toNpub() } + + /** + * What `secret_keeper_` holds. + * + * Only called when the store has nothing yet: opening this file creates it, + * so reading it unconditionally would resurrect it after the cleanup has + * deleted it. Touches disk; callers are off the main thread. + */ + private fun legacy(): GeohashIdentitySecrets = readLegacyGeohashIdentity(LegacySharedPreferences(Amethyst.instance.encryptedStorage(pubKeyHex))) + + override suspend fun read(): GeohashIdentitySecrets = accountSecretsStore.readGeohashIdentity(npub) { legacy() } + + override suspend fun write(value: GeohashIdentitySecrets) = accountSecretsStore.mirrorGeohashIdentity(npub, value) + + // Mirrored, not moved: the legacy file stays readable until the legacy writes are retired + // app-wide. Gated on the same switch as every other mirror. + override fun mirrorLegacyNickname(nickname: String) { + if (!LocalPreferences.LEGACY_WRITES_RETIRED) { + Amethyst.instance.encryptedStorage(pubKeyHex).edit { putString(PREF_NICKNAME, nickname) } + } + } + + override fun mirrorLegacyDeviceSeed(seedHex: String) { + if (!LocalPreferences.LEGACY_WRITES_RETIRED) { + Amethyst.instance.encryptedStorage(pubKeyHex).edit { putString(PREF_KEY, seedHex) } + } + } + + companion object { + private const val PREF_KEY = "geohash_chat_device_seed" + private const val PREF_NICKNAME = "geohash_chat_nickname" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index a2bd5a0c14..14d617c0c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -1037,6 +1037,7 @@ class AppModules( // A provider: notificationDispatcher is declared further down this class. marmotNotifier = { notificationDispatcher }, nip46Consent = Nip46ConsentBridge, + geohashIdentityStore = { AndroidGeohashIdentityStore(it) }, rootFilesDir = { appContext.filesDir }, powQueue = { powPublishQueue }, meterSigner = { MeteringNostrSigner(it, resourceUsage) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2cf8c6cdab..4ffa099f83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -398,6 +398,8 @@ class Account( val marmotNotifier: () -> MarmotGroupNotifier, /** Asks the user to approve NIP-46 client connections and operations (the app's signer dialogs). */ val nip46Consent: Nip46ConsentPrompter, + /** Where this account's geohash-chat seed and nickname are kept (the app's encrypted storage). */ + val geohashIdentityStore: GeohashIdentityStore, /** * Where cordn keeps its encrypted group state, or null to run without it. * @@ -789,7 +791,7 @@ class Account( val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) // Anonymous, per-geohash throwaway identities for Bitchat-interoperable location chats. - val geohashIdentity = GeohashChatIdentityState(signer, scope) + val geohashIdentity = GeohashChatIdentityState(signer, scope, geohashIdentityStore) val muteListDecryptionCache = MuteListDecryptionCache(signer) val muteList = MuteListState(signer, cache, muteListDecryptionCache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt index e45bc638b7..3308383afe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt @@ -20,20 +20,13 @@ */ package com.vitorpamplona.amethyst.model -import androidx.core.content.edit -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.LegacySharedPreferences -import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.accountSecretsStore import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets -import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity import com.vitorpamplona.quartz.experimental.bitchat.identity.GeohashKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -64,6 +57,7 @@ import java.util.concurrent.ConcurrentHashMap class GeohashChatIdentityState( private val signer: NostrSigner, private val scope: CoroutineScope, + private val store: GeohashIdentityStore, ) { /** * Guards seed creation as well as the key cache: two callers racing into @@ -74,43 +68,23 @@ class GeohashChatIdentityState( private val mutex = Mutex() private val cache = ConcurrentHashMap() - /** - * The npub the current store is keyed by. - * - * The legacy file is keyed by the pubkey *hex* — the old code passed - * `signer.pubKey` where every other caller passes an npub, so the identity - * lived in `secret_keeper_`, a different file from the account's own - * `secret_keeper_`. The copy below reads that file and writes the - * npub-keyed store, which is what folds this orphan back in with the rest. - */ - private val npub by lazy { signer.pubKey.hexToByteArray().toNpub() } - @Volatile private var loaded: GeohashIdentitySecrets? = null /** - * What `secret_keeper_` holds. - * - * Only called when the store has nothing yet: opening this file creates it, - * so reading it unconditionally would resurrect it after the cleanup has - * deleted it. Touches disk; callers are off the main thread. - */ - private fun legacy(): GeohashIdentitySecrets = readLegacyGeohashIdentity(LegacySharedPreferences(Amethyst.instance.encryptedStorage(signer.pubKey))) - - /** - * The stored identity, copying it out of the legacy file the first time. + * The stored identity, read once from [store]. * * **Call under [mutex].** Not self-locking, because [keyPair] already holds * the lock when it reaches here and [Mutex] is not reentrant. */ private suspend fun current(): GeohashIdentitySecrets { loaded?.let { return it } - return accountSecretsStore.readGeohashIdentity(npub) { legacy() }.also { loaded = it } + return store.read().also { loaded = it } } /** Call under [mutex], for the reason [current] gives. */ private suspend fun persist(value: GeohashIdentitySecrets) { loaded = value - accountSecretsStore.mirrorGeohashIdentity(npub, value) + store.write(value) } /** @@ -138,13 +112,7 @@ class GeohashChatIdentityState( // session would be unreproducible on the next launch. mutex.withLock { persist(current().copy(nickname = trimmed)) - // Mirrored, not moved: the legacy file stays readable until the - // legacy writes are retired app-wide, so a rollback keeps the handle. - // Gated on the same switch as every other mirror — otherwise flipping - // it would retire the documented four and leave this one writing. - if (!LocalPreferences.LEGACY_WRITES_RETIRED) { - Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) } - } + store.mirrorLegacyNickname(trimmed) } } } @@ -175,14 +143,7 @@ class GeohashChatIdentityState( val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE) persist(current().copy(deviceSeed = fresh.toHexKey())) - if (!LocalPreferences.LEGACY_WRITES_RETIRED) { - Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_KEY, fresh.toHexKey()) } - } + store.mirrorLegacyDeviceSeed(fresh.toHexKey()) return fresh } - - companion object { - private const val PREF_KEY = "geohash_chat_device_seed" - private const val PREF_NICKNAME = "geohash_chat_nickname" - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashIdentityStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashIdentityStore.kt new file mode 100644 index 0000000000..0608e4dd8d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashIdentityStore.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets + +/** + * Where one account's geohash-chat identity secrets (the device seed and the location-chat + * nickname) are kept. [GeohashChatIdentityState] does the locking and derivation; this only moves + * bytes. Reads and writes touch disk, so callers are off the main thread. + */ +interface GeohashIdentityStore { + /** The stored secrets. The first read may copy them in from an older location. */ + suspend fun read(): GeohashIdentitySecrets + + /** Replaces the stored secrets with [value]. */ + suspend fun write(value: GeohashIdentitySecrets) + + /** Also records a new nickname wherever older app versions look for it, if the platform still does. */ + fun mirrorLegacyNickname(nickname: String) = Unit + + /** Also records a new device seed wherever older app versions look for it, if the platform still does. */ + fun mirrorLegacyDeviceSeed(seedHex: String) = Unit +} + +/** Keeps the secrets in memory only (previews, tests). */ +class InMemoryGeohashIdentityStore : GeohashIdentityStore { + @Volatile private var value = GeohashIdentitySecrets() + + override suspend fun read() = value + + override suspend fun write(value: GeohashIdentitySecrets) { + this.value = value + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 850e3cb3c4..8d84a0e37e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.GeohashIdentityStore import com.vitorpamplona.amethyst.model.marmot.MarmotGroupNotifier import com.vitorpamplona.amethyst.model.nip46Signer.Nip46ConsentPrompter import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -84,6 +85,8 @@ class AccountCacheState( val marmotNotifier: () -> MarmotGroupNotifier, /** The NIP-46 consent dialogs, shared by every [Account]. */ val nip46Consent: Nip46ConsentPrompter, + /** Builds the store for one account's geohash-chat identity, keyed by its pubkey. */ + val geohashIdentityStore: (HexKey) -> GeohashIdentityStore, val rootFilesDir: () -> File = { File("") }, val powQueue: () -> PoWPublishQueue? = { null }, /** Optional resource-ledger wrapper applied to every account signer (see MeteringNostrSigner). */ @@ -363,6 +366,7 @@ class AccountCacheState( saveSettings = saveSettings, marmotNotifier = marmotNotifier, nip46Consent = nip46Consent, + geohashIdentityStore = geohashIdentityStore(signer.pubKey), scope = CoroutineScope( Dispatchers.IO + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index f7fedf0d12..dc4bdfe718 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -119,6 +119,7 @@ import com.vitorpamplona.amethyst.commons.util.showAmount import com.vitorpamplona.amethyst.commons.util.showAmountInteger import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.InMemoryGeohashIdentityStore import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.marmot.MarmotGroupNotifier @@ -3240,6 +3241,7 @@ fun mockAccountViewModel(): AccountViewModel { saveSettings = {}, marmotNotifier = { MarmotGroupNotifier.None }, nip46Consent = Nip46ConsentPrompter.Unanswered, + geohashIdentityStore = InMemoryGeohashIdentityStore(), ) return AccountViewModel( @@ -3301,6 +3303,7 @@ fun mockVitorAccountViewModel(): AccountViewModel { saveSettings = {}, marmotNotifier = { MarmotGroupNotifier.None }, nip46Consent = Nip46ConsentPrompter.Unanswered, + geohashIdentityStore = InMemoryGeohashIdentityStore(), ) return AccountViewModel(