feat(identity): list and start identities from both stores, and fix the other entrance
Phase 3 of docs/nsec-sign-in.md, and the submodule bump that brings Phase 2's
NostrKeyManager in (lightning-kmp-app 01489b8 -> 59c11ed, branch
claude/nostr-key-store). Nothing can create an nsec identity yet -- that is
Phase 4 -- but from here one that exists is listed, selected, started and
routed like any wallet.
StoredIdentity is what the startup screen now reads: a sealed type over
Mnemonic(userWallet) and NostrSecret(id, privateKey), both carrying the nostr
public key, because that is the one thing the two kinds share and the one
thing a duplicate check has to compare. StoredIdentity.merge folds seed.dat
and nostr-keys.dat into one map keyed by WalletId; a wallet's pubkey is one
more LocalKeyManager over words that SeedManager has already derived once.
SovereignWalletViewModel.listAvailableWallets becomes listIdentities. It
reads both files and surfaces a failure in either rather than skipping it: a
corrupt nostr-keys.dat would otherwise drop every imported identity from the
list without a word, and the seed file has always been handled this way.
Metadata registration is unchanged -- it keys on WalletId and does not care
what is behind one.
SovereignWalletStartupScreen picks a StoredIdentity, and LoadWallet, the
screen-lock gate, takes the identity rather than the wallet: it only ever read
the id, to look up the lock preferences, and it now sits outside the branch on
kind so a future lock is not something to remember to add twice. The Mnemonic
branch is the old path -- startupNode, setActiveWallet. The NostrSecret branch
builds the Identity directly with business = null and sets it; no
platformStartupLogic, no schedulePlatformLogic, no StartupViewState. Setting
it recomposes into the existing `activeIdentity != null` branch, which is what
reports the startup, so the nsec path does not double-report as the mnemonic
path does. WalletsSelector shows the npub on the second line for both kinds;
it used to show the node id, which a bare key does not have.
The other entrance. NavigationViewModel.loadNostrProfile(startupRoute) read
getLocalAccounts().firstOrNull()?.profile?.publicKey
which is wrong twice for this plan. It takes the first kind-0 account on the
device whichever identity is active -- harmless with one wallet, wrong half
the time with two, and an imported key is how a device gets its second. And
it keys off the Profile row, which only the notary's *signing* writes, so a
placeholder account planted by signInToProfile (or a profile created moments
ago and not yet signed) read as null and was answered with Landing, while
observeProfile, reading the same rows, answered the sync screen: two writers
to one state in whichever order the coroutines ran, and Landing pops the back
stack. It now selects the account by the active identity's pubkey, matched on
the unsigned event's pubKey, and passes it straight to processLocalAccount
rather than fetching it a second time.
The seed writer, hoisted. platformWriteSeed was an expect with three
byte-identical actuals -- android, jvm, ios -- using nothing but commonMain.
It is one function now, IdentityWriter.writeMnemonic, and the second writer,
IdentityWriter.writeNostrKey, sits next to it rather than being triplicated
in turn. Both refuse a duplicate by nostr public key as well as by id: a
wallet and an imported key can be the same npub under different ids, and the
database is keyed by pubkey. The three platform files lose the copy and the
imports that only it used; the jvm file's header, which described the copy,
now describes what is left.
Tests. NavigationRoutingTest gains the placeholder fixture -- kind 0 at
GENESIS_AT, no Profile, no sync request -- and asserts it routes to
UnqueuedProfileSynchronization and never Landing. The jvm routing test gains
a device with two accounts and asserts the startup entrance reads the active
identity's. StoredIdentityJvmTest pins the merge against NIP-06's own vector:
the words "leader monkey parrot ..." derive 17162c92...cd917, the same key an
nsec import of that secret produces, which is the whole basis of the
duplicate check; that a wallet and its own nostr key as a bare secret land
under different ids (why the writers dedupe by pubkey); and that a key filed
under a pubkey it does not derive is dropped.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (870
tests) and :composeApp:m3Audit. The submodule commit is local to the branch
named above and has to be pushed with this one.
Replayed onto Mantra by docs/curated-to-mantra.md: gitlink -> 59c11ed9, the pin this commit compiles against (git had fast-forwarded it to the newer one).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@366b017787
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
package press.mantra.compose.identity
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import fr.acinq.bitcoin.MnemonicCode
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.lightning.crypto.LocalKeyManager
|
||||
import fr.acinq.lightning.utils.toByteVector
|
||||
import fr.acinq.phoenix.PhoenixGlobal
|
||||
import fr.acinq.phoenix.data.ElectrumConfig
|
||||
import fr.acinq.phoenix.data.WalletId
|
||||
import fr.acinq.phoenix.managers.DataStoreManager
|
||||
import fr.acinq.phoenix.managers.NodeParamsManager
|
||||
import fr.acinq.phoenix.managers.NostrKeyManager
|
||||
import fr.acinq.phoenix.managers.SeedManager
|
||||
import fr.acinq.phoenix.managers.nostrPrivateKey
|
||||
import fr.acinq.phoenix.managers.nostrPublicKeyHex
|
||||
import fr.acinq.phoenix.security.EncryptedNostrKeys
|
||||
import fr.acinq.phoenix.security.EncryptedSeed
|
||||
import fr.acinq.phoenix.utils.preferences.GlobalPrefs
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.AppVersion
|
||||
import press.mantra.compose.ui.view.model.WritingSeedState
|
||||
|
||||
/**
|
||||
* Puts a new identity's secret on disk, one function per kind, and prepares the
|
||||
* per-identity preferences both kinds read.
|
||||
*
|
||||
* [writeMnemonic] was an `expect` with three byte-identical actuals -- android, jvm, ios --
|
||||
* using nothing but commonMain: SeedManager, EncryptedSeed, LocalKeyManager,
|
||||
* DataStoreManager, AppVersion. It was an expect because something once needed to
|
||||
* differ and nothing does now, so it is one function here, and the second writer sits
|
||||
* next to it rather than being triplicated in turn.
|
||||
*
|
||||
* Both refuse a duplicate **by nostr public key**, not only by id. A wallet and an
|
||||
* imported key can be the same npub under different ids -- one is hash160 of the node
|
||||
* key, the other hash160 of the nostr key -- and the database is keyed by pubkey, so two
|
||||
* identities for one pubkey would share every row and disagree about which is active.
|
||||
*/
|
||||
object IdentityWriter {
|
||||
|
||||
/** The nostr public keys of every identity already on this device, or null if a store could not be read. */
|
||||
private fun knownNostrPublicKeys(phoenixGlobal: PhoenixGlobal): Set<HexKey>? {
|
||||
val wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: return null
|
||||
val nostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) ?: return null
|
||||
return StoredIdentity.merge(wallets, nostrKeys).values.map { it.nostrPublicKey }.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the preference files a new id needs and records the choices made before
|
||||
* the id was known. Tor and a custom Electrum server mean nothing to an identity
|
||||
* without a wallet; they are saved anyway, because `loadUserPrefsForWallet` is what
|
||||
* creates the file and the recovery screens read it.
|
||||
*/
|
||||
private suspend fun prepareIdentity(
|
||||
phoenixGlobal: PhoenixGlobal,
|
||||
globalPrefs: GlobalPrefs,
|
||||
id: WalletId,
|
||||
isTorEnabled: Boolean,
|
||||
customElectrumServer: ElectrumConfig.Custom?,
|
||||
) {
|
||||
globalPrefs.saveLastUsedAppCode(AppVersion.versionCode)
|
||||
val dataStoreManager = DataStoreManager(
|
||||
phoenixGlobal.ctx,
|
||||
chain = NodeParamsManager.chain,
|
||||
)
|
||||
val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = id)
|
||||
userPrefs.saveIsTorEnabled(isTorEnabled)
|
||||
userPrefs.saveElectrumServer(customElectrumServer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [mnemonics] to `seed.dat`. Callback-shaped, driving [WritingSeedState], because
|
||||
* that is what the create flow and its view model were built around.
|
||||
*/
|
||||
fun writeMnemonic(
|
||||
log: Logger,
|
||||
phoenixGlobal: PhoenixGlobal,
|
||||
globalPrefs: GlobalPrefs,
|
||||
writingState: WritingSeedState,
|
||||
viewModelScope: CoroutineScope,
|
||||
mnemonics: List<String>,
|
||||
onWritingSeedError: (WritingSeedState.Error) -> Unit,
|
||||
onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit,
|
||||
isRestoringWallet: Boolean,
|
||||
isTorEnabled: Boolean,
|
||||
customElectrumServer: ElectrumConfig.Custom?,
|
||||
onSeedWritten: (WalletId) -> Unit
|
||||
) {
|
||||
if (writingState !is WritingSeedState.Init) return
|
||||
viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e ->
|
||||
log.e("failed to write mnemonics to disk: ${e.message}")
|
||||
onWritingSeedError.invoke(
|
||||
WritingSeedState.Error.Generic(e)
|
||||
)
|
||||
}) {
|
||||
log.d("writing mnemonics to disk...")
|
||||
|
||||
onWritingSeedStateWriting.invoke(
|
||||
WritingSeedState.Writing(mnemonics)
|
||||
)
|
||||
val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map {
|
||||
it.key to it.value.words
|
||||
}?.toMap()
|
||||
// Null when nostr-keys.dat exists and cannot be read; an empty map when it is absent.
|
||||
val existingNostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal)
|
||||
|
||||
val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector()
|
||||
val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub)
|
||||
val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey)
|
||||
val newNostrPublicKey = keyManager.nostrPrivateKey().nostrPublicKeyHex()
|
||||
|
||||
when {
|
||||
existingSeeds == null || existingNostrKeys == null -> {
|
||||
log.e("could not load the existing seed map, aborting...")
|
||||
onWritingSeedError.invoke(
|
||||
WritingSeedState.Error.CannotLoadSeedMap
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
existingSeeds.containsKey(newWalletId) -> {
|
||||
log.i("attempting to import a seed that already exists, aborting...")
|
||||
onWritingSeedError.invoke(
|
||||
WritingSeedState.Error.SeedAlreadyExists
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
existingNostrKeys.containsKey(newNostrPublicKey) -> {
|
||||
// The same npub is already here as a bare key. The id check above cannot
|
||||
// see that -- different hash, different key -- so it is asked by pubkey.
|
||||
log.i("attempting to import a seed whose nostr key is already here, aborting...")
|
||||
onWritingSeedError.invoke(
|
||||
WritingSeedState.Error.SeedAlreadyExists
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
else -> {
|
||||
val newSeedMap = existingSeeds + (newWalletId to mnemonics)
|
||||
val encrypted = EncryptedSeed.V2.encrypt(newSeedMap)
|
||||
SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true)
|
||||
if (isRestoringWallet) {
|
||||
log.i("successfully restored wallet=$newWalletId")
|
||||
} else {
|
||||
log.i("successfully created wallet=$newWalletId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prepareIdentity(phoenixGlobal, globalPrefs, newWalletId, isTorEnabled, customElectrumServer)
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
delay(1000)
|
||||
onSeedWritten(newWalletId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class WriteNostrKeyResult {
|
||||
data class Written(val id: WalletId) : WriteNostrKeyResult()
|
||||
|
||||
/** An identity with this nostr public key is already on the device, as a wallet or as a key. */
|
||||
data object AlreadyExists : WriteNostrKeyResult()
|
||||
|
||||
/** One of the two stores exists and could not be read; nothing was written. */
|
||||
data object CannotLoadKeys : WriteNostrKeyResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [privateKey] to `nostr-keys.dat` and prepares its preferences. Suspending and
|
||||
* result-shaped rather than callback-shaped: nothing was built around a state machine
|
||||
* for it, and the caller is a coroutine already. Throws on an I/O or key store failure
|
||||
* during the write, as [writeMnemonic]'s handler would have caught.
|
||||
*/
|
||||
suspend fun writeNostrKey(
|
||||
log: Logger,
|
||||
phoenixGlobal: PhoenixGlobal,
|
||||
globalPrefs: GlobalPrefs,
|
||||
privateKey: PrivateKey,
|
||||
isTorEnabled: Boolean,
|
||||
customElectrumServer: ElectrumConfig.Custom?,
|
||||
): WriteNostrKeyResult {
|
||||
log.d("writing nostr key to disk...")
|
||||
val stored = StoredIdentity.nostrSecret(privateKey)
|
||||
|
||||
val known = knownNostrPublicKeys(phoenixGlobal)
|
||||
val existingNostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal)
|
||||
if (known == null || existingNostrKeys == null) {
|
||||
log.e("could not load the existing keys, aborting...")
|
||||
return WriteNostrKeyResult.CannotLoadKeys
|
||||
}
|
||||
if (stored.nostrPublicKey in known) {
|
||||
log.i("attempting to import a nostr key that is already here, aborting...")
|
||||
return WriteNostrKeyResult.AlreadyExists
|
||||
}
|
||||
|
||||
val encrypted = EncryptedNostrKeys.encrypt(existingNostrKeys + (stored.nostrPublicKey to privateKey))
|
||||
NostrKeyManager.writeToDisk(phoenixGlobal, encrypted)
|
||||
log.i("successfully imported nostr key for identity=${stored.id}")
|
||||
|
||||
prepareIdentity(phoenixGlobal, globalPrefs, stored.id, isTorEnabled, customElectrumServer)
|
||||
|
||||
return WriteNostrKeyResult.Written(stored.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package press.mantra.compose.identity
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
import fr.acinq.bitcoin.MnemonicCode
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.bitcoin.XonlyPublicKey
|
||||
import fr.acinq.bitcoin.byteVector
|
||||
import fr.acinq.lightning.crypto.LocalKeyManager
|
||||
import fr.acinq.phoenix.data.UserWallet
|
||||
import fr.acinq.phoenix.data.WalletId
|
||||
import fr.acinq.phoenix.managers.NodeParamsManager
|
||||
import fr.acinq.phoenix.managers.nostrPrivateKey
|
||||
import fr.acinq.phoenix.managers.nostrPublicKeyHex
|
||||
|
||||
/**
|
||||
* An identity this device holds the secret for, as listed before any of them is started.
|
||||
*
|
||||
* Two stores feed this -- `seed.dat` for wallets, `nostr-keys.dat` for bare keys -- and
|
||||
* the startup screen wants one list. The nostr public key is on both kinds because it
|
||||
* is the one thing they have in common and the one thing a duplicate check has to
|
||||
* compare: a wallet and an imported key can be the *same* npub under different ids.
|
||||
*
|
||||
* On what this holds: `SovereignWalletViewModel.availableWallets` has always carried
|
||||
* decrypted words for the view model's life, because starting the node needs them.
|
||||
* [NostrSecret] carries its key for the same reason and is no worse; decrypting at
|
||||
* activation instead would be an improvement for both kinds and belongs to both.
|
||||
*/
|
||||
sealed interface StoredIdentity {
|
||||
val id: WalletId
|
||||
val kind: IdentityKind
|
||||
val nostrPublicKey: HexKey
|
||||
|
||||
data class Mnemonic(
|
||||
val userWallet: UserWallet,
|
||||
override val nostrPublicKey: HexKey,
|
||||
) : StoredIdentity {
|
||||
override val id: WalletId get() = userWallet.walletId
|
||||
override val kind: IdentityKind get() = IdentityKind.Mnemonic
|
||||
override fun toString(): String = "StoredIdentity.Mnemonic(id=$id, npub=$nostrPublicKey)"
|
||||
}
|
||||
|
||||
data class NostrSecret(
|
||||
override val id: WalletId,
|
||||
override val nostrPublicKey: HexKey,
|
||||
val privateKey: PrivateKey,
|
||||
) : StoredIdentity {
|
||||
override val kind: IdentityKind get() = IdentityKind.NostrSecret
|
||||
override fun toString(): String = "StoredIdentity.NostrSecret(id=$id, npub=$nostrPublicKey, key=<redacted>)"
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The nostr public key a wallet's words derive. `SeedManager.loadAndDecrypt` has
|
||||
* already built exactly this key manager to learn the node id, so this is the second
|
||||
* derivation of it, not a new cost.
|
||||
*/
|
||||
fun nostrPublicKeyOf(words: List<String>): HexKey = LocalKeyManager(
|
||||
seed = MnemonicCode.toSeed(words, "").byteVector(),
|
||||
chain = NodeParamsManager.chain,
|
||||
remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub,
|
||||
).nostrPrivateKey().nostrPublicKeyHex()
|
||||
|
||||
fun mnemonic(userWallet: UserWallet): Mnemonic =
|
||||
Mnemonic(userWallet, nostrPublicKeyOf(userWallet.words))
|
||||
|
||||
fun nostrSecret(privateKey: PrivateKey): NostrSecret {
|
||||
val xOnly = privateKey.publicKey().xOnly()
|
||||
return NostrSecret(
|
||||
id = xOnly.toWalletId(),
|
||||
nostrPublicKey = xOnly.value.toHex(),
|
||||
privateKey = privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One map from the two stores. [nostrKeys] is keyed by x-only public key hex, as
|
||||
* `nostr-keys.dat` is; the id of each is derived from that key, so a stored key
|
||||
* whose map key disagrees with its own public key is dropped here too, though
|
||||
* `EncryptedNostrKeys` refuses such a file before it gets this far.
|
||||
*/
|
||||
fun merge(
|
||||
wallets: Map<WalletId, UserWallet>,
|
||||
nostrKeys: Map<HexKey, PrivateKey>,
|
||||
): Map<WalletId, StoredIdentity> {
|
||||
val merged = LinkedHashMap<WalletId, StoredIdentity>()
|
||||
wallets.values.forEach { userWallet -> merged[userWallet.walletId] = mnemonic(userWallet) }
|
||||
nostrKeys.forEach { (publicKeyHex, privateKey) ->
|
||||
val stored = nostrSecret(privateKey)
|
||||
if (stored.nostrPublicKey == publicKeyHex) merged[stored.id] = stored
|
||||
}
|
||||
return merged
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The x-only key as bitcoin-kmp types it, from the hex nostr carries it as. */
|
||||
fun HexKey.toXonlyPublicKey(): XonlyPublicKey = XonlyPublicKey(ByteVector32(this))
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
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.NodeParamsManager
|
||||
@@ -26,6 +25,9 @@ import fr.acinq.phoenix.utils.preferences.UserPrefs
|
||||
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
|
||||
import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault
|
||||
import kotlinx.coroutines.flow.first
|
||||
import press.mantra.compose.identity.Identity
|
||||
import press.mantra.compose.identity.IdentityKind
|
||||
import press.mantra.compose.identity.StoredIdentity
|
||||
import press.mantra.compose.ui.theme.spacing
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -74,7 +76,7 @@ fun SovereignWalletStartupScreen(
|
||||
is ListWalletState.Success -> {
|
||||
val globalPrefs = sovereignWalletViewModel.getGlobalPrefs()
|
||||
|
||||
val availableWallets by sovereignWalletViewModel.availableWallets.collectAsState()
|
||||
val availableIdentities by sovereignWalletViewModel.availableIdentities.collectAsState()
|
||||
val defaultWallet = globalPrefs.getDefaultWallet.collectAsState(null)
|
||||
val startWalletImmediately by sovereignWalletViewModel.startWalletImmediately.collectAsState()
|
||||
|
||||
@@ -88,7 +90,7 @@ fun SovereignWalletStartupScreen(
|
||||
val activeIdentity = activeIdentityFlow.value
|
||||
|
||||
when {
|
||||
availableWallets.isEmpty() -> {
|
||||
availableIdentities.isEmpty() -> {
|
||||
LaunchedEffect(Unit) { onNavigateToWalletLandingPage.invoke() }
|
||||
LoadingDataIndicator(
|
||||
text = "Initializing..."
|
||||
@@ -111,29 +113,29 @@ fun SovereignWalletStartupScreen(
|
||||
else -> {
|
||||
when (val startupState = sovereignWalletStartupViewModel.state.value) {
|
||||
is press.mantra.compose.ui.view.model.StartupViewState.Init -> {
|
||||
var loadingWallet by remember {
|
||||
var loadingIdentity by remember {
|
||||
mutableStateOf(
|
||||
when {
|
||||
forceWalletId != null -> availableWallets[forceWalletId]
|
||||
forceWalletId != null -> availableIdentities[forceWalletId]
|
||||
!startWalletImmediately -> null
|
||||
availableWallets.size == 1 -> availableWallets.entries.firstOrNull()?.value
|
||||
desiredWalletId != null -> availableWallets[desiredWalletId]
|
||||
startWalletImmediately -> availableWallets[defaultWallet.value]
|
||||
availableIdentities.size == 1 -> availableIdentities.entries.firstOrNull()?.value
|
||||
desiredWalletId != null -> availableIdentities[desiredWalletId]
|
||||
startWalletImmediately -> availableIdentities[defaultWallet.value]
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
when (val wallet = loadingWallet) {
|
||||
when (val stored = loadingIdentity) {
|
||||
null -> {
|
||||
press.mantra.compose.ui.composable.widgets.wallet.WalletsSelector(
|
||||
wallets = availableWallets,
|
||||
wallets = availableIdentities,
|
||||
globalPrefs = globalPrefs,
|
||||
walletsMetadata = availableWalletMetadata,
|
||||
activeWalletId = null,
|
||||
onWalletClick = {
|
||||
sovereignWalletViewModel.switchToWallet(
|
||||
it.walletId
|
||||
); loadingWallet = it
|
||||
it.id
|
||||
); loadingIdentity = it
|
||||
},
|
||||
canEdit = false,
|
||||
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.space300),
|
||||
@@ -157,26 +159,50 @@ fun SovereignWalletStartupScreen(
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val metadata = remember { availableWalletMetadata.getByWalletIdOrDefault(wallet.walletId) }
|
||||
val metadata = remember { availableWalletMetadata.getByWalletIdOrDefault(stored.id) }
|
||||
val dataStoreManager = DataStoreManager(
|
||||
ctx = sovereignWalletViewModel.phoenixGlobal.ctx,
|
||||
chain = NodeParamsManager.chain
|
||||
)
|
||||
// The screen-lock gate wraps both kinds. It only ever needed the
|
||||
// id, to read the lock preferences, so it sits outside the branch
|
||||
// and a future lock is not something to remember to add twice.
|
||||
LoadWallet(
|
||||
userWallet = wallet,
|
||||
identity = stored,
|
||||
metadata = metadata,
|
||||
userPrefs = dataStoreManager.loadUserPrefsForWallet(wallet.walletId),
|
||||
userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id),
|
||||
promptScreenLockImmediately = startWalletImmediately,
|
||||
doLoadWallet = { userWallet ->
|
||||
sovereignWalletStartupViewModel.startupNode(walletId = userWallet.walletId, words = userWallet.words, onStartupSuccess = {
|
||||
sovereignWalletViewModel.setActiveWallet(walletId = userWallet.walletId, business = it)
|
||||
onSuccessfulStartup.invoke()
|
||||
})
|
||||
loadingWallet = null
|
||||
doLoadWallet = { identity ->
|
||||
when (identity) {
|
||||
is StoredIdentity.Mnemonic -> {
|
||||
sovereignWalletStartupViewModel.startupNode(walletId = identity.id, words = identity.userWallet.words, onStartupSuccess = {
|
||||
sovereignWalletViewModel.setActiveWallet(walletId = identity.id, business = it)
|
||||
onSuccessfulStartup.invoke()
|
||||
})
|
||||
}
|
||||
is StoredIdentity.NostrSecret -> {
|
||||
// No node to start, so no platformStartupLogic, no
|
||||
// schedulePlatformLogic and no StartupViewState: the
|
||||
// identity is active the moment it is read. Setting it
|
||||
// recomposes into the `activeIdentity != null` branch
|
||||
// above, which is what reports the startup.
|
||||
sovereignWalletViewModel.setActiveIdentity(
|
||||
Identity(
|
||||
id = identity.id,
|
||||
kind = IdentityKind.NostrSecret,
|
||||
nostrPrivateKey = identity.privateKey,
|
||||
userPrefs = dataStoreManager.loadUserPrefsForWallet(identity.id),
|
||||
internalPrefs = dataStoreManager.loadInternalPrefsForWallet(identity.id),
|
||||
business = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
loadingIdentity = null
|
||||
},
|
||||
// only show back-to-selector button if there's more than one wallet
|
||||
goToWalletSelector = availableWallets.takeIf { it.size > 1 }?.let {
|
||||
{ loadingWallet = null; sovereignWalletViewModel.startWalletImmediately.value = false }
|
||||
// only show back-to-selector button if there's more than one identity
|
||||
goToWalletSelector = availableIdentities.takeIf { it.size > 1 }?.let {
|
||||
{ loadingIdentity = null; sovereignWalletViewModel.startWalletImmediately.value = false }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -233,15 +259,15 @@ fun SovereignWalletStartupScreen(
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.LoadWallet(
|
||||
userWallet: UserWallet,
|
||||
identity: StoredIdentity,
|
||||
metadata: UserWalletMetadata,
|
||||
userPrefs: UserPrefs,
|
||||
promptScreenLockImmediately: Boolean,
|
||||
doLoadWallet: (UserWallet) -> Unit,
|
||||
doLoadWallet: (StoredIdentity) -> Unit,
|
||||
goToWalletSelector: (() -> Unit)?
|
||||
) {
|
||||
|
||||
val isScreenLockRequired = produceState<Boolean?>(initialValue = null, key1 = userWallet) {
|
||||
val isScreenLockRequired = produceState<Boolean?>(initialValue = null, key1 = identity) {
|
||||
val biometricLockEnabled = userPrefs.getLockBiometricsEnabled.first()
|
||||
val customPinLockEnabled = userPrefs.getLockPinEnabled.first()
|
||||
|
||||
@@ -255,10 +281,10 @@ private fun BoxScope.LoadWallet(
|
||||
true -> {
|
||||
LoadingDataIndicator(text = "Unlock to continue")
|
||||
ScreenLockPrompt(
|
||||
walletId = userWallet.walletId,
|
||||
walletId = identity.id,
|
||||
walletName = metadata.nameOrDefault(),
|
||||
promptScreenLockImmediately = promptScreenLockImmediately,
|
||||
onUnlock = { doLoadWallet(userWallet) },
|
||||
onUnlock = { doLoadWallet(identity) },
|
||||
onLock = { },
|
||||
userPrefs = userPrefs,
|
||||
goToWalletSelector = goToWalletSelector,
|
||||
@@ -267,7 +293,7 @@ private fun BoxScope.LoadWallet(
|
||||
false -> {
|
||||
LoadingDataIndicator(text = "Starting wallet...")
|
||||
LaunchedEffect(Unit) {
|
||||
doLoadWallet(userWallet)
|
||||
doLoadWallet(identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,7 +502,7 @@ fun MantraNavHost(
|
||||
onSeedWritten()
|
||||
|
||||
sovereignWalletViewModel.loadSovereignData(walletId)
|
||||
sovereignWalletViewModel.listAvailableWallets {
|
||||
sovereignWalletViewModel.listIdentities {
|
||||
sovereignWalletViewModel.switchToWallet(walletId)
|
||||
navController.navigate(
|
||||
route = SovereignWalletStartupRoute
|
||||
|
||||
@@ -44,22 +44,23 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import press.mantra.compose.ui.composable.widgets.buttons.Clickable
|
||||
import fr.acinq.phoenix.data.UserWallet
|
||||
import fr.acinq.phoenix.data.WalletId
|
||||
import fr.acinq.phoenix.utils.preferences.GlobalPrefs
|
||||
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
|
||||
import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault
|
||||
import press.mantra.compose.extensions.hexToNpubHrp
|
||||
import press.mantra.compose.identity.StoredIdentity
|
||||
import press.mantra.compose.ui.theme.spacing
|
||||
|
||||
@Composable
|
||||
fun WalletsSelector(
|
||||
modifier: Modifier = Modifier,
|
||||
globalPrefs: GlobalPrefs,
|
||||
wallets: Map<WalletId, UserWallet>,
|
||||
wallets: Map<WalletId, StoredIdentity>,
|
||||
walletsMetadata: Map<WalletId, UserWalletMetadata>,
|
||||
activeWalletId: WalletId?,
|
||||
canEdit: Boolean,
|
||||
onWalletClick: (UserWallet) -> Unit,
|
||||
onWalletClick: (StoredIdentity) -> Unit,
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
|
||||
topContent: @Composable (() -> Unit)? = null,
|
||||
@@ -76,9 +77,9 @@ fun WalletsSelector(
|
||||
if (currentWallet != null) {
|
||||
item {
|
||||
AvailableWalletView(
|
||||
userWallet = currentWallet,
|
||||
identity = currentWallet,
|
||||
globalPrefs = globalPrefs,
|
||||
metadata = walletsMetadata.getByWalletIdOrDefault(currentWallet.walletId),
|
||||
metadata = walletsMetadata.getByWalletIdOrDefault(currentWallet.id),
|
||||
isCurrent = true,
|
||||
canEdit = canEdit,
|
||||
onClick = { onWalletClick(currentWallet) }
|
||||
@@ -88,14 +89,14 @@ fun WalletsSelector(
|
||||
Spacer(Modifier.height(MaterialTheme.spacing.space100))
|
||||
}
|
||||
}
|
||||
items(items = otherWalletsList) { (walletId, userWallet) ->
|
||||
items(items = otherWalletsList) { (walletId, identity) ->
|
||||
AvailableWalletView(
|
||||
userWallet = userWallet,
|
||||
identity = identity,
|
||||
globalPrefs = globalPrefs,
|
||||
metadata = walletsMetadata.getByWalletIdOrDefault(walletId),
|
||||
isCurrent = false,
|
||||
canEdit = canEdit,
|
||||
onClick = { onWalletClick(userWallet) }
|
||||
onClick = { onWalletClick(identity) }
|
||||
)
|
||||
Spacer(Modifier.height(MaterialTheme.spacing.space100))
|
||||
}
|
||||
@@ -105,11 +106,16 @@ fun WalletsSelector(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The second line is the npub for both kinds. It used to be the node id, which an
|
||||
* identity made from a bare key does not have -- and the npub is the identifier the rest
|
||||
* of the app shows, and the one a user might actually recognise.
|
||||
*/
|
||||
@Composable
|
||||
private fun AvailableWalletView(
|
||||
modifier: Modifier = Modifier,
|
||||
globalPrefs: GlobalPrefs,
|
||||
userWallet: UserWallet,
|
||||
identity: StoredIdentity,
|
||||
metadata: UserWalletMetadata,
|
||||
isCurrent: Boolean,
|
||||
canEdit: Boolean,
|
||||
@@ -122,7 +128,7 @@ private fun AvailableWalletView(
|
||||
)
|
||||
// EditWalletDialog(
|
||||
// onDismiss = { showWalletEditDialog = false },
|
||||
// walletId = userWallet.walletId,
|
||||
// walletId = identity.id,
|
||||
// globalPrefs = globalPrefs,
|
||||
// metadata = metadata,
|
||||
// )
|
||||
@@ -134,7 +140,7 @@ private fun AvailableWalletView(
|
||||
if (isCurrent) {
|
||||
if (canEdit) showWalletEditDialog = true else return@Clickable
|
||||
} else {
|
||||
onClick(userWallet.walletId)
|
||||
onClick(identity.id)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
@@ -152,7 +158,7 @@ private fun AvailableWalletView(
|
||||
Column {
|
||||
Text(text = metadata.nameOrDefault(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(MaterialTheme.spacing.space25))
|
||||
Text(text = userWallet.nodeId, modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp))
|
||||
Text(text = identity.nostrPublicKey.hexToNpubHrp(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp))
|
||||
}
|
||||
}
|
||||
if (isCurrent && canEdit) {
|
||||
|
||||
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
@@ -65,7 +64,22 @@ class NavigationViewModel(
|
||||
}
|
||||
|
||||
suspend fun loadNostrProfile(startupRoute: SovereignWalletStartupRoute? = null) {
|
||||
val activeUserPublicKey = nostrRepository.getLocalAccounts().firstOrNull()?.profile?.publicKey
|
||||
// Which account is "ours" is the active identity's to say, when there is one: with
|
||||
// two identities on the device, the first kind-0 row is the wrong one half the time.
|
||||
// Matched on the unsigned event's pubKey, not `profile.publicKey` -- the Profile row is
|
||||
// what the notary upserts when it *signs* the kind 0, so an account planted by
|
||||
// `signInToProfile` (or one created moments ago and not yet signed) has none, and
|
||||
// reading through it answered `Landing` for a key that had just been imported while
|
||||
// `observeProfile`, reading the same rows, answered the sync screen. Two writers to
|
||||
// one state, in whichever order the coroutines ran, and Landing pops the back stack.
|
||||
val accounts = nostrRepository.getLocalAccounts()
|
||||
val identity = activeIdentityStateFlow.value
|
||||
val account = if (identity != null) {
|
||||
accounts.firstOrNull { it.unsignedNostrEvent?.pubKey == identity.nostrPublicKey }
|
||||
} else {
|
||||
accounts.firstOrNull()
|
||||
}
|
||||
val activeUserPublicKey = account?.unsignedNostrEvent?.pubKey
|
||||
|
||||
if (activeUserPublicKey == null) {
|
||||
// TODO: We can just go directly to startup witout fear...
|
||||
@@ -81,12 +95,9 @@ class NavigationViewModel(
|
||||
// `observeLocalAccount`, which turned the whole call into a no-op for anyone who
|
||||
// already had an account: the caller had navigated to a loading screen first and then
|
||||
// nothing ever moved the user off it. Every path out of here must leave the navigation
|
||||
// state pointing somewhere.
|
||||
processLocalAccount(
|
||||
nostrRepository.observeLocalAccount(
|
||||
publicKey = activeUserPublicKey
|
||||
).firstOrNull()
|
||||
)
|
||||
// state pointing somewhere. `getLocalAccounts` already joined the relations this
|
||||
// reads, so the account goes straight in rather than being fetched a second time.
|
||||
processLocalAccount(account)
|
||||
} else {
|
||||
_navigationUIState.getAndUpdate {
|
||||
NavigationUIState.StartupPhoenix
|
||||
|
||||
@@ -12,12 +12,16 @@ import co.touchlab.kermit.Logger
|
||||
import fr.acinq.lightning.logging.error
|
||||
import fr.acinq.phoenix.PhoenixBusiness
|
||||
import fr.acinq.phoenix.PhoenixGlobal
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.phoenix.data.DecryptNostrKeysResult
|
||||
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.NostrKeyManager
|
||||
import fr.acinq.phoenix.managers.nostrPrivateKey
|
||||
import fr.acinq.phoenix.utils.preferences.GlobalPrefs
|
||||
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
|
||||
@@ -35,6 +39,8 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.identity.Identity
|
||||
import press.mantra.compose.identity.IdentityKind
|
||||
import press.mantra.compose.identity.IdentityWriter
|
||||
import press.mantra.compose.identity.StoredIdentity
|
||||
|
||||
sealed class WritingSeedState {
|
||||
data object Init : WritingSeedState()
|
||||
@@ -71,15 +77,19 @@ class SovereignWalletViewModel(
|
||||
// A StateFlow, not a `mutableStateOf`, and it has to stay one. This view model is built by
|
||||
// `viewModel()` during composition, so a Compose state created in its constructor is created
|
||||
// inside that composition's snapshot -- and a write from another thread that lands before the
|
||||
// composition is applied is discarded. `listAvailableWallets` runs from `init` and, on a device
|
||||
// composition is applied is discarded. `listIdentities` runs from `init` and, on a device
|
||||
// with no seed file, answers in about four milliseconds, which is squarely inside that window:
|
||||
// the write to Success was lost and the app sat on "Decrypting..." for ever. A StateFlow has no
|
||||
// snapshot to belong to, so the same write from the same thread at the same moment is seen.
|
||||
private val _listWalletState = MutableStateFlow<ListWalletState>(ListWalletState.Init)
|
||||
val listWalletState = _listWalletState.asStateFlow()
|
||||
|
||||
private val _availableWallets = MutableStateFlow<Map<WalletId, UserWallet>>(emptyMap())
|
||||
val availableWallets = _availableWallets.asStateFlow()
|
||||
/**
|
||||
* Every identity this device holds a secret for, from both stores -- `seed.dat` and
|
||||
* `nostr-keys.dat` -- keyed by the id the preferences and metadata use.
|
||||
*/
|
||||
private val _availableIdentities = MutableStateFlow<Map<WalletId, StoredIdentity>>(emptyMap())
|
||||
val availableIdentities = _availableIdentities.asStateFlow()
|
||||
|
||||
private val _desiredWalletId = MutableStateFlow<WalletId?>(null)
|
||||
val desiredWalletId = _desiredWalletId.asStateFlow()
|
||||
@@ -123,7 +133,7 @@ class SovereignWalletViewModel(
|
||||
|
||||
|
||||
init {
|
||||
listAvailableWallets(onDone = {})
|
||||
listIdentities(onDone = {})
|
||||
}
|
||||
|
||||
/** Makes [identity] the one the app runs as. Everything reading [activeIdentity] follows. */
|
||||
@@ -160,59 +170,93 @@ class SovereignWalletViewModel(
|
||||
updateBusinessActiveInUI(walletId)
|
||||
}
|
||||
|
||||
fun listAvailableWallets(onDone: () -> Unit) {
|
||||
/**
|
||||
* Reads both stores and publishes the merged list, registering metadata for any id
|
||||
* seen for the first time.
|
||||
*
|
||||
* A failure in either file is surfaced, not skipped: a corrupt `nostr-keys.dat` would
|
||||
* otherwise drop every imported identity from the list without a word, and the seed
|
||||
* file has always been handled this way.
|
||||
*/
|
||||
fun listIdentities(onDone: () -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e ->
|
||||
// log.error("error when initialising startup-view: ", e)
|
||||
_listWalletState.value = ListWalletState.Error.Generic(e)
|
||||
}) {
|
||||
|
||||
when (val result = loadAndDecryptSeed(phoenixGlobal)) {
|
||||
val wallets: Map<WalletId, UserWallet> = when (val result = loadAndDecryptSeed(phoenixGlobal)) {
|
||||
is DecryptSeedResult.Failure.SerializationError -> {
|
||||
log.error {"cannot deserialize seed file: "}
|
||||
_listWalletState.value = ListWalletState.Error.Serialization
|
||||
return@launch
|
||||
}
|
||||
is DecryptSeedResult.Failure.DecryptionError -> {
|
||||
log.e("cannot decrypt seed file: ", throwable = result.cause)
|
||||
_listWalletState.value = ListWalletState.Error.DecryptionError.GeneralException(result.cause)
|
||||
return@launch
|
||||
}
|
||||
is DecryptSeedResult.Failure.KeyStoreFailure -> {
|
||||
log.e("key store failure: ", throwable = result.cause)
|
||||
_listWalletState.value = ListWalletState.Error.DecryptionError.KeystoreFailure(result.cause)
|
||||
return@launch
|
||||
}
|
||||
is DecryptSeedResult.Failure.SeedFileUnreadable -> {
|
||||
log.e("aborting, unreadable seed file")
|
||||
_listWalletState.value = ListWalletState.Error.Generic(null)
|
||||
return@launch
|
||||
}
|
||||
is DecryptSeedResult.Failure.SeedInvalid -> {
|
||||
log.e("aborting, seed is invalid")
|
||||
_listWalletState.value = ListWalletState.Error.Generic(null)
|
||||
return@launch
|
||||
}
|
||||
is DecryptSeedResult.Failure.SeedFileNotFound -> emptyMap()
|
||||
is DecryptSeedResult.Success -> result.userWalletsMap
|
||||
}
|
||||
|
||||
is DecryptSeedResult.Failure.SeedFileNotFound -> {
|
||||
_listWalletState.value = ListWalletState.Success
|
||||
_availableWallets.value = emptyMap()
|
||||
val nostrKeys: Map<HexKey, PrivateKey> = when (val result = NostrKeyManager.loadAndDecrypt(phoenixGlobal)) {
|
||||
is DecryptNostrKeysResult.Failure.SerializationError -> {
|
||||
log.e { "cannot deserialize nostr keys file" }
|
||||
_listWalletState.value = ListWalletState.Error.Serialization
|
||||
return@launch
|
||||
}
|
||||
|
||||
is DecryptSeedResult.Success -> {
|
||||
|
||||
val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first()
|
||||
result.userWalletsMap.forEach { (walletId, _) ->
|
||||
if (metadataMap[walletId] == null) {
|
||||
saveAvailableWalletMeta(
|
||||
phoenixGlobal = phoenixGlobal,
|
||||
walletId = walletId,
|
||||
name = null,
|
||||
avatar = press.mantra.compose.ui.composable.widgets.wallet.WalletAvatars.list.random(),
|
||||
isHidden = false
|
||||
)
|
||||
}
|
||||
}
|
||||
_availableWallets.value = result.userWalletsMap
|
||||
_listWalletState.value = ListWalletState.Success
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onDone()
|
||||
}
|
||||
is DecryptNostrKeysResult.Failure.DecryptionError -> {
|
||||
log.e("cannot decrypt nostr keys file: ", throwable = result.cause)
|
||||
_listWalletState.value = ListWalletState.Error.DecryptionError.GeneralException(result.cause)
|
||||
return@launch
|
||||
}
|
||||
is DecryptNostrKeysResult.Failure.KeyStoreFailure -> {
|
||||
log.e("key store failure: ", throwable = result.cause)
|
||||
_listWalletState.value = ListWalletState.Error.DecryptionError.KeystoreFailure(result.cause)
|
||||
return@launch
|
||||
}
|
||||
is DecryptNostrKeysResult.Failure.FileUnreadable -> {
|
||||
log.e("aborting, unreadable nostr keys file")
|
||||
_listWalletState.value = ListWalletState.Error.Generic(null)
|
||||
return@launch
|
||||
}
|
||||
is DecryptNostrKeysResult.Failure.FileNotFound -> emptyMap()
|
||||
is DecryptNostrKeysResult.Success -> result.keys
|
||||
}
|
||||
|
||||
val identities = StoredIdentity.merge(wallets, nostrKeys)
|
||||
|
||||
val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first()
|
||||
identities.keys.forEach { walletId ->
|
||||
if (metadataMap[walletId] == null) {
|
||||
saveAvailableWalletMeta(
|
||||
phoenixGlobal = phoenixGlobal,
|
||||
walletId = walletId,
|
||||
name = null,
|
||||
avatar = press.mantra.compose.ui.composable.widgets.wallet.WalletAvatars.list.random(),
|
||||
isHidden = false
|
||||
)
|
||||
}
|
||||
}
|
||||
_availableIdentities.value = identities
|
||||
_listWalletState.value = ListWalletState.Success
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,10 +326,10 @@ class SovereignWalletViewModel(
|
||||
onSeedWriteError: (WritingSeedState.Error) -> Unit = {}
|
||||
) {
|
||||
if (writingState is WritingSeedState.Error || writingState is WritingSeedState.WrittenToDisk) {
|
||||
// A previous attempt finished; reset so platformWriteSeed does not silently skip this one.
|
||||
// A previous attempt finished; reset so writeMnemonic does not silently skip this one.
|
||||
writingState = WritingSeedState.Init
|
||||
}
|
||||
platformWriteSeed(
|
||||
IdentityWriter.writeMnemonic(
|
||||
log = log,
|
||||
phoenixGlobal = phoenixGlobal,
|
||||
globalPrefs = getGlobalPrefs(),
|
||||
@@ -322,18 +366,3 @@ class SovereignWalletViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect fun platformWriteSeed(
|
||||
log: Logger,
|
||||
phoenixGlobal: PhoenixGlobal,
|
||||
globalPrefs: GlobalPrefs,
|
||||
writingState: WritingSeedState,
|
||||
viewModelScope: CoroutineScope,
|
||||
mnemonics: List<String>,
|
||||
onWritingSeedError: (WritingSeedState.Error) -> Unit,
|
||||
onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit,
|
||||
isRestoringWallet: Boolean,
|
||||
isTorEnabled: Boolean,
|
||||
customElectrumServer: ElectrumConfig.Custom?,
|
||||
onSeedWritten: (WalletId) -> Unit
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user