Add basic ability to login to an existing numberless account.

This commit is contained in:
Greyson Parrelli
2026-09-02 16:11:28 -03:00
committed by Alex Hart
parent cac953c724
commit 59d8b5f61f
87 changed files with 2395 additions and 1160 deletions
@@ -213,7 +213,7 @@ fun ArchiveUploadStatusBannerView(
) {
DropdownMenus.ItemWithIcon(
menuController = menuController,
drawableResId = R.drawable.symbol_visible_slash,
imageVector = SignalIcons.VisibleSlash.imageVector,
stringResId = R.string.BackupStatus__hide,
onClick = { emitter(ArchiveUploadStatusBannerViewEvents.HideClicked) }
)
@@ -182,7 +182,8 @@ class AppRegistrationNetworkController(
aciPreKeys: PreKeyCollection,
pniPreKeys: PreKeyCollection?,
fcmToken: String?,
skipDeviceTransfer: Boolean
skipDeviceTransfer: Boolean,
aci: ACI?
): RequestResult<RegisterAccountResponse, RegisterAccountError> {
return registrationApi.registerAccount(
e164 = e164,
@@ -194,7 +195,8 @@ class AppRegistrationNetworkController(
aciPreKeys = aciPreKeys,
pniPreKeys = pniPreKeys,
fcmToken = fcmToken,
skipDeviceTransfer = skipDeviceTransfer
skipDeviceTransfer = skipDeviceTransfer,
aci = aci
)
}
@@ -0,0 +1,26 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.signal.core.models.ServiceId.ACI
class ACISerializer : KSerializer<ACI> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ACI", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): ACI {
return ACI.parseOrThrow(decoder.decodeString())
}
override fun serialize(encoder: Encoder, value: ACI) {
encoder.encodeString(value.toString())
}
}
@@ -114,6 +114,8 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
Video(icon(R.drawable.symbol_video_24)),
ViewOnce(icon(R.drawable.symbol_view_once_24)),
ViewOnceInfinite(icon(R.drawable.symbol_view_once_infinite_24)),
Visible(icon(R.drawable.symbol_visible_24)),
VisibleSlash(icon(R.drawable.symbol_visible_slash_24)),
X(icon(R.drawable.symbol_x_24)),
XCircle(icon(R.drawable.symbol_x_circle_24)),
XCircleFill(icon(R.drawable.symbol_x_circle_fill_24))
@@ -139,13 +139,14 @@ class DebugNetworkController(
aciPreKeys: PreKeyCollection,
pniPreKeys: PreKeyCollection?,
fcmToken: String?,
skipDeviceTransfer: Boolean
skipDeviceTransfer: Boolean,
aci: ACI?
): RequestResult<RegisterAccountResponse, RegisterAccountError> {
NetworkDebugState.getOverride<RequestResult<RegisterAccountResponse, RegisterAccountError>>("registerAccount")?.let {
Log.d(TAG, "[registerAccount] Returning debug override")
return it
}
return delegate.registerAccount(e164, password, sessionId, recoveryPassword, receiptCredentialPresentation, attributes, aciPreKeys, pniPreKeys, fcmToken, skipDeviceTransfer)
return delegate.registerAccount(e164, password, sessionId, recoveryPassword, receiptCredentialPresentation, attributes, aciPreKeys, pniPreKeys, fcmToken, skipDeviceTransfer, aci)
}
override suspend fun createLoginPurchaseReceiptCredential(
@@ -25,6 +25,7 @@ import okhttp3.Response
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.models.ServiceId
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.Base64
import org.signal.core.util.Hex
@@ -212,7 +213,8 @@ class DemoNetworkController(
aciPreKeys: PreKeyCollection,
pniPreKeys: PreKeyCollection?,
fcmToken: String?,
skipDeviceTransfer: Boolean
skipDeviceTransfer: Boolean,
aci: ACI?
): RequestResult<RegisterAccountResponse, RegisterAccountError> {
return registrationApi.registerAccount(
e164 = e164,
@@ -224,7 +226,8 @@ class DemoNetworkController(
aciPreKeys = aciPreKeys,
pniPreKeys = pniPreKeys,
fcmToken = fcmToken,
skipDeviceTransfer = skipDeviceTransfer
skipDeviceTransfer = skipDeviceTransfer,
aci = aci
)
}
@@ -100,15 +100,19 @@ interface NetworkController {
* Must provide exactly one of [sessionId], [recoveryPassword], or [receiptCredentialPresentation].
*
* Providing a [receiptCredentialPresentation] (built by [createReceiptCredentialPresentation] from the credential
* issued by [createLoginPurchaseReceiptCredential]) registers an account that has no phone number. For that mode,
* [e164] and [pniPreKeys] must be null, and [attributes] must have a null `pniRegistrationId` and a null
* `discoverableByPhoneNumber`.
* issued by [createLoginPurchaseReceiptCredential]) registers a new account that has no phone number, and providing
* an [aci] alongside a [recoveryPassword] logs back in to an existing one. For both, [e164] must be null and
* [attributes] must have a null `discoverableByPhoneNumber`. Creating a new account also requires a null
* [pniPreKeys] and a null `attributes.pniRegistrationId`, while logging back in requires both to be present -- the
* service demands PNI key material of any recovery-by-identifier, then ignores it for an account with no phone
* number, so throwaway material is fine.
*
* `POST /v1/registration`
*
* @param e164 The phone number in E.164 format (used as username for basic auth). Null when registering without a
* phone number, in which case the implementation generates a placeholder username the service ignores.
* @param password The password for basic auth
* @param aci The ACI of the existing numberless account to log back in to, used as the username for basic auth.
*/
suspend fun registerAccount(
e164: String?,
@@ -120,7 +124,8 @@ interface NetworkController {
aciPreKeys: PreKeyCollection,
pniPreKeys: PreKeyCollection?,
fcmToken: String?,
skipDeviceTransfer: Boolean
skipDeviceTransfer: Boolean,
aci: ACI?
): RequestResult<RegisterAccountResponse, RegisterAccountError>
/**
@@ -30,6 +30,7 @@ data class PersistedFlowState(
val restoredAepValue: String? = null,
val restoreMethodToken: String? = null,
val storageCapable: Boolean = false,
val phoneNumberlessAccount: Boolean = false,
val smsVerificationCodeRequest: VerificationCodeRequest? = null,
val callVerificationCodeRequest: VerificationCodeRequest? = null
)
@@ -49,6 +50,7 @@ fun RegistrationFlowState.toPersistedFlowState(): PersistedFlowState {
restoredAepValue = unverifiedRestoredAep?.value,
restoreMethodToken = restoreMethodToken,
storageCapable = storageCapable,
phoneNumberlessAccount = isPhoneNumberlessAccount,
smsVerificationCodeRequest = lastSmsVerificationCodeRequest,
callVerificationCodeRequest = lastCallVerificationCodeRequest
)
@@ -80,6 +82,7 @@ fun PersistedFlowState.toRegistrationFlowState(
unverifiedRestoredAep = restoredAepValue?.let { AccountEntropyPool(it) },
restoreMethodToken = restoreMethodToken,
storageCapable = storageCapable,
isPhoneNumberlessAccount = phoneNumberlessAccount,
lastSmsVerificationCodeRequest = smsVerificationCodeRequest,
lastCallVerificationCodeRequest = callVerificationCodeRequest
)
@@ -75,9 +75,11 @@ sealed interface RegistrationFlowEvent {
* @param aci The account identifier the server assigned (or re-confirmed) in the registration response.
* @param storageCapable Whether the server reports that this account already has SVR/PIN data, as returned in the
* registration response. Used later (e.g. when skipping a restore) to decide between PIN entry and PIN creation.
* @param phoneNumberless Whether the registration response came back without a phone number. Such an account has no
* PIN, so the screens that would otherwise ask for or create one must be skipped.
*/
data class Registered(val aci: ACI, val accountEntropyPool: AccountEntropyPool, val storageCapable: Boolean) : RegistrationFlowEvent {
override fun toString(): String = "Registered(aci=${aci.logString()}, accountEntropyPool=${accountEntropyPool.displayValue.censor()}, storageCapable=$storageCapable)"
data class Registered(val aci: ACI, val accountEntropyPool: AccountEntropyPool, val storageCapable: Boolean, val phoneNumberless: Boolean) : RegistrationFlowEvent {
override fun toString(): String = "Registered(aci=${aci.logString()}, accountEntropyPool=${accountEntropyPool.displayValue.censor()}, storageCapable=$storageCapable, phoneNumberless=$phoneNumberless)"
}
/** The master key has been restored from SVR. */
@@ -46,6 +46,9 @@ data class RegistrationFlowState(
/** Whether the server reported that this account already has SVR/PIN data, captured from the registration response. */
val storageCapable: Boolean = false,
/** Whether this account has no phone number, captured from the registration response. Such an account has no PIN. */
val isPhoneNumberlessAccount: Boolean = false,
/** 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,
@@ -77,7 +80,7 @@ data class RegistrationFlowState(
val isRestoringNavigationState: Boolean = true
) : Parcelable {
override fun toString(): String {
return "RegistrationFlowState(backStack=${backStack.joinToString()}, sessionMetadata=$sessionMetadata, sessionE164=$sessionE164, submittedVerificationCode=${submittedVerificationCode?.censor()}, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()}, aci=${aci?.logString()}, storageCapable=$storageCapable, temporaryMasterKey=${temporaryMasterKey?.toString()?.censor()}, preExistingRegistrationData=$preExistingRegistrationData, doNotAttemptRecoveryPassword=$doNotAttemptRecoveryPassword, pendingRestoreOption=$pendingRestoreOption, unverifiedRestoredAep=${unverifiedRestoredAep?.displayValue?.censor()}, restoreMethodToken=${restoreMethodToken?.censor()}, lastSmsVerificationCodeRequest=$lastSmsVerificationCodeRequest, lastCallVerificationCodeRequest=$lastCallVerificationCodeRequest, isRestoringNavigation=$isRestoringNavigationState)"
return "RegistrationFlowState(backStack=${backStack.joinToString()}, sessionMetadata=$sessionMetadata, sessionE164=$sessionE164, submittedVerificationCode=${submittedVerificationCode?.censor()}, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()}, aci=${aci?.logString()}, storageCapable=$storageCapable, isPhoneNumberlessAccount=$isPhoneNumberlessAccount, temporaryMasterKey=${temporaryMasterKey?.toString()?.censor()}, preExistingRegistrationData=$preExistingRegistrationData, doNotAttemptRecoveryPassword=$doNotAttemptRecoveryPassword, pendingRestoreOption=$pendingRestoreOption, unverifiedRestoredAep=${unverifiedRestoredAep?.displayValue?.censor()}, restoreMethodToken=${restoreMethodToken?.censor()}, lastSmsVerificationCodeRequest=$lastSmsVerificationCodeRequest, lastCallVerificationCodeRequest=$lastCallVerificationCodeRequest, isRestoringNavigation=$isRestoringNavigationState)"
}
}
@@ -114,9 +114,9 @@ import org.signal.registration.screens.restoreselection.ArchiveRestoreOption
import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionScreen
import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionViewModel
import org.signal.registration.screens.restoreselection.RegisteredState
import org.signal.registration.screens.signallogin.SignalLoginScreen
import org.signal.registration.screens.signallogin.SignalLoginScreenActions
import org.signal.registration.screens.signallogin.SignalLoginViewModel
import org.signal.registration.screens.signallogincredentials.SignalLoginCredentialEntryScreen
import org.signal.registration.screens.signallogincredentials.SignalLoginCredentialEntryScreenActions
import org.signal.registration.screens.signallogincredentials.SignalLoginCredentialEntryViewModel
import org.signal.registration.screens.signallogindetails.SignalLoginViewDetailsScreenActions
import org.signal.registration.screens.signallogindetails.SignalLoginViewDetailsViewModel
import org.signal.registration.screens.signallogininfo.SignalLoginInfoScreen
@@ -180,9 +180,9 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
@Serializable
data object SignalLoginViewDetails : RegistrationRoute
/** Log in with the account key of a Signal Login the user already owns. */
/** Logging in with a Signal Login the user already owns: the account ID and the recovery key that pairs with it. */
@Serializable
data object SignalLogin : RegistrationRoute
data object SignalLoginCredentialEntry : RegistrationRoute
/** Optional username selection for a phone-numberless account. */
@Serializable
@@ -268,6 +268,24 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
registeredState = RegisteredState.RegisteredAndPinKnown
)
}
/**
* For an account reclaimed with an [aep] the user typed in, so either restore source can be read straight away
* without asking for a PIN.
*/
fun forPostRegisterWithKnownAep(aep: AccountEntropyPool, hasRemoteBackup: Boolean): ArchiveRestoreSelection {
return ArchiveRestoreSelection(
restoreOptions = buildList {
if (hasRemoteBackup) {
add(ArchiveRestoreOption.SignalSecureBackup)
}
add(ArchiveRestoreOption.LocalBackup)
add(ArchiveRestoreOption.None)
},
registeredState = RegisteredState.RegisteredAndPinKnown,
aep = aep
)
}
}
}
@@ -301,9 +319,15 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
@Serializable
data object EnterAepForRemoteBackupPostRegistration : RegistrationRoute
/**
* @param backwardNavigationAllowed Whether the user can reasonably go backwards in the nav graph.
*/
@Serializable
@TypeParceler<AccountEntropyPool, AccountEntropyPoolParceler>
data class RemoteRestore(@Serializable(with = AccountEntropyPoolSerializer::class) val aep: AccountEntropyPool) : RegistrationRoute
data class RemoteRestore(
@Serializable(with = AccountEntropyPoolSerializer::class) val aep: AccountEntropyPool,
val backwardNavigationAllowed: Boolean = false
) : RegistrationRoute
@Serializable
data object QuickRestoreQrScan : RegistrationRoute
@@ -729,10 +753,10 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
}
// -- Signal Login Screen
entry<RegistrationRoute.SignalLogin> {
val viewModel: SignalLoginViewModel = viewModel(
factory = SignalLoginViewModel.Factory(
// -- Signal Login Credential Entry Screen
entry<RegistrationRoute.SignalLoginCredentialEntry> {
val viewModel: SignalLoginCredentialEntryViewModel = viewModel(
factory = SignalLoginCredentialEntryViewModel.Factory(
repository = registrationRepository,
parentEventEmitter = registrationViewModel::onEvent
)
@@ -741,11 +765,11 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
val context = LocalContext.current
CollectActions(viewModel.actions) { action ->
when (action) {
SignalLoginScreenActions.OpenNeedHelpArticle -> openUrl(context, SIGNAL_LOGIN_LEARN_MORE_URL)
SignalLoginCredentialEntryScreenActions.OpenNeedHelpArticle -> openUrl(context, SIGNAL_LOGIN_LEARN_MORE_URL)
}
}
SignalLoginScreen(
SignalLoginCredentialEntryScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
@@ -889,6 +913,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
val viewModel: RemoteBackupRestoreViewModel = viewModel(
factory = RemoteBackupRestoreViewModel.Factory(
aep = key.aep,
canNavigateBackwards = key.backwardNavigationAllowed,
repository = registrationRepository,
parentState = registrationViewModel.state,
parentEventEmitter = registrationViewModel::onEvent
@@ -22,6 +22,7 @@ import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.archive.LocalBackupRestoreProgress
import org.signal.core.models.AccountEntropyPool
@@ -355,7 +356,7 @@ class RegistrationRepository(
}
/**
* Registers a new account that has no phone number, redeeming [receiptCredentialPresentation] (issued for a
* Registers a brand new account that has no phone number, by redeeming [receiptCredentialPresentation] (issued for a
* completed Signal Login purchase) as proof of payment.
*
* @return The registration result containing account information or an error
@@ -369,7 +370,10 @@ class RegistrationRepository(
sessionId = null,
recoveryPassword = null,
receiptCredentialPresentation = receiptCredentialPresentation,
skipDeviceTransfer = skipDeviceTransfer
aci = null,
registrationLock = null,
skipDeviceTransfer = skipDeviceTransfer,
existingAccountEntropyPool = null
)
if (result is RequestResult.Success) {
@@ -384,6 +388,35 @@ class RegistrationRepository(
result
}
/**
* Logs back in to the account with no phone number identified by [aci], using the [recoveryPassword] and [aep]
* behind it.
*/
suspend fun reRegisterAccountWithoutPhoneNumber(
aci: ACI,
recoveryPassword: String,
aep: AccountEntropyPool,
registrationLock: String? = null,
skipDeviceTransfer: Boolean = true
): RequestResult<RegisteredAccountData, RegisterAccountError> = withContext(Dispatchers.IO) {
val result = registerAccount(
e164 = null,
sessionId = null,
recoveryPassword = recoveryPassword,
receiptCredentialPresentation = null,
aci = aci,
registrationLock = registrationLock,
skipDeviceTransfer = skipDeviceTransfer,
existingAccountEntropyPool = aep
)
if (result is RequestResult.Success && result.result.response.authCredentialSalt == null) {
Log.w(TAG, "[reRegisterAccountWithoutPhoneNumber] The service did not return an authCredentialSalt!")
}
result
}
/**
* Starts a provisioning session for QR-based quick restore.
* See [NetworkController.startProvisioning].
@@ -653,8 +686,10 @@ class RegistrationRepository(
* 4. On success, saves the registration data to persistent storage
*
* Must provide exactly one of [sessionId], [recoveryPassword], or [receiptCredentialPresentation]. Providing a
* [receiptCredentialPresentation] registers an account that has no phone number, so [e164] must be null and no PNI
* key material is generated or sent.
* [receiptCredentialPresentation] or an [aci] registers an account that has no phone number, so [e164] must be null.
* A [receiptCredentialPresentation] sends no PNI key material at all, while an [aci] always sends throwaway PNI key
* material, which the service requires of any recovery-by-identifier. That material is only kept locally if the
* response says the reclaimed account has a phone number, meaning the service kept it too.
*
* @param e164 The phone number in E.164 format (used for basic auth). Null when registering without a phone number.
* @param sessionId The verified session ID from phone number verification.
@@ -663,6 +698,7 @@ class RegistrationRepository(
* @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.
* @param aci The ACI of the existing phone-numberless account being logged back in to. Requires a [recoveryPassword], and implies there is no [e164].
* @return The registration result containing account information or an error
*/
private suspend fun registerAccount(
@@ -670,6 +706,7 @@ class RegistrationRepository(
sessionId: String?,
recoveryPassword: String?,
receiptCredentialPresentation: ReceiptCredentialPresentation? = null,
aci: ACI? = null,
registrationLock: String? = null,
skipDeviceTransfer: Boolean = true,
existingAccountEntropyPool: AccountEntropyPool? = null,
@@ -677,16 +714,17 @@ class RegistrationRepository(
existingPniIdentityKeyPair: IdentityKeyPair? = null,
unrestrictedUnidentifiedAccess: Boolean = false
): RequestResult<RegisteredAccountData, RegisterAccountError> = withContext(Dispatchers.IO) {
val phoneNumberless = receiptCredentialPresentation != null
val phoneNumberless = receiptCredentialPresentation != null || aci != null
check(listOfNotNull(sessionId, recoveryPassword, receiptCredentialPresentation).size == 1) { "Must provide exactly one of: sessionId, recoveryPassword, receiptCredentialPresentation" }
check(aci == null || recoveryPassword != null) { "Must provide a recoveryPassword alongside an aci" }
if (phoneNumberless) {
check(e164 == null) { "Must not provide an e164 when registering without a phone number" }
} else {
check(e164 != null) { "Must provide an e164 when registering with a phone number" }
}
Log.i(TAG, "[registerAccount] Starting registration for $e164. sessionId: ${sessionId != null}, recoveryPassword: ${recoveryPassword != null}, receiptCredentialPresentation: $phoneNumberless, registrationLock: ${registrationLock != null}, skipDeviceTransfer: $skipDeviceTransfer, existingAep: ${existingAccountEntropyPool != null}")
Log.i(TAG, "[registerAccount] Starting registration for $e164. sessionId: ${sessionId != null}, recoveryPassword: ${recoveryPassword != null}, receiptCredentialPresentation: ${receiptCredentialPresentation != null}, aci: ${aci != null}, phoneNumberless: $phoneNumberless, registrationLock: ${registrationLock != null}, skipDeviceTransfer: $skipDeviceTransfer, existingAep: ${existingAccountEntropyPool != null}")
val inProgressData = storageController.readInProgressRegistrationData()
val resumedAciIdentityKeyPair = inProgressData.accountData?.aciIdentityKeyPair?.takeIf { it.size > 0 }?.let { IdentityKeyPair(it.toByteArray()) }
@@ -732,6 +770,16 @@ class RegistrationRepository(
SensitiveLog.d(TAG, "[registerAccount] Using master key [${Hex.toStringCondensed(newMasterKey.serialize())}] and RRP [$newRecoveryPassword]")
val pniKeyMaterialForRequest = when {
// Traditional registration with a number requires PNI material
!phoneNumberless -> checkNotNull(keyMaterial.pni) { "Missing PNI key material for a primary registration!" }
// Numberless re-registration requires PNI material just in case, and the response tells us if we should keep it
aci != null -> generatePniKeyMaterial()
// Fresh numberless registration requires null PNI material
receiptCredentialPresentation != null -> null
else -> error("Invalid state! numberless: $phoneNumberless, hasAci: ${aci != null}, hasReceiptCredential: ${receiptCredentialPresentation != null}")
}
val accountAttributes = AccountAttributes(
signalingKey = null,
registrationId = keyMaterial.aciRegistrationId,
@@ -743,18 +791,15 @@ class RegistrationRepository(
unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess,
discoverableByPhoneNumber = if (phoneNumberless) null else false, // Important -- this should be false initially, and then the user should be given a choice as to whether to turn it on later
capabilities = getAccountCapabilities().copy(optionalPhoneNumber = phoneNumberless),
pniRegistrationId = keyMaterial.pni?.registrationId,
pniRegistrationId = pniKeyMaterialForRequest?.registrationId,
recoveryPassword = newRecoveryPassword
)
val pniPreKeys = if (phoneNumberless) {
null
} else {
val pniKeyMaterial = checkNotNull(keyMaterial.pni) { "Missing PNI key material for a primary registration!" }
val pniPreKeys = pniKeyMaterialForRequest?.let {
PreKeyCollection(
identityKey = pniKeyMaterial.identityKeyPair.publicKey,
signedPreKey = pniKeyMaterial.signedPreKey,
lastResortKyberPreKey = pniKeyMaterial.lastResortKyberPreKey
identityKey = it.identityKeyPair.publicKey,
signedPreKey = it.signedPreKey,
lastResortKyberPreKey = it.lastResortKyberPreKey
)
}
@@ -768,7 +813,8 @@ class RegistrationRepository(
aciPreKeys = keyMaterial.toAciPreKeyCollection(),
pniPreKeys = pniPreKeys,
fcmToken = fcmToken,
skipDeviceTransfer = skipDeviceTransfer
skipDeviceTransfer = skipDeviceTransfer,
aci = aci
)
when (result) {
@@ -778,6 +824,14 @@ class RegistrationRepository(
checkNotNull(result.result.pni) { "Missing PNI in the response for a primary registration!" }
}
// Numberless re-reg sends throwaway PNI material, and the response tells us if we had a phone number or not.
// Only keep the PNI material if it turns out there was a number associated with the account.
val pniKeyMaterialToKeep = keyMaterial.pni ?: pniKeyMaterialForRequest?.takeIf { result.result.pni != null }
if (aci != null) {
Log.i(TAG, "[registerAccount] Reclaimed an account that ${if (result.result.pni != null) "has" else "does not have"} a phone number. Keeping the PNI key material we sent: ${pniKeyMaterialToKeep != null}")
}
storageController.updateInProgressRegistrationData {
this.accountEntropyPool = keyMaterial.accountEntropyPool.value
}
@@ -788,10 +842,24 @@ class RegistrationRepository(
this.servicePassword = keyMaterial.servicePassword
this.reRegistration = result.result.reregistration
this.authCredentialSalt = result.result.authCredentialSalt?.let { Base64.decode(it).toByteString() }
if (pniKeyMaterialToKeep != null) {
this.pniIdentityKeyPair = pniKeyMaterialToKeep.identityKeyPair.serialize().toByteString()
this.pniSignedPreKey = pniKeyMaterialToKeep.signedPreKey.serialize().toByteString()
this.pniLastResortKyberPreKey = pniKeyMaterialToKeep.lastResortKyberPreKey.serialize().toByteString()
this.pniRegistrationId = pniKeyMaterialToKeep.registrationId
} else {
// An earlier, abandoned attempt in this same registration may have left PNI material behind, and an
// account with no PNI must not be committed holding on to it.
this.pniIdentityKeyPair = ByteString.EMPTY
this.pniSignedPreKey = ByteString.EMPTY
this.pniLastResortKyberPreKey = ByteString.EMPTY
this.pniRegistrationId = 0
}
}
storageController.commitRegistrationData()
RequestResult.Success(RegisteredAccountData(result.result, keyMaterial, ACI.parseOrThrow(result.result.aci)))
RequestResult.Success(RegisteredAccountData(result.result, keyMaterial.copy(pni = pniKeyMaterialToKeep), ACI.parseOrThrow(result.result.aci)))
}
is RequestResult.NonSuccess -> result
is RequestResult.RetryableNetworkError -> result
@@ -1133,7 +1201,8 @@ class RegistrationRepository(
/**
* @param includePniKeyMaterial Whether to generate PNI key material at all. False for an account with no phone number,
* which has no PNI to attach the keys to.
* which has no PNI to attach the keys to. See [generatePniKeyMaterial] for the material such a registration sends
* without keeping.
*/
private fun generateKeyMaterial(
existingAccountEntropyPool: AccountEntropyPool? = null,
@@ -1150,18 +1219,7 @@ class RegistrationRepository(
val aciSignedPreKey = generateSignedPreKey(generatePreKeyId(), timestamp, aciIdentityKeyPair)
val aciLastResortKyberPreKey = generateKyberPreKey(generatePreKeyId(), timestamp, aciIdentityKeyPair)
val pniKeyMaterial = if (includePniKeyMaterial) {
val pniIdentityKeyPair = existingPniIdentityKeyPair ?: IdentityKeyPair.generate()
KeyMaterial.PniKeyMaterial(
identityKeyPair = pniIdentityKeyPair,
signedPreKey = generateSignedPreKey(generatePreKeyId(), timestamp, pniIdentityKeyPair),
lastResortKyberPreKey = generateKyberPreKey(generatePreKeyId(), timestamp, pniIdentityKeyPair),
registrationId = generateRegistrationId()
)
} else {
null
}
val pniKeyMaterial = if (includePniKeyMaterial) generatePniKeyMaterial(existingPniIdentityKeyPair) else null
val profileKey = profileKey ?: generateProfileKey()
@@ -1178,6 +1236,19 @@ class RegistrationRepository(
)
}
/** A self-consistent set of PNI key material: the pre-keys are signed by the identity key returned alongside them. */
private fun generatePniKeyMaterial(existingPniIdentityKeyPair: IdentityKeyPair? = null): KeyMaterial.PniKeyMaterial {
val pniIdentityKeyPair = existingPniIdentityKeyPair ?: IdentityKeyPair.generate()
val timestamp = System.currentTimeMillis()
return KeyMaterial.PniKeyMaterial(
identityKeyPair = pniIdentityKeyPair,
signedPreKey = generateSignedPreKey(generatePreKeyId(), timestamp, pniIdentityKeyPair),
lastResortKyberPreKey = generateKyberPreKey(generatePreKeyId(), timestamp, pniIdentityKeyPair),
registrationId = generateRegistrationId()
)
}
private fun generateSignedPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): SignedPreKeyRecord {
val keyPair = ECKeyPair.generate()
val signature = identityKeyPair.privateKey.calculateSignature(keyPair.publicKey.serialize())
@@ -106,7 +106,7 @@ class RegistrationViewModel(
lastSmsVerificationCodeRequest = event.nextSmsAllowedTimestamp?.let { VerificationCodeRequest(event.e164, it) } ?: state.lastSmsVerificationCodeRequest,
lastCallVerificationCodeRequest = event.nextCallAllowedTimestamp?.let { VerificationCodeRequest(event.e164, it) } ?: state.lastCallVerificationCodeRequest
)
is RegistrationFlowEvent.Registered -> state.copy(aci = event.aci, accountEntropyPool = event.accountEntropyPool, storageCapable = event.storageCapable)
is RegistrationFlowEvent.Registered -> state.copy(aci = event.aci, accountEntropyPool = event.accountEntropyPool, storageCapable = event.storageCapable, isPhoneNumberlessAccount = event.phoneNumberless)
is RegistrationFlowEvent.MasterKeyRestoredFromSvr -> state.copy(temporaryMasterKey = event.masterKey)
is RegistrationFlowEvent.NavigateToScreen -> applyNavigationToScreenEvent(state, event)
is RegistrationFlowEvent.NavigateBackToScreen -> applyNavigateBackToScreenEvent(state, event)
@@ -157,8 +157,8 @@ class RegistrationViewModel(
is RegistrationRoute.Welcome,
is RegistrationRoute.PinCreate,
is RegistrationRoute.PinEntryForSvrRestore,
is RegistrationRoute.SignalLoginInfo,
is RegistrationRoute.RemoteRestore -> true
is RegistrationRoute.SignalLoginInfo -> true
is RegistrationRoute.RemoteRestore -> !this.backwardNavigationAllowed
is RegistrationRoute.ArchiveRestoreSelection -> this.registeredState != RegisteredState.NotRegistered
else -> false
}
@@ -0,0 +1,57 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.aepentry
import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.censor
/**
* Recovery key text as the user has typed it so far, alongside the normalized form and whatever is currently wrong with
* it. Every screen that collects a recovery key shares this so they all agree on when a key is too long, malformed, or
* finished.
*
* @param enteredText The typed text, preserved verbatim (illegal characters stripped) so #/= stay visible as they are typed.
* @param normalized Storage-normalized lowercase form of [enteredText], used for validation and submit.
*/
data class AepInput(
val enteredText: String = "",
val normalized: String = "",
val isValid: Boolean = false,
val error: AepValidationError? = null
) {
override fun toString(): String = "AepInput(enteredText=${enteredText.censor()}, normalized=${normalized.censor()}, isValid=$isValid, error=$error)"
companion object {
/**
* Normalizes [input] and works out what, if anything, is wrong with it. An error the user has already been shown
* sticks around until it is actually resolved, so [previousError] gets a say in the outcome.
*/
fun from(input: String, previousError: AepValidationError? = null): AepInput {
val enteredText = AccountEntropyPool.removeIllegalCharacters(input).take(AccountEntropyPool.LENGTH + 16)
val normalized = AccountEntropyPool.formatForStorage(enteredText).lowercase()
val isValid = AccountEntropyPool.isFullyValid(normalized)
val isShort = normalized.length < AccountEntropyPool.LENGTH
val isExact = normalized.length == AccountEntropyPool.LENGTH
val carriedError = when (previousError) {
is AepValidationError.TooLong -> if (isShort || isExact) null else previousError.copy(count = normalized.length)
AepValidationError.Invalid -> if (isValid) null else previousError
AepValidationError.Incorrect -> null
null -> null
}
val error = carriedError ?: when {
!isShort && !isExact -> AepValidationError.TooLong(normalized.length, AccountEntropyPool.LENGTH)
!isValid && isExact -> AepValidationError.Invalid
else -> null
}
return AepInput(enteredText = enteredText, normalized = normalized, isValid = isValid, error = error)
}
}
}
@@ -92,15 +92,15 @@ class EnterAepForLocalBackupViewModel(
}
private suspend fun applySubmit(inputState: EnterAepState, stateEmitter: (EnterAepState) -> Unit) {
check(inputState.isBackupKeyValid) { "AEP is not valid, should not have gotten here." }
check(inputState.recoveryKey.isValid) { "AEP is not valid, should not have gotten here." }
if (!isPreRegistration) {
resultBus.sendResult(resultKey, EnterAepForLocalBackupResult.RestoreReady(inputState.backupKey))
resultBus.sendResult(resultKey, EnterAepForLocalBackupResult.RestoreReady(inputState.recoveryKey.normalized))
parentEventEmitter.navigateBack()
return
}
val aep = AccountEntropyPool(inputState.backupKey)
val aep = AccountEntropyPool(inputState.recoveryKey.normalized)
stateEmitter(inputState.copy(isRegistering = true))
@@ -108,7 +108,7 @@ class EnterAepForLocalBackupViewModel(
// only mean the backup belongs to a different account rather than a mistyped key.
if (!repository.verifyLocalBackupKey(checkNotNull(backupUri).toUri(), aep)) {
Log.w(TAG, "[Submit] Entered key cannot decrypt the selected backup.")
stateEmitter(inputState.copy(isRegistering = false, aepValidationError = AepValidationError.Incorrect))
stateEmitter(inputState.copy(isRegistering = false, recoveryKey = inputState.recoveryKey.copy(error = AepValidationError.Incorrect)))
return
}
@@ -131,8 +131,8 @@ class EnterAepForLocalBackupViewModel(
val (response, keyMaterial, aci) = result.result
stateEmitter(inputState.copy(isRegistering = false))
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable))
resultBus.sendResult(resultKey, EnterAepForLocalBackupResult.RestoreReady(inputState.backupKey))
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable, phoneNumberless = response.e164 == null))
resultBus.sendResult(resultKey, EnterAepForLocalBackupResult.RestoreReady(inputState.recoveryKey.normalized))
parentEventEmitter.navigateBack()
}
is RequestResult.NonSuccess -> {
@@ -66,9 +66,9 @@ class EnterAepForRemoteBackupPostRegistrationViewModel(
* failing partway through a restore with no recourse.
*/
private suspend fun applySubmit(inputState: EnterAepState, stateEmitter: (EnterAepState) -> Unit) {
check(inputState.isBackupKeyValid) { "AEP is not valid, should not have gotten here." }
check(inputState.recoveryKey.isValid) { "AEP is not valid, should not have gotten here." }
val aep = AccountEntropyPool(inputState.backupKey)
val aep = AccountEntropyPool(inputState.recoveryKey.normalized)
stateEmitter(inputState.copy(isRegistering = true))
@@ -83,10 +83,13 @@ class EnterAepForRemoteBackupPostRegistrationViewModel(
}
is RequestResult.NonSuccess -> {
when (val error = result.error) {
is NetworkController.VerifyBackupKeyError.IncorrectKey,
is NetworkController.VerifyBackupKeyError.IncorrectKey -> {
Log.w(TAG, "[Submit] Entered backup key is incorrect.")
stateEmitter(inputState.copy(isRegistering = false, recoveryKey = inputState.recoveryKey.copy(error = AepValidationError.Incorrect)))
}
is NetworkController.VerifyBackupKeyError.NoBackup -> {
Log.w(TAG, "[Submit] Entered backup key is incorrect (error: $error).")
stateEmitter(inputState.copy(isRegistering = false, aepValidationError = AepValidationError.Incorrect))
Log.w(TAG, "[Submit] The key verified, but the account has no remote backup.")
stateEmitter(inputState.copy(isRegistering = false, registrationError = RegistrationError.NoRemoteBackup))
}
is NetworkController.VerifyBackupKeyError.RateLimited -> {
Log.w(TAG, "[Submit] Rate limited (retryAfter: ${error.retryAfter}).")
@@ -71,9 +71,9 @@ class EnterAepForRemoteBackupPreRegistrationViewModel(
}
private suspend fun applySubmit(inputState: EnterAepState, stateEmitter: (EnterAepState) -> Unit) {
check(inputState.isBackupKeyValid) { "AEP is not valid, should not have gotten here." }
check(inputState.recoveryKey.isValid) { "AEP is not valid, should not have gotten here." }
val aep = AccountEntropyPool(inputState.backupKey)
val aep = AccountEntropyPool(inputState.recoveryKey.normalized)
stateEmitter(inputState.copy(isRegistering = true))
parentEventEmitter(RegistrationFlowEvent.UserSuppliedAepSubmitted(aep))
@@ -94,7 +94,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModel(
val (response, keyMaterial, aci) = result.result
stateEmitter(inputState.copy(isRegistering = false))
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable))
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable, phoneNumberless = response.e164 == null))
parentEventEmitter.navigateTo(RegistrationRoute.RemoteRestore(aep))
}
is RequestResult.NonSuccess -> {
@@ -105,7 +105,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModel(
inputState.copy(
isRegistering = false,
registrationError = RegistrationError.IncorrectRecoveryPassword,
aepValidationError = AepValidationError.Incorrect
recoveryKey = inputState.recoveryKey.copy(error = AepValidationError.Incorrect)
)
)
}
@@ -21,6 +21,7 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -36,6 +37,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -102,25 +104,29 @@ private fun DifferentAccountDialog(onEvent: (EnterAepEvents) -> Unit) {
}
/**
* Shows a dismissable dialog for generic registration errors (network/rate-limit/unknown). Incorrect-key errors are
* surfaced inline on the text field instead, so they are intentionally not shown here.
* Shows a dismissable dialog for registration errors the text field can't express: the generic ones
* (network/rate-limit/unknown), plus [RegistrationError.NoRemoteBackup], which gets its own title and body so it does
* not read as a rejected key. Incorrect-key errors are surfaced inline on the text field instead.
*/
@Composable
private fun RegistrationErrorDialog(error: RegistrationError?, onEvent: (EnterAepEvents) -> Unit) {
val message = when (error) {
RegistrationError.NetworkError -> stringResource(R.string.VerificationCodeScreen__network_error)
RegistrationError.RateLimited -> stringResource(R.string.VerificationCodeScreen__too_many_attempts)
RegistrationError.UnknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred)
RegistrationError.IncorrectRecoveryPassword, null -> null
} ?: return
val (title, message) = when (error) {
RegistrationError.NetworkError -> null to stringResource(R.string.VerificationCodeScreen__network_error)
RegistrationError.RateLimited -> null to stringResource(R.string.VerificationCodeScreen__too_many_attempts)
RegistrationError.UnknownError -> null to stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred)
RegistrationError.NoRemoteBackup -> stringResource(R.string.EnterAepScreen__no_backup_found) to stringResource(R.string.EnterAepScreen__no_backup_found_body)
RegistrationError.IncorrectRecoveryPassword, null -> return
}
Dialogs.SimpleMessageDialog(
title = title,
message = message,
dismiss = stringResource(android.R.string.ok),
onDismiss = { onEvent(EnterAepEvents.DismissError) }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun OnePaneLayout(
params: RegistrationScaffold.Params.OnePane,
@@ -129,6 +135,7 @@ private fun OnePaneLayout(
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
OnePaneRegistrationScaffold(
modifier = modifier
@@ -139,6 +146,7 @@ private fun OnePaneLayout(
Column(
modifier = Modifier
.fillMaxSize()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(scrollState)
.padding(paddingValues),
horizontalAlignment = Alignment.CenterHorizontally
@@ -165,7 +173,7 @@ private fun OnePaneLayout(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart
) {
NoRecoverKeyButton(onEvent)
NoRecoveryKeyButton(onEvent)
}
Box(
modifier = Modifier.weight(1f),
@@ -179,6 +187,7 @@ private fun OnePaneLayout(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TwoPaneLayout(
params: RegistrationScaffold.Params.TwoPane,
@@ -188,6 +197,7 @@ private fun TwoPaneLayout(
) {
val firstPaneScrollState = rememberScrollState()
val secondPaneScrollState = rememberScrollState()
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
TwoPaneRegistrationScaffold(
modifier = modifier
@@ -229,7 +239,7 @@ private fun TwoPaneLayout(
.fillMaxWidth()
.padding(16.dp)
) {
NoRecoverKeyButton(onEvent)
NoRecoveryKeyButton(onEvent)
Spacer(modifier = Modifier.size(24.dp))
NextButton(state, onEvent)
}
@@ -267,7 +277,7 @@ private fun RecoveryKeyTextField(state: EnterAepState, onEvent: (EnterAepEvents)
val autoFillHelper = passwordAutoFillHelper { onEvent(EnterAepEvents.BackupKeyChanged(it)) }
TextField(
value = state.enteredText,
value = state.recoveryKey.enteredText,
onValueChange = {
onEvent(EnterAepEvents.BackupKeyChanged(it))
autoFillHelper.onValueChanged(it)
@@ -290,21 +300,21 @@ private fun RecoveryKeyTextField(state: EnterAepState, onEvent: (EnterAepEvents)
),
keyboardActions = KeyboardActions(
onNext = {
if (state.isBackupKeyValid) {
if (state.recoveryKey.isValid) {
keyboardController?.hide()
onEvent(EnterAepEvents.Submit)
}
}
),
supportingText = {
when (val error = state.aepValidationError) {
when (val error = state.recoveryKey.error) {
is AepValidationError.TooLong -> Text(stringResource(R.string.EnterAepScreen__too_long, error.count, error.max))
is AepValidationError.Invalid -> Text(stringResource(R.string.EnterAepScreen__invalid_recovery_key))
is AepValidationError.Incorrect -> Text(stringResource(R.string.EnterAepScreen__incorrect_recovery_key))
null -> {}
}
},
isError = state.aepValidationError != null,
isError = state.recoveryKey.error != null,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
@@ -342,7 +352,7 @@ private fun FillFromPasswordManagerButton(onEvent: (EnterAepEvents) -> Unit, mod
}
@Composable
private fun NoRecoverKeyButton(onEvent: (EnterAepEvents) -> Unit, modifier: Modifier = Modifier) {
private fun NoRecoveryKeyButton(onEvent: (EnterAepEvents) -> Unit, modifier: Modifier = Modifier) {
TextButton(
modifier = modifier.testTag(TestTags.ENTER_AEP_NO_KEY_BUTTON),
shape = RoundedCornerShape(0.dp),
@@ -356,7 +366,7 @@ private fun NoRecoverKeyButton(onEvent: (EnterAepEvents) -> Unit, modifier: Modi
private fun NextButton(state: EnterAepState, onEvent: (EnterAepEvents) -> Unit, modifier: Modifier = Modifier) {
Buttons.LargeTonal(
modifier = modifier.testTag(TestTags.ENTER_AEP_NEXT_BUTTON),
enabled = state.isBackupKeyValid && state.aepValidationError == null && !state.isRegistering,
enabled = state.recoveryKey.isValid && state.recoveryKey.error == null && !state.isRegistering,
onClick = { onEvent(EnterAepEvents.Submit) }
) {
if (state.isRegistering) {
@@ -426,9 +436,7 @@ private fun EnterAepScreenFilledPreview() {
Previews.Preview {
EnterAepScreen(
state = EnterAepState(
enteredText = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
backupKey = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
isBackupKeyValid = true,
recoveryKey = AepInput.from("uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t"),
isPasswordManagerAvailable = true
),
onEvent = {}
@@ -442,9 +450,7 @@ private fun EnterAepScreenLoadingPreview() {
Previews.Preview {
EnterAepScreen(
state = EnterAepState(
enteredText = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
backupKey = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
isBackupKeyValid = true,
recoveryKey = AepInput.from("uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t"),
isRegistering = true,
isPasswordManagerAvailable = true
),
@@ -459,10 +465,12 @@ private fun EnterAepScreenErrorPreview() {
Previews.Preview {
EnterAepScreen(
state = EnterAepState(
enteredText = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
backupKey = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
isBackupKeyValid = false,
aepValidationError = AepValidationError.Invalid,
recoveryKey = AepInput(
enteredText = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
normalized = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
isValid = false,
error = AepValidationError.Invalid
),
isPasswordManagerAvailable = true
),
onEvent = {}
@@ -5,49 +5,13 @@
package org.signal.registration.screens.aepentry
import org.signal.core.models.AccountEntropyPool
object EnterAepScreenEventHandler {
fun applyEvent(state: EnterAepState, event: EnterAepEvents): EnterAepState {
return when (event) {
is EnterAepEvents.BackupKeyChanged -> applyBackupKeyChanged(state, event.value)
is EnterAepEvents.BackupKeyChanged -> state.copy(recoveryKey = AepInput.from(event.value, state.recoveryKey.error))
is EnterAepEvents.DismissError -> state.copy(registrationError = null)
else -> throw UnsupportedOperationException("This event is not handled generically!")
}
}
private fun applyBackupKeyChanged(state: EnterAepState, key: String): EnterAepState {
val enteredText = AccountEntropyPool.removeIllegalCharacters(key)
.take(AccountEntropyPool.LENGTH + 16)
val newKey = AccountEntropyPool.formatForStorage(enteredText).lowercase()
val isValid = AccountEntropyPool.isFullyValid(newKey)
val isShort = newKey.length < AccountEntropyPool.LENGTH
val isExact = newKey.length == AccountEntropyPool.LENGTH
val previousError = state.aepValidationError
var updatedError: AepValidationError? = when (previousError) {
is AepValidationError.TooLong -> if (isShort || isExact) null else previousError.copy(count = newKey.length)
AepValidationError.Invalid -> if (isValid) null else previousError
AepValidationError.Incorrect -> null
null -> null
}
if (updatedError == null) {
updatedError = when {
!isShort && !isExact -> AepValidationError.TooLong(newKey.length, AccountEntropyPool.LENGTH)
!isValid && isExact -> AepValidationError.Invalid
else -> null
}
}
return state.copy(
enteredText = enteredText,
backupKey = newKey,
isBackupKeyValid = isValid,
aepValidationError = updatedError
)
}
}
@@ -5,15 +5,8 @@
package org.signal.registration.screens.aepentry
import org.signal.core.util.censor
data class EnterAepState(
/** The user's typed text, preserved verbatim (illegal chars stripped). Bound to the TextField so #/= stay visible as the user types them. */
val enteredText: String = "",
/** Storage-normalized lowercase form of [enteredText], used for validation and submit. */
val backupKey: String = "",
val isBackupKeyValid: Boolean = false,
val aepValidationError: AepValidationError? = null,
val recoveryKey: AepInput = AepInput(),
val chunkLength: Int = 4,
val isRegistering: Boolean = false,
val registrationError: RegistrationError? = null,
@@ -22,7 +15,7 @@ data class EnterAepState(
/** Whether a password manager / credential provider is available to fill the recovery key. */
val isPasswordManagerAvailable: Boolean = false
) {
override fun toString(): String = "EnterAepState(enteredText=${enteredText.censor()}, backupKey=${backupKey.censor()}, isBackupKeyValid=$isBackupKeyValid, aepValidationError=$aepValidationError, chunkLength=$chunkLength, isRegistering=$isRegistering, registrationError=$registrationError, showDifferentAccountDialog=$showDifferentAccountDialog, isPasswordManagerAvailable=$isPasswordManagerAvailable)"
override fun toString(): String = "EnterAepState(recoveryKey=$recoveryKey, chunkLength=$chunkLength, isRegistering=$isRegistering, registrationError=$registrationError, showDifferentAccountDialog=$showDifferentAccountDialog, isPasswordManagerAvailable=$isPasswordManagerAvailable)"
}
sealed interface AepValidationError {
@@ -33,6 +26,9 @@ sealed interface AepValidationError {
sealed interface RegistrationError {
data object IncorrectRecoveryPassword : RegistrationError
/** The key verified against the account, but there is no remote backup to restore. Distinct from an incorrect key. */
data object NoRemoteBackup : RegistrationError
data object RateLimited : RegistrationError
data object NetworkError : RegistrationError
data object UnknownError : RegistrationError
@@ -11,8 +11,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -35,7 +35,7 @@ import kotlin.time.Duration.Companion.seconds
class LocalBackupRestoreViewModel(
private val repository: RegistrationRepository,
parentState: Flow<RegistrationFlowState>,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val isPreRegistration: Boolean,
private val resultBus: ResultEventBus,
@@ -166,7 +166,11 @@ class LocalBackupRestoreViewModel(
repository.setRestoreDecision(RestoreDecision.COMPLETED)
if (progress.restoredSvrPin != null) {
if (parentState.value.isPhoneNumberlessAccount) {
Log.i(TAG, "[onRestoreComplete] Account has no phone number, and therefore no PIN. Completing registration.")
repository.restoreAccountRecord()
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
} else if (progress.restoredSvrPin != null) {
repository.restoreAccountRecord()
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
} else if (state.storageCapable) {
@@ -266,7 +270,7 @@ class LocalBackupRestoreViewModel(
class Factory(
private val repository: RegistrationRepository,
private val parentState: Flow<RegistrationFlowState>,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val isPreRegistration: Boolean,
private val knownAep: AccountEntropyPool?,
@@ -312,7 +312,7 @@ class PhoneNumberEntryViewModel(
Log.i(TAG, "[Register] Successfully re-registered using RRP from pre-existing data.")
val (response, keyMaterial, aci) = registerResult.result
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable))
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable, phoneNumberless = response.e164 == null))
if (response.storageCapable) {
parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore)
@@ -417,7 +417,7 @@ class PhoneNumberEntryViewModel(
Log.i(TAG, "[LocalRestore] Successfully registered using RRP from restored AEP.")
val (response, keyMaterial, aci) = result.result
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable))
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable, phoneNumberless = response.e164 == null))
if (response.storageCapable) {
parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore)
@@ -174,17 +174,22 @@ class PinEntryForRegistrationLockViewModel(
is RequestResult.Success -> {
Log.i(TAG, "[PinEntered] Successfully registered!")
val (response, keyMaterial, aci) = registerResult.result
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable))
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable, phoneNumberless = response.e164 == null))
repository.enqueueSvrResetGuessCountJob()
repository.restoreAccountRecord()
val pendingRestore = pendingRestoreNavigation()
when {
pendingRestore != null -> {
Log.i(TAG, "[PinEntered] A restore was pending behind the registration lock. Resuming it now.")
repository.restoreAccountRecord()
parentEventEmitter.navigateTo(pendingRestore)
}
response.reregistration && parentState.value.pendingRestoreOption == null && parentState.value.preExistingRegistrationData == null -> parentEventEmitter.navigateTo(RegistrationRoute.ArchiveRestoreSelection.forPostRegisterWithPinKnown())
else -> parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
response.reregistration && parentState.value.pendingRestoreOption == null && parentState.value.preExistingRegistrationData == null -> {
parentEventEmitter.navigateTo(RegistrationRoute.ArchiveRestoreSelection.forPostRegisterWithPinKnown())
}
else -> {
repository.restoreAccountRecord()
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
}
state
}
@@ -122,7 +122,7 @@ class QuickRestoreQrViewModel(
is RequestResult.Success -> {
val (response, keyMaterial, aci) = registerResult.result
Log.i(TAG, "[Register] Success! reregistration: ${response.reregistration}")
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable))
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable, phoneNumberless = response.e164 == null))
parentEventEmitter.navigateTo(
RegistrationRoute.ArchiveRestoreSelection.forQuickRestore(
aep = AccountEntropyPool(message.accountEntropyPool),
@@ -6,6 +6,7 @@
package org.signal.registration.screens.remotebackuprestore
import android.text.format.DateFormat
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -62,6 +64,8 @@ fun RemoteRestoreScreen(
KeepScreenOnEffect()
}
BackHandler(enabled = state.restoreState == RemoteBackupRestoreState.RestoreState.InProgress) {}
when (state.loadState) {
RemoteBackupRestoreState.LoadState.Loading -> {
Dialogs.IndeterminateProgressDialog(
@@ -134,8 +138,8 @@ private fun OnePaneLayout(
.fillMaxWidth()
.padding(24.dp)
) {
RestoreButton(onEvent, Modifier.fillMaxWidth())
CancelButton(onEvent, Modifier.fillMaxWidth())
RestoreButton(state, onEvent, Modifier.fillMaxWidth())
CancelButton(state, onEvent, Modifier.fillMaxWidth())
}
}
}
@@ -191,9 +195,9 @@ private fun TwoPaneLayout(
.padding(24.dp),
horizontalArrangement = Arrangement.End
) {
CancelButton(onEvent, Modifier)
CancelButton(state, onEvent, Modifier)
Spacer(modifier = Modifier.size(8.dp))
RestoreButton(onEvent, Modifier)
RestoreButton(state, onEvent, Modifier)
}
}
}
@@ -271,10 +275,12 @@ private fun BackupInfoDetails(state: RemoteBackupRestoreState, modifier: Modifie
@Composable
private fun RestoreButton(
state: RemoteBackupRestoreState,
onEvent: (RemoteBackupRestoreScreenEvents) -> Unit,
modifier: Modifier
) {
Buttons.LargeTonal(
enabled = !state.isSkipping,
onClick = { onEvent(RemoteBackupRestoreScreenEvents.BackupRestoreBackup) },
modifier = modifier.testTag(TestTags.REMOTE_BACKUP_RESTORE_RESTORE_BUTTON)
) {
@@ -284,14 +290,24 @@ private fun RestoreButton(
@Composable
private fun CancelButton(
state: RemoteBackupRestoreState,
onEvent: (RemoteBackupRestoreScreenEvents) -> Unit,
modifier: Modifier
) {
TextButton(
enabled = !state.isSkipping,
onClick = { onEvent(RemoteBackupRestoreScreenEvents.Cancel) },
modifier = modifier.testTag(TestTags.REMOTE_BACKUP_RESTORE_CANCEL_BUTTON)
) {
Text(text = stringResource(android.R.string.cancel))
if (state.isSkipping) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(text = stringResource(android.R.string.cancel))
}
}
}
@@ -17,10 +17,11 @@ data class RemoteBackupRestoreState(
val restoreState: RestoreState = RestoreState.None,
val restoreProgress: RestoreProgress? = null,
val loadAttempts: Int = 0,
val showContactSupportDialog: Boolean = false
val showContactSupportDialog: Boolean = false,
val isSkipping: Boolean = false
) {
override fun toString(): String = "RemoteBackupRestoreState(aep=${aep.displayValue.censor()}, loadState=$loadState, backupTime=$backupTime, backupSize=$backupSize, restoreState=$restoreState, restoreProgress=$restoreProgress, loadAttempts=$loadAttempts, showContactSupportDialog=$showContactSupportDialog)"
override fun toString(): String = "RemoteBackupRestoreState(aep=${aep.displayValue.censor()}, loadState=$loadState, backupTime=$backupTime, backupSize=$backupSize, restoreState=$restoreState, restoreProgress=$restoreProgress, loadAttempts=$loadAttempts, showContactSupportDialog=$showContactSupportDialog, isSkipping=$isSkipping)"
enum class LoadState {
Loading,
@@ -36,6 +36,7 @@ import kotlin.time.Duration.Companion.seconds
class RemoteBackupRestoreViewModel(
private val aep: AccountEntropyPool,
private val canNavigateBackwards: Boolean,
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
@@ -76,8 +77,21 @@ class RemoteBackupRestoreViewModel(
stateEmitter(state)
}
is RemoteBackupRestoreScreenEvents.Cancel -> {
parentEventEmitter.navigateBack()
stateEmitter(state)
if (state.isSkipping) {
Log.i(TAG, "[Cancel] Already moving on without a remote restore. Ignoring.")
return
}
if (canNavigateBackwards) {
Log.i(TAG, "[Cancel] Going back to previous screen.")
parentEventEmitter.navigateBack()
return
}
Log.i(TAG, "[Cancel] Moving on without a remote restore.")
stateEmitter(state.copy(isSkipping = true))
repository.setRestoreDecision(RestoreDecision.SKIPPED)
continuePastRestore()
}
is RemoteBackupRestoreScreenEvents.DismissError -> {
stateEmitter(state.copy(restoreState = RemoteBackupRestoreState.RestoreState.None, restoreProgress = null))
@@ -91,6 +105,28 @@ class RemoteBackupRestoreViewModel(
}
}
private suspend fun continuePastRestore() {
when {
parentState.value.isPhoneNumberlessAccount -> {
Log.i(TAG, "[continuePastRestore] Account has no phone number, and therefore no PIN. Completing registration.")
repository.restoreAccountRecord()
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
repository.hasKnownPin() -> {
repository.restoreAccountRecord()
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
parentState.value.storageCapable -> {
Log.i(TAG, "[continuePastRestore] No PIN is known and the account is storage capable. Navigating to PIN entry to restore the existing PIN.")
parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore)
}
else -> {
Log.i(TAG, "[continuePastRestore] No PIN is known and the account is not storage capable. Navigating to PIN creation.")
parentEventEmitter.navigateTo(RegistrationRoute.PinCreate)
}
}
}
private fun restoreBackup() {
viewModelScope.launch {
repository.restoreRemoteBackup(_state.value.aep).collect { progress ->
@@ -136,21 +172,7 @@ class RemoteBackupRestoreViewModel(
)
repository.persistRestoredBackupState(progress.restoredSvrPin, progress.restoredProfileKey)
repository.setRestoreDecision(RestoreDecision.COMPLETED)
when {
repository.hasKnownPin() -> {
repository.restoreAccountRecord()
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
parentState.value.storageCapable -> {
Log.i(TAG, "[restoreBackup] No PIN is known and the account is storage capable. Navigating to PIN entry to restore the existing PIN.")
parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore)
}
else -> {
Log.i(TAG, "[restoreBackup] No PIN is known and the account is not storage capable. Navigating to PIN creation.")
parentEventEmitter.navigateTo(RegistrationRoute.PinCreate)
}
}
continuePastRestore()
}
is RemoteBackupRestoreProgress.NetworkError -> {
Log.w(TAG, "[restoreBackup] Remote restore failed with network error.", progress.cause)
@@ -267,12 +289,13 @@ class RemoteBackupRestoreViewModel(
class Factory(
private val aep: AccountEntropyPool,
private val canNavigateBackwards: Boolean,
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return RemoteBackupRestoreViewModel(aep, repository, parentState, parentEventEmitter) as T
return RemoteBackupRestoreViewModel(aep, canNavigateBackwards, repository, parentState, parentEventEmitter) as T
}
}
}
@@ -5,6 +5,7 @@
package org.signal.registration.screens.restoreselection
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -18,6 +19,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -160,6 +162,8 @@ private fun RestoreOptions(state: ArchiveRestoreSelectionState, onEvent: (Archiv
}
RestoreOptionCard(
option = option,
enabled = !state.isSkipping,
showSpinner = state.isSkipping && option == ArchiveRestoreOption.None,
onClick = { onEvent(ArchiveRestoreSelectionScreenEvents.RestoreOptionSelected(option)) }
)
}
@@ -168,6 +172,8 @@ private fun RestoreOptions(state: ArchiveRestoreSelectionState, onEvent: (Archiv
@Composable
private fun RestoreOptionCard(
option: ArchiveRestoreOption,
enabled: Boolean,
showSpinner: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -177,6 +183,7 @@ private fun RestoreOptionCard(
imageVector = SignalIcons.SignalBackupsDisplay.imageVector,
title = stringResource(R.string.ArchiveRestoreSelectionScreen__from_signal_backups),
subtitle = stringResource(R.string.ArchiveRestoreSelectionScreen__your_free_or_paid_signal_backup_plan),
enabled = enabled,
onClick = onClick,
modifier = modifier.testTag(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_SIGNAL_BACKUPS)
)
@@ -187,6 +194,7 @@ private fun RestoreOptionCard(
imageVector = SignalIcons.TransferDisplay.imageVector,
title = stringResource(R.string.ArchiveRestoreSelectionScreen__from_your_old_phone),
subtitle = stringResource(R.string.ArchiveRestoreSelectionScreen__transfer_directly_from_old),
enabled = enabled,
onClick = onClick,
modifier = modifier.testTag(TestTags.ARCHIVE_RESTORE_SELECTION_DEVICE_TRANSFER)
)
@@ -197,6 +205,7 @@ private fun RestoreOptionCard(
imageVector = SignalIcons.FolderDisplay.imageVector,
title = stringResource(R.string.ArchiveRestoreSelectionScreen__local_backup_card_title),
subtitle = stringResource(R.string.ArchiveRestoreSelectionScreen__local_backup_card_description),
enabled = enabled,
onClick = onClick,
modifier = modifier.testTag(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_BACKUP_FOLDER)
)
@@ -207,6 +216,8 @@ private fun RestoreOptionCard(
imageVector = SignalIcons.MobileNextDisplay.imageVector,
title = stringResource(R.string.ArchiveRestoreSelectionScreen__skip_restore_title),
subtitle = stringResource(R.string.ArchiveRestoreSelectionScreen__skip_restore_description),
enabled = enabled,
showSpinner = showSpinner,
onClick = onClick,
modifier = modifier.testTag(TestTags.ARCHIVE_RESTORE_SELECTION_NONE)
)
@@ -220,10 +231,13 @@ private fun SelectionCard(
title: String,
subtitle: String,
onClick: () -> Unit,
enabled: Boolean = true,
showSpinner: Boolean = false,
modifier: Modifier = Modifier
) {
Card(
onClick = onClick,
enabled = enabled,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
),
@@ -233,7 +247,17 @@ private fun SelectionCard(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp)
) {
Icon(imageVector = imageVector, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(48.dp))
if (showSpinner) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(48.dp)) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary
)
}
} else {
Icon(imageVector = imageVector, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(48.dp))
}
Spacer(modifier = Modifier.width(16.dp))
@@ -13,7 +13,9 @@ data class ArchiveRestoreSelectionState(
/** Token that, if present, indicates that the user did a quick restore, and we should hit a network endpoint to indicate our restore selection. */
val restoreMethodToken: String? = null,
/** Whether the account already has SVR/PIN data on the server. Determines whether skipping restore leads to PIN entry or PIN creation. */
val storageCapable: Boolean = false
val storageCapable: Boolean = false,
/** Whether the skip is underway. The last of the work it does is a network call, so the skip card shows a spinner until the flow moves on. */
val isSkipping: Boolean = false
) {
override fun toString(): String = "ArchiveRestoreSelectionState(restoreOptions=$restoreOptions, showSkipWarningDialog=$showSkipWarningDialog, restoreMethodToken=${restoreMethodToken?.censor()}, storageCapable=$storageCapable)"
override fun toString(): String = "ArchiveRestoreSelectionState(restoreOptions=$restoreOptions, showSkipWarningDialog=$showSkipWarningDialog, restoreMethodToken=${restoreMethodToken?.censor()}, storageCapable=$storageCapable, isSkipping=$isSkipping)"
}
@@ -87,7 +87,7 @@ class ArchiveRestoreSelectionViewModel(
parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry)
}
knownAep != null -> {
parentEventEmitter.navigateTo(RegistrationRoute.RemoteRestore(knownAep))
parentEventEmitter.navigateTo(RegistrationRoute.RemoteRestore(knownAep, backwardNavigationAllowed = true))
}
else -> {
parentEventEmitter.navigateTo(RegistrationRoute.EnterAepForRemoteBackupPostRegistration)
@@ -137,10 +137,14 @@ class ArchiveRestoreSelectionViewModel(
state.copy(showSkipWarningDialog = false)
}
RegisteredState.RegisteredAndPinKnown -> {
val skipping = state.copy(showSkipWarningDialog = false, isSkipping = true)
stateEmitter(skipping)
notifyOldDevice(state.restoreMethodToken, RestoreMethod.DECLINE)
repository.setRestoreDecision(RestoreDecision.SKIPPED)
repository.restoreAccountRecord()
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
state.copy(showSkipWarningDialog = false)
skipping
}
}
}
@@ -1,421 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.registration.R
import org.signal.registration.fonts.MonoTypeface
import org.signal.registration.screens.OnePaneRegistrationScaffold
import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.screens.TwoPaneRegistrationScaffold
import org.signal.registration.screens.attachDebugLogHelper
import org.signal.registration.screens.shared.BackTopAppBar
import org.signal.registration.test.TestTags
/**
* Logs an existing Signal Login in by asking for its account key.
*/
@Composable
fun SignalLoginScreen(
state: SignalLoginState,
onEvent: (SignalLoginScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
val simpleError: Pair<String, SignalLoginScreenEvents>? = when {
state.dialogs.networkError -> stringResource(R.string.VerificationCodeScreen__network_error) to SignalLoginScreenEvents.NetworkErrorDialogDismissed
state.dialogs.unknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred) to SignalLoginScreenEvents.UnknownErrorDialogDismissed
else -> null
}
simpleError?.let { (message, dismissedEvent) ->
Dialogs.SimpleMessageDialog(
message = message,
dismiss = stringResource(android.R.string.ok),
onDismiss = { onEvent(dismissedEvent) }
)
}
Surface(
modifier = modifier
.fillMaxSize()
.testTag(TestTags.SIGNAL_LOGIN_SCREEN)
) {
when (val params = RegistrationScaffold.rememberLayoutParams()) {
is RegistrationScaffold.Params.OnePane -> OnePaneLayout(params, state, onEvent)
is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout(params, state, onEvent)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun OnePaneLayout(
params: RegistrationScaffold.Params.OnePane,
state: SignalLoginState,
onEvent: (SignalLoginScreenEvents) -> Unit
) {
val scrollState = rememberScrollState()
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
OnePaneRegistrationScaffold(
params = params,
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginScreenEvents.BackClicked) }) },
content = { paddingValues ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(scrollState)
.padding(paddingValues)
) {
Header()
Spacer(modifier = Modifier.height(32.dp))
AccountKeyTextField(state = state, onEvent = onEvent)
}
},
footer = { Footer(params, state, scrollState.canScrollForward, onEvent) }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TwoPaneLayout(
params: RegistrationScaffold.Params.TwoPane,
state: SignalLoginState,
onEvent: (SignalLoginScreenEvents) -> Unit
) {
val firstPaneScrollState = rememberScrollState()
val secondPaneScrollState = rememberScrollState()
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
TwoPaneRegistrationScaffold(
params = params,
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginScreenEvents.BackClicked) }) },
firstPane = { paddingValues ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(firstPaneScrollState)
.padding(paddingValues)
) {
Header(twoPane = true)
}
},
secondPane = { paddingValues ->
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(secondPaneScrollState)
.padding(paddingValues)
) {
AccountKeyTextField(state = state, onEvent = onEvent)
}
},
footer = { Footer(params, state, firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward, onEvent) }
)
}
@Composable
private fun Header(twoPane: Boolean = false) {
Image(
painter = painterResource(R.drawable.image_signal_login_ring),
contentDescription = null,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(R.string.SignalLoginScreen__signal_login),
style = if (twoPane) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.attachDebugLogHelper()
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.SignalLoginScreen__enter_your_32_character_account_key),
style = if (twoPane) MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal) else MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
@Composable
private fun AccountKeyTextField(
state: SignalLoginState,
onEvent: (SignalLoginScreenEvents) -> Unit
) {
val focusRequester = remember { FocusRequester() }
var requestFocus by remember { mutableStateOf(true) }
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = state.accountKey,
onValueChange = { onEvent(SignalLoginScreenEvents.AccountKeyChanged(it)) },
label = { Text(stringResource(R.string.SignalLoginScreen__account_key)) },
enabled = !state.isSubmitting,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontFamily = MonoTypeface.fontFamily(),
fontSize = 18.sp,
letterSpacing = 1.44.sp
),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
errorContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Next,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onNext = {
if (state.isNextEnabled) {
keyboardController?.hide()
onEvent(SignalLoginScreenEvents.NextClicked)
}
}
),
supportingText = {
when (val error = state.accountKeyError) {
is AccountKeyError.TooLong -> Text(stringResource(R.string.SignalLoginScreen__too_long, error.count, SignalLoginState.ACCOUNT_KEY_LENGTH))
is AccountKeyError.Invalid -> Text(stringResource(R.string.SignalLoginScreen__invalid_account_key))
is AccountKeyError.Incorrect -> Text(stringResource(R.string.SignalLoginScreen__incorrect_account_key))
null -> {}
}
},
isError = state.accountKeyError != null,
visualTransformation = AccountKeyVisualTransformation,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.SIGNAL_LOGIN_ACCOUNT_KEY_FIELD)
.focusRequester(focusRequester)
.onGloballyPositioned {
if (requestFocus) {
focusRequester.requestFocus()
requestFocus = false
}
}
)
}
@Composable
private fun Footer(
params: RegistrationScaffold.Params,
state: SignalLoginState,
isElevated: Boolean,
onEvent: (SignalLoginScreenEvents) -> Unit
) {
RegistrationScaffold.FooterSurface(isElevated = isElevated) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(params.footerPadding)
) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(1f)
) {
NeedHelpButton(onEvent)
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.weight(1f)
) {
NextButton(state, onEvent)
}
}
}
}
@Composable
private fun NeedHelpButton(onEvent: (SignalLoginScreenEvents) -> Unit) {
TextButton(
shape = RoundedCornerShape(0.dp),
onClick = { onEvent(SignalLoginScreenEvents.NeedHelpClicked) },
modifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_NEED_HELP_BUTTON)
) {
Text(text = stringResource(R.string.SignalLoginScreen__need_help))
}
}
@Composable
private fun NextButton(state: SignalLoginState, onEvent: (SignalLoginScreenEvents) -> Unit) {
Buttons.LargeTonal(
enabled = state.isNextEnabled,
onClick = { onEvent(SignalLoginScreenEvents.NextClicked) },
modifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON)
) {
if (state.isSubmitting) {
CircularProgressIndicator(
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
} else {
Text(text = stringResource(R.string.SignalLoginScreen__next))
}
}
}
/**
* Renders an account key the way its ACI is normally written: uppercased and split into 8-4-4-4-12 groups by dashes.
* The dashes are display-only, so what the view model sees is always the unformatted key.
*/
internal object AccountKeyVisualTransformation : VisualTransformation {
/** Offsets in the raw key that a dash is inserted in front of. */
private val DASH_OFFSETS = intArrayOf(8, 12, 16, 20)
override fun filter(text: AnnotatedString): TransformedText {
val transformed = buildString {
for ((index, character) in text.text.withIndex()) {
if (index in DASH_OFFSETS) {
append('-')
}
append(character.uppercaseChar())
}
}
return TransformedText(
text = AnnotatedString(transformed),
offsetMapping = AccountKeyOffsetMapping(text.length)
)
}
/**
* A dash is only present if the key is long enough to have a character after it, so [inputLength] decides which of
* [DASH_OFFSETS] actually made it into the transformed text.
*/
private class AccountKeyOffsetMapping(private val inputLength: Int) : OffsetMapping {
override fun originalToTransformed(offset: Int): Int = offset + DASH_OFFSETS.count { it <= offset && it < inputLength }
override fun transformedToOriginal(offset: Int): Int = offset - DASH_OFFSETS.withIndex().count { (index, dashOffset) -> dashOffset < inputLength && dashOffset + index < offset }
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginScreenPreview() {
Previews.Preview {
SignalLoginScreen(
state = SignalLoginState(),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginScreenFilledPreview() {
Previews.Preview {
SignalLoginScreen(
state = SignalLoginState(accountKey = "a6b284822e3283d07f2391360a4c2b91"),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginScreenSubmittingPreview() {
Previews.Preview {
SignalLoginScreen(
state = SignalLoginState(
accountKey = "a6b284822e3283d07f2391360a4c2b91",
isSubmitting = true
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginScreenErrorPreview() {
Previews.Preview {
SignalLoginScreen(
state = SignalLoginState(
accountKey = "a6b284822e3283d07f2391360a4c2b91",
accountKeyError = AccountKeyError.Incorrect
),
onEvent = {}
)
}
}
@@ -1,11 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
sealed interface SignalLoginScreenActions {
/** Open the article explaining where to find your account key. */
data object OpenNeedHelpArticle : SignalLoginScreenActions
}
@@ -1,30 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
import org.signal.core.util.censor
sealed class SignalLoginScreenEvents {
/** The user tapped the back arrow. */
data object BackClicked : SignalLoginScreenEvents()
/** The user edited the account key field. Carries the raw text, formatting and all. */
data class AccountKeyChanged(val value: String) : SignalLoginScreenEvents() {
override fun toString(): String = "AccountKeyChanged(value=${value.censor()})"
}
/** The user tapped "Need help?". */
data object NeedHelpClicked : SignalLoginScreenEvents()
/** The user submitted the account key, either with the next button or the keyboard's next action. */
data object NextClicked : SignalLoginScreenEvents()
/** The user dismissed the network error dialog. */
data object NetworkErrorDialogDismissed : SignalLoginScreenEvents()
/** The user dismissed the unknown error dialog. */
data object UnknownErrorDialogDismissed : SignalLoginScreenEvents()
}
@@ -1,50 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
import org.signal.core.util.censor
/**
* State for the screen where a user who already owns a Signal Login types in their account key to log in.
*
* [accountKey] holds the key without any of the formatting the user sees: the screen renders the dashes and
* uppercasing itself, so what is stored here is always the raw lowercase value.
*/
data class SignalLoginState(
val accountKey: String = "",
val accountKeyError: AccountKeyError? = null,
val isSubmitting: Boolean = false,
val dialogs: Dialogs = Dialogs()
) {
/** Whether the entered key is complete and well-formed enough to send to the service. */
val isNextEnabled: Boolean
get() = accountKey.length == ACCOUNT_KEY_LENGTH && accountKeyError == null && !isSubmitting
override fun toString(): String = "SignalLoginState(accountKey=${accountKey.censor()}, accountKeyError=$accountKeyError, isSubmitting=$isSubmitting, dialogs=$dialogs)"
data class Dialogs(
val networkError: Boolean = false,
val unknownError: Boolean = false
)
companion object {
/** An account key is an ACI with its dashes removed, so it is always this many hex characters. */
const val ACCOUNT_KEY_LENGTH = 32
}
}
/** Why the entered account key can't be submitted. Shown beneath the text field rather than in a dialog. */
sealed interface AccountKeyError {
/** More than [SignalLoginState.ACCOUNT_KEY_LENGTH] characters were entered. */
data class TooLong(val count: Int) : AccountKeyError
/** The entered text contains characters that can't appear in an account key. */
data object Invalid : AccountKeyError
/** The service didn't recognize the entered account key. */
data object Incorrect : AccountKeyError
}
@@ -1,118 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
import org.signal.registration.screens.util.navigateBack
/**
* View model for [SignalLoginScreen].
*
* Logging in with an account key requires an endpoint that doesn't exist yet, so [SignalLoginScreenEvents.NextClicked]
* is deliberately left as a stub. Everything the screen needs to validate and format what the user types is here.
*/
class SignalLoginViewModel(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<SignalLoginScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(SignalLoginViewModel::class)
/** Formatting the user may have pasted along with the key, which we accept and discard. */
private val FORMATTING_CHARACTERS = Regex("""[\s-]""")
private fun Char.isAccountKeyCharacter(): Boolean = this in '0'..'9' || this in 'a'..'f'
}
private val _state = MutableStateFlow(SignalLoginState())
val state: StateFlow<SignalLoginState> = _state.asStateFlow()
private val _actions = Channel<SignalLoginScreenActions>(Channel.BUFFERED)
val actions: Flow<SignalLoginScreenActions> = _actions.receiveAsFlow()
init {
_state
.onEach { Log.d(TAG, "[State] $it") }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: SignalLoginScreenEvents) {
applyEvent(_state.value, event, parentEventEmitter) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(
state: SignalLoginState,
event: SignalLoginScreenEvents,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (SignalLoginState) -> Unit
) {
when (event) {
is SignalLoginScreenEvents.BackClicked -> {
parentEventEmitter.navigateBack()
}
is SignalLoginScreenEvents.AccountKeyChanged -> {
val accountKey = event.value.replace(FORMATTING_CHARACTERS, "").lowercase()
stateEmitter(state.copy(accountKey = accountKey, accountKeyError = validate(accountKey)))
}
is SignalLoginScreenEvents.NeedHelpClicked -> {
_actions.trySend(SignalLoginScreenActions.OpenNeedHelpArticle)
}
is SignalLoginScreenEvents.NextClicked -> {
Log.i(TAG, "Next clicked, but logging in with an account key isn't implemented yet.")
}
is SignalLoginScreenEvents.NetworkErrorDialogDismissed -> {
stateEmitter(state.copy(dialogs = state.dialogs.copy(networkError = false)))
}
is SignalLoginScreenEvents.UnknownErrorDialogDismissed -> {
stateEmitter(state.copy(dialogs = state.dialogs.copy(unknownError = false)))
}
}
}
/**
* Checks an already-normalized [accountKey]. A key that is merely incomplete isn't an error the next button stays
* disabled until it is the right length, without nagging the user as they type.
*/
private fun validate(accountKey: String): AccountKeyError? {
return when {
accountKey.length > SignalLoginState.ACCOUNT_KEY_LENGTH -> AccountKeyError.TooLong(accountKey.length)
accountKey.any { !it.isAccountKeyCharacter() } -> AccountKeyError.Invalid
else -> null
}
}
class Factory(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return SignalLoginViewModel(repository, parentEventEmitter) as T
}
}
}
@@ -0,0 +1,52 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogincredentials
import org.signal.core.models.ServiceId.ACI
/**
* The 8-4-4-4-12 layout an account ID shares with the [ACI] it is written from.
* Exists to support formatting ACI's as-you-type.
*/
internal object AccountIdFormat {
/** Offsets in a raw account ID that a dash is inserted in front of. */
private val DASH_OFFSETS = intArrayOf(8, 12, 16, 20)
/** Rewrites a raw account ID with the dashes a UUID is normally written with. */
fun dashed(accountId: String): String {
return buildString {
for ((index, character) in accountId.withIndex()) {
if (index in DASH_OFFSETS) {
append('-')
}
append(character)
}
}
}
/**
* How many dashes [dashed] inserts before [offset] in an ID of [length] characters. A dash is only present if the ID
* is long enough to have a character after it, so [length] decides which offsets actually contribute.
*/
fun dashesBeforeRawOffset(offset: Int, length: Int): Int {
return DASH_OFFSETS.count { it <= offset && it < length }
}
/** How many dashes precede [offset] in the output of [dashed] for an ID of [length] characters. */
fun dashesBeforeDashedOffset(offset: Int, length: Int): Int {
return DASH_OFFSETS.withIndex().count { (index, dashOffset) -> dashOffset < length && dashOffset + index < offset }
}
/** Parses a complete raw account ID as the [ACI] it stands for. Null if it isn't a valid ACI. */
fun toAciOrNull(accountId: String): ACI? {
if (accountId.length != SignalLoginCredentialEntryState.ACCOUNT_ID_LENGTH) {
return null
}
return ACI.parseOrNull(dashed(accountId))
}
}
@@ -0,0 +1,493 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogincredentials
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.passwordmanager.compose.attachPasswordAutoFillHelper
import org.signal.passwordmanager.compose.passwordAutoFillHelper
import org.signal.registration.R
import org.signal.registration.fonts.MonoTypeface
import org.signal.registration.screens.OnePaneRegistrationScaffold
import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.screens.TwoPaneRegistrationScaffold
import org.signal.registration.screens.aepentry.AepInput
import org.signal.registration.screens.aepentry.AepValidationError
import org.signal.registration.screens.aepentry.AepVisualTransformation
import org.signal.registration.screens.attachDebugLogHelper
import org.signal.registration.screens.shared.BackTopAppBar
import org.signal.registration.test.TestTags
/** How the recovery key is grouped when it is spelled out rather than masked. */
private const val RECOVERY_KEY_CHUNK_LENGTH = 4
/**
* Collects an existing Signal Login -- the account ID and the recovery key that pairs with it -- and logs the user
* back in with the two together.
*/
@Composable
fun SignalLoginCredentialEntryScreen(
state: SignalLoginCredentialEntryState,
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
LoginErrorDialog(state.loginError, onEvent)
Surface(
modifier = modifier
.fillMaxSize()
.testTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_ENTRY_SCREEN)
) {
when (val params = RegistrationScaffold.rememberLayoutParams()) {
is RegistrationScaffold.Params.OnePane -> OnePaneLayout(params, state, onEvent)
is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout(params, state, onEvent)
}
}
}
/**
* Shows a dismissable dialog for the login failures the text fields can't express. A rejected pair is surfaced inline
* on both fields instead, since either half could be the one at fault.
*/
@Composable
private fun LoginErrorDialog(error: SignalLoginError?, onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit) {
val message = when (error) {
SignalLoginError.NetworkError -> stringResource(R.string.VerificationCodeScreen__network_error)
SignalLoginError.RateLimited -> stringResource(R.string.VerificationCodeScreen__too_many_attempts)
SignalLoginError.UnknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred)
null -> null
} ?: return
Dialogs.SimpleMessageDialog(
message = message,
dismiss = stringResource(android.R.string.ok),
onDismiss = { onEvent(SignalLoginCredentialEntryScreenEvents.DismissError) }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun OnePaneLayout(
params: RegistrationScaffold.Params.OnePane,
state: SignalLoginCredentialEntryState,
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit
) {
val scrollState = rememberScrollState()
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
OnePaneRegistrationScaffold(
params = params,
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginCredentialEntryScreenEvents.BackClicked) }) },
content = { paddingValues ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(scrollState)
.padding(paddingValues)
) {
Header()
Spacer(modifier = Modifier.height(32.dp))
CredentialTextFields(state = state, onEvent = onEvent)
}
},
footer = { Footer(params, state, scrollState.canScrollForward, onEvent) }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TwoPaneLayout(
params: RegistrationScaffold.Params.TwoPane,
state: SignalLoginCredentialEntryState,
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit
) {
val firstPaneScrollState = rememberScrollState()
val secondPaneScrollState = rememberScrollState()
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
TwoPaneRegistrationScaffold(
params = params,
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginCredentialEntryScreenEvents.BackClicked) }) },
firstPane = { paddingValues ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(firstPaneScrollState)
.padding(paddingValues)
) {
Header(twoPane = true)
}
},
secondPane = { paddingValues ->
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(secondPaneScrollState)
.padding(paddingValues)
) {
CredentialTextFields(state = state, onEvent = onEvent)
}
},
footer = { Footer(params, state, firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward, onEvent) }
)
}
@Composable
private fun Header(twoPane: Boolean = false) {
Image(
painter = painterResource(R.drawable.image_signal_login_ring),
contentDescription = null,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(R.string.SignalLoginCredentialEntryScreen__signal_login),
style = if (twoPane) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.attachDebugLogHelper()
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.SignalLoginCredentialEntryScreen__enter_your_account_id_followed_by_your_recovery_key),
style = if (twoPane) MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal) else MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
@Composable
private fun CredentialTextFields(
state: SignalLoginCredentialEntryState,
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit
) {
AccountIdTextField(state = state, onEvent = onEvent)
Spacer(modifier = Modifier.height(12.dp))
RecoveryKeyTextField(state = state, onEvent = onEvent)
}
@Composable
private fun AccountIdTextField(
state: SignalLoginCredentialEntryState,
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit
) {
TextField(
value = state.accountId,
onValueChange = { onEvent(SignalLoginCredentialEntryScreenEvents.AccountIdChanged(it)) },
label = { Text(stringResource(R.string.SignalLoginCredentialEntryScreen__account_id)) },
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontFamily = MonoTypeface.fontFamily(),
fontSize = 18.sp,
letterSpacing = 1.44.sp
),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
errorContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Next,
autoCorrectEnabled = false
),
supportingText = {
when (val error = state.accountIdError) {
is AccountIdError.TooLong -> Text(stringResource(R.string.SignalLoginCredentialEntryScreen__too_long, error.count, SignalLoginCredentialEntryState.ACCOUNT_ID_LENGTH))
is AccountIdError.Invalid -> Text(stringResource(R.string.SignalLoginCredentialEntryScreen__invalid_account_id))
null -> {}
}
},
isError = state.accountIdError != null || state.areCredentialsIncorrect,
visualTransformation = AccountIdVisualTransformation,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_ACCOUNT_ID_FIELD)
)
}
@Composable
private fun RecoveryKeyTextField(
state: SignalLoginCredentialEntryState,
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit
) {
val keyboardController = LocalSoftwareKeyboardController.current
val autoFillHelper = passwordAutoFillHelper { onEvent(SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged(it)) }
val revealed = state.isRecoveryKeyRevealed
val visualTransformation = remember(revealed) {
if (revealed) AepVisualTransformation(RECOVERY_KEY_CHUNK_LENGTH) else PasswordVisualTransformation()
}
TextField(
value = state.recoveryKey.enteredText,
onValueChange = {
onEvent(SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged(it))
autoFillHelper.onValueChanged(it)
},
label = { Text(stringResource(R.string.SignalLoginCredentialEntryScreen__recovery_key)) },
singleLine = !revealed,
minLines = if (revealed) 3 else 1,
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontFamily = MonoTypeface.fontFamily(),
lineHeight = 36.sp
),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
errorContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onDone = {
if (state.isNextEnabled) {
keyboardController?.hide()
onEvent(SignalLoginCredentialEntryScreenEvents.NextClicked)
}
}
),
trailingIcon = { RevealRecoveryKeyButton(revealed, onEvent) },
supportingText = {
val error = state.recoveryKey.error
when {
state.areCredentialsIncorrect -> Text(stringResource(R.string.SignalLoginCredentialEntryScreen__incorrect_account_id_or_recovery_key))
error is AepValidationError.TooLong -> Text(stringResource(R.string.EnterAepScreen__too_long, error.count, error.max))
error != null -> Text(stringResource(R.string.EnterAepScreen__invalid_recovery_key))
}
},
isError = state.recoveryKey.error != null || state.areCredentialsIncorrect,
visualTransformation = visualTransformation,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_RECOVERY_KEY_FIELD)
.attachPasswordAutoFillHelper(autoFillHelper)
)
}
@Composable
private fun RevealRecoveryKeyButton(revealed: Boolean, onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit) {
IconButton(
onClick = { onEvent(SignalLoginCredentialEntryScreenEvents.RecoveryKeyVisibilityToggled) },
modifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_REVEAL_RECOVERY_KEY_BUTTON)
) {
Icon(
painter = if (revealed) SignalIcons.VisibleSlash.painter else SignalIcons.Visible.painter,
contentDescription = stringResource(
if (revealed) R.string.SignalLoginCredentialEntryScreen__hide_recovery_key else R.string.SignalLoginCredentialEntryScreen__show_recovery_key
)
)
}
}
@Composable
private fun Footer(
params: RegistrationScaffold.Params,
state: SignalLoginCredentialEntryState,
isElevated: Boolean,
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit
) {
RegistrationScaffold.FooterSurface(isElevated = isElevated) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(params.footerPadding)
) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(1f)
) {
NeedHelpButton(onEvent)
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.weight(1f)
) {
NextButton(state, onEvent)
}
}
}
}
@Composable
private fun NeedHelpButton(onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit) {
TextButton(
shape = RoundedCornerShape(0.dp),
onClick = { onEvent(SignalLoginCredentialEntryScreenEvents.NeedHelpClicked) },
modifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEED_HELP_BUTTON)
) {
Text(text = stringResource(R.string.SignalLoginCredentialEntryScreen__need_help))
}
}
@Composable
private fun NextButton(state: SignalLoginCredentialEntryState, onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit) {
Buttons.LargeTonal(
enabled = state.isNextEnabled,
onClick = { onEvent(SignalLoginCredentialEntryScreenEvents.NextClicked) },
modifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON)
) {
if (state.isLoggingIn) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(text = stringResource(R.string.SignalLoginCredentialEntryScreen__next))
}
}
}
/**
* Renders an account ID the way an ACI is normally written: uppercased and split into 8-4-4-4-12 groups by dashes.
* The dashes are display-only, so what the view model sees is always the unformatted ID.
*/
internal object AccountIdVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return TransformedText(
text = AnnotatedString(AccountIdFormat.dashed(text.text).uppercase()),
offsetMapping = AccountIdOffsetMapping(text.length)
)
}
private class AccountIdOffsetMapping(private val inputLength: Int) : OffsetMapping {
override fun originalToTransformed(offset: Int): Int = offset + AccountIdFormat.dashesBeforeRawOffset(offset, inputLength)
override fun transformedToOriginal(offset: Int): Int = offset - AccountIdFormat.dashesBeforeDashedOffset(offset, inputLength)
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginCredentialEntryScreenPreview() {
Previews.Preview {
SignalLoginCredentialEntryScreen(
state = SignalLoginCredentialEntryState(),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginCredentialEntryScreenFilledPreview() {
Previews.Preview {
SignalLoginCredentialEntryScreen(
state = SignalLoginCredentialEntryState(
accountId = "a6b284822e3283d07f2391360a4c2b91",
recoveryKey = AepInput.from("uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t")
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginCredentialEntryScreenRevealedPreview() {
Previews.Preview {
SignalLoginCredentialEntryScreen(
state = SignalLoginCredentialEntryState(
accountId = "a6b284822e3283d07f2391360a4c2b91",
recoveryKey = AepInput.from("uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t"),
isRecoveryKeyRevealed = true
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginCredentialEntryScreenErrorPreview() {
Previews.Preview {
SignalLoginCredentialEntryScreen(
state = SignalLoginCredentialEntryState(
accountId = "a6b284822e3283d07f2391360a4c2b91",
recoveryKey = AepInput.from("uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t"),
areCredentialsIncorrect = true
),
onEvent = {}
)
}
}
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogincredentials
sealed interface SignalLoginCredentialEntryScreenActions {
/** Open the article explaining where to find your Signal Login. */
data object OpenNeedHelpArticle : SignalLoginCredentialEntryScreenActions
}
@@ -0,0 +1,35 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogincredentials
import org.signal.core.util.censor
sealed class SignalLoginCredentialEntryScreenEvents {
/** The user tapped the back arrow. */
data object BackClicked : SignalLoginCredentialEntryScreenEvents()
/** The user edited the account ID field. Carries the raw text, formatting and all. */
data class AccountIdChanged(val value: String) : SignalLoginCredentialEntryScreenEvents() {
override fun toString(): String = "AccountIdChanged(value=${value.censor()})"
}
/** The user edited the recovery key field. Carries the raw text, formatting and all. */
data class RecoveryKeyChanged(val value: String) : SignalLoginCredentialEntryScreenEvents() {
override fun toString(): String = "RecoveryKeyChanged(value=${value.censor()})"
}
/** The user tapped the eye button that switches the recovery key between masked and spelled out. */
data object RecoveryKeyVisibilityToggled : SignalLoginCredentialEntryScreenEvents()
/** The user tapped "Need help?". */
data object NeedHelpClicked : SignalLoginCredentialEntryScreenEvents()
/** The user submitted the login, either with the next button or the keyboard's done action. */
data object NextClicked : SignalLoginCredentialEntryScreenEvents()
/** The user dismissed the login error dialog. */
data object DismissError : SignalLoginCredentialEntryScreenEvents()
}
@@ -0,0 +1,61 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogincredentials
import org.signal.core.util.censor
import org.signal.registration.screens.aepentry.AepInput
/**
* State for the screen where a user who already owns a Signal Login types both halves of it in: the account ID and the
* recovery key that pairs with it.
*
* [accountId] holds the ID without any of the formatting the user sees: the screen renders the dashes and the
* uppercasing itself, so what is stored here is always the raw lowercase value.
*/
data class SignalLoginCredentialEntryState(
val accountId: String = "",
val accountIdError: AccountIdError? = null,
val recoveryKey: AepInput = AepInput(),
/** Whether the recovery key is spelled out rather than masked like a password. */
val isRecoveryKeyRevealed: Boolean = false,
/** The service rejected the pair. Either half could be at fault, so both fields are flagged rather than just one. */
val areCredentialsIncorrect: Boolean = false,
val isLoggingIn: Boolean = false,
val loginError: SignalLoginError? = null
) {
/** Whether both halves of the login are complete and well-formed enough to attempt. */
val isNextEnabled: Boolean
get() = accountId.length == ACCOUNT_ID_LENGTH &&
accountIdError == null &&
recoveryKey.isValid &&
recoveryKey.error == null &&
!areCredentialsIncorrect &&
!isLoggingIn
override fun toString(): String = "SignalLoginCredentialEntryState(accountId=${accountId.censor()}, accountIdError=$accountIdError, recoveryKey=$recoveryKey, isRecoveryKeyRevealed=$isRecoveryKeyRevealed, areCredentialsIncorrect=$areCredentialsIncorrect, isLoggingIn=$isLoggingIn, loginError=$loginError)"
companion object {
/** An account ID is an ACI with its dashes removed, so it is always this many hex characters. */
const val ACCOUNT_ID_LENGTH = 32
}
}
/** Why the entered account ID can't be submitted. Shown beneath the text field rather than in a dialog. */
sealed interface AccountIdError {
/** More than [SignalLoginCredentialEntryState.ACCOUNT_ID_LENGTH] characters were entered. */
data class TooLong(val count: Int) : AccountIdError
/** The entered text contains characters that can't appear in an account ID. */
data object Invalid : AccountIdError
}
/** A login failure that the text fields can't express, so it gets a dialog instead. */
sealed interface SignalLoginError {
data object RateLimited : SignalLoginError
data object NetworkError : SignalLoginError
data object UnknownError : SignalLoginError
}
@@ -0,0 +1,253 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogincredentials
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.ServiceId.ACI
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.network.api.RegistrationApiV2.RegisterAccountError
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import org.signal.registration.RestoreDecision
import org.signal.registration.screens.aepentry.AepInput
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
/**
* View model for [SignalLoginCredentialEntryScreen], where a user who already owns a Signal Login types in both halves
* of it and gets logged back in.
*/
class SignalLoginCredentialEntryViewModel(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<SignalLoginCredentialEntryScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(SignalLoginCredentialEntryViewModel::class)
/** Formatting the user may have pasted along with the account ID, which we accept and discard. */
private val FORMATTING_CHARACTERS = Regex("""[\s-]""")
private fun Char.isAccountIdCharacter(): Boolean = this in '0'..'9' || this in 'a'..'f'
}
private val _state = MutableStateFlow(SignalLoginCredentialEntryState())
val state: StateFlow<SignalLoginCredentialEntryState> = _state.asStateFlow()
private val _actions = Channel<SignalLoginCredentialEntryScreenActions>(Channel.BUFFERED)
val actions: Flow<SignalLoginCredentialEntryScreenActions> = _actions.receiveAsFlow()
init {
_state
.onEach { Log.d(TAG, "[State] $it") }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: SignalLoginCredentialEntryScreenEvents) {
applyEvent(_state.value, event, parentEventEmitter) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(
state: SignalLoginCredentialEntryState,
event: SignalLoginCredentialEntryScreenEvents,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (SignalLoginCredentialEntryState) -> Unit
) {
when (event) {
is SignalLoginCredentialEntryScreenEvents.BackClicked -> {
parentEventEmitter.navigateBack()
}
is SignalLoginCredentialEntryScreenEvents.AccountIdChanged -> {
val accountId = event.value.replace(FORMATTING_CHARACTERS, "").lowercase()
stateEmitter(state.copy(accountId = accountId, accountIdError = validateAccountId(accountId), areCredentialsIncorrect = false))
}
is SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged -> {
stateEmitter(state.copy(recoveryKey = AepInput.from(event.value, state.recoveryKey.error), areCredentialsIncorrect = false))
}
is SignalLoginCredentialEntryScreenEvents.RecoveryKeyVisibilityToggled -> {
stateEmitter(state.copy(isRecoveryKeyRevealed = !state.isRecoveryKeyRevealed))
}
is SignalLoginCredentialEntryScreenEvents.NeedHelpClicked -> {
_actions.trySend(SignalLoginCredentialEntryScreenActions.OpenNeedHelpArticle)
}
is SignalLoginCredentialEntryScreenEvents.DismissError -> {
stateEmitter(state.copy(loginError = null))
}
is SignalLoginCredentialEntryScreenEvents.NextClicked -> {
applyNextClicked(state, parentEventEmitter, stateEmitter)
}
}
}
private suspend fun applyNextClicked(
state: SignalLoginCredentialEntryState,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (SignalLoginCredentialEntryState) -> Unit
) {
val aci = AccountIdFormat.toAciOrNull(state.accountId)
if (aci == null) {
Log.w(TAG, "[Next] The entered account ID isn't a valid ACI.")
stateEmitter(state.copy(accountIdError = AccountIdError.Invalid))
return
}
check(state.recoveryKey.isValid) { "Recovery key is not valid, should not have gotten here." }
val aep = AccountEntropyPool(state.recoveryKey.normalized)
stateEmitter(state.copy(isLoggingIn = true))
parentEventEmitter(RegistrationFlowEvent.UserSuppliedAepSubmitted(aep))
Log.i(TAG, "[Next] Attempting to log in to ${aci.logString()} with the RRP derived from the entered recovery key.")
attemptToLogIn(state, aci, aep, provideRegistrationLock = false, parentEventEmitter, stateEmitter)
}
private suspend fun attemptToLogIn(
inputState: SignalLoginCredentialEntryState,
aci: ACI,
aep: AccountEntropyPool,
provideRegistrationLock: Boolean,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (SignalLoginCredentialEntryState) -> Unit
) {
val masterKey = aep.deriveMasterKey()
val recoveryPassword = masterKey.deriveRegistrationRecoveryPassword()
val registrationLock = masterKey.deriveRegistrationLock().takeIf { provideRegistrationLock }
val result = repository.reRegisterAccountWithoutPhoneNumber(
aci = aci,
recoveryPassword = recoveryPassword,
aep = aep,
registrationLock = registrationLock
)
when (result) {
is RequestResult.Success -> {
Log.i(TAG, "[Next] Successfully logged back in without a phone number.")
val (response, keyMaterial, registeredAci) = result.result
parentEventEmitter(RegistrationFlowEvent.Registered(registeredAci, keyMaterial.accountEntropyPool, response.storageCapable, phoneNumberless = response.e164 == null))
if (response.reregistration) {
val hasRemoteBackup = hasRemoteBackup(aep)
Log.i(TAG, "[Next] Reclaimed an existing account. Letting the user choose how to restore it. hasRemoteBackup: $hasRemoteBackup")
stateEmitter(inputState.copy(isLoggingIn = false))
parentEventEmitter.navigateTo(RegistrationRoute.ArchiveRestoreSelection.forPostRegisterWithKnownAep(aep, hasRemoteBackup))
} else {
Log.i(TAG, "[Next] The service reports a brand new account, so there is nothing to restore. Finishing up.")
repository.setRestoreDecision(RestoreDecision.NEW_ACCOUNT)
repository.restoreAccountRecord()
stateEmitter(inputState.copy(isLoggingIn = false))
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
}
is RequestResult.NonSuccess -> {
when (val error = result.error) {
is RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
Log.w(TAG, "[Next] RRP incorrect. Either the account ID or the recovery key is wrong. Message: ${error.message}")
stateEmitter(inputState.copy(isLoggingIn = false, areCredentialsIncorrect = true))
}
is RegisterAccountError.RegistrationLock -> {
if (provideRegistrationLock) {
Log.w(TAG, "[Next] Still registration locked after providing the reglock token derived from the recovery key. Falling back to PIN entry.")
stateEmitter(inputState.copy(isLoggingIn = false))
parentEventEmitter.navigateTo(
RegistrationRoute.PinEntryForRegistrationLock(
timeRemaining = error.data.timeRemaining,
svrCredentials = error.data.svr2Credentials
)
)
} else {
Log.w(TAG, "[Next] Registration locked. Retrying with the reglock token derived from the recovery key.")
attemptToLogIn(inputState, aci, aep, provideRegistrationLock = true, parentEventEmitter, stateEmitter)
}
}
is RegisterAccountError.RateLimited -> {
Log.w(TAG, "[Next] Rate limited (retryAfter: ${error.retryAfter}).")
stateEmitter(inputState.copy(isLoggingIn = false, loginError = SignalLoginError.RateLimited))
}
is RegisterAccountError.SessionNotFoundOrNotVerified -> {
error("[Next] Session not found or not verified. This should not happen with RRP-based registration.")
}
is RegisterAccountError.DeviceTransferPossible -> {
error("[Next] Device transfer possible. This should not happen with RRP-based registration.")
}
is RegisterAccountError.InvalidRequest,
is RegisterAccountError.InvalidReceiptCredentialPresentation,
RegisterAccountError.TotpMissingOrIncorrect,
RegisterAccountError.PostQuantumRatchetRequired -> {
Log.w(TAG, "[Next] Unexpected registration error: $error")
stateEmitter(inputState.copy(isLoggingIn = false, loginError = SignalLoginError.UnknownError))
}
}
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[Next] Network error.", result.networkError)
stateEmitter(inputState.copy(isLoggingIn = false, loginError = SignalLoginError.NetworkError))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[Next] Application error.", result.cause)
stateEmitter(inputState.copy(isLoggingIn = false, loginError = SignalLoginError.UnknownError))
}
}
}
private suspend fun hasRemoteBackup(aep: AccountEntropyPool): Boolean {
val result = repository.getAndMaybeHealRemoteBackupInfo(aep)
return when {
result is RequestResult.Success -> true
result is RequestResult.NonSuccess && result.error is NetworkController.GetBackupInfoError.NoBackup -> false
else -> {
Log.w(TAG, "[hasRemoteBackup] Could not determine whether a remote backup exists ($result). Offering it anyway.")
true
}
}
}
private fun validateAccountId(accountId: String): AccountIdError? {
return when {
accountId.length > SignalLoginCredentialEntryState.ACCOUNT_ID_LENGTH -> AccountIdError.TooLong(accountId.length)
accountId.any { !it.isAccountIdCharacter() } -> AccountIdError.Invalid
else -> null
}
}
class Factory(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return SignalLoginCredentialEntryViewModel(repository, parentEventEmitter) as T
}
}
}
@@ -89,7 +89,7 @@ class SignalLoginPaymentViewModel(
localState = applyManualReceiptCredentialSubmitted(localState, parentEventEmitter)
stateEmitter(localState.copy(showSpinner = false))
} else if (state.selectedOption == SignalLoginPaymentState.Option.ExistingLogin) {
parentEventEmitter.navigateTo(RegistrationRoute.SignalLogin)
parentEventEmitter.navigateTo(RegistrationRoute.SignalLoginCredentialEntry)
} else {
// TODO [phonenumberless] Launch the purchase flow.
Log.i(TAG, "Continue clicked for ${state.selectedOption}, but the purchase flow isn't implemented yet.")
@@ -134,7 +134,7 @@ class SignalLoginPaymentViewModel(
Log.i(TAG, "[ManualReceipt] Successfully registered without a phone number.")
val (response, keyMaterial, aci) = result.result
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable))
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable, phoneNumberless = response.e164 == null))
parentEventEmitter.navigateTo(RegistrationRoute.SignalLoginInfo)
state
}
@@ -372,7 +372,7 @@ class VerificationCodeViewModel(
is RequestResult.Success -> {
val (response, keyMaterial, aci) = registerResult.result
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable))
parentEventEmitter(RegistrationFlowEvent.Registered(aci, keyMaterial.accountEntropyPool, response.storageCapable, phoneNumberless = response.e164 == null))
val pendingRestore = pendingRestoreNavigation()
when {
@@ -57,11 +57,13 @@ object TestTags {
const val SIGNAL_LOGIN_PAYMENT_CONTINUE_BUTTON = "signal_login_payment_continue_button"
const val SIGNAL_LOGIN_PAYMENT_RECEIPT_CREDENTIAL_FIELD = "signal_login_payment_receipt_credential_field"
// Signal Login Screen
const val SIGNAL_LOGIN_SCREEN = "signal_login_screen"
const val SIGNAL_LOGIN_ACCOUNT_KEY_FIELD = "signal_login_account_key_field"
const val SIGNAL_LOGIN_NEED_HELP_BUTTON = "signal_login_need_help_button"
const val SIGNAL_LOGIN_NEXT_BUTTON = "signal_login_next_button"
// Signal Login Credential Entry Screen
const val SIGNAL_LOGIN_CREDENTIAL_ENTRY_SCREEN = "signal_login_credential_entry_screen"
const val SIGNAL_LOGIN_CREDENTIAL_ACCOUNT_ID_FIELD = "signal_login_credential_account_id_field"
const val SIGNAL_LOGIN_CREDENTIAL_RECOVERY_KEY_FIELD = "signal_login_credential_recovery_key_field"
const val SIGNAL_LOGIN_CREDENTIAL_REVEAL_RECOVERY_KEY_BUTTON = "signal_login_credential_reveal_recovery_key_button"
const val SIGNAL_LOGIN_CREDENTIAL_NEED_HELP_BUTTON = "signal_login_credential_need_help_button"
const val SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON = "signal_login_credential_next_button"
// Signal Login Info Screen
const val SIGNAL_LOGIN_INFO_SCREEN = "signal_login_info_screen"
@@ -242,6 +242,10 @@
<string name="EnterAepScreen__restore_to_new_account_body">The backup you are restoring was created using a different Signal account. You can restore this backup to a new account, but you will no longer be a member of any groups that were restored.</string>
<!-- Confirm button label for the restore to new account dialog -->
<string name="EnterAepScreen__restore">Restore</string>
<!-- Title of the dialog shown when the entered recovery key is correct, but the account has no Signal Backup to restore -->
<string name="EnterAepScreen__no_backup_found">No backup found</string>
<!-- Body of the dialog shown when the entered recovery key is correct, but the account has no Signal Backup to restore -->
<string name="EnterAepScreen__no_backup_found_body">Your recovery key is correct, but there\'s no Signal Backup for this account. Go back to choose another way to restore, or continue without restoring.</string>
<!-- Text shown while preparing the restore -->
<string name="LocalBackupRestoreScreen__preparing_restore">Preparing restore…</string>
<!-- Title shown while restore is in progress -->
@@ -654,23 +658,29 @@
<!-- Error shown when the Signal Login could not be stored in the password manager. -->
<string name="SignalLoginInfoScreen__your_signal_login_could_not_be_saved">Your Signal Login could not be saved. Please save it manually instead.</string>
<!-- Signal Login account key entry screen -->
<!-- Title of the screen where a user with an existing Signal Login types in their account key. -->
<string name="SignalLoginScreen__signal_login">Signal Login</string>
<!-- Signal Login credential entry screen -->
<!-- Title of the screen where a user with an existing Signal Login types it in to log back in. -->
<string name="SignalLoginCredentialEntryScreen__signal_login">Signal Login</string>
<!-- Description telling the user what to enter to log in. -->
<string name="SignalLoginScreen__enter_your_32_character_account_key">Enter your 32-character account key to get started.</string>
<!-- Label of the text field the account key is typed into. -->
<string name="SignalLoginScreen__account_key">Account key</string>
<!-- Button that opens a support article for users who can\'t find their account key. -->
<string name="SignalLoginScreen__need_help">Need help?</string>
<!-- Action button that submits the entered account key. -->
<string name="SignalLoginScreen__next">Next</string>
<!-- Error shown when too many characters were entered. First placeholder is how many were entered, second is the maximum. -->
<string name="SignalLoginScreen__too_long">Too long. %1$d/%2$d characters.</string>
<!-- Error shown when the entered account key contains characters that can\'t appear in one. -->
<string name="SignalLoginScreen__invalid_account_key">Invalid account key</string>
<!-- Error shown when the service doesn\'t recognize the entered account key. -->
<string name="SignalLoginScreen__incorrect_account_key">Incorrect account key</string>
<string name="SignalLoginCredentialEntryScreen__enter_your_account_id_followed_by_your_recovery_key">Enter your account ID followed by your recovery key to restore your account.</string>
<!-- Label of the text field the account ID is typed into. -->
<string name="SignalLoginCredentialEntryScreen__account_id">Account ID</string>
<!-- Label of the text field the recovery key is typed into. -->
<string name="SignalLoginCredentialEntryScreen__recovery_key">Recovery key</string>
<!-- Description of the button that spells out the typed recovery key instead of masking it. -->
<string name="SignalLoginCredentialEntryScreen__show_recovery_key">Show recovery key</string>
<!-- Description of the button that masks the typed recovery key. -->
<string name="SignalLoginCredentialEntryScreen__hide_recovery_key">Hide recovery key</string>
<!-- Button that opens a support article for users who can\'t find their Signal Login. -->
<string name="SignalLoginCredentialEntryScreen__need_help">Need help?</string>
<!-- Action button that submits the entered Signal Login. -->
<string name="SignalLoginCredentialEntryScreen__next">Next</string>
<!-- Error shown when too many characters were entered in the account ID field. First placeholder is how many were entered, second is the maximum. -->
<string name="SignalLoginCredentialEntryScreen__too_long">Too long. %1$d/%2$d characters.</string>
<!-- Error shown when the entered account ID contains characters that can\'t appear in one. -->
<string name="SignalLoginCredentialEntryScreen__invalid_account_id">Invalid account ID</string>
<!-- Error shown when the service rejects the entered login, which means one of the two halves is wrong. -->
<string name="SignalLoginCredentialEntryScreen__incorrect_account_id_or_recovery_key">Incorrect account ID or recovery key</string>
<!-- Add username screen -->
<!-- Title of the screen offering the user an optional username. -->
@@ -0,0 +1,51 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogincredentials
import androidx.compose.runtime.Composable
import com.android.tools.screenshot.PreviewTest
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.ScreenshotPreviews
import org.signal.registration.screens.aepentry.AepInput
class SignalLoginCredentialEntryScreenScreenshotTests {
companion object {
private const val ACCOUNT_ID = "a6b284822e3283d07f2391360a4c2b91"
private const val RECOVERY_KEY = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t"
}
@PreviewTest
@ScreenshotPreviews
@Composable
fun SignalLoginCredentialEntryScreenPreview() {
Previews.Preview {
SignalLoginCredentialEntryScreen(
state = SignalLoginCredentialEntryState(
accountId = ACCOUNT_ID,
recoveryKey = AepInput.from(RECOVERY_KEY)
),
onEvent = {}
)
}
}
@PreviewTest
@ScreenshotPreviews
@Composable
fun SignalLoginCredentialEntryScreenRevealedPreview() {
Previews.Preview {
SignalLoginCredentialEntryScreen(
state = SignalLoginCredentialEntryState(
accountId = ACCOUNT_ID,
recoveryKey = AepInput.from(RECOVERY_KEY),
isRecoveryKeyRevealed = true
),
onEvent = {}
)
}
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4cd1462cb348709863f0eac0cdb2be92308c9d7e8eec2721562f60c231f5a8a7
size 87610
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6181b64094fb0da1c3f59acdcc46c958c09fc0ff2e61d5a246c2172938f2a3e5
size 87700
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1a3a2ca46117e189d678857e3a4fe7ac0092b6b8ca5d191e464c11477409acbd
size 88170
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e8f90ce0441a30e396c64283de3e0caa478ec3796c77727d1c2d5345103ad86
size 44668
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4f9b7d98b41d6e99eca4f0a83ba7bf61c5c0e21eeba80cca8d443cae961387a9
size 65630
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:636f75a3b0fd5403f868c4a7c1887ba8612565832f28cdd43c7eee9192b4a211
size 66127
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8b29584319ef35c3d61803089206ea0bd865256f12720a6f9bac4e1b92888a8f
size 70983
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e318874b37a907aa1f6dd59578e454ad705fcf6a002ff7a2315bc6eb5aed037a
size 95639
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b99f4a69b489277df80dc1c71a9d3bcfa46e96d9673dfd2027803d37fecd8e8c
size 96116
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8f2150fbe9db06bec98f0bbd693731a25ada12f11a368afae6dd0c7856586366
size 92499
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:31ee6385b9fda0b2b56f7ec6feeaf78867e6708e1da46fff82659e6771138ef6
size 111836
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:98d1ae5efd5aa61dcf3a3a93f9dbfca6e3833e56e7763508e7586c11099f9b43
size 111868
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0ff8d8d2d222d7de4de1d55fda4129eb6d6ff92f4c2e253f5b74925079f2dc96
size 112086
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e8f90ce0441a30e396c64283de3e0caa478ec3796c77727d1c2d5345103ad86
size 44668
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a10e916d1e7438df204ddd736fac222d69aac90674e541529e2fca06776a2ab1
size 87932
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d03495dad3d164fd1f0321904f0341c1803914c9cfa4770c8f152fe586956085
size 88181
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1a7689d4db7c4a674972a7611b716f69616b0c2203c644c8fca43af93944aaed
size 92504
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9d25fbc34aa1eb3aa61f015f3203bc00b963488e43286a0649dd38036b8e1a78
size 118159
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2dd6791f21d969a27078997ac7eb17f2647b836a3d03caf2b897eddd8ff050be
size 118334
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d45c15675a4c6bd7847421fd7006dbd0cdda2f5f2a6bb73b2eb450bdb6876e05
size 113490
@@ -154,6 +154,24 @@ class PersistedFlowStateTest {
assertThat(decoded).isEqualTo(state)
}
@Test
fun `round-trip serialization with SignalLoginCredentialEntry`() {
val state = PersistedFlowState(
backStack = listOf(
RegistrationRoute.Welcome,
RegistrationRoute.SignalLoginCredentialEntry
),
sessionMetadata = null,
sessionE164 = null,
doNotAttemptRecoveryPassword = false
)
val encoded = json.encodeToString(PersistedFlowState.serializer(), state)
val decoded = json.decodeFromString(PersistedFlowState.serializer(), encoded)
assertThat(decoded).isEqualTo(state)
}
@Test
fun `deserialization ignores unknown keys for forward compatibility`() {
val validJson = """{"backStack":[{"type":"org.signal.registration.RegistrationRoute.Welcome"}],"sessionMetadata":null,"sessionE164":null,"doNotAttemptRecoveryPassword":false,"unknownField":"value"}"""
@@ -183,6 +201,7 @@ class PersistedFlowStateTest {
accountEntropyPool = AccountEntropyPool.generate(),
aci = ACI.from(UUID.fromString("3f8b6a90-8f9c-4a3e-9c7d-1f2e3a4b5c6d")),
storageCapable = true,
isPhoneNumberlessAccount = true,
temporaryMasterKey = MasterKey(ByteArray(32)),
doNotAttemptRecoveryPassword = true,
lastSmsVerificationCodeRequest = VerificationCodeRequest("+15551234567", 12_345L),
@@ -198,6 +217,7 @@ class PersistedFlowStateTest {
assertThat(persisted.aci).isEqualTo("3f8b6a90-8f9c-4a3e-9c7d-1f2e3a4b5c6d")
assertThat(persisted.doNotAttemptRecoveryPassword).isEqualTo(true)
assertThat(persisted.storageCapable).isEqualTo(true)
assertThat(persisted.phoneNumberlessAccount).isEqualTo(true)
assertThat(persisted.smsVerificationCodeRequest).isEqualTo(VerificationCodeRequest("+15551234567", 12_345L))
assertThat(persisted.callVerificationCodeRequest).isEqualTo(VerificationCodeRequest("+15551234567", 23_456L))
}
@@ -222,6 +242,7 @@ class PersistedFlowStateTest {
aci = "3f8b6a90-8f9c-4a3e-9c7d-1f2e3a4b5c6d",
doNotAttemptRecoveryPassword = true,
storageCapable = true,
phoneNumberlessAccount = true,
smsVerificationCodeRequest = VerificationCodeRequest("+15551234567", 12_345L),
callVerificationCodeRequest = VerificationCodeRequest("+15551234567", 23_456L)
)
@@ -245,6 +266,7 @@ class PersistedFlowStateTest {
assertThat(flowState.preExistingRegistrationData).isNull()
assertThat(flowState.doNotAttemptRecoveryPassword).isEqualTo(true)
assertThat(flowState.storageCapable).isEqualTo(true)
assertThat(flowState.isPhoneNumberlessAccount).isEqualTo(true)
assertThat(flowState.lastSmsVerificationCodeRequest).isEqualTo(VerificationCodeRequest("+15551234567", 12_345L))
assertThat(flowState.lastCallVerificationCodeRequest).isEqualTo(VerificationCodeRequest("+15551234567", 23_456L))
}
@@ -16,11 +16,14 @@ import assertk.assertions.isTrue
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.junit.Before
import org.junit.Test
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.network.api.RegistrationApiV2.SvrCredentials
import org.signal.registration.NetworkController.GetBackupInfoError
import org.signal.registration.NetworkController.GetBackupInfoResponse
@@ -30,7 +33,9 @@ import org.signal.registration.NetworkController.RestoreMasterKeyError
import org.signal.registration.fakes.FakeNetworkController
import org.signal.registration.fakes.FakeStorageController
import org.signal.registration.fakes.SystemOutLogger
import org.signal.registration.proto.AccountData
import java.io.IOException
import java.util.UUID
import kotlin.time.Duration.Companion.seconds
/**
@@ -42,6 +47,7 @@ class RegistrationRepositoryTest {
private lateinit var networkController: FakeNetworkController
private lateinit var storageController: FakeStorageController
private lateinit var repository: RegistrationRepository
private lateinit var numberlessRepository: RegistrationRepository
private val aep = AccountEntropyPool.generate()
private val masterKey = aep.deriveMasterKey()
@@ -59,6 +65,13 @@ class RegistrationRepositoryTest {
storageController = storageController,
isLinkAndSyncAvailable = false
)
numberlessRepository = RegistrationRepository(
context = mockk<Context>(relaxed = true),
networkController = networkController,
storageController = storageController,
isLinkAndSyncAvailable = false,
isPhoneNumberlessRegistrationAvailable = true
)
}
// ==================== getAndMaybeHealRemoteBackupInfo ====================
@@ -190,4 +203,95 @@ class RegistrationRepositoryTest {
assertThat(storageController.committedData).isNull()
assertThat(storageController.readInProgressRegistrationData().pin).isEmpty()
}
// ==================== registerAccountWithoutPhoneNumber / reRegisterAccountWithoutPhoneNumber ====================
@Test
fun `reRegisterAccountWithoutPhoneNumber sends PNI key material when recovering an account by ACI`() = runTest {
networkController.onRegisterAccount = { RequestResult.Success(networkController.registerAccountResponse(e164 = null)) }
val result = numberlessRepository.reRegisterAccountWithoutPhoneNumber(
aci = ACI.from(UUID.randomUUID()),
recoveryPassword = masterKey.deriveRegistrationRecoveryPassword(),
aep = aep
)
assertThat(result).isInstanceOf(RequestResult.Success::class)
val pniPreKeys = networkController.lastRegisterAccountRequest?.pniPreKeys
assertThat(pniPreKeys).isNotNull()
assertThat(networkController.lastRegisterAccountRequest?.pniRegistrationId).isNotNull()
assertThat(pniPreKeys!!.identityKey.publicKey.verifySignature(pniPreKeys.signedPreKey.keyPair.publicKey.serialize(), pniPreKeys.signedPreKey.signature)).isTrue()
assertThat(pniPreKeys.identityKey.publicKey.verifySignature(pniPreKeys.lastResortKyberPreKey.keyPair.publicKey.serialize(), pniPreKeys.lastResortKyberPreKey.signature)).isTrue()
}
@Test
fun `reRegisterAccountWithoutPhoneNumber does not keep the PNI key material it sends when recovering an account by ACI`() = runTest {
networkController.onRegisterAccount = { RequestResult.Success(networkController.registerAccountResponse(e164 = null)) }
numberlessRepository.reRegisterAccountWithoutPhoneNumber(
aci = ACI.from(UUID.randomUUID()),
recoveryPassword = masterKey.deriveRegistrationRecoveryPassword(),
aep = aep
)
val accountData = storageController.committedData?.accountData
assertThat(accountData).isNotNull()
assertThat(accountData!!.pniIdentityKeyPair.size).isEqualTo(0)
assertThat(accountData.pniRegistrationId).isEqualTo(0)
}
@Test
fun `reRegisterAccountWithoutPhoneNumber keeps the PNI key material it sends when the reclaimed account has a phone number`() = runTest {
networkController.onRegisterAccount = { RequestResult.Success(networkController.registerAccountResponse(e164 = "+15551234567")) }
numberlessRepository.reRegisterAccountWithoutPhoneNumber(
aci = ACI.from(UUID.randomUUID()),
recoveryPassword = masterKey.deriveRegistrationRecoveryPassword(),
aep = aep
)
val sentPniPreKeys = networkController.lastRegisterAccountRequest!!.pniPreKeys!!
val accountData = storageController.committedData?.accountData
assertThat(accountData).isNotNull()
assertThat(IdentityKeyPair(accountData!!.pniIdentityKeyPair.toByteArray()).publicKey).isEqualTo(sentPniPreKeys.identityKey)
assertThat(accountData.pniRegistrationId).isEqualTo(networkController.lastRegisterAccountRequest!!.pniRegistrationId)
}
@Test
fun `reRegisterAccountWithoutPhoneNumber clears PNI key material left behind by an abandoned attempt`() = runTest {
networkController.onRegisterAccount = { RequestResult.Success(networkController.registerAccountResponse(e164 = null)) }
storageController.updateInProgressRegistrationData {
accountData = AccountData(
pniIdentityKeyPair = IdentityKeyPair.generate().serialize().toByteString(),
pniSignedPreKey = "abandoned-signed-pre-key".toByteArray().toByteString(),
pniLastResortKyberPreKey = "abandoned-kyber-pre-key".toByteArray().toByteString(),
pniRegistrationId = 1234
)
}
numberlessRepository.reRegisterAccountWithoutPhoneNumber(
aci = ACI.from(UUID.randomUUID()),
recoveryPassword = masterKey.deriveRegistrationRecoveryPassword(),
aep = aep
)
val accountData = storageController.committedData?.accountData
assertThat(accountData).isNotNull()
assertThat(accountData!!.pniIdentityKeyPair.size).isEqualTo(0)
assertThat(accountData.pniSignedPreKey.size).isEqualTo(0)
assertThat(accountData.pniLastResortKyberPreKey.size).isEqualTo(0)
assertThat(accountData.pniRegistrationId).isEqualTo(0)
}
@Test
fun `registerAccountWithoutPhoneNumber does not send PNI key material when creating a brand new account`() = runTest {
networkController.onRegisterAccount = { RequestResult.Success(networkController.registerAccountResponse(e164 = null)) }
val result = numberlessRepository.registerAccountWithoutPhoneNumber(receiptCredentialPresentation = mockk(relaxed = true))
assertThat(result).isInstanceOf(RequestResult.Success::class)
assertThat(networkController.lastRegisterAccountRequest?.pniPreKeys).isNull()
assertThat(networkController.lastRegisterAccountRequest?.pniRegistrationId).isNull()
}
}
@@ -286,7 +286,7 @@ class RegistrationViewModelTest {
val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle())
advanceUntilIdle()
viewModel.onEvent(RegistrationFlowEvent.Registered(ACI.from(UUID.randomUUID()), AccountEntropyPool.generate(), storageCapable = false))
viewModel.onEvent(RegistrationFlowEvent.Registered(ACI.from(UUID.randomUUID()), AccountEntropyPool.generate(), storageCapable = false, phoneNumberless = false))
advanceUntilIdle()
coVerify(exactly = 0) { mockRepository.saveFlowState(any()) }
@@ -520,6 +520,27 @@ class RegistrationViewModelTest {
assertThat(result.backStack).isEqualTo(listOf(remoteRestore))
}
@Test
fun `applyEvent NavigateToScreen RemoteRestore entered from restore selection keeps backStack`() = runTest(testDispatcher) {
coEvery { mockRepository.restoreFlowState() } returns null
coEvery { mockRepository.getPreExistingRegistrationData() } returns null
val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle())
advanceUntilIdle()
val restoreSelection = RegistrationRoute.ArchiveRestoreSelection.forPostRegisterWithKnownAep(AccountEntropyPool.generate(), hasRemoteBackup = true)
val initialState = RegistrationFlowState(backStack = listOf(restoreSelection))
val remoteRestore = RegistrationRoute.RemoteRestore(AccountEntropyPool.generate(), backwardNavigationAllowed = true)
val result = viewModel.applyEvent(
initialState,
RegistrationFlowEvent.NavigateToScreen(remoteRestore)
)
assertThat(result.backStack).isEqualTo(listOf(restoreSelection, remoteRestore))
}
@Test
fun `applyEvent NavigateToScreen post-registration ArchiveRestoreSelection clears backStack`() = runTest(testDispatcher) {
coEvery { mockRepository.restoreFlowState() } returns null
@@ -738,7 +759,7 @@ class RegistrationViewModelTest {
val result = viewModel.applyEvent(
RegistrationFlowState(),
RegistrationFlowEvent.Registered(aci, aep, storageCapable = true)
RegistrationFlowEvent.Registered(aci, aep, storageCapable = true, phoneNumberless = false)
)
assertThat(result.aci).isEqualTo(aci)
@@ -87,7 +87,7 @@ class FakeNetworkController(
}
data class UpdateSessionRequest(val sessionId: String?, val pushChallengeToken: String?, val captchaToken: String?)
data class RegisterAccountRequest(val e164: String?, val sessionId: String?, val recoveryPassword: String?, val registrationLock: String?)
data class RegisterAccountRequest(val e164: String?, val sessionId: String?, val recoveryPassword: String?, val registrationLock: String?, val aci: ACI? = null, val pniPreKeys: PreKeyCollection? = null, val pniRegistrationId: Int? = null)
data class SetPinRequest(val pin: String, val masterKey: MasterKey)
data class RestoreMasterKeyRequest(val svrCredentials: SvrCredentials, val pin: String)
data class SetRestoreMethodRequest(val token: String, val method: RestoreMethod)
@@ -275,7 +275,8 @@ class FakeNetworkController(
): RegisterAccountResponse {
return RegisterAccountResponse(
aci = UUID.randomUUID().toString(),
pni = UUID.randomUUID().toString(),
// An account with no phone number has no PNI, exactly as the service reports it.
pni = if (e164 != null) UUID.randomUUID().toString() else null,
e164 = e164,
usernameHash = null,
usernameLinkHandle = null,
@@ -327,9 +328,10 @@ class FakeNetworkController(
aciPreKeys: PreKeyCollection,
pniPreKeys: PreKeyCollection?,
fcmToken: String?,
skipDeviceTransfer: Boolean
skipDeviceTransfer: Boolean,
aci: ACI?
): RequestResult<RegisterAccountResponse, RegisterAccountError> {
val request = RegisterAccountRequest(e164, sessionId, recoveryPassword, attributes.registrationLock)
val request = RegisterAccountRequest(e164, sessionId, recoveryPassword, attributes.registrationLock, aci, pniPreKeys, attributes.pniRegistrationId)
lastRegisterAccountRequest = request
return onRegisterAccount(request)
}
@@ -92,7 +92,7 @@ class EnterAepForLocalBackupViewModelTest {
@Test
fun `Submit without requiring registration hands the key back and navigates back`() = runTest {
val viewModel = createViewModel(isPreRegistration = false)
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter)
@@ -107,13 +107,13 @@ class EnterAepForLocalBackupViewModelTest {
@Test
fun `Submit with a key that cannot decrypt the backup shows an inline error without registering`() = runTest {
val viewModel = createViewModel()
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.verifyLocalBackupKey(any(), any()) } returns false
viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter)
assertThat(emittedStates.last().aepValidationError).isEqualTo(AepValidationError.Incorrect)
assertThat(emittedStates.last().recoveryKey.error).isEqualTo(AepValidationError.Incorrect)
assertThat(emittedStates.last().isRegistering).isEqualTo(false)
coVerify(exactly = 0) { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) }
}
@@ -126,7 +126,7 @@ class EnterAepForLocalBackupViewModelTest {
io.mockk.every { accountEntropyPool } returns aep
}
val mockResponse = mockk<RegisterAccountResponse>(relaxed = true)
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.verifyLocalBackupKey(any(), any()) } returns true
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
@@ -144,7 +144,7 @@ class EnterAepForLocalBackupViewModelTest {
@Test
fun `Submit with RegistrationRecoveryPasswordIncorrect shows the different account dialog`() = runTest {
val viewModel = createViewModel()
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.verifyLocalBackupKey(any(), any()) } returns true
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
@@ -162,7 +162,7 @@ class EnterAepForLocalBackupViewModelTest {
@Test
fun `ConfirmDifferentAccountRestore forces the session path and hands control back for SMS verification`() = runTest {
val viewModel = createViewModel()
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true, showDifferentAccountDialog = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP), showDifferentAccountDialog = true)
viewModel.applyEvent(initialState, EnterAepEvents.ConfirmDifferentAccountRestore, stateEmitter)
@@ -176,7 +176,7 @@ class EnterAepForLocalBackupViewModelTest {
@Test
fun `DismissDifferentAccountDialog clears the dialog`() = runTest {
val viewModel = createViewModel()
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true, showDifferentAccountDialog = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP), showDifferentAccountDialog = true)
viewModel.applyEvent(initialState, EnterAepEvents.DismissDifferentAccountDialog, stateEmitter)
@@ -192,7 +192,7 @@ class EnterAepForLocalBackupViewModelTest {
io.mockk.every { accountEntropyPool } returns aep
}
val mockResponse = mockk<RegisterAccountResponse>(relaxed = true)
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
val registrationLockData = RegistrationLockResponse(
timeRemaining = 86400000L,
svr2Credentials = SvrCredentials(username = "test-username", password = "test-password")
@@ -217,7 +217,7 @@ class EnterAepForLocalBackupViewModelTest {
@Test
fun `Submit with RegistrationLock when already providing the reglock token navigates to PinEntryForRegistrationLock`() = runTest {
val viewModel = createViewModel()
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
val registrationLockData = RegistrationLockResponse(
timeRemaining = 86400000L,
svr2Credentials = SvrCredentials(username = "test-username", password = "test-password")
@@ -241,7 +241,7 @@ class EnterAepForLocalBackupViewModelTest {
@Test
fun `Submit with RateLimited sets registrationError to RateLimited`() = runTest {
val viewModel = createViewModel()
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.verifyLocalBackupKey(any(), any()) } returns true
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
@@ -258,7 +258,7 @@ class EnterAepForLocalBackupViewModelTest {
@Test
fun `Submit with RetryableNetworkError sets registrationError to NetworkError`() = runTest {
val viewModel = createViewModel()
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.verifyLocalBackupKey(any(), any()) } returns true
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
@@ -55,14 +55,14 @@ class EnterAepForRemoteBackupPostRegistrationViewModelTest {
viewModel.applyEvent(EnterAepState(), EnterAepEvents.BackupKeyChanged(VALID_AEP), stateEmitter)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().backupKey).isEqualTo(VALID_AEP)
assertThat(emittedStates.last().recoveryKey.normalized).isEqualTo(VALID_AEP)
}
// ==================== Submit Tests ====================
@Test
fun `Submit with verified key emits UserSuppliedAepSubmitted then NavigateToScreen with RemoteRestore`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.verifyBackupKeyAssociatedWithAccount(any()) } returns RequestResult.Success(Unit)
viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter)
@@ -81,7 +81,7 @@ class EnterAepForRemoteBackupPostRegistrationViewModelTest {
@Test
fun `Submit sets isRegistering true before verification then false`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.verifyBackupKeyAssociatedWithAccount(any()) } returns RequestResult.Success(Unit)
viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter)
@@ -92,32 +92,33 @@ class EnterAepForRemoteBackupPostRegistrationViewModelTest {
}
@Test
fun `Submit with incorrect key sets aepValidationError and does not navigate`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
fun `Submit with incorrect key flags the recovery key and does not navigate`() = runTest {
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.verifyBackupKeyAssociatedWithAccount(any()) } returns
RequestResult.NonSuccess(NetworkController.VerifyBackupKeyError.IncorrectKey)
viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter)
assertThat(emittedParentEvents).isEmpty()
assertThat(emittedStates.last().aepValidationError).isEqualTo(AepValidationError.Incorrect)
assertThat(emittedStates.last().recoveryKey.error).isEqualTo(AepValidationError.Incorrect)
}
@Test
fun `Submit with no backup is treated as an incorrect key and does not navigate`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
fun `Submit with no backup sets NoRemoteBackup rather than marking the key incorrect`() = runTest {
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.verifyBackupKeyAssociatedWithAccount(any()) } returns
RequestResult.NonSuccess(NetworkController.VerifyBackupKeyError.NoBackup)
viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter)
assertThat(emittedParentEvents).isEmpty()
assertThat(emittedStates.last().aepValidationError).isEqualTo(AepValidationError.Incorrect)
assertThat(emittedStates.last().registrationError).isEqualTo(RegistrationError.NoRemoteBackup)
assertThat(emittedStates.last().recoveryKey.error).isNull()
}
@Test
fun `Submit with rate limited sets registrationError and does not navigate`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.verifyBackupKeyAssociatedWithAccount(any()) } returns
RequestResult.NonSuccess(NetworkController.VerifyBackupKeyError.RateLimited(30.seconds))
@@ -129,7 +130,7 @@ class EnterAepForRemoteBackupPostRegistrationViewModelTest {
@Test
fun `Submit with network error sets registrationError and does not navigate`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.verifyBackupKeyAssociatedWithAccount(any()) } returns
RequestResult.RetryableNetworkError(IOException("network"))
@@ -66,7 +66,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModelTest {
viewModel.applyEvent(initialState, EnterAepEvents.BackupKeyChanged(VALID_AEP), stateEmitter)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().backupKey).isEqualTo(VALID_AEP)
assertThat(emittedStates.last().recoveryKey.normalized).isEqualTo(VALID_AEP)
}
// ==================== Submit Success Tests ====================
@@ -78,7 +78,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModelTest {
io.mockk.every { accountEntropyPool } returns aep
}
val mockResponse = mockk<RegisterAccountResponse>(relaxed = true)
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
RequestResult.Success(RegisteredAccountData(mockResponse, mockKeyMaterial, testAci))
@@ -101,7 +101,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModelTest {
io.mockk.every { accountEntropyPool } returns aep
}
val mockResponse = mockk<RegisterAccountResponse>(relaxed = true)
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
RequestResult.Success(RegisteredAccountData(mockResponse, mockKeyMaterial, testAci))
@@ -116,8 +116,8 @@ class EnterAepForRemoteBackupPreRegistrationViewModelTest {
// ==================== Submit Error Tests ====================
@Test
fun `Submit with RegistrationRecoveryPasswordIncorrect sets registrationError and aepValidationError`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
fun `Submit with RegistrationRecoveryPasswordIncorrect sets registrationError and flags the recovery key`() = runTest {
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
RequestResult.NonSuccess(
@@ -127,13 +127,13 @@ class EnterAepForRemoteBackupPreRegistrationViewModelTest {
viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter)
assertThat(emittedStates.last().registrationError).isEqualTo(RegistrationError.IncorrectRecoveryPassword)
assertThat(emittedStates.last().aepValidationError).isEqualTo(AepValidationError.Incorrect)
assertThat(emittedStates.last().recoveryKey.error).isEqualTo(AepValidationError.Incorrect)
assertThat(emittedStates.last().isRegistering).isEqualTo(false)
}
@Test
fun `Submit with InvalidRequest sets registrationError to UnknownError`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
RequestResult.NonSuccess(
@@ -153,7 +153,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModelTest {
io.mockk.every { accountEntropyPool } returns aep
}
val mockResponse = mockk<RegisterAccountResponse>(relaxed = true)
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
val registrationLockData = RegistrationLockResponse(
timeRemaining = 86400000L,
svr2Credentials = SvrCredentials(username = "test-username", password = "test-password")
@@ -182,7 +182,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModelTest {
@Test
fun `Submit with RegistrationLock when already providing the reglock token navigates to PinEntryForRegistrationLock`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
val svrCredentials = SvrCredentials(username = "test-username", password = "test-password")
val registrationLockData = RegistrationLockResponse(
timeRemaining = 86400000L,
@@ -206,7 +206,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModelTest {
@Test
fun `Submit with RateLimited sets registrationError to RateLimited`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
RequestResult.NonSuccess(
@@ -221,7 +221,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModelTest {
@Test(expected = IllegalStateException::class)
fun `Submit with SessionNotFoundOrNotVerified throws IllegalStateException`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
RequestResult.NonSuccess(
@@ -233,7 +233,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModelTest {
@Test(expected = IllegalStateException::class)
fun `Submit with DeviceTransferPossible throws IllegalStateException`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
RequestResult.NonSuccess(
@@ -245,7 +245,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModelTest {
@Test
fun `Submit with RetryableNetworkError sets registrationError to NetworkError`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
RequestResult.RetryableNetworkError(java.io.IOException("Network error"))
@@ -258,7 +258,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModelTest {
@Test
fun `Submit with ApplicationError sets registrationError to UnknownError`() = runTest {
val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true)
val initialState = EnterAepState(recoveryKey = AepInput.from(VALID_AEP))
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns
RequestResult.ApplicationError(RuntimeException("Unexpected"))
@@ -18,7 +18,7 @@ class EnterAepScreenEventHandlerTest {
EnterAepEvents.BackupKeyChanged("a0O#=b")
)
assertThat(updated.enteredText).isEqualTo("a0O#=b")
assertThat(updated.backupKey).isEqualTo("a0oo0b")
assertThat(updated.recoveryKey.enteredText).isEqualTo("a0O#=b")
assertThat(updated.recoveryKey.normalized).isEqualTo("a0oo0b")
}
}
@@ -101,8 +101,7 @@ class EnterAepScreenTest {
SignalTheme {
EnterAepScreen(
state = EnterAepState(
isBackupKeyValid = true,
aepValidationError = null,
recoveryKey = AepInput(isValid = true),
isRegistering = false
),
onEvent = { event ->
@@ -21,6 +21,7 @@ import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -80,7 +81,7 @@ class LocalBackupRestoreViewModelTest {
): LocalBackupRestoreViewModel {
return LocalBackupRestoreViewModel(
repository = mockRepository,
parentState = flowOf(parentState),
parentState = MutableStateFlow(parentState),
parentEventEmitter = parentEventEmitter,
isPreRegistration = isPreRegistration,
resultBus = resultBus,
@@ -401,6 +402,28 @@ class LocalBackupRestoreViewModelTest {
assertThat(emittedParentEvents).contains(RegistrationFlowEvent.RegistrationComplete)
}
@Test
fun `restore without a PIN for a phone-numberless account completes registration, since it has no PIN`() = runTest(testDispatcher) {
val viewModel = createViewModel(
isPreRegistration = false,
parentState = RegistrationFlowState(storageCapable = true, isPhoneNumberlessAccount = true)
)
val backupInfo = LocalBackupInfo(
type = LocalBackupInfo.BackupType.V1,
date = LocalDateTime.now(),
name = "backup.backup",
uri = mockk()
)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo, selectedFolderUri = mockk())
every { mockRepository.restoreV1Backup(any(), any(), any()) } returns flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = null, restoredProfileKey = null))
viewModel.applyEvent(initialState, LocalBackupRestoreEvents.PassphraseSubmitted("passphrase"), stateEmitter)
coVerify { mockRepository.restoreAccountRecord(any()) }
assertThat(emittedParentEvents).contains(RegistrationFlowEvent.RegistrationComplete)
}
@Test
fun `V1 restore without a PIN when storage capable navigates to PinEntryForSvrRestore`() = runTest(testDispatcher) {
val viewModel = createViewModel(isPreRegistration = false, storageCapable = true)
@@ -125,7 +125,7 @@ class PinEntryForRegistrationLockViewModelTest {
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isEqualTo(RegistrationRoute.ArchiveRestoreSelection.forPostRegisterWithPinKnown())
coVerify { mockRepository.restoreAccountRecord(any()) }
coVerify(exactly = 0) { mockRepository.restoreAccountRecord(any()) }
assertThat(emittedStates.last().loading).isEqualTo(true)
}
@@ -6,6 +6,8 @@
package org.signal.registration.screens.remotebackuprestore
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.doesNotContain
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
@@ -70,11 +72,16 @@ class RemoteBackupRestoreViewModelTest {
Dispatchers.resetMain()
}
private fun createViewModel(storageCapable: Boolean = false): RemoteBackupRestoreViewModel {
private fun createViewModel(
storageCapable: Boolean = false,
phoneNumberless: Boolean = false,
canNavigateBackwards: Boolean = false
): RemoteBackupRestoreViewModel {
return RemoteBackupRestoreViewModel(
aep = aep,
canNavigateBackwards = canNavigateBackwards,
repository = mockRepository,
parentState = MutableStateFlow(RegistrationFlowState(storageCapable = storageCapable)),
parentState = MutableStateFlow(RegistrationFlowState(storageCapable = storageCapable, isPhoneNumberlessAccount = phoneNumberless)),
parentEventEmitter = parentEventEmitter,
ioDispatcher = testDispatcher
)
@@ -116,14 +123,88 @@ class RemoteBackupRestoreViewModelTest {
// ==================== Cancel ====================
@Test
fun `Cancel emits NavigateBack`() = runTest(testDispatcher) {
fun `Cancel records the skip and completes registration when a pin is known`() = runTest(testDispatcher) {
coEvery { mockRepository.hasKnownPin() } returns true
val viewModel = createViewModel()
val initialState = RemoteBackupRestoreState(aep = aep)
viewModel.applyEvent(initialState, RemoteBackupRestoreScreenEvents.Cancel, stateEmitter)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.Cancel, stateEmitter)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
coVerify { mockRepository.setRestoreDecision(RestoreDecision.SKIPPED) }
coVerify { mockRepository.restoreAccountRecord() }
assertThat(emittedParentEvents.last()).isEqualTo(RegistrationFlowEvent.RegistrationComplete)
}
@Test
fun `Cancel navigates to pin creation when no pin is known and the account is not storage capable`() = runTest(testDispatcher) {
coEvery { mockRepository.hasKnownPin() } returns false
val viewModel = createViewModel(storageCapable = false)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.Cancel, stateEmitter)
coVerify { mockRepository.setRestoreDecision(RestoreDecision.SKIPPED) }
assertThat(emittedParentEvents.last()).isEqualTo(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.PinCreate))
}
@Test
fun `Cancel navigates to SVR pin entry when no pin is known and the account is storage capable`() = runTest(testDispatcher) {
coEvery { mockRepository.hasKnownPin() } returns false
val viewModel = createViewModel(storageCapable = true)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.Cancel, stateEmitter)
coVerify { mockRepository.setRestoreDecision(RestoreDecision.SKIPPED) }
assertThat(emittedParentEvents.last()).isEqualTo(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.PinEntryForSvrRestore))
}
@Test
fun `Cancel completes registration for a phone-numberless account, which has no pin`() = runTest(testDispatcher) {
coEvery { mockRepository.hasKnownPin() } returns false
val viewModel = createViewModel(storageCapable = true, phoneNumberless = true)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.Cancel, stateEmitter)
coVerify { mockRepository.setRestoreDecision(RestoreDecision.SKIPPED) }
coVerify { mockRepository.restoreAccountRecord() }
assertThat(emittedParentEvents.last()).isEqualTo(RegistrationFlowEvent.RegistrationComplete)
}
@Test
fun `Complete progress completes registration for a phone-numberless account, which has no pin`() = runTest(testDispatcher) {
coEvery { mockRepository.hasKnownPin() } returns false
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(RemoteBackupRestoreProgress.Complete(restoredSvrPin = null, restoredProfileKey = null))
val viewModel = createViewModel(storageCapable = true, phoneNumberless = true)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.BackupRestoreBackup, stateEmitter)
assertThat(emittedParentEvents.last()).isEqualTo(RegistrationFlowEvent.RegistrationComplete)
}
@Test
fun `Cancel never navigates back, since this screen cleared the back stack`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.Cancel, stateEmitter)
assertThat(emittedParentEvents).doesNotContain(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `Cancel navigates back to the restore selection screen when it is still behind us`() = runTest(testDispatcher) {
val viewModel = createViewModel(canNavigateBackwards = true)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.Cancel, stateEmitter)
assertThat(emittedParentEvents).containsExactly(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `Cancel does not record a skip when handing control back to the restore selection screen`() = runTest(testDispatcher) {
val viewModel = createViewModel(canNavigateBackwards = true)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.Cancel, stateEmitter)
coVerify(exactly = 0) { mockRepository.setRestoreDecision(any()) }
assertThat(emittedParentEvents).doesNotContain(RegistrationFlowEvent.RegistrationComplete)
}
// ==================== Retry ====================
@@ -225,13 +225,14 @@ class ArchiveRestoreSelectionViewModelTest {
}
@Test
fun `ConfirmSkip post-registration when PIN is known records skip and completes registration`() = runTest {
fun `ConfirmSkip post-registration when PIN is known records skip, restores the account record, and completes registration`() = runTest {
val viewModel = createViewModel(registeredState = RegisteredState.RegisteredAndPinKnown)
val initialState = ArchiveRestoreSelectionState(showSkipWarningDialog = true)
viewModel.applyEvent(initialState, ArchiveRestoreSelectionScreenEvents.ConfirmSkip, stateEmitter)
coVerify { mockRepository.setRestoreDecision(RestoreDecision.SKIPPED) }
coVerify { mockRepository.restoreAccountRecord() }
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.RegistrationComplete)
assertThat(emittedStates.last().showSkipWarningDialog).isFalse()
@@ -1,101 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
import android.app.Application
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.contains
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.registration.test.TestTags
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class SignalLoginScreenTest {
companion object {
private const val VALID_ACCOUNT_KEY = "a6b284822e3283d07f2391360a4c2b91"
}
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
private val events = mutableListOf<SignalLoginScreenEvents>()
@Test
fun `when text is typed into the account key field, AccountKeyChanged is emitted`() {
setContent(SignalLoginState())
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_ACCOUNT_KEY_FIELD).performTextInput("a6b2")
assertThat(events).contains(SignalLoginScreenEvents.AccountKeyChanged("a6b2"))
}
@Test
fun `when Need help is clicked, NeedHelpClicked is emitted`() {
setContent(SignalLoginState())
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEED_HELP_BUTTON).performClick()
assertThat(events).contains(SignalLoginScreenEvents.NeedHelpClicked)
}
@Test
fun `when Next is clicked with a complete key, NextClicked is emitted`() {
setContent(SignalLoginState(accountKey = VALID_ACCOUNT_KEY))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON).performClick()
assertThat(events).contains(SignalLoginScreenEvents.NextClicked)
}
@Test
fun `given an incomplete key, Next is disabled`() {
setContent(SignalLoginState(accountKey = "a6b28482"))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON).assertIsNotEnabled()
}
@Test
fun `given a complete key, Next is enabled`() {
setContent(SignalLoginState(accountKey = VALID_ACCOUNT_KEY))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON).assertIsEnabled()
}
@Test
fun `given a submission is in flight, Next is disabled`() {
setContent(SignalLoginState(accountKey = VALID_ACCOUNT_KEY, isSubmitting = true))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON).assertIsNotEnabled()
}
private fun setContent(state: SignalLoginState) {
composeTestRule.setContent {
SignalTheme {
SignalLoginScreen(
state = state,
onEvent = { events += it }
)
}
}
}
}
@@ -1,148 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
@OptIn(ExperimentalCoroutinesApi::class)
class SignalLoginViewModelTest {
companion object {
private const val VALID_ACCOUNT_KEY = "a6b284822e3283d07f2391360a4c2b91"
}
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var mockRepository: RegistrationRepository
private lateinit var viewModel: SignalLoginViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockRepository = mockk(relaxed = true)
viewModel = SignalLoginViewModel(repository = mockRepository, parentEventEmitter = {})
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `BackClicked navigates back`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
viewModel.applyEvent(SignalLoginState(), SignalLoginScreenEvents.BackClicked, { parentEvents.add(it) }) {}
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `AccountKeyChanged strips formatting and lowercases the entered key`() = runTest(testDispatcher) {
val state = applyAccountKey("A6B28482-2E32-83D0-7F23 91360A4C2B91")
assertThat(state.accountKey).isEqualTo(VALID_ACCOUNT_KEY)
assertThat(state.accountKeyError).isNull()
assertThat(state.isNextEnabled).isTrue()
}
@Test
fun `AccountKeyChanged does not report an error for a partially typed key`() = runTest(testDispatcher) {
val state = applyAccountKey("a6b28482")
assertThat(state.accountKeyError).isNull()
assertThat(state.isNextEnabled).isFalse()
}
@Test
fun `AccountKeyChanged reports non-hex characters as invalid`() = runTest(testDispatcher) {
val state = applyAccountKey(VALID_ACCOUNT_KEY.dropLast(1) + "z")
assertThat(state.accountKeyError).isEqualTo(AccountKeyError.Invalid)
assertThat(state.isNextEnabled).isFalse()
}
@Test
fun `AccountKeyChanged reports an over-long key as too long`() = runTest(testDispatcher) {
val state = applyAccountKey(VALID_ACCOUNT_KEY + "ab")
assertThat(state.accountKeyError).isEqualTo(AccountKeyError.TooLong(34))
assertThat(state.isNextEnabled).isFalse()
}
@Test
fun `NeedHelpClicked opens the help article`() = runTest(testDispatcher) {
val actions = mutableListOf<SignalLoginScreenActions>()
backgroundScope.launch { viewModel.actions.toList(actions) }
viewModel.applyEvent(SignalLoginState(), SignalLoginScreenEvents.NeedHelpClicked, {}) {}
assertThat(actions).containsExactly(SignalLoginScreenActions.OpenNeedHelpArticle)
}
@Test
fun `NextClicked does nothing yet because logging in is not implemented`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
val states = mutableListOf<SignalLoginState>()
viewModel.applyEvent(SignalLoginState(accountKey = VALID_ACCOUNT_KEY), SignalLoginScreenEvents.NextClicked, { parentEvents.add(it) }) { states.add(it) }
assertThat(parentEvents).isEmpty()
assertThat(states).isEmpty()
}
@Test
fun `NetworkErrorDialogDismissed clears the dialog`() = runTest(testDispatcher) {
var state: SignalLoginState? = null
viewModel.applyEvent(
SignalLoginState(dialogs = SignalLoginState.Dialogs(networkError = true)),
SignalLoginScreenEvents.NetworkErrorDialogDismissed,
{}
) { state = it }
assertThat(state!!.dialogs.networkError).isFalse()
}
@Test
fun `UnknownErrorDialogDismissed clears the dialog`() = runTest(testDispatcher) {
var state: SignalLoginState? = null
viewModel.applyEvent(
SignalLoginState(dialogs = SignalLoginState.Dialogs(unknownError = true)),
SignalLoginScreenEvents.UnknownErrorDialogDismissed,
{}
) { state = it }
assertThat(state!!.dialogs.unknownError).isFalse()
}
private suspend fun applyAccountKey(value: String): SignalLoginState {
var state = SignalLoginState()
viewModel.applyEvent(state, SignalLoginScreenEvents.AccountKeyChanged(value), {}) { state = it }
return state
}
}
@@ -3,14 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
package org.signal.registration.screens.signallogincredentials
import androidx.compose.ui.text.AnnotatedString
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Test
class AccountKeyVisualTransformationTest {
class AccountIdVisualTransformationTest {
companion object {
private const val FULL_KEY = "a6b284822e3283d07f2391360a4c2b91"
@@ -42,7 +42,7 @@ class AccountKeyVisualTransformationTest {
fun `every cursor position maps into the transformed text and back`() {
for (length in 0..FULL_KEY.length) {
val key = FULL_KEY.take(length)
val mapping = AccountKeyVisualTransformation.filter(AnnotatedString(key)).offsetMapping
val mapping = AccountIdVisualTransformation.filter(AnnotatedString(key)).offsetMapping
val transformedLength = transform(key).length
for (offset in 0..length) {
@@ -54,5 +54,5 @@ class AccountKeyVisualTransformationTest {
}
}
private fun transform(text: String): String = AccountKeyVisualTransformation.filter(AnnotatedString(text)).text.text
private fun transform(text: String): String = AccountIdVisualTransformation.filter(AnnotatedString(text)).text.text
}
@@ -0,0 +1,150 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogincredentials
import android.app.Application
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.contains
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.registration.screens.aepentry.AepInput
import org.signal.registration.test.TestTags
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class SignalLoginCredentialEntryScreenTest {
companion object {
private const val VALID_ACCOUNT_ID = "a6b284822e3283d07f2391360a4c2b91"
private const val VALID_RECOVERY_KEY = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t"
}
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
private val events = mutableListOf<SignalLoginCredentialEntryScreenEvents>()
@Test
fun `when text is typed into the account ID field, AccountIdChanged is emitted`() {
setContent(SignalLoginCredentialEntryState())
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_ACCOUNT_ID_FIELD).performTextInput("a6b2")
assertThat(events).contains(SignalLoginCredentialEntryScreenEvents.AccountIdChanged("a6b2"))
}
@Test
fun `when text is typed into the recovery key field, RecoveryKeyChanged is emitted`() {
setContent(SignalLoginCredentialEntryState())
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_RECOVERY_KEY_FIELD).performTextInput("uy38")
assertThat(events).contains(SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged("uy38"))
}
@Test
fun `when the eye button is clicked, RecoveryKeyVisibilityToggled is emitted`() {
setContent(SignalLoginCredentialEntryState())
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_REVEAL_RECOVERY_KEY_BUTTON, useUnmergedTree = true).performScrollTo().performClick()
assertThat(events).contains(SignalLoginCredentialEntryScreenEvents.RecoveryKeyVisibilityToggled)
}
@Test
fun `when Need help is clicked, NeedHelpClicked is emitted`() {
setContent(SignalLoginCredentialEntryState())
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEED_HELP_BUTTON).performClick()
assertThat(events).contains(SignalLoginCredentialEntryScreenEvents.NeedHelpClicked)
}
@Test
fun `when Next is clicked with a complete login, NextClicked is emitted`() {
setContent(completeState())
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON).performClick()
assertThat(events).contains(SignalLoginCredentialEntryScreenEvents.NextClicked)
}
@Test
fun `given a complete login, Next is enabled`() {
setContent(completeState())
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON).assertIsEnabled()
}
@Test
fun `given an incomplete account ID, Next is disabled`() {
setContent(completeState().copy(accountId = "a6b28482"))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON).assertIsNotEnabled()
}
@Test
fun `given an incomplete recovery key, Next is disabled`() {
setContent(completeState().copy(recoveryKey = AepInput.from("uy38jh27")))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON).assertIsNotEnabled()
}
@Test
fun `given an account ID with an invalid character, Next is disabled`() {
setContent(completeState().copy(accountIdError = AccountIdError.Invalid))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON).assertIsNotEnabled()
}
@Test
fun `given a rejected login, Next stays disabled until something is edited`() {
setContent(completeState().copy(areCredentialsIncorrect = true))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON).assertIsNotEnabled()
}
@Test
fun `while logging in, Next is disabled`() {
setContent(completeState().copy(isLoggingIn = true))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON).assertIsNotEnabled()
}
private fun completeState(): SignalLoginCredentialEntryState {
return SignalLoginCredentialEntryState(
accountId = VALID_ACCOUNT_ID,
recoveryKey = AepInput.from(VALID_RECOVERY_KEY)
)
}
private fun setContent(state: SignalLoginCredentialEntryState) {
composeTestRule.setContent {
SignalTheme {
SignalLoginCredentialEntryScreen(
state = state,
onEvent = { events += it }
)
}
}
}
}
@@ -0,0 +1,414 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogincredentials
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.prop
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.ServiceId.ACI
import org.signal.libsignal.net.RequestResult
import org.signal.network.api.RegistrationApiV2.RegisterAccountError
import org.signal.network.api.RegistrationApiV2.RegisterAccountResponse
import org.signal.network.api.RegistrationApiV2.RegistrationLockResponse
import org.signal.network.api.RegistrationApiV2.SvrCredentials
import org.signal.registration.KeyMaterial
import org.signal.registration.NetworkController
import org.signal.registration.RegisteredAccountData
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import org.signal.registration.RestoreDecision
import org.signal.registration.screens.aepentry.AepInput
import org.signal.registration.screens.restoreselection.ArchiveRestoreOption
import org.signal.registration.screens.restoreselection.RegisteredState
import java.io.IOException
import java.util.UUID
import kotlin.time.Duration
@OptIn(ExperimentalCoroutinesApi::class)
class SignalLoginCredentialEntryViewModelTest {
companion object {
private const val VALID_ACCOUNT_ID = "a6b284822e3283d07f2391360a4c2b91"
private const val VALID_AEP = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t"
private val VALID_ACI = ACI.from(UUID.fromString("a6b28482-2e32-83d0-7f23-91360a4c2b91"))
}
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var viewModel: SignalLoginCredentialEntryViewModel
private lateinit var mockRepository: RegistrationRepository
private lateinit var emittedParentEvents: MutableList<RegistrationFlowEvent>
private lateinit var emittedStates: MutableList<SignalLoginCredentialEntryState>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var stateEmitter: (SignalLoginCredentialEntryState) -> Unit
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockRepository = mockk(relaxed = true)
emittedParentEvents = mutableListOf()
emittedStates = mutableListOf()
parentEventEmitter = { event -> emittedParentEvents.add(event) }
stateEmitter = { state -> emittedStates.add(state) }
viewModel = SignalLoginCredentialEntryViewModel(repository = mockRepository, parentEventEmitter = parentEventEmitter)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `BackClicked navigates back`() = runTest(testDispatcher) {
applyEvent(SignalLoginCredentialEntryState(), SignalLoginCredentialEntryScreenEvents.BackClicked)
assertThat(emittedParentEvents).containsExactly(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `AccountIdChanged strips formatting and lowercases the entered ID`() = runTest(testDispatcher) {
val state = applyAccountId("A6B28482-2E32-83D0-7F23 91360A4C2B91")
assertThat(state.accountId).isEqualTo(VALID_ACCOUNT_ID)
assertThat(state.accountIdError).isNull()
}
@Test
fun `AccountIdChanged does not report an error for a partially typed ID`() = runTest(testDispatcher) {
val state = applyAccountId("a6b28482")
assertThat(state.accountIdError).isNull()
assertThat(state.isNextEnabled).isFalse()
}
@Test
fun `AccountIdChanged reports non-hex characters as invalid`() = runTest(testDispatcher) {
val state = applyAccountId(VALID_ACCOUNT_ID.dropLast(1) + "z")
assertThat(state.accountIdError).isEqualTo(AccountIdError.Invalid)
assertThat(state.isNextEnabled).isFalse()
}
@Test
fun `AccountIdChanged reports an over-long ID as too long`() = runTest(testDispatcher) {
val state = applyAccountId(VALID_ACCOUNT_ID + "ab")
assertThat(state.accountIdError).isEqualTo(AccountIdError.TooLong(34))
assertThat(state.isNextEnabled).isFalse()
}
@Test
fun `RecoveryKeyChanged normalizes the entered key`() = runTest(testDispatcher) {
applyEvent(SignalLoginCredentialEntryState(), SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged(VALID_AEP.uppercase()))
assertThat(emittedStates.last().recoveryKey.normalized).isEqualTo(VALID_AEP)
assertThat(emittedStates.last().recoveryKey.isValid).isTrue()
}
@Test
fun `editing either half clears a rejected login`() = runTest(testDispatcher) {
val rejected = completeState().copy(areCredentialsIncorrect = true)
applyEvent(rejected, SignalLoginCredentialEntryScreenEvents.AccountIdChanged(VALID_ACCOUNT_ID.dropLast(1)))
assertThat(emittedStates.last().areCredentialsIncorrect).isFalse()
applyEvent(rejected, SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged(VALID_AEP.dropLast(1)))
assertThat(emittedStates.last().areCredentialsIncorrect).isFalse()
}
@Test
fun `RecoveryKeyVisibilityToggled flips whether the key is spelled out`() = runTest(testDispatcher) {
applyEvent(SignalLoginCredentialEntryState(), SignalLoginCredentialEntryScreenEvents.RecoveryKeyVisibilityToggled)
assertThat(emittedStates.last().isRecoveryKeyRevealed).isTrue()
}
@Test
fun `NeedHelpClicked opens the help article`() = runTest(testDispatcher) {
val actions = mutableListOf<SignalLoginCredentialEntryScreenActions>()
backgroundScope.launch { viewModel.actions.toList(actions) }
applyEvent(SignalLoginCredentialEntryState(), SignalLoginCredentialEntryScreenEvents.NeedHelpClicked)
assertThat(actions).containsExactly(SignalLoginCredentialEntryScreenActions.OpenNeedHelpArticle)
}
@Test
fun `DismissError clears the login error`() = runTest(testDispatcher) {
applyEvent(completeState().copy(loginError = SignalLoginError.NetworkError), SignalLoginCredentialEntryScreenEvents.DismissError)
assertThat(emittedStates.last().loginError).isNull()
}
@Test
fun `NextClicked with an account ID that is not a valid ACI reports it as invalid`() = runTest(testDispatcher) {
applyEvent(completeState().copy(accountId = "a6b28482"), SignalLoginCredentialEntryScreenEvents.NextClicked)
assertThat(emittedParentEvents).isEmpty()
assertThat(emittedStates.last().accountIdError).isEqualTo(AccountIdError.Invalid)
}
@Test
fun `NextClicked logs in with the entered account ID and the recovery password derived from the entered key`() = runTest(testDispatcher) {
val aep = AccountEntropyPool(VALID_AEP)
stubSuccessfulLogin(aep)
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
coVerify {
mockRepository.reRegisterAccountWithoutPhoneNumber(
aci = VALID_ACI,
recoveryPassword = aep.deriveMasterKey().deriveRegistrationRecoveryPassword(),
aep = match { it.value == VALID_AEP },
registrationLock = null
)
}
}
@Test
fun `NextClicked reclaiming an existing account emits UserSuppliedAepSubmitted, Registered, and navigates to the restore selection`() = runTest(testDispatcher) {
val aep = AccountEntropyPool(VALID_AEP)
stubSuccessfulLogin(aep, reregistration = true)
stubRemoteBackup(exists = true)
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
assertThat(emittedParentEvents).hasSize(3)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.UserSuppliedAepSubmitted>()
assertThat(emittedParentEvents[1])
.isInstanceOf<RegistrationFlowEvent.Registered>()
.prop(RegistrationFlowEvent.Registered::phoneNumberless)
.isEqualTo(true)
val route = assertThat(emittedParentEvents[2])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.ArchiveRestoreSelection>()
route.prop(RegistrationRoute.ArchiveRestoreSelection::aep).isNotNull().prop(AccountEntropyPool::value).isEqualTo(aep.value)
route.prop(RegistrationRoute.ArchiveRestoreSelection::registeredState).isEqualTo(RegisteredState.RegisteredAndPinKnown)
route.prop(RegistrationRoute.ArchiveRestoreSelection::restoreOptions)
.containsExactly(ArchiveRestoreOption.SignalSecureBackup, ArchiveRestoreOption.LocalBackup, ArchiveRestoreOption.None)
}
@Test
fun `NextClicked reclaiming an account with no remote backup does not offer a remote restore`() = runTest(testDispatcher) {
stubSuccessfulLogin(AccountEntropyPool(VALID_AEP), reregistration = true)
stubRemoteBackup(exists = false)
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
assertThat(emittedParentEvents.last())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.ArchiveRestoreSelection>()
.prop(RegistrationRoute.ArchiveRestoreSelection::restoreOptions)
.containsExactly(ArchiveRestoreOption.LocalBackup, ArchiveRestoreOption.None)
}
@Test
fun `NextClicked reclaiming an account still offers a remote restore when the backup check fails`() = runTest(testDispatcher) {
stubSuccessfulLogin(AccountEntropyPool(VALID_AEP), reregistration = true)
coEvery { mockRepository.getAndMaybeHealRemoteBackupInfo(any()) } returns RequestResult.RetryableNetworkError(IOException("Network error"))
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
assertThat(emittedParentEvents.last())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.ArchiveRestoreSelection>()
.prop(RegistrationRoute.ArchiveRestoreSelection::restoreOptions)
.containsExactly(ArchiveRestoreOption.SignalSecureBackup, ArchiveRestoreOption.LocalBackup, ArchiveRestoreOption.None)
}
@Test
fun `NextClicked registering a brand new account restores the account record and completes registration`() = runTest(testDispatcher) {
stubSuccessfulLogin(AccountEntropyPool(VALID_AEP), reregistration = false)
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
coVerify { mockRepository.setRestoreDecision(RestoreDecision.NEW_ACCOUNT) }
coVerify { mockRepository.restoreAccountRecord() }
assertThat(emittedParentEvents.last()).isEqualTo(RegistrationFlowEvent.RegistrationComplete)
}
@Test
fun `NextClicked shows a spinner while the login is in flight`() = runTest(testDispatcher) {
stubSuccessfulLogin(AccountEntropyPool(VALID_AEP))
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
assertThat(emittedStates).hasSize(2)
assertThat(emittedStates[0].isLoggingIn).isEqualTo(true)
assertThat(emittedStates[1].isLoggingIn).isEqualTo(false)
}
@Test
fun `NextClicked with an incorrect recovery password flags the entered login`() = runTest(testDispatcher) {
coEvery { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), any(), any()) } returns
RequestResult.NonSuccess(RegisterAccountError.RegistrationRecoveryPasswordIncorrect("Incorrect"))
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
assertThat(emittedStates.last().areCredentialsIncorrect).isTrue()
assertThat(emittedStates.last().isLoggingIn).isEqualTo(false)
assertThat(emittedStates.last().isNextEnabled).isFalse()
}
@Test
fun `NextClicked with RegistrationLock retries with the reglock token derived from the recovery key`() = runTest(testDispatcher) {
val aep = AccountEntropyPool(VALID_AEP)
val reglock = aep.deriveMasterKey().deriveRegistrationLock()
coEvery { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), registrationLock = null, any()) } returns
RequestResult.NonSuccess(RegisterAccountError.RegistrationLock(registrationLockResponse()))
coEvery { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), registrationLock = reglock, any()) } returns
successfulResult(aep)
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
coVerify { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), registrationLock = reglock, any()) }
assertThat(emittedParentEvents[2])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.ArchiveRestoreSelection>()
}
@Test
fun `NextClicked still registration locked after providing the reglock token falls back to PIN entry`() = runTest(testDispatcher) {
coEvery { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), any(), any()) } returns
RequestResult.NonSuccess(RegisterAccountError.RegistrationLock(registrationLockResponse()))
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
assertThat(emittedStates.last().isLoggingIn).isEqualTo(false)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.PinEntryForRegistrationLock>()
}
@Test
fun `NextClicked with RateLimited sets loginError to RateLimited`() = runTest(testDispatcher) {
coEvery { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), any(), any()) } returns
RequestResult.NonSuccess(RegisterAccountError.RateLimited(Duration.parse("1m")))
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
assertThat(emittedStates.last().loginError).isEqualTo(SignalLoginError.RateLimited)
}
@Test
fun `NextClicked with InvalidRequest sets loginError to UnknownError`() = runTest(testDispatcher) {
coEvery { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), any(), any()) } returns
RequestResult.NonSuccess(RegisterAccountError.InvalidRequest("Bad request"))
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
assertThat(emittedStates.last().loginError).isEqualTo(SignalLoginError.UnknownError)
}
@Test
fun `NextClicked with a network error sets loginError to NetworkError`() = runTest(testDispatcher) {
coEvery { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), any(), any()) } returns
RequestResult.RetryableNetworkError(IOException("Network error"))
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
assertThat(emittedStates.last().loginError).isEqualTo(SignalLoginError.NetworkError)
}
@Test
fun `NextClicked with an application error sets loginError to UnknownError`() = runTest(testDispatcher) {
coEvery { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), any(), any()) } returns
RequestResult.ApplicationError(RuntimeException("Unexpected"))
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
assertThat(emittedStates.last().loginError).isEqualTo(SignalLoginError.UnknownError)
}
@Test(expected = IllegalStateException::class)
fun `NextClicked with SessionNotFoundOrNotVerified throws`() = runTest(testDispatcher) {
coEvery { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), any(), any()) } returns
RequestResult.NonSuccess(RegisterAccountError.SessionNotFoundOrNotVerified("Not found"))
applyEvent(completeState(), SignalLoginCredentialEntryScreenEvents.NextClicked)
}
private suspend fun applyEvent(state: SignalLoginCredentialEntryState, event: SignalLoginCredentialEntryScreenEvents) {
viewModel.applyEvent(state, event, parentEventEmitter, stateEmitter)
}
private suspend fun applyAccountId(value: String): SignalLoginCredentialEntryState {
applyEvent(SignalLoginCredentialEntryState(), SignalLoginCredentialEntryScreenEvents.AccountIdChanged(value))
return emittedStates.last()
}
private fun completeState(): SignalLoginCredentialEntryState {
return SignalLoginCredentialEntryState(
accountId = VALID_ACCOUNT_ID,
recoveryKey = AepInput.from(VALID_AEP)
)
}
private fun registrationLockResponse(): RegistrationLockResponse {
return RegistrationLockResponse(
timeRemaining = 86400000L,
svr2Credentials = SvrCredentials(username = "test-username", password = "test-password")
)
}
private fun successfulResult(aep: AccountEntropyPool, reregistration: Boolean = true): RequestResult.Success<RegisteredAccountData> {
val keyMaterial = mockk<KeyMaterial>(relaxed = true) {
every { accountEntropyPool } returns aep
}
val response = mockk<RegisterAccountResponse>(relaxed = true) {
every { this@mockk.reregistration } returns reregistration
every { e164 } returns null
every { pni } returns null
}
return RequestResult.Success(RegisteredAccountData(response, keyMaterial, VALID_ACI))
}
private fun stubSuccessfulLogin(aep: AccountEntropyPool, reregistration: Boolean = true) {
coEvery { mockRepository.reRegisterAccountWithoutPhoneNumber(any(), any(), any(), any(), any()) } returns successfulResult(aep, reregistration)
}
private fun stubRemoteBackup(exists: Boolean) {
coEvery { mockRepository.getAndMaybeHealRemoteBackupInfo(any()) } returns if (exists) {
RequestResult.Success(NetworkController.GetBackupInfoResponse(cdn = 3, backupDir = "dir", mediaDir = "media", backupName = "backup", usedSpace = 1024))
} else {
RequestResult.NonSuccess(NetworkController.GetBackupInfoError.NoBackup)
}
}
}
@@ -280,11 +280,18 @@ class RegistrationApiV2(
* Submit the cryptographic assets required for an account to use the service.
* Must provide exactly one of [sessionId], [recoveryPassword], or [receiptCredentialPresentation].
*
* Providing a [receiptCredentialPresentation] (issued by [createLoginPurchaseReceiptCredential]) registers an
* account that has no phone number. For that mode, [e164] and [pniPreKeys] must be null, [attributes] must have a
* null `pniRegistrationId` and a null `discoverableByPhoneNumber`, and the basic auth username is a placeholder the
* service ignores (it must not parse as an e164 or a UUID -- the service reads a UUID username as a request to
* recover the account with that ACI; re-registering an existing numberless account isn't supported yet).
* There are two ways to register an account that has no phone number, and both require [e164] to be null and
* [attributes] to have a null `discoverableByPhoneNumber`:
*
* - Providing a [receiptCredentialPresentation] (issued by [createLoginPurchaseReceiptCredential]) creates a brand
* new numberless account. [pniPreKeys] and `attributes.pniRegistrationId` must be null. The basic auth username is
* a placeholder the service ignores; it must not parse as an e164 or a UUID, because the service reads a UUID
* username as a request to recover the account with that ACI.
* - Providing an [aci] alongside a [recoveryPassword] logs back in to the numberless account with that ACI. The
* ACI is the basic auth username, which is exactly how the service is told which account to recover. A full set of
* [pniPreKeys] and an `attributes.pniRegistrationId` are required even so: the service demands them of any
* recovery-by-identifier, and then ignores them when the recovered account has no phone number. Throwaway key
* material is fine, as long as the pre-keys are signed by the PNI identity key sent alongside them.
*
* `POST /v1/registration`
* - 200: Success, body is the account response
@@ -300,6 +307,7 @@ class RegistrationApiV2(
*
* @param e164 The phone number in E.164 format (used as username for basic auth). Null when registering without a phone number.
* @param password The password for basic auth
* @param aci The ACI of the existing numberless account to log back in to, used as the username for basic auth.
* @param totp A TOTP one-time password, required when recovering an account that has TOTP keys.
*/
suspend fun registerAccount(
@@ -313,16 +321,23 @@ class RegistrationApiV2(
pniPreKeys: PreKeyCollection?,
fcmToken: String?,
skipDeviceTransfer: Boolean,
aci: ACI? = null,
totp: Int? = null
): RequestResult<RegisterAccountResponse, RegisterAccountError> {
val phoneNumberless = receiptCredentialPresentation != null
val redeemingReceipt = receiptCredentialPresentation != null
val phoneNumberless = redeemingReceipt || aci != null
require(listOfNotNull(sessionId, recoveryPassword, receiptCredentialPresentation).size == 1) { "You must supply exactly one of: Session ID, Recovery Password, or Receipt Credential Presentation." }
require(aci == null || recoveryPassword != null) { "Must send a recovery password alongside an ACI." }
if (phoneNumberless) {
check(phonenumberlessRegistrationAllowed) { "Phone-number-less registration is not allowed in this build!" }
require(e164 == null && pniPreKeys == null) { "Must not send an e164 or PNI key material when registering without a phone number." }
require(attributes.pniRegistrationId == null) { "Must not send PNI key material when registering without a phone number." }
require(e164 == null) { "Must not send an e164 when registering without a phone number." }
require(attributes.discoverableByPhoneNumber == null) { "Must not set phone number discoverability when registering without a phone number." }
if (aci != null) {
require(pniPreKeys != null && attributes.pniRegistrationId != null) { "Must send PNI key material when recovering an account by identifier, even though the service ignores it." }
} else {
require(pniPreKeys == null && attributes.pniRegistrationId == null) { "Must not send PNI key material when registering a new account without a phone number." }
}
} else {
require(e164 != null && pniPreKeys != null) { "Must send an e164 and PNI key material when registering with a phone number." }
require(attributes.pniRegistrationId != null) { "Must send PNI key material when registering with a phone number." }
@@ -351,7 +366,7 @@ class RegistrationApiV2(
host = RequestSpec.Host.Service,
path = "/v1/registration",
body = if (phoneNumberless) body.toJsonRequestBodyOmittingNulls() else body.toJsonRequestBody(),
auth = RequestSpec.Auth.Header("Authorization", basicAuth(e164 ?: NO_NUMBER_AUTH_USERNAME, password))
auth = RequestSpec.Auth.Header("Authorization", basicAuth(e164 ?: aci?.toString() ?: NO_NUMBER_AUTH_USERNAME, password))
)
)
@@ -359,7 +374,7 @@ class RegistrationApiV2(
parseSuccess = { SignalJson.json.decodeFromString<RegisterAccountResponse>(it.bodyString()) },
mapError = { error ->
when (error.statusCode) {
400 -> if (phoneNumberless) RegisterAccountError.InvalidReceiptCredentialPresentation(error.bodyString()) else RegisterAccountError.InvalidRequest(error.bodyString())
400 -> if (redeemingReceipt) RegisterAccountError.InvalidReceiptCredentialPresentation(error.bodyString()) else RegisterAccountError.InvalidRequest(error.bodyString())
422 -> RegisterAccountError.InvalidRequest(error.bodyString())
401 -> RegisterAccountError.SessionNotFoundOrNotVerified(error.bodyString())
403 -> RegisterAccountError.RegistrationRecoveryPasswordIncorrect(error.bodyString())
@@ -827,7 +842,10 @@ class RegistrationApiV2(
val code: String
)
/** The PNI properties are all null when registering an account that has no phone number, in which case they are omitted from the request. */
/**
* The PNI properties are all null when registering a new account that has no phone number, in which case they are
* omitted from the request.
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
private class RegisterAccountRequestBody(