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) + } +}