mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-20 00:29:11 +01:00
Re-organize gradle modules.
This commit is contained in:
committed by
jeffrey-signal
parent
f4863efb2e
commit
e162eb27c7
19
feature/registration/src/main/AndroidManifest.xml
Normal file
19
feature/registration/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".RegistrationActivity"
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration
|
||||
|
||||
/**
|
||||
* Injection point for dependencies needed by this module.
|
||||
*/
|
||||
class RegistrationDependencies(
|
||||
val networkController: NetworkController,
|
||||
val storageController: StorageController
|
||||
) {
|
||||
companion object {
|
||||
lateinit var dependencies: RegistrationDependencies
|
||||
|
||||
fun provide(registrationDependencies: RegistrationDependencies) {
|
||||
dependencies = registrationDependencies
|
||||
}
|
||||
|
||||
fun get(): RegistrationDependencies = dependencies
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.accountlocked
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
|
||||
/**
|
||||
* Screen shown when the user's account is locked due to too many failed PIN attempts
|
||||
* and there's no SVR data available to recover.
|
||||
*/
|
||||
@Composable
|
||||
fun AccountLockedScreen(
|
||||
state: AccountLockedState,
|
||||
onEvent: (AccountLockedScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(49.dp))
|
||||
|
||||
Text(
|
||||
text = "Account locked",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Your account has been locked to protect your privacy and security. After ${state.daysRemaining} days of inactivity in your account you'll be able to re-register this phone number without needing your PIN. All content will be deleted.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Button(
|
||||
onClick = { onEvent(AccountLockedScreenEvents.Next) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Next")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onEvent(AccountLockedScreenEvents.LearnMore) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Learn More")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun AccountLockedScreenPreview() {
|
||||
Previews.Preview {
|
||||
AccountLockedScreen(
|
||||
state = AccountLockedState(daysRemaining = 7),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.accountlocked
|
||||
|
||||
sealed class AccountLockedScreenEvents {
|
||||
data object Next : AccountLockedScreenEvents()
|
||||
data object LearnMore : AccountLockedScreenEvents()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.accountlocked
|
||||
|
||||
data class AccountLockedState(
|
||||
val daysRemaining: Int = 10
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.captcha
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
|
||||
/**
|
||||
* Screen to display a captcha verification using a WebView.
|
||||
* The WebView loads the Signal captcha URL and intercepts the callback
|
||||
* when the user completes the captcha.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun CaptchaScreen(
|
||||
state: CaptchaState,
|
||||
onEvent: (CaptchaScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var loadState by remember { mutableStateOf(state.loadState) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
settings.javaScriptEnabled = true
|
||||
clearCache(true)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
if (url.startsWith(state.captchaScheme)) {
|
||||
val token = url.substring(state.captchaScheme.length)
|
||||
onEvent(CaptchaScreenEvents.CaptchaCompleted(token))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
loadState = CaptchaLoadState.Loaded
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String?
|
||||
) {
|
||||
super.onReceivedError(view, errorCode, description, failingUrl)
|
||||
loadState = CaptchaLoadState.Error
|
||||
}
|
||||
}
|
||||
|
||||
loadUrl(state.captchaUrl)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
when (loadState) {
|
||||
CaptchaLoadState.Loaded -> Unit
|
||||
CaptchaLoadState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
}
|
||||
CaptchaLoadState.Error -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Failed to load captcha",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { onEvent(CaptchaScreenEvents.Cancel) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CaptchaScreenLoadingPreview() {
|
||||
Previews.Preview {
|
||||
CaptchaScreen(
|
||||
state = CaptchaState(
|
||||
captchaUrl = "https://example.com/captcha",
|
||||
loadState = CaptchaLoadState.Loading
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CaptchaScreenErrorPreview() {
|
||||
Previews.Preview {
|
||||
CaptchaScreen(
|
||||
state = CaptchaState(
|
||||
captchaUrl = "https://example.com/captcha",
|
||||
loadState = CaptchaLoadState.Error
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.captcha
|
||||
|
||||
sealed class CaptchaScreenEvents {
|
||||
data class CaptchaCompleted(val token: String) : CaptchaScreenEvents()
|
||||
data object Cancel : CaptchaScreenEvents()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.captcha
|
||||
|
||||
sealed class CaptchaLoadState {
|
||||
data object Loading : CaptchaLoadState()
|
||||
data object Loaded : CaptchaLoadState()
|
||||
data object Error : CaptchaLoadState()
|
||||
}
|
||||
|
||||
data class CaptchaState(
|
||||
val captchaUrl: String,
|
||||
val captchaScheme: String = "signalcaptcha://",
|
||||
val loadState: CaptchaLoadState = CaptchaLoadState.Loading
|
||||
)
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||
|
||||
package org.signal.registration.screens.permissions
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.registration.screens.util.MockMultiplePermissionsState
|
||||
import org.signal.registration.screens.util.MockPermissionsState
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Permissions screen for the registration flow.
|
||||
* Requests necessary runtime permissions before continuing.
|
||||
*
|
||||
* @param permissionsState The permissions state managed at the activity level.
|
||||
* @param onEvent Callback for screen events.
|
||||
* @param modifier Modifier to be applied to the root container.
|
||||
*/
|
||||
@Composable
|
||||
fun PermissionsScreen(
|
||||
permissionsState: MultiplePermissionsState,
|
||||
onProceed: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Permissions",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Signal needs the following permissions to provide the best experience:",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
PermissionsList(permissions = permissionsState.permissions.map { it.permission })
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
permissionsState.launchMultiplePermissionRequest()
|
||||
onProceed()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.PERMISSIONS_NEXT_BUTTON)
|
||||
) {
|
||||
Text("Next")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onProceed() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.PERMISSIONS_NOT_NOW_BUTTON)
|
||||
) {
|
||||
Text("Not now")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a list of permission explanations.
|
||||
*/
|
||||
@Composable
|
||||
private fun PermissionsList(
|
||||
permissions: List<String>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val permissionDescriptions = getPermissionDescriptions(permissions)
|
||||
permissionDescriptions.forEach { description ->
|
||||
PermissionItem(description = description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual permission item with description.
|
||||
*/
|
||||
@Composable
|
||||
private fun PermissionItem(
|
||||
description: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = "• $description",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts permission names to user-friendly descriptions.
|
||||
*/
|
||||
private fun getPermissionDescriptions(permissions: List<String>): List<String> {
|
||||
return buildList {
|
||||
if (permissions.any { it == Manifest.permission.POST_NOTIFICATIONS }) {
|
||||
add("Notifications - Stay updated with new messages")
|
||||
}
|
||||
if (permissions.any { it == Manifest.permission.READ_CONTACTS || it == Manifest.permission.WRITE_CONTACTS }) {
|
||||
add("Contacts - Find friends who use Signal")
|
||||
}
|
||||
if (permissions.any {
|
||||
it == Manifest.permission.READ_EXTERNAL_STORAGE ||
|
||||
it == Manifest.permission.WRITE_EXTERNAL_STORAGE ||
|
||||
it == Manifest.permission.READ_MEDIA_IMAGES ||
|
||||
it == Manifest.permission.READ_MEDIA_VIDEO ||
|
||||
it == Manifest.permission.READ_MEDIA_AUDIO
|
||||
}
|
||||
) {
|
||||
add("Photos and media - Share images and videos")
|
||||
}
|
||||
if (permissions.any { it == Manifest.permission.READ_PHONE_STATE || it == Manifest.permission.READ_PHONE_NUMBERS }) {
|
||||
add("Phone - Verify your phone number")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PermissionsScreenPreview() {
|
||||
Previews.Preview {
|
||||
PermissionsScreen(
|
||||
permissionsState = MockMultiplePermissionsState(
|
||||
permissions = listOf(
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.WRITE_CONTACTS,
|
||||
Manifest.permission.READ_PHONE_STATE,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
).map { MockPermissionsState(it) }
|
||||
),
|
||||
onProceed = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.phonenumber
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Phone number entry screen for the registration flow.
|
||||
* Allows users to select their country and enter their phone number.
|
||||
*/
|
||||
@Composable
|
||||
fun PhoneNumberScreen(
|
||||
state: PhoneNumberEntryState,
|
||||
onEvent: (PhoneNumberEntryScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var simpleErrorMessage: String? by remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(state.oneTimeEvent) {
|
||||
onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent)
|
||||
when (state.oneTimeEvent) {
|
||||
OneTimeEvent.NetworkError -> simpleErrorMessage = "Network error"
|
||||
is OneTimeEvent.RateLimited -> simpleErrorMessage = "Rate limited"
|
||||
OneTimeEvent.UnknownError -> simpleErrorMessage = "Unknown error"
|
||||
OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> simpleErrorMessage = "Could not request code with selected transport"
|
||||
OneTimeEvent.ThirdPartyError -> simpleErrorMessage = "Third party error"
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
simpleErrorMessage?.let { message ->
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
dismiss = "Ok",
|
||||
onDismiss = { simpleErrorMessage = null }
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
ScreenContent(state, onEvent)
|
||||
|
||||
if (state.showFullScreenSpinner) {
|
||||
Dialogs.IndeterminateProgressDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenContent(state: PhoneNumberEntryState, onEvent: (PhoneNumberEntryScreenEvents) -> Unit) {
|
||||
// TODO: These should come from state once country picker is implemented
|
||||
var selectedCountry by remember { mutableStateOf("United States") }
|
||||
var selectedCountryEmoji by remember { mutableStateOf("🇺🇸") }
|
||||
|
||||
// Track the phone number text field value with cursor position
|
||||
var phoneNumberTextFieldValue by remember { mutableStateOf(TextFieldValue(state.formattedNumber)) }
|
||||
|
||||
// Update the text field value when state.formattedNumber changes, preserving cursor position
|
||||
LaunchedEffect(state.formattedNumber) {
|
||||
if (phoneNumberTextFieldValue.text != state.formattedNumber) {
|
||||
// Calculate cursor position: count digits before cursor in old text,
|
||||
// then find position with same digit count in new text
|
||||
val oldText = phoneNumberTextFieldValue.text
|
||||
val oldCursorPos = phoneNumberTextFieldValue.selection.end
|
||||
val digitsBeforeCursor = oldText.take(oldCursorPos).count { it.isDigit() }
|
||||
|
||||
val newText = state.formattedNumber
|
||||
var digitCount = 0
|
||||
var newCursorPos = newText.length
|
||||
for (i in newText.indices) {
|
||||
if (newText[i].isDigit()) {
|
||||
digitCount++
|
||||
}
|
||||
if (digitCount >= digitsBeforeCursor) {
|
||||
newCursorPos = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
phoneNumberTextFieldValue = TextFieldValue(
|
||||
text = newText,
|
||||
selection = TextRange(newCursorPos)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
// Title
|
||||
Text(
|
||||
text = "Phone number",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Subtitle
|
||||
Text(
|
||||
text = "You will receive a verification code",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
|
||||
// Country Picker Button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
onEvent(PhoneNumberEntryScreenEvents.CountryPicker)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.testTag(TestTags.PHONE_NUMBER_COUNTRY_PICKER)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = selectedCountryEmoji,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = selectedCountry,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(android.R.drawable.arrow_down_float),
|
||||
contentDescription = "Select country",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Phone number input fields
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
// Country code field
|
||||
OutlinedTextField(
|
||||
value = state.countryCode,
|
||||
onValueChange = { onEvent(PhoneNumberEntryScreenEvents.CountryCodeChanged(it)) },
|
||||
modifier = Modifier
|
||||
.width(76.dp)
|
||||
.testTag(TestTags.PHONE_NUMBER_COUNTRY_CODE_FIELD),
|
||||
leadingIcon = {
|
||||
Text(
|
||||
text = "+",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Phone number field
|
||||
OutlinedTextField(
|
||||
value = phoneNumberTextFieldValue,
|
||||
onValueChange = { newValue ->
|
||||
phoneNumberTextFieldValue = newValue
|
||||
onEvent(PhoneNumberEntryScreenEvents.PhoneNumberChanged(newValue.text))
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTag(TestTags.PHONE_NUMBER_PHONE_FIELD),
|
||||
placeholder = {
|
||||
Text("Phone number")
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Phone,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
}
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Next button
|
||||
Button(
|
||||
onClick = {
|
||||
onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.testTag(TestTags.PHONE_NUMBER_NEXT_BUTTON),
|
||||
enabled = state.countryCode.isNotEmpty() && state.nationalNumber.isNotEmpty()
|
||||
) {
|
||||
Text("Next")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PhoneNumberScreenPreview() {
|
||||
Previews.Preview {
|
||||
PhoneNumberScreen(
|
||||
state = PhoneNumberEntryState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PhoneNumberScreenSpinnerPreview() {
|
||||
Previews.Preview {
|
||||
PhoneNumberScreen(
|
||||
state = PhoneNumberEntryState(showFullScreenSpinner = true),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.phonenumber
|
||||
|
||||
sealed interface PhoneNumberEntryScreenEvents {
|
||||
data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents
|
||||
data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents
|
||||
data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents
|
||||
data object CountryPicker : PhoneNumberEntryScreenEvents
|
||||
data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents
|
||||
data object ConsumeOneTimeEvent : PhoneNumberEntryScreenEvents
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.phonenumber
|
||||
|
||||
import org.signal.registration.NetworkController.SessionMetadata
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class PhoneNumberEntryState(
|
||||
val regionCode: String = "US",
|
||||
val countryCode: String = "1",
|
||||
val nationalNumber: String = "",
|
||||
val formattedNumber: String = "",
|
||||
val sessionMetadata: SessionMetadata? = null,
|
||||
val showFullScreenSpinner: Boolean = false,
|
||||
val oneTimeEvent: OneTimeEvent? = null
|
||||
) {
|
||||
sealed interface OneTimeEvent {
|
||||
data object NetworkError : OneTimeEvent
|
||||
data object UnknownError : OneTimeEvent
|
||||
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
|
||||
data object ThirdPartyError : OneTimeEvent
|
||||
data object CouldNotRequestCodeWithSelectedTransport : OneTimeEvent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.pincreation
|
||||
|
||||
sealed class PinCreationScreenEvents {
|
||||
data class PinSubmitted(val pin: String) : PinCreationScreenEvents()
|
||||
data object ToggleKeyboard : PinCreationScreenEvents()
|
||||
data object LearnMore : PinCreationScreenEvents()
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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!")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.pinentry
|
||||
|
||||
sealed class PinEntryScreenEvents {
|
||||
data class PinEntered(val pin: String) : PinEntryScreenEvents()
|
||||
data object ToggleKeyboard : PinEntryScreenEvents()
|
||||
data object NeedHelp : PinEntryScreenEvents()
|
||||
data object Skip : PinEntryScreenEvents()
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.restore
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.QrCode
|
||||
import org.signal.core.ui.compose.QrCodeData
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
|
||||
/**
|
||||
* Screen to display QR code for restoring from an old device.
|
||||
* The old device scans this QR code to initiate the transfer.
|
||||
*/
|
||||
@Composable
|
||||
fun RestoreViaQrScreen(
|
||||
state: RestoreViaQrState,
|
||||
onEvent: (RestoreViaQrScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Scan from old device",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// QR Code display area
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = state.qrState,
|
||||
contentKey = { it::class },
|
||||
label = "qr-code-state"
|
||||
) { qrState ->
|
||||
when (qrState) {
|
||||
is QrState.Loaded -> {
|
||||
QrCode(
|
||||
data = qrState.qrCodeData,
|
||||
foregroundColor = Color(0xFF2449C0),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
QrState.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
QrState.Scanned -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "QR code scanned",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QrState.Failed -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Failed to generate QR code",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Instructions
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 320.dp)
|
||||
) {
|
||||
InstructionRow(
|
||||
icon = SignalIcons.Phone.painter,
|
||||
instruction = "On your old phone, open Signal"
|
||||
)
|
||||
|
||||
InstructionRow(
|
||||
icon = SignalIcons.Camera.painter,
|
||||
instruction = "Go to Settings > Transfer account"
|
||||
)
|
||||
|
||||
InstructionRow(
|
||||
icon = SignalIcons.QrCode.painter,
|
||||
instruction = "Scan this QR code"
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(
|
||||
onClick = { onEvent(RestoreViaQrScreenEvents.Cancel) }
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Loading dialog
|
||||
if (state.isRegistering) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { },
|
||||
confirmButton = { },
|
||||
text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text("Registering...")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Error dialog
|
||||
if (state.showRegistrationError) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { onEvent(RestoreViaQrScreenEvents.DismissError) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onEvent(RestoreViaQrScreenEvents.DismissError) }) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Text(state.errorMessage ?: "An error occurred during registration")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstructionRow(
|
||||
icon: Painter,
|
||||
instruction: String
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Text(
|
||||
text = instruction,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenLoadingPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrState(qrState = QrState.Loading),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenLoadedPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrState(
|
||||
qrState = QrState.Loaded(QrCodeData.forData("sgnl://rereg?uuid=test&pub_key=test", false))
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenFailedPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrState(qrState = QrState.Failed),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenRegisteringPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrState(
|
||||
qrState = QrState.Scanned,
|
||||
isRegistering = true
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.restore
|
||||
|
||||
sealed class RestoreViaQrScreenEvents {
|
||||
data object RetryQrCode : RestoreViaQrScreenEvents()
|
||||
data object Cancel : RestoreViaQrScreenEvents()
|
||||
data object UseProxy : RestoreViaQrScreenEvents()
|
||||
data object DismissError : RestoreViaQrScreenEvents()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.restore
|
||||
|
||||
import org.signal.core.ui.compose.QrCodeData
|
||||
|
||||
sealed class QrState {
|
||||
data object Loading : QrState()
|
||||
data class Loaded(val qrCodeData: QrCodeData) : QrState()
|
||||
data object Scanned : QrState()
|
||||
data object Failed : QrState()
|
||||
}
|
||||
|
||||
data class RestoreViaQrState(
|
||||
val qrState: QrState = QrState.Loading,
|
||||
val isRegistering: Boolean = false,
|
||||
val showRegistrationError: Boolean = false,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||
|
||||
package org.signal.registration.screens.util
|
||||
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
|
||||
/**
|
||||
* Helpful mock for [MultiplePermissionsState] to make previews easier.
|
||||
*/
|
||||
class MockMultiplePermissionsState(
|
||||
override val allPermissionsGranted: Boolean = false,
|
||||
override val permissions: List<PermissionState> = emptyList(),
|
||||
override val revokedPermissions: List<PermissionState> = emptyList(),
|
||||
override val shouldShowRationale: Boolean = false
|
||||
) : MultiplePermissionsState {
|
||||
override fun launchMultiplePermissionRequest() = Unit
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||
|
||||
package org.signal.registration.screens.util
|
||||
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.PermissionStatus
|
||||
|
||||
/**
|
||||
* Helpful mock for [PermissionsState] to make previews easier.
|
||||
*/
|
||||
class MockPermissionsState(
|
||||
override val permission: String,
|
||||
override val status: PermissionStatus = PermissionStatus.Granted
|
||||
) : PermissionState {
|
||||
override fun launchPermissionRequest() = Unit
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.verificationcode
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Verification code entry screen for the registration flow.
|
||||
* Displays a 6-digit code input in XXX-XXX format.
|
||||
*/
|
||||
@Composable
|
||||
fun VerificationCodeScreen(
|
||||
state: VerificationCodeState,
|
||||
onEvent: (VerificationCodeScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var digits by remember { mutableStateOf(List(6) { "" }) }
|
||||
val focusRequesters = remember { List(6) { FocusRequester() } }
|
||||
|
||||
// Auto-submit when all digits are entered
|
||||
LaunchedEffect(digits) {
|
||||
if (digits.all { it.isNotEmpty() }) {
|
||||
val code = digits.joinToString("")
|
||||
onEvent(VerificationCodeScreenEvents.CodeEntered(code))
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.oneTimeEvent) {
|
||||
onEvent(VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent)
|
||||
|
||||
when (state.oneTimeEvent) {
|
||||
VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> { }
|
||||
VerificationCodeState.OneTimeEvent.IncorrectVerificationCode -> { }
|
||||
VerificationCodeState.OneTimeEvent.NetworkError -> { }
|
||||
is VerificationCodeState.OneTimeEvent.RateLimited -> { }
|
||||
VerificationCodeState.OneTimeEvent.ThirdPartyError -> { }
|
||||
VerificationCodeState.OneTimeEvent.UnknownError -> { }
|
||||
VerificationCodeState.OneTimeEvent.RegistrationError -> { }
|
||||
null -> { }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter verification code",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter the code we sent to ${state.e164}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Code input fields - XXX-XXX format
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.VERIFICATION_CODE_INPUT),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// First three digits
|
||||
for (i in 0..2) {
|
||||
DigitField(
|
||||
value = digits[i],
|
||||
onValueChange = { newValue ->
|
||||
if (newValue.length <= 1 && (newValue.isEmpty() || newValue.all { it.isDigit() })) {
|
||||
digits = digits.toMutableList().apply { this[i] = newValue }
|
||||
if (newValue.isNotEmpty() && i < 5) {
|
||||
focusRequesters[i + 1].requestFocus()
|
||||
}
|
||||
}
|
||||
},
|
||||
focusRequester = focusRequesters[i],
|
||||
testTag = when (i) {
|
||||
0 -> TestTags.VERIFICATION_CODE_DIGIT_0
|
||||
1 -> TestTags.VERIFICATION_CODE_DIGIT_1
|
||||
else -> TestTags.VERIFICATION_CODE_DIGIT_2
|
||||
}
|
||||
)
|
||||
if (i < 2) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Separator
|
||||
Text(
|
||||
text = "-",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
|
||||
// Last three digits
|
||||
for (i in 3..5) {
|
||||
if (i > 3) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
DigitField(
|
||||
value = digits[i],
|
||||
onValueChange = { newValue ->
|
||||
if (newValue.length <= 1 && (newValue.isEmpty() || newValue.all { it.isDigit() })) {
|
||||
digits = digits.toMutableList().apply { this[i] = newValue }
|
||||
if (newValue.isNotEmpty() && i < 5) {
|
||||
focusRequesters[i + 1].requestFocus()
|
||||
}
|
||||
}
|
||||
},
|
||||
focusRequester = focusRequesters[i],
|
||||
testTag = when (i) {
|
||||
3 -> TestTags.VERIFICATION_CODE_DIGIT_3
|
||||
4 -> TestTags.VERIFICATION_CODE_DIGIT_4
|
||||
else -> TestTags.VERIFICATION_CODE_DIGIT_5
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = { onEvent(VerificationCodeScreenEvents.WrongNumber) },
|
||||
modifier = Modifier.testTag(TestTags.VERIFICATION_CODE_WRONG_NUMBER_BUTTON)
|
||||
) {
|
||||
Text("Wrong number?")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(
|
||||
onClick = { onEvent(VerificationCodeScreenEvents.ResendSms) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.VERIFICATION_CODE_RESEND_SMS_BUTTON)
|
||||
) {
|
||||
Text("Resend SMS")
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { onEvent(VerificationCodeScreenEvents.CallMe) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.VERIFICATION_CODE_CALL_ME_BUTTON)
|
||||
) {
|
||||
Text("Call me instead")
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-focus first field on initial composition
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequesters[0].requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual digit input field
|
||||
*/
|
||||
@Composable
|
||||
private fun DigitField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
testTag: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier
|
||||
.width(44.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.testTag(testTag),
|
||||
textStyle = MaterialTheme.typography.titleLarge.copy(textAlign = TextAlign.Center),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun VerificationCodeScreenPreview() {
|
||||
Previews.Preview {
|
||||
VerificationCodeScreen(
|
||||
state = VerificationCodeState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.verificationcode
|
||||
|
||||
sealed class VerificationCodeScreenEvents {
|
||||
data class CodeEntered(val code: String) : VerificationCodeScreenEvents()
|
||||
data object WrongNumber : VerificationCodeScreenEvents()
|
||||
data object ResendSms : VerificationCodeScreenEvents()
|
||||
data object CallMe : VerificationCodeScreenEvents()
|
||||
data object HavingTrouble : VerificationCodeScreenEvents()
|
||||
data object ConsumeInnerOneTimeEvent : VerificationCodeScreenEvents()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.verificationcode
|
||||
|
||||
import org.signal.registration.NetworkController.SessionMetadata
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class VerificationCodeState(
|
||||
val sessionMetadata: SessionMetadata? = null,
|
||||
val e164: String = "",
|
||||
val oneTimeEvent: OneTimeEvent? = null
|
||||
) {
|
||||
sealed interface OneTimeEvent {
|
||||
data object NetworkError : OneTimeEvent
|
||||
data object UnknownError : OneTimeEvent
|
||||
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
|
||||
data object ThirdPartyError : OneTimeEvent
|
||||
data object CouldNotRequestCodeWithSelectedTransport : OneTimeEvent
|
||||
data object IncorrectVerificationCode : OneTimeEvent
|
||||
data object RegistrationError : OneTimeEvent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.signal.registration.screens.welcome
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.dismissWithAnimation
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Welcome screen for the registration flow.
|
||||
* This is the initial screen users see when starting the registration process.
|
||||
*/
|
||||
@Composable
|
||||
fun WelcomeScreen(
|
||||
onEvent: (WelcomeScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Welcome to Signal",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Button(
|
||||
onClick = { onEvent(WelcomeScreenEvents.Continue) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.WELCOME_GET_STARTED_BUTTON)
|
||||
) {
|
||||
Text("Get Started")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { showBottomSheet = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON)
|
||||
) {
|
||||
Text("Restore or transfer")
|
||||
}
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
RestoreOrTransferBottomSheet(
|
||||
onEvent = {
|
||||
showBottomSheet = false
|
||||
onEvent(it)
|
||||
},
|
||||
onDismiss = { showBottomSheet = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom sheet for restore or transfer options.
|
||||
*/
|
||||
@Composable
|
||||
private fun RestoreOrTransferBottomSheet(
|
||||
onEvent: (WelcomeScreenEvents) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BottomSheets.BottomSheet(
|
||||
onDismissRequest = { sheetState.dismissWithAnimation(scope, onComplete = onDismiss) },
|
||||
sheetState = sheetState
|
||||
) {
|
||||
RestoreOrTransferBottomSheetContent(
|
||||
sheetState = sheetState,
|
||||
onEvent = onEvent,
|
||||
scope = scope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom sheet content for restore or transfer options (needs to be separate for preview).
|
||||
*/
|
||||
@Composable
|
||||
private fun RestoreOrTransferBottomSheetContent(
|
||||
sheetState: SheetState,
|
||||
scope: CoroutineScope,
|
||||
onEvent: (WelcomeScreenEvents) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
sheetState.dismissWithAnimation(scope) {
|
||||
onEvent(WelcomeScreenEvents.HasOldPhone)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON)
|
||||
) {
|
||||
Text("I have my old phone")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onEvent(WelcomeScreenEvents.DoesNotHaveOldPhone)
|
||||
sheetState.dismissWithAnimation(scope) {
|
||||
onEvent(WelcomeScreenEvents.DoesNotHaveOldPhone)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON)
|
||||
) {
|
||||
Text("I don't have my old phone")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun WelcomeScreenPreview() {
|
||||
Previews.Preview {
|
||||
WelcomeScreen(onEvent = {})
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RestoreOrTransferBottomSheetPreview() {
|
||||
Previews.BottomSheetPreview(forceRtl = true) {
|
||||
RestoreOrTransferBottomSheetContent(
|
||||
sheetState = rememberModalBottomSheetState(),
|
||||
scope = rememberCoroutineScope(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.welcome
|
||||
|
||||
sealed class WelcomeScreenEvents {
|
||||
data object Continue : WelcomeScreenEvents()
|
||||
data object HasOldPhone : WelcomeScreenEvents()
|
||||
data object DoesNotHaveOldPhone : WelcomeScreenEvents()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.test
|
||||
|
||||
/**
|
||||
* Test tags for Compose UI testing.
|
||||
*/
|
||||
object TestTags {
|
||||
|
||||
// Welcome Screen
|
||||
const val WELCOME_GET_STARTED_BUTTON = "welcome_get_started_button"
|
||||
const val WELCOME_RESTORE_OR_TRANSFER_BUTTON = "welcome_restore_or_transfer_button"
|
||||
const val WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON = "welcome_restore_has_old_phone_button"
|
||||
const val WELCOME_RESTORE_NO_OLD_PHONE_BUTTON = "welcome_restore_no_old_phone_button"
|
||||
|
||||
// Permissions Screen
|
||||
const val PERMISSIONS_NEXT_BUTTON = "permissions_next_button"
|
||||
const val PERMISSIONS_NOT_NOW_BUTTON = "permissions_not_now_button"
|
||||
|
||||
// Phone Number Screen
|
||||
const val PHONE_NUMBER_COUNTRY_PICKER = "phone_number_country_picker"
|
||||
const val PHONE_NUMBER_COUNTRY_CODE_FIELD = "phone_number_country_code_field"
|
||||
const val PHONE_NUMBER_PHONE_FIELD = "phone_number_phone_field"
|
||||
const val PHONE_NUMBER_NEXT_BUTTON = "phone_number_next_button"
|
||||
|
||||
// Verification Code Screen
|
||||
const val VERIFICATION_CODE_INPUT = "verification_code_input"
|
||||
const val VERIFICATION_CODE_DIGIT_0 = "verification_code_digit_0"
|
||||
const val VERIFICATION_CODE_DIGIT_1 = "verification_code_digit_1"
|
||||
const val VERIFICATION_CODE_DIGIT_2 = "verification_code_digit_2"
|
||||
const val VERIFICATION_CODE_DIGIT_3 = "verification_code_digit_3"
|
||||
const val VERIFICATION_CODE_DIGIT_4 = "verification_code_digit_4"
|
||||
const val VERIFICATION_CODE_DIGIT_5 = "verification_code_digit_5"
|
||||
const val VERIFICATION_CODE_WRONG_NUMBER_BUTTON = "verification_code_wrong_number_button"
|
||||
const val VERIFICATION_CODE_RESEND_SMS_BUTTON = "verification_code_resend_sms_button"
|
||||
const val VERIFICATION_CODE_CALL_ME_BUTTON = "verification_code_call_me_button"
|
||||
}
|
||||
Reference in New Issue
Block a user