mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-04 20:34:14 +01:00
Do not allow re-registers to restore data in regV5.
This commit is contained in:
+13
-19
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+38
-33
@@ -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
@@ -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()
|
||||
|
||||
+16
@@ -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
|
||||
)
|
||||
+103
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ class WelcomeScreenScreenshotTests {
|
||||
@Composable
|
||||
fun WelcomeScreenPhonePreview() {
|
||||
Previews.Preview {
|
||||
WelcomeScreen(onEvent = {})
|
||||
WelcomeScreen(state = WelcomeScreenState(), onEvent = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+86
@@ -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,
|
||||
|
||||
+20
@@ -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)
|
||||
|
||||
+27
@@ -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()
|
||||
|
||||
+26
-3
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+164
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user