Re-organize gradle modules.

This commit is contained in:
Greyson Parrelli
2025-12-31 11:56:13 -05:00
committed by jeffrey-signal
parent f4863efb2e
commit e162eb27c7
1444 changed files with 111 additions and 144 deletions

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,391 @@
/*
* 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>
/**
* Retrieves SVR2 authentication credentials for the authenticated account.
*
* `GET /v2/svr/auth`
*
* @return SVR credentials on success, or an appropriate error.
*/
suspend fun getSvrCredentials(): RegistrationNetworkResult<SvrCredentials, GetSvrCredentialsError>
/**
* Updates account attributes on the server.
*
* `PUT /v1/accounts/attributes`
*
* @param attributes The account attributes to set.
* @return Success or an appropriate error.
*/
suspend fun setAccountAttributes(attributes: AccountAttributes): RegistrationNetworkResult<Unit, SetAccountAttributesError>
// 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>
fun <NewSuccessModel> mapSuccess(transform: (SuccessModel) -> NewSuccessModel): RegistrationNetworkResult<NewSuccessModel, FailureModel> {
return when (this) {
is Success<SuccessModel> -> Success(transform(this.data))
is Failure<FailureModel> -> Failure(this.error)
is NetworkError -> NetworkError(this.exception)
is ApplicationError -> ApplicationError(this.exception)
}
}
}
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()
}
sealed class SetAccountAttributesError() {
data class InvalidRequest(val message: String) : SetAccountAttributesError()
data object Unauthorized : SetAccountAttributesError()
}
sealed class GetSvrCredentialsError() {
data object Unauthorized : GetSvrCredentialsError()
data object NoServiceCredentialsAvailable : GetSvrCredentialsError()
}
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,94 @@
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.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import org.signal.core.ui.compose.theme.SignalTheme
/**
* 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 {
SignalTheme(incognitoKeyboardEnabled = false) {
Surface {
RegistrationNavHost(
registrationRepository = repository,
modifier = Modifier.fillMaxSize(),
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,20 @@
/*
* 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.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 Registered(val accountEntropyPool: AccountEntropyPool) : RegistrationFlowEvent
data class MasterKeyRestoredViaRegistrationLock(val masterKey: MasterKey) : RegistrationFlowEvent
data class MasterKeyRestoredViaPostRegisterPinEntry(val masterKey: MasterKey) : RegistrationFlowEvent
}

View File

@@ -0,0 +1,48 @@
/*
* 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.AccountEntropyPool
import org.signal.core.models.MasterKey
@Parcelize
@TypeParceler<MasterKey?, MasterKeyParceler>
@TypeParceler<AccountEntropyPool?, AepParceler>
data class RegistrationFlowState(
val backStack: List<RegistrationRoute> = listOf(RegistrationRoute.Welcome),
val sessionMetadata: NetworkController.SessionMetadata? = null,
val sessionE164: String? = null,
val accountEntropyPool: AccountEntropyPool? = null,
val temporaryMasterKey: 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())
}
}
object AepParceler : Parceler<AccountEntropyPool?> {
override fun create(parcel: Parcel): AccountEntropyPool? {
val aep = parcel.readString()
return aep?.let { AccountEntropyPool(it) }
}
override fun AccountEntropyPool?.write(parcel: Parcel, flags: Int) {
parcel.writeString(this?.value)
}
}

View File

@@ -0,0 +1,427 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
@file:OptIn(ExperimentalPermissionsApi::class)
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.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 com.google.accompanist.permissions.rememberMultiplePermissionsState
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.PinCreationViewModel
import org.signal.registration.screens.pinentry.PinEntryForRegistrationLockViewModel
import org.signal.registration.screens.pinentry.PinEntryForSvrRestoreViewModel
import org.signal.registration.screens.pinentry.PinEntryScreen
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.util.navigateBack
import org.signal.registration.screens.util.navigateTo
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 PinEntryForSvrRestore : RegistrationRoute
@Serializable
data class PinEntryForRegistrationLock(
val timeRemaining: Long,
val svrCredentials: NetworkController.SvrCredentials
) : RegistrationRoute
@Serializable
data class AccountLocked(val timeRemainingMs: Long) : RegistrationRoute
@Serializable
data object PinCreate : RegistrationRoute
@Serializable
data object Restore : RegistrationRoute
@Serializable
data object RestoreViaQr : RegistrationRoute
@Serializable
data object Transfer : RegistrationRoute
@Serializable
data object Profile : RegistrationRoute
@Serializable
data object FullyComplete : RegistrationRoute
}
private const val CAPTCHA_RESULT = "captcha_token"
/**
* Sets up the navigation graph for the registration flow using Navigation 3.
*
* @param registrationRepository The repository for registration data.
* @param registrationViewModel Optional ViewModel for testing. If null, creates one internally.
* @param permissionsState Optional permissions state for testing. If null, creates one internally.
* @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? = null,
permissionsState: MultiplePermissionsState? = null,
modifier: Modifier = Modifier,
onRegistrationComplete: () -> Unit = {}
) {
val viewModel: RegistrationViewModel = registrationViewModel ?: viewModel(
factory = RegistrationViewModel.Factory(registrationRepository)
)
val registrationState by viewModel.state.collectAsStateWithLifecycle()
val permissions: MultiplePermissionsState = permissionsState ?: rememberMultiplePermissionsState(viewModel.getRequiredPermissions())
val entryProvider = entryProvider {
navigationEntries(
registrationRepository = registrationRepository,
registrationViewModel = viewModel,
permissionsState = permissions,
onRegistrationComplete = onRegistrationComplete
)
}
val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>()
)
val entries = rememberDecoratedNavEntries(
backStack = registrationState.backStack,
entryDecorators = decorators,
entryProvider = entryProvider
)
NavDisplay(
entries = entries,
onBack = { viewModel.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))
)
}
)
}
private fun EntryProviderScope<NavKey>.navigationEntries(
registrationRepository: RegistrationRepository,
registrationViewModel: RegistrationViewModel,
permissionsState: MultiplePermissionsState,
onRegistrationComplete: () -> Unit
) {
val parentEventEmitter: (RegistrationFlowEvent) -> Unit = registrationViewModel::onEvent
// --- Welcome Screen
entry<RegistrationRoute.Welcome> {
WelcomeScreen(
onEvent = { event ->
when (event) {
WelcomeScreenEvents.Continue -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(forRestore = false))
WelcomeScreenEvents.DoesNotHaveOldPhone -> parentEventEmitter.navigateTo(RegistrationRoute.Restore)
WelcomeScreenEvents.HasOldPhone -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(forRestore = true))
}
}
)
}
// --- Permissions Screen
entry<RegistrationRoute.Permissions> { key ->
PermissionsScreen(
permissionsState = permissionsState,
onProceed = {
if (key.forRestore) {
parentEventEmitter.navigateTo(RegistrationRoute.RestoreViaQr)
} else {
parentEventEmitter.navigateTo(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)
parentEventEmitter.navigateBack()
}
CaptchaScreenEvents.Cancel -> {
parentEventEmitter.navigateBack()
}
}
}
)
}
// -- 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) }
)
}
// -- SVR Restore PIN Entry Screen (for users with existing backup data)
entry<RegistrationRoute.PinEntryForSvrRestore> {
val viewModel: PinEntryForSvrRestoreViewModel = viewModel(
factory = PinEntryForSvrRestoreViewModel.Factory(
repository = registrationRepository,
parentState = registrationViewModel.state,
parentEventEmitter = registrationViewModel::onEvent
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
PinEntryScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
// -- PIN Creation Screen (for new users creating their first PIN)
entry<RegistrationRoute.PinCreate> {
val viewModel: PinCreationViewModel = viewModel(
factory = PinCreationViewModel.Factory(
repository = registrationRepository,
parentState = registrationViewModel.state,
parentEventEmitter = registrationViewModel::onEvent
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
PinCreationScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
// -- Registration Lock PIN Entry Screen
entry<RegistrationRoute.PinEntryForRegistrationLock> { key ->
val viewModel: PinEntryForRegistrationLockViewModel = viewModel(
factory = PinEntryForRegistrationLockViewModel.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)
parentEventEmitter.navigateTo(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 -> {
parentEventEmitter.navigateBack()
}
RestoreViaQrScreenEvents.UseProxy -> {
// TODO: Navigate to proxy settings
}
RestoreViaQrScreenEvents.DismissError -> {
// TODO: Clear error state
}
}
}
)
}
entry<RegistrationRoute.Transfer> {
// TODO: Implement TransferScreen
}
entry<RegistrationRoute.Profile> {
// TODO: Implement ProfileScreen
}
entry<RegistrationRoute.FullyComplete> {
LaunchedEffect(Unit) {
onRegistrationComplete()
}
}
}

View File

@@ -0,0 +1,208 @@
/*
* 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.MasterKey
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 getSvrCredentials(): RegistrationNetworkResult<SvrCredentials, NetworkController.GetSvrCredentialsError> = withContext(Dispatchers.IO) {
networkController.getSvrCredentials()
}
suspend fun restoreMasterKeyFromSvr(
svr2Credentials: SvrCredentials,
pin: String,
isAlphanumeric: Boolean,
forRegistrationLock: Boolean
): RegistrationNetworkResult<MasterKeyResponse, RestoreMasterKeyError> = withContext(Dispatchers.IO) {
networkController.restoreMasterKeyFromSvr(
svr2Credentials = svr2Credentials,
pin = pin
).also {
if (it is RegistrationNetworkResult.Success) {
// TODO consider whether we should save this now, or whether we should keep in app state and then hand it back to the library user at the end of the flow
storageController.saveValidatedPinAndTemporaryMasterKey(pin, isAlphanumeric, it.data.masterKey, forRegistrationLock)
}
}
}
/**
* 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<Pair<RegisterAccountResponse, KeyMaterial>, 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(
storage = true, // True initially -- can turn off later if users opt-out
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.mapSuccess { it to keyMaterial }
}
suspend fun setNewlyCreatedPin(
pin: String,
isAlphanumeric: Boolean,
masterKey: MasterKey
): RegistrationNetworkResult<Unit, NetworkController.BackupMasterKeyError> = withContext(Dispatchers.IO) {
val result = networkController.setPinAndMasterKeyOnSvr(pin, masterKey)
if (result is RegistrationNetworkResult.Success) {
storageController.saveNewlyCreatedPin(pin, isAlphanumeric)
}
result
}
}

View File

@@ -0,0 +1,98 @@
/*
* 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.Registered -> state.copy(accountEntropyPool = event.accountEntropyPool)
is RegistrationFlowEvent.MasterKeyRestoredViaRegistrationLock -> state.copy(temporaryMasterKey = event.masterKey, registrationLockProof = event.masterKey.deriveRegistrationLock())
is RegistrationFlowEvent.MasterKeyRestoredViaPostRegisterPinEntry -> state.copy(temporaryMasterKey = event.masterKey)
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,105 @@
/*
* 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.MasterKey
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?
/**
* Saves a validated PIN, temporary master key, and registration lock status.
*
* Called after successfully verifying a PIN against SVR, either during
* registration lock unlock or SVR restore flows.
*
* It's a "temporary master key" because at the end of the day, what we actually want is a master key derived from the AEP.
* We may need this master key to perform the initial storage service restore, but after that's done, it will be discarded after generating a new AEP.
*
* @param pin The validated PIN that was successfully verified.
* @param registrationLockEnabled Whether registration lock should be enabled for this account.
*/
suspend fun saveValidatedPinAndTemporaryMasterKey(pin: String, isAlphanumeric: Boolean, masterKey: MasterKey, registrationLockEnabled: Boolean)
/**
* Saves a newly-created PIN for the account.
*/
suspend fun saveNewlyCreatedPin(pin: String, isAlphanumeric: Boolean)
/**
* 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,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,358 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.phonenumber
import androidx.annotation.VisibleForTesting
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 = _state
.combine(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)
}
}
@VisibleForTesting
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))
}
}
}
@VisibleForTesting
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.isAlphanumericKeyboard) KeyboardType.Password else KeyboardType.NumberPassword,
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.isAlphanumericKeyboard) "Switch to numeric" else "Switch to alphanumberic"
)
}
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(
isAlphanumericKeyboard = 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,15 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.pincreation
import org.signal.core.models.AccountEntropyPool
data class PinCreationState(
val isAlphanumericKeyboard: Boolean = false,
val inputLabel: String? = null,
val isConfirmEnabled: Boolean = false,
val accountEntropyPool: AccountEntropyPool? = null
)

View File

@@ -0,0 +1,140 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.pincreation
import androidx.annotation.VisibleForTesting
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.navigateTo
/**
* ViewModel for the PIN creation screen.
*
* Shown post-registration to allow the user to create a PIN.
*/
class PinCreationViewModel(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModel() {
companion object {
private val TAG = Log.tag(PinCreationViewModel::class)
}
private val _state = MutableStateFlow(
PinCreationState(
inputLabel = "PIN must be at least 4 digits"
)
)
val state: StateFlow<PinCreationState> = _state
.combine(parentState) { state, parentState -> applyParentState(state, parentState) }
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.Eagerly, PinCreationState(inputLabel = "PIN must be at least 4 digits"))
fun onEvent(event: PinCreationScreenEvents) {
viewModelScope.launch {
applyEvent(state.value, event)
}
}
@VisibleForTesting
suspend fun applyEvent(state: PinCreationState, event: PinCreationScreenEvents) {
when (event) {
is PinCreationScreenEvents.PinSubmitted -> {
_state.value = state.copy(isConfirmEnabled = false)
val result = applyPinSubmitted(state, event.pin)
_state.value = result
}
is PinCreationScreenEvents.ToggleKeyboard -> {
val newValue = !state.isAlphanumericKeyboard
_state.value = state.copy(
isAlphanumericKeyboard = newValue,
inputLabel = if (newValue) "PIN must be at least 4 digits" else "PIN must be at least 4 characters"
)
}
is PinCreationScreenEvents.LearnMore -> {
TODO("Show learn more dialog or navigate to help screen")
}
}
}
@VisibleForTesting
fun applyParentState(state: PinCreationState, parentState: RegistrationFlowState): PinCreationState {
return state.copy(accountEntropyPool = parentState.accountEntropyPool)
}
private suspend fun applyPinSubmitted(state: PinCreationState, pin: String): PinCreationState {
Log.d(TAG, "[PinSubmitted] Creating PIN and backing up master key to SVR...")
if (state.accountEntropyPool == null) {
Log.w(TAG, "[PinSubmitted] Missing account entropy pool. This should not be possible. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
return state
}
val masterKey = state.accountEntropyPool.deriveMasterKey()
return when (val result = repository.setNewlyCreatedPin(pin, state.isAlphanumericKeyboard, masterKey)) {
is NetworkController.RegistrationNetworkResult.Success -> {
Log.i(TAG, "[PinSubmitted] Successfully backed up master key to SVR.")
// TODO profile creation
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
state
}
is NetworkController.RegistrationNetworkResult.Failure -> {
when (result.error) {
is NetworkController.BackupMasterKeyError.EnclaveNotFound -> {
Log.w(TAG, "[PinSubmitted] SVR enclave not found.")
TODO("Report to UI and indicate to library user that pin could not be created")
}
is NetworkController.BackupMasterKeyError.NotRegistered -> {
Log.w(TAG, "[PinSubmitted] Account not registered. This should not happen. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
state
}
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "[PinSubmitted] Network error when backing up master key.", result.exception)
TODO("Report to UI and indicate to library user that pin could not be created")
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "[PinSubmitted] Application error when backing up master key.", result.exception)
TODO("Report to UI and indicate to library user that pin could not be created")
}
}
}
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 PinCreationViewModel(
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.pinentry
import androidx.annotation.VisibleForTesting
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.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 PinEntryForRegistrationLockViewModel(
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(PinEntryForRegistrationLockViewModel::class)
}
private val _state = MutableStateFlow(
PinEntryState(
mode = PinEntryState.Mode.RegistrationLock
)
)
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)
}
}
@VisibleForTesting
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, state.isAlphanumericKeyboard, forRegistrationLock = true)
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.MasterKeyRestoredViaRegistrationLock(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!")
val (response, keyMaterial) = registerResult.data
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
// TODO storage service restore + profile screen
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
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 PinEntryForRegistrationLockViewModel(
repository,
parentState,
parentEventEmitter,
timeRemaining,
svrCredentials
) as T
}
}
}

View File

@@ -0,0 +1,165 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.pinentry
import androidx.annotation.VisibleForTesting
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.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.navigateTo
/**
* ViewModel for the SVR restore PIN entry screen.
*
* This screen is shown after successful registration when the account has `storageCapable = true`, meaning the user has previously backed up data to SVR.
* The user must enter their PIN to restore their master key and subsequently restore their data.
*/
class PinEntryForSvrRestoreViewModel(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModel() {
companion object {
private val TAG = Log.tag(PinEntryForSvrRestoreViewModel::class)
}
private val _state = MutableStateFlow(
PinEntryState(
mode = PinEntryState.Mode.SvrRestore
)
)
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)
}
}
@VisibleForTesting
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 svrCredentials = when (val result = repository.getSvrCredentials()) {
is NetworkController.RegistrationNetworkResult.Success<NetworkController.SvrCredentials> -> {
result.data
}
is NetworkController.RegistrationNetworkResult.Failure<NetworkController.GetSvrCredentialsError> -> {
when (result.error) {
NetworkController.GetSvrCredentialsError.NoServiceCredentialsAvailable -> {
Log.w(TAG, "[PinEntered] No service credentials available when restoring from SVR. This should not happen. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
return state
}
NetworkController.GetSvrCredentialsError.Unauthorized -> {
Log.w(TAG, "[PinEntered] Service does not think we're authorized. This should not happen. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
return state
}
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
return state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
return state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
}
}
return when (val result = repository.restoreMasterKeyFromSvr(svrCredentials, event.pin, state.isAlphanumericKeyboard, forRegistrationLock = false)) {
is NetworkController.RegistrationNetworkResult.Success -> {
Log.i(TAG, "[PinEntered] Successfully restored master key from SVR.")
parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredViaPostRegisterPinEntry(result.data.masterKey))
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
state
}
is NetworkController.RegistrationNetworkResult.Failure -> {
when (result.error) {
is NetworkController.RestoreMasterKeyError.WrongPin -> {
Log.w(TAG, "[PinEntered] Wrong PIN. Tries remaining: ${result.error.triesRemaining}")
state.copy(triesRemaining = result.error.triesRemaining)
}
is NetworkController.RestoreMasterKeyError.NoDataFound -> {
Log.w(TAG, "[PinEntered] No SVR data found. Proceeding without restore.")
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.SvrDataMissing)
}
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "[PinEntered] Network error when restoring master key.", result.exception)
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "[PinEntered] Application error when restoring master key.", result.exception)
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
}
}
}
private fun handleSkip() {
TODO("Handle skip")
}
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 PinEntryForSvrRestoreViewModel(
repository,
parentState,
parentEventEmitter
) as T
}
}
}

View File

@@ -0,0 +1,222 @@
/*
* 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))
val titleString = remember {
return@remember when (state.mode) {
PinEntryState.Mode.RegistrationLock -> "Registration Lock"
PinEntryState.Mode.SvrRestore -> "Enter your PIN"
}
}
Text(
text = titleString,
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.isAlphanumericKeyboard) KeyboardType.Password else KeyboardType.NumberPassword,
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(
mode = PinEntryState.Mode.RegistrationLock,
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(isAlphanumericKeyboard = !state.isAlphanumericKeyboard)
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,29 @@
/*
* 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 isAlphanumericKeyboard: Boolean = false,
val loading: Boolean = false,
val triesRemaining: Int? = null,
val mode: Mode = Mode.SvrRestore,
val oneTimeEvent: OneTimeEvent? = null
) {
enum class Mode {
RegistrationLock,
SvrRestore
}
sealed interface OneTimeEvent {
data object NetworkError : OneTimeEvent
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
data object SvrDataMissing : OneTimeEvent
data object UnknownError : OneTimeEvent
}
}

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,23 @@
/*
* 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
/**
* Convenience function to emit a navigation event to a parentEmitter.
*/
fun ((RegistrationFlowEvent) -> Unit).navigateTo(route: RegistrationRoute) {
this(RegistrationFlowEvent.NavigateToScreen(route))
}
/**
* Convenience function to emit a navigate-back event to a parentEmitter.
*/
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,264 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.verificationcode
import androidx.annotation.VisibleForTesting
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))
}
}
@VisibleForTesting
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)
}
}
@VisibleForTesting
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 -> {
val (response, keyMaterial) = registerResult.data
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
if (response.storageCapable) {
parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore)
} else {
parentEventEmitter.navigateTo(RegistrationRoute.PinCreate)
}
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.PinEntryForRegistrationLock(
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"
}