diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerView.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerView.kt index 6a0a862b85..b307bfcd75 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerView.kt @@ -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) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt index eb331a5e5e..c6e8a1672c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt @@ -182,7 +182,8 @@ class AppRegistrationNetworkController( aciPreKeys: PreKeyCollection, pniPreKeys: PreKeyCollection?, fcmToken: String?, - skipDeviceTransfer: Boolean + skipDeviceTransfer: Boolean, + aci: ACI? ): RequestResult { return registrationApi.registerAccount( e164 = e164, @@ -194,7 +195,8 @@ class AppRegistrationNetworkController( aciPreKeys = aciPreKeys, pniPreKeys = pniPreKeys, fcmToken = fcmToken, - skipDeviceTransfer = skipDeviceTransfer + skipDeviceTransfer = skipDeviceTransfer, + aci = aci ) } diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/ACISerializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/ACISerializer.kt new file mode 100644 index 0000000000..2285b2ce21 --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/ACISerializer.kt @@ -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 { + 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()) + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt index bb036c8307..15b439bd7d 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -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)) diff --git a/app/src/main/res/drawable/symbol_visible.xml b/core/ui/src/main/res/drawable/symbol_visible_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_visible.xml rename to core/ui/src/main/res/drawable/symbol_visible_24.xml diff --git a/app/src/main/res/drawable/symbol_visible_slash.xml b/core/ui/src/main/res/drawable/symbol_visible_slash_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_visible_slash.xml rename to core/ui/src/main/res/drawable/symbol_visible_slash_24.xml diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt index 505e815731..cd2a35f912 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt @@ -139,13 +139,14 @@ class DebugNetworkController( aciPreKeys: PreKeyCollection, pniPreKeys: PreKeyCollection?, fcmToken: String?, - skipDeviceTransfer: Boolean + skipDeviceTransfer: Boolean, + aci: ACI? ): RequestResult { NetworkDebugState.getOverride>("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( diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt index 525db215da..51e411cbf0 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt @@ -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 { return registrationApi.registerAccount( e164 = e164, @@ -224,7 +226,8 @@ class DemoNetworkController( aciPreKeys = aciPreKeys, pniPreKeys = pniPreKeys, fcmToken = fcmToken, - skipDeviceTransfer = skipDeviceTransfer + skipDeviceTransfer = skipDeviceTransfer, + aci = aci ) } diff --git a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt index 98f4d4e1c3..8e526ea620 100644 --- a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt +++ b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt @@ -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 /** diff --git a/feature/registration/src/main/java/org/signal/registration/PersistedFlowState.kt b/feature/registration/src/main/java/org/signal/registration/PersistedFlowState.kt index e2a0a83b27..94c18deb41 100644 --- a/feature/registration/src/main/java/org/signal/registration/PersistedFlowState.kt +++ b/feature/registration/src/main/java/org/signal/registration/PersistedFlowState.kt @@ -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 ) diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt index 6dc79a9587..aaaeebc8e7 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt @@ -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. */ diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt index 62cdf6c3f8..0cb9fec9d7 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt @@ -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)" } } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index b050ede781..6765000b16 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -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 - 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.navigationEntries( ) } - // -- Signal Login Screen - entry { - val viewModel: SignalLoginViewModel = viewModel( - factory = SignalLoginViewModel.Factory( + // -- Signal Login Credential Entry Screen + entry { + val viewModel: SignalLoginCredentialEntryViewModel = viewModel( + factory = SignalLoginCredentialEntryViewModel.Factory( repository = registrationRepository, parentEventEmitter = registrationViewModel::onEvent ) @@ -741,11 +765,11 @@ private fun EntryProviderScope.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.navigationEntries( val viewModel: RemoteBackupRestoreViewModel = viewModel( factory = RemoteBackupRestoreViewModel.Factory( aep = key.aep, + canNavigateBackwards = key.backwardNavigationAllowed, repository = registrationRepository, parentState = registrationViewModel.state, parentEventEmitter = registrationViewModel::onEvent diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index 77a395157d..1dd8398621 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -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 = 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 = 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()) diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt index 120a675d49..4b53841b50 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt @@ -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 } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/AepInput.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/AepInput.kt new file mode 100644 index 0000000000..d5b31aacb7 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/AepInput.kt @@ -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) + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt index c800a66f7e..35b0f2320e 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt @@ -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 -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt index 5d7cde2a33..1659f290e4 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt @@ -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}).") diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt index f658721d1c..6a3bdb914e 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt @@ -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) ) ) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt index f17708454c..bd188e1257 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt @@ -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 = {} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandler.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandler.kt index 23f8c21280..dc8d120546 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandler.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandler.kt @@ -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 - ) - } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt index 088b1654bb..863d0af1d0 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt @@ -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 diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt index 8b36f5d788..f5c4183d50 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt @@ -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, + private val parentState: StateFlow, 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, + private val parentState: StateFlow, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, private val isPreRegistration: Boolean, private val knownAep: AccountEntropyPool?, diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt index 0900689379..4a7b1a394a 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt @@ -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) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt index 080d1e2afd..6b0c157ee1 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt @@ -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 } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt index 4684a1baec..ae27eab34f 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt @@ -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), diff --git a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreScreen.kt index 27fbc7d40a..9daa5277e1 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreScreen.kt @@ -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)) + } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreState.kt b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreState.kt index a7687ada6a..39dcd9e027 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreState.kt @@ -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, diff --git a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt index 640032a116..8acd5701bb 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt @@ -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, 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, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return RemoteBackupRestoreViewModel(aep, repository, parentState, parentEventEmitter) as T + return RemoteBackupRestoreViewModel(aep, canNavigateBackwards, repository, parentState, parentEventEmitter) as T } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionScreen.kt index 068cf41238..24c0e200f6 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionScreen.kt @@ -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)) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionState.kt b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionState.kt index f2ad7c1f21..ac1618a4b4 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionState.kt @@ -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)" } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt index 5079438a91..bbf0e2163d 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt @@ -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 } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreen.kt deleted file mode 100644 index 3dfb877c26..0000000000 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreen.kt +++ /dev/null @@ -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? = 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 = {} - ) - } -} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreenActions.kt deleted file mode 100644 index 5278e78a03..0000000000 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreenActions.kt +++ /dev/null @@ -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 -} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreenEvents.kt deleted file mode 100644 index 51e4d0bfb4..0000000000 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreenEvents.kt +++ /dev/null @@ -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() -} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginState.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginState.kt deleted file mode 100644 index 115fa9ecc7..0000000000 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginState.kt +++ /dev/null @@ -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 -} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginViewModel.kt deleted file mode 100644 index 5bf64aa653..0000000000 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginViewModel.kt +++ /dev/null @@ -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(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 = _state.asStateFlow() - - private val _actions = Channel(Channel.BUFFERED) - val actions: Flow = _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 create(modelClass: Class): T { - return SignalLoginViewModel(repository, parentEventEmitter) as T - } - } -} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/AccountIdFormat.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/AccountIdFormat.kt new file mode 100644 index 0000000000..e7f2cadded --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/AccountIdFormat.kt @@ -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)) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreen.kt new file mode 100644 index 0000000000..e1b9e1182f --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreen.kt @@ -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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenActions.kt new file mode 100644 index 0000000000..9af8a91395 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenActions.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEvents.kt new file mode 100644 index 0000000000..14f3bc0869 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryState.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryState.kt new file mode 100644 index 0000000000..325f0855a9 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryState.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModel.kt new file mode 100644 index 0000000000..c249c8790b --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModel.kt @@ -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(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 = _state.asStateFlow() + + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _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 create(modelClass: Class): T { + return SignalLoginCredentialEntryViewModel(repository, parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt index 0a2728d91e..73130915e5 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt @@ -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 } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt index ce4ddb967c..fda2aa2d10 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt @@ -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 { diff --git a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt index c88001a511..7188242ac1 100644 --- a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt +++ b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt @@ -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" diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index ba36f2e54c..bf6b5e3d78 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -242,6 +242,10 @@ 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. Restore + + No backup found + + 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. Preparing restore… @@ -654,23 +658,29 @@ Your Signal Login could not be saved. Please save it manually instead. - - - Signal Login + + + Signal Login - Enter your 32-character account key to get started. - - Account key - - Need help? - - Next - - Too long. %1$d/%2$d characters. - - Invalid account key - - Incorrect account key + Enter your account ID followed by your recovery key to restore your account. + + Account ID + + Recovery key + + Show recovery key + + Hide recovery key + + Need help? + + Next + + Too long. %1$d/%2$d characters. + + Invalid account ID + + Incorrect account ID or recovery key diff --git a/feature/registration/src/screenshotTest/kotlin/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests.kt b/feature/registration/src/screenshotTest/kotlin/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests.kt new file mode 100644 index 0000000000..3cdd12de89 --- /dev/null +++ b/feature/registration/src/screenshotTest/kotlin/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests.kt @@ -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 = {} + ) + } + } +} diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_foldable landscape (day)_41c0d57c_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_foldable landscape (day)_41c0d57c_0.png new file mode 100644 index 0000000000..0bb60678d6 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_foldable landscape (day)_41c0d57c_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4cd1462cb348709863f0eac0cdb2be92308c9d7e8eec2721562f60c231f5a8a7 +size 87610 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_foldable portrait (day)_bf09cc79_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_foldable portrait (day)_bf09cc79_0.png new file mode 100644 index 0000000000..3d3333b45b --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_foldable portrait (day)_bf09cc79_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6181b64094fb0da1c3f59acdcc46c958c09fc0ff2e61d5a246c2172938f2a3e5 +size 87700 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_foldable portrait (night)_7382068f_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_foldable portrait (night)_7382068f_0.png new file mode 100644 index 0000000000..d07fbe855d --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_foldable portrait (night)_7382068f_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a3a2ca46117e189d678857e3a4fe7ac0092b6b8ca5d191e464c11477409acbd +size 88170 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_phone landscape (day)_5d9eaff2_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_phone landscape (day)_5d9eaff2_0.png new file mode 100644 index 0000000000..91d40d61f4 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_phone landscape (day)_5d9eaff2_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e8f90ce0441a30e396c64283de3e0caa478ec3796c77727d1c2d5345103ad86 +size 44668 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_phone portrait (day)_e052ce8c_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_phone portrait (day)_e052ce8c_0.png new file mode 100644 index 0000000000..d38543d26e --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_phone portrait (day)_e052ce8c_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f9b7d98b41d6e99eca4f0a83ba7bf61c5c0e21eeba80cca8d443cae961387a9 +size 65630 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_phone portrait (night)_cc3c9530_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_phone portrait (night)_cc3c9530_0.png new file mode 100644 index 0000000000..34b33390c3 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_phone portrait (night)_cc3c9530_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:636f75a3b0fd5403f868c4a7c1887ba8612565832f28cdd43c7eee9192b4a211 +size 66127 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_rtl_5cbb5cfd_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_rtl_5cbb5cfd_0.png new file mode 100644 index 0000000000..d04e3f54fc --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_rtl_5cbb5cfd_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b29584319ef35c3d61803089206ea0bd865256f12720a6f9bac4e1b92888a8f +size 70983 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_tablet landscape (day)_308754cb_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_tablet landscape (day)_308754cb_0.png new file mode 100644 index 0000000000..64f3e3c16c --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_tablet landscape (day)_308754cb_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e318874b37a907aa1f6dd59578e454ad705fcf6a002ff7a2315bc6eb5aed037a +size 95639 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_tablet landscape (night)_d25401fe_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_tablet landscape (night)_d25401fe_0.png new file mode 100644 index 0000000000..0f391aa5d9 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_tablet landscape (night)_d25401fe_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b99f4a69b489277df80dc1c71a9d3bcfa46e96d9673dfd2027803d37fecd8e8c +size 96116 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_tablet portrait (day)_ae17fb11_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_tablet portrait (day)_ae17fb11_0.png new file mode 100644 index 0000000000..e42d43efc4 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenPreview_tablet portrait (day)_ae17fb11_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f2150fbe9db06bec98f0bbd693731a25ada12f11a368afae6dd0c7856586366 +size 92499 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_foldable landscape (day)_41c0d57c_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_foldable landscape (day)_41c0d57c_0.png new file mode 100644 index 0000000000..b741bf3f85 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_foldable landscape (day)_41c0d57c_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31ee6385b9fda0b2b56f7ec6feeaf78867e6708e1da46fff82659e6771138ef6 +size 111836 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_foldable portrait (day)_bf09cc79_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_foldable portrait (day)_bf09cc79_0.png new file mode 100644 index 0000000000..2e354be676 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_foldable portrait (day)_bf09cc79_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98d1ae5efd5aa61dcf3a3a93f9dbfca6e3833e56e7763508e7586c11099f9b43 +size 111868 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_foldable portrait (night)_7382068f_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_foldable portrait (night)_7382068f_0.png new file mode 100644 index 0000000000..7392dbf524 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_foldable portrait (night)_7382068f_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ff8d8d2d222d7de4de1d55fda4129eb6d6ff92f4c2e253f5b74925079f2dc96 +size 112086 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_phone landscape (day)_5d9eaff2_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_phone landscape (day)_5d9eaff2_0.png new file mode 100644 index 0000000000..91d40d61f4 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_phone landscape (day)_5d9eaff2_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e8f90ce0441a30e396c64283de3e0caa478ec3796c77727d1c2d5345103ad86 +size 44668 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_phone portrait (day)_e052ce8c_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_phone portrait (day)_e052ce8c_0.png new file mode 100644 index 0000000000..2178259507 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_phone portrait (day)_e052ce8c_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a10e916d1e7438df204ddd736fac222d69aac90674e541529e2fca06776a2ab1 +size 87932 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_phone portrait (night)_cc3c9530_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_phone portrait (night)_cc3c9530_0.png new file mode 100644 index 0000000000..9df2b2223e --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_phone portrait (night)_cc3c9530_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d03495dad3d164fd1f0321904f0341c1803914c9cfa4770c8f152fe586956085 +size 88181 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_rtl_5cbb5cfd_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_rtl_5cbb5cfd_0.png new file mode 100644 index 0000000000..82663ce1ec --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_rtl_5cbb5cfd_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a7689d4db7c4a674972a7611b716f69616b0c2203c644c8fca43af93944aaed +size 92504 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_tablet landscape (day)_308754cb_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_tablet landscape (day)_308754cb_0.png new file mode 100644 index 0000000000..da3e9ce601 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_tablet landscape (day)_308754cb_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d25fbc34aa1eb3aa61f015f3203bc00b963488e43286a0649dd38036b8e1a78 +size 118159 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_tablet landscape (night)_d25401fe_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_tablet landscape (night)_d25401fe_0.png new file mode 100644 index 0000000000..12b8e75f93 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_tablet landscape (night)_d25401fe_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2dd6791f21d969a27078997ac7eb17f2647b836a3d03caf2b897eddd8ff050be +size 118334 diff --git a/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_tablet portrait (day)_ae17fb11_0.png b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_tablet portrait (day)_ae17fb11_0.png new file mode 100644 index 0000000000..9e3197e3b0 --- /dev/null +++ b/feature/registration/src/screenshotTestDebug/reference/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenScreenshotTests/SignalLoginCredentialEntryScreenRevealedPreview_tablet portrait (day)_ae17fb11_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d45c15675a4c6bd7847421fd7006dbd0cdda2f5f2a6bb73b2eb450bdb6876e05 +size 113490 diff --git a/feature/registration/src/test/java/org/signal/registration/PersistedFlowStateTest.kt b/feature/registration/src/test/java/org/signal/registration/PersistedFlowStateTest.kt index 17f44735ec..76f7d5c916 100644 --- a/feature/registration/src/test/java/org/signal/registration/PersistedFlowStateTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/PersistedFlowStateTest.kt @@ -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)) } diff --git a/feature/registration/src/test/java/org/signal/registration/RegistrationRepositoryTest.kt b/feature/registration/src/test/java/org/signal/registration/RegistrationRepositoryTest.kt index 74de6fce70..57cb057fa3 100644 --- a/feature/registration/src/test/java/org/signal/registration/RegistrationRepositoryTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/RegistrationRepositoryTest.kt @@ -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(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() + } } diff --git a/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt index 0e3be849fe..4e38dcd60e 100644 --- a/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt @@ -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) diff --git a/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt b/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt index 43150a3d58..e93a0da52b 100644 --- a/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt +++ b/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt @@ -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 { - 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) } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModelTest.kt index 94f03c09f3..1fc4d6c250 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModelTest.kt @@ -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(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(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 diff --git a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModelTest.kt index 6224b4fa98..73a4f1e94d 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModelTest.kt @@ -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")) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModelTest.kt index 24291ca42c..d0af7fca85 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModelTest.kt @@ -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(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(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(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")) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandlerTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandlerTest.kt index 1f67f27391..eeb1c99a3d 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandlerTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandlerTest.kt @@ -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") } } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepScreenTest.kt index 6b60435282..e4ac644ff6 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepScreenTest.kt @@ -101,8 +101,7 @@ class EnterAepScreenTest { SignalTheme { EnterAepScreen( state = EnterAepState( - isBackupKeyValid = true, - aepValidationError = null, + recoveryKey = AepInput(isValid = true), isRegistering = false ), onEvent = { event -> diff --git a/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt index af6bb3e812..b3a0f90efb 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt @@ -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) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt index dbbb653a31..e052c23771 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt @@ -125,7 +125,7 @@ class PinEntryForRegistrationLockViewModelTest { .isInstanceOf() .prop(RegistrationFlowEvent.NavigateToScreen::route) .isEqualTo(RegistrationRoute.ArchiveRestoreSelection.forPostRegisterWithPinKnown()) - coVerify { mockRepository.restoreAccountRecord(any()) } + coVerify(exactly = 0) { mockRepository.restoreAccountRecord(any()) } assertThat(emittedStates.last().loading).isEqualTo(true) } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModelTest.kt index 553669144b..56b57d22bc 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModelTest.kt @@ -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 ==================== diff --git a/feature/registration/src/test/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModelTest.kt index e45b381b2a..1b887a266a 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModelTest.kt @@ -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() diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogin/SignalLoginScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogin/SignalLoginScreenTest.kt deleted file mode 100644 index e91776e2a7..0000000000 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogin/SignalLoginScreenTest.kt +++ /dev/null @@ -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() - - @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 } - ) - } - } - } -} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogin/SignalLoginViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogin/SignalLoginViewModelTest.kt deleted file mode 100644 index 51af41aa38..0000000000 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogin/SignalLoginViewModelTest.kt +++ /dev/null @@ -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() - - 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() - 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() - val states = mutableListOf() - - 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 - } -} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogin/AccountKeyVisualTransformationTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/AccountIdVisualTransformationTest.kt similarity index 82% rename from feature/registration/src/test/java/org/signal/registration/screens/signallogin/AccountKeyVisualTransformationTest.kt rename to feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/AccountIdVisualTransformationTest.kt index bdfa12eeca..82ac0a539c 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogin/AccountKeyVisualTransformationTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/AccountIdVisualTransformationTest.kt @@ -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 } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenTest.kt new file mode 100644 index 0000000000..0204cab178 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenTest.kt @@ -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() + + @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 } + ) + } + } + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModelTest.kt new file mode 100644 index 0000000000..d92f4447e8 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModelTest.kt @@ -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 + private lateinit var emittedStates: MutableList + 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() + 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() + assertThat(emittedParentEvents[1]) + .isInstanceOf() + .prop(RegistrationFlowEvent.Registered::phoneNumberless) + .isEqualTo(true) + + val route = assertThat(emittedParentEvents[2]) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + + 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() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + .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() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + .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() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + + @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() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + + @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 { + val keyMaterial = mockk(relaxed = true) { + every { accountEntropyPool } returns aep + } + val response = mockk(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) + } + } +} diff --git a/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt b/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt index 573c4dfe1e..b3a67f8f72 100644 --- a/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt +++ b/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt @@ -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 { - 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(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(