diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 2eeb8f7b..b74dcb34 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -85,7 +85,6 @@
eg. st
eg. To Kill a Mocking Bird
Emergency kit
- end this
ENDED
Encrypt and back your recovery information up to your Google Drive or iCloud.
Enter the name you want to use for your group
@@ -481,4 +480,9 @@
Loading profiles…
Switch profile
Wallet
+
+ Create a new profile
+ Sign in with a key
+ Switch to it
+ The profile you are in stays on this device.
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt
index c784828c..62ea1fd5 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt
@@ -55,12 +55,11 @@ import press.mantra.compose.ui.view.model.WritingSeedState
*/
object IdentityWriter {
- /** What the seed store says, or null if it could not be read. */
- private fun seedPublicKeys(phoenixGlobal: PhoenixGlobal): Set? =
+ /** Each seed's nostr public key and the wallet id it is filed under, or null if the store could not be read. */
+ private fun seedWalletIds(phoenixGlobal: PhoenixGlobal): Map? =
SeedManager.loadAndDecryptOrNull(phoenixGlobal)
?.values
- ?.map { StoredIdentity.nostrPublicKeyOf(it.words) }
- ?.toSet()
+ ?.associate { StoredIdentity.nostrPublicKeyOf(it.words) to it.walletId }
/**
* Creates the preference files a new id needs and records the choices made before
@@ -137,10 +136,12 @@ object IdentityWriter {
existingSeeds.containsKey(newWalletId) -> {
// The same seed, and so the same nostr key with a wallet already attached.
// Two different seeds cannot derive one nostr key, so this is the only
- // way a profile with a wallet attached is offered its phrase again.
+ // way a profile with a wallet attached is offered its phrase again. The
+ // id goes with the refusal: from inside the app, "already here" has an
+ // obvious next step, which is to switch to it.
log.i("attempting to import a seed that already exists, aborting...")
onWritingSeedError.invoke(
- WritingSeedState.Error.SeedAlreadyExists
+ WritingSeedState.Error.SeedAlreadyExists(newWalletId)
)
return@launch
}
@@ -205,8 +206,10 @@ object IdentityWriter {
/**
* An identity with this nostr public key is already on the device and holds at least
* what was pasted: a wallet or a secret, for either input; a public key, for an npub.
+ * [id] is the one it is listed under -- the wallet's where a seed derives the key --
+ * so that the refusal can offer to switch to it.
*/
- data object AlreadyExists : WriteNostrCredentialResult()
+ data class AlreadyExists(val id: WalletId) : WriteNostrCredentialResult()
/** One of the two stores exists and could not be read; nothing was written. */
data object CannotLoadKeys : WriteNostrCredentialResult()
@@ -233,15 +236,19 @@ object IdentityWriter {
log.d("writing nostr key to disk...")
val stored = StoredIdentity.nostrSecret(privateKey)
- val seeds = seedPublicKeys(phoenixGlobal)
+ val seeds = seedWalletIds(phoenixGlobal)
val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal)
if (seeds == null || existing == null) {
log.e("could not load the existing keys, aborting...")
return WriteNostrCredentialResult.CannotLoadKeys
}
- if (stored.nostrPublicKey in seeds || existing[stored.nostrPublicKey] is NostrCredential.Secret) {
+ seeds[stored.nostrPublicKey]?.let { walletId ->
+ log.i("attempting to import the nostr key of a wallet that is already here, aborting...")
+ return WriteNostrCredentialResult.AlreadyExists(walletId)
+ }
+ if (existing[stored.nostrPublicKey] is NostrCredential.Secret) {
log.i("attempting to import a nostr key that is already here, aborting...")
- return WriteNostrCredentialResult.AlreadyExists
+ return WriteNostrCredentialResult.AlreadyExists(stored.id)
}
val upgrading = existing[stored.nostrPublicKey] is NostrCredential.Public
@@ -277,15 +284,19 @@ object IdentityWriter {
log.d("writing nostr public key to disk...")
val stored = StoredIdentity.nostrPublic(nostrPublicKey)
- val seeds = seedPublicKeys(phoenixGlobal)
+ val seeds = seedWalletIds(phoenixGlobal)
val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal)
if (seeds == null || existing == null) {
log.e("could not load the existing keys, aborting...")
return WriteNostrCredentialResult.CannotLoadKeys
}
- if (stored.nostrPublicKey in seeds || existing.containsKey(stored.nostrPublicKey)) {
+ seeds[stored.nostrPublicKey]?.let { walletId ->
+ log.i("attempting to add the public key of a wallet that is already here, aborting...")
+ return WriteNostrCredentialResult.AlreadyExists(walletId)
+ }
+ if (existing.containsKey(stored.nostrPublicKey)) {
log.i("attempting to add a public key the device already holds something for, aborting...")
- return WriteNostrCredentialResult.AlreadyExists
+ return WriteNostrCredentialResult.AlreadyExists(stored.id)
}
NostrCredentialManager.writeToDisk(
@@ -327,13 +338,13 @@ object IdentityWriter {
id: WalletId,
nostrPublicKey: HexKey,
): ForgetNostrCredentialResult {
- val seeds = seedPublicKeys(phoenixGlobal)
+ val seeds = seedWalletIds(phoenixGlobal)
val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal)
if (seeds == null || existing == null) {
log.e("could not load the existing keys, aborting...")
return ForgetNostrCredentialResult.CannotLoadKeys
}
- if (nostrPublicKey in seeds) {
+ if (seeds.containsKey(nostrPublicKey)) {
log.i("asked to forget a key a seed derives; the wallet has to go first")
return ForgetNostrCredentialResult.WalletAttached
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt
new file mode 100644
index 00000000..fb8df175
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt
@@ -0,0 +1,24 @@
+package press.mantra.compose.identity
+
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import fr.acinq.phoenix.data.WalletId
+
+/** What a [NewProfileWriter] made: the key the new profile signs as, and the id it is listed under. */
+data class NewProfile(val nostrPublicKey: HexKey, val id: WalletId)
+
+/**
+ * Makes the secret a new profile signs with and puts it on disk.
+ *
+ * Two live in the app, and the create screen does not know which it was given. One
+ * generates twelve words and writes a seed -- for Landing, where the first profile on a
+ * device is the one the wallet will belong to. The other generates a bare key and
+ * writes a credential -- for the switcher, where a user who wants another profile has
+ * not asked for another Lightning node, another set of channels, or a second phrase
+ * with funds behind it. Under docs/multiple-profiles.md the two differ by exactly one
+ * thing, whether a wallet is attached to the profile they make, which is what the model
+ * was for.
+ */
+fun interface NewProfileWriter {
+ /** Generates and writes the secret. A failure is a write that did not happen; nothing is half-made. */
+ suspend fun write(): Result
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt
index 28cc9ee3..13991e6c 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt
@@ -16,7 +16,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
@@ -34,6 +33,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import press.mantra.compose.repository.MarmotRepository
+import press.mantra.compose.identity.NewProfileWriter
+import fr.acinq.phoenix.data.WalletId
import press.mantra.compose.ui.theme.LocalExtendedColors
import press.mantra.compose.ui.view.model.CreateProfileViewModel
import press.mantra.compose.ui.view.state.CreateProfileUIState
@@ -43,7 +44,6 @@ import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.after_this_there_is_no_turning_back
import mantra.composeapp.generated.resources.bio
import mantra.composeapp.generated.resources.create_profile
-import mantra.composeapp.generated.resources.end_this
import mantra.composeapp.generated.resources.enter_the_name_you_want_to_use_for_your_2
import mantra.composeapp.generated.resources.introduce_yourself
import mantra.composeapp.generated.resources.it_is_cryptographical_secure_and
@@ -70,11 +70,13 @@ import press.mantra.compose.ui.theme.ConformancePreviews
@Composable
fun CreateProfileScreen(
initialCreateProfileUIState: CreateProfileUIState = CreateProfileUIState.Declaration,
- onNavigateToEndThis: () -> Unit,
onNavigateBack: () -> Unit,
nostrRepository: press.mantra.compose.repository.NostrRepository,
marmotRepository: MarmotRepository,
- writeSeed: (mnemonics: List, onSeedWritten: () -> Unit, onSeedWriteError: () -> Unit) -> Unit
+ /** Which secret the profile is made of -- a seed from Landing, a bare key from the switcher. The screen does not know which. */
+ newProfile: NewProfileWriter,
+ /** The nav host's tail: re-list, select, go to startup. */
+ onProfileCreated: (WalletId) -> Unit,
) {
val createProfileViewModel: CreateProfileViewModel = viewModel (
factory = CreateProfileViewModel.factory(
@@ -309,7 +311,7 @@ fun CreateProfileScreen(
} else {
Button(
onClick = {
- createProfileViewModel.createAccount(writeSeed)
+ createProfileViewModel.createAccount(newProfile, onProfileCreated)
}
) {
Text(
@@ -333,11 +335,16 @@ fun CreateProfileScreen(
)
}
is CreateProfileUIState.ProfileReady -> {
- // Both pills come from the extended families rather than from a
- // literal paired with Color.White/Color.DarkGray by eye. The old
- // pairings read 4.57:1 and 2.90:1 against their containers, and
- // RedPill carried alpha 0.749, so composited over the surface the
- // first was really 3.50:1. Both are below the 4.5:1 floor.
+ // The pill comes from the extended family rather than from a
+ // literal paired with Color.White by eye: the old pairing read
+ // 4.57:1 against its container, and with alpha 0.749 composited
+ // over the surface was really 3.50:1, below the 4.5:1 floor.
+ //
+ // The blue pill that stood beside it -- "end this" -- is gone. It
+ // wiped the database: every profile's rooms, MLS state, key
+ // packages and queues, from a screen whose purpose is to add a
+ // profile. It was only ever a development exit, and with several
+ // profiles on a device it was a way to lose the others.
val extendedColors = LocalExtendedColors.current
Spacer(
@@ -359,34 +366,13 @@ fun CreateProfileScreen(
contentColor = extendedColors.redPill.onColor
),
onClick = {
- createProfileViewModel.createAccount(writeSeed)
+ createProfileViewModel.createAccount(newProfile, onProfileCreated)
}
) {
Text(
text = stringResource(Res.string.see_how_deep_the_rabbit_hole_goes)
)
}
-
- HorizontalDivider(
- modifier = Modifier.padding(MaterialTheme.spacing.space250)
- )
-
- Button(
- colors = ButtonDefaults.buttonColors(
- containerColor = extendedColors.bluePill.color,
- contentColor = extendedColors.bluePill.onColor
- ),
- onClick = {
- // TODO: Delete everything and close the app
- createProfileViewModel.wipeDatabase {
- onNavigateToEndThis.invoke()
- }
- }
- ) {
- Text(
- text = stringResource(Res.string.end_this)
- )
- }
Spacer(
modifier = Modifier.weight(2f)
)
@@ -441,9 +427,9 @@ fun CreateAccountScreenPreview() {
// about = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider."
// )
// ),
- onNavigateToEndThis = {},
onNavigateBack = {},
- writeSeed = { _, _, _ -> },
+ newProfile = NewProfileWriter { Result.failure(IllegalStateException("a preview")) },
+ onProfileCreated = {},
nostrRepository = press.mantra.compose.repository.NostrRepository.NO_OP_NOSTR_REPOSITORY,
marmotRepository = MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt
index cd0805cc..9efbac71 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt
@@ -23,6 +23,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
+import press.mantra.compose.ui.composable.widgets.wallet.AddProfileRows
import press.mantra.compose.ui.composable.widgets.wallet.WalletsSelector
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.theme.spacing
@@ -49,8 +50,9 @@ fun ProfilesScreen(
onSwitch: (WalletId) -> Unit,
/** Re-lists. An unreadable key file is exactly the failure a retry can answer. */
onRetry: () -> Unit,
- /** The two ways to add a profile, drawn under the list; Phase 5 fills the slot. */
- addProfile: (@Composable () -> Unit)? = null,
+ /** The two ways to add a profile, under the list. Both push a screen with a back button. */
+ onSignIn: () -> Unit,
+ onCreateProfile: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsState()
@@ -83,7 +85,9 @@ fun ProfilesScreen(
canEdit = false,
onWalletClick = { onSwitch(it.id) },
profiles = state.profiles,
- bottomContent = addProfile,
+ bottomContent = {
+ AddProfileRows(onSignIn = onSignIn, onCreateProfile = onCreateProfile, caption = true)
+ },
)
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt
index 050702ba..a809c665 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt
@@ -48,6 +48,7 @@ import mantra.composeapp.generated.resources.recognised_as_a_recovery_phrase_it_
import mantra.composeapp.generated.resources.recovery_phrase_nsec_or_npub
import mantra.composeapp.generated.resources.sign_in
import mantra.composeapp.generated.resources.signed_in
+import mantra.composeapp.generated.resources.switch_to_it
import mantra.composeapp.generated.resources.signing_you_in
import mantra.composeapp.generated.resources.that_is_not_a_recovery_phrase_or_a_nostr_key
import mantra.composeapp.generated.resources.that_nostr_secret_key_is_not_valid
@@ -102,6 +103,11 @@ fun SignInToProfileScreen(
onError: (WritingSeedState.Error) -> Unit,
) -> Unit,
onNavigateBack: () -> Unit,
+ /**
+ * The tail: re-list, select, go to startup. Also what "switch to it" does when the key
+ * pasted is already here -- the same tail with the id it is here under, which makes a
+ * duplicate paste the fastest switch in the app.
+ */
onSignedIn: (WalletId) -> Unit,
) {
val viewModel: SignInToProfileViewModel = viewModel(
@@ -151,10 +157,21 @@ fun SignInToProfileScreen(
text = stringResource(Res.string.signing_you_in)
)
- is SignInToProfileUIState.Error -> ErrorState(
- message = problemMessage(state.problem),
- onRetry = { viewModel.retry() },
- )
+ is SignInToProfileUIState.Error -> Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ ErrorState(
+ message = problemMessage(state.problem),
+ onRetry = { viewModel.retry() },
+ )
+ // "Already on this device" is a sentence with an obvious next step.
+ state.alreadyHere?.let { id ->
+ TextButton(onClick = { onSignedIn(id) }) {
+ Text(stringResource(Res.string.switch_to_it))
+ }
+ }
+ }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt
index 0d80ba68..d0e04ab7 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt
@@ -29,6 +29,7 @@ import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.IdentityKind
import press.mantra.compose.identity.StoredIdentity
import press.mantra.compose.repository.NostrRepository
+import press.mantra.compose.ui.composable.widgets.wallet.AddProfileRows
import press.mantra.compose.ui.composable.widgets.wallet.rememberProfilesOf
import press.mantra.compose.ui.theme.spacing
import mantra.composeapp.generated.resources.Res
@@ -52,6 +53,9 @@ fun SovereignWalletStartupScreen(
nostrRepository: NostrRepository,
onNavigateToWalletLandingPage: () -> Unit,
onSuccessfulStartup: () -> Unit,
+ /** The two ways to add a profile from the selector; a device with one profile can be given a second without opening the first. */
+ onNavigateToSignIn: () -> Unit,
+ onNavigateToCreateProfile: () -> Unit,
forceWalletId: WalletId?,
) {
val sovereignWalletStartupViewModel = viewModel(
@@ -168,8 +172,13 @@ fun SovereignWalletStartupScreen(
Spacer(Modifier.height(MaterialTheme.spacing.space200))
},
bottomContent = {
+ AddProfileRows(
+ onSignIn = onNavigateToSignIn,
+ onCreateProfile = onNavigateToCreateProfile,
+ caption = false,
+ )
// m3-spacing-exempt: room to scroll the
- // last wallet clear of the bottom of the
+ // last row clear of the bottom of the
// window, not a step in the spacing
// rhythm. The scale tops out at 72dp and
// rounding to it would put the last row
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
index 6d05f453..ad9e3731 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
@@ -90,6 +90,8 @@ import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute
import press.mantra.compose.ui.composable.ProfilesScreen
import press.mantra.compose.ui.view.model.ProfilesViewModel
import press.mantra.compose.ui.view.model.getAvailableWalletsMeta
+import press.mantra.compose.ui.view.model.bareKeyProfileWriter
+import press.mantra.compose.ui.view.model.seedProfileWriter
import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchRoute
@@ -475,6 +477,12 @@ fun MantraNavHost(
navigationViewModel.loadNostrProfile(route)
}
},
+ onNavigateToSignIn = {
+ navController.navigate(route = SignInRoute)
+ },
+ onNavigateToCreateProfile = {
+ navController.navigate(route = CreateProfileRoute(withWallet = false))
+ },
forceWalletId = null,
)
}
@@ -499,47 +507,41 @@ fun MantraNavHost(
route = SignInRoute
)
},
+ // The first profile on a device is the one the wallet will belong to.
onNavigateToCreateProfile = {
navController.navigate(
- route = CreateProfileRoute()
+ route = CreateProfileRoute(withWallet = true)
)
}
)
}
- composable {
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
CreateProfileScreen(
- onNavigateToEndThis = {
- navController.navigate(
- route = BlankRoute
- ) {
- popUpTo(0)
- }
- },
onNavigateBack = {
navController.popBackStack()
},
nostrRepository = databaseNostrRepository,
marmotRepository = databaseMarmotRepository,
- writeSeed = { words, onSeedWritten, onSeedWriteError ->
- sovereignWalletViewModel.writeSeed(
- words,
- isRestoringWallet = false,
- onSeedWritten = { walletId ->
- onSeedWritten()
-
- sovereignWalletViewModel.loadSovereignData(walletId)
- sovereignWalletViewModel.listIdentities {
- sovereignWalletViewModel.switchToIdentity(walletId)
- navController.navigate(
- route = SovereignWalletStartupRoute
- )
- }
- },
- onSeedWriteError = { onSeedWriteError() }
- )
- }
+ // A seed from Landing, a bare key from inside: the screen does not know
+ // which. See docs/multiple-profiles.md, Phase 5.
+ newProfile = if (route.withWallet) sovereignWalletViewModel.seedProfileWriter() else sovereignWalletViewModel.bareKeyProfileWriter(),
+ // The sign-in tail, with the popUpTo(0) it always had there: back from the
+ // new profile's startup must not return to a form whose secret is already
+ // on disk. The account rows are in the database before this runs, so the
+ // machine finds them the moment the identity is activated.
+ onProfileCreated = { walletId ->
+ sovereignWalletViewModel.loadSovereignData(walletId)
+ sovereignWalletViewModel.listIdentities {
+ sovereignWalletViewModel.switchToIdentity(walletId)
+ navController.navigate(
+ route = SovereignWalletStartupRoute
+ ) {
+ popUpTo(0)
+ }
+ }
+ },
)
-
}
composable { backStackEntry ->
val route = backStackEntry.toRoute()
@@ -917,6 +919,12 @@ fun MantraNavHost(
onRetry = {
sovereignWalletViewModel.listIdentities {}
},
+ onSignIn = {
+ navController.navigate(route = SignInRoute)
+ },
+ onCreateProfile = {
+ navController.navigate(route = CreateProfileRoute(withWallet = false))
+ },
)
}
composable { backStackEntry ->
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt
index b0ea6406..b05b9442 100755
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt
@@ -2,7 +2,13 @@ package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
+/**
+ * @param withWallet whether the profile is made of a seed, with a wallet attached, or of
+ * a bare key. True from Landing, where the first profile on a device is the one the
+ * wallet will belong to; false from the switcher, where wanting another profile is not
+ * wanting another wallet. See docs/multiple-profiles.md, Phase 5.
+ */
@Serializable
data class CreateProfileRoute(
- val nostrEventId: String? = null,
-): Route()
\ No newline at end of file
+ val withWallet: Boolean,
+): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt
new file mode 100644
index 00000000..c21e969b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt
@@ -0,0 +1,75 @@
+package press.mantra.compose.ui.composable.widgets.wallet
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Key
+import androidx.compose.material.icons.filled.PersonAdd
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import mantra.composeapp.generated.resources.Res
+import mantra.composeapp.generated.resources.create_a_new_profile
+import mantra.composeapp.generated.resources.sign_in_with_a_key
+import mantra.composeapp.generated.resources.the_profile_you_are_in_stays_on_this_device
+import org.jetbrains.compose.resources.stringResource
+import press.mantra.compose.ui.composable.widgets.Decorative
+import press.mantra.compose.ui.theme.spacing
+
+/**
+ * The two ways to add a profile, under the list of the ones the device has -- on the
+ * switcher and on the startup selector alike. Phase 5 of docs/multiple-profiles.md.
+ *
+ * Both push a screen that has a back button. Landing is not one of them: it has no back
+ * button and clears the stack on its way out, because it is the first-run screen, and
+ * pushing it from inside would make it a pushed screen on some days and a root on
+ * others.
+ *
+ * @param caption said under the rows where there is a profile to say it of -- the
+ * switcher -- and omitted on the startup selector, where nothing is open.
+ */
+@Composable
+fun AddProfileRows(
+ onSignIn: () -> Unit,
+ onCreateProfile: () -> Unit,
+ caption: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(Modifier.height(MaterialTheme.spacing.itemGap))
+ HorizontalDivider()
+ Spacer(Modifier.height(MaterialTheme.spacing.itemGap))
+ TextButton(onClick = onSignIn) {
+ Icon(Icons.Default.Key, contentDescription = Decorative)
+ Spacer(Modifier.width(MaterialTheme.spacing.relatedGap))
+ Text(stringResource(Res.string.sign_in_with_a_key))
+ }
+ TextButton(onClick = onCreateProfile) {
+ Icon(Icons.Default.PersonAdd, contentDescription = Decorative)
+ Spacer(Modifier.width(MaterialTheme.spacing.relatedGap))
+ Text(stringResource(Res.string.create_a_new_profile))
+ }
+ if (caption) {
+ Text(
+ text = stringResource(Res.string.the_profile_you_are_in_stays_on_this_device),
+ modifier = Modifier.padding(MaterialTheme.spacing.compactPadding),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt
index dce2283c..7c2ec497 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt
@@ -12,20 +12,13 @@ import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.view.state.CreateProfileUIState
import press.mantra.compose.ui.view.state.form.CreateProfileFormState
import co.touchlab.kermit.Logger
-import fr.acinq.bitcoin.MnemonicCode
-import fr.acinq.bitcoin.byteVector
-import fr.acinq.lightning.Lightning
-import fr.acinq.lightning.crypto.LocalKeyManager
-import fr.acinq.phoenix.managers.NodeParamsManager
-import fr.acinq.phoenix.managers.nostrPublicKey
-import fr.acinq.phoenix.managers.nsecPassword
-import fr.acinq.phoenix.utils.MnemonicLanguage
+import fr.acinq.phoenix.data.WalletId
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
-import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
-import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.withContext
+import press.mantra.compose.identity.NewProfileWriter
class CreateProfileViewModel(
val initialCreateProfileUIState: CreateProfileUIState,
@@ -78,19 +71,24 @@ class CreateProfileViewModel(
}
- private fun failProfileCreation(e: Throwable? = null) {
- if (e != null) {
- logger.e("error when creating new profile: ", e)
- } else {
- logger.e { "seed write failed, aborting profile creation" }
- }
+ private fun failProfileCreation(e: Throwable) {
+ logger.e("error when creating new profile: ", e)
createProfileUIState.value = CreateProfileUIState.Error
isActionPending.value = false
}
- fun createAccount(
- writeSeed: (mnemonics: List, onSeedWritten: () -> Unit, onSeedWriteError: () -> Unit) -> Unit
- ) {
+ /**
+ * Makes the profile: the secret through [newProfile], then the six bootstrap events
+ * for the key it derives, then [onProfileCreated] with the id, on the main thread.
+ *
+ * The secret is written first, so that the events are only ever queued for a key the
+ * device holds and the notary can sign; and the events are in the database before the
+ * caller's tail activates the identity, so that the navigation machine finds the
+ * account the moment it looks -- an ordering the sign-in screen documents as
+ * load-bearing and the create flow used to get away with by luck. Which secret is made
+ * -- a seed, or a bare key -- is the writer's business; see [NewProfileWriter].
+ */
+ fun createAccount(newProfile: NewProfileWriter, onProfileCreated: (WalletId) -> Unit) {
logger.d { "createAccount" }
if (isActionPending.value) return
isActionPending.value = true
@@ -98,52 +96,21 @@ class CreateProfileViewModel(
viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e ->
failProfileCreation(e)
}) {
- logger.d("generating new wallet...")
- val entropy = Lightning.randomBytes(16)
- val mnemonics = MnemonicCode.toMnemonics(
- entropy = entropy,
- wordlist = MnemonicLanguage.English.wordlist()
+ val written = newProfile.write().getOrElse { e ->
+ failProfileCreation(e)
+ return@launch
+ }
+ logger.d("Pubkey: ${written.nostrPublicKey}")
+
+ nostrRepository.createNewProfile(
+ written.nostrPublicKey,
+ name = createProfileFormState.nameField.textFieldState.text.toString(),
+ biography = createProfileFormState.biographyField.textFieldState.text.toString(),
+ onCompletion = {
+ logger.d("Created: ${written.nostrPublicKey}")
+ }
)
-
- val localKeyManager = LocalKeyManager(
- seed = MnemonicCode.toSeed(mnemonics, "").byteVector(),
- chain = NodeParamsManager.chain,
- remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub
- )
-
- val pubkey = localKeyManager.nostrPublicKey()
-
- logger.d("Pubkey: $pubkey" )
-
- // Only create the profile events once the seed backing this pubkey is safely
- // on disk; otherwise the notary could never sign them.
- writeSeed(
- mnemonics,
- {
- viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e ->
- failProfileCreation(e)
- }) {
- nostrRepository.createNewProfile(
- pubkey,
- name = createProfileFormState.nameField.textFieldState.text.toString(),
- biography = createProfileFormState.biographyField.textFieldState.text.toString(),
- onCompletion = {
- logger.d("Created: $pubkey" )
- }
- )
- }
- },
- { failProfileCreation() }
- )
- }
- }
-
- fun wipeDatabase(
- onNavigateToEndThis: () -> Unit
- ) {
- viewModelScope.launch {
- nostrRepository.wipeDatabase()
- onNavigateToEndThis.invoke()
+ withContext(Dispatchers.Main) { onProfileCreated(written.id) }
}
}
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt
new file mode 100644
index 00000000..11d06d87
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt
@@ -0,0 +1,40 @@
+package press.mantra.compose.ui.view.model
+
+import fr.acinq.bitcoin.MnemonicCode
+import fr.acinq.bitcoin.PrivateKey
+import fr.acinq.lightning.Lightning
+import fr.acinq.phoenix.managers.nostrPublicKeyHex
+import fr.acinq.phoenix.utils.MnemonicLanguage
+import kotlinx.coroutines.CompletableDeferred
+import press.mantra.compose.identity.IdentityWriter
+import press.mantra.compose.identity.NewProfile
+import press.mantra.compose.identity.NewProfileWriter
+import press.mantra.compose.identity.StoredIdentity
+
+/**
+ * A profile with a wallet attached: twelve fresh words through the seed writer, which
+ * since docs/multiple-profiles.md writes the derived key to the credentials file as
+ * well. Callback-shaped underneath, awaited here: exactly one of the two callbacks
+ * fires.
+ */
+fun SovereignWalletViewModel.seedProfileWriter(): NewProfileWriter = NewProfileWriter {
+ val words = MnemonicCode.toMnemonics(Lightning.randomBytes(16), MnemonicLanguage.English.wordlist())
+ val outcome = CompletableDeferred>()
+ writeSeed(
+ mnemonics = words,
+ isRestoringWallet = false,
+ onSeedWritten = { id -> outcome.complete(Result.success(NewProfile(StoredIdentity.nostrPublicKeyOf(words), id))) },
+ onSeedWriteError = { error -> outcome.complete(Result.failure(IllegalStateException("seed write failed: $error"))) },
+ )
+ outcome.await()
+}
+
+/** A profile that is a key: thirty-two random bytes, written the way a pasted nsec is. */
+fun SovereignWalletViewModel.bareKeyProfileWriter(): NewProfileWriter = NewProfileWriter {
+ val key = PrivateKey(Lightning.randomBytes(32))
+ when (val result = writeNostrKey(key)) {
+ is IdentityWriter.WriteNostrCredentialResult.Written -> Result.success(NewProfile(key.nostrPublicKeyHex(), result.id))
+ is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Result.failure(IllegalStateException("a fresh key collided with one already here"))
+ is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Result.failure(IllegalStateException("the credentials could not be read"))
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt
index 82e9a8be..4155310c 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt
@@ -104,13 +104,15 @@ class SignInToProfileViewModel(
/** The outcome of [commit], for the screen to act on and the tests to assert. */
sealed interface Outcome {
data class SignedIn(val id: WalletId) : Outcome
- data class Failed(val problem: CredentialProblem) : Outcome
+
+ /** @param alreadyHere for [CredentialProblem.AlreadyOnThisDevice], the id the key is listed under. */
+ data class Failed(val problem: CredentialProblem, val alreadyHere: WalletId? = null) : Outcome
}
private suspend fun write(what: String, writer: suspend () -> IdentityWriter.WriteNostrCredentialResult): Outcome = try {
when (val result = writer()) {
is IdentityWriter.WriteNostrCredentialResult.Written -> Outcome.SignedIn(result.id)
- is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice)
+ is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere = result.id)
is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite)
}
} catch (e: CancellationException) {
@@ -141,13 +143,11 @@ class SignInToProfileViewModel(
{ id -> outcome.complete(Outcome.SignedIn(id)) },
{ error ->
outcome.complete(
- Outcome.Failed(
- when (error) {
- is WritingSeedState.Error.SeedAlreadyExists -> CredentialProblem.AlreadyOnThisDevice
- is WritingSeedState.Error.CannotLoadSeedMap,
- is WritingSeedState.Error.Generic -> CredentialProblem.CouldNotWrite
- }
- )
+ when (error) {
+ is WritingSeedState.Error.SeedAlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere = error.id)
+ is WritingSeedState.Error.CannotLoadSeedMap,
+ is WritingSeedState.Error.Generic -> Outcome.Failed(CredentialProblem.CouldNotWrite)
+ }
)
},
)
@@ -170,7 +170,7 @@ class SignInToProfileViewModel(
viewModelScope.launch(Dispatchers.IO) {
when (val outcome = commit(credential)) {
is Outcome.SignedIn -> withContext(Dispatchers.Main) { onSignedIn(outcome.id) }
- is Outcome.Failed -> uiState.value = SignInToProfileUIState.Error(outcome.problem)
+ is Outcome.Failed -> uiState.value = SignInToProfileUIState.Error(outcome.problem, outcome.alreadyHere)
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt
index 2a9dbe37..1b0ea520 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt
@@ -52,7 +52,8 @@ sealed class WritingSeedState {
sealed class Error : WritingSeedState() {
data class Generic(val cause: Throwable) : Error()
data object CannotLoadSeedMap: Error()
- data object SeedAlreadyExists: Error()
+ /** [id] is the wallet's, so that a refusal from inside the app can offer to switch to it. */
+ data class SeedAlreadyExists(val id: WalletId): Error()
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt
index fb233399..1710d0e2 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt
@@ -1,5 +1,6 @@
package press.mantra.compose.ui.view.state
+import fr.acinq.phoenix.data.WalletId
import press.mantra.compose.identity.CredentialProblem
import press.mantra.compose.identity.SignInCredential
@@ -23,5 +24,10 @@ sealed interface SignInToProfileUIState {
/** Writing the secret and planting the account. */
data object Committing : SignInToProfileUIState
- data class Error(val problem: CredentialProblem) : SignInToProfileUIState
+ /**
+ * @param alreadyHere for [CredentialProblem.AlreadyOnThisDevice], the id the key is
+ * listed under: "already on this device" is a sentence with an obvious next step,
+ * and from inside the app the screen offers it.
+ */
+ data class Error(val problem: CredentialProblem, val alreadyHere: WalletId? = null) : SignInToProfileUIState
}
diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt
new file mode 100644
index 00000000..74991562
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt
@@ -0,0 +1,26 @@
+package press.mantra.compose.extensions
+
+import fr.acinq.bitcoin.Chain
+import fr.acinq.phoenix.PhoenixGlobal
+import fr.acinq.phoenix.managers.DataStoreManager
+import fr.acinq.phoenix.utils.preferences.GlobalPrefs
+
+/**
+ * The one way the jvm target reaches the global preferences.
+ *
+ * `DataStoreManager.loadGlobalPrefsForWallet` caches one `GlobalPrefs` per process behind
+ * an unsynchronised check-then-set, and DataStore refuses a second instance over the same
+ * file. Two first calls at once -- the listing on IO and the startup screen on Main, or a
+ * sign-in's write and the listing it races -- can both see the empty cache, both build
+ * one, and the loser throws "multiple DataStores active for the same file" on first use.
+ * Android is unaffected, since it reaches the prefs through the Application's single
+ * instance; the jvm actuals all went through the cache, and this serialises them. The
+ * library's own callers come later, at node start, when the cache is long populated.
+ */
+internal object JvmGlobalPrefs {
+ private val lock = Any()
+
+ fun of(phoenixGlobal: PhoenixGlobal): GlobalPrefs = synchronized(lock) {
+ DataStoreManager(phoenixGlobal.ctx, chain = Chain.Mainnet).loadGlobalPrefsForWallet()
+ }
+}
diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt
index d47aa7ec..31d70efd 100644
--- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt
+++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt
@@ -1,11 +1,9 @@
package press.mantra.compose.extensions
import co.touchlab.kermit.Logger
-import fr.acinq.bitcoin.Chain
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.jvm.BusinessManager
-import fr.acinq.phoenix.managers.DataStoreManager
import fr.acinq.phoenix.utils.preferences.GlobalPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -44,9 +42,9 @@ actual fun schedulePlatformLogic(phoenixGlobal: PhoenixGlobal) {
}
actual fun getShowIntroFlow(phoenixGlobal: PhoenixGlobal): Flow {
- return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet().getShowIntro
+ return JvmGlobalPrefs.of(phoenixGlobal).getShowIntro
}
actual fun getGlobalPrefs(phoenixGlobal: PhoenixGlobal): GlobalPrefs {
- return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet()
+ return JvmGlobalPrefs.of(phoenixGlobal)
}
diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt
index acea8fb4..1d9fa171 100644
--- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt
+++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt
@@ -8,15 +8,14 @@
package press.mantra.compose.ui.view.model
-import fr.acinq.bitcoin.Chain
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.DecryptSeedResult
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.jvm.BusinessManager
-import fr.acinq.phoenix.managers.DataStoreManager
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import kotlinx.coroutines.flow.Flow
+import press.mantra.compose.extensions.JvmGlobalPrefs
actual fun updateBusinessActiveInUI(walletId: WalletId) {
@@ -34,20 +33,14 @@ actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult {
}
actual fun getAvailableWalletsMeta(phoenixGlobal: PhoenixGlobal): Flow