Add RRP support to regV5.

This commit is contained in:
Greyson Parrelli
2026-02-11 20:38:35 -05:00
committed by Alex Hart
parent 17dbdf3b74
commit 5c418a4260
33 changed files with 1821 additions and 232 deletions

View File

@@ -108,12 +108,12 @@ interface NetworkController {
* This is called when the user encounters a registration lock and needs to prove
* they know their PIN to proceed with registration.
*
* @param svr2Credentials The SVR2 credentials provided by the server during the registration lock response.
* @param svrCredentials The SVR2 credentials provided by the server during the registration lock response.
* @param pin The user-entered PIN.
* @return The restored master key on success, or an appropriate error.
*/
suspend fun restoreMasterKeyFromSvr(
svr2Credentials: SvrCredentials,
svrCredentials: SvrCredentials,
pin: String
): RegistrationNetworkResult<MasterKeyResponse, RestoreMasterKeyError>
@@ -127,7 +127,14 @@ interface NetworkController {
suspend fun setPinAndMasterKeyOnSvr(
pin: String,
masterKey: MasterKey
): RegistrationNetworkResult<Unit, BackupMasterKeyError>
): RegistrationNetworkResult<SvrCredentials?, BackupMasterKeyError>
/**
* Requests that the currently-set PIN and [MasterKey] are backed up to SVR.
* It should always be the case that when this is called, you should have a stored PIN and [MasterKey].
* If you do not, you should probably crash.
*/
suspend fun enqueueSvrGuessResetJob()
/**
* Enables registration lock on the account using the registration lock token
@@ -153,6 +160,15 @@ interface NetworkController {
*/
suspend fun getSvrCredentials(): RegistrationNetworkResult<SvrCredentials, GetSvrCredentialsError>
/**
* Checks if the SVR2 credentials are valid for the given phone number.
*
* `POST /v2/svr/auth/check`
*
* @return A response containing a mapping of which credentials are matches.
*/
suspend fun checkSvrCredentials(e164: String, credentials: List<SvrCredentials>): RegistrationNetworkResult<CheckSvrCredentialsResponse, CheckSvrCredentialsError>
/**
* Updates account attributes on the server.
*
@@ -282,6 +298,11 @@ interface NetworkController {
data object NoServiceCredentialsAvailable : GetSvrCredentialsError()
}
sealed class CheckSvrCredentialsError() {
data object Unauthorized : CheckSvrCredentialsError()
data class InvalidRequest(val message: String) : CheckSvrCredentialsError()
}
data class MasterKeyResponse(
val masterKey: MasterKey
)
@@ -373,6 +394,43 @@ interface NetworkController {
val password: String
) : Parcelable
@Serializable
data class CheckSvrCredentialsResponse(
val matches: Map<String, String>
) {
/**
* The first valid credential, if any.
*
* The response is structured like this:
* {
* matches: {
* <token>: "match|no-match|invalid"
* }
* }
*
* So we find the first map entry with "match". The token is "username:password", so we split it apart.
* Important: The password can have ":" in it, so we need to make sure to just split on the first ":".
*/
val validCredential: SvrCredentials? by lazy {
matches.entries.firstOrNull { it.value == "match" }?.key?.split(":", limit = 2)?.let { SvrCredentials(it[0], it[1]) }
}
}
@Serializable
data class CheckSvrCredentialsRequest(
val number: String,
val tokens: List<String>
) {
companion object {
fun createForCredentials(number: String, credentials: List<SvrCredentials>): CheckSvrCredentialsRequest {
return CheckSvrCredentialsRequest(
number = number,
tokens = credentials.map { "${it.username}:${it.password}" }
)
}
}
}
@Serializable
data class ThirdPartyServiceErrorResponse(
val reason: String,

View File

@@ -23,6 +23,7 @@ class RegistrationActivity : ComponentActivity() {
private val repository: RegistrationRepository by lazy {
RegistrationRepository(
context = this.application,
networkController = RegistrationDependencies.get().networkController,
storageController = RegistrationDependencies.get().storageController
)

View File

@@ -5,18 +5,26 @@
package org.signal.registration
import org.signal.core.util.logging.Log
import org.signal.registration.util.SensitiveLog
/**
* Injection point for dependencies needed by this module.
*
* @param sensitiveLogger A logger for logging sensitive material. The intention is this would only be used in the demo app for testing + debugging, while
* the actual app would just pass null.
*/
class RegistrationDependencies(
val networkController: NetworkController,
val storageController: StorageController
val storageController: StorageController,
val sensitiveLogger: Log.Logger?
) {
companion object {
lateinit var dependencies: RegistrationDependencies
fun provide(registrationDependencies: RegistrationDependencies) {
dependencies = registrationDependencies
SensitiveLog.init(dependencies.sensitiveLogger)
}
fun get(): RegistrationDependencies = dependencies

View File

@@ -9,12 +9,27 @@ import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
sealed interface RegistrationFlowEvent {
/** Navigate to a specific screen. */
data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent
/** Navigate back one screen. */
data object NavigateBack : RegistrationFlowEvent
/** We've encountered some irrecoverable state where the best course of action is to completely reset registration. */
data object ResetState : RegistrationFlowEvent
/** An update has been made to the ongoing registration session. */
data class SessionUpdated(val session: NetworkController.SessionMetadata) : RegistrationFlowEvent
/** The e164 associated with this registration attempt has been updated. */
data class E164Chosen(val e164: String) : RegistrationFlowEvent
/** The user has successfully registered. */
data class Registered(val accountEntropyPool: AccountEntropyPool) : RegistrationFlowEvent
data class MasterKeyRestoredViaRegistrationLock(val masterKey: MasterKey) : RegistrationFlowEvent
data class MasterKeyRestoredViaPostRegisterPinEntry(val masterKey: MasterKey) : RegistrationFlowEvent
/** The master key has been restored from SVR. */
data class MasterKeyRestoredFromSvr(val masterKey: MasterKey) : RegistrationFlowEvent
/** We've discovered that RRP-based registration is not possible for this account. */
data object RecoveryPasswordInvalid : RegistrationFlowEvent
}

View File

@@ -17,12 +17,25 @@ import org.signal.registration.util.MasterKeyParceler
@TypeParceler<MasterKey?, MasterKeyParceler>
@TypeParceler<AccountEntropyPool?, AccountEntropyPoolParceler>
data class RegistrationFlowState(
/** The navigation stack. Controls what screen we're on and what the backstack looks like. */
val backStack: List<RegistrationRoute> = listOf(RegistrationRoute.Welcome),
/** The metadata for the currently-active registration session. */
val sessionMetadata: NetworkController.SessionMetadata? = null,
/** The e164 associated with the [sessionMetadata]. */
val sessionE164: String? = null,
/** The AEP we generated as part of this registration. */
val accountEntropyPool: AccountEntropyPool? = null,
/** The master key we restored from SVR. Needed for initial storage service restore, but afterwards we'll generate a new one. */
val temporaryMasterKey: MasterKey? = null,
val registrationLockProof: String? = null,
val preExistingRegistrationData: PreExistingRegistrationData? = null
/** If set, indicates that this is a re-registration. It contains a bundle of data related to that previous registration. */
val preExistingRegistrationData: PreExistingRegistrationData? = null,
/** If true, do not attempt any flows where we generate RRP's. Create a session instead. */
val doNotAttemptRecoveryPassword: Boolean = false
) : Parcelable

View File

@@ -45,6 +45,7 @@ import org.signal.registration.screens.phonenumber.PhoneNumberScreen
import org.signal.registration.screens.pincreation.PinCreationScreen
import org.signal.registration.screens.pincreation.PinCreationViewModel
import org.signal.registration.screens.pinentry.PinEntryForRegistrationLockViewModel
import org.signal.registration.screens.pinentry.PinEntryForSmsBypassViewModel
import org.signal.registration.screens.pinentry.PinEntryForSvrRestoreViewModel
import org.signal.registration.screens.pinentry.PinEntryScreen
import org.signal.registration.screens.restore.RestoreViaQrScreen
@@ -90,6 +91,9 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
val svrCredentials: NetworkController.SvrCredentials
) : RegistrationRoute
@Serializable
data class PinEntryForSmsBypass(val svrCredentials: NetworkController.SvrCredentials) : RegistrationRoute
@Serializable
data class AccountLocked(val timeRemainingMs: Long) : RegistrationRoute
@@ -367,6 +371,24 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
}
// -- SMS Bypass PIN Entry Screen
entry<RegistrationRoute.PinEntryForSmsBypass> { key ->
val viewModel: PinEntryForSmsBypassViewModel = viewModel(
factory = PinEntryForSmsBypassViewModel.Factory(
repository = registrationRepository,
parentState = registrationViewModel.state,
parentEventEmitter = registrationViewModel::onEvent,
svrCredentials = key.svrCredentials
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
PinEntryScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
// -- Account Locked Screen
entry<RegistrationRoute.AccountLocked> { key ->
val daysRemaining = (key.timeRemainingMs / (1000 * 60 * 60 * 24)).toInt()

View File

@@ -5,11 +5,14 @@
package org.signal.registration
import android.app.backup.BackupManager
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.core.models.MasterKey
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.logging.Log
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.MasterKeyResponse
@@ -22,9 +25,14 @@ import org.signal.registration.NetworkController.RestoreMasterKeyError
import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.NetworkController.SvrCredentials
import org.signal.registration.NetworkController.UpdateSessionError
import org.signal.registration.util.SensitiveLog
import java.util.Locale
class RegistrationRepository(val networkController: NetworkController, val storageController: StorageController) {
class RegistrationRepository(val context: Context, val networkController: NetworkController, val storageController: StorageController) {
companion object {
private val TAG = Log.tag(RegistrationRepository::class)
}
suspend fun createSession(e164: String): RegistrationNetworkResult<SessionMetadata, CreateSessionError> = withContext(Dispatchers.IO) {
val fcmToken = networkController.getFcmToken()
@@ -88,26 +96,47 @@ class RegistrationRepository(val networkController: NetworkController, val stora
}
suspend fun getSvrCredentials(): RegistrationNetworkResult<SvrCredentials, NetworkController.GetSvrCredentialsError> = withContext(Dispatchers.IO) {
networkController.getSvrCredentials()
networkController.getSvrCredentials().also {
if (it is RegistrationNetworkResult.Success) {
storageController.appendSvrCredentials(listOf(it.data))
BackupManager(context).dataChanged()
}
}
}
suspend fun getRestoredSvrCredentials(): List<SvrCredentials> = withContext(Dispatchers.IO) {
storageController.getRestoredSvrCredentials()
}
suspend fun checkSvrCredentials(e164: String, credentials: List<SvrCredentials>): RegistrationNetworkResult<NetworkController.CheckSvrCredentialsResponse, NetworkController.CheckSvrCredentialsError> = withContext(Dispatchers.IO) {
networkController.checkSvrCredentials(e164, credentials)
}
suspend fun restoreMasterKeyFromSvr(
svr2Credentials: SvrCredentials,
svrCredentials: SvrCredentials,
pin: String,
isAlphanumeric: Boolean,
forRegistrationLock: Boolean
): RegistrationNetworkResult<MasterKeyResponse, RestoreMasterKeyError> = withContext(Dispatchers.IO) {
networkController.restoreMasterKeyFromSvr(
svr2Credentials = svr2Credentials,
svrCredentials = svrCredentials,
pin = pin
).also {
if (it is RegistrationNetworkResult.Success) {
// TODO consider whether we should save this now, or whether we should keep in app state and then hand it back to the library user at the end of the flow
storageController.saveValidatedPinAndTemporaryMasterKey(pin, isAlphanumeric, it.data.masterKey, forRegistrationLock)
storageController.appendSvrCredentials(listOf(svrCredentials))
}
}
}
/**
* See [NetworkController.enqueueSvrGuessResetJob]
*/
suspend fun enqueueSvrResetGuessCountJob() {
networkController.enqueueSvrGuessResetJob()
}
/**
* Registers a new account using a recovery password derived from the user's [MasterKey].
*
@@ -119,7 +148,7 @@ class RegistrationRepository(val networkController: NetworkController, val stora
*
* @param e164 The phone number in E.164 format (used for basic auth)
* @param recoveryPassword The recovery password, derived from the user's [MasterKey], which allows us to forgo session creation.
* @param registrationLock The registration lock token derived from the master key (if unlocking a reglocked account)
* @param registrationLock The registration lock token derived from the master key, if unlocking a reglocked account. Must be null if the account is not reglocked.
* @param skipDeviceTransfer Whether to skip device transfer flow
* @return The registration result containing account information or an error
*/
@@ -127,9 +156,10 @@ class RegistrationRepository(val networkController: NetworkController, val stora
e164: String,
recoveryPassword: String,
registrationLock: String? = null,
skipDeviceTransfer: Boolean = true
skipDeviceTransfer: Boolean = true,
preExistingRegistrationData: PreExistingRegistrationData? = null
): RegistrationNetworkResult<Pair<RegisterAccountResponse, KeyMaterial>, RegisterAccountError> = withContext(Dispatchers.IO) {
registerAccount(e164, sessionId = null, recoveryPassword, registrationLock, skipDeviceTransfer)
registerAccount(e164, sessionId = null, recoveryPassword, registrationLock, skipDeviceTransfer, preExistingRegistrationData)
}
/**
@@ -168,8 +198,9 @@ class RegistrationRepository(val networkController: NetworkController, val stora
* @param e164 The phone number in E.164 format (used for basic auth)
* @param sessionId The verified session ID from phone number verification. Must provide if you're not using [recoveryPassword].
* @param recoveryPassword The recovery password, derived from the user's [MasterKey], which allows us to forgo session creation. Must provide if you're not using [sessionId].
* @param registrationLock The registration lock token derived from the master key (if unlocking a reglocked account)
* @param registrationLock The registration lock token derived from the master key (if unlocking a reglocked account). Important: if you provide this, the user will be registered with reglock enabled.
* @param skipDeviceTransfer Whether to skip device transfer flow
* @param preExistingRegistrationData If present, we will use the pre-existing key material from this pre-existing registration rather than generating new key material.
* @return The registration result containing account information or an error
*/
private suspend fun registerAccount(
@@ -177,14 +208,26 @@ class RegistrationRepository(val networkController: NetworkController, val stora
sessionId: String?,
recoveryPassword: String?,
registrationLock: String? = null,
skipDeviceTransfer: Boolean = true
skipDeviceTransfer: Boolean = true,
preExistingRegistrationData: PreExistingRegistrationData? = null
): RegistrationNetworkResult<Pair<RegisterAccountResponse, KeyMaterial>, RegisterAccountError> = withContext(Dispatchers.IO) {
check(sessionId != null || recoveryPassword != null) { "Either sessionId or recoveryPassword must be provided" }
check(sessionId == null || recoveryPassword == null) { "Either sessionId or recoveryPassword must be provided, but not both" }
val keyMaterial = storageController.generateAndStoreKeyMaterial()
Log.i(TAG, "[registerAccount] Starting registration for $e164. sessionId: ${sessionId != null}, recoveryPassword: ${recoveryPassword != null}, registrationLock: ${registrationLock != null}, skipDeviceTransfer: $skipDeviceTransfer, preExistingRegistrationData: ${preExistingRegistrationData != null}")
val keyMaterial = storageController.generateAndStoreKeyMaterial(
existingAccountEntropyPool = preExistingRegistrationData?.aep,
existingAciIdentityKeyPair = preExistingRegistrationData?.aciIdentityKeyPair,
existingPniIdentityKeyPair = preExistingRegistrationData?.pniIdentityKeyPair
)
val fcmToken = networkController.getFcmToken()
val newMasterKey = keyMaterial.accountEntropyPool.deriveMasterKey()
val newRecoveryPassword = newMasterKey.deriveRegistrationRecoveryPassword()
SensitiveLog.d(TAG, "[registerAccount] Using master key [${org.signal.libsignal.protocol.util.Hex.toStringCondensed(newMasterKey.serialize())}] and RRP [$newRecoveryPassword]")
val accountAttributes = AccountAttributes(
signalingKey = null,
registrationId = keyMaterial.aciRegistrationId,
@@ -203,7 +246,7 @@ class RegistrationRepository(val networkController: NetworkController, val stora
),
name = null,
pniRegistrationId = keyMaterial.pniRegistrationId,
recoveryPassword = keyMaterial.accountEntropyPool.deriveMasterKey().deriveRegistrationRecoveryPassword()
recoveryPassword = newRecoveryPassword
)
val aciPreKeys = PreKeyCollection(
@@ -249,11 +292,14 @@ class RegistrationRepository(val networkController: NetworkController, val stora
pin: String,
isAlphanumeric: Boolean,
masterKey: MasterKey
): RegistrationNetworkResult<Unit, NetworkController.BackupMasterKeyError> = withContext(Dispatchers.IO) {
): RegistrationNetworkResult<SvrCredentials?, NetworkController.BackupMasterKeyError> = withContext(Dispatchers.IO) {
val result = networkController.setPinAndMasterKeyOnSvr(pin, masterKey)
if (result is RegistrationNetworkResult.Success) {
storageController.saveNewlyCreatedPin(pin, isAlphanumeric)
result.data?.let { credential ->
storageController.appendSvrCredentials(listOf(credential))
}
}
result

View File

@@ -54,10 +54,10 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
is RegistrationFlowEvent.SessionUpdated -> state.copy(sessionMetadata = event.session)
is RegistrationFlowEvent.E164Chosen -> state.copy(sessionE164 = event.e164)
is RegistrationFlowEvent.Registered -> state.copy(accountEntropyPool = event.accountEntropyPool)
is RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock -> state.copy(temporaryMasterKey = event.masterKey, registrationLockProof = event.masterKey.deriveRegistrationLock())
is RegistrationFlowEvent.MasterKeyRestoredViaPostRegisterPinEntry -> state.copy(temporaryMasterKey = event.masterKey)
is RegistrationFlowEvent.MasterKeyRestoredFromSvr -> state.copy(temporaryMasterKey = event.masterKey)
is RegistrationFlowEvent.NavigateToScreen -> applyNavigationToScreenEvent(state, event)
is RegistrationFlowEvent.NavigateBack -> state.copy(backStack = state.backStack.dropLast(1))
is RegistrationFlowEvent.RecoveryPasswordInvalid -> state.copy(doNotAttemptRecoveryPassword = true)
}
}

View File

@@ -28,9 +28,19 @@ interface StorageController {
* Generates all key material required for account registration and stores it persistently.
* This includes ACI identity key, PNI identity key, and their respective pre-keys.
*
* If optional parameters are provided (e.g. from a pre-existing registration), those values
* will be re-used instead of generating new ones.
*
* @param existingAccountEntropyPool If non-null, re-use this AEP instead of generating a new one.
* @param existingAciIdentityKeyPair If non-null, re-use this ACI identity key pair instead of generating a new one.
* @param existingPniIdentityKeyPair If non-null, re-use this PNI identity key pair instead of generating a new one.
* @return [KeyMaterial] containing all generated cryptographic material needed for registration.
*/
suspend fun generateAndStoreKeyMaterial(): KeyMaterial
suspend fun generateAndStoreKeyMaterial(
existingAccountEntropyPool: AccountEntropyPool? = null,
existingAciIdentityKeyPair: IdentityKeyPair? = null,
existingPniIdentityKeyPair: IdentityKeyPair? = null
): KeyMaterial
/**
* Called after a successful registration to store new registration data.
@@ -44,6 +54,18 @@ interface StorageController {
*/
suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData?
/**
* Retrieves any SVR2 credentials that may have been restored via the OS-level backup/restore service. May be empty.
*/
suspend fun getRestoredSvrCredentials(): List<NetworkController.SvrCredentials>
// TODO [regV5] Can this just take a single item?
/**
* Appends known-working SVR credentials to the local store of credentials.
* Implementations should limit the number of stored credentials to some reasonable maximum.
*/
suspend fun appendSvrCredentials(credentials: List<NetworkController.SvrCredentials>)
/**
* Saves a validated PIN, temporary master key, and registration lock status.
*
@@ -114,10 +136,14 @@ data class NewRegistrationData(
@TypeParceler<AccountEntropyPool, AccountEntropyPoolParceler>
@TypeParceler<ACI, ACIParceler>
@TypeParceler<PNI, PNIParceler>
@TypeParceler<IdentityKeyPair, IdentityKeyPairParceler>
data class PreExistingRegistrationData(
val e164: String,
val aci: ACI,
val pni: PNI,
val servicePassword: String,
val aep: AccountEntropyPool
val aep: AccountEntropyPool,
val registrationLockEnabled: Boolean,
val aciIdentityKeyPair: IdentityKeyPair,
val pniIdentityKeyPair: IdentityKeyPair
) : Parcelable

View File

@@ -5,6 +5,7 @@
package org.signal.registration.screens.phonenumber
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.PreExistingRegistrationData
import kotlin.time.Duration
@@ -18,7 +19,8 @@ data class PhoneNumberEntryState(
val sessionMetadata: SessionMetadata? = null,
val showFullScreenSpinner: Boolean = false,
val oneTimeEvent: OneTimeEvent? = null,
val preExistingRegistrationData: PreExistingRegistrationData? = null
val preExistingRegistrationData: PreExistingRegistrationData? = null,
val restoredSvrCredentials: List<NetworkController.SvrCredentials> = emptyList()
) {
sealed interface OneTimeEvent {
data object NetworkError : OneTimeEvent

View File

@@ -51,6 +51,14 @@ class PhoneNumberEntryViewModel(
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.Eagerly, PhoneNumberEntryState())
init {
viewModelScope.launch {
_state.value = state.value.copy(
restoredSvrCredentials = repository.getRestoredSvrCredentials()
)
}
}
fun onEvent(event: PhoneNumberEntryScreenEvents) {
viewModelScope.launch {
val stateEmitter: (PhoneNumberEntryState) -> Unit = { state ->
@@ -92,7 +100,8 @@ class PhoneNumberEntryViewModel(
return state.copy(
sessionE164 = parentState.sessionE164,
sessionMetadata = parentState.sessionMetadata,
preExistingRegistrationData = parentState.preExistingRegistrationData
preExistingRegistrationData = parentState.preExistingRegistrationData,
restoredSvrCredentials = state.restoredSvrCredentials.takeUnless { parentState.doNotAttemptRecoveryPassword } ?: emptyList()
)
}
@@ -140,10 +149,11 @@ class PhoneNumberEntryViewModel(
if (state.preExistingRegistrationData?.e164 == e164) {
val masterKey = state.preExistingRegistrationData.aep.deriveMasterKey()
val recoveryPassword = masterKey.deriveRegistrationRecoveryPassword()
val registrationLock = masterKey.deriveRegistrationLock()
val registrationLock = masterKey.deriveRegistrationLock().takeIf { state.preExistingRegistrationData.registrationLockEnabled }
when (val registerResult = repository.registerAccountWithRecoveryPassword(e164, recoveryPassword, registrationLock, skipDeviceTransfer = true)) {
when (val registerResult = repository.registerAccountWithRecoveryPassword(e164, recoveryPassword, registrationLock, skipDeviceTransfer = true, state.preExistingRegistrationData)) {
is NetworkController.RegistrationNetworkResult.Success -> {
Log.i(TAG, "[Register] Successfully re-registered using RRP from pre-existing data.")
val (response, keyMaterial) = registerResult.data
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
@@ -153,6 +163,7 @@ class PhoneNumberEntryViewModel(
} else {
parentEventEmitter.navigateTo(RegistrationRoute.PinCreate)
}
return state
}
is NetworkController.RegistrationNetworkResult.Failure -> {
when (registerResult.error) {
@@ -167,7 +178,7 @@ class PhoneNumberEntryViewModel(
return state
}
is NetworkController.RegisterAccountError.RegistrationLock -> {
Log.w(TAG, "[Register] Reglocked.")
Log.w(TAG, "[Register] Reglocked. This implies that the user still had reglock enabled despite the pre-existing data not thinking it was.")
parentEventEmitter.navigateTo(
RegistrationRoute.PinEntryForRegistrationLock(
timeRemaining = registerResult.error.data.timeRemaining,
@@ -177,17 +188,17 @@ class PhoneNumberEntryViewModel(
return state
}
is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[Register] Rate limited.")
Log.w(TAG, "[Register] Rate limited (retryAfter: ${registerResult.error.retryAfter}).")
return state.copy(oneTimeEvent = OneTimeEvent.RateLimited(registerResult.error.retryAfter))
}
is NetworkController.RegisterAccountError.InvalidRequest -> {
Log.w(TAG, "[Register] Invalid request when registering account with RRP. Ditching pre-existing data and continuing with session creation. Message: ${registerResult.error.message}")
// TODO should we clear it in the parent state as well?
parentEventEmitter(RegistrationFlowEvent.RecoveryPasswordInvalid)
state = state.copy(preExistingRegistrationData = null)
}
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
Log.w(TAG, "[Register] Registration recovery password incorrect. Ditching pre-existing data and continuing with session creation. Message: ${registerResult.error.message}")
// TODO should we clear it in the parent state as well?
parentEventEmitter(RegistrationFlowEvent.RecoveryPasswordInvalid)
state = state.copy(preExistingRegistrationData = null)
}
}
@@ -203,6 +214,39 @@ class PhoneNumberEntryViewModel(
}
}
// Detect if we have valid SVR credentials for the current number. If so, we can go right to the PIN entry screen.
// If they successfully restore the master key at that screen, we can use that to build the RRP and register without SMS.
if (state.restoredSvrCredentials.isNotEmpty()) {
when (val result = repository.checkSvrCredentials(e164, state.restoredSvrCredentials)) {
is NetworkController.RegistrationNetworkResult.Success -> {
Log.i(TAG, "[CheckSVRCredentials] Successfully validated credentials for ${e164}.")
val credential = result.data.validCredential
if (credential != null) {
parentEventEmitter(RegistrationFlowEvent.E164Chosen(e164))
parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSmsBypass(credential))
return state
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "[CheckSVRCredentials] Network error. Ignoring error and continuing without RRP.", result.exception)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "[CheckSVRCredentials] Application error. Ignoring error and continuing without RRP.", result.exception)
}
is NetworkController.RegistrationNetworkResult.Failure -> {
when (result.error) {
is NetworkController.CheckSvrCredentialsError.InvalidRequest -> {
Log.w(TAG, "[CheckSVRCredentials] Invalid request. Ignoring error and continuing without RRP. Message: ${result.error.message}")
}
NetworkController.CheckSvrCredentialsError.Unauthorized -> {
Log.w(TAG, "[CheckSVRCredentials] Unauthorized. Ignoring error and continuing without RRP.")
}
}
}
}
}
// Detect if someone backed into this screen and entered a different number
if (state.sessionE164 != null && state.sessionE164 != e164) {
state = state.copy(sessionMetadata = null)

View File

@@ -113,7 +113,7 @@ class PinEntryForRegistrationLockViewModel(
}
}
parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock(masterKey))
parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredFromSvr(masterKey))
val registrationLockToken = masterKey.deriveRegistrationLock()

View File

@@ -0,0 +1,226 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.pinentry
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.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.util.Hex
import org.signal.registration.NetworkController
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.navigateBack
import org.signal.registration.screens.util.navigateTo
import org.signal.registration.util.SensitiveLog
/**
* ViewModel for the SMS-bypass PIN entry screen.
*
* This screen is shown when we have a known-valid SVR credential for the entered phone number,
* allowing the user to restore their master key and bypass SMS verification.
*/
class PinEntryForSmsBypassViewModel(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val svrCredentials: NetworkController.SvrCredentials
) : ViewModel() {
companion object {
private val TAG = Log.tag(PinEntryForSmsBypassViewModel::class)
}
private val _state = MutableStateFlow(
PinEntryState(
mode = PinEntryState.Mode.SmsBypass
)
)
val state: StateFlow<PinEntryState> = _state
.combine(parentState) { state, parentState -> applyParentState(state, parentState) }
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.Eagerly, PinEntryState(showNeedHelp = true))
fun onEvent(event: PinEntryScreenEvents) {
viewModelScope.launch {
val stateEmitter: (PinEntryState) -> Unit = { _state.value = it }
applyEvent(state.value, event, stateEmitter, parentEventEmitter)
}
}
@VisibleForTesting
suspend fun applyEvent(
state: PinEntryState,
event: PinEntryScreenEvents,
stateEmitter: (PinEntryState) -> Unit,
parentEventEmitter: (RegistrationFlowEvent) -> Unit
) {
when (event) {
is PinEntryScreenEvents.PinEntered -> {
var localState = state.copy(loading = true)
stateEmitter(localState)
localState = applyPinEntered(localState, event, parentEventEmitter)
stateEmitter(localState.copy(loading = false))
}
is PinEntryScreenEvents.Skip -> {
handleSkip()
}
is PinEntryScreenEvents.ToggleKeyboard,
is PinEntryScreenEvents.NeedHelp -> {
stateEmitter(PinEntryScreenEventHandler.applyEvent(state, event))
}
}
}
fun applyParentState(state: PinEntryState, parentState: RegistrationFlowState): PinEntryState {
return state.copy(e164 = parentState.sessionE164)
}
private suspend fun applyPinEntered(
state: PinEntryState,
event: PinEntryScreenEvents.PinEntered,
parentEventEmitter: (RegistrationFlowEvent) -> Unit
): PinEntryState {
Log.d(TAG, "[PinEntered] Attempting to restore master key from SVR...")
if (state.e164 == null) {
Log.w(TAG, "[PinEntered] No e164 available! Shouldn't be in this state. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
return state
}
return when (val result = repository.restoreMasterKeyFromSvr(svrCredentials, event.pin, state.isAlphanumericKeyboard, forRegistrationLock = false)) {
is NetworkController.RegistrationNetworkResult.Success -> {
Log.i(TAG, "[PinEntered] Successfully restored master key from SVR.")
parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredFromSvr(result.data.masterKey))
attemptToRegister(state, state.e164, result.data.masterKey, provideRegistrationLock = false, parentEventEmitter)
}
is NetworkController.RegistrationNetworkResult.Failure -> {
when (result.error) {
is NetworkController.RestoreMasterKeyError.WrongPin -> {
Log.w(TAG, "[PinEntered] Wrong PIN. Tries remaining: ${result.error.triesRemaining}")
state.copy(triesRemaining = result.error.triesRemaining)
}
is NetworkController.RestoreMasterKeyError.NoDataFound -> {
Log.w(TAG, "[PinEntered] No SVR data found for sms-bypass credential. Marking RRP as invalid and navigating back.")
parentEventEmitter(RegistrationFlowEvent.RecoveryPasswordInvalid)
parentEventEmitter.navigateBack()
state
}
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "[PinEntered] Network error when restoring master key (sms-bypass).", result.exception)
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "[PinEntered] Application error when restoring master key (sms-bypass).", result.exception)
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
}
}
}
private fun handleSkip() {
// TODO: Decide desired behavior (likely return to phone number entry).
Log.d(TAG, "[Skip] Not yet implemented.")
}
private suspend fun attemptToRegister(
state: PinEntryState,
e164: String,
masterKey: MasterKey,
provideRegistrationLock: Boolean,
parentEventEmitter: (RegistrationFlowEvent) -> Unit
): PinEntryState {
val recoveryPassword = masterKey.deriveRegistrationRecoveryPassword()
val registrationLock = masterKey.deriveRegistrationLock().takeIf { provideRegistrationLock }
SensitiveLog.d(TAG, "Attempting registration using master key [${Hex.toStringCondensed(masterKey.serialize())}] and RRP [$recoveryPassword]")
return when (val result = repository.registerAccountWithRecoveryPassword(e164, recoveryPassword, registrationLock, skipDeviceTransfer = true)) {
is NetworkController.RegistrationNetworkResult.Success -> {
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
repository.enqueueSvrResetGuessCountJob()
state
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
}
is NetworkController.RegistrationNetworkResult.Failure -> {
when (result.error) {
NetworkController.RegisterAccountError.DeviceTransferPossible -> {
Log.w(TAG, "[Register] Got told a device transfer is possible. We should never get into this state. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
state
}
is NetworkController.RegisterAccountError.InvalidRequest -> {
Log.w(TAG, "[Register] Invalid request when registering account with RRP. Marking RRP as invalid and navigating back. Message: ${result.error.message}")
parentEventEmitter(RegistrationFlowEvent.RecoveryPasswordInvalid)
parentEventEmitter.navigateBack()
state
}
is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[Register] Rate limited (retryAfter: ${result.error.retryAfter}).")
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.RateLimited(result.error.retryAfter))
}
is NetworkController.RegisterAccountError.RegistrationLock -> {
if (provideRegistrationLock) {
Log.w(TAG, "[Register] Hit reglock error when supplying RRP with reglock. This shouldn't happen and implies that the RRP is likely invalid. Marking RRP as invalid and navigating back.")
parentEventEmitter(RegistrationFlowEvent.RecoveryPasswordInvalid)
parentEventEmitter.navigateBack()
state
} else {
Log.w(TAG, "[Register] Hit reglock error when supplying RRP without reglock. Attempting again with reglock.")
attemptToRegister(state, e164, masterKey, provideRegistrationLock = true, parentEventEmitter)
}
}
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
Log.w(TAG, "[Register] Told that RRP is incorrect. Marking RRP as invalid and navigating back.")
parentEventEmitter(RegistrationFlowEvent.RecoveryPasswordInvalid)
parentEventEmitter.navigateBack()
state
}
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
Log.w(TAG, "[Register] Got told our session wasn't found when trying to use RRP. We should never get into this state. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
state
}
}
}
}
}
class Factory(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val svrCredentials: NetworkController.SvrCredentials
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PinEntryForSmsBypassViewModel(
repository = repository,
parentState = parentState,
parentEventEmitter = parentEventEmitter,
svrCredentials = svrCredentials
) as T
}
}
}

View File

@@ -118,7 +118,8 @@ class PinEntryForSvrRestoreViewModel(
return when (val result = repository.restoreMasterKeyFromSvr(svrCredentials, event.pin, state.isAlphanumericKeyboard, forRegistrationLock = false)) {
is NetworkController.RegistrationNetworkResult.Success -> {
Log.i(TAG, "[PinEntered] Successfully restored master key from SVR.")
parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredViaPostRegisterPinEntry(result.data.masterKey))
repository.enqueueSvrResetGuessCountJob()
parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredFromSvr(result.data.masterKey))
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
state
}
@@ -129,8 +130,9 @@ class PinEntryForSvrRestoreViewModel(
state.copy(triesRemaining = result.error.triesRemaining)
}
is NetworkController.RestoreMasterKeyError.NoDataFound -> {
Log.w(TAG, "[PinEntered] No SVR data found. Proceeding without restore.")
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.SvrDataMissing)
Log.w(TAG, "[PinEntered] No SVR data found. Need to create a PIN instead.")
parentEventEmitter.navigateTo(RegistrationRoute.PinCreate)
state
}
}
}

View File

@@ -73,7 +73,8 @@ fun PinEntryScreen(
val titleString = remember {
return@remember when (state.mode) {
PinEntryState.Mode.RegistrationLock -> "Registration Lock"
PinEntryState.Mode.SvrRestore -> "Enter your PIN"
PinEntryState.Mode.SvrRestore,
PinEntryState.Mode.SmsBypass -> "Enter your PIN"
}
}

View File

@@ -13,10 +13,12 @@ data class PinEntryState(
val loading: Boolean = false,
val triesRemaining: Int? = null,
val mode: Mode = Mode.SvrRestore,
val oneTimeEvent: OneTimeEvent? = null
val oneTimeEvent: OneTimeEvent? = null,
val e164: String? = null
) {
enum class Mode {
RegistrationLock,
SmsBypass,
SvrRestore
}

View File

@@ -108,7 +108,7 @@ class VerificationCodeViewModel(
}
}
is NetworkController.SubmitVerificationCodeError.RateLimited -> {
Log.w(TAG, "[SubmitCode] Rate limited.")
Log.w(TAG, "[SubmitCode] Rate limited (retryAfter: ${result.error.retryAfter}).")
return state.copy(oneTimeEvent = OneTimeEvent.RateLimited(result.error.retryAfter))
}
}
@@ -167,7 +167,7 @@ class VerificationCodeViewModel(
state
}
is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[Register] Rate limited.")
Log.w(TAG, "[Register] Rate limited (retryAfter: ${registerResult.error.retryAfter}).")
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(registerResult.error.retryAfter))
}
is NetworkController.RegisterAccountError.InvalidRequest -> {

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.util
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.NoopLogger
/**
* A logger that can be used to log sensitive information for debugging purposes.
* The actual application will use a NoopLogger, while the demo app will provide actual logging capabilities to ease debugging.
*/
object SensitiveLog : Log.Logger() {
private var logger: Log.Logger = NoopLogger()
fun init(logger: Log.Logger?) {
this.logger = logger ?: NoopLogger()
}
override fun v(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
this.logger.v(tag, "[SENSITIVE] $message", t, keepLonger)
}
override fun d(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
this.logger.d(tag, "[SENSITIVE] $message", t, keepLonger)
}
override fun i(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
this.logger.i(tag, "[SENSITIVE] $message", t, keepLonger)
}
override fun w(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
this.logger.w(tag, "[SENSITIVE] $message", t, keepLonger)
}
override fun e(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
this.logger.e(tag, "[SENSITIVE] $message", t, keepLonger)
}
override fun flush() {
this.logger.flush()
}
}

View File

@@ -7,6 +7,7 @@ package org.signal.registration.screens.phonenumber
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
@@ -15,16 +16,20 @@ import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.prop
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.signal.registration.KeyMaterial
import org.signal.registration.NetworkController
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
import java.io.IOException
import kotlin.time.Duration.Companion.seconds
class PhoneNumberEntryViewModelTest {
@@ -822,6 +827,542 @@ class PhoneNumberEntryViewModelTest {
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
}
// ==================== applyParentState Tests ====================
@Test
fun `applyParentState copies preExistingRegistrationData from parent`() {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true)
val state = PhoneNumberEntryState()
val parentFlowState = RegistrationFlowState(preExistingRegistrationData = preExistingData)
val result = viewModel.applyParentState(state, parentFlowState)
assertThat(result.preExistingRegistrationData).isEqualTo(preExistingData)
}
@Test
fun `applyParentState clears restoredSvrCredentials when doNotAttemptRecoveryPassword is true`() {
val credentials = listOf(
NetworkController.SvrCredentials(username = "user", password = "pass")
)
val state = PhoneNumberEntryState(restoredSvrCredentials = credentials)
val parentFlowState = RegistrationFlowState(doNotAttemptRecoveryPassword = true)
val result = viewModel.applyParentState(state, parentFlowState)
assertThat(result.restoredSvrCredentials).isEmpty()
}
@Test
fun `applyParentState keeps restoredSvrCredentials when doNotAttemptRecoveryPassword is false`() {
val credentials = listOf(
NetworkController.SvrCredentials(username = "user", password = "pass")
)
val state = PhoneNumberEntryState(restoredSvrCredentials = credentials)
val parentFlowState = RegistrationFlowState(doNotAttemptRecoveryPassword = false)
val result = viewModel.applyParentState(state, parentFlowState)
assertThat(result.restoredSvrCredentials).isEqualTo(credentials)
}
// ==================== Pre-existing Registration Data (RRP) Tests ====================
@Test
fun `PhoneNumberSubmitted with matching preExistingRegistrationData registers with RRP and navigates to PinEntryForSvrRestore`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15551234567"
coEvery { registrationLockEnabled } returns false
}
val keyMaterial = mockk<KeyMaterial>(relaxed = true)
val registerResponse = createRegisterAccountResponse(storageCapable = true)
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(registerResponse to keyMaterial)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedEvents.first()).isInstanceOf<RegistrationFlowEvent.Registered>()
assertThat(emittedEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.PinEntryForSvrRestore>()
}
@Test
fun `PhoneNumberSubmitted with matching preExistingRegistrationData navigates to PinCreate when not storage capable`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15551234567"
coEvery { registrationLockEnabled } returns false
}
val keyMaterial = mockk<KeyMaterial>(relaxed = true)
val registerResponse = createRegisterAccountResponse(storageCapable = false)
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(registerResponse to keyMaterial)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedEvents.first()).isInstanceOf<RegistrationFlowEvent.Registered>()
assertThat(emittedEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.PinCreate>()
}
@Test
fun `PhoneNumberSubmitted with preExistingRegistrationData and SessionNotFoundOrNotVerified emits ResetState`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15551234567"
coEvery { registrationLockEnabled } returns false
}
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified("Not found")
)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
}
@Test
fun `PhoneNumberSubmitted with preExistingRegistrationData and DeviceTransferPossible emits ResetState`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15551234567"
coEvery { registrationLockEnabled } returns false
}
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.DeviceTransferPossible
)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
}
@Test
fun `PhoneNumberSubmitted with preExistingRegistrationData and RegistrationLock navigates to PinEntryForRegistrationLock`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15551234567"
coEvery { registrationLockEnabled } returns false
}
val svrCredentials = NetworkController.SvrCredentials(username = "user", password = "pass")
val registrationLockData = NetworkController.RegistrationLockResponse(
timeRemaining = 60000L,
svr2Credentials = svrCredentials
)
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.RegistrationLock(registrationLockData)
)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.PinEntryForRegistrationLock>()
}
@Test
fun `PhoneNumberSubmitted with preExistingRegistrationData and RateLimited returns RateLimited event`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15551234567"
coEvery { registrationLockEnabled } returns false
}
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.RateLimited(30.seconds)
)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
.prop(PhoneNumberEntryState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(30.seconds)
}
@Test
fun `PhoneNumberSubmitted with preExistingRegistrationData and InvalidRequest emits RecoveryPasswordInvalid and falls through to session creation`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15551234567"
coEvery { registrationLockEnabled } returns false
}
val sessionMetadata = createSessionMetadata(requestedInformation = emptyList())
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.InvalidRequest("Bad request")
)
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Should emit RecoveryPasswordInvalid and then continue to session creation
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.RecoveryPasswordInvalid)
// Should ultimately navigate to verification code entry after falling through
assertThat(emittedStates.last().preExistingRegistrationData).isNull()
}
@Test
fun `PhoneNumberSubmitted with preExistingRegistrationData and RegistrationRecoveryPasswordIncorrect emits RecoveryPasswordInvalid and falls through`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15551234567"
coEvery { registrationLockEnabled } returns false
}
val sessionMetadata = createSessionMetadata(requestedInformation = emptyList())
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect("Wrong password")
)
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.RecoveryPasswordInvalid)
assertThat(emittedStates.last().preExistingRegistrationData).isNull()
}
@Test
fun `PhoneNumberSubmitted with preExistingRegistrationData and NetworkError returns NetworkError event`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15551234567"
coEvery { registrationLockEnabled } returns false
}
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.NetworkError(IOException("Network error"))
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
}
@Test
fun `PhoneNumberSubmitted with preExistingRegistrationData and ApplicationError returns UnknownError event`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15551234567"
coEvery { registrationLockEnabled } returns false
}
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
}
@Test
fun `PhoneNumberSubmitted with non-matching preExistingRegistrationData skips RRP and creates session`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15559999999"
coEvery { registrationLockEnabled } returns false
}
val sessionMetadata = createSessionMetadata(requestedInformation = emptyList())
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
preExistingRegistrationData = preExistingData
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Should skip RRP and go to session creation flow
coVerify(exactly = 0) { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) }
assertThat(emittedEvents.last())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
}
// ==================== SVR Credential Checking Tests ====================
@Test
fun `PhoneNumberSubmitted with valid SVR credentials navigates to PinEntryForSmsBypass`() = runTest {
val svrCredentials = listOf(
NetworkController.SvrCredentials(username = "user", password = "pass")
)
val validCredential = NetworkController.SvrCredentials(username = "user", password = "pass")
val checkResponse = NetworkController.CheckSvrCredentialsResponse(
matches = mapOf("user:pass" to "match")
)
coEvery { mockRepository.checkSvrCredentials(any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(checkResponse)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
restoredSvrCredentials = svrCredentials
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedEvents).hasSize(2)
assertThat(emittedEvents[0]).isInstanceOf<RegistrationFlowEvent.E164Chosen>()
assertThat(emittedEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.PinEntryForSmsBypass>()
}
@Test
fun `PhoneNumberSubmitted with no matching SVR credentials falls through to session creation`() = runTest {
val svrCredentials = listOf(
NetworkController.SvrCredentials(username = "user", password = "pass")
)
val checkResponse = NetworkController.CheckSvrCredentialsResponse(
matches = mapOf("user:pass" to "no-match")
)
val sessionMetadata = createSessionMetadata(requestedInformation = emptyList())
coEvery { mockRepository.checkSvrCredentials(any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(checkResponse)
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
restoredSvrCredentials = svrCredentials
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Should fall through to session creation
assertThat(emittedEvents.last())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
}
@Test
fun `PhoneNumberSubmitted with SVR credentials network error falls through to session creation`() = runTest {
val svrCredentials = listOf(
NetworkController.SvrCredentials(username = "user", password = "pass")
)
val sessionMetadata = createSessionMetadata(requestedInformation = emptyList())
coEvery { mockRepository.checkSvrCredentials(any(), any()) } returns
NetworkController.RegistrationNetworkResult.NetworkError(IOException("Network error"))
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
restoredSvrCredentials = svrCredentials
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Should ignore error and fall through
assertThat(emittedEvents.last())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
}
@Test
fun `PhoneNumberSubmitted with SVR credentials application error falls through to session creation`() = runTest {
val svrCredentials = listOf(
NetworkController.SvrCredentials(username = "user", password = "pass")
)
val sessionMetadata = createSessionMetadata(requestedInformation = emptyList())
coEvery { mockRepository.checkSvrCredentials(any(), any()) } returns
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
restoredSvrCredentials = svrCredentials
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedEvents.last())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
}
@Test
fun `PhoneNumberSubmitted with SVR credentials invalid request falls through to session creation`() = runTest {
val svrCredentials = listOf(
NetworkController.SvrCredentials(username = "user", password = "pass")
)
val sessionMetadata = createSessionMetadata(requestedInformation = emptyList())
coEvery { mockRepository.checkSvrCredentials(any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.CheckSvrCredentialsError.InvalidRequest("Bad request")
)
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
restoredSvrCredentials = svrCredentials
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedEvents.last())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
}
@Test
fun `PhoneNumberSubmitted with SVR credentials unauthorized falls through to session creation`() = runTest {
val svrCredentials = listOf(
NetworkController.SvrCredentials(username = "user", password = "pass")
)
val sessionMetadata = createSessionMetadata(requestedInformation = emptyList())
coEvery { mockRepository.checkSvrCredentials(any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.CheckSvrCredentialsError.Unauthorized
)
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
restoredSvrCredentials = svrCredentials
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(emittedEvents.last())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
}
@Test
fun `PhoneNumberSubmitted with empty restoredSvrCredentials skips SVR check`() = runTest {
val sessionMetadata = createSessionMetadata(requestedInformation = emptyList())
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567",
restoredSvrCredentials = emptyList()
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
coVerify(exactly = 0) { mockRepository.checkSvrCredentials(any(), any()) }
assertThat(emittedEvents.last())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
}
// ==================== Helper Functions ====================
private fun createSessionMetadata(
@@ -837,4 +1378,20 @@ class PhoneNumberEntryViewModelTest {
requestedInformation = requestedInformation,
verified = verified
)
private fun createRegisterAccountResponse(
aci: String = "test-aci",
pni: String = "test-pni",
e164: String = "+15551234567",
storageCapable: Boolean = true
) = NetworkController.RegisterAccountResponse(
aci = aci,
pni = pni,
e164 = e164,
usernameHash = null,
usernameLinkHandle = null,
storageCapable = storageCapable,
entitlements = null,
reregistration = false
)
}

View File

@@ -80,7 +80,7 @@ class PinEntryForRegistrationLockViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(3)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock>()
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isInstanceOf<RegistrationFlowEvent.Registered>()
assertThat(emittedParentEvents[2])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
@@ -166,7 +166,7 @@ class PinEntryForRegistrationLockViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock>()
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.ResetState)
}
@@ -186,7 +186,7 @@ class PinEntryForRegistrationLockViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock>()
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.ResetState)
}
@@ -208,7 +208,7 @@ class PinEntryForRegistrationLockViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock>()
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.ResetState)
}
@@ -228,7 +228,7 @@ class PinEntryForRegistrationLockViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock>()
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<PinEntryState.OneTimeEvent.RateLimited>()
.prop(PinEntryState.OneTimeEvent.RateLimited::retryAfter)
@@ -250,7 +250,7 @@ class PinEntryForRegistrationLockViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock>()
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
}
@@ -269,7 +269,7 @@ class PinEntryForRegistrationLockViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock>()
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
}
@@ -286,7 +286,7 @@ class PinEntryForRegistrationLockViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock>()
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError)
}
@@ -303,7 +303,7 @@ class PinEntryForRegistrationLockViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock>()
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
}

View File

@@ -0,0 +1,397 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.pinentry
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.prop
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.signal.core.models.MasterKey
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import java.io.IOException
import kotlin.time.Duration.Companion.seconds
class PinEntryForSmsBypassViewModelTest {
private lateinit var viewModel: PinEntryForSmsBypassViewModel
private lateinit var mockRepository: RegistrationRepository
private lateinit var parentState: MutableStateFlow<RegistrationFlowState>
private lateinit var emittedParentEvents: MutableList<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var emittedStates: MutableList<PinEntryState>
private lateinit var stateEmitter: (PinEntryState) -> Unit
private val testSvrCredentials = NetworkController.SvrCredentials(
username = "test-username",
password = "test-password"
)
@Before
fun setup() {
mockRepository = mockk(relaxed = true)
parentState = MutableStateFlow(
RegistrationFlowState(
sessionE164 = "+15551234567"
)
)
emittedParentEvents = mutableListOf()
parentEventEmitter = { event -> emittedParentEvents.add(event) }
emittedStates = mutableListOf()
stateEmitter = { state -> emittedStates.add(state) }
viewModel = PinEntryForSmsBypassViewModel(
repository = mockRepository,
parentState = parentState,
parentEventEmitter = parentEventEmitter,
svrCredentials = testSvrCredentials
)
}
// ==================== PinEntered - Restore Master Key Tests ====================
@Test
fun `PinEntered with correct PIN restores master key and registers successfully`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(mockk(relaxed = true))
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.FullyComplete>()
}
@Test
fun `PinEntered with correct PIN enqueues SVR guess reset job after successful registration`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(mockk(relaxed = true))
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
coVerify { mockRepository.enqueueSvrResetGuessCountJob() }
}
@Test
fun `PinEntered with wrong PIN returns state with tries remaining`() = runTest {
val triesRemaining = 3
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RestoreMasterKeyError.WrongPin(triesRemaining)
)
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("wrong-pin"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedStates.last().triesRemaining).isEqualTo(triesRemaining)
}
@Test
fun `PinEntered with no SVR data emits RecoveryPasswordInvalid and navigates back`() = runTest {
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RestoreMasterKeyError.NoDataFound
)
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isEqualTo(RegistrationFlowEvent.RecoveryPasswordInvalid)
assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `PinEntered with network error restoring master key returns NetworkError event`() = runTest {
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.NetworkError(IOException("Network error"))
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError)
}
@Test
fun `PinEntered with application error restoring master key returns UnknownError event`() = runTest {
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
}
@Test
fun `PinEntered with missing e164 emits ResetState`() = runTest {
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = null)
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isEqualTo(RegistrationFlowEvent.ResetState)
}
// ==================== Registration Error Tests ====================
@Test
fun `PinEntered with registration network error returns NetworkError event`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.NetworkError(IOException("Network error"))
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError)
}
@Test
fun `PinEntered with registration application error returns UnknownError event`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
}
@Test
fun `PinEntered with DeviceTransferPossible during registration emits ResetState`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.DeviceTransferPossible
)
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.ResetState)
}
@Test
fun `PinEntered with InvalidRequest during registration emits RecoveryPasswordInvalid and navigates back`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.InvalidRequest("Bad request")
)
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(3)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.RecoveryPasswordInvalid)
assertThat(emittedParentEvents[2]).isEqualTo(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `PinEntered with RateLimited during registration returns RateLimited event`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val retryAfter = 30.seconds
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.RateLimited(retryAfter)
)
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<PinEntryState.OneTimeEvent.RateLimited>()
.prop(PinEntryState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(retryAfter)
}
@Test
fun `PinEntered with RegistrationLock without provideRegistrationLock retries with reglock`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val registrationLockData = mockk<NetworkController.RegistrationLockResponse>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey))
// First call (without reglock) returns RegistrationLock error, second call (with reglock) succeeds
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), registrationLock = null, any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.RegistrationLock(registrationLockData)
)
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), registrationLock = any<String>(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(mockk(relaxed = true))
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.FullyComplete>()
}
@Test
fun `PinEntered with RegistrationLock when already providing reglock emits RecoveryPasswordInvalid and navigates back`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val registrationLockData = mockk<NetworkController.RegistrationLockResponse>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey))
// Both calls (with and without reglock) return RegistrationLock error
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.RegistrationLock(registrationLockData)
)
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
// First retry (without reglock) -> reglock error -> retry with reglock -> reglock error again -> RecoveryPasswordInvalid + NavigateBack
assertThat(emittedParentEvents).hasSize(3)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.RecoveryPasswordInvalid)
assertThat(emittedParentEvents[2]).isEqualTo(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `PinEntered with RegistrationRecoveryPasswordIncorrect emits RecoveryPasswordInvalid and navigates back`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect("Wrong password")
)
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(3)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.RecoveryPasswordInvalid)
assertThat(emittedParentEvents[2]).isEqualTo(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `PinEntered with SessionNotFoundOrNotVerified during registration emits ResetState`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns
NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified("Not found")
)
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.ResetState)
}
// ==================== applyParentState Tests ====================
@Test
fun `applyParentState copies e164 from parent state`() {
val state = PinEntryState(mode = PinEntryState.Mode.SmsBypass)
val parentFlowState = RegistrationFlowState(sessionE164 = "+15559876543")
val result = viewModel.applyParentState(state, parentFlowState)
assertThat(result.e164).isEqualTo("+15559876543")
}
@Test
fun `applyParentState with null e164 in parent state sets null e164`() {
val state = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
val parentFlowState = RegistrationFlowState(sessionE164 = null)
val result = viewModel.applyParentState(state, parentFlowState)
assertThat(result.e164).isEqualTo(null)
}
// ==================== ToggleKeyboard Tests ====================
@Test
fun `ToggleKeyboard toggles isAlphanumericKeyboard from false to true`() = runTest {
val initialState = PinEntryState(isAlphanumericKeyboard = false)
viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, stateEmitter, parentEventEmitter)
assertThat(emittedStates.last().isAlphanumericKeyboard).isEqualTo(true)
}
@Test
fun `ToggleKeyboard toggles isAlphanumericKeyboard from true to false`() = runTest {
val initialState = PinEntryState(isAlphanumericKeyboard = true)
viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, stateEmitter, parentEventEmitter)
assertThat(emittedStates.last().isAlphanumericKeyboard).isEqualTo(false)
}
}

View File

@@ -72,7 +72,7 @@ class PinEntryForSvrRestoreViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredViaPostRegisterPinEntry>()
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
@@ -162,7 +162,7 @@ class PinEntryForSvrRestoreViewModelTest {
}
@Test
fun `PinEntered with no SVR data returns SvrDataMissing event`() = runTest {
fun `PinEntered with no SVR data navigates to PinCreate`() = runTest {
val svrCredentials = NetworkController.SvrCredentials(
username = "test-username",
password = "test-password"
@@ -178,8 +178,11 @@ class PinEntryForSvrRestoreViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.SvrDataMissing)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.PinCreate>()
}
@Test