mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Add basic ability to login to an existing numberless account.
This commit is contained in:
committed by
Alex Hart
parent
cac953c724
commit
59d8b5f61f
+1
-1
@@ -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) }
|
||||
)
|
||||
|
||||
+4
-2
@@ -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))
|
||||
|
||||
+3
-2
@@ -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(
|
||||
|
||||
+5
-2
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
-12
@@ -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
|
||||
|
||||
+100
-29
@@ -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
|
||||
}
|
||||
|
||||
+57
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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 -> {
|
||||
|
||||
+8
-5
@@ -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}).")
|
||||
|
||||
+4
-4
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+34
-26
@@ -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 = {}
|
||||
|
||||
+1
-37
@@ -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
-9
@@ -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
|
||||
|
||||
+8
-4
@@ -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?,
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+9
-4
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+21
-5
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+41
-18
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-1
@@ -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))
|
||||
|
||||
|
||||
+4
-2
@@ -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)"
|
||||
}
|
||||
|
||||
+6
-2
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-421
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
-11
@@ -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
|
||||
}
|
||||
-30
@@ -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()
|
||||
}
|
||||
-50
@@ -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
|
||||
}
|
||||
-118
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -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))
|
||||
}
|
||||
}
|
||||
+493
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
+35
@@ -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()
|
||||
}
|
||||
+61
@@ -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
|
||||
}
|
||||
+253
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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. -->
|
||||
|
||||
+51
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4cd1462cb348709863f0eac0cdb2be92308c9d7e8eec2721562f60c231f5a8a7
|
||||
size 87610
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6181b64094fb0da1c3f59acdcc46c958c09fc0ff2e61d5a246c2172938f2a3e5
|
||||
size 87700
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1a3a2ca46117e189d678857e3a4fe7ac0092b6b8ca5d191e464c11477409acbd
|
||||
size 88170
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0e8f90ce0441a30e396c64283de3e0caa478ec3796c77727d1c2d5345103ad86
|
||||
size 44668
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4f9b7d98b41d6e99eca4f0a83ba7bf61c5c0e21eeba80cca8d443cae961387a9
|
||||
size 65630
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:636f75a3b0fd5403f868c4a7c1887ba8612565832f28cdd43c7eee9192b4a211
|
||||
size 66127
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8b29584319ef35c3d61803089206ea0bd865256f12720a6f9bac4e1b92888a8f
|
||||
size 70983
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e318874b37a907aa1f6dd59578e454ad705fcf6a002ff7a2315bc6eb5aed037a
|
||||
size 95639
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b99f4a69b489277df80dc1c71a9d3bcfa46e96d9673dfd2027803d37fecd8e8c
|
||||
size 96116
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8f2150fbe9db06bec98f0bbd693731a25ada12f11a368afae6dd0c7856586366
|
||||
size 92499
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:31ee6385b9fda0b2b56f7ec6feeaf78867e6708e1da46fff82659e6771138ef6
|
||||
size 111836
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:98d1ae5efd5aa61dcf3a3a93f9dbfca6e3833e56e7763508e7586c11099f9b43
|
||||
size 111868
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0ff8d8d2d222d7de4de1d55fda4129eb6d6ff92f4c2e253f5b74925079f2dc96
|
||||
size 112086
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0e8f90ce0441a30e396c64283de3e0caa478ec3796c77727d1c2d5345103ad86
|
||||
size 44668
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a10e916d1e7438df204ddd736fac222d69aac90674e541529e2fca06776a2ab1
|
||||
size 87932
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d03495dad3d164fd1f0321904f0341c1803914c9cfa4770c8f152fe586956085
|
||||
size 88181
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1a7689d4db7c4a674972a7611b716f69616b0c2203c644c8fca43af93944aaed
|
||||
size 92504
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9d25fbc34aa1eb3aa61f015f3203bc00b963488e43286a0649dd38036b8e1a78
|
||||
size 118159
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2dd6791f21d969a27078997ac7eb17f2647b836a3d03caf2b897eddd8ff050be
|
||||
size 118334
|
||||
+3
@@ -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))
|
||||
}
|
||||
|
||||
+104
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+23
-2
@@ -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)
|
||||
|
||||
+6
-4
@@ -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)
|
||||
}
|
||||
|
||||
+11
-11
@@ -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
|
||||
|
||||
+12
-11
@@ -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"))
|
||||
|
||||
|
||||
+14
-14
@@ -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"))
|
||||
|
||||
+2
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -101,8 +101,7 @@ class EnterAepScreenTest {
|
||||
SignalTheme {
|
||||
EnterAepScreen(
|
||||
state = EnterAepState(
|
||||
isBackupKeyValid = true,
|
||||
aepValidationError = null,
|
||||
recoveryKey = AepInput(isValid = true),
|
||||
isRegistering = false
|
||||
),
|
||||
onEvent = { event ->
|
||||
|
||||
+24
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+88
-7
@@ -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 ====================
|
||||
|
||||
+2
-1
@@ -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()
|
||||
|
||||
-101
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-148
@@ -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
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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
|
||||
}
|
||||
+150
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+414
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user