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_<hex>, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-28 01:00:49 +00:00
parent 6c5b1c156b
commit d01120a2f0
9 changed files with 155 additions and 46 deletions
@@ -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(),
)
}
@@ -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 =
@@ -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_<hex>`, a different file from the account's own
* `secret_keeper_<npub>`. 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_<pubkey hex>` 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"
}
}
@@ -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) },
@@ -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)
@@ -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<String, KeyPair>()
/**
* 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_<hex>`, a different file from the account's own
* `secret_keeper_<npub>`. 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_<pubkey hex>` 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"
}
}
@@ -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
}
}
@@ -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 +
@@ -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(