Do not allow re-registers to restore data in regV5.

This commit is contained in:
Greyson Parrelli
2026-07-18 14:02:33 -04:00
parent 1f5d211b06
commit 0e1d60bf1d
13 changed files with 501 additions and 58 deletions
@@ -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<NavKey>.navigationEntries(
entry<RegistrationRoute.Welcome> {
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<NavKey>.navigationEntries(
}
}
}
else -> viewModel.onEvent(event)
}
}
)
@@ -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
@@ -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)
}
@@ -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 = {})
}
}
@@ -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()
@@ -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
)
@@ -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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val hasPermissions: () -> Boolean,
private val getRequiredLinkedDevicePermission: () -> String?
) : EventDrivenViewModel<WelcomeScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(WelcomeScreenViewModel::class)
}
private val _state = MutableStateFlow(WelcomeScreenState(isLinkAndSyncAvailable = repository.isLinkAndSyncAvailable, showRestoreOrTransfer = false))
val state: StateFlow<WelcomeScreenState> = _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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val hasPermissions: () -> Boolean,
private val getRequiredLinkedDevicePermission: () -> String?
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return WelcomeScreenViewModel(repository, parentState, parentEventEmitter, hasPermissions, getRequiredLinkedDevicePermission) as T
}
}
}
@@ -18,7 +18,7 @@ class WelcomeScreenScreenshotTests {
@Composable
fun WelcomeScreenPhonePreview() {
Previews.Preview {
WelcomeScreen(onEvent = {})
WelcomeScreen(state = WelcomeScreenState(), onEvent = {})
}
}
}
@@ -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,
@@ -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<MasterKey>(relaxed = true)
val keyMaterial = mockk<KeyMaterial>(relaxed = true)
val registerResponse = createRegisterAccountResponse(reregistration = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.RegistrationLock)
parentState.value = parentState.value.copy(preExistingRegistrationData = mockk<PreExistingRegistrationData>(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<MasterKey>(relaxed = true)
@@ -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<RegistrationRoute.PinEntryForSvrRestore>()
}
@Test
fun `CodeEntered reregistration with preExistingRegistrationData skips ArchiveRestoreSelection`() = runTest {
parentState.value = parentState.value.copy(preExistingRegistrationData = mockk<PreExistingRegistrationData>(relaxed = true))
val sessionMetadata = createSessionMetadata(verified = true)
val initialState = VerificationCodeState(
sessionMetadata = sessionMetadata,
e164 = "+15551234567"
)
val registerResponse = createRegisterAccountResponse(storageCapable = true, reregistration = true)
val keyMaterial = mockk<KeyMaterial>(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<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.PinEntryForSvrRestore>()
}
@Test
fun `CodeEntered with incorrect code returns IncorrectVerificationCode event`() = runTest {
val sessionMetadata = createSessionMetadata()
@@ -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 = {})
}
}
@@ -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<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var emittedStates: MutableList<WelcomeScreenState>
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<PreExistingRegistrationData>(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<RegistrationFlowEvent.NavigateToScreen>()
.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<RegistrationFlowEvent.NavigateToScreen>()
.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<RegistrationFlowEvent.NavigateToScreen>()
.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<RegistrationFlowEvent.NavigateToScreen>()
.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<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.LinkAccount>()
}
@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<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.AllowNotifications>()
}
private fun createViewModel(
parentState: RegistrationFlowState = RegistrationFlowState(),
hasPermissions: Boolean = true,
requiredLinkedDevicePermission: String? = null
): WelcomeScreenViewModel {
return WelcomeScreenViewModel(mockRepository, MutableStateFlow(parentState), parentEventEmitter, hasPermissions = { hasPermissions }, getRequiredLinkedDevicePermission = { requiredLinkedDevicePermission })
}
}