Add more end-to-end regV5 tests.

This commit is contained in:
Greyson Parrelli
2026-07-09 15:29:33 -04:00
parent 32961106e1
commit f50e970b5d
6 changed files with 614 additions and 109 deletions
@@ -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)
}
},
@@ -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"
@@ -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)
}
}
}
@@ -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> {
@@ -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.")