From bfba7e186f25936d13b4d20a11c5a67fc8becf4b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 11:39:37 +0200 Subject: [PATCH] refactor(identity): put an Identity in front of the wallet, and read the key off it Phase 1 of docs/nsec-sign-in.md. No behaviour changes; this is the type the rest of the plan stands on, landed alone so that its diff is boring. Every consumer of "the active user" in the app was the same expression, activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() ten times in eight files, and none of them wanted anything from the node but those 32 bytes. The new to.curare.compose.identity.Identity carries the nostr private key, the x-only public key (computed once), the per-id preferences and an optional PhoenixBusiness, with an IdentityKind saying whether it came from twelve words or a bare secret. It replaces fr.acinq.phoenix.data.ActiveWallet in the app rather than wrapping it: ActiveWallet is declared in the library but nothing in the library reads it, and flattening its fields into Identity is what lets the recovery screens, which read internalPrefs off the active wallet, keep working for an identity that will never have a wallet. SovereignWalletViewModel.activeWalletInUI becomes activeIdentity; setActiveWallet(walletId, business) builds the Mnemonic identity from the node it just started and delegates to a new setActiveIdentity(identity), which is the entry the nsec kind will use. The twenty-five StateFlow parameters across eighteen files become StateFlow, a rename. The ten sites become a read of the identity's key. Two got simpler: NotaryViewModel dropped the flatMapLatest from the wallet flow into the node's key-manager flow, which only existed because the key arrived after the node, and RelaysSocketManager likewise; both keep their per-key cancellation. NavigationViewModel.observeProfile lost its `business == null -> Startup` branch -- the one thing that would have made a nodeless identity loop -- and the "Couldn't get the nostrKey" dead end, since an identity is whole from the moment it exists. The unconstructed NostrNotaryRepository now takes the identity flow instead of a WalletManager, so that it compiles against the same seam. WalletId stays as the id for both kinds. Every preference in the library keys on it, and a second id type would mean a second copy of each; for an nsec identity XonlyPublicKey.toWalletId() gives hash160 of the x-only key, the same forty-hex shape as hash160 of a node id. Tests: NavigationRoutingTest keeps passing with the flow retyped, and a new jvm test pins the two things this refactor is for. A NostrSecret identity with business = null and a synced account on the device routes to ProfileLoaded rather than back to startup; and Identity.nostrPublicKey equals the key quartz derives for the same secret -- the x-only form, sixty-four hex characters, which is not what the library's own LocalKeyManager.nostrPublicKey() returns. That test runs under runBlocking with a real-time timeout because the observer waits 2.1 seconds on Dispatchers.IO, and runTest's virtual clock would time out first. The app's WalletManagerExtension.nostrPublicKey() stays: CreateProfileViewModel still derives a fresh key's pubkey with it. The plan said it could go; it was wrong by one caller. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (863 tests) and :composeApp:m3Audit. Replayed onto Mantra by docs/curated-to-mantra.md: RelaysSocketManager.kt: this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the call it served; NavigationViewModel.kt: this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the call it served. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@42f3a6971a6ffd9d8c9ea70eb8945e82c71ed1f5 --- .../press/mantra/compose/identity/Identity.kt | 78 +++++++++ .../network/relays/RelaysSocketManager.kt | 22 ++- .../repository/NostrNotaryRepository.kt | 8 +- .../ui/composable/DkgApprovalScaffold.kt | 6 +- .../ui/composable/DkgJoinApprovalScreen.kt | 6 +- .../compose/ui/composable/DkgRitualScreen.kt | 8 +- .../ui/composable/DkgRound1ApprovalScreen.kt | 6 +- .../ui/composable/DkgRound2ApprovalScreen.kt | 6 +- .../ui/composable/KeyRecoveryScreen.kt | 12 +- .../ui/composable/RecoveryPhraseScreen.kt | 6 +- .../ui/composable/SelectChatRoomTypeScreen.kt | 8 +- .../composable/SelectSubgroupAdminsScreen.kt | 8 +- .../SovereignWalletStartupScreen.kt | 12 +- .../ui/composable/navigation/MantraNavHost.kt | 22 +-- .../ui/view/model/DkgRitualViewModel.kt | 15 +- .../ui/view/model/NavigationViewModel.kt | 43 ++--- .../compose/ui/view/model/NotaryViewModel.kt | 24 ++- .../ui/view/model/RecoveryPhraseViewModel.kt | 20 +-- .../view/model/SelectChatRoomTypeViewModel.kt | 11 +- .../model/SelectSubgroupAdminsViewModel.kt | 12 +- .../ui/view/model/SovereignWalletViewModel.kt | 63 ++++++-- .../ui/view/model/SynchronizationViewModel.kt | 17 +- .../ui/view/model/NavigationRoutingTest.kt | 4 +- .../dao/UnsignedNostrEventQueueJvmTest.kt | 2 +- .../model/NavigationIdentityRoutingJvmTest.kt | 152 ++++++++++++++++++ 25 files changed, 406 insertions(+), 165 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt new file mode 100644 index 00000000..7a920d38 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt @@ -0,0 +1,78 @@ +package press.mantra.compose.identity + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.XonlyPublicKey +import fr.acinq.bitcoin.byteVector +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs + +/** + * Where an identity's key came from, which decides what else it can have. + * + * The distinction is not cosmetic: the nostr key is a BIP32 leaf of the seed at + * `m/44'/1237'/0'/0/0`, and that derivation runs one way. Twelve words yield the + * key and a wallet; a bare key yields nothing further, so an identity made from + * one can never grow a node behind it. See docs/nsec-sign-in.md. + */ +enum class IdentityKind { + /** Twelve words. The nostr key is derived from them, and so is a wallet. */ + Mnemonic, + + /** A bare nostr secret. Nothing else can be derived from it. */ + NostrSecret, +} + +/** + * The signing identity the app is running as. + * + * This is what every consumer of "the active user" reads, in place of reaching + * through the wallet -- `business.walletManager.keyManager.value.nostrPrivateKey()` + * -- for a key that only some identities derive that way. Everything downstream of + * the key is already independent of the seed: the notary and the sync pumps build + * a quartz `KeyPair` from its bytes, the ChillDKG host key is a hash of it, and the + * Marmot bundle password is `hash160` of it. None of them can tell where it came + * from, and after this type none of them need to. + * + * @param id keys the per-identity preference files and the wallet metadata. For a + * [IdentityKind.Mnemonic] identity it is the hash160 of the node id, as it always + * was; for a [IdentityKind.NostrSecret] identity it is the hash160 of the x-only + * public key ([toWalletId]). Same shape, so nothing downstream can tell the kinds + * apart by the id -- the kind is here, where it can be asked. + * @param business the running node. Non-null only for [IdentityKind.Mnemonic]. + */ +data class Identity( + val id: WalletId, + val kind: IdentityKind, + val nostrPrivateKey: PrivateKey, + val userPrefs: UserPrefs, + val internalPrefs: InternalPrefs, + val business: PhoenixBusiness?, +) { + /** + * X-only, hex -- the form every nostr call site wants. Computed once. + * + * Not `publicKey().toHex()`: that is the 33-byte compressed encoding, sixty-six + * hex characters, and it is what the library's own `nostrPublicKey()` returns. + * Nostr keys are the 32-byte x coordinate. + */ + val nostrPublicKey: HexKey = nostrPrivateKey.publicKey().xOnly().value.toHex() + + /** The key must never reach a log. `data class` would print it. */ + override fun toString(): String = "Identity(id=$id, kind=$kind, key=)" +} + +/** + * The [WalletId] of an identity that has no node id to hash. + * + * `WalletId(nodeId: PublicKey)` is the hash160 of the compressed node key; this is + * the hash160 of the x-only nostr key. The two are over different keys -- the node + * key sits at `m/50'/0'`, the nostr key at `m/44'/1237'/...` -- so a mnemonic wallet + * and its own nostr identity would never share an id even if both were registered. + * Which is exactly why a duplicate has to be caught by public key instead. + */ +fun XonlyPublicKey.toWalletId(): WalletId = + WalletId(Crypto.hash160(value).byteVector().toHex()) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index 99ba4c70..392b0b7d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -8,8 +8,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPublicKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -29,7 +28,7 @@ import kotlinx.coroutines.sync.withLock * https://github.com/PrimalHQ/primal-android-app/blob/6db2e6862239335dede4162338d7c4f10ad35031/app/src/main/kotlin/net/primal/android/networking/relays/RelaysSocketManager.kt */ class RelaysSocketManager( - private val activeWalletStateFlow: StateFlow, + private val activeIdentityStateFlow: StateFlow, private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory, private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository, private val relayRepository: press.mantra.compose.repository.RelayRepository, @@ -61,19 +60,18 @@ class RelaysSocketManager( private fun observeActiveUserId() = scope.launch { - activeWalletStateFlow.collectLatest { activeWallet -> - if (activeWallet == null) { + activeIdentityStateFlow.collectLatest { identity -> + if (identity == null) { // TODO: Cancel all pending jobs? } - activeWallet?.business?.walletManager?.keyManager?.collectLatest { keyManager -> - keyManager?.nostrPublicKey()?.let { pubkey -> - observeRelayJobs[pubkey]?.cancel() - observeRelayJobs[pubkey] = observeRelays(pubkey) - } + // The identity carries its pubkey, so this no longer waits on the node's key + // manager to fill in. The relay observer is still a job on `scope` keyed by + // pubkey and replaced per pubkey, exactly as before. + identity?.nostrPublicKey?.let { pubkey -> + observeRelayJobs[pubkey]?.cancel() + observeRelayJobs[pubkey] = observeRelays(pubkey) } } - - } private fun observeRelays(publicKey: String): Job = diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt index 5195690d..c56aa42a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt @@ -4,8 +4,8 @@ import press.mantra.compose.cryptography.signOrThrow import press.mantra.compose.network.asClientTag import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent -import fr.acinq.phoenix.managers.WalletManager -import fr.acinq.phoenix.managers.nostrPrivateKey +import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel @@ -23,7 +23,7 @@ import kotlinx.coroutines.sync.withLock */ class NostrNotaryRepository( private val nostrRepository: NostrRepository, - private val walletManager: WalletManager, + private val activeIdentityStateFlow: StateFlow, ) { private val scope = CoroutineScope(Dispatchers.Main) @@ -67,7 +67,7 @@ class NostrNotaryRepository( private fun findNsecOrThrow(activeUserPublicKey: String): String = runCatching { - walletManager.keyManager.value?.nostrPrivateKey()?.value?.toByteArray()?.toNsec() + activeIdentityStateFlow.value?.nostrPrivateKey?.value?.toByteArray()?.toNsec() }.getOrNull() ?: throw press.mantra.compose.exceptions.SigningKeyNotFoundException() private fun signNostrEvent(publicKey: String, event: press.mantra.compose.database.model.UnsignedNostrEvent): press.mantra.compose.database.model.NostrEvent { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt index e89709cd..b3766198 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt @@ -35,7 +35,7 @@ import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.view.model.DkgRitualViewModel import press.mantra.compose.ui.view.state.DkgRitualUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing import press.mantra.compose.ui.composable.widgets.Decorative @@ -65,7 +65,7 @@ internal fun DkgApprovalScaffold( activeUserPublicKey: HexKey, chatRoomId: String, dkgSessionId: String? = null, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onDone: () -> Unit, @@ -76,7 +76,7 @@ internal fun DkgApprovalScaffold( chatRoomId = chatRoomId, dkgSessionId = dkgSessionId, activeUserPublicKey = activeUserPublicKey, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt index a1c320ea..44b28938 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt @@ -12,7 +12,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing @@ -33,7 +33,7 @@ fun DkgJoinApprovalScreen( activeUserPublicKey: HexKey, chatRoomId: String, dkgSessionId: String? = null, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onDone: () -> Unit, @@ -45,7 +45,7 @@ fun DkgJoinApprovalScreen( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, dkgSessionId = dkgSessionId, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository, onDone = onDone diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index e246e90b..342b6702 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -73,7 +73,7 @@ import press.mantra.compose.ui.view.model.DkgRitualViewModel import press.mantra.compose.ui.view.state.DkgRitualUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.FrostSigningSession -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing @@ -135,7 +135,7 @@ fun DkgRitualScreen( /** Which of the room's ceremonies to show; null takes the newest. */ dkgSessionId: String? = null, initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onNavigateToRoute: (Route) -> Unit = {}, @@ -146,7 +146,7 @@ fun DkgRitualScreen( activeUserPublicKey = activeUserPublicKey, dkgSessionId = dkgSessionId, initialDkgRitualUIState = initialDkgRitualUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) @@ -882,7 +882,7 @@ private fun DkgRitualScreenPreview() { ), ) ), - activeWalletStateFlow = MutableStateFlow(null), + activeIdentityStateFlow = MutableStateFlow(null), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt index 24970df6..ef72e22b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt @@ -12,7 +12,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res @@ -37,7 +37,7 @@ fun DkgRound1ApprovalScreen( activeUserPublicKey: HexKey, chatRoomId: String, dkgSessionId: String? = null, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onDone: () -> Unit, @@ -49,7 +49,7 @@ fun DkgRound1ApprovalScreen( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, dkgSessionId = dkgSessionId, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository, onDone = onDone diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt index 7edd9f7a..f66d38f4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt @@ -12,7 +12,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res @@ -38,7 +38,7 @@ fun DkgRound2ApprovalScreen( activeUserPublicKey: HexKey, chatRoomId: String, dkgSessionId: String? = null, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onDone: () -> Unit, @@ -50,7 +50,7 @@ fun DkgRound2ApprovalScreen( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, dkgSessionId = dkgSessionId, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository, onDone = onDone diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt index 7d5f3396..ed56c4a1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf @@ -81,16 +81,16 @@ import press.mantra.compose.ui.theme.spacing @Composable fun KeyRecoveryScreen( activeUserPublicKey: HexKey, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, onNavigateBack: () -> Unit, onNavigateToRoute: (Route) -> Unit, ) { - val activeWallet by activeWalletStateFlow.collectAsState() + val activeIdentity by activeIdentityStateFlow.collectAsState() // The same preference the recovery phrase screen writes, so the state of the backup is // visible before the user goes looking for it. - val showBackupNotice by remember(activeWallet) { - activeWallet?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) + val showBackupNotice by remember(activeIdentity) { + activeIdentity?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) }.collectAsState(initial = false) Scaffold( @@ -305,7 +305,7 @@ private fun KeyRecoveryScreenPreview() { ) { KeyRecoveryScreen( activeUserPublicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240", - activeWalletStateFlow = MutableStateFlow(null), + activeIdentityStateFlow = MutableStateFlow(null), onNavigateBack = {}, onNavigateToRoute = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt index 904c3768..415d9973 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt @@ -42,7 +42,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.backup_confirmation @@ -82,13 +82,13 @@ import press.mantra.compose.ui.view.state.RecoveryPhraseUIState @Composable fun RecoveryPhraseScreen( phoenixGlobal: PhoenixGlobal, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, onNavigateBack: () -> Unit, ) { val recoveryPhraseViewModel: RecoveryPhraseViewModel = viewModel( factory = RecoveryPhraseViewModel.factory( phoenixGlobal = phoenixGlobal, - activeWalletStateFlow = activeWalletStateFlow + activeIdentityStateFlow = activeIdentityStateFlow ) ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index b31969d6..0d62827e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -51,7 +51,7 @@ import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.SelectChatRoomTypeViewModel import press.mantra.compose.ui.view.state.SelectChatRoomTypeUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing @@ -87,7 +87,7 @@ fun SelectChatRoomTypeScreen( description: String?, memberPublicKeys: List, initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, dkgRepository: DkgRepository, @@ -100,7 +100,7 @@ fun SelectChatRoomTypeScreen( description = description, memberPublicKeys = memberPublicKeys, initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, dkgRepository = dkgRepository @@ -490,7 +490,7 @@ private fun SelectChatRoomTypeScreenPreview() { ) ) ), - activeWalletStateFlow = MutableStateFlow(null), + activeIdentityStateFlow = MutableStateFlow(null), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt index dd9028bb..94b311b8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import mantra.composeapp.generated.resources.Res @@ -81,7 +81,7 @@ fun SelectSubgroupAdminsScreen( activeUserPublicKey: HexKey, parentChatRoomId: String, initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onNavigateToRoute: (Route) -> Unit @@ -91,7 +91,7 @@ fun SelectSubgroupAdminsScreen( activeUserPublicKey = activeUserPublicKey, parentChatRoomId = parentChatRoomId, initialUIState = initialUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) @@ -331,7 +331,7 @@ private fun SelectSubgroupAdminsScreenPreview() { SelectSubgroupAdminsScreen( activeUserPublicKey = "a".repeat(64), parentChatRoomId = "b".repeat(64), - activeWalletStateFlow = kotlinx.coroutines.flow.MutableStateFlow(null), + activeIdentityStateFlow = kotlinx.coroutines.flow.MutableStateFlow(null), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, onNavigateToRoute = {} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 02d664d9..66bacd42 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -49,8 +49,8 @@ fun SovereignWalletStartupScreen( // Deliberately no intro gate. `getShowIntro` defaults to true and nothing in this app ever // writes it back to false, so gating on it navigated away from this screen on every cold boot // — and a screen that has been left behind never gets to start the wallet. Startup has to run - // to completion here: everything downstream reads the node's key manager. An intro screen has - // to be a step inside this flow, not a detour around it. + // to completion here: everything downstream reads the active identity, and nothing sets one + // but this screen. An intro screen has to be a step inside this flow, not a detour around it. val listWalletState by sovereignWalletViewModel.listWalletState.collectAsState() @@ -84,8 +84,8 @@ fun SovereignWalletStartupScreen( val desiredWalletIdFlow = sovereignWalletViewModel.desiredWalletId.collectAsState() val desiredWalletId = desiredWalletIdFlow.value - val activeWalletFlow = sovereignWalletViewModel.activeWalletInUI.collectAsState() - val activeWallet = activeWalletFlow.value + val activeIdentityFlow = sovereignWalletViewModel.activeIdentity.collectAsState() + val activeIdentity = activeIdentityFlow.value when { availableWallets.isEmpty() -> { @@ -99,12 +99,12 @@ fun SovereignWalletStartupScreen( text = "Preparing wallet..." ) } - activeWallet != null -> { + activeIdentity != null -> { LoadingDataIndicator( text = "Opening wallet" ) LaunchedEffect(Unit) { - sovereignWalletViewModel.loadSovereignData(activeWallet.id) + sovereignWalletViewModel.loadSovereignData(activeIdentity.id) onSuccessfulStartup.invoke() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index b27a0ac2..07e08e15 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -230,7 +230,7 @@ fun MantraNavHost( val navigationViewModel: NavigationViewModel = viewModel ( factory = NavigationViewModel.factory( - activeWallet = sovereignWalletViewModel.activeWalletInUI, + activeIdentity = sovereignWalletViewModel.activeIdentity, initialNavigationUIState = NavigationUIState.Loading(), nostrRepository = databaseNostrRepository, scope = applicationIOScope @@ -239,7 +239,7 @@ fun MantraNavHost( val notaryViewModel: NotaryViewModel = viewModel( factory = NotaryViewModel.factory( - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, marmotRepository = databaseMarmotRepository, @@ -250,7 +250,7 @@ fun MantraNavHost( // TODO: Produce a notary UI Element... val synchronizationViewModel: SynchronizationViewModel = viewModel( factory = SynchronizationViewModel.factory( - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, relayRepository = databaseNostrRepository, @@ -548,7 +548,7 @@ fun MantraNavHost( SelectSubgroupAdminsScreen( activeUserPublicKey = route.activeUserPublicKey, parentChatRoomId = route.parentChatRoomId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onNavigateToRoute = { nextRoute -> @@ -563,7 +563,7 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, dkgSessionId = route.dkgSessionId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onNavigateToRoute = { approvalRoute -> @@ -578,7 +578,7 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, dkgSessionId = route.dkgSessionId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onDone = { navController.popBackStack() } @@ -591,7 +591,7 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, dkgSessionId = route.dkgSessionId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onDone = { navController.popBackStack() } @@ -604,7 +604,7 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, dkgSessionId = route.dkgSessionId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onDone = { navController.popBackStack() } @@ -618,7 +618,7 @@ fun MantraNavHost( name = route.name, description = route.description, memberPublicKeys = route.memberPublicKeys, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, @@ -815,7 +815,7 @@ fun MantraNavHost( KeyRecoveryScreen( activeUserPublicKey = route.activeUserPublicKey, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, onNavigateBack = { navController.popBackStack() }, @@ -829,7 +829,7 @@ fun MantraNavHost( composable { RecoveryPhraseScreen( phoenixGlobal = phoenixGlobal, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, onNavigateBack = { navController.popBackStack() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index 791a0414..d369fd14 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -32,8 +32,7 @@ import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job @@ -56,7 +55,7 @@ class DkgRitualViewModel( */ val dkgSessionId: String? = null, initialDkgRitualUIState: DkgRitualUIState, - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val chatRepository: ChatRepository, val dkgRepository: DkgRepository, ): ViewModel() { @@ -473,7 +472,7 @@ class DkgRitualViewModel( val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return @@ -513,7 +512,7 @@ class DkgRitualViewModel( val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return val session = loaded.session ?: return - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return @@ -567,7 +566,7 @@ class DkgRitualViewModel( // guard rather than a path anybody takes. if (!loaded.isKeyStateSigned) return - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return @@ -678,7 +677,7 @@ class DkgRitualViewModel( activeUserPublicKey: HexKey, dkgSessionId: String? = null, initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository ): ViewModelProvider.Factory = viewModelFactory { @@ -688,7 +687,7 @@ class DkgRitualViewModel( activeUserPublicKey = activeUserPublicKey, dkgSessionId = dkgSessionId, initialDkgRitualUIState = initialDkgRitualUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt index e3fa9011..f505ffae 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt @@ -8,8 +8,7 @@ import press.mantra.compose.database.model.intermdiate.LocalAccount import press.mantra.compose.ui.composable.navigation.routes.SovereignWalletStartupRoute import press.mantra.compose.ui.view.state.NavigationUIState import co.touchlab.kermit.Logger -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPublicKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -25,7 +24,7 @@ import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds class NavigationViewModel( - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, initialNavigationUIState: NavigationUIState, val nostrRepository: press.mantra.compose.repository.NostrRepository, val scope: CoroutineScope, @@ -38,14 +37,14 @@ class NavigationViewModel( private val PENDING_BROADCAST_STATUSES = setOf("pending", "processing") fun factory( - activeWallet: StateFlow, + activeIdentity: StateFlow, initialNavigationUIState: NavigationUIState, nostrRepository: press.mantra.compose.repository.NostrRepository, scope: CoroutineScope, ): ViewModelProvider.Factory = viewModelFactory { initializer { NavigationViewModel( - activeWallet, + activeIdentity, initialNavigationUIState = initialNavigationUIState, nostrRepository = nostrRepository, scope = scope @@ -176,34 +175,22 @@ class NavigationViewModel( scope.launch(Dispatchers.IO) { delay(2_100.milliseconds) // Looking busy... - activeWalletStateFlow.collectLatest { activeWallet -> - logger.d("Active Wallet: $activeWallet") - if (activeWallet == null) { - + activeIdentityStateFlow.collectLatest { identity -> + logger.d("Active identity: $identity") + if (identity == null) { + // Nothing has been started: go and start something. _navigationUIState.getAndUpdate { NavigationUIState.StartupPhoenix } } else { - activeWallet.business.let { business -> - if (business == null) { - _navigationUIState.getAndUpdate { - NavigationUIState.StartupPhoenix - } - } else { - val activeUserPublicKey = business.walletManager.keyManager.value?.nostrPublicKey() + // The identity carries its own key, so there is no node to wait on and no + // "couldn't get the nostrKey" dead end. The branch that used to send a + // nodeless identity back to startup went with it: an identity without a + // node is now a kind of identity, not a wallet that has not finished + // starting. + logger.i("Observing: ${identity.nostrPublicKey}") - if (activeUserPublicKey == null) { - _navigationUIState.getAndUpdate { - NavigationUIState.Loading(text = "Couldn't get the nostrKey") - } - } else { - logger.i("Observing: $activeUserPublicKey") - - loadNostrProfile(activeUserPublicKey) - } - } - logger.i("Navigation UI State is Landing") - } + loadNostrProfile(identity.nostrPublicKey) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt index 2f69969a..e1dade97 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt @@ -11,19 +11,15 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import fr.acinq.phoenix.managers.nsecPassword import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import press.mantra.compose.database.model.NostrEvent @@ -31,7 +27,7 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant class NotaryViewModel( - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val nostrRepository: press.mantra.compose.repository.NostrRepository, val chatRepository: press.mantra.compose.repository.ChatRepository, val marmotRepository: MarmotRepository, @@ -43,7 +39,7 @@ class NotaryViewModel( fun factory( - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, nostrRepository: press.mantra.compose.repository.NostrRepository, chatRepository: press.mantra.compose.repository.ChatRepository, marmotRepository: MarmotRepository, @@ -51,7 +47,7 @@ class NotaryViewModel( ): ViewModelProvider.Factory = viewModelFactory { initializer { NotaryViewModel( - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, marmotRepository = marmotRepository, @@ -63,14 +59,14 @@ class NotaryViewModel( private val logger = Logger.withTag(TAG) - @OptIn(ExperimentalCoroutinesApi::class) private fun observeActiveNostrKey() { scope.launch { - activeWalletStateFlow - .flatMapLatest { activeWallet -> - activeWallet?.business?.walletManager?.keyManager ?: flowOf(null) - } - .map { keyManager -> keyManager?.nostrPrivateKey() } + // One flow, not a `flatMapLatest` into the node's key manager: the identity + // carries its key from the moment it exists, so there is nothing to wait for. + // `distinctUntilChanged` on the key stays -- an identity re-emitted with the same + // key must not tear down and restart the observers below. + activeIdentityStateFlow + .map { identity -> identity?.nostrPrivateKey } .distinctUntilChanged() .collectLatest { nostrPrivateKey -> if (nostrPrivateKey == null) return@collectLatest diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt index dad1108f..59ab0575 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt @@ -7,7 +7,7 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import fr.acinq.phoenix.data.DecryptSeedResult import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers @@ -39,7 +39,7 @@ import press.mantra.compose.ui.view.state.RecoveryPhraseUIState */ class RecoveryPhraseViewModel( val phoenixGlobal: PhoenixGlobal, - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, ): ViewModel() { private val logger = Logger.withTag(TAG) @@ -60,18 +60,18 @@ class RecoveryPhraseViewModel( * instead of drawing an unticked box over a ticked preference. */ @OptIn(ExperimentalCoroutinesApi::class) - val isBackupDone: StateFlow = activeWalletStateFlow + val isBackupDone: StateFlow = activeIdentityStateFlow .flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) @OptIn(ExperimentalCoroutinesApi::class) - val isDisclaimerRead: StateFlow = activeWalletStateFlow + val isDisclaimerRead: StateFlow = activeIdentityStateFlow .flatMapLatest { it?.internalPrefs?.isSeedLossDisclaimerRead ?: flowOf(null) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) /** True until the user has both written the phrase down and read the disclaimer. */ @OptIn(ExperimentalCoroutinesApi::class) - val showBackupNotice: StateFlow = activeWalletStateFlow + val showBackupNotice: StateFlow = activeIdentityStateFlow .flatMapLatest { it?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) @@ -86,7 +86,7 @@ class RecoveryPhraseViewModel( fun revealRecoveryPhrase() { if (_recoveryPhraseUIState.value is RecoveryPhraseUIState.Revealing) return - val walletId = activeWalletStateFlow.value?.id + val walletId = activeIdentityStateFlow.value?.id if (walletId == null) { logger.e { "no active wallet, cannot read a recovery phrase" } _recoveryPhraseUIState.value = RecoveryPhraseUIState.Error.NoActiveWallet @@ -128,7 +128,7 @@ class RecoveryPhraseViewModel( } fun setBackupDone(isDone: Boolean) { - val internalPrefs = activeWalletStateFlow.value?.internalPrefs ?: return + val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> logger.e("could not save the backup flag", throwable) }) { @@ -137,7 +137,7 @@ class RecoveryPhraseViewModel( } fun setDisclaimerRead(isRead: Boolean) { - val internalPrefs = activeWalletStateFlow.value?.internalPrefs ?: return + val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> logger.e("could not save the disclaimer flag", throwable) }) { @@ -150,12 +150,12 @@ class RecoveryPhraseViewModel( fun factory( phoenixGlobal: PhoenixGlobal, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, ): ViewModelProvider.Factory = viewModelFactory { initializer { RecoveryPhraseViewModel( phoenixGlobal = phoenixGlobal, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt index 2d574794..a3c9d8ac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt @@ -30,8 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.utils.RandomInstance -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.async @@ -58,7 +57,7 @@ class SelectChatRoomTypeViewModel( val description: String?, val memberPublicKeys: List, initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState, - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val nostrRepository: NostrRepository, val chatRepository: ChatRepository, val dkgRepository: DkgRepository, @@ -190,7 +189,7 @@ class SelectChatRoomTypeViewModel( return } - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { selectChatRoomTypeUIState = SelectChatRoomTypeUIState.Error( "Couldn't read your keys. Please try again." @@ -447,7 +446,7 @@ class SelectChatRoomTypeViewModel( description: String?, memberPublicKeys: List, initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, dkgRepository: DkgRepository @@ -459,7 +458,7 @@ class SelectChatRoomTypeViewModel( description = description, memberPublicKeys = memberPublicKeys, initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, dkgRepository = dkgRepository diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt index ae4e93b2..e4ce6ff2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt @@ -12,8 +12,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.StateFlow @@ -51,7 +50,7 @@ class SelectSubgroupAdminsViewModel( val activeUserPublicKey: HexKey, val parentChatRoomId: String, initialUIState: SelectSubgroupAdminsUIState, - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val chatRepository: ChatRepository, val dkgRepository: DkgRepository, ): ViewModel() { @@ -204,8 +203,7 @@ class SelectSubgroupAdminsViewModel( val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return - val nostrPrivateKey = activeWalletStateFlow.value - ?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { uiState = SelectSubgroupAdminsUIState.Error("Couldn't read your keys. Please try again.") return @@ -293,7 +291,7 @@ class SelectSubgroupAdminsViewModel( activeUserPublicKey: HexKey, parentChatRoomId: String, initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository ): ViewModelProvider.Factory = viewModelFactory { @@ -302,7 +300,7 @@ class SelectSubgroupAdminsViewModel( activeUserPublicKey = activeUserPublicKey, parentChatRoomId = parentChatRoomId, initialUIState = initialUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index 5229bd03..a145bd93 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -12,13 +12,13 @@ import co.touchlab.kermit.Logger import fr.acinq.lightning.logging.error import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.data.ActiveWallet import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.ListWalletState import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import kotlinx.coroutines.CoroutineExceptionHandler @@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind sealed class WritingSeedState { data object Init : WritingSeedState() @@ -83,8 +85,17 @@ class SovereignWalletViewModel( val desiredWalletId = _desiredWalletId.asStateFlow() val startWalletImmediately = MutableStateFlow(true) - private val _activeWalletInUI = MutableStateFlow(null) - val activeWalletInUI = _activeWalletInUI.asStateFlow() + /** + * The identity the app is running as, or null while nothing has been started. + * + * This replaces what used to be a flow of the active *wallet*: every reader of it + * only ever wanted the nostr key, and reached it through the node -- + * `business.walletManager.keyManager.value.nostrPrivateKey()`. An [Identity] + * carries the key directly, so a reader no longer needs a node to exist, and an + * identity that never has one (a bare nsec) reads the same as one that does. + */ + private val _activeIdentity = MutableStateFlow(null) + val activeIdentity = _activeIdentity.asStateFlow() @OptIn(ExperimentalCoroutinesApi::class) val exchangeRates = phoenixGlobal.currencyManager.ratesFlow @@ -115,14 +126,38 @@ class SovereignWalletViewModel( listAvailableWallets(onDone = {}) } + /** Makes [identity] the one the app runs as. Everything reading [activeIdentity] follows. */ + fun setActiveIdentity(identity: Identity) { + _activeIdentity.value = identity +// scheduleAutoLock() + } + + /** + * Activates the identity a just-started node derives: the mnemonic kind, with the + * node behind it. + */ fun setActiveWallet(walletId: WalletId, business: PhoenixBusiness) { + val keyManager = business.walletManager.keyManager.value + if (keyManager == null) { + // `startNewBusiness` loads the wallet before it reports success, so a business + // handed here without a key manager is a programming error rather than a state + // to route around. Leaving the identity unset sends the UI back to startup. + log.e { "business for wallet=$walletId started without a key manager" } + return + } val dataStoreManager = DataStoreManager(business) - val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = walletId) - val internalPrefs = dataStoreManager.loadInternalPrefsForWallet( walletId = walletId) - _activeWalletInUI.value = ActiveWallet(id = walletId, business = business, userPrefs = userPrefs, internalPrefs = internalPrefs) + setActiveIdentity( + Identity( + id = walletId, + kind = IdentityKind.Mnemonic, + nostrPrivateKey = keyManager.nostrPrivateKey(), + userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = walletId), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId = walletId), + business = business, + ) + ) updateBusinessActiveInUI(walletId) -// scheduleAutoLock() } fun listAvailableWallets(onDone: () -> Unit) { @@ -186,7 +221,7 @@ class SovereignWalletViewModel( // // TODO: Run this through an expect... // viewModelScope.launch { // autoLockHandler.removeCallbacksAndMessages(null) -// val activeUserPrefs = activeWalletInUI.first()?.userPrefs ?: return@launch +// val activeUserPrefs = activeIdentity.first()?.userPrefs ?: return@launch // // val biometricLockEnabled = activeUserPrefs.getLockBiometricsEnabled.first() // val customPinLockEnabled = activeUserPrefs.getLockPinEnabled.first() @@ -198,21 +233,21 @@ class SovereignWalletViewModel( // } // } - /** Clears the active wallet and signals the startup screen to load the given [walletId]. */ + /** Clears the active identity and signals the startup screen to load the given [walletId]. */ fun switchToWallet(walletId: WalletId) { _desiredWalletId.value = walletId - _activeWalletInUI.value = null + _activeIdentity.value = null } - /** Clears the active wallet. It does not affect [desiredWalletId]. The UI may still auto-open a specific wallet, if [desiredWalletId] is not null. */ + /** Clears the active identity. It does not affect [desiredWalletId]. The UI may still auto-open a specific wallet, if [desiredWalletId] is not null. */ fun clearActiveWallet() { - _activeWalletInUI.value = null + _activeIdentity.value = null } - /** Resets the active wallet and [desiredWalletId]. The UI will redirect to the startup screen with the wallets selector prompt. */ + /** Resets the active identity and [desiredWalletId]. The UI will redirect to the startup screen with the wallets selector prompt. */ fun resetToSelector() { _desiredWalletId.value = null - _activeWalletInUI.value = null + _activeIdentity.value = null startWalletImmediately.value = false } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index c4fe428c..eba82d06 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -29,8 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -59,7 +58,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class SynchronizationViewModel( - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val nostrRepository: NostrRepository, val chatRepository: ChatRepository, val relayRepository: RelayRepository, @@ -67,7 +66,7 @@ class SynchronizationViewModel( ): ViewModel() { val relaysSocketManager = RelaysSocketManager( - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrSocketClientFactory = DefaultNostrSocketClientFactory, cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, relayRepository = relayRepository, @@ -130,7 +129,7 @@ class SynchronizationViewModel( private val mutex = Mutex() fun factory( - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, relayRepository: RelayRepository, @@ -138,7 +137,7 @@ class SynchronizationViewModel( ): ViewModelProvider.Factory = viewModelFactory { initializer { SynchronizationViewModel( - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, relayRepository = relayRepository, @@ -188,9 +187,9 @@ class SynchronizationViewModel( init { scope.launch { - activeWalletStateFlow.collectLatest { activeWallet -> - logger.d("Synchronization ActiveWallet: $activeWallet") - activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey -> + activeIdentityStateFlow.collectLatest { identity -> + logger.d("Synchronization identity: $identity") + identity?.nostrPrivateKey?.let { nostrPrivateKey -> val keyPair = KeyPair( privKey = nostrPrivateKey.value.toByteArray() ) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt index bde74339..cb153206 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt @@ -1,7 +1,7 @@ package press.mantra.compose.ui.view.model import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel @@ -102,7 +102,7 @@ class NavigationRoutingTest { private fun bootedOn(vararg accounts: LocalAccount): NavigationViewModel { val scope = CoroutineScope(Job()) return NavigationViewModel( - activeWalletStateFlow = MutableStateFlow(null), + activeIdentityStateFlow = MutableStateFlow(null), initialNavigationUIState = stranded, nostrRepository = Device(accounts.toList()), scope = scope, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventQueueJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventQueueJvmTest.kt index 0764b909..544ac63e 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventQueueJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventQueueJvmTest.kt @@ -39,7 +39,7 @@ import kotlin.time.Instant * * What is asserted here is the DAO's half: that the backlog arrives whole and in a stable * order, and that publishing some rows neither disturbs nor depends on the others. The loop - * that walks it lives in NotaryViewModel, which wants an ActiveWallet flow to stand up; the + * that walks it lives in NotaryViewModel, which wants an Identity flow to stand up; the * [drain] below is shaped like it but is this test's own, so it pins the queue rather than the * collector. */ diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt new file mode 100644 index 00000000..f8f1ccb8 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt @@ -0,0 +1,152 @@ +package press.mantra.compose.ui.view.model + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.toWalletId +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.NavigationUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * An identity with no node behind it is routed like any other. + * + * `NavigationViewModel.observeProfile` used to reach the active user's pubkey through + * the wallet -- `business.walletManager.keyManager.value.nostrPublicKey()` -- and + * answered a null `business` by navigating back to startup. For a wallet that is a + * loop the startup screen breaks by starting the node; for an identity that will + * never have a node it is a loop with no exit. The [Identity] type carries the key + * itself, so the branch is gone, and this pins that it stays gone: a `NostrSecret` + * identity with a signed, synced account on the device lands on `ProfileLoaded`. + * + * A jvm test rather than a common one because [Identity] derives its public key on + * construction, which takes the secp256k1 natives, and those are on this classpath + * (see the other tests in `jvmTest` that build a `PrivateKey`). + * + * The observer waits 2.1 seconds on `Dispatchers.IO` before its first read, so this + * runs under `runBlocking` and a real-time `withTimeout`: `runTest` would advance a + * virtual clock past the timeout while the real delay had not elapsed. + */ +class NavigationIdentityRoutingJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val stranded = NavigationUIState.Loading("Introducing... Torch") + + private val privateKey = PrivateKey(ByteVector32("01".repeat(32))) + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun nostrSecretIdentity() = Identity( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPrivateKey = privateKey, + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + business = null, + ) + + /** Everything `observeProfile` reads, and nothing else; every other member throws. */ + private class Device( + private val accounts: List + ) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun getLocalAccounts(): List = accounts + override suspend fun observeLocalAccount(publicKey: HexKey): Flow = + flowOf(accounts.firstOrNull { it.unsignedNostrEvent?.pubKey == publicKey }) + } + + private val at = Instant.fromEpochSeconds(1_700_000_000) + + private fun syncedAccount(publicKey: HexKey) = LocalAccount( + unsignedNostrEvent = UnsignedNostrEvent( + id = 1, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + signedAt = at, + createdAt = at, + updatedAt = at, + savedAt = at, + ), + nostrEvent = null, + profile = Profile( + publicKey = publicKey, + nostrEventId = "e".repeat(64), + createdAt = at, + updatedAt = at, + savedAt = at, + ), + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = emptyList(), + ) + + @Test + fun `the identity's public key is the x-only key nostr uses`() { + val identity = nostrSecretIdentity() + + // The same key quartz derives, so the notary's KeyPair and this agree on who + // the user is. Not the 33-byte compressed form the library's own helper returns. + assertEquals( + KeyPair(privKey = privateKey.value.toByteArray()).pubKey.toHexKey(), + identity.nostrPublicKey, + ) + assertEquals(64, identity.nostrPublicKey.length) + // Same shape as a wallet's id: forty hex characters of a hash160. + assertEquals(40, identity.id.nodeIdHash.length) + } + + @Test + fun `an identity with no node behind it is routed by its account, not back to startup`() { + val identity = nostrSecretIdentity() + val scope = CoroutineScope(Job()) + val viewModel = NavigationViewModel( + activeIdentityStateFlow = MutableStateFlow(identity), + initialNavigationUIState = stranded, + nostrRepository = Device(listOf(syncedAccount(identity.nostrPublicKey))), + scope = scope, + ) + + try { + val destination = runBlocking { + withTimeout(15_000) { viewModel.navigationUIState.first { it != stranded } } + } + + assertEquals( + NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey), + destination, + "a nodeless identity was not routed by its account", + ) + } finally { + scope.cancel() + } + } +}