mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-17 23:43:34 +01:00
Add RRP support to regV5.
This commit is contained in:
committed by
Alex Hart
parent
17dbdf3b74
commit
5c418a4260
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -113,7 +113,7 @@ class PinEntryForRegistrationLockViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock(masterKey))
|
||||
parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredFromSvr(masterKey))
|
||||
|
||||
val registrationLockToken = masterKey.deriveRegistrationLock()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user