From 786ac159597b9c4f5ecf9aec3ff94fef3206d920 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:45:34 +0200 Subject: [PATCH] feat(identity): two exits for an identity that holds no key Phase 6 of docs/npub-sign-in.md. Leaving: one sequence, two doors. ForgetIdentity is NostrSecretViewModel.forgetKey's sequence lifted out -- the credential out of the file, the metadata hidden, the account rows gone, in that order so a failure partway leaves the credential on disk rather than an identity the selector lists but nothing can open. Since the credentials file the first step is the same call for both credential kinds, so it is one function; a mnemonic identity is refused at the first step, because removing a seed is a wallet question this does not answer. The nsec view model calls it now and keeps only its own state. Sign out on the profile tab does, for a read-only identity only, what its colour has been promising: a confirmation naming the npub -- this device holds no key for it, so there is nothing to lose; the profile stays on the relays -- then the identity leaves the device and the nav host's tail re-lists and clears the active identity, which shows the selector or, if this was the last one, Landing. It is the first real sign out in the app. For the other two kinds the button keeps its pending route. SignOutViewModel owns the confirmation and the in-flight state; SignOutDependencies bundles what the screen needs so that a caller with none of it -- previews, tests -- passes nothing. The not-found screen had, for a read-only identity, no exit: Phase 5 hid the set-up form (a kind 0 has to be signed), the profile tab is not reachable before ProfileLoaded, and a user whose npub was found on no relay could try again for ever. So a third action, shown only where the second is not: use a different key, which is the same sequence behind the same dialog, reached from the other end of the identity's life. Tests: the sequence and where it stops, the view model's states around it, and the two screens composed as each kind -- the profile's sign out asking first and naming the npub, the not-found screen offering the form to one kind and the other key to the other. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@a586b7c116529f01a4414e40b0eb41f5dff419cc --- .../composeResources/values/strings.xml | 4 + .../mantra/compose/identity/ForgetIdentity.kt | 50 ++++++++ .../ui/composable/ActiveProfileScreen.kt | 72 ++++++++++- .../ui/composable/UnsyncedProfileScreen.kt | 60 ++++++++- .../ui/composable/navigation/MantraNavHost.kt | 23 +++- .../dialogs/SignOutOfReadOnlyDialog.kt | 43 +++++++ .../ui/view/model/NostrSecretViewModel.kt | 24 ++-- .../compose/ui/view/model/SignOutViewModel.kt | 106 ++++++++++++++++ .../compose/identity/ForgetIdentityJvmTest.kt | 95 +++++++++++++++ .../ui/composable/ReadOnlyEntrancesJvmTest.kt | 114 +++++++++++++++++- .../ui/view/model/SignOutViewModelJvmTest.kt | 106 ++++++++++++++++ 11 files changed, 678 insertions(+), 19 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 593c6b04..de74e248 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -267,6 +267,10 @@ Read only Messages need the secret key. This profile is read only: you can see it and the people it follows, but nothing here can be opened or sent. Sign in with the nsec + Sign out of this profile? + Use a different key? + This device holds no key for %1$s, so there is nothing to lose. The profile stays on the relays, and you can sign in again any time. + Could not sign out. Please try again. This will give you write access to the profile. Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised. Translate chunk diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt new file mode 100644 index 00000000..d37c2c03 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt @@ -0,0 +1,50 @@ +package press.mantra.compose.identity + +import press.mantra.compose.repository.NostrRepository + +/** + * Takes an identity off the device: the credential out of the file, its preference + * files deleted, its metadata hidden, its account rows gone -- in that order, so a + * failure partway leaves the credential on disk rather than an identity the selector + * lists but nothing can open. The caller then re-lists and clears the active identity. + * + * Lifted out of `NostrSecretViewModel.forgetKey`, where it was written for an nsec, so + * that the two exits a read-only identity has -- sign out, and *use a different key* on + * the not-found screen -- run the same sequence rather than a copy of it. The first step + * is the same call for both credential kinds since the credentials file; only a mnemonic + * identity is refused, because removing a seed is a wallet question this does not + * answer. + * + * The two writers are injected as functions so the sequence can be pinned in a test + * without a key store, as the nsec view model's already is. + */ +object ForgetIdentity { + + sealed interface Outcome { + /** The device no longer holds anything for the identity. */ + data object Forgotten : Outcome + + /** A mnemonic identity: its key is in the seed, and this does not remove seeds. */ + data object NotACredential : Outcome + + /** The credentials file could not be read; nothing was changed. */ + data object CannotForget : Outcome + } + + suspend fun forget( + identity: Identity, + nostrRepository: NostrRepository, + forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + hideIdentityMetadata: suspend (Identity) -> Unit, + ): Outcome = when (forgetNostrCredential(identity)) { + is IdentityWriter.ForgetNostrCredentialResult.Forgotten -> { + hideIdentityMetadata(identity) + // The kind 0 that made it a local account, and anything queued that can now + // never be signed. Published events and the profile cache stay. + nostrRepository.forgetLocalAccount(identity.nostrPublicKey) + Outcome.Forgotten + } + is IdentityWriter.ForgetNostrCredentialResult.NotACredential -> Outcome.NotACredential + is IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys -> Outcome.CannotForget + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index cf1920e1..f467485c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt @@ -62,6 +62,16 @@ import mantra.composeapp.generated.resources.profile import mantra.composeapp.generated.resources.key_recovery import mantra.composeapp.generated.resources.share_profile import mantra.composeapp.generated.resources.sign_out +import mantra.composeapp.generated.resources.sign_out_of_this_profile_question +import mantra.composeapp.generated.resources.could_not_sign_out_please_try_again +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.ui.composable.widgets.dialogs.SignOutOfReadOnlyDialog +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.view.model.SignOutViewModel +import kotlinx.coroutines.flow.StateFlow +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope import mantra.composeapp.generated.resources.something_went_wrong import mantra.composeapp.generated.resources.we_couldn_t_find_the_local_profile_please import press.mantra.compose.ui.composable.widgets.ErrorState @@ -83,11 +93,42 @@ fun ActiveProfileScreen( onNavigateBack: () -> Unit, onNavigateToRoute: (Route) -> Unit, nostrRepository: NostrRepository, + // Sign out is real for a read-only identity only -- there is nothing on the device + // to lose -- and these are what it takes. Null where the screen is composed without + // an identity to sign out of (previews, tests), in which case the button keeps its + // pending route for every kind. + signOut: SignOutDependencies? = null, ) { // Whether this identity can sign, which decides which rows below exist. val canSign = LocalCanSign.current + val signOutViewModel: SignOutViewModel? = signOut?.let { + viewModel( + factory = SignOutViewModel.factory( + activeIdentityStateFlow = it.activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrCredential = it.forgetNostrCredential, + hideIdentityMetadata = it.hideIdentityMetadata, + ), + ) + } + val signOutState = signOutViewModel?.state?.collectAsState()?.value + if (signOutViewModel != null && signOut != null && signOutState is SignOutViewModel.State.Confirming) { + SignOutOfReadOnlyDialog( + title = Res.string.sign_out_of_this_profile_question, + nostrPublicKey = signOutViewModel.nostrPublicKey, + onConfirm = { signOutViewModel.signOut(onSignedOut = signOut.onSignedOut) }, + onDismiss = { signOutViewModel.cancel() }, + ) + } + // The failure is short and needs no action: the button is still there to try again. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotSignOut = stringResource(Res.string.could_not_sign_out_please_try_again) + LaunchedEffect(signOutState) { + if (signOutState is SignOutViewModel.State.Failed) notify(couldNotSignOut) + } + val activeProfileViewModel: ActiveProfileViewModel = viewModel( factory = ActiveProfileViewModel.factory( nostrEventId = nostrEventId, @@ -339,14 +380,26 @@ fun ActiveProfileScreen( // destructive action. It now matches how leaving and // deleting a group are already treated in // ChatRoomDetailScreen: a TextButton in the error colour. + // + // For a read-only identity it does what its colour has been + // promising: a confirmation naming the npub, then the + // identity leaves the device. For the other two kinds it + // keeps routing to the pending screen -- removing a seed is + // a wallet question, and an nsec has its own forget under + // key recovery. TextButton( colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error ), + enabled = signOutState !is SignOutViewModel.State.InProgress, onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Sign out") - ) + if (!canSign && signOutViewModel != null) { + signOutViewModel.askToSignOut() + } else { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Sign out") + ) + } } ) { Icon( @@ -387,6 +440,19 @@ fun ActiveProfileScreen( } } +/** + * What signing out of a read-only identity needs, bundled so that the screen's parameter + * list gains one line rather than four, and so a caller that has none of them passes + * nothing rather than three lambdas that throw. + */ +class SignOutDependencies( + val activeIdentityStateFlow: StateFlow, + val forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + val hideIdentityMetadata: suspend (Identity) -> Unit, + /** The nav host's tail: re-list, and clear the active identity so startup shows the selector or Landing. */ + val onSignedOut: () -> Unit, +) + @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt index 6223c329..69c81a63 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt @@ -41,6 +41,14 @@ import mantra.composeapp.generated.resources.it_may_be_new_or_it_may_live_on_rel import mantra.composeapp.generated.resources.name_eg_alan_turing import mantra.composeapp.generated.resources.set_up_a_profile import mantra.composeapp.generated.resources.try_again +import mantra.composeapp.generated.resources.use_a_different_key +import mantra.composeapp.generated.resources.use_a_different_key_question +import mantra.composeapp.generated.resources.could_not_sign_out_please_try_again +import press.mantra.compose.ui.composable.widgets.dialogs.SignOutOfReadOnlyDialog +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.view.model.SignOutViewModel +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import mantra.composeapp.generated.resources.we_are_looking_for_your_profile_on_as_many import mantra.composeapp.generated.resources.we_are_searching_the_internet_to_find_your import mantra.composeapp.generated.resources.we_could_not_find_a_profile_for_this_key @@ -70,6 +78,10 @@ import press.mantra.compose.ui.view.state.UnsyncedProfileUIState fun UnsyncedProfileScreen( unsyncedProfilePublicKey: String, nostrRepository: NostrRepository, + // The not-found state's exit for an identity that cannot set a profile up: the same + // act as signing out, reached from the other end of the identity's life. Null where + // there is no identity to leave (previews), in which case that kind has no exit here. + signOut: SignOutDependencies? = null, ) { val viewModel: UnsyncedProfileViewModel = viewModel( factory = UnsyncedProfileViewModel.factory( @@ -79,6 +91,31 @@ fun UnsyncedProfileScreen( ) val uiState by viewModel.uiState.collectAsState() + val signOutViewModel: SignOutViewModel? = signOut?.let { + viewModel( + factory = SignOutViewModel.factory( + activeIdentityStateFlow = it.activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrCredential = it.forgetNostrCredential, + hideIdentityMetadata = it.hideIdentityMetadata, + ), + ) + } + val signOutState = signOutViewModel?.state?.collectAsState()?.value + if (signOutViewModel != null && signOut != null && signOutState is SignOutViewModel.State.Confirming) { + SignOutOfReadOnlyDialog( + title = Res.string.use_a_different_key_question, + nostrPublicKey = signOutViewModel.nostrPublicKey, + onConfirm = { signOutViewModel.signOut(onSignedOut = signOut.onSignedOut) }, + onDismiss = { signOutViewModel.cancel() }, + ) + } + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotSignOut = stringResource(Res.string.could_not_sign_out_please_try_again) + LaunchedEffect(signOutState) { + if (signOutState is SignOutViewModel.State.Failed) notify(couldNotSignOut) + } + // imePadding for the not-found form's fields. Scaffold( modifier = Modifier.imePadding(), @@ -94,6 +131,7 @@ fun UnsyncedProfileScreen( is UnsyncedProfileUIState.NotFound -> NotFound( viewModel = viewModel, unsignedNostrEventId = state.unsignedNostrEventId, + onUseADifferentKey = signOutViewModel?.let { { it.askToSignOut() } }, ) } } @@ -150,6 +188,8 @@ private fun Searching() { private fun NotFound( viewModel: UnsyncedProfileViewModel, unsignedNostrEventId: Long, + /** Shown only where the set-up form is not; null when there is no identity to leave. */ + onUseADifferentKey: (() -> Unit)?, ) { val nameField = viewModel.formState.nameField.textFieldState val biographyField = viewModel.formState.biographyField.textFieldState @@ -183,9 +223,23 @@ private fun NotFound( ) // Setting a profile up is signing a kind 0, which a read-only identity cannot do. - // The form is not offered to it; the way out for that kind is Phase 6 of - // docs/npub-sign-in.md. - if (!LocalCanSign.current) return@Column + // The form is not offered to it -- and then this state would have no exit: the + // profile tab is not reachable before ProfileLoaded, so a user whose npub was not + // found on any relay could try again for ever. So, for that kind only, the third + // action: the identity leaves the device, and the way back is the selector or + // Landing. + if (!LocalCanSign.current) { + if (onUseADifferentKey != null) { + TextButton( + modifier = Modifier.align(Alignment.End), + enabled = !isActionPending, + onClick = onUseADifferentKey, + ) { + Text(text = stringResource(Res.string.use_a_different_key)) + } + } + return@Column + } Text( text = stringResource(Res.string.set_up_a_profile), 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 7ce0f604..c70940d1 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 @@ -124,6 +124,7 @@ import press.mantra.compose.ui.view.state.NavigationUIState import press.mantra.compose.ui.view.state.NostrEventDetailUIState import press.mantra.compose.ui.view.state.SearchUIState import press.mantra.compose.ui.theme.NavigationMotion +import press.mantra.compose.ui.composable.SignOutDependencies import press.mantra.compose.ui.composable.widgets.ProvideSigningCapability import press.mantra.compose.ui.theme.breakpoint import co.touchlab.kermit.Logger @@ -431,6 +432,24 @@ fun MantraNavHost( } } + // What signing out of a read-only identity takes, for the two screens that offer it. + // The tail is the nsec forget's: the device no longer holds anything for the + // identity, so re-list -- the selector drops it -- then clear the active identity, + // and the navigation observer sends a null identity to startup, which shows the + // selector or, if this was the last one, Landing. + val signOutOfReadOnlyIdentity = remember(sovereignWalletViewModel) { + SignOutDependencies( + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, + forgetNostrCredential = { identity -> sovereignWalletViewModel.forgetNostrCredential(identity) }, + hideIdentityMetadata = { identity -> sovereignWalletViewModel.hideIdentityMetadata(identity) }, + onSignedOut = { + sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.resetToSelector() + } + }, + ) + } + // Once, above every screen: what a read-only identity may not be offered is decided // where the control is drawn, and this is how the control finds out. ProvideSigningCapability(sovereignWalletViewModel.activeIdentity) { @@ -747,6 +766,7 @@ fun MantraNavHost( UnsyncedProfileScreen( unsyncedProfilePublicKey = route.publicKey, nostrRepository = databaseNostrRepository, + signOut = signOutOfReadOnlyIdentity, ) } composable { backStackEntry -> @@ -847,7 +867,8 @@ fun MantraNavHost( navController.navigate( route = route ) - } + }, + signOut = signOutOfReadOnlyIdentity, ) } composable { backStackEntry -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt new file mode 100644 index 00000000..7c0fdd7e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt @@ -0,0 +1,43 @@ +package press.mantra.compose.ui.composable.widgets.dialogs + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.cancel +import mantra.composeapp.generated.resources.sign_out +import mantra.composeapp.generated.resources.this_device_holds_no_key_for_s_so_there +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.ui.composable.widgets.Decorative + +/** + * The confirmation before a read-only identity leaves the device, naming the npub so + * the user sees which one. Shared by the profile tab's *sign out* and the not-found + * screen's *use a different key*, which differ only in what the title calls it. + */ +@Composable +fun SignOutOfReadOnlyDialog( + title: StringResource, + nostrPublicKey: String?, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.Logout, contentDescription = Decorative) }, + title = { Text(stringResource(title)) }, + text = { Text(stringResource(Res.string.this_device_holds_no_key_for_s_so_there, nostrPublicKey?.hexToNpubHrp() ?: "")) }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(Res.string.sign_out)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt index ff121980..88bce8c0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import press.mantra.compose.extensions.hexToNsecHrp +import press.mantra.compose.identity.ForgetIdentity import press.mantra.compose.identity.Identity import press.mantra.compose.identity.IdentityKind import press.mantra.compose.identity.IdentityWriter @@ -46,8 +47,9 @@ import press.mantra.compose.ui.view.state.NostrSecretUIState * secret it is. * * It also owns the inverse of an import: [forgetKey], which removes the key from the - * device and the account with it. Only for a bare key; a mnemonic identity's key is in - * the seed, and removing a seed is a wallet question this does not answer. + * device and the account with it, through [ForgetIdentity]. Only for a bare key; a + * mnemonic identity's key is in the seed, and removing a seed is a wallet question this + * does not answer. */ class NostrSecretViewModel( val phoenixGlobal: PhoenixGlobal, @@ -163,9 +165,9 @@ class NostrSecretViewModel( /** * Key out of the file, preferences deleted, metadata hidden, account rows gone -- in - * that order, so a failure partway leaves the key on disk rather than an identity the - * selector lists but nothing can open. [onForgotten] runs on the main thread once the - * device no longer holds the key; the caller clears the active identity and re-lists. + * that order, for the reason [ForgetIdentity] gives. [onForgotten] runs on the main + * thread once the device no longer holds the key; the caller clears the active + * identity and re-lists. */ fun forgetKey(onForgotten: () -> Unit) { val identity = activeIdentityStateFlow.value ?: return @@ -177,15 +179,15 @@ class NostrSecretViewModel( logger.e("could not forget the key", throwable) _forgetting.value = NostrSecretUIState.Forgetting.Failed }) { - when (forgetNostrCredential(identity)) { - is IdentityWriter.ForgetNostrCredentialResult.Forgotten -> { - hideIdentityMetadata(identity) - nostrRepository.forgetLocalAccount(identity.nostrPublicKey) + // The sequence is ForgetIdentity's, shared with the two exits a read-only + // identity has; only the state it drives is this screen's. + when (ForgetIdentity.forget(identity, nostrRepository, forgetNostrCredential, hideIdentityMetadata)) { + is ForgetIdentity.Outcome.Forgotten -> { _forgetting.value = NostrSecretUIState.Forgetting.Idle withContext(Dispatchers.Main) { onForgotten() } } - is IdentityWriter.ForgetNostrCredentialResult.NotACredential, - is IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys -> { + is ForgetIdentity.Outcome.NotACredential, + is ForgetIdentity.Outcome.CannotForget -> { _forgetting.value = NostrSecretUIState.Forgetting.Failed } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt new file mode 100644 index 00000000..29773e92 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt @@ -0,0 +1,106 @@ +package press.mantra.compose.ui.view.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.identity.ForgetIdentity +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.repository.NostrRepository + +/** + * Signing out of a read-only identity, which is forgetting it: there is nothing on the + * device to lose, so it is the first real sign out in the app. The general case -- a + * seed, with funds behind it -- stays pending, for the reason docs/nsec-sign-in.md gives. + * + * Two screens drive this with the same state: the profile tab's *sign out*, and the + * not-found screen's *use a different key*, which is the same act reached from the other + * end of the identity's life. The sequence is [ForgetIdentity]'s; this owns only the + * confirmation and the in-flight state, the way `NostrSecretViewModel` does for an nsec. + */ +class SignOutViewModel( + private val activeIdentityStateFlow: StateFlow, + private val nostrRepository: NostrRepository, + private val forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + private val hideIdentityMetadata: suspend (Identity) -> Unit, +) : ViewModel() { + + sealed interface State { + data object Idle : State + data object Confirming : State + data object InProgress : State + data object Failed : State + } + + private val logger = Logger.withTag(TAG) + + private val _state = MutableStateFlow(State.Idle) + val state = _state.asStateFlow() + + /** The npub the confirmation names, so the user sees which identity is leaving. */ + val nostrPublicKey: String? get() = activeIdentityStateFlow.value?.nostrPublicKey + + fun askToSignOut() { + if (_state.value is State.InProgress) return + _state.value = State.Confirming + } + + fun cancel() { + if (_state.value is State.InProgress) return + _state.value = State.Idle + } + + /** [onSignedOut] runs on the main thread once the device holds nothing for the identity. */ + fun signOut(onSignedOut: () -> Unit) { + val identity = activeIdentityStateFlow.value ?: return + if (_state.value is State.InProgress) return + _state.value = State.InProgress + + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, throwable -> + logger.e("could not sign out", throwable) + _state.value = State.Failed + }) { + when (ForgetIdentity.forget(identity, nostrRepository, forgetNostrCredential, hideIdentityMetadata)) { + is ForgetIdentity.Outcome.Forgotten -> { + _state.value = State.Idle + withContext(Dispatchers.Main) { onSignedOut() } + } + is ForgetIdentity.Outcome.NotACredential, + is ForgetIdentity.Outcome.CannotForget -> { + _state.value = State.Failed + } + } + } + } + + companion object { + private const val TAG = "SignOutViewModel" + + fun factory( + activeIdentityStateFlow: StateFlow, + nostrRepository: NostrRepository, + forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + hideIdentityMetadata: suspend (Identity) -> Unit, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SignOutViewModel( + activeIdentityStateFlow = activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrCredential = forgetNostrCredential, + hideIdentityMetadata = hideIdentityMetadata, + ) + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt new file mode 100644 index 00000000..e746653e --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt @@ -0,0 +1,95 @@ +package press.mantra.compose.identity + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.repository.NostrRepository +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The sequence, and where it stops. + * + * Forgetting is four effects in an order that matters -- the credential out of the + * file, the metadata hidden, the account rows gone, then the caller's tail -- so that a + * failure partway leaves the credential on disk rather than an identity the selector + * lists but nothing can open. A refusal at the first step must leave the other two + * untouched: an identity that is still on the device must still have its account. + * + * The two writers are recorded rather than run; `IdentityWriterJvmTest` covers what + * they do to the disk. + */ +class ForgetIdentityJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val privateKey = PrivateKey(ByteVector32("04".repeat(32))) + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun readOnlyIdentity() = Identity.readOnly( + id = privateKey.publicKey().xOnly().toWalletId(), + nostrPublicKey = privateKey.nostrPublicKeyHex(), + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + ) + + private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + override suspend fun forgetLocalAccount(publicKey: HexKey) { + effects += "forgetLocalAccount:$publicKey" + } + } + + private suspend fun forget(recorder: Recorder, result: IdentityWriter.ForgetNostrCredentialResult) = + ForgetIdentity.forget( + identity = readOnlyIdentity(), + nostrRepository = recorder, + forgetNostrCredential = { recorder.effects += "forgetNostrCredential"; result }, + hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" }, + ) + + @Test + fun `the credential comes out first, then the metadata, then the account`() = runBlocking { + val recorder = Recorder() + + val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten) + + assertEquals(ForgetIdentity.Outcome.Forgotten, outcome) + assertEquals( + listOf("forgetNostrCredential", "hideIdentityMetadata", "forgetLocalAccount:${privateKey.nostrPublicKeyHex()}"), + recorder.effects, + ) + } + + @Test + fun `a mnemonic identity is refused before anything else is touched`() = runBlocking { + val recorder = Recorder() + + val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.NotACredential) + + assertEquals(ForgetIdentity.Outcome.NotACredential, outcome) + assertEquals(listOf("forgetNostrCredential"), recorder.effects) + } + + @Test + fun `a store that cannot be read stops the sequence at its first step`() = runBlocking { + val recorder = Recorder() + + val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys) + + assertEquals(ForgetIdentity.Outcome.CannotForget, outcome) + assertEquals(listOf("forgetNostrCredential"), recorder.effects) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt index a6e95041..2dfc62ac 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt @@ -3,9 +3,29 @@ package press.mantra.compose.ui.composable import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runDesktopComposeUiTest +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.flow.MutableStateFlow +import okio.Path.Companion.toPath +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.identity.Identity +import press.mantra.compose.nostr.SignInSync +import java.nio.file.Files +import kotlin.time.Instant import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.flow.Flow @@ -92,8 +112,99 @@ class ReadOnlyEntrancesJvmTest { onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed() } + // --- Profile: sign out is real for a read-only identity, and confirms first --- + + @Test + fun `a read-only identity's sign out asks before it acts, naming the npub`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { ActiveProfile(signOut = signOutDependencies) } } + + onNodeWithText("Sign out", useUnmergedTree = true).performClick() + + onNodeWithText("Sign out of this profile?", useUnmergedTree = true).assertIsDisplayed() + // The screen shows the npub once already; the dialog names it a second time. + onAllNodesWithText(publicKey.hexToNpubHrp(), substring = true, useUnmergedTree = true).assertCountEquals(2) + onNodeWithText("nothing to lose", substring = true, useUnmergedTree = true).assertIsDisplayed() + } + + // --- Not found: set one up, or use a different key --- + + @Test + fun `an identity that can sign is offered the set-up form when its profile is not found`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = true) { NotFound() } } + + onNodeWithText("Try again", useUnmergedTree = true).assertIsDisplayed() + // Heading and button both say it; either is the form. + onAllNodesWithText("Set up a profile", useUnmergedTree = true).onFirst().assertIsDisplayed() + onNodeWithText("Use a different key", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `a read-only identity is offered a different key instead, and it confirms first`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { NotFound() } } + + onNodeWithText("Try again", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Set up a profile", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Use a different key", useUnmergedTree = true).assertIsDisplayed() + + onNodeWithText("Use a different key", useUnmergedTree = true).performClick() + + onNodeWithText("Use a different key?", useUnmergedTree = true).assertIsDisplayed() + } + // --- harness --- + private val readOnlyIdentity = MutableStateFlow( + Identity.readOnly( + id = WalletId("ab".repeat(20)), + nostrPublicKey = publicKey, + userPrefs = UserPrefs(PreferenceDataStoreFactory.createWithPath { Files.createTempFile("user", ".preferences_pb").also { it.toFile().delete() }.toString().toPath() }), + internalPrefs = InternalPrefs(PreferenceDataStoreFactory.createWithPath { Files.createTempFile("internal", ".preferences_pb").also { it.toFile().delete() }.toString().toPath() }), + ) + ) + + /** Never run in these tests: the dialogs are looked at, not confirmed. */ + private val signOutDependencies = SignOutDependencies( + activeIdentityStateFlow = readOnlyIdentity, + forgetNostrCredential = { error("not reached") }, + hideIdentityMetadata = { error("not reached") }, + onSignedOut = { error("not reached") }, + ) + + @Composable + private fun NotFound() { + UnsyncedProfileScreen( + unsyncedProfilePublicKey = publicKey, + nostrRepository = NotFoundAccount, + signOut = signOutDependencies, + ) + } + + private val at = Instant.fromEpochSeconds(1_700_000_000) + + /** An account whose one sign-in request has finished with nothing: the not-found state. */ + private val NotFoundAccount = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeLocalAccount(publicKey: String): Flow = flowOf( + LocalAccount( + unsignedNostrEvent = UnsignedNostrEvent( + id = 1, pubKey = publicKey, kind = 0, tags = emptyArray(), content = "{}", + signedAt = GENESIS_AT, createdAt = at, updatedAt = at, savedAt = at, + ), + nostrEvent = null, + profile = null, + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = listOf( + SynchronizeNostrEventRequest( + id = "req", purpose = SignInSync.PURPOSE, status = SignInSync.STATUS_COMPLETE, + relayURL = "wss://relay.example", level = 0, + synchronizationFilters = arrayOf(SynchronizationFilter(authors = arrayOf(publicKey), kinds = SignInSync.KINDS)), + unsignedNostrEventId = 1, createdAt = at, updatedAt = at, + ) + ), + ) + ) + } + @Composable private fun AsIdentity(canSign: Boolean, content: @Composable () -> Unit) { MantraTheme { @@ -121,7 +232,7 @@ class ReadOnlyEntrancesJvmTest { } @Composable - private fun ActiveProfile() { + private fun ActiveProfile(signOut: SignOutDependencies? = null) { ActiveProfileScreen( initialActiveProfileUIState = ActiveProfileUIState.Loaded( localNostrEvent = LocalNostrEvent(nostrEvent = metadataEvent, profile = profile.profile), @@ -131,6 +242,7 @@ class ReadOnlyEntrancesJvmTest { onNavigateBack = {}, onNavigateToRoute = {}, nostrRepository = FixedProfile, + signOut = signOut, ) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt new file mode 100644 index 00000000..2dabb167 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt @@ -0,0 +1,106 @@ +package press.mantra.compose.ui.view.model + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.toWalletId +import press.mantra.compose.repository.NostrRepository +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Sign out for a read-only identity: the confirmation, the in-flight state, and the + * caller being told only once the device holds nothing for the identity. The sequence + * itself is `ForgetIdentity`'s and is pinned there; this is the state around it, the + * way `NostrSecretViewModelJvmTest` pins the nsec screen's. + */ +class SignOutViewModelJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val privateKey = PrivateKey(ByteVector32("05".repeat(32))) + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun identity() = Identity.readOnly( + id = privateKey.publicKey().xOnly().toWalletId(), + nostrPublicKey = privateKey.nostrPublicKeyHex(), + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + ) + + private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + override suspend fun forgetLocalAccount(publicKey: HexKey) { + effects += "forgetLocalAccount" + } + } + + private fun viewModel(recorder: Recorder, outcome: IdentityWriter.ForgetNostrCredentialResult) = SignOutViewModel( + activeIdentityStateFlow = MutableStateFlow(identity()), + nostrRepository = recorder, + forgetNostrCredential = { recorder.effects += "forgetNostrCredential"; outcome }, + hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" }, + ) + + @Test + fun `asking, then confirming, forgets and tells the caller, and the state is idle again`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten) + val told = CompletableDeferred() + + assertEquals(SignOutViewModel.State.Idle, viewModel.state.value) + viewModel.askToSignOut() + assertEquals(SignOutViewModel.State.Confirming, viewModel.state.value) + assertEquals(privateKey.nostrPublicKeyHex(), viewModel.nostrPublicKey, "the confirmation names the identity") + + viewModel.signOut { told.complete(Unit) } + withTimeout(10_000) { told.await() } + + assertEquals(listOf("forgetNostrCredential", "hideIdentityMetadata", "forgetLocalAccount"), recorder.effects) + withTimeout(10_000) { viewModel.state.first { it == SignOutViewModel.State.Idle } } + } + + @Test + fun `cancelling from the confirmation touches nothing`() { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten) + + viewModel.askToSignOut() + viewModel.cancel() + + assertEquals(SignOutViewModel.State.Idle, viewModel.state.value) + assertEquals(emptyList(), recorder.effects) + } + + @Test + fun `a failure is a state, the caller is not told, and nothing after the first step ran`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys) + var told = false + + viewModel.askToSignOut() + viewModel.signOut { told = true } + withTimeout(10_000) { viewModel.state.first { it == SignOutViewModel.State.Failed } } + + assertEquals(false, told) + assertEquals(listOf("forgetNostrCredential"), recorder.effects) + } +}