Repo cloned

This commit is contained in:
Fr4nz D13trich 2025-12-29 13:18:34 +01:00
commit 496ae75f58
7988 changed files with 1451097 additions and 0 deletions

View 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)
}

View 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>

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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())
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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
)

View file

@ -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
)
}

View file

@ -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 = {}
)
}
}

View file

@ -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()
}

View file

@ -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
)

View file

@ -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 = {}
)
}
}

View file

@ -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()
}

View file

@ -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
)

View file

@ -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 = {}
)
}
}

View file

@ -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 = {}
)
}
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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 = {}
)
}
}

View file

@ -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()
}

View file

@ -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
)

View file

@ -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 = {}
)
}
}

View file

@ -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!")
}
}
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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 = {}
)
}
}

View file

@ -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()
}

View file

@ -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
)

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 = {}
)
}
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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 = {}
)
}
}

View file

@ -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()
}

View file

@ -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"
}

View file

@ -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) }
)
}
}

View file

@ -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
)
}

View file

@ -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"
}
}
}

View file

@ -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()
}
}

View file

@ -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
)
}

View file

@ -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()
}
}