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() + } + } +}