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.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<ActiveWallet?>,
private val activeIdentityStateFlow: StateFlow<Identity?>,
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 =
@@ -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<Identity?>,
) {
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 {
@@ -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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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
)
@@ -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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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
@@ -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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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
)
@@ -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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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
@@ -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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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
@@ -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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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 = {}
)
@@ -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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
onNavigateBack: () -> Unit,
) {
val recoveryPhraseViewModel: RecoveryPhraseViewModel = viewModel(
factory = RecoveryPhraseViewModel.factory(
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.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<HexKey>,
initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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,
@@ -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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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 = {}
@@ -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()
}
}
@@ -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<RecoveryPhraseRoute> {
RecoveryPhraseScreen(
phoenixGlobal = phoenixGlobal,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
onNavigateBack = {
navController.popBackStack()
}
@@ -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<ActiveWallet?>,
val activeIdentityStateFlow: StateFlow<Identity?>,
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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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
)
@@ -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<ActiveWallet?>,
val activeIdentityStateFlow: StateFlow<Identity?>,
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<ActiveWallet?>,
activeIdentity: StateFlow<Identity?>,
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)
}
}
}
@@ -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<ActiveWallet?>,
val activeIdentityStateFlow: StateFlow<Identity?>,
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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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
@@ -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<ActiveWallet?>,
val activeIdentityStateFlow: StateFlow<Identity?>,
): 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<Boolean?> = activeWalletStateFlow
val isBackupDone: StateFlow<Boolean?> = activeIdentityStateFlow
.flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@OptIn(ExperimentalCoroutinesApi::class)
val isDisclaimerRead: StateFlow<Boolean?> = activeWalletStateFlow
val isDisclaimerRead: StateFlow<Boolean?> = 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<Boolean> = activeWalletStateFlow
val showBackupNotice: StateFlow<Boolean> = 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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
RecoveryPhraseViewModel(
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.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<HexKey>,
initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState,
val activeWalletStateFlow: StateFlow<ActiveWallet?>,
val activeIdentityStateFlow: StateFlow<Identity?>,
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<HexKey>,
initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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
@@ -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<ActiveWallet?>,
val activeIdentityStateFlow: StateFlow<Identity?>,
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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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
)
@@ -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<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)
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
}
@@ -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<ActiveWallet?>,
val activeIdentityStateFlow: StateFlow<Identity?>,
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<ActiveWallet?>,
activeIdentityStateFlow: StateFlow<Identity?>,
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()
)