mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-20 20:44:44 +01:00
Add more end-to-end regV5 tests.
This commit is contained in:
@@ -76,6 +76,7 @@ object Dialogs {
|
||||
|
||||
const val TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON = "dialog-confirm-button"
|
||||
const val TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON = "dialog-dismiss-button"
|
||||
const val TEST_TAG_MESSAGE_DIALOG_DISMISS_BUTTON = "dialog-message-dismiss-button"
|
||||
|
||||
object Defaults {
|
||||
val shape: Shape @Composable get() = RoundedCornerShape(28.dp)
|
||||
@@ -141,9 +142,12 @@ object Dialogs {
|
||||
},
|
||||
text = { Text(text = message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onDismiss()
|
||||
}) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
},
|
||||
modifier = Modifier.testTag(TEST_TAG_MESSAGE_DIALOG_DISMISS_BUTTON)
|
||||
) {
|
||||
Text(text = dismiss, color = dismissColor)
|
||||
}
|
||||
},
|
||||
|
||||
+5
-2
@@ -522,7 +522,9 @@ private fun PinCreationTopBar(
|
||||
|
||||
IconButton(
|
||||
onClick = { menuController.show() },
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.testTag(TestTags.PIN_CREATION_MENU_BUTTON)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(CoreR.drawable.symbol_more_vertical_24),
|
||||
@@ -547,7 +549,8 @@ private fun PinCreationTopBar(
|
||||
onClick = {
|
||||
menuController.hide()
|
||||
showOptOutDialog = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag(TestTags.PIN_CREATION_DISABLE_PIN_MENU_ITEM)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,8 @@ object TestTags {
|
||||
const val PIN_CREATION_CONFIRM_INPUT = "pin_creation_confirm_input"
|
||||
const val PIN_CREATION_NEXT_BUTTON = "pin_creation_next_button"
|
||||
const val PIN_CREATION_TOGGLE_KEYBOARD_BUTTON = "pin_creation_toggle_keyboard_button"
|
||||
const val PIN_CREATION_MENU_BUTTON = "pin_creation_menu_button"
|
||||
const val PIN_CREATION_DISABLE_PIN_MENU_ITEM = "pin_creation_disable_pin_menu_item"
|
||||
|
||||
// Pin Entry Screen
|
||||
const val PIN_ENTRY_SCREEN = "pin_entry_screen"
|
||||
|
||||
+368
-42
@@ -6,15 +6,25 @@
|
||||
package org.signal.registration
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.os.Looper
|
||||
import androidx.activity.compose.LocalActivityResultRegistryOwner
|
||||
import androidx.activity.result.ActivityResultRegistry
|
||||
import androidx.activity.result.ActivityResultRegistryOwner
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
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.compose.ui.test.performTextInput
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -22,10 +32,19 @@ import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows
|
||||
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.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.registration.NetworkController.MasterKeyResponse
|
||||
import org.signal.registration.NetworkController.RegisterAccountError
|
||||
import org.signal.registration.NetworkController.RegistrationLockResponse
|
||||
import org.signal.registration.NetworkController.RestoreMasterKeyError
|
||||
import org.signal.registration.NetworkController.SvrCredentials
|
||||
import org.signal.registration.fakes.FakeNetworkController
|
||||
import org.signal.registration.fakes.FakeStorageController
|
||||
import org.signal.registration.fakes.SystemOutLogger
|
||||
@@ -33,11 +52,15 @@ 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 kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* End-to-end tests for the registration flow: renders the full [RegistrationNavHost] with a real
|
||||
* [RegistrationRepository] backed by in-memory fake controllers, and drives it by interacting with
|
||||
* the UI the way a user would.
|
||||
*
|
||||
* The fakes default to a happy path. To exercise other navigation paths, override the relevant
|
||||
* response handler on [networkController] or state on [storageController] before driving the UI.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@@ -46,7 +69,8 @@ class RegistrationEndToEndTest {
|
||||
|
||||
companion object {
|
||||
private const val PHONE_NUMBER = "5550123456"
|
||||
private const val VERIFICATION_CODE = "123456"
|
||||
private const val E164 = "+1$PHONE_NUMBER"
|
||||
private const val VERIFICATION_CODE = FakeNetworkController.DEFAULT_VERIFICATION_CODE
|
||||
private const val PIN = "9182"
|
||||
private const val WAIT_TIMEOUT_MS = 30_000L
|
||||
}
|
||||
@@ -62,6 +86,8 @@ class RegistrationEndToEndTest {
|
||||
private lateinit var repository: RegistrationRepository
|
||||
private lateinit var viewModel: RegistrationViewModel
|
||||
|
||||
private val backupFolderUri: Uri = Uri.parse("content://test/backups")
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Log.initialize(SystemOutLogger())
|
||||
@@ -69,7 +95,7 @@ class RegistrationEndToEndTest {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
Shadows.shadowOf(context).grantPermissions(*RegistrationPermissions.getRequiredPermissions(context).toTypedArray())
|
||||
|
||||
networkController = FakeNetworkController(correctVerificationCode = VERIFICATION_CODE)
|
||||
networkController = FakeNetworkController()
|
||||
storageController = FakeStorageController()
|
||||
repository = RegistrationRepository(context, networkController, storageController, isLinkAndSyncAvailable = false)
|
||||
viewModel = RegistrationViewModel(repository, SavedStateHandle())
|
||||
@@ -78,37 +104,344 @@ class RegistrationEndToEndTest {
|
||||
@Test
|
||||
fun `happy path - new registration by entering phone number, verification code, and creating a pin`() {
|
||||
var registrationComplete = false
|
||||
launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true })
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
RegistrationNavHost(
|
||||
registrationRepository = repository,
|
||||
registrationViewModel = viewModel,
|
||||
permissionsState = createMockPermissionsState(),
|
||||
onRegistrationComplete = { registrationComplete = true }
|
||||
submitPhoneNumber()
|
||||
submitVerificationCode(VERIFICATION_CODE)
|
||||
createPin(PIN)
|
||||
|
||||
waitFor("registration to complete") { registrationComplete }
|
||||
|
||||
val committed = storageController.committedData
|
||||
assert(committed != null) { "Expected registration data to be committed" }
|
||||
assert(committed!!.e164 == E164) { "Expected committed e164 $E164 but was ${committed.e164}" }
|
||||
assert(committed.aci.isNotEmpty()) { "Expected committed ACI to be populated" }
|
||||
assert(committed.pni.isNotEmpty()) { "Expected committed PNI to be populated" }
|
||||
assert(committed.pin == PIN) { "Expected committed pin $PIN but was ${committed.pin}" }
|
||||
assert(committed.accountEntropyPool.isNotEmpty()) { "Expected committed AEP to be populated" }
|
||||
|
||||
assert(networkController.lastCreateSessionE164 == E164) { "Expected a session for $E164 but was ${networkController.lastCreateSessionE164}" }
|
||||
assert(networkController.lastRegisterAccountRequest?.e164 == E164) { "Expected registration for $E164 but was ${networkController.lastRegisterAccountRequest}" }
|
||||
assert(networkController.lastSetPinRequest?.pin == PIN) { "Expected pin $PIN on SVR but was ${networkController.lastSetPinRequest?.pin}" }
|
||||
assert(networkController.accountAttributesSyncJobEnqueued) { "Expected the account attributes sync job to be enqueued" }
|
||||
|
||||
assert(storageController.restoreDecision == RestoreDecision.NEW_ACCOUNT) { "Expected NEW_ACCOUNT restore decision but was ${storageController.restoreDecision}" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a registration lock is unlocked by entering the existing pin and registration completes`() {
|
||||
val masterKey = MasterKey(ByteArray(32) { it.toByte() })
|
||||
|
||||
networkController.onRegisterAccount = { request ->
|
||||
if (request.registrationLock == null) {
|
||||
RequestResult.NonSuccess(
|
||||
RegisterAccountError.RegistrationLock(
|
||||
RegistrationLockResponse(
|
||||
timeRemaining = 14.days.inWholeMilliseconds,
|
||||
svr2Credentials = SvrCredentials(username = "svr-user", password = "svr-pass")
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
RequestResult.Success(networkController.registerAccountResponse(request.e164))
|
||||
}
|
||||
}
|
||||
|
||||
// Welcome -> PhoneNumberEntry (permissions are already granted, so the permissions screen is skipped)
|
||||
waitForTag(TestTags.WELCOME_SCREEN)
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick()
|
||||
networkController.onRestoreMasterKeyFromSvr = { request ->
|
||||
if (request.pin == PIN) {
|
||||
RequestResult.Success(MasterKeyResponse(masterKey))
|
||||
} else {
|
||||
RequestResult.NonSuccess(RestoreMasterKeyError.WrongPin(triesRemaining = 3))
|
||||
}
|
||||
}
|
||||
|
||||
// Enter the phone number and confirm it in the dialog
|
||||
waitForTag(TestTags.PHONE_NUMBER_SCREEN)
|
||||
composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_PHONE_FIELD).performTextInput(PHONE_NUMBER)
|
||||
waitForTag(TestTags.PHONE_NUMBER_NEXT_BUTTON)
|
||||
composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).performClick()
|
||||
var registrationComplete = false
|
||||
launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true })
|
||||
|
||||
submitPhoneNumber()
|
||||
submitVerificationCode(VERIFICATION_CODE)
|
||||
|
||||
// The account is reglocked, so the user must prove they know their existing PIN
|
||||
waitForTag(TestTags.PIN_ENTRY_SCREEN)
|
||||
composeTestRule.onNodeWithTag(TestTags.PIN_ENTRY_INPUT).performTextInput(PIN)
|
||||
composeTestRule.onNodeWithTag(TestTags.PIN_ENTRY_CONTINUE_BUTTON).performClick()
|
||||
|
||||
waitFor("registration to complete") { registrationComplete }
|
||||
|
||||
assert(networkController.lastRestoreMasterKeyRequest?.pin == PIN) { "Expected master key restore with pin $PIN but was ${networkController.lastRestoreMasterKeyRequest}" }
|
||||
assert(networkController.lastRegisterAccountRequest?.registrationLock == masterKey.deriveRegistrationLock()) { "Expected registration with the derived reglock token" }
|
||||
|
||||
val committed = storageController.committedData
|
||||
assert(committed != null) { "Expected registration data to be committed" }
|
||||
assert(committed!!.e164 == E164) { "Expected committed e164 $E164 but was ${committed.e164}" }
|
||||
assert(committed.aci.isNotEmpty()) { "Expected committed ACI to be populated" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-registering an existing account offers restore selection, which can be skipped to complete registration`() {
|
||||
networkController.onRegisterAccount = { request ->
|
||||
RequestResult.Success(networkController.registerAccountResponse(request.e164, reregistration = true))
|
||||
}
|
||||
|
||||
var registrationComplete = false
|
||||
launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true })
|
||||
|
||||
submitPhoneNumber()
|
||||
submitVerificationCode(VERIFICATION_CODE)
|
||||
|
||||
// The user is re-registering, so they're offered a restore. Decline it, confirming the skip warning.
|
||||
waitForTag(TestTags.ARCHIVE_RESTORE_SELECTION_SCREEN)
|
||||
composeTestRule.onNodeWithTag(TestTags.ARCHIVE_RESTORE_SELECTION_NONE).performClick()
|
||||
waitForTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON)
|
||||
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick()
|
||||
|
||||
// Session is created and a code is requested, landing us on the verification code screen
|
||||
waitForTag(TestTags.VERIFICATION_CODE_DIGIT_0)
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).performTextInput(VERIFICATION_CODE)
|
||||
createPin(PIN)
|
||||
|
||||
// The code verifies and the account registers, landing us on PIN creation
|
||||
waitFor("registration to complete") { registrationComplete }
|
||||
|
||||
val committed = storageController.committedData
|
||||
assert(committed != null) { "Expected registration data to be committed" }
|
||||
assert(committed!!.e164 == E164) { "Expected committed e164 $E164 but was ${committed.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
|
||||
launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true })
|
||||
|
||||
submitPhoneNumber()
|
||||
submitVerificationCode(VERIFICATION_CODE)
|
||||
|
||||
// Instead of creating a PIN, disable PINs via the overflow menu, confirming the warning
|
||||
waitForTag(TestTags.PIN_CREATION_SCREEN)
|
||||
composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_INPUT).performTextInput(PIN)
|
||||
composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_MENU_BUTTON).performClick()
|
||||
waitForTag(TestTags.PIN_CREATION_DISABLE_PIN_MENU_ITEM)
|
||||
composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_DISABLE_PIN_MENU_ITEM).performClick()
|
||||
waitForTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON)
|
||||
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick()
|
||||
|
||||
waitFor("registration to complete") { registrationComplete }
|
||||
|
||||
val committed = storageController.committedData
|
||||
assert(committed != null) { "Expected registration data to be committed" }
|
||||
assert(committed!!.pinOptedOut) { "Expected the committed data to record the PIN opt-out" }
|
||||
assert(committed.pin.isEmpty()) { "Expected no committed pin but was ${committed.pin}" }
|
||||
assert(networkController.lastSetPinRequest == null) { "Should not have backed up a pin to SVR" }
|
||||
assert(storageController.restoreDecision == RestoreDecision.NEW_ACCOUNT) { "Expected NEW_ACCOUNT restore decision but was ${storageController.restoreDecision}" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoring a remote backup before registering completes registration`() {
|
||||
val aep = AccountEntropyPool.generate()
|
||||
|
||||
var registrationComplete = false
|
||||
launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true })
|
||||
|
||||
startManualRestore()
|
||||
chooseRestoreOption(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_SIGNAL_BACKUPS)
|
||||
enterPhoneNumber()
|
||||
enterAep(aep)
|
||||
startRemoteRestore()
|
||||
|
||||
waitFor("registration to complete") { registrationComplete }
|
||||
|
||||
assert(networkController.lastRegisterAccountRequest?.recoveryPassword == aep.deriveMasterKey().deriveRegistrationRecoveryPassword()) {
|
||||
"Expected registration via the recovery password derived from the entered AEP"
|
||||
}
|
||||
|
||||
val committed = storageController.committedData
|
||||
assert(committed != null) { "Expected registration data to be committed" }
|
||||
assert(committed!!.e164 == E164) { "Expected committed e164 $E164 but was ${committed.e164}" }
|
||||
assert(committed.accountEntropyPool == aep.value) { "Expected the committed AEP to be the one the user entered" }
|
||||
assert(storageController.restoreDecision == RestoreDecision.COMPLETED) { "Expected COMPLETED restore decision but was ${storageController.restoreDecision}" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoring a local backup before registering completes registration`() {
|
||||
val aep = AccountEntropyPool.generate()
|
||||
|
||||
var registrationComplete = false
|
||||
launchRegistrationFlow(folderPickerResult = backupFolderUri, onRegistrationComplete = { registrationComplete = true })
|
||||
|
||||
startManualRestore()
|
||||
chooseRestoreOption(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_BACKUP_FOLDER)
|
||||
enterPhoneNumber()
|
||||
restoreLocalBackup(aep)
|
||||
|
||||
// With the backup restored, registration happens via the recovery password and the user creates a PIN
|
||||
createPin(PIN)
|
||||
|
||||
waitFor("registration to complete") { registrationComplete }
|
||||
|
||||
assert(networkController.lastRegisterAccountRequest?.recoveryPassword == aep.deriveMasterKey().deriveRegistrationRecoveryPassword()) {
|
||||
"Expected registration via the recovery password derived from the restored AEP"
|
||||
}
|
||||
|
||||
val committed = storageController.committedData
|
||||
assert(committed != null) { "Expected registration data to be committed" }
|
||||
assert(committed!!.e164 == E164) { "Expected committed e164 $E164 but was ${committed.e164}" }
|
||||
assert(committed.accountEntropyPool == aep.value) { "Expected the committed AEP to be the one from the restored backup" }
|
||||
assert(committed.pin == PIN) { "Expected committed pin $PIN but was ${committed.pin}" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoring a remote backup after registering completes registration`() {
|
||||
val aep = AccountEntropyPool.generate()
|
||||
|
||||
networkController.onRegisterAccount = { request ->
|
||||
RequestResult.Success(networkController.registerAccountResponse(request.e164, reregistration = true))
|
||||
}
|
||||
|
||||
var registrationComplete = false
|
||||
launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true })
|
||||
|
||||
submitPhoneNumber()
|
||||
submitVerificationCode(VERIFICATION_CODE)
|
||||
|
||||
// The user is re-registering, so they're offered a restore
|
||||
chooseRestoreOption(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_SIGNAL_BACKUPS)
|
||||
enterAep(aep)
|
||||
startRemoteRestore()
|
||||
|
||||
waitFor("registration to complete") { registrationComplete }
|
||||
|
||||
val committed = storageController.committedData
|
||||
assert(committed != null) { "Expected registration data to be committed" }
|
||||
assert(committed!!.accountEntropyPool == aep.value) { "Expected the committed AEP to be the one the user entered" }
|
||||
assert(storageController.restoreDecision == RestoreDecision.COMPLETED) { "Expected COMPLETED restore decision but was ${storageController.restoreDecision}" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoring a local backup after registering completes registration`() {
|
||||
val aep = AccountEntropyPool.generate()
|
||||
|
||||
networkController.onRegisterAccount = { request ->
|
||||
RequestResult.Success(networkController.registerAccountResponse(request.e164, reregistration = true))
|
||||
}
|
||||
|
||||
// The backup contains the user's PIN, so the flow finishes without any PIN screens
|
||||
storageController.onRestoreLocalBackupV2 = { _, _ ->
|
||||
flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = PIN, restoredProfileKey = null))
|
||||
}
|
||||
|
||||
var registrationComplete = false
|
||||
launchRegistrationFlow(folderPickerResult = backupFolderUri, onRegistrationComplete = { registrationComplete = true })
|
||||
|
||||
submitPhoneNumber()
|
||||
submitVerificationCode(VERIFICATION_CODE)
|
||||
|
||||
// The user is re-registering, so they're offered a restore
|
||||
chooseRestoreOption(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_BACKUP_FOLDER)
|
||||
restoreLocalBackup(aep)
|
||||
|
||||
waitFor("registration to complete") { registrationComplete }
|
||||
|
||||
val committed = storageController.committedData
|
||||
assert(committed != null) { "Expected registration data to be committed" }
|
||||
assert(committed!!.pin == PIN) { "Expected the pin from the restored backup but was ${committed.pin}" }
|
||||
assert(storageController.restoreDecision == RestoreDecision.COMPLETED) { "Expected COMPLETED restore decision but was ${storageController.restoreDecision}" }
|
||||
}
|
||||
|
||||
// -- Flow helpers: each one drives the UI from the screen the flow is currently on.
|
||||
|
||||
/**
|
||||
* @param folderPickerResult When set, any system activity launched for a result (i.e. the backup folder picker)
|
||||
* is immediately answered with this value.
|
||||
*/
|
||||
private fun launchRegistrationFlow(
|
||||
folderPickerResult: Uri? = null,
|
||||
onRegistrationComplete: () -> Unit = {}
|
||||
) {
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
ActivityResultInterceptor(folderPickerResult) {
|
||||
RegistrationNavHost(
|
||||
registrationRepository = repository,
|
||||
registrationViewModel = viewModel,
|
||||
permissionsState = createMockPermissionsState(),
|
||||
onRegistrationComplete = onRegistrationComplete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityResultInterceptor(result: Any?, content: @Composable () -> Unit) {
|
||||
if (result == null) {
|
||||
content()
|
||||
} else {
|
||||
val owner = remember { ImmediateResultRegistryOwner(result) }
|
||||
CompositionLocalProvider(LocalActivityResultRegistryOwner provides owner) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** From the Welcome screen: continues to phone number entry (permissions are granted, so that screen is skipped), enters [PHONE_NUMBER], and confirms the dialog. */
|
||||
private fun submitPhoneNumber() {
|
||||
waitForTag(TestTags.WELCOME_SCREEN)
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick()
|
||||
enterPhoneNumber()
|
||||
}
|
||||
|
||||
/** From the phone number entry screen: enters [PHONE_NUMBER] and confirms the dialog. */
|
||||
private fun enterPhoneNumber() {
|
||||
waitForTag(TestTags.PHONE_NUMBER_SCREEN)
|
||||
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()
|
||||
}
|
||||
|
||||
/** From the Welcome screen: navigates to manual restore selection via "restore or transfer" → "don't have my old phone". */
|
||||
private fun startManualRestore() {
|
||||
waitForTag(TestTags.WELCOME_SCREEN)
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).performClick()
|
||||
waitForTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON)
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON).performClick()
|
||||
}
|
||||
|
||||
/** From the archive restore selection screen: picks the restore option with the given tag. */
|
||||
private fun chooseRestoreOption(optionTag: String) {
|
||||
waitForTag(TestTags.ARCHIVE_RESTORE_SELECTION_SCREEN)
|
||||
composeTestRule.onNodeWithTag(optionTag).performClick()
|
||||
}
|
||||
|
||||
/** From the AEP entry screen: types the backup key and submits it. */
|
||||
private fun enterAep(aep: AccountEntropyPool) {
|
||||
waitForTag(TestTags.ENTER_AEP_SCREEN)
|
||||
composeTestRule.onNodeWithTag(TestTags.ENTER_AEP_INPUT).performTextInput(aep.value)
|
||||
composeTestRule.onNodeWithTag(TestTags.ENTER_AEP_NEXT_BUTTON).performClick()
|
||||
}
|
||||
|
||||
/** From the local backup restore screen: picks the backup folder (answered by the fake folder picker), restores the backup that is found, and decrypts it with [aep]. */
|
||||
private fun restoreLocalBackup(aep: AccountEntropyPool) {
|
||||
waitForTag(TestTags.LOCAL_BACKUP_RESTORE_SELECT_FOLDER_BUTTON)
|
||||
composeTestRule.onNodeWithTag(TestTags.LOCAL_BACKUP_RESTORE_SELECT_FOLDER_BUTTON).performClick()
|
||||
waitForTag(TestTags.LOCAL_BACKUP_RESTORE_RESTORE_BUTTON)
|
||||
composeTestRule.onNodeWithTag(TestTags.LOCAL_BACKUP_RESTORE_RESTORE_BUTTON).performClick()
|
||||
enterAep(aep)
|
||||
}
|
||||
|
||||
/** From the remote restore screen: starts the restore once the backup info has loaded. */
|
||||
private fun startRemoteRestore() {
|
||||
waitForTag(TestTags.REMOTE_BACKUP_RESTORE_RESTORE_BUTTON)
|
||||
composeTestRule.onNodeWithTag(TestTags.REMOTE_BACKUP_RESTORE_RESTORE_BUTTON).performClick()
|
||||
}
|
||||
|
||||
/** From the verification code screen: enters all six digits of [code], which submits automatically. */
|
||||
private fun submitVerificationCode(code: String) {
|
||||
waitForTag(TestTags.VERIFICATION_CODE_DIGIT_0)
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).performTextInput(code)
|
||||
}
|
||||
|
||||
/** From the PIN creation screen: enters [pin], then re-enters it on the confirmation step. */
|
||||
private fun createPin(pin: String) {
|
||||
waitForTag(TestTags.PIN_CREATION_SCREEN)
|
||||
composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_INPUT).performTextInput(pin)
|
||||
composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_NEXT_BUTTON).performClick()
|
||||
|
||||
// Wait for the confirm step's fresh input field to fully replace the create step's
|
||||
@@ -116,27 +449,8 @@ class RegistrationEndToEndTest {
|
||||
composeTestRule.onAllNodesWithTag(TestTags.PIN_CREATION_CONFIRM_INPUT).fetchSemanticsNodes().isNotEmpty() &&
|
||||
composeTestRule.onAllNodesWithTag(TestTags.PIN_CREATION_INPUT).fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_CONFIRM_INPUT).performTextInput(PIN)
|
||||
composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_CONFIRM_INPUT).performTextInput(pin)
|
||||
composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_NEXT_BUTTON).performClick()
|
||||
|
||||
// The PIN is backed up to SVR and registration completes
|
||||
waitFor("registration to complete") { registrationComplete }
|
||||
|
||||
val committed = storageController.committedData
|
||||
assert(committed != null) { "Expected registration data to be committed" }
|
||||
assert(committed!!.e164 == "+1$PHONE_NUMBER") { "Expected committed e164 +1$PHONE_NUMBER but was ${committed.e164}" }
|
||||
assert(committed.aci.isNotEmpty()) { "Expected committed ACI to be populated" }
|
||||
assert(committed.pni.isNotEmpty()) { "Expected committed PNI to be populated" }
|
||||
assert(committed.pin == PIN) { "Expected committed pin $PIN but was ${committed.pin}" }
|
||||
assert(committed.accountEntropyPool.isNotEmpty()) { "Expected committed AEP to be populated" }
|
||||
|
||||
assert(networkController.sessionCreated) { "Expected a verification session to be created" }
|
||||
assert(networkController.verificationCodeRequested) { "Expected a verification code to be requested" }
|
||||
assert(networkController.registeredE164 == "+1$PHONE_NUMBER") { "Expected registration for +1$PHONE_NUMBER but was ${networkController.registeredE164}" }
|
||||
assert(networkController.svrPin == PIN) { "Expected pin $PIN on SVR but was ${networkController.svrPin}" }
|
||||
assert(networkController.accountAttributesSyncJobEnqueued) { "Expected the account attributes sync job to be enqueued" }
|
||||
|
||||
assert(storageController.restoreDecision == RestoreDecision.NEW_ACCOUNT) { "Expected NEW_ACCOUNT restore decision but was ${storageController.restoreDecision}" }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,3 +486,15 @@ class RegistrationEndToEndTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An [ActivityResultRegistryOwner] that immediately answers any launched contract (e.g. the system folder picker)
|
||||
* with [result], since no real activity can handle intents in a unit test.
|
||||
*/
|
||||
private class ImmediateResultRegistryOwner(private val result: Any?) : ActivityResultRegistryOwner {
|
||||
override val activityResultRegistry = object : ActivityResultRegistry() {
|
||||
override fun <I, O> onLaunch(requestCode: Int, contract: ActivityResultContract<I, O>, input: I, options: ActivityOptionsCompat?) {
|
||||
dispatchResult(requestCode, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+179
-54
@@ -47,53 +47,180 @@ import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* An in-memory [NetworkController] that plays the part of a well-behaved server for a fresh registration.
|
||||
* Methods that should never be hit in the flows under test fail loudly.
|
||||
* An in-memory [NetworkController] whose responses can be customized per-test.
|
||||
*
|
||||
* Every method the registration flow exercises delegates to an overridable `on<Method>` handler. The defaults play
|
||||
* the part of a well-behaved server for a fresh registration, so tests only override the responses they care about:
|
||||
*
|
||||
* ```
|
||||
* networkController.onRegisterAccount = {
|
||||
* RequestResult.NonSuccess(RegisterAccountError.RateLimited(retryAfter = 30.seconds))
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Requests are recorded (see `last*` properties) no matter which handler serves them, so tests can assert on what
|
||||
* the flow actually sent. Methods that no flow under test should reach fail loudly.
|
||||
*/
|
||||
class FakeNetworkController(
|
||||
private val correctVerificationCode: String = "123456"
|
||||
private val correctVerificationCode: String = DEFAULT_VERIFICATION_CODE
|
||||
) : NetworkController {
|
||||
|
||||
val sessionId = "fake-session-id"
|
||||
companion object {
|
||||
const val DEFAULT_VERIFICATION_CODE = "123456"
|
||||
const val SESSION_ID = "fake-session-id"
|
||||
}
|
||||
|
||||
var sessionCreated = false
|
||||
data class UpdateSessionRequest(val sessionId: String?, val pushChallengeToken: String?, val captchaToken: String?)
|
||||
data class RegisterAccountRequest(val e164: String, val sessionId: String?, val recoveryPassword: String?, val registrationLock: String?)
|
||||
data class SetPinRequest(val pin: String, val masterKey: MasterKey)
|
||||
data class RestoreMasterKeyRequest(val svrCredentials: SvrCredentials, val pin: String)
|
||||
|
||||
// -- Recorded requests, populated regardless of which handler serves them.
|
||||
|
||||
var lastCreateSessionE164: String? = null
|
||||
private set
|
||||
var verificationCodeRequested = false
|
||||
var lastUpdateSessionRequest: UpdateSessionRequest? = null
|
||||
private set
|
||||
var registeredE164: String? = null
|
||||
var lastRequestedCodeTransport: VerificationCodeTransport? = null
|
||||
private set
|
||||
var svrPin: String? = null
|
||||
var lastSubmittedVerificationCode: String? = null
|
||||
private set
|
||||
var svrMasterKey: MasterKey? = null
|
||||
var lastRegisterAccountRequest: RegisterAccountRequest? = null
|
||||
private set
|
||||
var lastSetPinRequest: SetPinRequest? = null
|
||||
private set
|
||||
var lastRestoreMasterKeyRequest: RestoreMasterKeyRequest? = null
|
||||
private set
|
||||
var accountAttributesSyncJobEnqueued = false
|
||||
private set
|
||||
|
||||
private var verified = false
|
||||
/**
|
||||
* Whether the fake session has been verified. Set by the default [onSubmitVerificationCode] handler when the
|
||||
* correct code is submitted, and reflected in the sessions built by [session].
|
||||
*/
|
||||
var sessionVerified = false
|
||||
|
||||
private fun currentSession(): SessionMetadata {
|
||||
/** Returned by [getFcmToken]. Null means the device does not support FCM. */
|
||||
var fcmToken: String? = null
|
||||
|
||||
/** Returned by [awaitPushChallengeToken]. Null means no push challenge ever arrives. */
|
||||
var pushChallengeToken: String? = null
|
||||
|
||||
// -- Response handlers. Override these in tests to change how the fake server responds.
|
||||
|
||||
var onCreateSession: suspend (e164: String) -> RequestResult<SessionMetadata, CreateSessionError> = {
|
||||
RequestResult.Success(session())
|
||||
}
|
||||
|
||||
var onGetSession: suspend (sessionId: String) -> RequestResult<SessionMetadata, GetSessionStatusError> = {
|
||||
RequestResult.Success(session())
|
||||
}
|
||||
|
||||
var onUpdateSession: suspend (UpdateSessionRequest) -> RequestResult<SessionMetadata, UpdateSessionError> = {
|
||||
RequestResult.Success(session())
|
||||
}
|
||||
|
||||
var onRequestVerificationCode: suspend (sessionId: String) -> RequestResult<SessionMetadata, RequestVerificationCodeError> = {
|
||||
RequestResult.Success(session())
|
||||
}
|
||||
|
||||
var onSubmitVerificationCode: suspend (code: String) -> RequestResult<SessionMetadata, SubmitVerificationCodeError> = { code ->
|
||||
if (code == correctVerificationCode) {
|
||||
sessionVerified = true
|
||||
}
|
||||
RequestResult.Success(session())
|
||||
}
|
||||
|
||||
var onRegisterAccount: suspend (RegisterAccountRequest) -> RequestResult<RegisterAccountResponse, RegisterAccountError> = { request ->
|
||||
check(sessionVerified || request.recoveryPassword != null) { "Attempted to register with a session before it was verified!" }
|
||||
RequestResult.Success(registerAccountResponse(request.e164))
|
||||
}
|
||||
|
||||
var onSetPinAndMasterKeyOnSvr: suspend (SetPinRequest) -> RequestResult<SvrCredentials?, BackupMasterKeyError> = {
|
||||
RequestResult.Success(null)
|
||||
}
|
||||
|
||||
var onRestoreMasterKeyFromSvr: suspend (RestoreMasterKeyRequest) -> RequestResult<MasterKeyResponse, RestoreMasterKeyError> = {
|
||||
notExpected()
|
||||
}
|
||||
|
||||
var onGetSvrCredentials: suspend () -> RequestResult<SvrCredentials, GetSvrCredentialsError> = {
|
||||
notExpected()
|
||||
}
|
||||
|
||||
var onCheckSvrCredentials: suspend (e164: String, credentials: List<SvrCredentials>) -> RequestResult<CheckSvrCredentialsResponse, CheckSvrCredentialsError> = { _, _ ->
|
||||
notExpected()
|
||||
}
|
||||
|
||||
var onRestoreAccountRecord: suspend () -> RequestResult<Unit, RestoreAccountRecordError> = {
|
||||
RequestResult.Success(Unit)
|
||||
}
|
||||
|
||||
var onGetRemoteBackupInfo: suspend (AccountEntropyPool) -> RequestResult<GetBackupInfoResponse, GetBackupInfoError> = {
|
||||
RequestResult.Success(GetBackupInfoResponse(cdn = 3, backupDir = "backup-dir", mediaDir = "media-dir", backupName = "backup", usedSpace = 1_000_000))
|
||||
}
|
||||
|
||||
var onGetBackupFileLastModified: suspend (AccountEntropyPool) -> RequestResult<Long, GetBackupInfoError> = {
|
||||
RequestResult.Success(1_700_000_000_000)
|
||||
}
|
||||
|
||||
var onVerifyBackupKey: suspend (AccountEntropyPool) -> RequestResult<Unit, NetworkController.VerifyBackupKeyError> = {
|
||||
RequestResult.Success(Unit)
|
||||
}
|
||||
|
||||
// -- Response factories with happy-path defaults, for handlers that only want to tweak a field or two.
|
||||
|
||||
fun session(
|
||||
verified: Boolean = sessionVerified,
|
||||
allowedToRequestCode: Boolean = true,
|
||||
requestedInformation: List<String> = emptyList(),
|
||||
nextSms: Long? = null,
|
||||
nextCall: Long? = null,
|
||||
nextVerificationAttempt: Long? = null
|
||||
): SessionMetadata {
|
||||
return SessionMetadata(
|
||||
id = sessionId,
|
||||
nextSms = null,
|
||||
nextCall = null,
|
||||
nextVerificationAttempt = null,
|
||||
allowedToRequestCode = true,
|
||||
requestedInformation = emptyList(),
|
||||
id = SESSION_ID,
|
||||
nextSms = nextSms,
|
||||
nextCall = nextCall,
|
||||
nextVerificationAttempt = nextVerificationAttempt,
|
||||
allowedToRequestCode = allowedToRequestCode,
|
||||
requestedInformation = requestedInformation,
|
||||
verified = verified
|
||||
)
|
||||
}
|
||||
|
||||
fun registerAccountResponse(
|
||||
e164: String,
|
||||
storageCapable: Boolean = false,
|
||||
reregistration: Boolean = false
|
||||
): RegisterAccountResponse {
|
||||
return RegisterAccountResponse(
|
||||
aci = UUID.randomUUID().toString(),
|
||||
pni = UUID.randomUUID().toString(),
|
||||
e164 = e164,
|
||||
usernameHash = null,
|
||||
usernameLinkHandle = null,
|
||||
storageCapable = storageCapable,
|
||||
entitlements = null,
|
||||
reregistration = reregistration
|
||||
)
|
||||
}
|
||||
|
||||
// -- NetworkController implementation: record the request, then delegate to the handler.
|
||||
|
||||
override suspend fun createSession(e164: String, fcmToken: String?, mcc: String?, mnc: String?): RequestResult<SessionMetadata, CreateSessionError> {
|
||||
sessionCreated = true
|
||||
return RequestResult.Success(currentSession())
|
||||
lastCreateSessionE164 = e164
|
||||
return onCreateSession(e164)
|
||||
}
|
||||
|
||||
override suspend fun getSession(sessionId: String): RequestResult<SessionMetadata, GetSessionStatusError> {
|
||||
return RequestResult.Success(currentSession())
|
||||
return onGetSession(sessionId)
|
||||
}
|
||||
|
||||
override suspend fun updateSession(sessionId: String?, pushChallengeToken: String?, captchaToken: String?): RequestResult<SessionMetadata, UpdateSessionError> {
|
||||
return RequestResult.Success(currentSession())
|
||||
val request = UpdateSessionRequest(sessionId, pushChallengeToken, captchaToken)
|
||||
lastUpdateSessionRequest = request
|
||||
return onUpdateSession(request)
|
||||
}
|
||||
|
||||
override suspend fun requestVerificationCode(
|
||||
@@ -102,15 +229,13 @@ class FakeNetworkController(
|
||||
androidSmsRetrieverSupported: Boolean,
|
||||
transport: VerificationCodeTransport
|
||||
): RequestResult<SessionMetadata, RequestVerificationCodeError> {
|
||||
verificationCodeRequested = true
|
||||
return RequestResult.Success(currentSession())
|
||||
lastRequestedCodeTransport = transport
|
||||
return onRequestVerificationCode(sessionId)
|
||||
}
|
||||
|
||||
override suspend fun submitVerificationCode(sessionId: String, verificationCode: String): RequestResult<SessionMetadata, SubmitVerificationCodeError> {
|
||||
if (verificationCode == correctVerificationCode) {
|
||||
verified = true
|
||||
}
|
||||
return RequestResult.Success(currentSession())
|
||||
lastSubmittedVerificationCode = verificationCode
|
||||
return onSubmitVerificationCode(verificationCode)
|
||||
}
|
||||
|
||||
override suspend fun registerAccount(
|
||||
@@ -124,37 +249,27 @@ class FakeNetworkController(
|
||||
fcmToken: String?,
|
||||
skipDeviceTransfer: Boolean
|
||||
): RequestResult<RegisterAccountResponse, RegisterAccountError> {
|
||||
check(verified) { "Attempted to register before the session was verified!" }
|
||||
registeredE164 = e164
|
||||
|
||||
return RequestResult.Success(
|
||||
RegisterAccountResponse(
|
||||
aci = UUID.randomUUID().toString(),
|
||||
pni = UUID.randomUUID().toString(),
|
||||
e164 = e164,
|
||||
usernameHash = null,
|
||||
usernameLinkHandle = null,
|
||||
storageCapable = false,
|
||||
entitlements = null,
|
||||
reregistration = false
|
||||
)
|
||||
)
|
||||
val request = RegisterAccountRequest(e164, sessionId, recoveryPassword, attributes.registrationLock)
|
||||
lastRegisterAccountRequest = request
|
||||
return onRegisterAccount(request)
|
||||
}
|
||||
|
||||
override suspend fun getFcmToken(): String? = null
|
||||
override suspend fun getFcmToken(): String? = fcmToken
|
||||
|
||||
override suspend fun awaitPushChallengeToken(): String? = null
|
||||
override suspend fun awaitPushChallengeToken(): String? = pushChallengeToken
|
||||
|
||||
override fun getCaptchaUrl(): String = "https://example.com/captcha"
|
||||
|
||||
override suspend fun restoreMasterKeyFromSvr(svrCredentials: SvrCredentials, pin: String): RequestResult<MasterKeyResponse, RestoreMasterKeyError> {
|
||||
notExpected()
|
||||
val request = RestoreMasterKeyRequest(svrCredentials, pin)
|
||||
lastRestoreMasterKeyRequest = request
|
||||
return onRestoreMasterKeyFromSvr(request)
|
||||
}
|
||||
|
||||
override suspend fun setPinAndMasterKeyOnSvr(pin: String, masterKey: MasterKey): RequestResult<SvrCredentials?, BackupMasterKeyError> {
|
||||
svrPin = pin
|
||||
svrMasterKey = masterKey
|
||||
return RequestResult.Success(null)
|
||||
val request = SetPinRequest(pin, masterKey)
|
||||
lastSetPinRequest = request
|
||||
return onSetPinAndMasterKeyOnSvr(request)
|
||||
}
|
||||
|
||||
override suspend fun enqueueSvrGuessResetJobIfPossible(): Boolean = true
|
||||
@@ -163,9 +278,13 @@ class FakeNetworkController(
|
||||
|
||||
override suspend fun disableRegistrationLock(): RequestResult<Unit, SetRegistrationLockError> = notExpected()
|
||||
|
||||
override suspend fun getSvrCredentials(): RequestResult<SvrCredentials, GetSvrCredentialsError> = notExpected()
|
||||
override suspend fun getSvrCredentials(): RequestResult<SvrCredentials, GetSvrCredentialsError> {
|
||||
return onGetSvrCredentials()
|
||||
}
|
||||
|
||||
override suspend fun checkSvrCredentials(e164: String, credentials: List<SvrCredentials>): RequestResult<CheckSvrCredentialsResponse, CheckSvrCredentialsError> = notExpected()
|
||||
override suspend fun checkSvrCredentials(e164: String, credentials: List<SvrCredentials>): RequestResult<CheckSvrCredentialsResponse, CheckSvrCredentialsError> {
|
||||
return onCheckSvrCredentials(e164, credentials)
|
||||
}
|
||||
|
||||
override suspend fun setAccountAttributes(attributes: AccountAttributes): RequestResult<Unit, SetAccountAttributesError> = notExpected()
|
||||
|
||||
@@ -173,11 +292,17 @@ class FakeNetworkController(
|
||||
accountAttributesSyncJobEnqueued = true
|
||||
}
|
||||
|
||||
override suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult<GetBackupInfoResponse, GetBackupInfoError> = notExpected()
|
||||
override suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult<GetBackupInfoResponse, GetBackupInfoError> {
|
||||
return onGetRemoteBackupInfo(aep)
|
||||
}
|
||||
|
||||
override suspend fun getBackupFileLastModified(aep: AccountEntropyPool, backupInfo: GetBackupInfoResponse): RequestResult<Long, GetBackupInfoError> = notExpected()
|
||||
override suspend fun getBackupFileLastModified(aep: AccountEntropyPool, backupInfo: GetBackupInfoResponse): RequestResult<Long, GetBackupInfoError> {
|
||||
return onGetBackupFileLastModified(aep)
|
||||
}
|
||||
|
||||
override suspend fun verifyBackupKeyAssociatedWithAccount(aep: AccountEntropyPool): RequestResult<Unit, NetworkController.VerifyBackupKeyError> = notExpected()
|
||||
override suspend fun verifyBackupKeyAssociatedWithAccount(aep: AccountEntropyPool): RequestResult<Unit, NetworkController.VerifyBackupKeyError> {
|
||||
return onVerifyBackupKey(aep)
|
||||
}
|
||||
|
||||
override fun startProvisioning(): Flow<ProvisioningEvent> = notExpected()
|
||||
|
||||
@@ -204,7 +329,7 @@ class FakeNetworkController(
|
||||
override suspend fun setRestoreMethod(token: String, method: RestoreMethod): RequestResult<Unit, SetRestoreMethodError> = notExpected()
|
||||
|
||||
override suspend fun restoreAccountRecord(timeout: Duration): RequestResult<Unit, RestoreAccountRecordError> {
|
||||
return RequestResult.Success(Unit)
|
||||
return onRestoreAccountRecord()
|
||||
}
|
||||
|
||||
override suspend fun setProfile(givenName: String, familyName: String, avatar: ByteArray?, discoverableByPhoneNumber: Boolean): RequestResult<Unit, SetProfileError> {
|
||||
|
||||
+53
-8
@@ -7,6 +7,7 @@ package org.signal.registration.fakes
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.archive.LocalBackupRestoreProgress
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.registration.PreExistingRegistrationData
|
||||
@@ -17,10 +18,14 @@ import org.signal.registration.proto.RegistrationData
|
||||
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
|
||||
import org.signal.registration.screens.messagesync.LinkAndSyncProgress
|
||||
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress
|
||||
import java.time.LocalDateTime
|
||||
|
||||
/**
|
||||
* An in-memory [StorageController] representing a fresh, never-registered install.
|
||||
* Methods that should never be hit in the flows under test fail loudly.
|
||||
* An in-memory [StorageController] representing a fresh, never-registered install by default.
|
||||
* Set [preExistingRegistrationData] or [storedProfileData] to simulate a device with prior state.
|
||||
*
|
||||
* Backup scanning and restore operations delegate to overridable `on<Method>` handlers whose defaults find a single
|
||||
* V2 backup and restore it successfully. Methods that no flow under test should reach fail loudly.
|
||||
*/
|
||||
class FakeStorageController : StorageController {
|
||||
|
||||
@@ -31,7 +36,39 @@ class FakeStorageController : StorageController {
|
||||
var restoreDecision: RestoreDecision? = null
|
||||
private set
|
||||
|
||||
override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = null
|
||||
/** Simulates a previously-registered device, which the flow will try to re-register via recovery password. */
|
||||
var preExistingRegistrationData: PreExistingRegistrationData? = null
|
||||
|
||||
/** Profile data already on disk, used to pre-seed or skip the create-profile screen. */
|
||||
var storedProfileData: StoredProfileData = StoredProfileData()
|
||||
|
||||
// -- Response handlers. Override these in tests to change what is found on disk and how restores play out.
|
||||
|
||||
var onScanLocalBackupFolder: suspend (folderUri: Uri) -> List<LocalBackupInfo> = { folderUri ->
|
||||
listOf(
|
||||
LocalBackupInfo(
|
||||
type = LocalBackupInfo.BackupType.V2,
|
||||
date = LocalDateTime.of(2026, 1, 1, 12, 0),
|
||||
name = "signal-backup-2026-01-01-12-00-00",
|
||||
uri = Uri.withAppendedPath(folderUri, "signal-backup-2026-01-01-12-00-00"),
|
||||
sizeBytes = 1024
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var onRestoreLocalBackupV1: (uri: Uri, passphrase: String) -> Flow<LocalBackupRestoreProgress> = { _, _ ->
|
||||
flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = null, restoredProfileKey = null))
|
||||
}
|
||||
|
||||
var onRestoreLocalBackupV2: (backupUri: Uri, aep: AccountEntropyPool) -> Flow<LocalBackupRestoreProgress> = { _, _ ->
|
||||
flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = null, restoredProfileKey = null))
|
||||
}
|
||||
|
||||
var onRestoreRemoteBackup: (aep: AccountEntropyPool) -> Flow<RemoteBackupRestoreProgress> = {
|
||||
flowOf(RemoteBackupRestoreProgress.Complete(restoredSvrPin = null, restoredProfileKey = null))
|
||||
}
|
||||
|
||||
override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = preExistingRegistrationData
|
||||
|
||||
override suspend fun clearAllData() {
|
||||
inProgressData = RegistrationData()
|
||||
@@ -53,17 +90,25 @@ class FakeStorageController : StorageController {
|
||||
restoreDecision = decision
|
||||
}
|
||||
|
||||
override fun restoreLocalBackupV1(uri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> = notExpected()
|
||||
override fun restoreLocalBackupV1(rootUri: Uri, backupUri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> {
|
||||
return onRestoreLocalBackupV1(backupUri, passphrase)
|
||||
}
|
||||
|
||||
override fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow<LocalBackupRestoreProgress> = notExpected()
|
||||
override fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow<LocalBackupRestoreProgress> {
|
||||
return onRestoreLocalBackupV2(backupUri, aep)
|
||||
}
|
||||
|
||||
override fun restoreRemoteBackup(aep: AccountEntropyPool): Flow<RemoteBackupRestoreProgress> = notExpected()
|
||||
override fun restoreRemoteBackup(aep: AccountEntropyPool): Flow<RemoteBackupRestoreProgress> {
|
||||
return onRestoreRemoteBackup(aep)
|
||||
}
|
||||
|
||||
override fun restoreLinkAndSyncBackup(cdn: Int, key: String): Flow<LinkAndSyncProgress> = notExpected()
|
||||
|
||||
override suspend fun scanLocalBackupFolder(folderUri: Uri): List<LocalBackupInfo> = notExpected()
|
||||
override suspend fun scanLocalBackupFolder(folderUri: Uri): List<LocalBackupInfo> {
|
||||
return onScanLocalBackupFolder(folderUri)
|
||||
}
|
||||
|
||||
override suspend fun getStoredProfileData(): StoredProfileData = StoredProfileData()
|
||||
override suspend fun getStoredProfileData(): StoredProfileData = storedProfileData
|
||||
|
||||
private fun notExpected(): Nothing {
|
||||
throw NotImplementedError("This method is not expected to be called in the flow under test.")
|
||||
|
||||
Reference in New Issue
Block a user