mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+2
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+6
-45
@@ -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
|
||||
}
|
||||
}
|
||||
+4
@@ -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 +
|
||||
|
||||
+3
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user