diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index ae069ab277..cf0621d3b2 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -108,6 +108,7 @@ import org.signal.registration.screens.verificationcode.VerificationCodeScreen import org.signal.registration.screens.verificationcode.VerificationCodeViewModel import org.signal.registration.screens.welcome.WelcomeScreen import org.signal.registration.screens.welcome.WelcomeScreenEvents +import org.signal.registration.screens.welcome.WelcomeScreenViewModel import org.signal.registration.util.AccountEntropyPoolParceler import org.signal.registration.util.RegistrationCredentialManager @@ -391,29 +392,21 @@ private fun EntryProviderScope.navigationEntries( entry { val context = LocalContext.current val termsAndPrivacyUrl = stringResource(R.string.terms_and_privacy_policy_url) - - val navigateRequestingPermissions = { nextRoute: RegistrationRoute -> - if (RegistrationPermissions.hasAllRequiredPermissions(context)) { - parentEventEmitter.navigateTo(nextRoute) - } else { - parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = nextRoute)) - } - } + val viewModel: WelcomeScreenViewModel = viewModel( + factory = WelcomeScreenViewModel.Factory( + repository = registrationRepository, + parentState = registrationViewModel.state, + parentEventEmitter = registrationViewModel::onEvent, + hasPermissions = { RegistrationPermissions.hasAllRequiredPermissions(context) }, + getRequiredLinkedDevicePermission = { registrationViewModel.getRequiredLinkedDevicePermission() } + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() WelcomeScreen( - isLinkAndSyncAvailable = registrationRepository.isLinkAndSyncAvailable, + state = state, onEvent = { event -> when (event) { - WelcomeScreenEvents.Continue -> navigateRequestingPermissions(RegistrationRoute.PhoneNumberEntry) - WelcomeScreenEvents.LinkDevice -> { - if (registrationViewModel.getRequiredLinkedDevicePermission().isNullOrBlank()) { - parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount()) - } else { - parentEventEmitter.navigateTo(RegistrationRoute.AllowNotifications(RegistrationRoute.LinkAccount())) - } - } - WelcomeScreenEvents.HasOldPhone -> navigateRequestingPermissions(RegistrationRoute.QuickRestoreQrScan) - WelcomeScreenEvents.DoesNotHaveOldPhone -> navigateRequestingPermissions(RegistrationRoute.ArchiveRestoreSelection.forManualRestore()) WelcomeScreenEvents.ViewTermsAndPrivacy -> { LinkActions.openUrl(context, termsAndPrivacyUrl) { error -> when (error) { @@ -421,6 +414,7 @@ private fun EntryProviderScope.navigationEntries( } } } + else -> viewModel.onEvent(event) } } ) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt index ad8e81f15a..5e93d8d902 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt @@ -170,7 +170,7 @@ class PinEntryForRegistrationLockViewModel( Log.i(TAG, "[PinEntered] A restore was pending behind the registration lock. Resuming it now.") parentEventEmitter.navigateTo(pendingRestore) } - response.reregistration && parentState.value.pendingRestoreOption == null -> parentEventEmitter.navigateTo(RegistrationRoute.ArchiveRestoreSelection.forPostRegisterWithPinKnown()) + response.reregistration && parentState.value.pendingRestoreOption == null && parentState.value.preExistingRegistrationData == null -> parentEventEmitter.navigateTo(RegistrationRoute.ArchiveRestoreSelection.forPostRegisterWithPinKnown()) else -> parentEventEmitter(RegistrationFlowEvent.RegistrationComplete) } state diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt index 0dac3b61ff..1e72217e32 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt @@ -371,7 +371,7 @@ class VerificationCodeViewModel( Log.i(TAG, "[Register] A restore was deferred until after SMS verification. Resuming it now.") parentEventEmitter.navigateTo(pendingRestore) } - response.reregistration && parentState.value.pendingRestoreOption == null -> parentEventEmitter.navigateTo(RegistrationRoute.ArchiveRestoreSelection.forPostRegisterWithPinUnknown()) + response.reregistration && parentState.value.pendingRestoreOption == null && parentState.value.preExistingRegistrationData == null -> parentEventEmitter.navigateTo(RegistrationRoute.ArchiveRestoreSelection.forPostRegisterWithPinUnknown()) response.storageCapable -> parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore) else -> parentEventEmitter.navigateTo(RegistrationRoute.PinCreate) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt index c5e2d9e9a1..8be7fa2ab0 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt @@ -82,40 +82,39 @@ import kotlin.math.sqrt */ @Composable fun WelcomeScreen( + state: WelcomeScreenState, onEvent: (WelcomeScreenEvents) -> Unit, - modifier: Modifier = Modifier, - isLinkAndSyncAvailable: Boolean = false + modifier: Modifier = Modifier ) { var showBottomSheet by remember { mutableStateOf(false) } val windowBreakpoint = rememberWindowBreakpoint() val onRestoreOrTransferClick = { showBottomSheet = true } - val onTermsAndPrivacyClick = { onEvent(WelcomeScreenEvents.ViewTermsAndPrivacy) } - val displayLinkAsPrimaryOption by rememberDisplayLinkAndSyncAsPrimaryPath(isLinkAndSyncAvailable) + val displayLinkAsPrimaryOption by rememberDisplayLinkAndSyncAsPrimaryPath(state.isLinkAndSyncAvailable) when (windowBreakpoint) { is WindowBreakpoint.Small -> { CompactLayout( + state = state, onEvent = onEvent, onRestoreOrTransferClick = onRestoreOrTransferClick, - onTermsAndPrivacyClick = onTermsAndPrivacyClick, modifier = modifier ) } is WindowBreakpoint.Medium -> { MediumLayout( + state = state, onEvent = onEvent, onRestoreOrTransferClick = onRestoreOrTransferClick, - onTermsAndPrivacyClick = onTermsAndPrivacyClick, modifier = modifier ) } is WindowBreakpoint.Large -> { LargeLayout( - displayLinkAsPrimaryOption = displayLinkAsPrimaryOption, + state = state, onEvent = onEvent, - onTermsAndPrivacyClick = onTermsAndPrivacyClick, + displayLinkAsPrimaryOption = displayLinkAsPrimaryOption, onRestoreOrTransferClick = onRestoreOrTransferClick, modifier = modifier ) @@ -135,8 +134,8 @@ fun WelcomeScreen( @Composable private fun CompactLayout( + state: WelcomeScreenState, onEvent: (WelcomeScreenEvents) -> Unit, - onTermsAndPrivacyClick: () -> Unit, onRestoreOrTransferClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -175,13 +174,14 @@ private fun CompactLayout( modifier = Modifier.widthIn(max = 320.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - TermsAndPrivacy(onTermsAndPrivacyClick = onTermsAndPrivacyClick) + TermsAndPrivacy(onEvent) Spacer(modifier = Modifier.height(16.dp)) PrimaryDeviceCallToActionButtons( onEvent = onEvent, - onRestoreOrTransferClick = onRestoreOrTransferClick + onRestoreOrTransferClick = onRestoreOrTransferClick, + showRestoreOrTransfer = state.showRestoreOrTransfer ) Spacer(modifier = Modifier.height(48.dp)) @@ -193,8 +193,8 @@ private fun CompactLayout( @Composable private fun MediumLayout( + state: WelcomeScreenState, onEvent: (WelcomeScreenEvents) -> Unit, - onTermsAndPrivacyClick: () -> Unit, onRestoreOrTransferClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -221,7 +221,7 @@ private fun MediumLayout( } TermsAndPrivacy( - onTermsAndPrivacyClick = onTermsAndPrivacyClick, + onEvent = onEvent, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 24.dp) @@ -239,7 +239,8 @@ private fun MediumLayout( ) { PrimaryDeviceCallToActionButtons( onEvent = onEvent, - onRestoreOrTransferClick = onRestoreOrTransferClick + onRestoreOrTransferClick = onRestoreOrTransferClick, + showRestoreOrTransfer = state.showRestoreOrTransfer ) } } @@ -249,9 +250,9 @@ private fun MediumLayout( @Composable private fun LargeLayout( - displayLinkAsPrimaryOption: Boolean, + state: WelcomeScreenState, onEvent: (WelcomeScreenEvents) -> Unit, - onTermsAndPrivacyClick: () -> Unit, + displayLinkAsPrimaryOption: Boolean, onRestoreOrTransferClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -288,7 +289,7 @@ private fun LargeLayout( Spacer(modifier = Modifier.height(77.dp)) TermsAndPrivacy( - onTermsAndPrivacyClick = onTermsAndPrivacyClick, + onEvent = onEvent, modifier = Modifier .align(Alignment.CenterHorizontally) .padding(bottom = 8.dp) @@ -301,7 +302,8 @@ private fun LargeLayout( } else { PrimaryDeviceCallToActionButtons( onEvent = onEvent, - onRestoreOrTransferClick = onRestoreOrTransferClick + onRestoreOrTransferClick = onRestoreOrTransferClick, + showRestoreOrTransfer = state.showRestoreOrTransfer ) } } @@ -341,11 +343,11 @@ private fun Headline( @Composable private fun TermsAndPrivacy( - onTermsAndPrivacyClick: () -> Unit, + onEvent: (WelcomeScreenEvents) -> Unit, modifier: Modifier = Modifier ) { TextButton( - onClick = onTermsAndPrivacyClick, + onClick = { onEvent(WelcomeScreenEvents.ViewTermsAndPrivacy) }, colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.onSurfaceVariant ), @@ -361,7 +363,8 @@ private fun TermsAndPrivacy( @Composable private fun PrimaryDeviceCallToActionButtons( onEvent: (WelcomeScreenEvents) -> Unit, - onRestoreOrTransferClick: () -> Unit + onRestoreOrTransferClick: () -> Unit, + showRestoreOrTransfer: Boolean ) { Buttons.LargeTonal( onClick = { onEvent(WelcomeScreenEvents.Continue) }, @@ -372,18 +375,20 @@ private fun PrimaryDeviceCallToActionButtons( Text(stringResource(R.string.RegistrationActivity_continue)) } - Spacer(modifier = Modifier.height(16.dp)) + if (showRestoreOrTransfer) { + Spacer(modifier = Modifier.height(16.dp)) - Buttons.LargeTonal( - onClick = onRestoreOrTransferClick, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = SignalTheme.colors.colorSurface2 - ), - modifier = Modifier - .fillMaxWidth() - .testTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON) - ) { - Text(stringResource(R.string.registration_activity__restore_or_transfer)) + Buttons.LargeTonal( + onClick = onRestoreOrTransferClick, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = SignalTheme.colors.colorSurface2 + ), + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON) + ) { + Text(stringResource(R.string.registration_activity__restore_or_transfer)) + } } } @@ -555,7 +560,7 @@ private fun rememberDisplayLinkAndSyncAsPrimaryPath(isLinkAndSyncAvailable: Bool @Composable private fun WelcomeScreenPreview() { Previews.Preview { - WelcomeScreen(onEvent = {}) + WelcomeScreen(state = WelcomeScreenState(), onEvent = {}) } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenEvents.kt index ffa963151c..545f18d5de 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenEvents.kt @@ -5,7 +5,12 @@ package org.signal.registration.screens.welcome +import org.signal.registration.RegistrationFlowState + sealed class WelcomeScreenEvents { + /** The parent registration flow state changed and needs to be merged into this screen's state. */ + data class ParentStateChanged(val parentState: RegistrationFlowState) : WelcomeScreenEvents() + data object Continue : WelcomeScreenEvents() data object LinkDevice : WelcomeScreenEvents() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenState.kt b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenState.kt new file mode 100644 index 0000000000..1e6bef5ea6 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenState.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.welcome + +data class WelcomeScreenState( + /** Gates whether the link device option is shown as the primary path on large devices. */ + val isLinkAndSyncAvailable: Boolean = false, + /** + * Whether to offer the restore-or-transfer option. Hidden during a re-registration, and kept hidden until the parent + * flow has finished loading so we never briefly show it before learning that pre-existing data exists. + */ + val showRestoreOrTransfer: Boolean = true +) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenViewModel.kt new file mode 100644 index 0000000000..55c2c5fe00 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenViewModel.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.welcome + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationFlowState +import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.util.navigateTo + +/** + * Drives the welcome screen. It observes the parent flow state to decide whether to offer the restore-or-transfer + * option (which depends on asynchronously-loaded pre-existing registration data) and handles the screen's navigation. + */ +class WelcomeScreenViewModel( + repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val hasPermissions: () -> Boolean, + private val getRequiredLinkedDevicePermission: () -> String? +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(WelcomeScreenViewModel::class) + } + + private val _state = MutableStateFlow(WelcomeScreenState(isLinkAndSyncAvailable = repository.isLinkAndSyncAvailable, showRestoreOrTransfer = false)) + val state: StateFlow = _state.asStateFlow() + + init { + _state + .onEach { Log.d(TAG, "[State] $it") } + .launchIn(viewModelScope) + + parentState + .onEach { onEvent(WelcomeScreenEvents.ParentStateChanged(it)) } + .launchIn(viewModelScope) + } + + override suspend fun processEvent(event: WelcomeScreenEvents) { + applyEvent(_state.value, event, parentEventEmitter) { _state.value = it } + } + + @VisibleForTesting + fun applyEvent(state: WelcomeScreenState, event: WelcomeScreenEvents, parentEventEmitter: (RegistrationFlowEvent) -> Unit, stateEmitter: (WelcomeScreenState) -> Unit) { + when (event) { + is WelcomeScreenEvents.ParentStateChanged -> stateEmitter(applyParentState(state, event.parentState)) + WelcomeScreenEvents.Continue -> navigateRequestingPermissions(RegistrationRoute.PhoneNumberEntry, parentEventEmitter) + WelcomeScreenEvents.HasOldPhone -> navigateRequestingPermissions(RegistrationRoute.QuickRestoreQrScan, parentEventEmitter) + WelcomeScreenEvents.DoesNotHaveOldPhone -> navigateRequestingPermissions(RegistrationRoute.ArchiveRestoreSelection.forManualRestore(), parentEventEmitter) + WelcomeScreenEvents.LinkDevice -> { + if (getRequiredLinkedDevicePermission().isNullOrBlank()) { + parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount()) + } else { + parentEventEmitter.navigateTo(RegistrationRoute.AllowNotifications(RegistrationRoute.LinkAccount())) + } + } + WelcomeScreenEvents.ViewTermsAndPrivacy -> error("This event is handled in the nav-entry.") + } + } + + private fun applyParentState(state: WelcomeScreenState, parentState: RegistrationFlowState): WelcomeScreenState { + if (parentState.isRestoringNavigationState) { + return state + } + + return state.copy(showRestoreOrTransfer = parentState.preExistingRegistrationData == null) + } + + private fun navigateRequestingPermissions(nextRoute: RegistrationRoute, parentEventEmitter: (RegistrationFlowEvent) -> Unit) { + if (hasPermissions()) { + parentEventEmitter.navigateTo(nextRoute) + } else { + parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = nextRoute)) + } + } + + class Factory( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val hasPermissions: () -> Boolean, + private val getRequiredLinkedDevicePermission: () -> String? + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return WelcomeScreenViewModel(repository, parentState, parentEventEmitter, hasPermissions, getRequiredLinkedDevicePermission) as T + } + } +} diff --git a/feature/registration/src/screenshotTest/kotlin/org/signal/registration/screens/welcome/WelcomeScreenScreenshotTests.kt b/feature/registration/src/screenshotTest/kotlin/org/signal/registration/screens/welcome/WelcomeScreenScreenshotTests.kt index 2d40e6c370..b9972d43db 100644 --- a/feature/registration/src/screenshotTest/kotlin/org/signal/registration/screens/welcome/WelcomeScreenScreenshotTests.kt +++ b/feature/registration/src/screenshotTest/kotlin/org/signal/registration/screens/welcome/WelcomeScreenScreenshotTests.kt @@ -18,7 +18,7 @@ class WelcomeScreenScreenshotTests { @Composable fun WelcomeScreenPhonePreview() { Previews.Preview { - WelcomeScreen(onEvent = {}) + WelcomeScreen(state = WelcomeScreenState(), onEvent = {}) } } } diff --git a/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt b/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt index 18b74050c2..c6a891bc7f 100644 --- a/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt @@ -43,11 +43,14 @@ import org.robolectric.annotation.Config import org.signal.archive.LocalBackupRestoreProgress import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey +import org.signal.core.models.ServiceId.ACI +import org.signal.core.models.ServiceId.PNI import org.signal.core.ui.CoreUiDependenciesRule import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.util.logging.Log import org.signal.libsignal.net.RequestResult +import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.registration.NetworkController.MasterKeyResponse import org.signal.registration.NetworkController.ProvisioningEvent import org.signal.registration.NetworkController.RegisterAccountError @@ -64,6 +67,7 @@ import org.signal.registration.screens.util.MockMultiplePermissionsState import org.signal.registration.screens.util.MockPermissionsState import org.signal.registration.test.TestTags import java.time.Duration +import java.util.UUID import kotlin.time.Duration.Companion.days /** @@ -462,6 +466,74 @@ class RegistrationEndToEndTest { assert(committed.pin == PIN) { "Expected committed pin $PIN but was ${committed.pin}" } } + @Test + fun `re-registering the same number offers no restore on the welcome screen and completes without a restore prompt`() { + storageController.preExistingRegistrationData = preExistingRegistrationData(E164) + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + // The welcome screen does not offer restore or transfer during a re-registration + waitForTag(TestTags.WELCOME_SCREEN) + assert(composeTestRule.onAllNodesWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).fetchSemanticsNodes().isEmpty()) { + "Expected no restore/transfer option on the welcome screen during re-registration" + } + + // Continue to phone entry, where the previous number is prefilled, and confirm it + composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick() + waitForTag(TestTags.PHONE_NUMBER_SCREEN) + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).performClick() + waitForTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON) + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick() + + // The account re-registers via the recovery password and goes straight to PIN creation, never offering a restore + createPin(PIN) + + waitFor("registration to complete") { registrationComplete } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.accountData?.e164 == E164) { "Expected committed e164 $E164 but was ${committed.accountData?.e164}" } + } + + @Test + fun `re-registering with pre-existing data through sms verification skips the post-registration restore prompt`() { + // Pre-existing data for a different number, so the same-number recovery-password fast path is skipped and the flow + // goes through SMS verification, exercising the post-registration re-registration branch. + storageController.preExistingRegistrationData = preExistingRegistrationData("+15557654321") + + networkController.onRegisterAccount = { request -> + RequestResult.Success(networkController.registerAccountResponse(request.e164, reregistration = true)) + } + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + waitForTag(TestTags.WELCOME_SCREEN) + composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick() + + // The previous number is prefilled; replace it with a different number and confirm + waitForTag(TestTags.PHONE_NUMBER_SCREEN) + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_PHONE_FIELD).performTextClearance() + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_PHONE_FIELD).performTextInput(PHONE_NUMBER) + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).performClick() + waitForTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON) + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick() + + submitVerificationCode(VERIFICATION_CODE) + + // The server reports a re-registration, but the pre-existing data suppresses the restore prompt and the user + // proceeds straight to PIN creation. + createPin(PIN) + + waitFor("registration to complete") { registrationComplete } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.accountData?.e164 == E164) { "Expected committed e164 $E164 but was ${committed.accountData?.e164}" } + assert(committed.pin == PIN) { "Expected committed pin $PIN but was ${committed.pin}" } + } + @Test fun `opting out of creating a pin still completes registration`() { var registrationComplete = false @@ -1212,6 +1284,20 @@ class RegistrationEndToEndTest { } } + private fun preExistingRegistrationData(e164: String): PreExistingRegistrationData { + return PreExistingRegistrationData( + e164 = e164, + aci = ACI.from(UUID.randomUUID()), + pni = PNI.from(UUID.randomUUID()), + servicePassword = "service-password", + aep = AccountEntropyPool.generate(), + registrationLockEnabled = false, + unrestrictedUnidentifiedAccess = false, + aciIdentityKeyPair = IdentityKeyPair.generate(), + pniIdentityKeyPair = IdentityKeyPair.generate() + ) + } + private fun createMockPermissionsState(): MockMultiplePermissionsState { return MockMultiplePermissionsState( allPermissionsGranted = true, diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt index ebc422d66d..b13fe9da59 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt @@ -25,6 +25,7 @@ import org.signal.libsignal.net.RequestResult import org.signal.registration.KeyMaterial import org.signal.registration.NetworkController import org.signal.registration.PendingRestoreOption +import org.signal.registration.PreExistingRegistrationData import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowState import org.signal.registration.RegistrationRepository @@ -118,6 +119,25 @@ class PinEntryForRegistrationLockViewModelTest { assertThat(emittedStates.last().loading).isEqualTo(true) } + @Test + fun `PinEntered on re-registration with preExistingRegistrationData completes without restore selection`() = runTest { + val masterKey = mockk(relaxed = true) + val keyMaterial = mockk(relaxed = true) + val registerResponse = createRegisterAccountResponse(reregistration = true) + val initialState = PinEntryState(mode = PinEntryState.Mode.RegistrationLock) + + parentState.value = parentState.value.copy(preExistingRegistrationData = mockk(relaxed = true)) + + coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), forRegistrationLock = true) } returns + RequestResult.Success(NetworkController.MasterKeyResponse(masterKey)) + coEvery { mockRepository.registerAccountWithSession(any(), any(), any(), any()) } returns + RequestResult.Success(registerResponse to keyMaterial) + + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) + + assertThat(emittedParentEvents[2]).isEqualTo(RegistrationFlowEvent.RegistrationComplete) + } + @Test fun `PinEntered resumes a pending local backup restore after clearing the registration lock`() = runTest { val masterKey = mockk(relaxed = true) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModelTest.kt index 513be3e7e1..d5eacd6b22 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModelTest.kt @@ -33,6 +33,7 @@ import org.signal.libsignal.net.RequestResult import org.signal.registration.KeyMaterial import org.signal.registration.NetworkController import org.signal.registration.PendingRestoreOption +import org.signal.registration.PreExistingRegistrationData import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowState import org.signal.registration.RegistrationRepository @@ -607,6 +608,32 @@ class VerificationCodeViewModelTest { .isInstanceOf() } + @Test + fun `CodeEntered reregistration with preExistingRegistrationData skips ArchiveRestoreSelection`() = runTest { + parentState.value = parentState.value.copy(preExistingRegistrationData = mockk(relaxed = true)) + + val sessionMetadata = createSessionMetadata(verified = true) + val initialState = VerificationCodeState( + sessionMetadata = sessionMetadata, + e164 = "+15551234567" + ) + + val registerResponse = createRegisterAccountResponse(storageCapable = true, reregistration = true) + val keyMaterial = mockk(relaxed = true) + + coEvery { mockRepository.submitVerificationCode(any(), any()) } returns + RequestResult.Success(sessionMetadata) + coEvery { mockRepository.registerAccountWithSession(any(), any(), any()) } returns + RequestResult.Success(registerResponse to keyMaterial) + + viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"), stateEmitter) + + assertThat(emittedEvents[1]) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + @Test fun `CodeEntered with incorrect code returns IncorrectVerificationCode event`() = runTest { val sessionMetadata = createSessionMetadata() diff --git a/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt index 5b365eb24b..0b531cb1d2 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt @@ -8,6 +8,7 @@ package org.signal.registration.screens.welcome import android.app.Application import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.test.core.app.ApplicationProvider @@ -42,6 +43,7 @@ class WelcomeScreenTest { composeTestRule.setContent { SignalTheme { WelcomeScreen( + state = WelcomeScreenState(), onEvent = { event -> emittedEvent = event } @@ -65,7 +67,7 @@ class WelcomeScreenTest { composeTestRule.setContent { SignalTheme { WelcomeScreen( - isLinkAndSyncAvailable = true, + state = WelcomeScreenState(isLinkAndSyncAvailable = true), onEvent = { event -> emittedEvent = event } @@ -85,7 +87,7 @@ class WelcomeScreenTest { // Given composeTestRule.setContent { SignalTheme { - WelcomeScreen(onEvent = {}) + WelcomeScreen(state = WelcomeScreenState(), onEvent = {}) } } @@ -105,6 +107,7 @@ class WelcomeScreenTest { composeTestRule.setContent { SignalTheme { WelcomeScreen( + state = WelcomeScreenState(), onEvent = { event -> emittedEvent = event } @@ -128,6 +131,7 @@ class WelcomeScreenTest { composeTestRule.setContent { SignalTheme { WelcomeScreen( + state = WelcomeScreenState(), onEvent = { event -> emittedEvent = event } @@ -143,12 +147,31 @@ class WelcomeScreenTest { assert(emittedEvent == WelcomeScreenEvents.DoesNotHaveOldPhone) } + @Test + fun `during re-registration the restore or transfer option is hidden`() { + // Given + composeTestRule.setContent { + SignalTheme { + WelcomeScreen( + state = WelcomeScreenState(showRestoreOrTransfer = false), + onEvent = {} + ) + } + } + + // Then - the continue option remains but the restore/transfer option is not offered + composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).assertIsDisplayed() + assert(composeTestRule.onAllNodesWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).fetchSemanticsNodes().isEmpty()) { + "Expected no restore/transfer option during re-registration" + } + } + @Test fun `screen displays welcome message`() { // Given composeTestRule.setContent { SignalTheme { - WelcomeScreen(onEvent = {}) + WelcomeScreen(state = WelcomeScreenState(), onEvent = {}) } } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenViewModelTest.kt new file mode 100644 index 0000000000..ab93bedc57 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenViewModelTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.welcome + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import assertk.assertions.prop +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.signal.registration.PreExistingRegistrationData +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationFlowState +import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationRoute + +@OptIn(ExperimentalCoroutinesApi::class) +class WelcomeScreenViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var mockRepository: RegistrationRepository + private lateinit var emittedParentEvents: MutableList + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + private lateinit var emittedStates: MutableList + private lateinit var stateEmitter: (WelcomeScreenState) -> Unit + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + mockRepository = mockk(relaxed = true) + every { mockRepository.isLinkAndSyncAvailable } returns false + emittedParentEvents = mutableListOf() + parentEventEmitter = { event -> emittedParentEvents.add(event) } + emittedStates = mutableListOf() + stateEmitter = { state -> emittedStates.add(state) } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `while the parent flow is still loading the restore or transfer option stays hidden`() { + val viewModel = createViewModel() + + viewModel.applyEvent(WelcomeScreenState(showRestoreOrTransfer = false), WelcomeScreenEvents.ParentStateChanged(RegistrationFlowState(isRestoringNavigationState = true)), parentEventEmitter, stateEmitter) + + assertThat(emittedStates.last().showRestoreOrTransfer).isFalse() + } + + @Test + fun `once loaded with no pre-existing data the restore or transfer option is shown`() { + val viewModel = createViewModel() + + viewModel.applyEvent(WelcomeScreenState(showRestoreOrTransfer = false), WelcomeScreenEvents.ParentStateChanged(RegistrationFlowState(isRestoringNavigationState = false, preExistingRegistrationData = null)), parentEventEmitter, stateEmitter) + + assertThat(emittedStates.last().showRestoreOrTransfer).isTrue() + } + + @Test + fun `once loaded with pre-existing data the restore or transfer option stays hidden`() { + val viewModel = createViewModel() + + viewModel.applyEvent(WelcomeScreenState(showRestoreOrTransfer = false), WelcomeScreenEvents.ParentStateChanged(RegistrationFlowState(isRestoringNavigationState = false, preExistingRegistrationData = mockk(relaxed = true))), parentEventEmitter, stateEmitter) + + assertThat(emittedStates.last().showRestoreOrTransfer).isFalse() + } + + @Test + fun `Continue navigates straight to phone number entry when permissions are granted`() { + val viewModel = createViewModel(hasPermissions = true) + + viewModel.applyEvent(WelcomeScreenState(), WelcomeScreenEvents.Continue, parentEventEmitter, stateEmitter) + + assertThat(emittedParentEvents.last()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isEqualTo(RegistrationRoute.PhoneNumberEntry) + } + + @Test + fun `Continue routes through the permissions screen when permissions are missing`() { + val viewModel = createViewModel(hasPermissions = false) + + viewModel.applyEvent(WelcomeScreenState(), WelcomeScreenEvents.Continue, parentEventEmitter, stateEmitter) + + assertThat(emittedParentEvents.last()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isEqualTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry)) + } + + @Test + fun `HasOldPhone navigates to the quick restore scan when permissions are granted`() { + val viewModel = createViewModel(hasPermissions = true) + + viewModel.applyEvent(WelcomeScreenState(), WelcomeScreenEvents.HasOldPhone, parentEventEmitter, stateEmitter) + + assertThat(emittedParentEvents.last()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isEqualTo(RegistrationRoute.QuickRestoreQrScan) + } + + @Test + fun `DoesNotHaveOldPhone navigates to the manual restore selection when permissions are granted`() { + val viewModel = createViewModel(hasPermissions = true) + + viewModel.applyEvent(WelcomeScreenState(), WelcomeScreenEvents.DoesNotHaveOldPhone, parentEventEmitter, stateEmitter) + + assertThat(emittedParentEvents.last()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isEqualTo(RegistrationRoute.ArchiveRestoreSelection.forManualRestore()) + } + + @Test + fun `LinkDevice navigates straight to link account when no linked device permission is required`() { + val viewModel = createViewModel(requiredLinkedDevicePermission = null) + + viewModel.applyEvent(WelcomeScreenState(), WelcomeScreenEvents.LinkDevice, parentEventEmitter, stateEmitter) + + assertThat(emittedParentEvents.last()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + + @Test + fun `LinkDevice routes through allow notifications when a linked device permission is required`() { + val viewModel = createViewModel(requiredLinkedDevicePermission = "android.permission.POST_NOTIFICATIONS") + + viewModel.applyEvent(WelcomeScreenState(), WelcomeScreenEvents.LinkDevice, parentEventEmitter, stateEmitter) + + assertThat(emittedParentEvents.last()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + + private fun createViewModel( + parentState: RegistrationFlowState = RegistrationFlowState(), + hasPermissions: Boolean = true, + requiredLinkedDevicePermission: String? = null + ): WelcomeScreenViewModel { + return WelcomeScreenViewModel(mockRepository, MutableStateFlow(parentState), parentEventEmitter, hasPermissions = { hasPermissions }, getRequiredLinkedDevicePermission = { requiredLinkedDevicePermission }) + } +}