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<ActiveWallet?>
parameters across eighteen files become StateFlow<Identity?>, 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 <noreply@anthropic.com>
Pulled-From: curated/curated@42f3a6971a
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-13 12:00:19 +02:00
1 parent c49914818a
commit bfba7e186f
25 files changed
+406 -165

No files matched your search

@@ -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=<redacted>)"
}
/**
* 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())
@@ -8,8 +8,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
import fr.acinq.phoenix.data.ActiveWallet import press.mantra.compose.identity.Identity
import fr.acinq.phoenix.managers.nostrPublicKey
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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 * https://github.com/PrimalHQ/primal-android-app/blob/6db2e6862239335dede4162338d7c4f10ad35031/app/src/main/kotlin/net/primal/android/networking/relays/RelaysSocketManager.kt
*/ */
class RelaysSocketManager( class RelaysSocketManager(
private val activeWalletStateFlow: StateFlow<ActiveWallet?>, private val activeIdentityStateFlow: StateFlow<Identity?>,
private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory, private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory,
private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository, private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository,
private val relayRepository: press.mantra.compose.repository.RelayRepository, private val relayRepository: press.mantra.compose.repository.RelayRepository,
@@ -61,19 +60,18 @@ class RelaysSocketManager(
private fun observeActiveUserId() = private fun observeActiveUserId() =
scope.launch { scope.launch {
activeWalletStateFlow.collectLatest { activeWallet -> activeIdentityStateFlow.collectLatest { identity ->
if (activeWallet == null) { if (identity == null) {
// TODO: Cancel all pending jobs? // TODO: Cancel all pending jobs?
} }
activeWallet?.business?.walletManager?.keyManager?.collectLatest { keyManager -> // The identity carries its pubkey, so this no longer waits on the node's key
keyManager?.nostrPublicKey()?.let { pubkey -> // manager to fill in. The relay observer is still a job on `scope` keyed by
observeRelayJobs[pubkey]?.cancel() // pubkey and replaced per pubkey, exactly as before.
observeRelayJobs[pubkey] = observeRelays(pubkey) identity?.nostrPublicKey?.let { pubkey ->
} observeRelayJobs[pubkey]?.cancel()
observeRelayJobs[pubkey] = observeRelays(pubkey)
} }
} }
} }
private fun observeRelays(publicKey: String): Job = private fun observeRelays(publicKey: String): Job =
@@ -4,8 +4,8 @@ import press.mantra.compose.cryptography.signOrThrow
import press.mantra.compose.network.asClientTag import press.mantra.compose.network.asClientTag
import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import fr.acinq.phoenix.managers.WalletManager import kotlinx.coroutines.flow.StateFlow
import fr.acinq.phoenix.managers.nostrPrivateKey import press.mantra.compose.identity.Identity
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@@ -23,7 +23,7 @@ import kotlinx.coroutines.sync.withLock
*/ */
class NostrNotaryRepository( class NostrNotaryRepository(
private val nostrRepository: NostrRepository, private val nostrRepository: NostrRepository,
private val walletManager: WalletManager, private val activeIdentityStateFlow: StateFlow<Identity?>,
) { ) {
private val scope = CoroutineScope(Dispatchers.Main) private val scope = CoroutineScope(Dispatchers.Main)
@@ -67,7 +67,7 @@ class NostrNotaryRepository(
private fun findNsecOrThrow(activeUserPublicKey: String): String = private fun findNsecOrThrow(activeUserPublicKey: String): String =
runCatching { runCatching {
walletManager.keyManager.value?.nostrPrivateKey()?.value?.toByteArray()?.toNsec() activeIdentityStateFlow.value?.nostrPrivateKey?.value?.toByteArray()?.toNsec()
}.getOrNull() ?: throw press.mantra.compose.exceptions.SigningKeyNotFoundException() }.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 { private fun signNostrEvent(publicKey: String, event: press.mantra.compose.database.model.UnsignedNostrEvent): press.mantra.compose.database.model.NostrEvent {
@@ -35,7 +35,7 @@ import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.view.model.DkgRitualViewModel import press.mantra.compose.ui.view.model.DkgRitualViewModel
import press.mantra.compose.ui.view.state.DkgRitualUIState import press.mantra.compose.ui.view.state.DkgRitualUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.flow.StateFlow
import press.mantra.compose.ui.theme.spacing import press.mantra.compose.ui.theme.spacing
import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.Decorative
@@ -65,7 +65,7 @@ internal fun DkgApprovalScaffold(
activeUserPublicKey: HexKey, activeUserPublicKey: HexKey,
chatRoomId: String, chatRoomId: String,
dkgSessionId: String? = null, dkgSessionId: String? = null,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
chatRepository: ChatRepository, chatRepository: ChatRepository,
dkgRepository: DkgRepository, dkgRepository: DkgRepository,
onDone: () -> Unit, onDone: () -> Unit,
@@ -76,7 +76,7 @@ internal fun DkgApprovalScaffold(
chatRoomId = chatRoomId, chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId, dkgSessionId = dkgSessionId,
activeUserPublicKey = activeUserPublicKey, activeUserPublicKey = activeUserPublicKey,
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
chatRepository = chatRepository, chatRepository = chatRepository,
dkgRepository = dkgRepository dkgRepository = dkgRepository
) )
@@ -12,7 +12,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.DkgRepository
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.flow.StateFlow
import press.mantra.compose.ui.theme.spacing import press.mantra.compose.ui.theme.spacing
@@ -33,7 +33,7 @@ fun DkgJoinApprovalScreen(
activeUserPublicKey: HexKey, activeUserPublicKey: HexKey,
chatRoomId: String, chatRoomId: String,
dkgSessionId: String? = null, dkgSessionId: String? = null,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
chatRepository: ChatRepository, chatRepository: ChatRepository,
dkgRepository: DkgRepository, dkgRepository: DkgRepository,
onDone: () -> Unit, onDone: () -> Unit,
@@ -45,7 +45,7 @@ fun DkgJoinApprovalScreen(
activeUserPublicKey = activeUserPublicKey, activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId, chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId, dkgSessionId = dkgSessionId,
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
chatRepository = chatRepository, chatRepository = chatRepository,
dkgRepository = dkgRepository, dkgRepository = dkgRepository,
onDone = onDone onDone = onDone
@@ -73,7 +73,7 @@ import press.mantra.compose.ui.view.model.DkgRitualViewModel
import press.mantra.compose.ui.view.state.DkgRitualUIState import press.mantra.compose.ui.view.state.DkgRitualUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.FrostSigningSession 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import press.mantra.compose.ui.theme.spacing 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. */ /** Which of the room's ceremonies to show; null takes the newest. */
dkgSessionId: String? = null, dkgSessionId: String? = null,
initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading, initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
chatRepository: ChatRepository, chatRepository: ChatRepository,
dkgRepository: DkgRepository, dkgRepository: DkgRepository,
onNavigateToRoute: (Route) -> Unit = {}, onNavigateToRoute: (Route) -> Unit = {},
@@ -146,7 +146,7 @@ fun DkgRitualScreen(
activeUserPublicKey = activeUserPublicKey, activeUserPublicKey = activeUserPublicKey,
dkgSessionId = dkgSessionId, dkgSessionId = dkgSessionId,
initialDkgRitualUIState = initialDkgRitualUIState, initialDkgRitualUIState = initialDkgRitualUIState,
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
chatRepository = chatRepository, chatRepository = chatRepository,
dkgRepository = dkgRepository dkgRepository = dkgRepository
) )
@@ -882,7 +882,7 @@ private fun DkgRitualScreenPreview() {
), ),
) )
), ),
activeWalletStateFlow = MutableStateFlow(null), activeIdentityStateFlow = MutableStateFlow(null),
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY
) )
@@ -12,7 +12,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.DkgRepository
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.flow.StateFlow
import press.mantra.compose.ui.theme.spacing import press.mantra.compose.ui.theme.spacing
import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.Res
@@ -37,7 +37,7 @@ fun DkgRound1ApprovalScreen(
activeUserPublicKey: HexKey, activeUserPublicKey: HexKey,
chatRoomId: String, chatRoomId: String,
dkgSessionId: String? = null, dkgSessionId: String? = null,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
chatRepository: ChatRepository, chatRepository: ChatRepository,
dkgRepository: DkgRepository, dkgRepository: DkgRepository,
onDone: () -> Unit, onDone: () -> Unit,
@@ -49,7 +49,7 @@ fun DkgRound1ApprovalScreen(
activeUserPublicKey = activeUserPublicKey, activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId, chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId, dkgSessionId = dkgSessionId,
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
chatRepository = chatRepository, chatRepository = chatRepository,
dkgRepository = dkgRepository, dkgRepository = dkgRepository,
onDone = onDone onDone = onDone
@@ -12,7 +12,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.DkgRepository
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.flow.StateFlow
import press.mantra.compose.ui.theme.spacing import press.mantra.compose.ui.theme.spacing
import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.Res
@@ -38,7 +38,7 @@ fun DkgRound2ApprovalScreen(
activeUserPublicKey: HexKey, activeUserPublicKey: HexKey,
chatRoomId: String, chatRoomId: String,
dkgSessionId: String? = null, dkgSessionId: String? = null,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
chatRepository: ChatRepository, chatRepository: ChatRepository,
dkgRepository: DkgRepository, dkgRepository: DkgRepository,
onDone: () -> Unit, onDone: () -> Unit,
@@ -50,7 +50,7 @@ fun DkgRound2ApprovalScreen(
activeUserPublicKey = activeUserPublicKey, activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId, chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId, dkgSessionId = dkgSessionId,
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
chatRepository = chatRepository, chatRepository = chatRepository,
dkgRepository = dkgRepository, dkgRepository = dkgRepository,
onDone = onDone onDone = onDone
@@ -41,7 +41,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -81,16 +81,16 @@ import press.mantra.compose.ui.theme.spacing
@Composable @Composable
fun KeyRecoveryScreen( fun KeyRecoveryScreen(
activeUserPublicKey: HexKey, activeUserPublicKey: HexKey,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToRoute: (Route) -> 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 // The same preference the recovery phrase screen writes, so the state of the backup is
// visible before the user goes looking for it. // visible before the user goes looking for it.
val showBackupNotice by remember(activeWallet) { val showBackupNotice by remember(activeIdentity) {
activeWallet?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) activeIdentity?.internalPrefs?.showSeedBackupNotice ?: flowOf(false)
}.collectAsState(initial = false) }.collectAsState(initial = false)
Scaffold( Scaffold(
@@ -305,7 +305,7 @@ private fun KeyRecoveryScreenPreview() {
) { ) {
KeyRecoveryScreen( KeyRecoveryScreen(
activeUserPublicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240", activeUserPublicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240",
activeWalletStateFlow = MutableStateFlow(null), activeIdentityStateFlow = MutableStateFlow(null),
onNavigateBack = {}, onNavigateBack = {},
onNavigateToRoute = {} onNavigateToRoute = {}
) )
@@ -42,7 +42,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.ActiveWallet import press.mantra.compose.identity.Identity
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.backup_confirmation import mantra.composeapp.generated.resources.backup_confirmation
@@ -82,13 +82,13 @@ import press.mantra.compose.ui.view.state.RecoveryPhraseUIState
@Composable @Composable
fun RecoveryPhraseScreen( fun RecoveryPhraseScreen(
phoenixGlobal: PhoenixGlobal, phoenixGlobal: PhoenixGlobal,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
) { ) {
val recoveryPhraseViewModel: RecoveryPhraseViewModel = viewModel( val recoveryPhraseViewModel: RecoveryPhraseViewModel = viewModel(
factory = RecoveryPhraseViewModel.factory( factory = RecoveryPhraseViewModel.factory(
phoenixGlobal = phoenixGlobal, phoenixGlobal = phoenixGlobal,
activeWalletStateFlow = activeWalletStateFlow activeIdentityStateFlow = activeIdentityStateFlow
) )
) )
@@ -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.model.SelectChatRoomTypeViewModel
import press.mantra.compose.ui.view.state.SelectChatRoomTypeUIState import press.mantra.compose.ui.view.state.SelectChatRoomTypeUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import press.mantra.compose.ui.theme.spacing import press.mantra.compose.ui.theme.spacing
@@ -87,7 +87,7 @@ fun SelectChatRoomTypeScreen(
description: String?, description: String?,
memberPublicKeys: List<HexKey>, memberPublicKeys: List<HexKey>,
initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
nostrRepository: NostrRepository, nostrRepository: NostrRepository,
chatRepository: ChatRepository, chatRepository: ChatRepository,
dkgRepository: DkgRepository, dkgRepository: DkgRepository,
@@ -100,7 +100,7 @@ fun SelectChatRoomTypeScreen(
description = description, description = description,
memberPublicKeys = memberPublicKeys, memberPublicKeys = memberPublicKeys,
initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState,
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
nostrRepository = nostrRepository, nostrRepository = nostrRepository,
chatRepository = chatRepository, chatRepository = chatRepository,
dkgRepository = dkgRepository dkgRepository = dkgRepository
@@ -490,7 +490,7 @@ private fun SelectChatRoomTypeScreenPreview() {
) )
) )
), ),
activeWalletStateFlow = MutableStateFlow(null), activeIdentityStateFlow = MutableStateFlow(null),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
@@ -35,7 +35,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.Res
@@ -81,7 +81,7 @@ fun SelectSubgroupAdminsScreen(
activeUserPublicKey: HexKey, activeUserPublicKey: HexKey,
parentChatRoomId: String, parentChatRoomId: String,
initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
chatRepository: ChatRepository, chatRepository: ChatRepository,
dkgRepository: DkgRepository, dkgRepository: DkgRepository,
onNavigateToRoute: (Route) -> Unit onNavigateToRoute: (Route) -> Unit
@@ -91,7 +91,7 @@ fun SelectSubgroupAdminsScreen(
activeUserPublicKey = activeUserPublicKey, activeUserPublicKey = activeUserPublicKey,
parentChatRoomId = parentChatRoomId, parentChatRoomId = parentChatRoomId,
initialUIState = initialUIState, initialUIState = initialUIState,
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
chatRepository = chatRepository, chatRepository = chatRepository,
dkgRepository = dkgRepository dkgRepository = dkgRepository
) )
@@ -331,7 +331,7 @@ private fun SelectSubgroupAdminsScreenPreview() {
SelectSubgroupAdminsScreen( SelectSubgroupAdminsScreen(
activeUserPublicKey = "a".repeat(64), activeUserPublicKey = "a".repeat(64),
parentChatRoomId = "b".repeat(64), parentChatRoomId = "b".repeat(64),
activeWalletStateFlow = kotlinx.coroutines.flow.MutableStateFlow(null), activeIdentityStateFlow = kotlinx.coroutines.flow.MutableStateFlow(null),
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
onNavigateToRoute = {} onNavigateToRoute = {}
@@ -49,8 +49,8 @@ fun SovereignWalletStartupScreen(
// Deliberately no intro gate. `getShowIntro` defaults to true and nothing in this app ever // 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 // 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 // — 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 completion here: everything downstream reads the active identity, and nothing sets one
// to be a step inside this flow, not a detour around it. // 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() val listWalletState by sovereignWalletViewModel.listWalletState.collectAsState()
@@ -84,8 +84,8 @@ fun SovereignWalletStartupScreen(
val desiredWalletIdFlow = sovereignWalletViewModel.desiredWalletId.collectAsState() val desiredWalletIdFlow = sovereignWalletViewModel.desiredWalletId.collectAsState()
val desiredWalletId = desiredWalletIdFlow.value val desiredWalletId = desiredWalletIdFlow.value
val activeWalletFlow = sovereignWalletViewModel.activeWalletInUI.collectAsState() val activeIdentityFlow = sovereignWalletViewModel.activeIdentity.collectAsState()
val activeWallet = activeWalletFlow.value val activeIdentity = activeIdentityFlow.value
when { when {
availableWallets.isEmpty() -> { availableWallets.isEmpty() -> {
@@ -99,12 +99,12 @@ fun SovereignWalletStartupScreen(
text = "Preparing wallet..." text = "Preparing wallet..."
) )
} }
activeWallet != null -> { activeIdentity != null -> {
LoadingDataIndicator( LoadingDataIndicator(
text = "Opening wallet" text = "Opening wallet"
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
sovereignWalletViewModel.loadSovereignData(activeWallet.id) sovereignWalletViewModel.loadSovereignData(activeIdentity.id)
onSuccessfulStartup.invoke() onSuccessfulStartup.invoke()
} }
} }
@@ -230,7 +230,7 @@ fun MantraNavHost(
val navigationViewModel: NavigationViewModel = viewModel ( val navigationViewModel: NavigationViewModel = viewModel (
factory = NavigationViewModel.factory( factory = NavigationViewModel.factory(
activeWallet = sovereignWalletViewModel.activeWalletInUI, activeIdentity = sovereignWalletViewModel.activeIdentity,
initialNavigationUIState = NavigationUIState.Loading(), initialNavigationUIState = NavigationUIState.Loading(),
nostrRepository = databaseNostrRepository, nostrRepository = databaseNostrRepository,
scope = applicationIOScope scope = applicationIOScope
@@ -239,7 +239,7 @@ fun MantraNavHost(
val notaryViewModel: NotaryViewModel = viewModel( val notaryViewModel: NotaryViewModel = viewModel(
factory = NotaryViewModel.factory( factory = NotaryViewModel.factory(
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
nostrRepository = databaseNostrRepository, nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository, chatRepository = databaseChatRepository,
marmotRepository = databaseMarmotRepository, marmotRepository = databaseMarmotRepository,
@@ -250,7 +250,7 @@ fun MantraNavHost(
// TODO: Produce a notary UI Element... // TODO: Produce a notary UI Element...
val synchronizationViewModel: SynchronizationViewModel = viewModel( val synchronizationViewModel: SynchronizationViewModel = viewModel(
factory = SynchronizationViewModel.factory( factory = SynchronizationViewModel.factory(
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
nostrRepository = databaseNostrRepository, nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository, chatRepository = databaseChatRepository,
relayRepository = databaseNostrRepository, relayRepository = databaseNostrRepository,
@@ -548,7 +548,7 @@ fun MantraNavHost(
SelectSubgroupAdminsScreen( SelectSubgroupAdminsScreen(
activeUserPublicKey = route.activeUserPublicKey, activeUserPublicKey = route.activeUserPublicKey,
parentChatRoomId = route.parentChatRoomId, parentChatRoomId = route.parentChatRoomId,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
chatRepository = databaseChatRepository, chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository, dkgRepository = databaseDkgRepository,
onNavigateToRoute = { nextRoute -> onNavigateToRoute = { nextRoute ->
@@ -563,7 +563,7 @@ fun MantraNavHost(
activeUserPublicKey = route.activeUserPublicKey, activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId, chatRoomId = route.chatRoomId,
dkgSessionId = route.dkgSessionId, dkgSessionId = route.dkgSessionId,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
chatRepository = databaseChatRepository, chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository, dkgRepository = databaseDkgRepository,
onNavigateToRoute = { approvalRoute -> onNavigateToRoute = { approvalRoute ->
@@ -578,7 +578,7 @@ fun MantraNavHost(
activeUserPublicKey = route.activeUserPublicKey, activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId, chatRoomId = route.chatRoomId,
dkgSessionId = route.dkgSessionId, dkgSessionId = route.dkgSessionId,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
chatRepository = databaseChatRepository, chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository, dkgRepository = databaseDkgRepository,
onDone = { navController.popBackStack() } onDone = { navController.popBackStack() }
@@ -591,7 +591,7 @@ fun MantraNavHost(
activeUserPublicKey = route.activeUserPublicKey, activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId, chatRoomId = route.chatRoomId,
dkgSessionId = route.dkgSessionId, dkgSessionId = route.dkgSessionId,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
chatRepository = databaseChatRepository, chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository, dkgRepository = databaseDkgRepository,
onDone = { navController.popBackStack() } onDone = { navController.popBackStack() }
@@ -604,7 +604,7 @@ fun MantraNavHost(
activeUserPublicKey = route.activeUserPublicKey, activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId, chatRoomId = route.chatRoomId,
dkgSessionId = route.dkgSessionId, dkgSessionId = route.dkgSessionId,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
chatRepository = databaseChatRepository, chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository, dkgRepository = databaseDkgRepository,
onDone = { navController.popBackStack() } onDone = { navController.popBackStack() }
@@ -618,7 +618,7 @@ fun MantraNavHost(
name = route.name, name = route.name,
description = route.description, description = route.description,
memberPublicKeys = route.memberPublicKeys, memberPublicKeys = route.memberPublicKeys,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
nostrRepository = databaseNostrRepository, nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository, chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository, dkgRepository = databaseDkgRepository,
@@ -815,7 +815,7 @@ fun MantraNavHost(
KeyRecoveryScreen( KeyRecoveryScreen(
activeUserPublicKey = route.activeUserPublicKey, activeUserPublicKey = route.activeUserPublicKey,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
onNavigateBack = { onNavigateBack = {
navController.popBackStack() navController.popBackStack()
}, },
@@ -829,7 +829,7 @@ fun MantraNavHost(
composable<RecoveryPhraseRoute> { composable<RecoveryPhraseRoute> {
RecoveryPhraseScreen( RecoveryPhraseScreen(
phoenixGlobal = phoenixGlobal, phoenixGlobal = phoenixGlobal,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
onNavigateBack = { onNavigateBack = {
navController.popBackStack() navController.popBackStack()
} }
@@ -32,8 +32,7 @@ import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.Kind
import fr.acinq.phoenix.data.ActiveWallet import press.mantra.compose.identity.Identity
import fr.acinq.phoenix.managers.nostrPrivateKey
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -56,7 +55,7 @@ class DkgRitualViewModel(
*/ */
val dkgSessionId: String? = null, val dkgSessionId: String? = null,
initialDkgRitualUIState: DkgRitualUIState, initialDkgRitualUIState: DkgRitualUIState,
val activeWalletStateFlow: StateFlow<ActiveWallet?>, val activeIdentityStateFlow: StateFlow<Identity?>,
val chatRepository: ChatRepository, val chatRepository: ChatRepository,
val dkgRepository: DkgRepository, val dkgRepository: DkgRepository,
): ViewModel() { ): ViewModel() {
@@ -473,7 +472,7 @@ class DkgRitualViewModel(
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
if (nostrPrivateKey == null) { if (nostrPrivateKey == null) {
dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
return return
@@ -513,7 +512,7 @@ class DkgRitualViewModel(
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
val session = loaded.session ?: return val session = loaded.session ?: return
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
if (nostrPrivateKey == null) { if (nostrPrivateKey == null) {
dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
return return
@@ -567,7 +566,7 @@ class DkgRitualViewModel(
// guard rather than a path anybody takes. // guard rather than a path anybody takes.
if (!loaded.isKeyStateSigned) return if (!loaded.isKeyStateSigned) return
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
if (nostrPrivateKey == null) { if (nostrPrivateKey == null) {
dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
return return
@@ -678,7 +677,7 @@ class DkgRitualViewModel(
activeUserPublicKey: HexKey, activeUserPublicKey: HexKey,
dkgSessionId: String? = null, dkgSessionId: String? = null,
initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading, initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
chatRepository: ChatRepository, chatRepository: ChatRepository,
dkgRepository: DkgRepository dkgRepository: DkgRepository
): ViewModelProvider.Factory = viewModelFactory { ): ViewModelProvider.Factory = viewModelFactory {
@@ -688,7 +687,7 @@ class DkgRitualViewModel(
activeUserPublicKey = activeUserPublicKey, activeUserPublicKey = activeUserPublicKey,
dkgSessionId = dkgSessionId, dkgSessionId = dkgSessionId,
initialDkgRitualUIState = initialDkgRitualUIState, initialDkgRitualUIState = initialDkgRitualUIState,
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
chatRepository = chatRepository, chatRepository = chatRepository,
dkgRepository = dkgRepository dkgRepository = dkgRepository
) )
@@ -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.composable.navigation.routes.SovereignWalletStartupRoute
import press.mantra.compose.ui.view.state.NavigationUIState import press.mantra.compose.ui.view.state.NavigationUIState
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import fr.acinq.phoenix.data.ActiveWallet import press.mantra.compose.identity.Identity
import fr.acinq.phoenix.managers.nostrPublicKey
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
@@ -25,7 +24,7 @@ import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class NavigationViewModel( class NavigationViewModel(
val activeWalletStateFlow: StateFlow<ActiveWallet?>, val activeIdentityStateFlow: StateFlow<Identity?>,
initialNavigationUIState: NavigationUIState, initialNavigationUIState: NavigationUIState,
val nostrRepository: press.mantra.compose.repository.NostrRepository, val nostrRepository: press.mantra.compose.repository.NostrRepository,
val scope: CoroutineScope, val scope: CoroutineScope,
@@ -38,14 +37,14 @@ class NavigationViewModel(
private val PENDING_BROADCAST_STATUSES = setOf("pending", "processing") private val PENDING_BROADCAST_STATUSES = setOf("pending", "processing")
fun factory( fun factory(
activeWallet: StateFlow<ActiveWallet?>, activeIdentity: StateFlow<Identity?>,
initialNavigationUIState: NavigationUIState, initialNavigationUIState: NavigationUIState,
nostrRepository: press.mantra.compose.repository.NostrRepository, nostrRepository: press.mantra.compose.repository.NostrRepository,
scope: CoroutineScope, scope: CoroutineScope,
): ViewModelProvider.Factory = viewModelFactory { ): ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
NavigationViewModel( NavigationViewModel(
activeWallet, activeIdentity,
initialNavigationUIState = initialNavigationUIState, initialNavigationUIState = initialNavigationUIState,
nostrRepository = nostrRepository, nostrRepository = nostrRepository,
scope = scope scope = scope
@@ -176,34 +175,22 @@ class NavigationViewModel(
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
delay(2_100.milliseconds) // Looking busy... delay(2_100.milliseconds) // Looking busy...
activeWalletStateFlow.collectLatest { activeWallet -> activeIdentityStateFlow.collectLatest { identity ->
logger.d("Active Wallet: $activeWallet") logger.d("Active identity: $identity")
if (activeWallet == null) { if (identity == null) {
// Nothing has been started: go and start something.
_navigationUIState.getAndUpdate { _navigationUIState.getAndUpdate {
NavigationUIState.StartupPhoenix NavigationUIState.StartupPhoenix
} }
} else { } else {
activeWallet.business.let { business -> // The identity carries its own key, so there is no node to wait on and no
if (business == null) { // "couldn't get the nostrKey" dead end. The branch that used to send a
_navigationUIState.getAndUpdate { // nodeless identity back to startup went with it: an identity without a
NavigationUIState.StartupPhoenix // node is now a kind of identity, not a wallet that has not finished
} // starting.
} else { logger.i("Observing: ${identity.nostrPublicKey}")
val activeUserPublicKey = business.walletManager.keyManager.value?.nostrPublicKey()
if (activeUserPublicKey == null) { loadNostrProfile(identity.nostrPublicKey)
_navigationUIState.getAndUpdate {
NavigationUIState.Loading(text = "Couldn't get the nostrKey")
}
} else {
logger.i("Observing: $activeUserPublicKey")
loadNostrProfile(activeUserPublicKey)
}
}
logger.i("Navigation UI State is Landing")
}
} }
} }
} }
@@ -11,19 +11,15 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import fr.acinq.phoenix.data.ActiveWallet import press.mantra.compose.identity.Identity
import fr.acinq.phoenix.managers.nostrPrivateKey
import fr.acinq.phoenix.managers.nsecPassword import fr.acinq.phoenix.managers.nsecPassword
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.NostrEvent
@@ -31,7 +27,7 @@ import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant import kotlin.time.Instant
class NotaryViewModel( class NotaryViewModel(
val activeWalletStateFlow: StateFlow<ActiveWallet?>, val activeIdentityStateFlow: StateFlow<Identity?>,
val nostrRepository: press.mantra.compose.repository.NostrRepository, val nostrRepository: press.mantra.compose.repository.NostrRepository,
val chatRepository: press.mantra.compose.repository.ChatRepository, val chatRepository: press.mantra.compose.repository.ChatRepository,
val marmotRepository: MarmotRepository, val marmotRepository: MarmotRepository,
@@ -43,7 +39,7 @@ class NotaryViewModel(
fun factory( fun factory(
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
nostrRepository: press.mantra.compose.repository.NostrRepository, nostrRepository: press.mantra.compose.repository.NostrRepository,
chatRepository: press.mantra.compose.repository.ChatRepository, chatRepository: press.mantra.compose.repository.ChatRepository,
marmotRepository: MarmotRepository, marmotRepository: MarmotRepository,
@@ -51,7 +47,7 @@ class NotaryViewModel(
): ViewModelProvider.Factory = viewModelFactory { ): ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
NotaryViewModel( NotaryViewModel(
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
nostrRepository = nostrRepository, nostrRepository = nostrRepository,
chatRepository = chatRepository, chatRepository = chatRepository,
marmotRepository = marmotRepository, marmotRepository = marmotRepository,
@@ -63,14 +59,14 @@ class NotaryViewModel(
private val logger = Logger.withTag(TAG) private val logger = Logger.withTag(TAG)
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeActiveNostrKey() { private fun observeActiveNostrKey() {
scope.launch { scope.launch {
activeWalletStateFlow // One flow, not a `flatMapLatest` into the node's key manager: the identity
.flatMapLatest { activeWallet -> // carries its key from the moment it exists, so there is nothing to wait for.
activeWallet?.business?.walletManager?.keyManager ?: flowOf(null) // `distinctUntilChanged` on the key stays -- an identity re-emitted with the same
} // key must not tear down and restart the observers below.
.map { keyManager -> keyManager?.nostrPrivateKey() } activeIdentityStateFlow
.map { identity -> identity?.nostrPrivateKey }
.distinctUntilChanged() .distinctUntilChanged()
.collectLatest { nostrPrivateKey -> .collectLatest { nostrPrivateKey ->
if (nostrPrivateKey == null) return@collectLatest if (nostrPrivateKey == null) return@collectLatest
@@ -7,7 +7,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.ActiveWallet import press.mantra.compose.identity.Identity
import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.DecryptSeedResult
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -39,7 +39,7 @@ import press.mantra.compose.ui.view.state.RecoveryPhraseUIState
*/ */
class RecoveryPhraseViewModel( class RecoveryPhraseViewModel(
val phoenixGlobal: PhoenixGlobal, val phoenixGlobal: PhoenixGlobal,
val activeWalletStateFlow: StateFlow<ActiveWallet?>, val activeIdentityStateFlow: StateFlow<Identity?>,
): ViewModel() { ): ViewModel() {
private val logger = Logger.withTag(TAG) private val logger = Logger.withTag(TAG)
@@ -60,18 +60,18 @@ class RecoveryPhraseViewModel(
* instead of drawing an unticked box over a ticked preference. * instead of drawing an unticked box over a ticked preference.
*/ */
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val isBackupDone: StateFlow<Boolean?> = activeWalletStateFlow val isBackupDone: StateFlow<Boolean?> = activeIdentityStateFlow
.flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) } .flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val isDisclaimerRead: StateFlow<Boolean?> = activeWalletStateFlow val isDisclaimerRead: StateFlow<Boolean?> = activeIdentityStateFlow
.flatMapLatest { it?.internalPrefs?.isSeedLossDisclaimerRead ?: flowOf(null) } .flatMapLatest { it?.internalPrefs?.isSeedLossDisclaimerRead ?: flowOf(null) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
/** True until the user has both written the phrase down and read the disclaimer. */ /** True until the user has both written the phrase down and read the disclaimer. */
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val showBackupNotice: StateFlow<Boolean> = activeWalletStateFlow val showBackupNotice: StateFlow<Boolean> = activeIdentityStateFlow
.flatMapLatest { it?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) } .flatMapLatest { it?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
@@ -86,7 +86,7 @@ class RecoveryPhraseViewModel(
fun revealRecoveryPhrase() { fun revealRecoveryPhrase() {
if (_recoveryPhraseUIState.value is RecoveryPhraseUIState.Revealing) return if (_recoveryPhraseUIState.value is RecoveryPhraseUIState.Revealing) return
val walletId = activeWalletStateFlow.value?.id val walletId = activeIdentityStateFlow.value?.id
if (walletId == null) { if (walletId == null) {
logger.e { "no active wallet, cannot read a recovery phrase" } logger.e { "no active wallet, cannot read a recovery phrase" }
_recoveryPhraseUIState.value = RecoveryPhraseUIState.Error.NoActiveWallet _recoveryPhraseUIState.value = RecoveryPhraseUIState.Error.NoActiveWallet
@@ -128,7 +128,7 @@ class RecoveryPhraseViewModel(
} }
fun setBackupDone(isDone: Boolean) { fun setBackupDone(isDone: Boolean) {
val internalPrefs = activeWalletStateFlow.value?.internalPrefs ?: return val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return
viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
logger.e("could not save the backup flag", throwable) logger.e("could not save the backup flag", throwable)
}) { }) {
@@ -137,7 +137,7 @@ class RecoveryPhraseViewModel(
} }
fun setDisclaimerRead(isRead: Boolean) { fun setDisclaimerRead(isRead: Boolean) {
val internalPrefs = activeWalletStateFlow.value?.internalPrefs ?: return val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return
viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
logger.e("could not save the disclaimer flag", throwable) logger.e("could not save the disclaimer flag", throwable)
}) { }) {
@@ -150,12 +150,12 @@ class RecoveryPhraseViewModel(
fun factory( fun factory(
phoenixGlobal: PhoenixGlobal, phoenixGlobal: PhoenixGlobal,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
): ViewModelProvider.Factory = viewModelFactory { ): ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
RecoveryPhraseViewModel( RecoveryPhraseViewModel(
phoenixGlobal = phoenixGlobal, phoenixGlobal = phoenixGlobal,
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
) )
} }
} }
@@ -30,8 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
import fr.acinq.phoenix.data.ActiveWallet import press.mantra.compose.identity.Identity
import fr.acinq.phoenix.managers.nostrPrivateKey
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -58,7 +57,7 @@ class SelectChatRoomTypeViewModel(
val description: String?, val description: String?,
val memberPublicKeys: List<HexKey>, val memberPublicKeys: List<HexKey>,
initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState, initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState,
val activeWalletStateFlow: StateFlow<ActiveWallet?>, val activeIdentityStateFlow: StateFlow<Identity?>,
val nostrRepository: NostrRepository, val nostrRepository: NostrRepository,
val chatRepository: ChatRepository, val chatRepository: ChatRepository,
val dkgRepository: DkgRepository, val dkgRepository: DkgRepository,
@@ -190,7 +189,7 @@ class SelectChatRoomTypeViewModel(
return return
} }
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
if (nostrPrivateKey == null) { if (nostrPrivateKey == null) {
selectChatRoomTypeUIState = SelectChatRoomTypeUIState.Error( selectChatRoomTypeUIState = SelectChatRoomTypeUIState.Error(
"Couldn't read your keys. Please try again." "Couldn't read your keys. Please try again."
@@ -447,7 +446,7 @@ class SelectChatRoomTypeViewModel(
description: String?, description: String?,
memberPublicKeys: List<HexKey>, memberPublicKeys: List<HexKey>,
initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
nostrRepository: NostrRepository, nostrRepository: NostrRepository,
chatRepository: ChatRepository, chatRepository: ChatRepository,
dkgRepository: DkgRepository dkgRepository: DkgRepository
@@ -459,7 +458,7 @@ class SelectChatRoomTypeViewModel(
description = description, description = description,
memberPublicKeys = memberPublicKeys, memberPublicKeys = memberPublicKeys,
initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState,
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
nostrRepository = nostrRepository, nostrRepository = nostrRepository,
chatRepository = chatRepository, chatRepository = chatRepository,
dkgRepository = dkgRepository dkgRepository = dkgRepository
@@ -12,8 +12,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet import press.mantra.compose.identity.Identity
import fr.acinq.phoenix.managers.nostrPrivateKey
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -51,7 +50,7 @@ class SelectSubgroupAdminsViewModel(
val activeUserPublicKey: HexKey, val activeUserPublicKey: HexKey,
val parentChatRoomId: String, val parentChatRoomId: String,
initialUIState: SelectSubgroupAdminsUIState, initialUIState: SelectSubgroupAdminsUIState,
val activeWalletStateFlow: StateFlow<ActiveWallet?>, val activeIdentityStateFlow: StateFlow<Identity?>,
val chatRepository: ChatRepository, val chatRepository: ChatRepository,
val dkgRepository: DkgRepository, val dkgRepository: DkgRepository,
): ViewModel() { ): ViewModel() {
@@ -204,8 +203,7 @@ class SelectSubgroupAdminsViewModel(
val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return
val nostrPrivateKey = activeWalletStateFlow.value val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
if (nostrPrivateKey == null) { if (nostrPrivateKey == null) {
uiState = SelectSubgroupAdminsUIState.Error("Couldn't read your keys. Please try again.") uiState = SelectSubgroupAdminsUIState.Error("Couldn't read your keys. Please try again.")
return return
@@ -293,7 +291,7 @@ class SelectSubgroupAdminsViewModel(
activeUserPublicKey: HexKey, activeUserPublicKey: HexKey,
parentChatRoomId: String, parentChatRoomId: String,
initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
chatRepository: ChatRepository, chatRepository: ChatRepository,
dkgRepository: DkgRepository dkgRepository: DkgRepository
): ViewModelProvider.Factory = viewModelFactory { ): ViewModelProvider.Factory = viewModelFactory {
@@ -302,7 +300,7 @@ class SelectSubgroupAdminsViewModel(
activeUserPublicKey = activeUserPublicKey, activeUserPublicKey = activeUserPublicKey,
parentChatRoomId = parentChatRoomId, parentChatRoomId = parentChatRoomId,
initialUIState = initialUIState, initialUIState = initialUIState,
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
chatRepository = chatRepository, chatRepository = chatRepository,
dkgRepository = dkgRepository dkgRepository = dkgRepository
) )
@@ -12,13 +12,13 @@ import co.touchlab.kermit.Logger
import fr.acinq.lightning.logging.error import fr.acinq.lightning.logging.error
import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.ActiveWallet
import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.DecryptSeedResult
import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.ElectrumConfig
import fr.acinq.phoenix.data.ListWalletState import fr.acinq.phoenix.data.ListWalletState
import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.UserWallet
import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.managers.DataStoreManager 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.GlobalPrefs
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
@@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.IdentityKind
sealed class WritingSeedState { sealed class WritingSeedState {
data object Init : WritingSeedState() data object Init : WritingSeedState()
@@ -83,8 +85,17 @@ class SovereignWalletViewModel(
val desiredWalletId = _desiredWalletId.asStateFlow() val desiredWalletId = _desiredWalletId.asStateFlow()
val startWalletImmediately = MutableStateFlow(true) val startWalletImmediately = MutableStateFlow(true)
private val _activeWalletInUI = MutableStateFlow<ActiveWallet?>(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<Identity?>(null)
val activeIdentity = _activeIdentity.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val exchangeRates = phoenixGlobal.currencyManager.ratesFlow val exchangeRates = phoenixGlobal.currencyManager.ratesFlow
@@ -115,14 +126,38 @@ class SovereignWalletViewModel(
listAvailableWallets(onDone = {}) 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) { 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 dataStoreManager = DataStoreManager(business)
val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = walletId) setActiveIdentity(
val internalPrefs = dataStoreManager.loadInternalPrefsForWallet( walletId = walletId) Identity(
_activeWalletInUI.value = ActiveWallet(id = walletId, business = business, userPrefs = userPrefs, internalPrefs = internalPrefs) id = walletId,
kind = IdentityKind.Mnemonic,
nostrPrivateKey = keyManager.nostrPrivateKey(),
userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = walletId),
internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId = walletId),
business = business,
)
)
updateBusinessActiveInUI(walletId) updateBusinessActiveInUI(walletId)
// scheduleAutoLock()
} }
fun listAvailableWallets(onDone: () -> Unit) { fun listAvailableWallets(onDone: () -> Unit) {
@@ -186,7 +221,7 @@ class SovereignWalletViewModel(
// // TODO: Run this through an expect... // // TODO: Run this through an expect...
// viewModelScope.launch { // viewModelScope.launch {
// autoLockHandler.removeCallbacksAndMessages(null) // autoLockHandler.removeCallbacksAndMessages(null)
// val activeUserPrefs = activeWalletInUI.first()?.userPrefs ?: return@launch // val activeUserPrefs = activeIdentity.first()?.userPrefs ?: return@launch
// //
// val biometricLockEnabled = activeUserPrefs.getLockBiometricsEnabled.first() // val biometricLockEnabled = activeUserPrefs.getLockBiometricsEnabled.first()
// val customPinLockEnabled = activeUserPrefs.getLockPinEnabled.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) { fun switchToWallet(walletId: WalletId) {
_desiredWalletId.value = 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() { 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() { fun resetToSelector() {
_desiredWalletId.value = null _desiredWalletId.value = null
_activeWalletInUI.value = null _activeIdentity.value = null
startWalletImmediately.value = false startWalletImmediately.value = false
} }
@@ -29,8 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
import fr.acinq.phoenix.data.ActiveWallet import press.mantra.compose.identity.Identity
import fr.acinq.phoenix.managers.nostrPrivateKey
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -59,7 +58,7 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class SynchronizationViewModel( class SynchronizationViewModel(
val activeWalletStateFlow: StateFlow<ActiveWallet?>, val activeIdentityStateFlow: StateFlow<Identity?>,
val nostrRepository: NostrRepository, val nostrRepository: NostrRepository,
val chatRepository: ChatRepository, val chatRepository: ChatRepository,
val relayRepository: RelayRepository, val relayRepository: RelayRepository,
@@ -67,7 +66,7 @@ class SynchronizationViewModel(
): ViewModel() { ): ViewModel() {
val relaysSocketManager = RelaysSocketManager( val relaysSocketManager = RelaysSocketManager(
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
nostrSocketClientFactory = DefaultNostrSocketClientFactory, nostrSocketClientFactory = DefaultNostrSocketClientFactory,
cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY,
relayRepository = relayRepository, relayRepository = relayRepository,
@@ -130,7 +129,7 @@ class SynchronizationViewModel(
private val mutex = Mutex() private val mutex = Mutex()
fun factory( fun factory(
activeWalletStateFlow: StateFlow<ActiveWallet?>, activeIdentityStateFlow: StateFlow<Identity?>,
nostrRepository: NostrRepository, nostrRepository: NostrRepository,
chatRepository: ChatRepository, chatRepository: ChatRepository,
relayRepository: RelayRepository, relayRepository: RelayRepository,
@@ -138,7 +137,7 @@ class SynchronizationViewModel(
): ViewModelProvider.Factory = viewModelFactory { ): ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
SynchronizationViewModel( SynchronizationViewModel(
activeWalletStateFlow = activeWalletStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
nostrRepository = nostrRepository, nostrRepository = nostrRepository,
chatRepository = chatRepository, chatRepository = chatRepository,
relayRepository = relayRepository, relayRepository = relayRepository,
@@ -188,9 +187,9 @@ class SynchronizationViewModel(
init { init {
scope.launch { scope.launch {
activeWalletStateFlow.collectLatest { activeWallet -> activeIdentityStateFlow.collectLatest { identity ->
logger.d("Synchronization ActiveWallet: $activeWallet") logger.d("Synchronization identity: $identity")
activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey -> identity?.nostrPrivateKey?.let { nostrPrivateKey ->
val keyPair = KeyPair( val keyPair = KeyPair(
privKey = nostrPrivateKey.value.toByteArray() privKey = nostrPrivateKey.value.toByteArray()
) )
@@ -1,7 +1,7 @@
package press.mantra.compose.ui.view.model package press.mantra.compose.ui.view.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@@ -102,7 +102,7 @@ class NavigationRoutingTest {
private fun bootedOn(vararg accounts: LocalAccount): NavigationViewModel { private fun bootedOn(vararg accounts: LocalAccount): NavigationViewModel {
val scope = CoroutineScope(Job()) val scope = CoroutineScope(Job())
return NavigationViewModel( return NavigationViewModel(
activeWalletStateFlow = MutableStateFlow<ActiveWallet?>(null), activeIdentityStateFlow = MutableStateFlow<Identity?>(null),
initialNavigationUIState = stranded, initialNavigationUIState = stranded,
nostrRepository = Device(accounts.toList()), nostrRepository = Device(accounts.toList()),
scope = scope, scope = scope,
@@ -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 * 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 * 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 * [drain] below is shaped like it but is this test's own, so it pins the queue rather than the
* collector. * collector.
*/ */
@@ -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<LocalAccount>
) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
override suspend fun getLocalAccounts(): List<LocalAccount> = accounts
override suspend fun observeLocalAccount(publicKey: HexKey): Flow<LocalAccount?> =
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?>(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()
}
}
}