Repo cloned
This commit is contained in:
commit
496ae75f58
7988 changed files with 1451097 additions and 0 deletions
72
registration/lib/build.gradle.kts
Normal file
72
registration/lib/build.gradle.kts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
plugins {
|
||||
id("signal-library")
|
||||
id("kotlin-parcelize")
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.signal.registration"
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.ui.test.junit4)
|
||||
lintChecks(project(":lintchecks"))
|
||||
|
||||
// Project dependencies
|
||||
implementation(project(":core-ui"))
|
||||
implementation(project(":core-util"))
|
||||
implementation(project(":core-models"))
|
||||
implementation(libs.libsignal.android)
|
||||
|
||||
// Compose BOM
|
||||
platform(libs.androidx.compose.bom).let { composeBom ->
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
}
|
||||
|
||||
// Compose dependencies
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling.core)
|
||||
|
||||
// Navigation 3
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
|
||||
// Kotlinx Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
|
||||
// Permissions
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
// Phone number formatting
|
||||
implementation(libs.google.libphonenumber)
|
||||
|
||||
// Testing
|
||||
testImplementation(testLibs.junit.junit)
|
||||
testImplementation(testLibs.mockk)
|
||||
testImplementation(testLibs.assertk)
|
||||
testImplementation(testLibs.kotlinx.coroutines.test)
|
||||
testImplementation(testLibs.robolectric.robolectric)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(testLibs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
implementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
19
registration/lib/src/main/AndroidManifest.xml
Normal file
19
registration/lib/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".RegistrationActivity"
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.signal.core.models.MasterKey
|
||||
import org.signal.core.util.serialization.ByteArrayToBase64Serializer
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration
|
||||
|
||||
interface NetworkController {
|
||||
|
||||
/**
|
||||
* Request that the service initialize a new registration session.
|
||||
*
|
||||
* `POST /v1/verification/session`
|
||||
*/
|
||||
suspend fun createSession(e164: String, fcmToken: String?, mcc: String?, mnc: String?): RegistrationNetworkResult<SessionMetadata, CreateSessionError>
|
||||
|
||||
/**
|
||||
* Retrieve current status of a registration session.
|
||||
*
|
||||
* `GET /v1/verification/session/{session-id}`
|
||||
*/
|
||||
suspend fun getSession(sessionId: String): RegistrationNetworkResult<SessionMetadata, GetSessionStatusError>
|
||||
|
||||
/**
|
||||
* Update the session with new information.
|
||||
*
|
||||
* `PATCH /v1/verification/session/{session-id}`
|
||||
*/
|
||||
suspend fun updateSession(sessionId: String?, pushChallengeToken: String?, captchaToken: String?): RegistrationNetworkResult<SessionMetadata, UpdateSessionError>
|
||||
|
||||
/**
|
||||
* Request an SMS verification code. On success, the server will send an SMS verification code to this Signal user.
|
||||
*
|
||||
* `POST /v1/verification/session/{session-id}/code`
|
||||
*
|
||||
* @param androidSmsRetrieverSupported whether the system framework will automatically parse the incoming verification message.
|
||||
*/
|
||||
suspend fun requestVerificationCode(
|
||||
sessionId: String,
|
||||
locale: Locale?,
|
||||
androidSmsRetrieverSupported: Boolean,
|
||||
transport: VerificationCodeTransport
|
||||
): RegistrationNetworkResult<SessionMetadata, RequestVerificationCodeError>
|
||||
|
||||
/**
|
||||
* Submit a verification code sent by the service via one of the supported channels (SMS, phone call) to prove the registrant's control of the phone number.
|
||||
*
|
||||
* `PUT /v1/verification/session/{session-id}/code`
|
||||
*/
|
||||
suspend fun submitVerificationCode(sessionId: String, verificationCode: String): RegistrationNetworkResult<SessionMetadata, SubmitVerificationCodeError>
|
||||
|
||||
/**
|
||||
* Officially register an account.
|
||||
* Must provide one of ([sessionId], [recoveryPassword]), but not both.
|
||||
*
|
||||
* `POST /v1/registration`
|
||||
*
|
||||
* @param e164 The phone number in E.164 format (used as username for basic auth)
|
||||
* @param password The password for basic auth
|
||||
*/
|
||||
suspend fun registerAccount(
|
||||
e164: String,
|
||||
password: String,
|
||||
sessionId: String?,
|
||||
recoveryPassword: String?,
|
||||
attributes: AccountAttributes,
|
||||
aciPreKeys: PreKeyCollection,
|
||||
pniPreKeys: PreKeyCollection,
|
||||
fcmToken: String?,
|
||||
skipDeviceTransfer: Boolean
|
||||
): RegistrationNetworkResult<RegisterAccountResponse, RegisterAccountError>
|
||||
|
||||
/**
|
||||
* Retrieves an FCM token, if possible. Null means that this device does not support FCM.
|
||||
*/
|
||||
suspend fun getFcmToken(): String?
|
||||
|
||||
/**
|
||||
* Waits for a push challenge token to arrive via FCM.
|
||||
* This is a suspending function that will complete when the token arrives.
|
||||
* The caller should wrap this in withTimeoutOrNull to handle timeout scenarios.
|
||||
*
|
||||
* @return The push challenge token, or null if cancelled/unavailable.
|
||||
*/
|
||||
suspend fun awaitPushChallengeToken(): String?
|
||||
|
||||
/**
|
||||
* Returns the URL to load in the WebView for captcha verification.
|
||||
*/
|
||||
fun getCaptchaUrl(): String
|
||||
|
||||
/**
|
||||
* Attempts to restore the master key from SVR using the provided credentials and PIN.
|
||||
*
|
||||
* This is called when the user encounters a registration lock and needs to prove
|
||||
* they know their PIN to proceed with registration.
|
||||
*
|
||||
* @param svr2Credentials The SVR2 credentials provided by the server during the registration lock response.
|
||||
* @param pin The user-entered PIN.
|
||||
* @return The restored master key on success, or an appropriate error.
|
||||
*/
|
||||
suspend fun restoreMasterKeyFromSvr(
|
||||
svr2Credentials: SvrCredentials,
|
||||
pin: String
|
||||
): RegistrationNetworkResult<MasterKeyResponse, RestoreMasterKeyError>
|
||||
|
||||
/**
|
||||
* Backs up the master key to SVR, protected by the user's PIN.
|
||||
*
|
||||
* @param pin The user-chosen PIN to protect the backup.
|
||||
* @param masterKey The master key to backup.
|
||||
* @return Success or an appropriate error.
|
||||
*/
|
||||
suspend fun setPinAndMasterKeyOnSvr(
|
||||
pin: String,
|
||||
masterKey: MasterKey
|
||||
): RegistrationNetworkResult<Unit, BackupMasterKeyError>
|
||||
|
||||
/**
|
||||
* Enables registration lock on the account using the registration lock token
|
||||
* derived from the master key.
|
||||
*
|
||||
* @return Success or an appropriate error.
|
||||
*/
|
||||
suspend fun enableRegistrationLock(): RegistrationNetworkResult<Unit, SetRegistrationLockError>
|
||||
|
||||
/**
|
||||
* Disables registration lock on the account.
|
||||
*
|
||||
* @return Success or an appropriate error.
|
||||
*/
|
||||
suspend fun disableRegistrationLock(): RegistrationNetworkResult<Unit, SetRegistrationLockError>
|
||||
|
||||
// TODO
|
||||
// /**
|
||||
// * Validates the provided SVR2 auth credentials, returning information on their usability.
|
||||
// *
|
||||
// * `POST /v2/svr/auth/check`
|
||||
// */
|
||||
// suspend fun validateSvr2AuthCredential(e164: String, usernamePasswords: List<String>)
|
||||
//
|
||||
// /**
|
||||
// * Validates the provided SVR3 auth credentials, returning information on their usability.
|
||||
// *
|
||||
// * `POST /v3/backup/auth/check`
|
||||
// */
|
||||
// suspend fun validateSvr3AuthCredential(e164: String, usernamePasswords: List<String>)
|
||||
//
|
||||
// /**
|
||||
// * Set [RestoreMethod] enum on the server for use by the old device to update UX.
|
||||
// */
|
||||
// suspend fun setRestoreMethod(token: String, method: RestoreMethod)
|
||||
//
|
||||
// /**
|
||||
// * Registers a device as a linked device on a pre-existing account.
|
||||
// *
|
||||
// * `PUT /v1/devices/link`
|
||||
// *
|
||||
// * - 403: Incorrect account verification
|
||||
// * - 409: Device missing required account capability
|
||||
// * - 411: Account reached max number of linked devices
|
||||
// * - 422: Request is invalid
|
||||
// * - 429: Rate limited
|
||||
// */
|
||||
// suspend fun registerAsSecondaryDevice(verificationCode: String, attributes: AccountAttributes, aciPreKeys: PreKeyCollection, pniPreKeys: PreKeyCollection, fcmToken: String?)
|
||||
|
||||
sealed interface RegistrationNetworkResult<out SuccessModel, out FailureModel> {
|
||||
data class Success<T>(val data: T) : RegistrationNetworkResult<T, Nothing>
|
||||
data class Failure<T>(val error: T) : RegistrationNetworkResult<Nothing, T>
|
||||
data class NetworkError(val exception: IOException) : RegistrationNetworkResult<Nothing, Nothing>
|
||||
data class ApplicationError(val exception: Throwable) : RegistrationNetworkResult<Nothing, Nothing>
|
||||
}
|
||||
|
||||
sealed class CreateSessionError() {
|
||||
data class InvalidRequest(val message: String) : CreateSessionError()
|
||||
data class RateLimited(val retryAfter: Duration) : CreateSessionError()
|
||||
}
|
||||
|
||||
sealed class GetSessionStatusError() {
|
||||
data class InvalidSessionId(val message: String) : GetSessionStatusError()
|
||||
data class SessionNotFound(val message: String) : GetSessionStatusError()
|
||||
data class InvalidRequest(val message: String) : GetSessionStatusError()
|
||||
}
|
||||
|
||||
sealed class UpdateSessionError() {
|
||||
data class RejectedUpdate(val message: String) : UpdateSessionError()
|
||||
data class InvalidRequest(val message: String) : UpdateSessionError()
|
||||
data class RateLimited(val retryAfter: Duration, val session: SessionMetadata) : UpdateSessionError()
|
||||
}
|
||||
|
||||
sealed class RequestVerificationCodeError() {
|
||||
data class InvalidSessionId(val message: String) : RequestVerificationCodeError()
|
||||
data class SessionNotFound(val message: String) : RequestVerificationCodeError()
|
||||
data class MissingRequestInformationOrAlreadyVerified(val session: SessionMetadata) : RequestVerificationCodeError()
|
||||
data class CouldNotFulfillWithRequestedTransport(val session: SessionMetadata) : RequestVerificationCodeError()
|
||||
data class InvalidRequest(val message: String) : RequestVerificationCodeError()
|
||||
data class RateLimited(val retryAfter: Duration, val session: SessionMetadata) : RequestVerificationCodeError()
|
||||
data class ThirdPartyServiceError(val data: ThirdPartyServiceErrorResponse) : RequestVerificationCodeError()
|
||||
}
|
||||
|
||||
sealed class SubmitVerificationCodeError() {
|
||||
data class InvalidSessionIdOrVerificationCode(val message: String) : SubmitVerificationCodeError()
|
||||
data class SessionNotFound(val message: String) : SubmitVerificationCodeError()
|
||||
data class SessionAlreadyVerifiedOrNoCodeRequested(val session: SessionMetadata) : SubmitVerificationCodeError()
|
||||
data class RateLimited(val retryAfter: Duration, val session: SessionMetadata) : SubmitVerificationCodeError()
|
||||
}
|
||||
|
||||
sealed class RegisterAccountError() {
|
||||
data class SessionNotFoundOrNotVerified(val message: String) : RegisterAccountError()
|
||||
data class RegistrationRecoveryPasswordIncorrect(val message: String) : RegisterAccountError()
|
||||
data object DeviceTransferPossible : RegisterAccountError()
|
||||
data class InvalidRequest(val message: String) : RegisterAccountError()
|
||||
data class RegistrationLock(val data: RegistrationLockResponse) : RegisterAccountError()
|
||||
data class RateLimited(val retryAfter: Duration) : RegisterAccountError()
|
||||
}
|
||||
|
||||
sealed class RestoreMasterKeyError() {
|
||||
data class WrongPin(val triesRemaining: Int) : RestoreMasterKeyError()
|
||||
data object NoDataFound : RestoreMasterKeyError()
|
||||
}
|
||||
|
||||
sealed class BackupMasterKeyError() {
|
||||
data object EnclaveNotFound : BackupMasterKeyError()
|
||||
data object NotRegistered : BackupMasterKeyError()
|
||||
}
|
||||
|
||||
sealed class SetRegistrationLockError() {
|
||||
data class InvalidRequest(val message: String) : SetRegistrationLockError()
|
||||
data object Unauthorized : SetRegistrationLockError()
|
||||
data object NotRegistered : SetRegistrationLockError()
|
||||
data object NoPinSet : SetRegistrationLockError()
|
||||
}
|
||||
|
||||
data class MasterKeyResponse(
|
||||
val masterKey: MasterKey
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class SessionMetadata(
|
||||
val id: String,
|
||||
val nextSms: Long?,
|
||||
val nextCall: Long?,
|
||||
val nextVerificationAttempt: Long?,
|
||||
val allowedToRequestCode: Boolean,
|
||||
val requestedInformation: List<String>,
|
||||
val verified: Boolean
|
||||
) : Parcelable
|
||||
|
||||
@Serializable
|
||||
class AccountAttributes(
|
||||
val signalingKey: String?,
|
||||
val registrationId: Int,
|
||||
val voice: Boolean = true,
|
||||
val video: Boolean = true,
|
||||
val fetchesMessages: Boolean,
|
||||
val registrationLock: String?,
|
||||
@Serializable(with = ByteArrayToBase64Serializer::class)
|
||||
val unidentifiedAccessKey: ByteArray?,
|
||||
val unrestrictedUnidentifiedAccess: Boolean,
|
||||
val discoverableByPhoneNumber: Boolean,
|
||||
val capabilities: Capabilities?,
|
||||
val name: String?,
|
||||
val pniRegistrationId: Int,
|
||||
val recoveryPassword: String?
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class Capabilities(
|
||||
val storage: Boolean,
|
||||
val versionedExpirationTimer: Boolean,
|
||||
val attachmentBackfill: Boolean,
|
||||
val spqr: Boolean
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class RegisterAccountResponse(
|
||||
@SerialName("uuid") val aci: String,
|
||||
val pni: String,
|
||||
@SerialName("number") val e164: String,
|
||||
val usernameHash: String?,
|
||||
val usernameLinkHandle: String?,
|
||||
val storageCapable: Boolean,
|
||||
val entitlements: Entitlements?,
|
||||
val reregistration: Boolean
|
||||
) : Parcelable {
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class Entitlements(
|
||||
val badges: List<Badge>,
|
||||
val backup: Backup?
|
||||
) : Parcelable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class Badge(
|
||||
val id: String,
|
||||
val expirationSeconds: Long,
|
||||
val visible: Boolean
|
||||
) : Parcelable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class Backup(
|
||||
val backupLevel: Long,
|
||||
val expirationSeconds: Long
|
||||
) : Parcelable
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RegistrationLockResponse(
|
||||
val timeRemaining: Long,
|
||||
val svr2Credentials: SvrCredentials
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class SvrCredentials(
|
||||
val username: String,
|
||||
val password: String
|
||||
) : Parcelable
|
||||
|
||||
@Serializable
|
||||
data class ThirdPartyServiceErrorResponse(
|
||||
val reason: String,
|
||||
val permanentFailure: Boolean
|
||||
)
|
||||
|
||||
data class PreKeyCollection(
|
||||
val identityKey: IdentityKey,
|
||||
val signedPreKey: SignedPreKeyRecord,
|
||||
val lastResortKyberPreKey: KyberPreKeyRecord
|
||||
)
|
||||
|
||||
enum class VerificationCodeTransport {
|
||||
SMS, VOICE
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package org.signal.registration
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.material3.Surface
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.registration.screens.RegistrationHostScreen
|
||||
|
||||
/**
|
||||
* Activity entry point for the registration flow.
|
||||
*
|
||||
* This activity can be launched from the main app to start the registration process.
|
||||
* Upon successful completion, it will return RESULT_OK.
|
||||
*/
|
||||
class RegistrationActivity : ComponentActivity() {
|
||||
|
||||
private val repository: RegistrationRepository by lazy {
|
||||
RegistrationRepository(
|
||||
networkController = RegistrationDependencies.get().networkController,
|
||||
storageController = RegistrationDependencies.get().storageController
|
||||
)
|
||||
}
|
||||
|
||||
private val viewModel: RegistrationViewModel by viewModels(factoryProducer = {
|
||||
RegistrationViewModel.Factory(
|
||||
repository = repository
|
||||
)
|
||||
})
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
permissions = viewModel.getRequiredPermissions()
|
||||
)
|
||||
|
||||
SignalTheme(incognitoKeyboardEnabled = false) {
|
||||
Surface {
|
||||
RegistrationHostScreen(
|
||||
registrationRepository = repository,
|
||||
viewModel = viewModel,
|
||||
permissionsState = permissionsState,
|
||||
onRegistrationComplete = {
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates an intent to launch the RegistrationActivity.
|
||||
*
|
||||
* @param context The context used to create the intent.
|
||||
* @return An intent that can be used to start the RegistrationActivity.
|
||||
*/
|
||||
fun createIntent(context: Context): Intent {
|
||||
return Intent(context, RegistrationActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity result contract for launching the registration flow.
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* val registrationLauncher = registerForActivityResult(RegistrationContract()) { success ->
|
||||
* if (success) {
|
||||
* // Registration completed successfully
|
||||
* } else {
|
||||
* // Registration was cancelled or failed
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* registrationLauncher.launch(Unit)
|
||||
* ```
|
||||
*/
|
||||
class RegistrationContract : ActivityResultContract<Unit, Boolean>() {
|
||||
override fun createIntent(context: Context, input: Unit): Intent {
|
||||
return createIntent(context)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||
return resultCode == RESULT_OK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration
|
||||
|
||||
/**
|
||||
* Injection point for dependencies needed by this module.
|
||||
*/
|
||||
class RegistrationDependencies(
|
||||
val networkController: NetworkController,
|
||||
val storageController: StorageController
|
||||
) {
|
||||
companion object {
|
||||
lateinit var dependencies: RegistrationDependencies
|
||||
|
||||
fun provide(registrationDependencies: RegistrationDependencies) {
|
||||
dependencies = registrationDependencies
|
||||
}
|
||||
|
||||
fun get(): RegistrationDependencies = dependencies
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration
|
||||
|
||||
import org.signal.core.models.MasterKey
|
||||
|
||||
sealed interface RegistrationFlowEvent {
|
||||
data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent
|
||||
data object NavigateBack : RegistrationFlowEvent
|
||||
data object ResetState : RegistrationFlowEvent
|
||||
data class SessionUpdated(val session: NetworkController.SessionMetadata) : RegistrationFlowEvent
|
||||
data class E164Chosen(val e164: String) : RegistrationFlowEvent
|
||||
data class MasterKeyRestoredForRegistrationLock(val masterKey: MasterKey) : RegistrationFlowEvent
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parceler
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.TypeParceler
|
||||
import org.signal.core.models.MasterKey
|
||||
|
||||
@Parcelize
|
||||
@TypeParceler<MasterKey?, MasterKeyParceler>
|
||||
data class RegistrationFlowState(
|
||||
val backStack: List<RegistrationRoute> = listOf(RegistrationRoute.Welcome),
|
||||
val sessionMetadata: NetworkController.SessionMetadata? = null,
|
||||
val sessionE164: String? = null,
|
||||
val masterKey: MasterKey? = null,
|
||||
val registrationLockProof: String? = null
|
||||
) : Parcelable
|
||||
|
||||
object MasterKeyParceler : Parceler<MasterKey?> {
|
||||
override fun create(parcel: Parcel): MasterKey? {
|
||||
val bytes = parcel.createByteArray()
|
||||
return bytes?.let { MasterKey(it) }
|
||||
}
|
||||
|
||||
override fun MasterKey?.write(parcel: Parcel, flags: Int) {
|
||||
parcel.writeByteArray(this?.serialize())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,427 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberDecoratedNavEntries
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.signal.core.ui.navigation.ResultEffect
|
||||
import org.signal.registration.screens.accountlocked.AccountLockedScreen
|
||||
import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents
|
||||
import org.signal.registration.screens.accountlocked.AccountLockedState
|
||||
import org.signal.registration.screens.captcha.CaptchaScreen
|
||||
import org.signal.registration.screens.captcha.CaptchaScreenEvents
|
||||
import org.signal.registration.screens.captcha.CaptchaState
|
||||
import org.signal.registration.screens.permissions.PermissionsScreen
|
||||
import org.signal.registration.screens.phonenumber.PhoneNumberEntryScreenEvents
|
||||
import org.signal.registration.screens.phonenumber.PhoneNumberEntryViewModel
|
||||
import org.signal.registration.screens.phonenumber.PhoneNumberScreen
|
||||
import org.signal.registration.screens.pincreation.PinCreationScreen
|
||||
import org.signal.registration.screens.pincreation.PinCreationScreenEvents
|
||||
import org.signal.registration.screens.pincreation.PinCreationState
|
||||
import org.signal.registration.screens.pinentry.PinEntryScreen
|
||||
import org.signal.registration.screens.registrationlock.RegistrationLockPinEntryViewModel
|
||||
import org.signal.registration.screens.restore.RestoreViaQrScreen
|
||||
import org.signal.registration.screens.restore.RestoreViaQrScreenEvents
|
||||
import org.signal.registration.screens.restore.RestoreViaQrState
|
||||
import org.signal.registration.screens.verificationcode.VerificationCodeScreen
|
||||
import org.signal.registration.screens.verificationcode.VerificationCodeViewModel
|
||||
import org.signal.registration.screens.welcome.WelcomeScreen
|
||||
import org.signal.registration.screens.welcome.WelcomeScreenEvents
|
||||
|
||||
/**
|
||||
* Navigation routes for the registration flow.
|
||||
* Using @Serializable and NavKey for type-safe navigation with Navigation 3.
|
||||
*/
|
||||
@Parcelize
|
||||
sealed interface RegistrationRoute : NavKey, Parcelable {
|
||||
@Serializable
|
||||
data object Welcome : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data class Permissions(val forRestore: Boolean = false) : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data object PhoneNumberEntry : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data object CountryCodePicker : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data class VerificationCodeEntry(val session: NetworkController.SessionMetadata, val e164: String) : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data class Captcha(val session: NetworkController.SessionMetadata) : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data object PinEntry : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data class RegistrationLockPinEntry(
|
||||
val timeRemaining: Long,
|
||||
val svrCredentials: NetworkController.SvrCredentials
|
||||
) : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data class AccountLocked(val timeRemainingMs: Long) : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data object Profile : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data object PinSetup : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data object Restore : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data object RestoreViaQr : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data object Transfer : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data class FullyComplete(val registeredData: NetworkController.RegisterAccountResponse) : RegistrationRoute
|
||||
}
|
||||
|
||||
private const val CAPTCHA_RESULT = "captcha_token"
|
||||
|
||||
/**
|
||||
* Sets up the navigation graph for the registration flow using Navigation 3.
|
||||
*
|
||||
* @param registrationViewModel The shared ViewModel for the registration flow.
|
||||
* @param permissionsState The permissions state managed at the activity level.
|
||||
* @param modifier Modifier to be applied to the NavDisplay.
|
||||
* @param onRegistrationComplete Callback invoked when registration is successfully completed.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun RegistrationNavHost(
|
||||
registrationRepository: RegistrationRepository,
|
||||
registrationViewModel: RegistrationViewModel,
|
||||
permissionsState: MultiplePermissionsState,
|
||||
modifier: Modifier = Modifier,
|
||||
onRegistrationComplete: () -> Unit = {}
|
||||
) {
|
||||
val registrationState by registrationViewModel.state.collectAsStateWithLifecycle()
|
||||
val navigator = remember { RegistrationNavigator(eventEmitter = registrationViewModel::onEvent) }
|
||||
|
||||
val entryProvider = entryProvider {
|
||||
registrationEntries(
|
||||
registrationRepository = registrationRepository,
|
||||
registrationViewModel = registrationViewModel,
|
||||
permissionsState = permissionsState,
|
||||
navigator = navigator,
|
||||
onRegistrationComplete = onRegistrationComplete
|
||||
)
|
||||
}
|
||||
|
||||
val decorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator<NavKey>()
|
||||
)
|
||||
|
||||
val entries = rememberDecoratedNavEntries(
|
||||
backStack = registrationState.backStack,
|
||||
entryDecorators = decorators,
|
||||
entryProvider = entryProvider
|
||||
)
|
||||
|
||||
NavDisplay(
|
||||
entries = entries,
|
||||
onBack = { registrationViewModel.onEvent(RegistrationFlowEvent.NavigateBack) },
|
||||
modifier = modifier,
|
||||
transitionSpec = {
|
||||
// Slide in from right and fade in when navigating forward
|
||||
(
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(200)
|
||||
) + fadeIn(animationSpec = tween(200))
|
||||
) togetherWith
|
||||
// Slide out to left and fade out
|
||||
(
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { -it },
|
||||
animationSpec = tween(200)
|
||||
) + fadeOut(animationSpec = tween(200))
|
||||
)
|
||||
},
|
||||
popTransitionSpec = {
|
||||
// Slide in from left and fade in when navigating back
|
||||
(
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -it },
|
||||
animationSpec = tween(200)
|
||||
) + fadeIn(animationSpec = tween(200))
|
||||
) togetherWith
|
||||
// Slide out to right and fade out
|
||||
(
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(200)
|
||||
) + fadeOut(animationSpec = tween(200))
|
||||
)
|
||||
},
|
||||
predictivePopTransitionSpec = {
|
||||
// Same as popTransitionSpec for predictive back gestures
|
||||
(
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -it },
|
||||
animationSpec = tween(200)
|
||||
) + fadeIn(animationSpec = tween(200))
|
||||
) togetherWith
|
||||
(
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(200)
|
||||
) + fadeOut(animationSpec = tween(200))
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines all navigation entries for the registration flow.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
private fun EntryProviderScope<NavKey>.registrationEntries(
|
||||
registrationRepository: RegistrationRepository,
|
||||
registrationViewModel: RegistrationViewModel,
|
||||
permissionsState: MultiplePermissionsState,
|
||||
navigator: RegistrationNavigator,
|
||||
onRegistrationComplete: () -> Unit
|
||||
) {
|
||||
// --- Welcome Screen
|
||||
entry<RegistrationRoute.Welcome> {
|
||||
WelcomeScreen(
|
||||
onEvent = { event ->
|
||||
when (event) {
|
||||
WelcomeScreenEvents.Continue -> navigator.navigate(RegistrationRoute.Permissions(forRestore = false))
|
||||
WelcomeScreenEvents.DoesNotHaveOldPhone -> navigator.navigate(RegistrationRoute.Restore)
|
||||
WelcomeScreenEvents.HasOldPhone -> navigator.navigate(RegistrationRoute.Permissions(forRestore = true))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// --- Permissions Screen
|
||||
entry<RegistrationRoute.Permissions> { key ->
|
||||
PermissionsScreen(
|
||||
permissionsState = permissionsState,
|
||||
onProceed = {
|
||||
if (key.forRestore) {
|
||||
navigator.navigate(RegistrationRoute.RestoreViaQr)
|
||||
} else {
|
||||
navigator.navigate(RegistrationRoute.PhoneNumberEntry)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// -- Phone Number Entry Screen
|
||||
entry<RegistrationRoute.PhoneNumberEntry> {
|
||||
val viewModel: PhoneNumberEntryViewModel = viewModel(
|
||||
factory = PhoneNumberEntryViewModel.Factory(
|
||||
repository = registrationRepository,
|
||||
parentState = registrationViewModel.state,
|
||||
parentEventEmitter = registrationViewModel::onEvent
|
||||
)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
ResultEffect<String?>(registrationViewModel.resultBus, CAPTCHA_RESULT) { captchaToken ->
|
||||
if (captchaToken != null) {
|
||||
viewModel.onEvent(PhoneNumberEntryScreenEvents.CaptchaCompleted(captchaToken))
|
||||
}
|
||||
}
|
||||
|
||||
PhoneNumberScreen(
|
||||
state = state,
|
||||
onEvent = { viewModel.onEvent(it) }
|
||||
)
|
||||
}
|
||||
|
||||
// -- Country Code Picker
|
||||
entry<RegistrationRoute.CountryCodePicker> {
|
||||
// We'll also want this to be some sort of launch-for-result flow as well
|
||||
TODO()
|
||||
}
|
||||
|
||||
// -- Captcha Screen
|
||||
entry<RegistrationRoute.Captcha> {
|
||||
CaptchaScreen(
|
||||
state = CaptchaState(
|
||||
captchaUrl = registrationRepository.getCaptchaUrl()
|
||||
),
|
||||
onEvent = { event ->
|
||||
when (event) {
|
||||
is CaptchaScreenEvents.CaptchaCompleted -> {
|
||||
registrationViewModel.resultBus.sendResult(CAPTCHA_RESULT, event.token)
|
||||
navigator.goBack()
|
||||
}
|
||||
CaptchaScreenEvents.Cancel -> {
|
||||
navigator.goBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// -- Verification Code Entry Screen
|
||||
entry<RegistrationRoute.VerificationCodeEntry> {
|
||||
val viewModel: VerificationCodeViewModel = viewModel(
|
||||
factory = VerificationCodeViewModel.Factory(
|
||||
repository = registrationRepository,
|
||||
parentState = registrationViewModel.state,
|
||||
parentEventEmitter = registrationViewModel::onEvent
|
||||
)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
VerificationCodeScreen(
|
||||
state = state,
|
||||
onEvent = { viewModel.onEvent(it) }
|
||||
)
|
||||
}
|
||||
|
||||
entry<RegistrationRoute.Profile> {
|
||||
// TODO: Implement ProfileScreen
|
||||
}
|
||||
|
||||
entry<RegistrationRoute.PinSetup> {
|
||||
PinCreationScreen(
|
||||
state = PinCreationState(
|
||||
inputLabel = "PIN must be at least 4 digits"
|
||||
),
|
||||
onEvent = { event ->
|
||||
when (event) {
|
||||
is PinCreationScreenEvents.PinSubmitted -> {
|
||||
// TODO: Save PIN and navigate to next screen
|
||||
onRegistrationComplete()
|
||||
}
|
||||
PinCreationScreenEvents.ToggleKeyboard -> {
|
||||
// TODO: Toggle between numeric and alphanumeric keyboard
|
||||
}
|
||||
PinCreationScreenEvents.LearnMore -> {
|
||||
// TODO: Show learn more dialog
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// -- Registration Lock PIN Entry Screen
|
||||
entry<RegistrationRoute.RegistrationLockPinEntry> { key ->
|
||||
val viewModel: RegistrationLockPinEntryViewModel = viewModel(
|
||||
factory = RegistrationLockPinEntryViewModel.Factory(
|
||||
repository = registrationRepository,
|
||||
parentState = registrationViewModel.state,
|
||||
parentEventEmitter = registrationViewModel::onEvent,
|
||||
timeRemaining = key.timeRemaining,
|
||||
svrCredentials = key.svrCredentials
|
||||
)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
PinEntryScreen(
|
||||
state = state,
|
||||
onEvent = { viewModel.onEvent(it) }
|
||||
)
|
||||
}
|
||||
|
||||
// -- Account Locked Screen
|
||||
entry<RegistrationRoute.AccountLocked> { key ->
|
||||
val daysRemaining = (key.timeRemainingMs / (1000 * 60 * 60 * 24)).toInt()
|
||||
AccountLockedScreen(
|
||||
state = AccountLockedState(daysRemaining = daysRemaining),
|
||||
onEvent = { event ->
|
||||
when (event) {
|
||||
AccountLockedScreenEvents.Next -> {
|
||||
// TODO: Navigate to appropriate next screen (likely back to welcome or phone entry)
|
||||
navigator.navigate(RegistrationRoute.Welcome)
|
||||
}
|
||||
AccountLockedScreenEvents.LearnMore -> {
|
||||
// TODO: Open learn more URL
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
entry<RegistrationRoute.Restore> {
|
||||
// TODO: Implement RestoreScreen
|
||||
}
|
||||
|
||||
entry<RegistrationRoute.RestoreViaQr> {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrState(),
|
||||
onEvent = { event ->
|
||||
when (event) {
|
||||
RestoreViaQrScreenEvents.RetryQrCode -> {
|
||||
// TODO: Retry QR code generation
|
||||
}
|
||||
RestoreViaQrScreenEvents.Cancel -> {
|
||||
navigator.goBack()
|
||||
}
|
||||
RestoreViaQrScreenEvents.UseProxy -> {
|
||||
// TODO: Navigate to proxy settings
|
||||
}
|
||||
RestoreViaQrScreenEvents.DismissError -> {
|
||||
// TODO: Clear error state
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
entry<RegistrationRoute.Transfer> {
|
||||
// TODO: Implement TransferScreen
|
||||
}
|
||||
|
||||
entry<RegistrationRoute.FullyComplete> {
|
||||
LaunchedEffect(Unit) {
|
||||
onRegistrationComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigator for the registration flow.
|
||||
* Handles navigation events by updating the back stack.
|
||||
*/
|
||||
private class RegistrationNavigator(
|
||||
private val eventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) {
|
||||
|
||||
fun navigate(route: RegistrationRoute) {
|
||||
eventEmitter(RegistrationFlowEvent.NavigateToScreen(route))
|
||||
}
|
||||
|
||||
fun goBack() {
|
||||
eventEmitter(RegistrationFlowEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.models.ServiceId.PNI
|
||||
import org.signal.registration.NetworkController.AccountAttributes
|
||||
import org.signal.registration.NetworkController.CreateSessionError
|
||||
import org.signal.registration.NetworkController.MasterKeyResponse
|
||||
import org.signal.registration.NetworkController.PreKeyCollection
|
||||
import org.signal.registration.NetworkController.RegisterAccountError
|
||||
import org.signal.registration.NetworkController.RegisterAccountResponse
|
||||
import org.signal.registration.NetworkController.RegistrationNetworkResult
|
||||
import org.signal.registration.NetworkController.RequestVerificationCodeError
|
||||
import org.signal.registration.NetworkController.RestoreMasterKeyError
|
||||
import org.signal.registration.NetworkController.SessionMetadata
|
||||
import org.signal.registration.NetworkController.SvrCredentials
|
||||
import org.signal.registration.NetworkController.UpdateSessionError
|
||||
import java.util.Locale
|
||||
|
||||
class RegistrationRepository(val networkController: NetworkController, val storageController: StorageController) {
|
||||
|
||||
suspend fun createSession(e164: String): RegistrationNetworkResult<SessionMetadata, CreateSessionError> = withContext(Dispatchers.IO) {
|
||||
val fcmToken = networkController.getFcmToken()
|
||||
networkController.createSession(
|
||||
e164 = e164,
|
||||
fcmToken = fcmToken,
|
||||
mcc = null,
|
||||
mnc = null
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun requestVerificationCode(
|
||||
sessionId: String,
|
||||
smsAutoRetrieveCodeSupported: Boolean,
|
||||
transport: NetworkController.VerificationCodeTransport
|
||||
): RegistrationNetworkResult<SessionMetadata, RequestVerificationCodeError> = withContext(Dispatchers.IO) {
|
||||
networkController.requestVerificationCode(
|
||||
sessionId = sessionId,
|
||||
locale = Locale.getDefault(),
|
||||
androidSmsRetrieverSupported = smsAutoRetrieveCodeSupported,
|
||||
transport = transport
|
||||
)
|
||||
}
|
||||
|
||||
fun getCaptchaUrl(): String = networkController.getCaptchaUrl()
|
||||
|
||||
suspend fun submitCaptchaToken(
|
||||
sessionId: String,
|
||||
captchaToken: String
|
||||
): RegistrationNetworkResult<SessionMetadata, UpdateSessionError> = withContext(Dispatchers.IO) {
|
||||
networkController.updateSession(
|
||||
sessionId = sessionId,
|
||||
pushChallengeToken = null,
|
||||
captchaToken = captchaToken
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun awaitPushChallengeToken(): String? = withContext(Dispatchers.IO) {
|
||||
networkController.awaitPushChallengeToken()
|
||||
}
|
||||
|
||||
suspend fun submitPushChallengeToken(
|
||||
sessionId: String,
|
||||
pushChallengeToken: String
|
||||
): RegistrationNetworkResult<SessionMetadata, UpdateSessionError> = withContext(Dispatchers.IO) {
|
||||
networkController.updateSession(
|
||||
sessionId = sessionId,
|
||||
pushChallengeToken = pushChallengeToken,
|
||||
captchaToken = null
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun submitVerificationCode(
|
||||
sessionId: String,
|
||||
verificationCode: String
|
||||
): RegistrationNetworkResult<SessionMetadata, NetworkController.SubmitVerificationCodeError> = withContext(Dispatchers.IO) {
|
||||
networkController.submitVerificationCode(
|
||||
sessionId = sessionId,
|
||||
verificationCode = verificationCode
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun restoreMasterKeyFromSvr(
|
||||
svr2Credentials: SvrCredentials,
|
||||
pin: String
|
||||
): RegistrationNetworkResult<MasterKeyResponse, RestoreMasterKeyError> = withContext(Dispatchers.IO) {
|
||||
networkController.restoreMasterKeyFromSvr(
|
||||
svr2Credentials = svr2Credentials,
|
||||
pin = pin
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new account after successful phone number verification.
|
||||
*
|
||||
* This method:
|
||||
* 1. Generates and stores all required cryptographic key material
|
||||
* 2. Creates account attributes with registration IDs and capabilities
|
||||
* 3. Calls the network controller to register the account
|
||||
* 4. On success, saves the registration data to persistent storage
|
||||
*
|
||||
* @param e164 The phone number in E.164 format (used for basic auth)
|
||||
* @param sessionId The verified session ID from phone number verification
|
||||
* @param registrationLock The registration lock token derived from the master key (if unlocking a reglocked account)
|
||||
* @param skipDeviceTransfer Whether to skip device transfer flow
|
||||
* @return The registration result containing account information or an error
|
||||
*/
|
||||
suspend fun registerAccount(
|
||||
e164: String,
|
||||
sessionId: String,
|
||||
registrationLock: String? = null,
|
||||
skipDeviceTransfer: Boolean = true
|
||||
): RegistrationNetworkResult<RegisterAccountResponse, RegisterAccountError> = withContext(Dispatchers.IO) {
|
||||
val keyMaterial = storageController.generateAndStoreKeyMaterial()
|
||||
val fcmToken = networkController.getFcmToken()
|
||||
|
||||
val accountAttributes = AccountAttributes(
|
||||
signalingKey = null,
|
||||
registrationId = keyMaterial.aciRegistrationId,
|
||||
voice = true,
|
||||
video = true,
|
||||
fetchesMessages = fcmToken == null,
|
||||
registrationLock = registrationLock,
|
||||
unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey,
|
||||
unrestrictedUnidentifiedAccess = false,
|
||||
discoverableByPhoneNumber = 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 = AccountAttributes.Capabilities( // TODO probably want to have this come from the app
|
||||
storage = false,
|
||||
versionedExpirationTimer = true,
|
||||
attachmentBackfill = true,
|
||||
spqr = true
|
||||
),
|
||||
name = null,
|
||||
pniRegistrationId = keyMaterial.pniRegistrationId,
|
||||
recoveryPassword = null
|
||||
)
|
||||
|
||||
val aciPreKeys = PreKeyCollection(
|
||||
identityKey = keyMaterial.aciIdentityKeyPair.publicKey,
|
||||
signedPreKey = keyMaterial.aciSignedPreKey,
|
||||
lastResortKyberPreKey = keyMaterial.aciLastResortKyberPreKey
|
||||
)
|
||||
|
||||
val pniPreKeys = PreKeyCollection(
|
||||
identityKey = keyMaterial.pniIdentityKeyPair.publicKey,
|
||||
signedPreKey = keyMaterial.pniSignedPreKey,
|
||||
lastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey
|
||||
)
|
||||
|
||||
val result = networkController.registerAccount(
|
||||
e164 = e164,
|
||||
password = keyMaterial.servicePassword,
|
||||
sessionId = sessionId,
|
||||
recoveryPassword = null,
|
||||
attributes = accountAttributes,
|
||||
aciPreKeys = aciPreKeys,
|
||||
pniPreKeys = pniPreKeys,
|
||||
fcmToken = fcmToken,
|
||||
skipDeviceTransfer = skipDeviceTransfer
|
||||
)
|
||||
|
||||
if (result is RegistrationNetworkResult.Success) {
|
||||
storageController.saveNewRegistrationData(
|
||||
NewRegistrationData(
|
||||
e164 = result.data.e164,
|
||||
aci = ACI.parseOrThrow(result.data.aci),
|
||||
pni = PNI.parseOrThrow(result.data.pni),
|
||||
servicePassword = keyMaterial.servicePassword,
|
||||
aep = keyMaterial.accountEntropyPool
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.signal.core.ui.navigation.ResultEventBus
|
||||
import org.signal.core.util.logging.Log
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* ViewModel shared across the registration flow.
|
||||
* Manages state and logic for registration screens.
|
||||
*/
|
||||
class RegistrationViewModel(private val repository: RegistrationRepository, savedStateHandle: SavedStateHandle) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RegistrationViewModel::class)
|
||||
}
|
||||
|
||||
private var _state: MutableStateFlow<RegistrationFlowState> = savedStateHandle.getMutableStateFlow("registration_state", initialValue = RegistrationFlowState())
|
||||
val state: StateFlow<RegistrationFlowState> = _state.asStateFlow()
|
||||
|
||||
val resultBus = ResultEventBus()
|
||||
|
||||
fun onEvent(event: RegistrationFlowEvent) {
|
||||
_state.value = applyEvent(_state.value, event)
|
||||
}
|
||||
|
||||
fun applyEvent(state: RegistrationFlowState, event: RegistrationFlowEvent): RegistrationFlowState {
|
||||
return when (event) {
|
||||
is RegistrationFlowEvent.ResetState -> RegistrationFlowState()
|
||||
is RegistrationFlowEvent.SessionUpdated -> state.copy(sessionMetadata = event.session)
|
||||
is RegistrationFlowEvent.E164Chosen -> state.copy(sessionE164 = event.e164)
|
||||
is RegistrationFlowEvent.MasterKeyRestoredForRegistrationLock -> state.copy(masterKey = event.masterKey, registrationLockProof = event.masterKey.deriveRegistrationLock())
|
||||
is RegistrationFlowEvent.NavigateToScreen -> applyNavigationToScreenEvent(state, event)
|
||||
is RegistrationFlowEvent.NavigateBack -> state.copy(backStack = state.backStack.dropLast(1))
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyNavigationToScreenEvent(inputState: RegistrationFlowState, event: RegistrationFlowEvent.NavigateToScreen): RegistrationFlowState {
|
||||
val state = inputState.copy(backStack = inputState.backStack + event.route)
|
||||
|
||||
return when (event.route) {
|
||||
is RegistrationRoute.VerificationCodeEntry -> {
|
||||
state.copy(sessionMetadata = event.route.session, sessionE164 = event.route.e164)
|
||||
}
|
||||
else -> state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of permissions to request based on the current API level.
|
||||
*/
|
||||
fun getRequiredPermissions(): List<String> {
|
||||
return buildList {
|
||||
// Notifications (API 33+)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
// Contacts
|
||||
add(Manifest.permission.READ_CONTACTS)
|
||||
add(Manifest.permission.WRITE_CONTACTS)
|
||||
|
||||
// Storage/Media
|
||||
if (Build.VERSION.SDK_INT < 29) {
|
||||
add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
|
||||
// Phone state
|
||||
add(Manifest.permission.READ_PHONE_STATE)
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
add(Manifest.permission.READ_PHONE_NUMBERS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(private val repository: RegistrationRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: KClass<T>, extras: CreationExtras): T {
|
||||
return RegistrationViewModel(repository, extras.createSavedStateHandle()) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration
|
||||
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.models.ServiceId.PNI
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
|
||||
interface StorageController {
|
||||
|
||||
/**
|
||||
* Generates all key material required for account registration and stores it persistently.
|
||||
* This includes ACI identity key, PNI identity key, and their respective pre-keys.
|
||||
*
|
||||
* @return [KeyMaterial] containing all generated cryptographic material needed for registration.
|
||||
*/
|
||||
suspend fun generateAndStoreKeyMaterial(): KeyMaterial
|
||||
|
||||
/**
|
||||
* Called after a successful registration to store new registration data.
|
||||
*/
|
||||
suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData)
|
||||
|
||||
/**
|
||||
* Retrieves previously stored registration data for registered installs, if any.
|
||||
*
|
||||
* @return Data for the existing registration if registered, otherwise null.
|
||||
*/
|
||||
suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData?
|
||||
|
||||
/**
|
||||
* Clears all stored registration data, including key material and account information.
|
||||
*/
|
||||
suspend fun clearAllData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for all cryptographic key material generated during registration.
|
||||
*/
|
||||
data class KeyMaterial(
|
||||
/** Identity key pair for the Account Identity (ACI). */
|
||||
val aciIdentityKeyPair: IdentityKeyPair,
|
||||
/** Signed pre-key for ACI. */
|
||||
val aciSignedPreKey: SignedPreKeyRecord,
|
||||
/** Last resort Kyber pre-key for ACI. */
|
||||
val aciLastResortKyberPreKey: KyberPreKeyRecord,
|
||||
/** Identity key pair for the Phone Number Identity (PNI). */
|
||||
val pniIdentityKeyPair: IdentityKeyPair,
|
||||
/** Signed pre-key for PNI. */
|
||||
val pniSignedPreKey: SignedPreKeyRecord,
|
||||
/** Last resort Kyber pre-key for PNI. */
|
||||
val pniLastResortKyberPreKey: KyberPreKeyRecord,
|
||||
/** Registration ID for the ACI. */
|
||||
val aciRegistrationId: Int,
|
||||
/** Registration ID for the PNI. */
|
||||
val pniRegistrationId: Int,
|
||||
/** Unidentified access key (derived from profile key) for sealed sender. */
|
||||
val unidentifiedAccessKey: ByteArray,
|
||||
/** Password for basic auth during registration (18 random bytes, base64 encoded). */
|
||||
val servicePassword: String,
|
||||
/** Account entropy pool for key derivation. */
|
||||
val accountEntropyPool: AccountEntropyPool
|
||||
)
|
||||
|
||||
data class NewRegistrationData(
|
||||
val e164: String,
|
||||
val aci: ACI,
|
||||
val pni: PNI,
|
||||
val servicePassword: String,
|
||||
val aep: AccountEntropyPool
|
||||
)
|
||||
|
||||
data class PreExistingRegistrationData(
|
||||
val e164: String,
|
||||
val aci: ACI,
|
||||
val pni: PNI,
|
||||
val servicePassword: String,
|
||||
val aep: AccountEntropyPool
|
||||
)
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import org.signal.registration.RegistrationNavHost
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.RegistrationViewModel
|
||||
|
||||
/**
|
||||
* Entry point for the registration flow.
|
||||
*
|
||||
* This composable sets up the entire registration navigation flow and can be
|
||||
* embedded into the main app's navigation or launched as a standalone flow.
|
||||
*
|
||||
* @param viewModel The shared ViewModel for the registration flow.
|
||||
* @param permissionsState The permissions state managed at the activity level.
|
||||
* @param modifier Modifier to be applied to the root container.
|
||||
* @param onRegistrationComplete Callback invoked when the registration process is successfully completed.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun RegistrationHostScreen(
|
||||
registrationRepository: RegistrationRepository,
|
||||
viewModel: RegistrationViewModel,
|
||||
permissionsState: MultiplePermissionsState,
|
||||
modifier: Modifier = Modifier,
|
||||
onRegistrationComplete: () -> Unit = {}
|
||||
) {
|
||||
RegistrationNavHost(
|
||||
registrationRepository = registrationRepository,
|
||||
registrationViewModel = viewModel,
|
||||
permissionsState = permissionsState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
onRegistrationComplete = onRegistrationComplete
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.accountlocked
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
|
||||
/**
|
||||
* Screen shown when the user's account is locked due to too many failed PIN attempts
|
||||
* and there's no SVR data available to recover.
|
||||
*/
|
||||
@Composable
|
||||
fun AccountLockedScreen(
|
||||
state: AccountLockedState,
|
||||
onEvent: (AccountLockedScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(49.dp))
|
||||
|
||||
Text(
|
||||
text = "Account locked",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Your account has been locked to protect your privacy and security. After ${state.daysRemaining} days of inactivity in your account you'll be able to re-register this phone number without needing your PIN. All content will be deleted.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Button(
|
||||
onClick = { onEvent(AccountLockedScreenEvents.Next) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Next")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onEvent(AccountLockedScreenEvents.LearnMore) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Learn More")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun AccountLockedScreenPreview() {
|
||||
Previews.Preview {
|
||||
AccountLockedScreen(
|
||||
state = AccountLockedState(daysRemaining = 7),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.accountlocked
|
||||
|
||||
sealed class AccountLockedScreenEvents {
|
||||
data object Next : AccountLockedScreenEvents()
|
||||
data object LearnMore : AccountLockedScreenEvents()
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.accountlocked
|
||||
|
||||
data class AccountLockedState(
|
||||
val daysRemaining: Int = 10
|
||||
)
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.captcha
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
|
||||
/**
|
||||
* Screen to display a captcha verification using a WebView.
|
||||
* The WebView loads the Signal captcha URL and intercepts the callback
|
||||
* when the user completes the captcha.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun CaptchaScreen(
|
||||
state: CaptchaState,
|
||||
onEvent: (CaptchaScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var loadState by remember { mutableStateOf(state.loadState) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
settings.javaScriptEnabled = true
|
||||
clearCache(true)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
if (url.startsWith(state.captchaScheme)) {
|
||||
val token = url.substring(state.captchaScheme.length)
|
||||
onEvent(CaptchaScreenEvents.CaptchaCompleted(token))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
loadState = CaptchaLoadState.Loaded
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String?
|
||||
) {
|
||||
super.onReceivedError(view, errorCode, description, failingUrl)
|
||||
loadState = CaptchaLoadState.Error
|
||||
}
|
||||
}
|
||||
|
||||
loadUrl(state.captchaUrl)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
when (loadState) {
|
||||
CaptchaLoadState.Loaded -> Unit
|
||||
CaptchaLoadState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
}
|
||||
CaptchaLoadState.Error -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Failed to load captcha",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { onEvent(CaptchaScreenEvents.Cancel) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CaptchaScreenLoadingPreview() {
|
||||
Previews.Preview {
|
||||
CaptchaScreen(
|
||||
state = CaptchaState(
|
||||
captchaUrl = "https://example.com/captcha",
|
||||
loadState = CaptchaLoadState.Loading
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CaptchaScreenErrorPreview() {
|
||||
Previews.Preview {
|
||||
CaptchaScreen(
|
||||
state = CaptchaState(
|
||||
captchaUrl = "https://example.com/captcha",
|
||||
loadState = CaptchaLoadState.Error
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.captcha
|
||||
|
||||
sealed class CaptchaScreenEvents {
|
||||
data class CaptchaCompleted(val token: String) : CaptchaScreenEvents()
|
||||
data object Cancel : CaptchaScreenEvents()
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.captcha
|
||||
|
||||
sealed class CaptchaLoadState {
|
||||
data object Loading : CaptchaLoadState()
|
||||
data object Loaded : CaptchaLoadState()
|
||||
data object Error : CaptchaLoadState()
|
||||
}
|
||||
|
||||
data class CaptchaState(
|
||||
val captchaUrl: String,
|
||||
val captchaScheme: String = "signalcaptcha://",
|
||||
val loadState: CaptchaLoadState = CaptchaLoadState.Loading
|
||||
)
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||
|
||||
package org.signal.registration.screens.permissions
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.registration.screens.util.MockMultiplePermissionsState
|
||||
import org.signal.registration.screens.util.MockPermissionsState
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Permissions screen for the registration flow.
|
||||
* Requests necessary runtime permissions before continuing.
|
||||
*
|
||||
* @param permissionsState The permissions state managed at the activity level.
|
||||
* @param onEvent Callback for screen events.
|
||||
* @param modifier Modifier to be applied to the root container.
|
||||
*/
|
||||
@Composable
|
||||
fun PermissionsScreen(
|
||||
permissionsState: MultiplePermissionsState,
|
||||
onProceed: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Permissions",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Signal needs the following permissions to provide the best experience:",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
PermissionsList(permissions = permissionsState.permissions.map { it.permission })
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
permissionsState.launchMultiplePermissionRequest()
|
||||
onProceed()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.PERMISSIONS_NEXT_BUTTON)
|
||||
) {
|
||||
Text("Next")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onProceed() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.PERMISSIONS_NOT_NOW_BUTTON)
|
||||
) {
|
||||
Text("Not now")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a list of permission explanations.
|
||||
*/
|
||||
@Composable
|
||||
private fun PermissionsList(
|
||||
permissions: List<String>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val permissionDescriptions = getPermissionDescriptions(permissions)
|
||||
permissionDescriptions.forEach { description ->
|
||||
PermissionItem(description = description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual permission item with description.
|
||||
*/
|
||||
@Composable
|
||||
private fun PermissionItem(
|
||||
description: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = "• $description",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts permission names to user-friendly descriptions.
|
||||
*/
|
||||
private fun getPermissionDescriptions(permissions: List<String>): List<String> {
|
||||
return buildList {
|
||||
if (permissions.any { it == Manifest.permission.POST_NOTIFICATIONS }) {
|
||||
add("Notifications - Stay updated with new messages")
|
||||
}
|
||||
if (permissions.any { it == Manifest.permission.READ_CONTACTS || it == Manifest.permission.WRITE_CONTACTS }) {
|
||||
add("Contacts - Find friends who use Signal")
|
||||
}
|
||||
if (permissions.any {
|
||||
it == Manifest.permission.READ_EXTERNAL_STORAGE ||
|
||||
it == Manifest.permission.WRITE_EXTERNAL_STORAGE ||
|
||||
it == Manifest.permission.READ_MEDIA_IMAGES ||
|
||||
it == Manifest.permission.READ_MEDIA_VIDEO ||
|
||||
it == Manifest.permission.READ_MEDIA_AUDIO
|
||||
}
|
||||
) {
|
||||
add("Photos and media - Share images and videos")
|
||||
}
|
||||
if (permissions.any { it == Manifest.permission.READ_PHONE_STATE || it == Manifest.permission.READ_PHONE_NUMBERS }) {
|
||||
add("Phone - Verify your phone number")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PermissionsScreenPreview() {
|
||||
Previews.Preview {
|
||||
PermissionsScreen(
|
||||
permissionsState = MockMultiplePermissionsState(
|
||||
permissions = listOf(
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.WRITE_CONTACTS,
|
||||
Manifest.permission.READ_PHONE_STATE,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
).map { MockPermissionsState(it) }
|
||||
),
|
||||
onProceed = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.phonenumber
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Phone number entry screen for the registration flow.
|
||||
* Allows users to select their country and enter their phone number.
|
||||
*/
|
||||
@Composable
|
||||
fun PhoneNumberScreen(
|
||||
state: PhoneNumberEntryState,
|
||||
onEvent: (PhoneNumberEntryScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var simpleErrorMessage: String? by remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(state.oneTimeEvent) {
|
||||
onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent)
|
||||
when (state.oneTimeEvent) {
|
||||
OneTimeEvent.NetworkError -> simpleErrorMessage = "Network error"
|
||||
is OneTimeEvent.RateLimited -> simpleErrorMessage = "Rate limited"
|
||||
OneTimeEvent.UnknownError -> simpleErrorMessage = "Unknown error"
|
||||
OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> simpleErrorMessage = "Could not request code with selected transport"
|
||||
OneTimeEvent.ThirdPartyError -> simpleErrorMessage = "Third party error"
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
simpleErrorMessage?.let { message ->
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
dismiss = "Ok",
|
||||
onDismiss = { simpleErrorMessage = null }
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
ScreenContent(state, onEvent)
|
||||
|
||||
if (state.showFullScreenSpinner) {
|
||||
Dialogs.IndeterminateProgressDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenContent(state: PhoneNumberEntryState, onEvent: (PhoneNumberEntryScreenEvents) -> Unit) {
|
||||
// TODO: These should come from state once country picker is implemented
|
||||
var selectedCountry by remember { mutableStateOf("United States") }
|
||||
var selectedCountryEmoji by remember { mutableStateOf("🇺🇸") }
|
||||
|
||||
// Track the phone number text field value with cursor position
|
||||
var phoneNumberTextFieldValue by remember { mutableStateOf(TextFieldValue(state.formattedNumber)) }
|
||||
|
||||
// Update the text field value when state.formattedNumber changes, preserving cursor position
|
||||
LaunchedEffect(state.formattedNumber) {
|
||||
if (phoneNumberTextFieldValue.text != state.formattedNumber) {
|
||||
// Calculate cursor position: count digits before cursor in old text,
|
||||
// then find position with same digit count in new text
|
||||
val oldText = phoneNumberTextFieldValue.text
|
||||
val oldCursorPos = phoneNumberTextFieldValue.selection.end
|
||||
val digitsBeforeCursor = oldText.take(oldCursorPos).count { it.isDigit() }
|
||||
|
||||
val newText = state.formattedNumber
|
||||
var digitCount = 0
|
||||
var newCursorPos = newText.length
|
||||
for (i in newText.indices) {
|
||||
if (newText[i].isDigit()) {
|
||||
digitCount++
|
||||
}
|
||||
if (digitCount >= digitsBeforeCursor) {
|
||||
newCursorPos = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
phoneNumberTextFieldValue = TextFieldValue(
|
||||
text = newText,
|
||||
selection = TextRange(newCursorPos)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
// Title
|
||||
Text(
|
||||
text = "Phone number",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Subtitle
|
||||
Text(
|
||||
text = "You will receive a verification code",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
|
||||
// Country Picker Button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
onEvent(PhoneNumberEntryScreenEvents.CountryPicker)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.testTag(TestTags.PHONE_NUMBER_COUNTRY_PICKER)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = selectedCountryEmoji,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = selectedCountry,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(android.R.drawable.arrow_down_float),
|
||||
contentDescription = "Select country",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Phone number input fields
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
// Country code field
|
||||
OutlinedTextField(
|
||||
value = state.countryCode,
|
||||
onValueChange = { onEvent(PhoneNumberEntryScreenEvents.CountryCodeChanged(it)) },
|
||||
modifier = Modifier
|
||||
.width(76.dp)
|
||||
.testTag(TestTags.PHONE_NUMBER_COUNTRY_CODE_FIELD),
|
||||
leadingIcon = {
|
||||
Text(
|
||||
text = "+",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Phone number field
|
||||
OutlinedTextField(
|
||||
value = phoneNumberTextFieldValue,
|
||||
onValueChange = { newValue ->
|
||||
phoneNumberTextFieldValue = newValue
|
||||
onEvent(PhoneNumberEntryScreenEvents.PhoneNumberChanged(newValue.text))
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTag(TestTags.PHONE_NUMBER_PHONE_FIELD),
|
||||
placeholder = {
|
||||
Text("Phone number")
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Phone,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
}
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Next button
|
||||
Button(
|
||||
onClick = {
|
||||
onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.testTag(TestTags.PHONE_NUMBER_NEXT_BUTTON),
|
||||
enabled = state.countryCode.isNotEmpty() && state.nationalNumber.isNotEmpty()
|
||||
) {
|
||||
Text("Next")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PhoneNumberScreenPreview() {
|
||||
Previews.Preview {
|
||||
PhoneNumberScreen(
|
||||
state = PhoneNumberEntryState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PhoneNumberScreenSpinnerPreview() {
|
||||
Previews.Preview {
|
||||
PhoneNumberScreen(
|
||||
state = PhoneNumberEntryState(showFullScreenSpinner = true),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.phonenumber
|
||||
|
||||
sealed interface PhoneNumberEntryScreenEvents {
|
||||
data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents
|
||||
data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents
|
||||
data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents
|
||||
data object CountryPicker : PhoneNumberEntryScreenEvents
|
||||
data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents
|
||||
data object ConsumeOneTimeEvent : PhoneNumberEntryScreenEvents
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.phonenumber
|
||||
|
||||
import org.signal.registration.NetworkController.SessionMetadata
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class PhoneNumberEntryState(
|
||||
val regionCode: String = "US",
|
||||
val countryCode: String = "1",
|
||||
val nationalNumber: String = "",
|
||||
val formattedNumber: String = "",
|
||||
val sessionMetadata: SessionMetadata? = null,
|
||||
val showFullScreenSpinner: Boolean = false,
|
||||
val oneTimeEvent: OneTimeEvent? = null
|
||||
) {
|
||||
sealed interface OneTimeEvent {
|
||||
data object NetworkError : OneTimeEvent
|
||||
data object UnknownError : OneTimeEvent
|
||||
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
|
||||
data object ThirdPartyError : OneTimeEvent
|
||||
data object CouldNotRequestCodeWithSelectedTransport : OneTimeEvent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.phonenumber
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.i18n.phonenumbers.AsYouTypeFormatter
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationFlowState
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.RegistrationRoute
|
||||
import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent
|
||||
import org.signal.registration.screens.util.navigateTo
|
||||
|
||||
class PhoneNumberEntryViewModel(
|
||||
val repository: RegistrationRepository,
|
||||
private val parentState: StateFlow<RegistrationFlowState>,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PhoneNumberEntryViewModel::class)
|
||||
private const val PUSH_CHALLENGE_TIMEOUT_MS = 5000L
|
||||
}
|
||||
|
||||
private val phoneNumberUtil: PhoneNumberUtil = PhoneNumberUtil.getInstance()
|
||||
private var formatter: AsYouTypeFormatter = phoneNumberUtil.getAsYouTypeFormatter("US")
|
||||
|
||||
private val _state = MutableStateFlow(PhoneNumberEntryState())
|
||||
val state = combine(_state, parentState) { state, parentState -> applyParentState(state, parentState) }
|
||||
.onEach { Log.d(TAG, "[State] $it") }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, PhoneNumberEntryState())
|
||||
|
||||
fun onEvent(event: PhoneNumberEntryScreenEvents) {
|
||||
viewModelScope.launch {
|
||||
val stateEmitter: (PhoneNumberEntryState) -> Unit = { state ->
|
||||
_state.value = state
|
||||
}
|
||||
applyEvent(_state.value, event, stateEmitter, parentEventEmitter)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun applyEvent(state: PhoneNumberEntryState, event: PhoneNumberEntryScreenEvents, stateEmitter: (PhoneNumberEntryState) -> Unit, parentEventEmitter: (RegistrationFlowEvent) -> Unit) {
|
||||
when (event) {
|
||||
is PhoneNumberEntryScreenEvents.CountryCodeChanged -> {
|
||||
stateEmitter(applyCountryCodeChanged(state, event.value))
|
||||
}
|
||||
is PhoneNumberEntryScreenEvents.PhoneNumberChanged -> {
|
||||
stateEmitter(applyPhoneNumberChanged(state, event.value))
|
||||
}
|
||||
is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> {
|
||||
var localState = state.copy(showFullScreenSpinner = true)
|
||||
stateEmitter(localState)
|
||||
localState = applyPhoneNumberSubmitted(localState, parentEventEmitter)
|
||||
stateEmitter(localState.copy(showFullScreenSpinner = false))
|
||||
}
|
||||
is PhoneNumberEntryScreenEvents.CountryPicker -> {
|
||||
state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) }
|
||||
}
|
||||
is PhoneNumberEntryScreenEvents.CaptchaCompleted -> {
|
||||
stateEmitter(applyCaptchaCompleted(state, event.token, parentEventEmitter))
|
||||
}
|
||||
is PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent -> {
|
||||
stateEmitter(state.copy(oneTimeEvent = null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applyParentState(state: PhoneNumberEntryState, parentState: RegistrationFlowState): PhoneNumberEntryState {
|
||||
return state.copy(sessionMetadata = parentState.sessionMetadata)
|
||||
}
|
||||
|
||||
private fun applyCountryCodeChanged(state: PhoneNumberEntryState, countryCode: String): PhoneNumberEntryState {
|
||||
// Only allow digits, max 3 characters
|
||||
val sanitized = countryCode.filter { it.isDigit() }.take(3)
|
||||
if (sanitized == state.countryCode) return state
|
||||
|
||||
// Try to determine region from country code
|
||||
val regionCode = phoneNumberUtil.getRegionCodeForCountryCode(sanitized.toIntOrNull() ?: 0) ?: state.regionCode
|
||||
|
||||
// Reset formatter for new region and reformat the existing national number
|
||||
formatter = phoneNumberUtil.getAsYouTypeFormatter(regionCode)
|
||||
val formattedNumber = formatNumber(state.nationalNumber)
|
||||
|
||||
return state.copy(
|
||||
countryCode = sanitized,
|
||||
regionCode = regionCode,
|
||||
formattedNumber = formattedNumber
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyPhoneNumberChanged(state: PhoneNumberEntryState, input: String): PhoneNumberEntryState {
|
||||
// Extract only digits from the input
|
||||
val digitsOnly = input.filter { it.isDigit() }
|
||||
if (digitsOnly == state.nationalNumber) return state
|
||||
|
||||
// Format the number using AsYouTypeFormatter
|
||||
val formattedNumber = formatNumber(digitsOnly)
|
||||
|
||||
return state.copy(
|
||||
nationalNumber = digitsOnly,
|
||||
formattedNumber = formattedNumber
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatNumber(nationalNumber: String): String {
|
||||
formatter.clear()
|
||||
var result = ""
|
||||
for (digit in nationalNumber) {
|
||||
result = formatter.inputDigit(digit)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun applyPhoneNumberSubmitted(
|
||||
inputState: PhoneNumberEntryState,
|
||||
parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
): PhoneNumberEntryState {
|
||||
val e164 = "+${inputState.countryCode}${inputState.nationalNumber}"
|
||||
var state = inputState.copy()
|
||||
|
||||
// TODO Consider that someone may back into this screen and change the number, requiring us to create a new session.
|
||||
|
||||
var sessionMetadata: NetworkController.SessionMetadata = state.sessionMetadata ?: when (val response = this@PhoneNumberEntryViewModel.repository.createSession(e164)) {
|
||||
is NetworkController.RegistrationNetworkResult.Success<NetworkController.SessionMetadata> -> {
|
||||
response.data
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.Failure<NetworkController.CreateSessionError> -> {
|
||||
return when (response.error) {
|
||||
is NetworkController.CreateSessionError.InvalidRequest -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
is NetworkController.CreateSessionError.RateLimited -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(response.error.retryAfter))
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "Unknown error when creating session.", response.exception)
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
}
|
||||
|
||||
state = state.copy(sessionMetadata = sessionMetadata)
|
||||
|
||||
if (sessionMetadata.requestedInformation.contains("pushChallenge")) {
|
||||
Log.d(TAG, "Push challenge requested, waiting for token...")
|
||||
val pushChallengeToken = withTimeoutOrNull(PUSH_CHALLENGE_TIMEOUT_MS) {
|
||||
repository.awaitPushChallengeToken()
|
||||
}
|
||||
|
||||
if (pushChallengeToken != null) {
|
||||
Log.d(TAG, "Received push challenge token, submitting...")
|
||||
val updateResult = repository.submitPushChallengeToken(sessionMetadata.id, pushChallengeToken)
|
||||
sessionMetadata = when (updateResult) {
|
||||
is NetworkController.RegistrationNetworkResult.Success -> updateResult.data
|
||||
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||
Log.w(TAG, "Failed to submit push challenge token: ${updateResult.error}")
|
||||
sessionMetadata
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||
Log.w(TAG, "Network error submitting push challenge token", updateResult.exception)
|
||||
sessionMetadata
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "Application error submitting push challenge token", updateResult.exception)
|
||||
sessionMetadata
|
||||
}
|
||||
}
|
||||
state = state.copy(sessionMetadata = sessionMetadata)
|
||||
} else {
|
||||
Log.d(TAG, "Push challenge token not received within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionMetadata.requestedInformation.contains("captcha")) {
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.Captcha(sessionMetadata))
|
||||
return state
|
||||
}
|
||||
|
||||
val verificationCodeResponse = this@PhoneNumberEntryViewModel.repository.requestVerificationCode(
|
||||
sessionMetadata.id,
|
||||
smsAutoRetrieveCodeSupported = false,
|
||||
transport = NetworkController.VerificationCodeTransport.SMS
|
||||
)
|
||||
|
||||
sessionMetadata = when (verificationCodeResponse) {
|
||||
is NetworkController.RegistrationNetworkResult.Success<NetworkController.SessionMetadata> -> {
|
||||
verificationCodeResponse.data
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.Failure<NetworkController.RequestVerificationCodeError> -> {
|
||||
return when (verificationCodeResponse.error) {
|
||||
is NetworkController.RequestVerificationCodeError.InvalidRequest -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.RateLimited -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(verificationCodeResponse.error.retryAfter))
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.InvalidSessionId -> {
|
||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||
state
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> {
|
||||
Log.w(TAG, "When requesting verification code, missing request information or already verified.")
|
||||
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.SessionNotFound -> {
|
||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||
state
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.ThirdPartyError)
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "Unknown error when creating session.", verificationCodeResponse.exception)
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
}
|
||||
|
||||
state = state.copy(sessionMetadata = sessionMetadata)
|
||||
|
||||
if (sessionMetadata.requestedInformation.contains("captcha")) {
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.Captcha(sessionMetadata))
|
||||
return state
|
||||
}
|
||||
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.VerificationCodeEntry(sessionMetadata, e164))
|
||||
return state
|
||||
}
|
||||
|
||||
private suspend fun applyCaptchaCompleted(inputState: PhoneNumberEntryState, token: String, parentEventEmitter: (RegistrationFlowEvent) -> Unit): PhoneNumberEntryState {
|
||||
var state = inputState.copy()
|
||||
var sessionMetadata = state.sessionMetadata ?: return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
|
||||
val updateResult = this@PhoneNumberEntryViewModel.repository.submitCaptchaToken(sessionMetadata.id, token)
|
||||
|
||||
sessionMetadata = when (updateResult) {
|
||||
is NetworkController.RegistrationNetworkResult.Success -> updateResult.data
|
||||
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||
return when (updateResult.error) {
|
||||
is NetworkController.UpdateSessionError.InvalidRequest -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
is NetworkController.UpdateSessionError.RejectedUpdate -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
is NetworkController.UpdateSessionError.RateLimited -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(updateResult.error.retryAfter))
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "Unknown error when submitting captcha.", updateResult.exception)
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
}
|
||||
|
||||
state = state.copy(sessionMetadata = sessionMetadata)
|
||||
|
||||
// TODO should we be reading "allowedToRequestCode"?
|
||||
if (sessionMetadata.requestedInformation.contains("captcha")) {
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.Captcha(sessionMetadata))
|
||||
return state
|
||||
}
|
||||
|
||||
val verificationCodeResponse = this@PhoneNumberEntryViewModel.repository.requestVerificationCode(
|
||||
sessionId = sessionMetadata.id,
|
||||
smsAutoRetrieveCodeSupported = false, // TODO eventually support this
|
||||
transport = NetworkController.VerificationCodeTransport.SMS
|
||||
)
|
||||
|
||||
sessionMetadata = when (verificationCodeResponse) {
|
||||
is NetworkController.RegistrationNetworkResult.Success -> verificationCodeResponse.data
|
||||
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||
return when (verificationCodeResponse.error) {
|
||||
is NetworkController.RequestVerificationCodeError.InvalidRequest -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.RateLimited -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(verificationCodeResponse.error.retryAfter))
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.InvalidSessionId -> {
|
||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||
state
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> {
|
||||
TODO()
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.SessionNotFound -> {
|
||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||
state
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.ThirdPartyError)
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "Unknown error when requesting verification code.", verificationCodeResponse.exception)
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
}
|
||||
|
||||
val e164 = "+${inputState.countryCode}${inputState.nationalNumber}"
|
||||
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.VerificationCodeEntry(sessionMetadata, e164))
|
||||
return state
|
||||
}
|
||||
|
||||
class Factory(
|
||||
val repository: RegistrationRepository,
|
||||
val parentState: StateFlow<RegistrationFlowState>,
|
||||
val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return PhoneNumberEntryViewModel(repository, parentState, parentEventEmitter) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.pincreation
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
|
||||
/**
|
||||
* PIN creation screen for the registration flow.
|
||||
* Allows users to create a new PIN for their account.
|
||||
*/
|
||||
@Composable
|
||||
fun PinCreationScreen(
|
||||
state: PinCreationState,
|
||||
onEvent: (PinCreationScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var pin by remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = "Create your PIN",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val descriptionText = buildAnnotatedString {
|
||||
append("PINs can help you restore your account if you lose your phone. ")
|
||||
pushStringAnnotation(tag = "LEARN_MORE", annotation = "learn_more")
|
||||
withStyle(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
) {
|
||||
append("Learn more")
|
||||
}
|
||||
pop()
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = descriptionText,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Start
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { offset ->
|
||||
descriptionText.getStringAnnotations(tag = "LEARN_MORE", start = offset, end = offset)
|
||||
.firstOrNull()?.let {
|
||||
onEvent(PinCreationScreenEvents.LearnMore)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TextField(
|
||||
value = pin,
|
||||
onValueChange = { pin = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (state.isNumericKeyboard) KeyboardType.NumberPassword else KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (pin.length >= 4) {
|
||||
onEvent(PinCreationScreenEvents.PinSubmitted(pin))
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = state.inputLabel ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onEvent(PinCreationScreenEvents.ToggleKeyboard) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
painter = SignalIcons.Keyboard.painter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = if (state.isNumericKeyboard) "Create alphanumeric PIN" else "Create numeric PIN"
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Button(
|
||||
onClick = { onEvent(PinCreationScreenEvents.PinSubmitted(pin)) },
|
||||
enabled = pin.length >= 4
|
||||
) {
|
||||
Text("Next")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Auto-focus PIN field on initial composition
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PinCreationScreenPreview() {
|
||||
Previews.Preview {
|
||||
PinCreationScreen(
|
||||
state = PinCreationState(
|
||||
inputLabel = "PIN must be at least 4 digits"
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PinCreationScreenAlphanumericPreview() {
|
||||
Previews.Preview {
|
||||
PinCreationScreen(
|
||||
state = PinCreationState(
|
||||
isNumericKeyboard = false,
|
||||
inputLabel = "PIN must be at least 4 characters"
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.pincreation
|
||||
|
||||
sealed class PinCreationScreenEvents {
|
||||
data class PinSubmitted(val pin: String) : PinCreationScreenEvents()
|
||||
data object ToggleKeyboard : PinCreationScreenEvents()
|
||||
data object LearnMore : PinCreationScreenEvents()
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.pincreation
|
||||
|
||||
data class PinCreationState(
|
||||
val isNumericKeyboard: Boolean = true,
|
||||
val inputLabel: String? = null,
|
||||
val isConfirmEnabled: Boolean = false
|
||||
)
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.pinentry
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
|
||||
/**
|
||||
* PIN entry screen for the registration flow.
|
||||
* Allows users to enter their PIN to restore their account.
|
||||
*/
|
||||
@Composable
|
||||
fun PinEntryScreen(
|
||||
state: PinEntryState,
|
||||
onEvent: (PinEntryScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var pin by remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter your PIN",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter the PIN you created when you first installed Signal",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TextField(
|
||||
value = pin,
|
||||
onValueChange = { pin = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (state.isNumericKeyboard) KeyboardType.Number else KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (pin.isNotEmpty()) {
|
||||
onEvent(PinEntryScreenEvents.PinEntered(pin))
|
||||
}
|
||||
}
|
||||
),
|
||||
isError = state.triesRemaining != null
|
||||
)
|
||||
|
||||
if (state.triesRemaining != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Incorrect PIN. ${state.triesRemaining} attempts remaining.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (state.showNeedHelp) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { onEvent(PinEntryScreenEvents.NeedHelp) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Need help?")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onEvent(PinEntryScreenEvents.ToggleKeyboard) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
painter = SignalIcons.Keyboard.painter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text("Switch keyboard")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (pin.isNotEmpty()) {
|
||||
onEvent(PinEntryScreenEvents.PinEntered(pin))
|
||||
}
|
||||
},
|
||||
enabled = pin.isNotEmpty()
|
||||
) {
|
||||
Text("Continue")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Skip button in top right
|
||||
TextButton(
|
||||
onClick = { onEvent(PinEntryScreenEvents.Skip) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Skip",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-focus PIN field on initial composition
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PinEntryScreenPreview() {
|
||||
Previews.Preview {
|
||||
PinEntryScreen(
|
||||
state = PinEntryState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PinEntryScreenWithErrorPreview() {
|
||||
Previews.Preview {
|
||||
PinEntryScreen(
|
||||
state = PinEntryState(
|
||||
triesRemaining = 3,
|
||||
showNeedHelp = true
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.pinentry
|
||||
|
||||
object PinEntryScreenEventHandler {
|
||||
|
||||
fun applyEvent(state: PinEntryState, event: PinEntryScreenEvents): PinEntryState {
|
||||
return when (event) {
|
||||
PinEntryScreenEvents.ToggleKeyboard -> state.copy(isNumericKeyboard = !state.isNumericKeyboard)
|
||||
else -> throw UnsupportedOperationException("This even is not handled generically!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.pinentry
|
||||
|
||||
sealed class PinEntryScreenEvents {
|
||||
data class PinEntered(val pin: String) : PinEntryScreenEvents()
|
||||
data object ToggleKeyboard : PinEntryScreenEvents()
|
||||
data object NeedHelp : PinEntryScreenEvents()
|
||||
data object Skip : PinEntryScreenEvents()
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.pinentry
|
||||
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class PinEntryState(
|
||||
val showNeedHelp: Boolean = false,
|
||||
val isNumericKeyboard: Boolean = true,
|
||||
val loading: Boolean = false,
|
||||
val triesRemaining: Int? = null,
|
||||
val oneTimeEvent: OneTimeEvent? = null
|
||||
) {
|
||||
sealed interface OneTimeEvent {
|
||||
data object NetworkError : OneTimeEvent
|
||||
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
|
||||
data object UnknownError : OneTimeEvent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.registrationlock
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.models.MasterKey
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationFlowState
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.RegistrationRoute
|
||||
import org.signal.registration.screens.pinentry.PinEntryScreenEventHandler
|
||||
import org.signal.registration.screens.pinentry.PinEntryScreenEvents
|
||||
import org.signal.registration.screens.pinentry.PinEntryState
|
||||
import org.signal.registration.screens.util.navigateTo
|
||||
|
||||
/**
|
||||
* ViewModel for the registration lock PIN entry screen.
|
||||
*
|
||||
* This screen is shown when the user attempts to register and their account
|
||||
* is protected by a registration lock (PIN). The user must enter their PIN
|
||||
* to proceed with registration.
|
||||
*/
|
||||
class RegistrationLockPinEntryViewModel(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentState: StateFlow<RegistrationFlowState>,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
private val timeRemaining: Long,
|
||||
private val svrCredentials: NetworkController.SvrCredentials
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RegistrationLockPinEntryViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
PinEntryState(
|
||||
showNeedHelp = true
|
||||
)
|
||||
)
|
||||
|
||||
val state: StateFlow<PinEntryState> = _state
|
||||
.onEach { Log.d(TAG, "[State] $it") }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, PinEntryState(showNeedHelp = true))
|
||||
|
||||
fun onEvent(event: PinEntryScreenEvents) {
|
||||
viewModelScope.launch {
|
||||
val stateEmitter: (PinEntryState) -> Unit = { state ->
|
||||
_state.value = state
|
||||
}
|
||||
applyEvent(state.value, event, stateEmitter, parentEventEmitter)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyEvent(state: PinEntryState, event: PinEntryScreenEvents, stateEmitter: (PinEntryState) -> Unit, parentEventEmitter: (RegistrationFlowEvent) -> Unit) {
|
||||
when (event) {
|
||||
is PinEntryScreenEvents.PinEntered -> {
|
||||
var localState = state.copy(loading = true)
|
||||
stateEmitter(localState)
|
||||
localState = applyPinEntered(localState, event, parentEventEmitter)
|
||||
stateEmitter(localState.copy(loading = false))
|
||||
}
|
||||
is PinEntryScreenEvents.Skip -> {
|
||||
handleSkip()
|
||||
}
|
||||
is PinEntryScreenEvents.ToggleKeyboard,
|
||||
is PinEntryScreenEvents.NeedHelp -> {
|
||||
stateEmitter(PinEntryScreenEventHandler.applyEvent(state, event))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyPinEntered(state: PinEntryState, event: PinEntryScreenEvents.PinEntered, parentEventEmitter: (RegistrationFlowEvent) -> Unit): PinEntryState {
|
||||
Log.d(TAG, "[PinEntered] Attempting to restore master key from SVR...")
|
||||
|
||||
val restoreResult = repository.restoreMasterKeyFromSvr(svrCredentials, event.pin)
|
||||
|
||||
val masterKey: MasterKey = when (restoreResult) {
|
||||
is NetworkController.RegistrationNetworkResult.Success -> {
|
||||
Log.i(TAG, "[PinEntered] Successfully restored master key from SVR.")
|
||||
restoreResult.data.masterKey
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||
return when (restoreResult.error) {
|
||||
is NetworkController.RestoreMasterKeyError.WrongPin -> {
|
||||
Log.w(TAG, "[PinEntered] Wrong PIN. Tries remaining: ${restoreResult.error.triesRemaining}")
|
||||
state.copy(triesRemaining = restoreResult.error.triesRemaining)
|
||||
}
|
||||
is NetworkController.RestoreMasterKeyError.NoDataFound -> {
|
||||
Log.w(TAG, "[PinEntered] No SVR data found. Account is locked.")
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.AccountLocked(timeRemainingMs = timeRemaining))
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||
Log.w(TAG, "[PinEntered] Network error when restoring master key.", restoreResult.exception)
|
||||
return state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "[PinEntered] Application error when restoring master key.", restoreResult.exception)
|
||||
return state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
}
|
||||
|
||||
parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredForRegistrationLock(masterKey))
|
||||
|
||||
val registrationLockToken = masterKey.deriveRegistrationLock()
|
||||
|
||||
val e164 = parentState.value.sessionE164
|
||||
val sessionId = parentState.value.sessionMetadata?.id
|
||||
|
||||
if (e164 == null || sessionId == null) {
|
||||
Log.w(TAG, "[PinEntered] Missing e164 or sessionId. Resetting state.")
|
||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||
return state
|
||||
}
|
||||
|
||||
Log.d(TAG, "[PinEntered] Attempting to register with registration lock token...")
|
||||
val registerResult = repository.registerAccount(
|
||||
e164 = e164,
|
||||
sessionId = sessionId,
|
||||
registrationLock = registrationLockToken,
|
||||
skipDeviceTransfer = true
|
||||
)
|
||||
|
||||
return when (registerResult) {
|
||||
is NetworkController.RegistrationNetworkResult.Success -> {
|
||||
Log.i(TAG, "[PinEntered] Successfully registered!")
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete(registerResult.data))
|
||||
state
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||
when (registerResult.error) {
|
||||
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
|
||||
Log.w(TAG, "[PinEntered] Session not found or verified: ${registerResult.error.message}")
|
||||
TODO()
|
||||
}
|
||||
is NetworkController.RegisterAccountError.RegistrationLock -> {
|
||||
Log.w(TAG, "[PinEntered] Still getting registration lock error after providing token. This shouldn't happen. Resetting state.")
|
||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||
state
|
||||
}
|
||||
is NetworkController.RegisterAccountError.RateLimited -> {
|
||||
Log.w(TAG, "[PinEntered] Rate limited when registering. Retry After: ${registerResult.error.retryAfter}")
|
||||
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.RateLimited(registerResult.error.retryAfter))
|
||||
}
|
||||
is NetworkController.RegisterAccountError.InvalidRequest -> {
|
||||
Log.w(TAG, "[PinEntered] Invalid request when registering: ${registerResult.error.message}")
|
||||
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
|
||||
Log.w(TAG, "[PinEntered] Device transfer possible. This shouldn't happen when skipDeviceTransfer is true.")
|
||||
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
|
||||
Log.w(TAG, "[PinEntered] Registration recovery password incorrect: ${registerResult.error.message}")
|
||||
TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||
Log.w(TAG, "[PinEntered] Network error when registering.", registerResult.exception)
|
||||
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "[PinEntered] Application error when registering.", registerResult.exception)
|
||||
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSkip() {
|
||||
Log.d(TAG, "Skip requested - this will result in account data loss after timeRemaining: $timeRemaining ms")
|
||||
// TODO: Show confirmation dialog warning about data loss, then proceed without PIN
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentState: StateFlow<RegistrationFlowState>,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
private val timeRemaining: Long,
|
||||
private val svrCredentials: NetworkController.SvrCredentials
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return RegistrationLockPinEntryViewModel(
|
||||
repository,
|
||||
parentState,
|
||||
parentEventEmitter,
|
||||
timeRemaining,
|
||||
svrCredentials
|
||||
) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.restore
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
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.aspectRatio
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.QrCode
|
||||
import org.signal.core.ui.compose.QrCodeData
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
|
||||
/**
|
||||
* Screen to display QR code for restoring from an old device.
|
||||
* The old device scans this QR code to initiate the transfer.
|
||||
*/
|
||||
@Composable
|
||||
fun RestoreViaQrScreen(
|
||||
state: RestoreViaQrState,
|
||||
onEvent: (RestoreViaQrScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Scan from old device",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// QR Code display area
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = state.qrState,
|
||||
contentKey = { it::class },
|
||||
label = "qr-code-state"
|
||||
) { qrState ->
|
||||
when (qrState) {
|
||||
is QrState.Loaded -> {
|
||||
QrCode(
|
||||
data = qrState.qrCodeData,
|
||||
foregroundColor = Color(0xFF2449C0),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
QrState.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
QrState.Scanned -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "QR code scanned",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QrState.Failed -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Failed to generate QR code",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Instructions
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 320.dp)
|
||||
) {
|
||||
InstructionRow(
|
||||
icon = SignalIcons.Phone.painter,
|
||||
instruction = "On your old phone, open Signal"
|
||||
)
|
||||
|
||||
InstructionRow(
|
||||
icon = SignalIcons.Camera.painter,
|
||||
instruction = "Go to Settings > Transfer account"
|
||||
)
|
||||
|
||||
InstructionRow(
|
||||
icon = SignalIcons.QrCode.painter,
|
||||
instruction = "Scan this QR code"
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(
|
||||
onClick = { onEvent(RestoreViaQrScreenEvents.Cancel) }
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Loading dialog
|
||||
if (state.isRegistering) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { },
|
||||
confirmButton = { },
|
||||
text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text("Registering...")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Error dialog
|
||||
if (state.showRegistrationError) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { onEvent(RestoreViaQrScreenEvents.DismissError) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onEvent(RestoreViaQrScreenEvents.DismissError) }) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Text(state.errorMessage ?: "An error occurred during registration")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstructionRow(
|
||||
icon: Painter,
|
||||
instruction: String
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Text(
|
||||
text = instruction,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenLoadingPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrState(qrState = QrState.Loading),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenLoadedPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrState(
|
||||
qrState = QrState.Loaded(QrCodeData.forData("sgnl://rereg?uuid=test&pub_key=test", false))
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenFailedPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrState(qrState = QrState.Failed),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenRegisteringPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrState(
|
||||
qrState = QrState.Scanned,
|
||||
isRegistering = true
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.restore
|
||||
|
||||
sealed class RestoreViaQrScreenEvents {
|
||||
data object RetryQrCode : RestoreViaQrScreenEvents()
|
||||
data object Cancel : RestoreViaQrScreenEvents()
|
||||
data object UseProxy : RestoreViaQrScreenEvents()
|
||||
data object DismissError : RestoreViaQrScreenEvents()
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.restore
|
||||
|
||||
import org.signal.core.ui.compose.QrCodeData
|
||||
|
||||
sealed class QrState {
|
||||
data object Loading : QrState()
|
||||
data class Loaded(val qrCodeData: QrCodeData) : QrState()
|
||||
data object Scanned : QrState()
|
||||
data object Failed : QrState()
|
||||
}
|
||||
|
||||
data class RestoreViaQrState(
|
||||
val qrState: QrState = QrState.Loading,
|
||||
val isRegistering: Boolean = false,
|
||||
val showRegistrationError: Boolean = false,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.util
|
||||
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationRoute
|
||||
|
||||
fun ((RegistrationFlowEvent) -> Unit).navigateTo(route: RegistrationRoute) {
|
||||
this(RegistrationFlowEvent.NavigateToScreen(route))
|
||||
}
|
||||
|
||||
fun ((RegistrationFlowEvent) -> Unit).navigateBack() {
|
||||
this(RegistrationFlowEvent.NavigateBack)
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||
|
||||
package org.signal.registration.screens.util
|
||||
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
|
||||
/**
|
||||
* Helpful mock for [MultiplePermissionsState] to make previews easier.
|
||||
*/
|
||||
class MockMultiplePermissionsState(
|
||||
override val allPermissionsGranted: Boolean = false,
|
||||
override val permissions: List<PermissionState> = emptyList(),
|
||||
override val revokedPermissions: List<PermissionState> = emptyList(),
|
||||
override val shouldShowRationale: Boolean = false
|
||||
) : MultiplePermissionsState {
|
||||
override fun launchMultiplePermissionRequest() = Unit
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||
|
||||
package org.signal.registration.screens.util
|
||||
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.PermissionStatus
|
||||
|
||||
/**
|
||||
* Helpful mock for [PermissionsState] to make previews easier.
|
||||
*/
|
||||
class MockPermissionsState(
|
||||
override val permission: String,
|
||||
override val status: PermissionStatus = PermissionStatus.Granted
|
||||
) : PermissionState {
|
||||
override fun launchPermissionRequest() = Unit
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.verificationcode
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.platform.testTag
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Verification code entry screen for the registration flow.
|
||||
* Displays a 6-digit code input in XXX-XXX format.
|
||||
*/
|
||||
@Composable
|
||||
fun VerificationCodeScreen(
|
||||
state: VerificationCodeState,
|
||||
onEvent: (VerificationCodeScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var digits by remember { mutableStateOf(List(6) { "" }) }
|
||||
val focusRequesters = remember { List(6) { FocusRequester() } }
|
||||
|
||||
// Auto-submit when all digits are entered
|
||||
LaunchedEffect(digits) {
|
||||
if (digits.all { it.isNotEmpty() }) {
|
||||
val code = digits.joinToString("")
|
||||
onEvent(VerificationCodeScreenEvents.CodeEntered(code))
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.oneTimeEvent) {
|
||||
onEvent(VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent)
|
||||
|
||||
when (state.oneTimeEvent) {
|
||||
VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> { }
|
||||
VerificationCodeState.OneTimeEvent.IncorrectVerificationCode -> { }
|
||||
VerificationCodeState.OneTimeEvent.NetworkError -> { }
|
||||
is VerificationCodeState.OneTimeEvent.RateLimited -> { }
|
||||
VerificationCodeState.OneTimeEvent.ThirdPartyError -> { }
|
||||
VerificationCodeState.OneTimeEvent.UnknownError -> { }
|
||||
VerificationCodeState.OneTimeEvent.RegistrationError -> { }
|
||||
null -> { }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter verification code",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter the code we sent to ${state.e164}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Code input fields - XXX-XXX format
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.VERIFICATION_CODE_INPUT),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// First three digits
|
||||
for (i in 0..2) {
|
||||
DigitField(
|
||||
value = digits[i],
|
||||
onValueChange = { newValue ->
|
||||
if (newValue.length <= 1 && (newValue.isEmpty() || newValue.all { it.isDigit() })) {
|
||||
digits = digits.toMutableList().apply { this[i] = newValue }
|
||||
if (newValue.isNotEmpty() && i < 5) {
|
||||
focusRequesters[i + 1].requestFocus()
|
||||
}
|
||||
}
|
||||
},
|
||||
focusRequester = focusRequesters[i],
|
||||
testTag = when (i) {
|
||||
0 -> TestTags.VERIFICATION_CODE_DIGIT_0
|
||||
1 -> TestTags.VERIFICATION_CODE_DIGIT_1
|
||||
else -> TestTags.VERIFICATION_CODE_DIGIT_2
|
||||
}
|
||||
)
|
||||
if (i < 2) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Separator
|
||||
Text(
|
||||
text = "-",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
|
||||
// Last three digits
|
||||
for (i in 3..5) {
|
||||
if (i > 3) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
DigitField(
|
||||
value = digits[i],
|
||||
onValueChange = { newValue ->
|
||||
if (newValue.length <= 1 && (newValue.isEmpty() || newValue.all { it.isDigit() })) {
|
||||
digits = digits.toMutableList().apply { this[i] = newValue }
|
||||
if (newValue.isNotEmpty() && i < 5) {
|
||||
focusRequesters[i + 1].requestFocus()
|
||||
}
|
||||
}
|
||||
},
|
||||
focusRequester = focusRequesters[i],
|
||||
testTag = when (i) {
|
||||
3 -> TestTags.VERIFICATION_CODE_DIGIT_3
|
||||
4 -> TestTags.VERIFICATION_CODE_DIGIT_4
|
||||
else -> TestTags.VERIFICATION_CODE_DIGIT_5
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = { onEvent(VerificationCodeScreenEvents.WrongNumber) },
|
||||
modifier = Modifier.testTag(TestTags.VERIFICATION_CODE_WRONG_NUMBER_BUTTON)
|
||||
) {
|
||||
Text("Wrong number?")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(
|
||||
onClick = { onEvent(VerificationCodeScreenEvents.ResendSms) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.VERIFICATION_CODE_RESEND_SMS_BUTTON)
|
||||
) {
|
||||
Text("Resend SMS")
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { onEvent(VerificationCodeScreenEvents.CallMe) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.VERIFICATION_CODE_CALL_ME_BUTTON)
|
||||
) {
|
||||
Text("Call me instead")
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-focus first field on initial composition
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequesters[0].requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual digit input field
|
||||
*/
|
||||
@Composable
|
||||
private fun DigitField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
testTag: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier
|
||||
.width(44.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.testTag(testTag),
|
||||
textStyle = MaterialTheme.typography.titleLarge.copy(textAlign = TextAlign.Center),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun VerificationCodeScreenPreview() {
|
||||
Previews.Preview {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.verificationcode
|
||||
|
||||
sealed class VerificationCodeScreenEvents {
|
||||
data class CodeEntered(val code: String) : VerificationCodeScreenEvents()
|
||||
data object WrongNumber : VerificationCodeScreenEvents()
|
||||
data object ResendSms : VerificationCodeScreenEvents()
|
||||
data object CallMe : VerificationCodeScreenEvents()
|
||||
data object HavingTrouble : VerificationCodeScreenEvents()
|
||||
data object ConsumeInnerOneTimeEvent : VerificationCodeScreenEvents()
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.verificationcode
|
||||
|
||||
import org.signal.registration.NetworkController.SessionMetadata
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class VerificationCodeState(
|
||||
val sessionMetadata: SessionMetadata? = null,
|
||||
val e164: String = "",
|
||||
val oneTimeEvent: OneTimeEvent? = null
|
||||
) {
|
||||
sealed interface OneTimeEvent {
|
||||
data object NetworkError : OneTimeEvent
|
||||
data object UnknownError : OneTimeEvent
|
||||
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
|
||||
data object ThirdPartyError : OneTimeEvent
|
||||
data object CouldNotRequestCodeWithSelectedTransport : OneTimeEvent
|
||||
data object IncorrectVerificationCode : OneTimeEvent
|
||||
data object RegistrationError : OneTimeEvent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.verificationcode
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationFlowState
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.RegistrationRoute
|
||||
import org.signal.registration.screens.util.navigateBack
|
||||
import org.signal.registration.screens.util.navigateTo
|
||||
import org.signal.registration.screens.verificationcode.VerificationCodeState.OneTimeEvent
|
||||
|
||||
class VerificationCodeViewModel(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentState: StateFlow<RegistrationFlowState>,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(VerificationCodeViewModel::class)
|
||||
}
|
||||
|
||||
private val _localState = MutableStateFlow(VerificationCodeState())
|
||||
val state = combine(_localState, parentState) { state, parentState -> applyParentState(state, parentState) }
|
||||
.onEach { Log.d(TAG, "[State] $it") }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, VerificationCodeState())
|
||||
|
||||
fun onEvent(event: VerificationCodeScreenEvents) {
|
||||
viewModelScope.launch {
|
||||
_localState.emit(applyEvent(state.value, event))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun applyEvent(state: VerificationCodeState, event: VerificationCodeScreenEvents): VerificationCodeState {
|
||||
return when (event) {
|
||||
is VerificationCodeScreenEvents.CodeEntered -> transformCodeEntered(state, event.code)
|
||||
is VerificationCodeScreenEvents.WrongNumber -> state.also { parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) }
|
||||
is VerificationCodeScreenEvents.ResendSms -> transformResendCode(state, NetworkController.VerificationCodeTransport.SMS)
|
||||
is VerificationCodeScreenEvents.CallMe -> transformResendCode(state, NetworkController.VerificationCodeTransport.VOICE)
|
||||
is VerificationCodeScreenEvents.HavingTrouble -> TODO("having trouble flow")
|
||||
is VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent -> state.copy(oneTimeEvent = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun applyParentState(state: VerificationCodeState, parentState: RegistrationFlowState): VerificationCodeState {
|
||||
if (parentState.sessionMetadata == null || parentState.sessionE164 == null) {
|
||||
Log.w(TAG, "Parent state is missing session metadata or e164! Resetting.")
|
||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||
return state
|
||||
}
|
||||
|
||||
return state.copy(
|
||||
sessionMetadata = parentState.sessionMetadata,
|
||||
e164 = parentState.sessionE164
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun transformCodeEntered(inputState: VerificationCodeState, code: String): VerificationCodeState {
|
||||
var state = inputState.copy()
|
||||
var sessionMetadata = state.sessionMetadata ?: return state.also { parentEventEmitter(RegistrationFlowEvent.ResetState) }
|
||||
|
||||
// TODO should we be checking on whether we need to do more captcha stuff?
|
||||
|
||||
val result = repository.submitVerificationCode(sessionMetadata.id, code)
|
||||
|
||||
sessionMetadata = when (result) {
|
||||
is NetworkController.RegistrationNetworkResult.Success -> {
|
||||
result.data
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||
when (result.error) {
|
||||
is NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode -> {
|
||||
Log.w(TAG, "[SubmitCode] Invalid sessionId or verification code entered. This is distinct from an *incorrect* verification code. Body: ${result.error.message}")
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode)
|
||||
}
|
||||
is NetworkController.SubmitVerificationCodeError.SessionNotFound -> {
|
||||
Log.w(TAG, "[SubmitCode] Session not found: ${result.error.message}")
|
||||
// TODO don't start over, go back to phone number entry
|
||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||
return state
|
||||
}
|
||||
is NetworkController.SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested -> {
|
||||
if (result.error.session.verified) {
|
||||
Log.i(TAG, "[SubmitCode] Session already had number verified, continuing with registration.")
|
||||
result.error.session
|
||||
} else {
|
||||
Log.w(TAG, "[SubmitCode] No code was requested for this session? Need to have user re-submit.")
|
||||
parentEventEmitter.navigateBack()
|
||||
return state
|
||||
}
|
||||
}
|
||||
is NetworkController.SubmitVerificationCodeError.RateLimited -> {
|
||||
Log.w(TAG, "[SubmitCode] Rate limited.")
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.RateLimited(result.error.retryAfter))
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "[SubmitCode] Unknown error when submitting verification code.", result.exception)
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
}
|
||||
|
||||
state = state.copy(sessionMetadata = sessionMetadata)
|
||||
|
||||
if (!sessionMetadata.verified) {
|
||||
Log.w(TAG, "[SubmitCode] Verification code was incorrect.")
|
||||
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode)
|
||||
}
|
||||
|
||||
// Attempt to register
|
||||
val registerResult = repository.registerAccount(e164 = state.e164, sessionId = sessionMetadata.id, skipDeviceTransfer = true)
|
||||
|
||||
return when (registerResult) {
|
||||
is NetworkController.RegistrationNetworkResult.Success -> {
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete(registerResult.data))
|
||||
state
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||
when (registerResult.error) {
|
||||
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
|
||||
TODO()
|
||||
}
|
||||
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
|
||||
Log.w(TAG, "[Register] Got told a device transfer is possible. We should never get into this state. Resetting.")
|
||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||
state
|
||||
}
|
||||
is NetworkController.RegisterAccountError.RegistrationLock -> {
|
||||
Log.w(TAG, "[Register] Reglocked.")
|
||||
parentEventEmitter.navigateTo(
|
||||
RegistrationRoute.RegistrationLockPinEntry(
|
||||
timeRemaining = registerResult.error.data.timeRemaining,
|
||||
svrCredentials = registerResult.error.data.svr2Credentials
|
||||
)
|
||||
)
|
||||
state
|
||||
}
|
||||
is NetworkController.RegisterAccountError.RateLimited -> {
|
||||
Log.w(TAG, "[Register] Rate limited.")
|
||||
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(registerResult.error.retryAfter))
|
||||
}
|
||||
is NetworkController.RegisterAccountError.InvalidRequest -> {
|
||||
Log.w(TAG, "[Register] Invalid request when registering account: ${registerResult.error.message}")
|
||||
state.copy(oneTimeEvent = OneTimeEvent.RegistrationError)
|
||||
}
|
||||
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
|
||||
Log.w(TAG, "[Register] Registration recovery password incorrect: ${registerResult.error.message}")
|
||||
state.copy(oneTimeEvent = OneTimeEvent.RegistrationError)
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||
Log.w(TAG, "[Register] Network error.", registerResult.exception)
|
||||
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "[Register] Unknown error when registering account.", registerResult.exception)
|
||||
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun transformResendCode(
|
||||
inputState: VerificationCodeState,
|
||||
transport: NetworkController.VerificationCodeTransport
|
||||
): VerificationCodeState {
|
||||
val state = inputState.copy()
|
||||
if (state.sessionMetadata == null) {
|
||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||
return inputState
|
||||
}
|
||||
|
||||
val sessionMetadata = state.sessionMetadata
|
||||
|
||||
val result = repository.requestVerificationCode(
|
||||
sessionId = sessionMetadata.id,
|
||||
smsAutoRetrieveCodeSupported = false,
|
||||
transport = transport
|
||||
)
|
||||
|
||||
return when (result) {
|
||||
is NetworkController.RegistrationNetworkResult.Success -> {
|
||||
state.copy(sessionMetadata = result.data)
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||
when (result.error) {
|
||||
is NetworkController.RequestVerificationCodeError.InvalidRequest -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.RateLimited -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(result.error.retryAfter))
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.InvalidSessionId -> {
|
||||
// TODO don't start over, go back to phone number entry
|
||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||
state
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> {
|
||||
Log.w(TAG, "When requesting verification code, missing request information or already verified.")
|
||||
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.SessionNotFound -> {
|
||||
// TODO don't start over, go back to phone number entry
|
||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||
state
|
||||
}
|
||||
is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.ThirdPartyError)
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "Unknown error when requesting verification code.", result.exception)
|
||||
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentState: StateFlow<RegistrationFlowState>,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return VerificationCodeViewModel(repository, parentState, parentEventEmitter) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.signal.registration.screens.welcome
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.dismissWithAnimation
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Welcome screen for the registration flow.
|
||||
* This is the initial screen users see when starting the registration process.
|
||||
*/
|
||||
@Composable
|
||||
fun WelcomeScreen(
|
||||
onEvent: (WelcomeScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Welcome to Signal",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Button(
|
||||
onClick = { onEvent(WelcomeScreenEvents.Continue) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.WELCOME_GET_STARTED_BUTTON)
|
||||
) {
|
||||
Text("Get Started")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { showBottomSheet = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON)
|
||||
) {
|
||||
Text("Restore or transfer")
|
||||
}
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
RestoreOrTransferBottomSheet(
|
||||
onEvent = {
|
||||
showBottomSheet = false
|
||||
onEvent(it)
|
||||
},
|
||||
onDismiss = { showBottomSheet = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom sheet for restore or transfer options.
|
||||
*/
|
||||
@Composable
|
||||
private fun RestoreOrTransferBottomSheet(
|
||||
onEvent: (WelcomeScreenEvents) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BottomSheets.BottomSheet(
|
||||
onDismissRequest = { sheetState.dismissWithAnimation(scope, onComplete = onDismiss) },
|
||||
sheetState = sheetState
|
||||
) {
|
||||
RestoreOrTransferBottomSheetContent(
|
||||
sheetState = sheetState,
|
||||
onEvent = onEvent,
|
||||
scope = scope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom sheet content for restore or transfer options (needs to be separate for preview).
|
||||
*/
|
||||
@Composable
|
||||
private fun RestoreOrTransferBottomSheetContent(
|
||||
sheetState: SheetState,
|
||||
scope: CoroutineScope,
|
||||
onEvent: (WelcomeScreenEvents) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
sheetState.dismissWithAnimation(scope) {
|
||||
onEvent(WelcomeScreenEvents.HasOldPhone)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON)
|
||||
) {
|
||||
Text("I have my old phone")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onEvent(WelcomeScreenEvents.DoesNotHaveOldPhone)
|
||||
sheetState.dismissWithAnimation(scope) {
|
||||
onEvent(WelcomeScreenEvents.DoesNotHaveOldPhone)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON)
|
||||
) {
|
||||
Text("I don't have my old phone")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun WelcomeScreenPreview() {
|
||||
Previews.Preview {
|
||||
WelcomeScreen(onEvent = {})
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RestoreOrTransferBottomSheetPreview() {
|
||||
Previews.BottomSheetPreview(forceRtl = true) {
|
||||
RestoreOrTransferBottomSheetContent(
|
||||
sheetState = rememberModalBottomSheetState(),
|
||||
scope = rememberCoroutineScope(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.welcome
|
||||
|
||||
sealed class WelcomeScreenEvents {
|
||||
data object Continue : WelcomeScreenEvents()
|
||||
data object HasOldPhone : WelcomeScreenEvents()
|
||||
data object DoesNotHaveOldPhone : WelcomeScreenEvents()
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.test
|
||||
|
||||
/**
|
||||
* Test tags for Compose UI testing.
|
||||
*/
|
||||
object TestTags {
|
||||
|
||||
// Welcome Screen
|
||||
const val WELCOME_GET_STARTED_BUTTON = "welcome_get_started_button"
|
||||
const val WELCOME_RESTORE_OR_TRANSFER_BUTTON = "welcome_restore_or_transfer_button"
|
||||
const val WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON = "welcome_restore_has_old_phone_button"
|
||||
const val WELCOME_RESTORE_NO_OLD_PHONE_BUTTON = "welcome_restore_no_old_phone_button"
|
||||
|
||||
// Permissions Screen
|
||||
const val PERMISSIONS_NEXT_BUTTON = "permissions_next_button"
|
||||
const val PERMISSIONS_NOT_NOW_BUTTON = "permissions_not_now_button"
|
||||
|
||||
// Phone Number Screen
|
||||
const val PHONE_NUMBER_COUNTRY_PICKER = "phone_number_country_picker"
|
||||
const val PHONE_NUMBER_COUNTRY_CODE_FIELD = "phone_number_country_code_field"
|
||||
const val PHONE_NUMBER_PHONE_FIELD = "phone_number_phone_field"
|
||||
const val PHONE_NUMBER_NEXT_BUTTON = "phone_number_next_button"
|
||||
|
||||
// Verification Code Screen
|
||||
const val VERIFICATION_CODE_INPUT = "verification_code_input"
|
||||
const val VERIFICATION_CODE_DIGIT_0 = "verification_code_digit_0"
|
||||
const val VERIFICATION_CODE_DIGIT_1 = "verification_code_digit_1"
|
||||
const val VERIFICATION_CODE_DIGIT_2 = "verification_code_digit_2"
|
||||
const val VERIFICATION_CODE_DIGIT_3 = "verification_code_digit_3"
|
||||
const val VERIFICATION_CODE_DIGIT_4 = "verification_code_digit_4"
|
||||
const val VERIFICATION_CODE_DIGIT_5 = "verification_code_digit_5"
|
||||
const val VERIFICATION_CODE_WRONG_NUMBER_BUTTON = "verification_code_wrong_number_button"
|
||||
const val VERIFICATION_CODE_RESEND_SMS_BUTTON = "verification_code_resend_sms_button"
|
||||
const val VERIFICATION_CODE_CALL_ME_BUTTON = "verification_code_call_me_button"
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import io.mockk.mockk
|
||||
import org.junit.Before
|
||||
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.compose.theme.SignalTheme
|
||||
import org.signal.registration.screens.util.MockMultiplePermissionsState
|
||||
import org.signal.registration.screens.util.MockPermissionsState
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Tests for registration navigation flow using Navigation 3.
|
||||
* Tests navigation by verifying UI state changes rather than using NavController.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class RegistrationNavigationTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private lateinit var viewModel: RegistrationViewModel
|
||||
private lateinit var mockRepository: RegistrationRepository
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
mockRepository = mockk<RegistrationRepository>(relaxed = true)
|
||||
viewModel = RegistrationViewModel(mockRepository, SavedStateHandle())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigation starts at Welcome screen`() {
|
||||
// Given
|
||||
val permissionsState = createMockPermissionsState()
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme(incognitoKeyboardEnabled = false) {
|
||||
RegistrationNavHost(
|
||||
registrationRepository = mockRepository,
|
||||
registrationViewModel = viewModel,
|
||||
permissionsState = permissionsState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Then - verify Welcome screen is displayed
|
||||
composeTestRule.onNodeWithText("Welcome to Signal").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking Get Started navigates from Welcome to Permissions`() {
|
||||
// Given
|
||||
val permissionsState = createMockPermissionsState()
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
RegistrationNavHost(
|
||||
registrationRepository = mockRepository,
|
||||
registrationViewModel = viewModel,
|
||||
permissionsState = permissionsState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick()
|
||||
|
||||
// Then - verify Permissions screen is displayed
|
||||
composeTestRule.onNodeWithText("Permissions").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking Next on Permissions navigates to PhoneNumber`() {
|
||||
// Given
|
||||
val permissionsState = createMockPermissionsState()
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
RegistrationNavHost(
|
||||
registrationRepository = mockRepository,
|
||||
registrationViewModel = viewModel,
|
||||
permissionsState = permissionsState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to Permissions screen first
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick()
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.PERMISSIONS_NEXT_BUTTON).performClick()
|
||||
|
||||
// Then - verify PhoneNumber screen is displayed
|
||||
composeTestRule.onNodeWithText("You will receive a verification code").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking Not now on Permissions navigates to PhoneNumber`() {
|
||||
// Given
|
||||
val permissionsState = createMockPermissionsState()
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
RegistrationNavHost(
|
||||
registrationRepository = mockRepository,
|
||||
registrationViewModel = viewModel,
|
||||
permissionsState = permissionsState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to Permissions screen first
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick()
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.PERMISSIONS_NOT_NOW_BUTTON).performClick()
|
||||
|
||||
// Then - verify PhoneNumber screen is displayed
|
||||
composeTestRule.onNodeWithText("You will receive a verification code").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// Note: Back navigation testing in Navigation 3 requires testing through
|
||||
// actual back button presses at the Activity level, which is better suited
|
||||
// for instrumentation tests. The back stack is managed internally by Nav3
|
||||
// and not directly accessible in unit tests.
|
||||
|
||||
@Test
|
||||
fun `clicking I have my old phone navigates to Permissions for restore`() {
|
||||
// Given
|
||||
val permissionsState = createMockPermissionsState()
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
RegistrationNavHost(
|
||||
registrationRepository = mockRepository,
|
||||
registrationViewModel = viewModel,
|
||||
permissionsState = permissionsState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).performClick()
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON).performClick()
|
||||
|
||||
// Then - verify Permissions screen is displayed
|
||||
// (After permissions, user would go to RestoreViaQr screen)
|
||||
composeTestRule.onNodeWithText("Permissions").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking I don't have my old phone navigates to Restore`() {
|
||||
// Given
|
||||
val permissionsState = createMockPermissionsState()
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
RegistrationNavHost(
|
||||
registrationRepository = mockRepository,
|
||||
registrationViewModel = viewModel,
|
||||
permissionsState = permissionsState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).performClick()
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON).performClick()
|
||||
|
||||
// Then - verify Restore screen is displayed (or its expected content)
|
||||
// Note: Update this assertion based on actual Restore screen content when implemented
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock permissions state for testing.
|
||||
* Since we're in JUnit tests, we can't use the real rememberMultiplePermissionsState.
|
||||
*/
|
||||
private fun createMockPermissionsState(): MockMultiplePermissionsState {
|
||||
return MockMultiplePermissionsState(
|
||||
permissions = viewModel.getRequiredPermissions().map { MockPermissionsState(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,840 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.phonenumber
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasSize
|
||||
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.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationFlowState
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.RegistrationRoute
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class PhoneNumberEntryViewModelTest {
|
||||
|
||||
private lateinit var viewModel: PhoneNumberEntryViewModel
|
||||
private lateinit var mockRepository: RegistrationRepository
|
||||
private lateinit var parentState: MutableStateFlow<RegistrationFlowState>
|
||||
private lateinit var emittedStates: MutableList<PhoneNumberEntryState>
|
||||
private lateinit var stateEmitter: (PhoneNumberEntryState) -> Unit
|
||||
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
|
||||
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
mockRepository = mockk(relaxed = true)
|
||||
parentState = MutableStateFlow(RegistrationFlowState())
|
||||
emittedStates = mutableListOf()
|
||||
stateEmitter = { state -> emittedStates.add(state) }
|
||||
emittedEvents = mutableListOf()
|
||||
parentEventEmitter = { event -> emittedEvents.add(event) }
|
||||
viewModel = PhoneNumberEntryViewModel(mockRepository, parentState, parentEventEmitter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state has default US region and country code`() {
|
||||
val state = PhoneNumberEntryState()
|
||||
|
||||
assertThat(state.regionCode).isEqualTo("US")
|
||||
assertThat(state.countryCode).isEqualTo("1")
|
||||
assertThat(state.nationalNumber).isEqualTo("")
|
||||
assertThat(state.formattedNumber).isEqualTo("")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberChanged extracts digits and formats number`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
|
||||
assertThat(emittedStates.last().formattedNumber).isEqualTo("(555) 123-4567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberChanged with raw digits formats correctly`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551234567"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
|
||||
assertThat(emittedStates.last().formattedNumber).isEqualTo("(555) 123-4567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberChanged formats progressively as digits are added`() = runTest {
|
||||
var state = PhoneNumberEntryState()
|
||||
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.nationalNumber).isEqualTo("5")
|
||||
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.nationalNumber).isEqualTo("55")
|
||||
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("555"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.nationalNumber).isEqualTo("555")
|
||||
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.nationalNumber).isEqualTo("5551")
|
||||
// libphonenumber formats progressively - at 4 digits it's still building the format
|
||||
assertThat(state.formattedNumber).isEqualTo("555-1")
|
||||
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55512"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.nationalNumber).isEqualTo("55512")
|
||||
assertThat(state.formattedNumber).isEqualTo("555-12")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberChanged ignores non-digit characters`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.PhoneNumberChanged("(555) abc 123-4567!"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberChanged with same digits does not emit new state`() = runTest {
|
||||
val initialState = PhoneNumberEntryState(nationalNumber = "5551234567", formattedNumber = "(555) 123-4567")
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
// Should emit the same state since digits haven't changed
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last()).isEqualTo(initialState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CountryCodeChanged updates country code and region`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.CountryCodeChanged("44"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().countryCode).isEqualTo("44")
|
||||
assertThat(emittedStates.last().regionCode).isEqualTo("GB")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CountryCodeChanged sanitizes input to digits only`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.CountryCodeChanged("+44abc"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().countryCode).isEqualTo("44")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CountryCodeChanged limits to 3 digits`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.CountryCodeChanged("12345"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().countryCode).isEqualTo("123")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CountryCodeChanged reformats existing phone number for new region`() = runTest {
|
||||
// Start with a US number
|
||||
val state = PhoneNumberEntryState(
|
||||
regionCode = "US",
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567",
|
||||
formattedNumber = "(555) 123-4567"
|
||||
)
|
||||
|
||||
// Change to UK
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("44"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().countryCode).isEqualTo("44")
|
||||
assertThat(emittedStates.last().regionCode).isEqualTo("GB")
|
||||
// The digits should be reformatted for UK format
|
||||
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CountryPicker emits NavigateToCountryPicker event`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.CountryPicker,
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(
|
||||
RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.CountryCodePicker)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConsumeInnerOneTimeEvent clears inner event`() = runTest {
|
||||
val initialState = PhoneNumberEntryState(
|
||||
oneTimeEvent = PhoneNumberEntryState.OneTimeEvent.NetworkError
|
||||
)
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent,
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().oneTimeEvent).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `German country code formats correctly`() = runTest {
|
||||
var state = PhoneNumberEntryState()
|
||||
|
||||
// Set German country code
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("49"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.regionCode).isEqualTo("DE")
|
||||
|
||||
// Enter a German number
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("15123456789"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.nationalNumber).isEqualTo("15123456789")
|
||||
}
|
||||
|
||||
// ==================== PhoneNumberSubmitted Tests ====================
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted creates session and requests code on success`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata(requestedInformation = emptyList())
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().sessionMetadata).isNotNull()
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted navigates to captcha when required`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata(requestedInformation = listOf("captcha"))
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.Captcha>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted handles rate limiting from createSession`() = runTest {
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.CreateSessionError.RateLimited(60.seconds)
|
||||
)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
|
||||
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
|
||||
.prop(PhoneNumberEntryState.OneTimeEvent.RateLimited::retryAfter)
|
||||
.isEqualTo(60.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted handles invalid request from createSession`() = runTest {
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.CreateSessionError.InvalidRequest("Bad request")
|
||||
)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted handles network error`() = runTest {
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error"))
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted handles application error`() = runTest {
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected error"))
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted reuses existing session`() = runTest {
|
||||
val existingSession = createSessionMetadata(id = "existing-session")
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567",
|
||||
sessionMetadata = existingSession
|
||||
)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(existingSession)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Should not create a new session, just request verification code
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted handles rate limiting from requestVerificationCode`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.RateLimited(30.seconds, sessionMetadata)
|
||||
)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isNotNull().isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted handles session not found`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.SessionNotFound("Session expired")
|
||||
)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted handles transport not supported`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(sessionMetadata)
|
||||
)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted handles third party service error`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.ThirdPartyServiceError(
|
||||
NetworkController.ThirdPartyServiceErrorResponse("Provider error", false)
|
||||
)
|
||||
)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.ThirdPartyError)
|
||||
}
|
||||
|
||||
// ==================== Push Challenge Tests ====================
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted with push challenge submits token when received`() = runTest {
|
||||
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
|
||||
val sessionAfterPushChallenge = createSessionMetadata(requestedInformation = emptyList())
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
|
||||
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionAfterPushChallenge)
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionAfterPushChallenge)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Verify navigation to verification code entry
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
|
||||
// Verify push challenge token was submitted
|
||||
io.mockk.coVerify { mockRepository.submitPushChallengeToken(sessionWithPushChallenge.id, "test-push-challenge-token") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted with push challenge continues when token times out`() = runTest {
|
||||
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
coEvery { mockRepository.awaitPushChallengeToken() } returns null
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Verify navigation continues despite no push challenge token
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
|
||||
// Verify submit was never called since token was null
|
||||
io.mockk.coVerify(exactly = 0) { mockRepository.submitPushChallengeToken(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted with push challenge continues when submission fails`() = runTest {
|
||||
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
|
||||
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.UpdateSessionError.RejectedUpdate("Invalid token")
|
||||
)
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Verify navigation continues despite push challenge submission failure
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted with push challenge continues when submission has network error`() = runTest {
|
||||
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
|
||||
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Connection lost"))
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Verify navigation continues despite network error
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted with push challenge continues when submission has application error`() = runTest {
|
||||
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
|
||||
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected error"))
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Verify navigation continues despite application error
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted with push challenge navigates to captcha if still required after submission`() = runTest {
|
||||
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge", "captcha"))
|
||||
val sessionAfterPushChallenge = createSessionMetadata(requestedInformation = listOf("captcha"))
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
|
||||
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionAfterPushChallenge)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Verify navigation to captcha
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.Captcha>()
|
||||
}
|
||||
|
||||
// ==================== CaptchaCompleted Tests ====================
|
||||
|
||||
@Test
|
||||
fun `CaptchaCompleted submits token and navigates to verification code`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata(requestedInformation = emptyList())
|
||||
val initialState = PhoneNumberEntryState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CaptchaCompleted returns error when no session exists`() = runTest {
|
||||
val initialState = PhoneNumberEntryState(sessionMetadata = null)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CaptchaCompleted handles captcha still required after submission`() = runTest {
|
||||
val sessionWithCaptcha = createSessionMetadata(requestedInformation = listOf("captcha"))
|
||||
val initialState = PhoneNumberEntryState(sessionMetadata = sessionWithCaptcha)
|
||||
|
||||
coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithCaptcha)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.Captcha>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CaptchaCompleted handles rate limiting`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = PhoneNumberEntryState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.UpdateSessionError.RateLimited(45.seconds, sessionMetadata)
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
|
||||
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
|
||||
.prop(PhoneNumberEntryState.OneTimeEvent.RateLimited::retryAfter)
|
||||
.isEqualTo(45.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CaptchaCompleted handles rejected update`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = PhoneNumberEntryState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.UpdateSessionError.RejectedUpdate("Invalid captcha")
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CaptchaCompleted handles network error`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = PhoneNumberEntryState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Connection lost"))
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
|
||||
}
|
||||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||
private fun createSessionMetadata(
|
||||
id: String = "test-session-id",
|
||||
requestedInformation: List<String> = emptyList(),
|
||||
verified: Boolean = false
|
||||
) = NetworkController.SessionMetadata(
|
||||
id = id,
|
||||
nextSms = null,
|
||||
nextCall = null,
|
||||
nextVerificationAttempt = null,
|
||||
allowedToRequestCode = true,
|
||||
requestedInformation = requestedInformation,
|
||||
verified = verified
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.phonenumber
|
||||
|
||||
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 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.compose.theme.SignalTheme
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Tests for PhoneNumberScreen that validate user interactions and event emissions.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class PhoneNumberScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `Next button is disabled when fields are empty`() {
|
||||
// Given
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
PhoneNumberScreen(
|
||||
state = PhoneNumberEntryState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Next button is enabled when nationalNumber is present in state`() {
|
||||
// Given
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
PhoneNumberScreen(
|
||||
state = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567",
|
||||
formattedNumber = "(555) 123-4567"
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Next is clicked, PhoneNumberSubmitted event is emitted`() {
|
||||
// Given
|
||||
var emittedEvent: PhoneNumberEntryScreenEvents? = null
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
PhoneNumberScreen(
|
||||
state = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567",
|
||||
formattedNumber = "(555) 123-4567"
|
||||
),
|
||||
onEvent = { event ->
|
||||
emittedEvent = event
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When - click Next
|
||||
composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).performClick()
|
||||
|
||||
// Then
|
||||
assert(emittedEvent is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) {
|
||||
"Expected PhoneNumberSubmitted event but got $emittedEvent"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking country picker emits CountryPicker event`() {
|
||||
// Given
|
||||
var emittedEvent: PhoneNumberEntryScreenEvents? = null
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
PhoneNumberScreen(
|
||||
state = PhoneNumberEntryState(),
|
||||
onEvent = { event ->
|
||||
emittedEvent = event
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_COUNTRY_PICKER).performClick()
|
||||
|
||||
// Then
|
||||
assert(emittedEvent is PhoneNumberEntryScreenEvents.CountryPicker) {
|
||||
"Expected CountryPicker event but got $emittedEvent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.verificationcode
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
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.compose.theme.SignalTheme
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Tests for VerificationCodeScreen that validate event emissions and UI behavior.
|
||||
* Uses Robolectric to run fast JUnit tests without an emulator.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class VerificationCodeScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `screen displays title`() {
|
||||
// Given
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithText("Enter verification code").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `screen displays all six digit fields`() {
|
||||
// Given
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_1).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_2).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_3).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_4).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking wrong number emits WrongNumber event`() {
|
||||
// Given
|
||||
var emittedEvent: VerificationCodeScreenEvents? = null
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(),
|
||||
onEvent = { event ->
|
||||
emittedEvent = event
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_WRONG_NUMBER_BUTTON).performClick()
|
||||
|
||||
// Then
|
||||
assert(emittedEvent == VerificationCodeScreenEvents.WrongNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking resend SMS emits ResendSms event`() {
|
||||
// Given
|
||||
var emittedEvent: VerificationCodeScreenEvents? = null
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(),
|
||||
onEvent = { event ->
|
||||
emittedEvent = event
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_RESEND_SMS_BUTTON).performClick()
|
||||
|
||||
// Then
|
||||
assert(emittedEvent == VerificationCodeScreenEvents.ResendSms)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking call me emits CallMe event`() {
|
||||
// Given
|
||||
var emittedEvent: VerificationCodeScreenEvents? = null
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(),
|
||||
onEvent = { event ->
|
||||
emittedEvent = event
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_CALL_ME_BUTTON).performClick()
|
||||
|
||||
// Then
|
||||
assert(emittedEvent == VerificationCodeScreenEvents.CallMe)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entering complete code emits CodeEntered event`() {
|
||||
// Given
|
||||
var emittedEvent: VerificationCodeScreenEvents? = null
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(),
|
||||
onEvent = { event ->
|
||||
emittedEvent = event
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When - enter all 6 digits
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).performTextInput("1")
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_1).performTextInput("2")
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_2).performTextInput("3")
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_3).performTextInput("4")
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_4).performTextInput("5")
|
||||
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).performTextInput("6")
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Then
|
||||
assert(emittedEvent is VerificationCodeScreenEvents.CodeEntered) {
|
||||
"Expected CodeEntered event but got $emittedEvent"
|
||||
}
|
||||
assert((emittedEvent as VerificationCodeScreenEvents.CodeEntered).code == "123456") {
|
||||
"Expected code '123456' but got ${(emittedEvent as VerificationCodeScreenEvents.CodeEntered).code}"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `screen displays all action buttons`() {
|
||||
// Given
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithText("Wrong number?").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Resend SMS").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Call me instead").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,773 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.verificationcode
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.prop
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationFlowState
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.RegistrationRoute
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class VerificationCodeViewModelTest {
|
||||
|
||||
private lateinit var viewModel: VerificationCodeViewModel
|
||||
private lateinit var mockRepository: RegistrationRepository
|
||||
private lateinit var parentState: MutableStateFlow<RegistrationFlowState>
|
||||
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
|
||||
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
mockRepository = mockk(relaxed = true)
|
||||
// Initialize with valid session data to prevent ResetState emission during ViewModel initialization
|
||||
parentState = MutableStateFlow(
|
||||
RegistrationFlowState(
|
||||
sessionMetadata = createSessionMetadata(),
|
||||
sessionE164 = "+15551234567"
|
||||
)
|
||||
)
|
||||
emittedEvents = mutableListOf()
|
||||
parentEventEmitter = { event -> emittedEvents.add(event) }
|
||||
viewModel = VerificationCodeViewModel(mockRepository, parentState, parentEventEmitter)
|
||||
}
|
||||
|
||||
// ==================== applyParentState Tests ====================
|
||||
|
||||
@Test
|
||||
fun `applyParentState with null sessionMetadata emits ResetState`() {
|
||||
val state = VerificationCodeState()
|
||||
val parentFlowState = RegistrationFlowState(
|
||||
sessionMetadata = null,
|
||||
sessionE164 = "+15551234567"
|
||||
)
|
||||
|
||||
viewModel.applyParentState(state, parentFlowState)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyParentState with null sessionE164 emits ResetState`() {
|
||||
val state = VerificationCodeState()
|
||||
val parentFlowState = RegistrationFlowState(
|
||||
sessionMetadata = createSessionMetadata(),
|
||||
sessionE164 = null
|
||||
)
|
||||
|
||||
viewModel.applyParentState(state, parentFlowState)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyParentState with both null values emits ResetState`() {
|
||||
val state = VerificationCodeState()
|
||||
val parentFlowState = RegistrationFlowState(
|
||||
sessionMetadata = null,
|
||||
sessionE164 = null
|
||||
)
|
||||
|
||||
viewModel.applyParentState(state, parentFlowState)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyParentState with valid session copies metadata and e164`() {
|
||||
val state = VerificationCodeState()
|
||||
val sessionMetadata = createSessionMetadata(id = "test-session")
|
||||
val e164 = "+15551234567"
|
||||
val parentFlowState = RegistrationFlowState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
sessionE164 = e164
|
||||
)
|
||||
|
||||
val result = viewModel.applyParentState(state, parentFlowState)
|
||||
|
||||
assertThat(emittedEvents).hasSize(0)
|
||||
assertThat(result.sessionMetadata).isEqualTo(sessionMetadata)
|
||||
assertThat(result.e164).isEqualTo(e164)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyParentState preserves existing oneTimeEvent`() {
|
||||
val state = VerificationCodeState(oneTimeEvent = VerificationCodeState.OneTimeEvent.NetworkError)
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val parentFlowState = RegistrationFlowState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
sessionE164 = "+15551234567"
|
||||
)
|
||||
|
||||
val result = viewModel.applyParentState(state, parentFlowState)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
|
||||
}
|
||||
|
||||
// ==================== applyEvent: ConsumeInnerOneTimeEvent Tests ====================
|
||||
|
||||
@Test
|
||||
fun `ConsumeInnerOneTimeEvent clears oneTimeEvent`() = runTest {
|
||||
val initialState = VerificationCodeState(
|
||||
oneTimeEvent = VerificationCodeState.OneTimeEvent.NetworkError
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent
|
||||
)
|
||||
|
||||
assertThat(result.oneTimeEvent).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConsumeInnerOneTimeEvent with null event returns state with null event`() = runTest {
|
||||
val initialState = VerificationCodeState(oneTimeEvent = null)
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent
|
||||
)
|
||||
|
||||
assertThat(result.oneTimeEvent).isNull()
|
||||
}
|
||||
|
||||
// ==================== applyEvent: WrongNumber Tests ====================
|
||||
|
||||
@Test
|
||||
fun `WrongNumber navigates to PhoneNumberEntry`() = runTest {
|
||||
val initialState = VerificationCodeState()
|
||||
|
||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.WrongNumber)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.PhoneNumberEntry>()
|
||||
}
|
||||
|
||||
// ==================== applyEvent: CodeEntered Tests ====================
|
||||
|
||||
@Test
|
||||
fun `CodeEntered emits ResetState when sessionMetadata is null`() = runTest {
|
||||
val initialState = VerificationCodeState(sessionMetadata = null)
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(initialState)
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.ResetState>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CodeEntered with success registers account and navigates to FullyComplete`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata(verified = true)
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
val registerResponse = createRegisterAccountResponse()
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.registerAccount(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(registerResponse)
|
||||
|
||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.FullyComplete>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CodeEntered with incorrect code returns IncorrectVerificationCode event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode("Wrong code")
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
||||
)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.IncorrectVerificationCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CodeEntered with session not found emits ResetState`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.SubmitVerificationCodeError.SessionNotFound("Session expired")
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CodeEntered with already verified session continues to register`() = runTest {
|
||||
val verifiedSession = createSessionMetadata(verified = true)
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = createSessionMetadata(verified = false),
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
val registerResponse = createRegisterAccountResponse()
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested(verifiedSession)
|
||||
)
|
||||
coEvery { mockRepository.registerAccount(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(registerResponse)
|
||||
|
||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.FullyComplete>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CodeEntered with no code requested and not verified navigates back`() = runTest {
|
||||
val unverifiedSession = createSessionMetadata(verified = false)
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = createSessionMetadata(),
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested(unverifiedSession)
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CodeEntered with rate limit from submitVerificationCode returns RateLimited event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.SubmitVerificationCodeError.RateLimited(60.seconds, sessionMetadata)
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
||||
)
|
||||
|
||||
assertThat(result.oneTimeEvent).isNotNull()
|
||||
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
|
||||
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
|
||||
.isEqualTo(60.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CodeEntered with network error from submitVerificationCode returns NetworkError event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error"))
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
||||
)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CodeEntered with application error from submitVerificationCode returns UnknownError event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
||||
)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
|
||||
// ==================== applyEvent: CodeEntered - Registration Errors ====================
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun `CodeEntered with DeviceTransferPossible emits ResetState`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.registerAccount(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RegisterAccountError.DeviceTransferPossible
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CodeEntered("123456"))
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun `CodeEntered with rate limit from registerAccount returns RateLimited event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.registerAccount(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RegisterAccountError.RateLimited(30.seconds)
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
||||
)
|
||||
|
||||
assertThat(result.oneTimeEvent).isNotNull()
|
||||
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
|
||||
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
|
||||
.isEqualTo(30.seconds)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun `CodeEntered with InvalidRequest from registerAccount returns RegistrationError event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.registerAccount(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RegisterAccountError.InvalidRequest("Bad request")
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
||||
)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.RegistrationError)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun `CodeEntered with RegistrationRecoveryPasswordIncorrect returns RegistrationError event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.registerAccount(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect("Wrong password")
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
||||
)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.RegistrationError)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun `CodeEntered with network error from registerAccount returns NetworkError event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.registerAccount(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error"))
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
||||
)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun `CodeEntered with application error from registerAccount returns UnknownError event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(
|
||||
sessionMetadata = sessionMetadata,
|
||||
e164 = "+15551234567"
|
||||
)
|
||||
|
||||
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
coEvery { mockRepository.registerAccount(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
initialState,
|
||||
VerificationCodeScreenEvents.CodeEntered("123456")
|
||||
)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
|
||||
// ==================== applyEvent: ResendSms Tests ====================
|
||||
|
||||
@Test
|
||||
fun `ResendSms with null sessionMetadata emits ResetState`() = runTest {
|
||||
val initialState = VerificationCodeState(sessionMetadata = null)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||
assertThat(result).isEqualTo(initialState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResendSms with success updates sessionMetadata`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata(id = "original-session")
|
||||
val updatedSession = createSessionMetadata(id = "updated-session")
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(updatedSession)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
||||
|
||||
assertThat(result.sessionMetadata).isEqualTo(updatedSession)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResendSms with rate limit returns RateLimited event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.RateLimited(45.seconds, sessionMetadata)
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
||||
|
||||
assertThat(result.oneTimeEvent).isNotNull()
|
||||
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
|
||||
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
|
||||
.isEqualTo(45.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResendSms with InvalidRequest returns UnknownError event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.InvalidRequest("Bad request")
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResendSms with CouldNotFulfillWithRequestedTransport returns appropriate event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(sessionMetadata)
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResendSms with InvalidSessionId emits ResetState`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.InvalidSessionId("Invalid session")
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResendSms with SessionNotFound emits ResetState`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.SessionNotFound("Session not found")
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResendSms with MissingRequestInformationOrAlreadyVerified returns NetworkError event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified(sessionMetadata)
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResendSms with ThirdPartyServiceError returns ThirdPartyError event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.ThirdPartyServiceError(
|
||||
NetworkController.ThirdPartyServiceErrorResponse("Provider error", false)
|
||||
)
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.ThirdPartyError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResendSms with network error returns NetworkError event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error"))
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.NetworkError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResendSms with application error returns UnknownError event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.SMS)) } returns
|
||||
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected"))
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
|
||||
// ==================== applyEvent: CallMe Tests ====================
|
||||
|
||||
@Test
|
||||
fun `CallMe with null sessionMetadata emits ResetState`() = runTest {
|
||||
val initialState = VerificationCodeState(sessionMetadata = null)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||
assertThat(result).isEqualTo(initialState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CallMe with success updates sessionMetadata`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata(id = "original-session")
|
||||
val updatedSession = createSessionMetadata(id = "updated-session")
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.VOICE)) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(updatedSession)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe)
|
||||
|
||||
assertThat(result.sessionMetadata).isEqualTo(updatedSession)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CallMe with rate limit returns RateLimited event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.VOICE)) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.RateLimited(90.seconds, sessionMetadata)
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe)
|
||||
|
||||
assertThat(result.oneTimeEvent).isNotNull()
|
||||
.isInstanceOf<VerificationCodeState.OneTimeEvent.RateLimited>()
|
||||
.prop(VerificationCodeState.OneTimeEvent.RateLimited::retryAfter)
|
||||
.isEqualTo(90.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CallMe with CouldNotFulfillWithRequestedTransport returns appropriate event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.VOICE)) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(sessionMetadata)
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CallMe with ThirdPartyServiceError returns ThirdPartyError event`() = runTest {
|
||||
val sessionMetadata = createSessionMetadata()
|
||||
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
|
||||
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), eq(NetworkController.VerificationCodeTransport.VOICE)) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.RequestVerificationCodeError.ThirdPartyServiceError(
|
||||
NetworkController.ThirdPartyServiceErrorResponse("Voice provider error", true)
|
||||
)
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, VerificationCodeScreenEvents.CallMe)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.ThirdPartyError)
|
||||
}
|
||||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||
private fun createSessionMetadata(
|
||||
id: String = "test-session-id",
|
||||
requestedInformation: List<String> = emptyList(),
|
||||
verified: Boolean = false
|
||||
) = NetworkController.SessionMetadata(
|
||||
id = id,
|
||||
nextSms = null,
|
||||
nextCall = null,
|
||||
nextVerificationAttempt = null,
|
||||
allowedToRequestCode = true,
|
||||
requestedInformation = requestedInformation,
|
||||
verified = verified
|
||||
)
|
||||
|
||||
private fun createRegisterAccountResponse(
|
||||
aci: String = "test-aci",
|
||||
pni: String = "test-pni",
|
||||
e164: String = "+15551234567"
|
||||
) = NetworkController.RegisterAccountResponse(
|
||||
aci = aci,
|
||||
pni = pni,
|
||||
e164 = e164,
|
||||
usernameHash = null,
|
||||
usernameLinkHandle = null,
|
||||
storageCapable = false,
|
||||
entitlements = null,
|
||||
reregistration = false
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.welcome
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
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.compose.theme.SignalTheme
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Tests for WelcomeScreen that validate event emissions.
|
||||
* Uses Robolectric to run fast JUnit tests without an emulator.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class WelcomeScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `when Get Started is clicked, Continue event is emitted`() {
|
||||
// Given
|
||||
var emittedEvent: WelcomeScreenEvents? = null
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
WelcomeScreen(
|
||||
onEvent = { event ->
|
||||
emittedEvent = event
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick()
|
||||
|
||||
// Then
|
||||
assert(emittedEvent == WelcomeScreenEvents.Continue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Restore or transfer is clicked, bottom sheet is shown`() {
|
||||
// Given
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
WelcomeScreen(onEvent = {})
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).performClick()
|
||||
|
||||
// Then - bottom sheet options should be visible
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when I have my old phone is clicked, HasOldPhone event is emitted`() {
|
||||
// Given
|
||||
var emittedEvent: WelcomeScreenEvents? = null
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
WelcomeScreen(
|
||||
onEvent = { event ->
|
||||
emittedEvent = event
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).performClick()
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON).performClick()
|
||||
|
||||
// Then
|
||||
assert(emittedEvent == WelcomeScreenEvents.HasOldPhone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when I don't have my old phone is clicked, DoesNotHaveOldPhone event is emitted`() {
|
||||
// Given
|
||||
var emittedEvent: WelcomeScreenEvents? = null
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
WelcomeScreen(
|
||||
onEvent = { event ->
|
||||
emittedEvent = event
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).performClick()
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON).performClick()
|
||||
|
||||
// Then
|
||||
assert(emittedEvent == WelcomeScreenEvents.DoesNotHaveOldPhone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `screen displays welcome message`() {
|
||||
// Given
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
WelcomeScreen(onEvent = {})
|
||||
}
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithText("Welcome to Signal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue