Add quick restore flow and DebugLoggableModel to regV5.

Renames restore → quickrestore package, adds QuickRestoreQrViewModel,
introduces DebugLoggableModel for safe toString in release builds,
updates all State/Events classes to extend it, switches previews to
AllDevicePreviews, and enables BuildConfig for the registration module.
This commit is contained in:
Greyson Parrelli
2026-03-12 13:42:15 -04:00
committed by Michelle Tang
parent 889ebcadd4
commit 39de824bf0
59 changed files with 800 additions and 143 deletions

View File

@@ -119,3 +119,11 @@ fun String.splitByByteLength(byteLength: Int): Pair<String, String?> {
val remainder = this.substring(firstPart.length) val remainder = this.substring(firstPart.length)
return firstPart to remainder return firstPart to remainder
} }
/**
* Returns a new string with the same length, but all chars replaced with the [censorChar].
* e.g. "abc".censor() -> "***"
*/
fun String.censor(censorChar: Char = '*'): String {
return String(CharArray(this.length) { censorChar })
}

View File

@@ -59,6 +59,7 @@ dependencies {
implementation(project(":core:util")) implementation(project(":core:util"))
implementation(project(":core:models-jvm")) implementation(project(":core:models-jvm"))
implementation(project(":lib:libsignal-service")) implementation(project(":lib:libsignal-service"))
implementation(project(":lib:qr"))
// libsignal-protocol for PreKeyCollection types // libsignal-protocol for PreKeyCollection types
implementation(libs.libsignal.client) implementation(libs.libsignal.client)

View File

@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<application <application
android:name=".RegistrationApplication" android:name=".RegistrationApplication"

View File

@@ -56,6 +56,8 @@ import org.signal.registration.sample.debug.NetworkDebugOverlay
import org.signal.registration.sample.screens.RegistrationCompleteScreen import org.signal.registration.sample.screens.RegistrationCompleteScreen
import org.signal.registration.sample.screens.main.MainScreen import org.signal.registration.sample.screens.main.MainScreen
import org.signal.registration.sample.screens.main.MainScreenViewModel import org.signal.registration.sample.screens.main.MainScreenViewModel
import org.signal.registration.sample.screens.olddevicetransfer.TransferAccountScreen
import org.signal.registration.sample.screens.olddevicetransfer.TransferAccountViewModel
import org.signal.registration.sample.screens.pinsettings.PinSettingsScreen import org.signal.registration.sample.screens.pinsettings.PinSettingsScreen
import org.signal.registration.sample.screens.pinsettings.PinSettingsViewModel import org.signal.registration.sample.screens.pinsettings.PinSettingsViewModel
@@ -74,6 +76,9 @@ sealed interface SampleRoute : NavKey {
@Serializable @Serializable
data object RegistrationComplete : SampleRoute data object RegistrationComplete : SampleRoute
@Serializable
data object TransferAccount : SampleRoute
@Serializable @Serializable
data object PinSettings : SampleRoute data object PinSettings : SampleRoute
} }
@@ -146,7 +151,9 @@ private fun SampleNavHost(
val viewModel: MainScreenViewModel = viewModel( val viewModel: MainScreenViewModel = viewModel(
factory = MainScreenViewModel.Factory( factory = MainScreenViewModel.Factory(
storageController = registrationDependencies.storageController, storageController = registrationDependencies.storageController,
networkController = registrationDependencies.networkController,
onLaunchRegistration = { backStack.add(SampleRoute.Registration) }, onLaunchRegistration = { backStack.add(SampleRoute.Registration) },
onTransferAccount = { backStack.add(SampleRoute.TransferAccount) },
onOpenPinSettings = { backStack.add(SampleRoute.PinSettings) } onOpenPinSettings = { backStack.add(SampleRoute.PinSettings) }
) )
) )
@@ -177,6 +184,20 @@ private fun SampleNavHost(
RegistrationCompleteScreen(onStartOver = onStartOver) RegistrationCompleteScreen(onStartOver = onStartOver)
} }
entry<SampleRoute.TransferAccount> {
val viewModel: TransferAccountViewModel = viewModel(
factory = TransferAccountViewModel.Factory(
onBack = { backStack.removeLastOrNull() }
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
TransferAccountScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
entry<SampleRoute.PinSettings>( entry<SampleRoute.PinSettings>(
metadata = BottomSheetTransitionSpec metadata = BottomSheetTransitionSpec
) { ) {

View File

@@ -35,6 +35,9 @@ class RegistrationApplication : Application() {
companion object { companion object {
// Staging SVR2 mrEnclave value // Staging SVR2 mrEnclave value
private const val SVR2_MRENCLAVE = "97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535" private const val SVR2_MRENCLAVE = "97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535"
lateinit var serviceConfiguration: SignalServiceConfiguration
private set
} }
override fun onCreate() { override fun onCreate() {
@@ -46,6 +49,7 @@ class RegistrationApplication : Application() {
val trustStore = SampleTrustStore() val trustStore = SampleTrustStore()
val configuration = createServiceConfiguration(trustStore) val configuration = createServiceConfiguration(trustStore)
serviceConfiguration = configuration
val pushServiceSocket = createPushServiceSocket(configuration) val pushServiceSocket = createPushServiceSocket(configuration)
val demoNetworkController = DemoNetworkController(this, pushServiceSocket, configuration, SVR2_MRENCLAVE) val demoNetworkController = DemoNetworkController(this, pushServiceSocket, configuration, SVR2_MRENCLAVE)
val networkController = DebugNetworkController(demoNetworkController) val networkController = DebugNetworkController(demoNetworkController)

View File

@@ -5,6 +5,7 @@
package org.signal.registration.sample.debug package org.signal.registration.sample.debug
import kotlinx.coroutines.flow.Flow
import org.signal.core.models.MasterKey import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.registration.NetworkController import org.signal.registration.NetworkController
@@ -17,6 +18,7 @@ import org.signal.registration.NetworkController.GetSessionStatusError
import org.signal.registration.NetworkController.GetSvrCredentialsError import org.signal.registration.NetworkController.GetSvrCredentialsError
import org.signal.registration.NetworkController.MasterKeyResponse import org.signal.registration.NetworkController.MasterKeyResponse
import org.signal.registration.NetworkController.PreKeyCollection import org.signal.registration.NetworkController.PreKeyCollection
import org.signal.registration.NetworkController.ProvisioningEvent
import org.signal.registration.NetworkController.RegisterAccountError import org.signal.registration.NetworkController.RegisterAccountError
import org.signal.registration.NetworkController.RegisterAccountResponse import org.signal.registration.NetworkController.RegisterAccountResponse
import org.signal.registration.NetworkController.RegistrationNetworkResult import org.signal.registration.NetworkController.RegistrationNetworkResult
@@ -197,6 +199,10 @@ class DebugNetworkController(
return delegate.getSvrCredentials() return delegate.getSvrCredentials()
} }
override fun startProvisioning(): Flow<ProvisioningEvent> {
return delegate.startProvisioning()
}
override suspend fun checkSvrCredentials( override suspend fun checkSvrCredentials(
e164: String, e164: String,
credentials: List<SvrCredentials> credentials: List<SvrCredentials>

View File

@@ -6,6 +6,12 @@
package org.signal.registration.sample.dependencies package org.signal.registration.sample.dependencies
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -14,6 +20,9 @@ import okhttp3.Response
import org.signal.core.models.MasterKey import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.libsignal.net.Network import org.signal.libsignal.net.Network
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.protocol.util.Hex import org.signal.libsignal.protocol.util.Hex
import org.signal.registration.NetworkController import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.AccountAttributes import org.signal.registration.NetworkController.AccountAttributes
@@ -22,6 +31,8 @@ import org.signal.registration.NetworkController.CheckSvrCredentialsResponse
import org.signal.registration.NetworkController.CreateSessionError import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.GetSessionStatusError import org.signal.registration.NetworkController.GetSessionStatusError
import org.signal.registration.NetworkController.PreKeyCollection import org.signal.registration.NetworkController.PreKeyCollection
import org.signal.registration.NetworkController.ProvisioningEvent
import org.signal.registration.NetworkController.ProvisioningMessage
import org.signal.registration.NetworkController.RegisterAccountError import org.signal.registration.NetworkController.RegisterAccountError
import org.signal.registration.NetworkController.RegisterAccountResponse import org.signal.registration.NetworkController.RegisterAccountResponse
import org.signal.registration.NetworkController.RegistrationLockResponse import org.signal.registration.NetworkController.RegistrationLockResponse
@@ -32,9 +43,11 @@ import org.signal.registration.NetworkController.SubmitVerificationCodeError
import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse
import org.signal.registration.NetworkController.UpdateSessionError import org.signal.registration.NetworkController.UpdateSessionError
import org.signal.registration.NetworkController.VerificationCodeTransport import org.signal.registration.NetworkController.VerificationCodeTransport
import org.signal.registration.proto.RegistrationProvisionMessage
import org.signal.registration.sample.fcm.FcmUtil import org.signal.registration.sample.fcm.FcmUtil
import org.signal.registration.sample.fcm.PushChallengeReceiver import org.signal.registration.sample.fcm.PushChallengeReceiver
import org.signal.registration.sample.storage.RegistrationPreferences import org.signal.registration.sample.storage.RegistrationPreferences
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2 import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2
@@ -43,6 +56,7 @@ import org.whispersystems.signalservice.api.websocket.HealthMonitor
import org.whispersystems.signalservice.api.websocket.SignalWebSocket import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.api.websocket.WebSocketFactory import org.whispersystems.signalservice.api.websocket.WebSocketFactory
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
import org.whispersystems.signalservice.internal.push.AuthCredentials import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.PushServiceSocket import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider
@@ -368,6 +382,88 @@ class DemoNetworkController(
return "https://signalcaptchas.org/staging/registration/generate.html" return "https://signalcaptchas.org/staging/registration/generate.html"
} }
override fun startProvisioning(): Flow<ProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<java.io.Closeable>()
fun startSocket() {
val handle = ProvisioningSocket.start<RegistrationProvisionMessage>(
mode = ProvisioningSocket.Mode.REREG,
identityKeyPair = IdentityKeyPair.generate(),
configuration = serviceConfiguration,
handler = { id, t ->
Log.w(TAG, "[startProvisioning] Socket [$id] failed", t)
trySend(ProvisioningEvent.Error(t))
}
) { socket ->
val url = socket.getProvisioningUrl()
trySend(ProvisioningEvent.QrCodeReady(url))
val result = socket.getProvisioningMessageDecryptResult()
if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) {
val msg = result.message
trySend(
ProvisioningEvent.MessageReceived(
ProvisioningMessage(
accountEntropyPool = msg.accountEntropyPool,
e164 = msg.e164,
pin = msg.pin,
aciIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.aciIdentityKeyPublic.toByteArray()), ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray())),
pniIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.pniIdentityKeyPublic.toByteArray()), ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray())),
platform = when (msg.platform) {
RegistrationProvisionMessage.Platform.ANDROID -> NetworkController.ProvisioningMessage.Platform.ANDROID
RegistrationProvisionMessage.Platform.IOS -> NetworkController.ProvisioningMessage.Platform.IOS
},
tier = when (msg.tier) {
RegistrationProvisionMessage.Tier.FREE -> NetworkController.ProvisioningMessage.Tier.FREE
RegistrationProvisionMessage.Tier.PAID -> NetworkController.ProvisioningMessage.Tier.PAID
null -> null
},
backupTimestampMs = msg.backupTimestampMs,
backupSizeBytes = msg.backupSizeBytes,
restoreMethodToken = msg.restoreMethodToken,
backupVersion = msg.backupVersion
)
)
)
channel.close()
} else {
Log.w(TAG, "[startProvisioning] Failed to decrypt provisioning message")
trySend(ProvisioningEvent.Error(IOException("Failed to decrypt provisioning message")))
}
}
synchronized(socketHandles) {
socketHandles += handle
if (socketHandles.size > 2) {
socketHandles.removeAt(0).close()
}
}
}
startSocket()
val rotationJob = launch {
var count = 0
while (count < 5 && isActive) {
delay(ProvisioningSocket.LIFESPAN / 2)
if (isActive) {
startSocket()
count++
Log.d(TAG, "[startProvisioning] Rotated socket, count: $count")
}
}
}
awaitClose {
rotationJob.cancel()
synchronized(socketHandles) {
socketHandles.forEach { it.close() }
socketHandles.clear()
}
}
}
override suspend fun restoreMasterKeyFromSvr( override suspend fun restoreMasterKeyFromSvr(
svrCredentials: NetworkController.SvrCredentials, svrCredentials: NetworkController.SvrCredentials,
pin: String pin: String

View File

@@ -124,6 +124,10 @@ class DemoStorageController(context: Context) : StorageController {
RegistrationPreferences.pinAlphanumeric = isAlphanumeric RegistrationPreferences.pinAlphanumeric = isAlphanumeric
} }
override suspend fun saveProvisioningData(provisioningMessage: NetworkController.ProvisioningMessage) = withContext(Dispatchers.IO) {
RegistrationPreferences.saveProvisioningData(provisioningMessage)
}
private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) { private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) {
// Clear existing data // Clear existing data
RegistrationPreferences.clearKeyMaterial() RegistrationPreferences.clearKeyMaterial()

View File

@@ -5,13 +5,16 @@
package org.signal.registration.sample.screens.main package org.signal.registration.sample.screens.main
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -93,6 +96,26 @@ fun MainScreen(
} }
if (state.existingRegistrationState != null) { if (state.existingRegistrationState != null) {
if (state.registrationExpired) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.errorContainer,
shape = RoundedCornerShape(8.dp)
)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "No longer registered. Your credentials are no longer valid on the server.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
Spacer(modifier = Modifier.height(8.dp))
}
RegistrationInfo(state.existingRegistrationState) RegistrationInfo(state.existingRegistrationState)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -104,6 +127,13 @@ fun MainScreen(
Text("Re-register") Text("Re-register")
} }
OutlinedButton(
onClick = { onEvent(MainScreenEvents.TransferAccount) },
modifier = Modifier.fillMaxWidth()
) {
Text("Transfer to New Device")
}
OutlinedButton( OutlinedButton(
onClick = { onEvent(MainScreenEvents.OpenPinSettings) }, onClick = { onEvent(MainScreenEvents.OpenPinSettings) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()

View File

@@ -7,6 +7,7 @@ package org.signal.registration.sample.screens.main
sealed interface MainScreenEvents { sealed interface MainScreenEvents {
data object LaunchRegistration : MainScreenEvents data object LaunchRegistration : MainScreenEvents
data object TransferAccount : MainScreenEvents
data object OpenPinSettings : MainScreenEvents data object OpenPinSettings : MainScreenEvents
data object ClearAllData : MainScreenEvents data object ClearAllData : MainScreenEvents
} }

View File

@@ -6,7 +6,8 @@
package org.signal.registration.sample.screens.main package org.signal.registration.sample.screens.main
data class MainScreenState( data class MainScreenState(
val existingRegistrationState: ExistingRegistrationState? = null val existingRegistrationState: ExistingRegistrationState? = null,
val registrationExpired: Boolean = false
) { ) {
data class ExistingRegistrationState( data class ExistingRegistrationState(
val phoneNumber: String, val phoneNumber: String,

View File

@@ -13,15 +13,23 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.signal.core.util.Base64 import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.signal.registration.NetworkController
import org.signal.registration.StorageController import org.signal.registration.StorageController
import org.signal.registration.sample.storage.RegistrationPreferences import org.signal.registration.sample.storage.RegistrationPreferences
class MainScreenViewModel( class MainScreenViewModel(
private val storageController: StorageController, private val storageController: StorageController,
private val networkController: NetworkController,
private val onLaunchRegistration: () -> Unit, private val onLaunchRegistration: () -> Unit,
private val onTransferAccount: () -> Unit,
private val onOpenPinSettings: () -> Unit private val onOpenPinSettings: () -> Unit
) : ViewModel() { ) : ViewModel() {
companion object {
private val TAG = Log.tag(MainScreenViewModel::class)
}
private val _state = MutableStateFlow(MainScreenState()) private val _state = MutableStateFlow(MainScreenState())
val state: StateFlow<MainScreenState> = _state.asStateFlow() val state: StateFlow<MainScreenState> = _state.asStateFlow()
@@ -37,6 +45,7 @@ class MainScreenViewModel(
viewModelScope.launch { viewModelScope.launch {
when (event) { when (event) {
MainScreenEvents.LaunchRegistration -> onLaunchRegistration() MainScreenEvents.LaunchRegistration -> onLaunchRegistration()
MainScreenEvents.TransferAccount -> onTransferAccount()
MainScreenEvents.OpenPinSettings -> onOpenPinSettings() MainScreenEvents.OpenPinSettings -> onOpenPinSettings()
MainScreenEvents.ClearAllData -> { MainScreenEvents.ClearAllData -> {
storageController.clearAllData() storageController.clearAllData()
@@ -65,18 +74,51 @@ class MainScreenViewModel(
) )
} else { } else {
null null
} },
registrationExpired = false
) )
if (existingData != null) {
checkRegistrationStatus()
}
}
}
private suspend fun checkRegistrationStatus() {
when (val result = networkController.getSvrCredentials()) {
is NetworkController.RegistrationNetworkResult.Success -> {
Log.d(TAG, "[CheckRegistration] Still registered.")
}
is NetworkController.RegistrationNetworkResult.Failure -> {
when (result.error) {
NetworkController.GetSvrCredentialsError.Unauthorized -> {
Log.w(TAG, "[CheckRegistration] No longer registered (401).")
_state.value = _state.value.copy(registrationExpired = true)
}
NetworkController.GetSvrCredentialsError.NoServiceCredentialsAvailable -> {
Log.w(TAG, "[CheckRegistration] No credentials available locally.")
_state.value = _state.value.copy(registrationExpired = true)
}
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "[CheckRegistration] Network error, can't verify status.", result.exception)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "[CheckRegistration] Application error, can't verify status.", result.exception)
}
} }
} }
class Factory( class Factory(
private val storageController: StorageController, private val storageController: StorageController,
private val networkController: NetworkController,
private val onLaunchRegistration: () -> Unit, private val onLaunchRegistration: () -> Unit,
private val onTransferAccount: () -> Unit,
private val onOpenPinSettings: () -> Unit private val onOpenPinSettings: () -> Unit
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return MainScreenViewModel(storageController, onLaunchRegistration, onOpenPinSettings) as T return MainScreenViewModel(storageController, networkController, onLaunchRegistration, onTransferAccount, onOpenPinSettings) as T
} }
} }
} }

View File

@@ -46,6 +46,12 @@ object RegistrationPreferences {
private const val KEY_PIN_ALPHANUMERIC = "pin_alphanumeric" private const val KEY_PIN_ALPHANUMERIC = "pin_alphanumeric"
private const val KEY_PINS_OPTED_OUT = "pins_opted_out" private const val KEY_PINS_OPTED_OUT = "pins_opted_out"
private const val KEY_SVR2_CREDENTIALS = "svr2_credentials" private const val KEY_SVR2_CREDENTIALS = "svr2_credentials"
private const val KEY_RESTORE_METHOD_TOKEN = "restore_method_token"
private const val KEY_BACKUP_TIER = "backup_tier"
private const val KEY_BACKUP_TIMESTAMP_MS = "backup_timestamp_ms"
private const val KEY_BACKUP_SIZE_BYTES = "backup_size_bytes"
private const val KEY_OTHER_DEVICE_PLATFORM = "other_device_platform"
private const val KEY_BACKUP_VERSION = "backup_version"
fun init(context: Application) { fun init(context: Application) {
this.context = context this.context = context
@@ -169,6 +175,17 @@ object RegistrationPreferences {
) )
} }
fun saveProvisioningData(message: NetworkController.ProvisioningMessage) {
prefs.edit {
putString(KEY_RESTORE_METHOD_TOKEN, message.restoreMethodToken)
putString(KEY_BACKUP_TIER, message.tier?.name)
message.backupTimestampMs?.let { putLong(KEY_BACKUP_TIMESTAMP_MS, it) }
message.backupSizeBytes?.let { putLong(KEY_BACKUP_SIZE_BYTES, it) }
putString(KEY_OTHER_DEVICE_PLATFORM, message.platform.name)
putLong(KEY_BACKUP_VERSION, message.backupVersion)
}
}
fun clearKeyMaterial() { fun clearKeyMaterial() {
prefs.edit { prefs.edit {
remove(KEY_PROFILE_KEY) remove(KEY_PROFILE_KEY)

View File

@@ -10,6 +10,7 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
testOptions { testOptions {

View File

@@ -6,12 +6,14 @@
package org.signal.registration package org.signal.registration
import android.os.Parcelable import android.os.Parcelable
import kotlinx.coroutines.flow.Flow
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.signal.core.models.MasterKey import org.signal.core.models.MasterKey
import org.signal.core.util.serialization.ByteArrayToBase64Serializer import org.signal.core.util.serialization.ByteArrayToBase64Serializer
import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import java.io.IOException import java.io.IOException
@@ -179,6 +181,19 @@ interface NetworkController {
*/ */
suspend fun setAccountAttributes(attributes: AccountAttributes): RegistrationNetworkResult<Unit, SetAccountAttributesError> suspend fun setAccountAttributes(attributes: AccountAttributes): RegistrationNetworkResult<Unit, SetAccountAttributesError>
/**
* Starts a provisioning session for QR-based quick restore.
*
* The returned flow emits [ProvisioningEvent]s:
* - [ProvisioningEvent.QrCodeReady] whenever a new QR code URL is available (e.g. due to socket rotation).
* - [ProvisioningEvent.MessageReceived] when the old device scans the QR code and sends provisioning data.
* - [ProvisioningEvent.Error] if the provisioning session encounters an unrecoverable error.
*
* The flow will manage socket lifecycle (rotation, keep-alive) internally.
* Cancel the collecting coroutine to stop provisioning.
*/
fun startProvisioning(): Flow<ProvisioningEvent>
// /** // /**
// * Set [RestoreMethod] enum on the server for use by the old device to update UX. // * Set [RestoreMethod] enum on the server for use by the old device to update UX.
// */ // */
@@ -431,4 +446,38 @@ interface NetworkController {
enum class VerificationCodeTransport { enum class VerificationCodeTransport {
SMS, VOICE SMS, VOICE
} }
/**
* Data received from the old device during QR-based provisioning.
*/
data class ProvisioningMessage(
val accountEntropyPool: String,
val e164: String,
val pin: String?,
val aciIdentityKeyPair: IdentityKeyPair,
val pniIdentityKeyPair: IdentityKeyPair,
val platform: Platform,
val tier: Tier?,
val backupTimestampMs: Long?,
val backupSizeBytes: Long?,
val restoreMethodToken: String,
val backupVersion: Long
) {
enum class Platform { ANDROID, IOS }
enum class Tier { FREE, PAID }
}
/**
* Events emitted during a provisioning session.
*/
sealed interface ProvisioningEvent {
/** A new QR code URL is available for display. */
data class QrCodeReady(val url: String) : ProvisioningEvent
/** The old device has scanned the QR code and sent provisioning data. */
data class MessageReceived(val message: ProvisioningMessage) : ProvisioningEvent
/** The provisioning session encountered an error. */
data class Error(val cause: Throwable?) : ProvisioningEvent
}
} }

View File

@@ -7,8 +7,9 @@ package org.signal.registration
import org.signal.core.models.AccountEntropyPool import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey import org.signal.core.models.MasterKey
import org.signal.registration.util.DebugLoggable
sealed interface RegistrationFlowEvent { sealed interface RegistrationFlowEvent : DebugLoggable {
/** Navigate to a specific screen. */ /** Navigate to a specific screen. */
data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent

View File

@@ -11,6 +11,7 @@ import kotlinx.parcelize.TypeParceler
import org.signal.core.models.AccountEntropyPool import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey import org.signal.core.models.MasterKey
import org.signal.registration.util.AccountEntropyPoolParceler import org.signal.registration.util.AccountEntropyPoolParceler
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.MasterKeyParceler import org.signal.registration.util.MasterKeyParceler
@Parcelize @Parcelize
@@ -37,4 +38,4 @@ data class RegistrationFlowState(
/** If true, do not attempt any flows where we generate RRP's. Create a session instead. */ /** If true, do not attempt any flows where we generate RRP's. Create a session instead. */
val doNotAttemptRecoveryPassword: Boolean = false val doNotAttemptRecoveryPassword: Boolean = false
) : Parcelable ) : Parcelable, DebugLoggable

View File

@@ -30,6 +30,9 @@ import org.signal.core.ui.navigation.TransitionSpecs
import org.signal.registration.screens.accountlocked.AccountLockedScreen import org.signal.registration.screens.accountlocked.AccountLockedScreen
import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents
import org.signal.registration.screens.accountlocked.AccountLockedState import org.signal.registration.screens.accountlocked.AccountLockedState
// TODO [regV5] Uncomment when restore selection flow is ready
// import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionScreen
// import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionViewModel
import org.signal.registration.screens.captcha.CaptchaScreen import org.signal.registration.screens.captcha.CaptchaScreen
import org.signal.registration.screens.captcha.CaptchaScreenEvents import org.signal.registration.screens.captcha.CaptchaScreenEvents
import org.signal.registration.screens.captcha.CaptchaState import org.signal.registration.screens.captcha.CaptchaState
@@ -47,9 +50,8 @@ import org.signal.registration.screens.pinentry.PinEntryForRegistrationLockViewM
import org.signal.registration.screens.pinentry.PinEntryForSmsBypassViewModel import org.signal.registration.screens.pinentry.PinEntryForSmsBypassViewModel
import org.signal.registration.screens.pinentry.PinEntryForSvrRestoreViewModel import org.signal.registration.screens.pinentry.PinEntryForSvrRestoreViewModel
import org.signal.registration.screens.pinentry.PinEntryScreen import org.signal.registration.screens.pinentry.PinEntryScreen
import org.signal.registration.screens.restore.RestoreViaQrScreen import org.signal.registration.screens.quickrestore.QuickRestoreQrScreen
import org.signal.registration.screens.restore.RestoreViaQrScreenEvents import org.signal.registration.screens.quickrestore.QuickRestoreQrViewModel
import org.signal.registration.screens.restore.RestoreViaQrState
import org.signal.registration.screens.util.navigateBack import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo import org.signal.registration.screens.util.navigateTo
import org.signal.registration.screens.verificationcode.VerificationCodeScreen import org.signal.registration.screens.verificationcode.VerificationCodeScreen
@@ -99,6 +101,10 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
@Serializable @Serializable
data object PinCreate : RegistrationRoute data object PinCreate : RegistrationRoute
// TODO [regV5] Uncomment when restore selection flow is ready
// @Serializable
// data object ArchiveRestoreSelection : RegistrationRoute
@Serializable @Serializable
data object ChooseRestoreOptionBeforeRegistration : RegistrationRoute data object ChooseRestoreOptionBeforeRegistration : RegistrationRoute
@@ -398,29 +404,39 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
) )
} }
// TODO [regV5] Uncomment when restore selection flow is ready
// entry<RegistrationRoute.ArchiveRestoreSelection> {
// val viewModel: ArchiveRestoreSelectionViewModel = viewModel(
// factory = ArchiveRestoreSelectionViewModel.Factory(
// repository = registrationRepository,
// parentState = registrationViewModel.state,
// parentEventEmitter = registrationViewModel::onEvent
// )
// )
// val state by viewModel.state.collectAsStateWithLifecycle()
//
// ArchiveRestoreSelectionScreen(
// state = state,
// onEvent = { viewModel.onEvent(it) }
// )
// }
entry<RegistrationRoute.ChooseRestoreOptionAfterRegistration> { entry<RegistrationRoute.ChooseRestoreOptionAfterRegistration> {
// TODO: Implement RestoreScreen // TODO: Implement RestoreScreen
} }
entry<RegistrationRoute.QuickRestoreQrScan> { entry<RegistrationRoute.QuickRestoreQrScan> {
RestoreViaQrScreen( val viewModel: QuickRestoreQrViewModel = viewModel(
state = RestoreViaQrState(), factory = QuickRestoreQrViewModel.Factory(
onEvent = { event -> repository = registrationRepository,
when (event) { parentEventEmitter = registrationViewModel::onEvent
RestoreViaQrScreenEvents.RetryQrCode -> { )
// TODO: Retry QR code generation )
} val state by viewModel.state.collectAsStateWithLifecycle()
RestoreViaQrScreenEvents.Cancel -> {
parentEventEmitter.navigateBack() QuickRestoreQrScreen(
} state = state,
RestoreViaQrScreenEvents.UseProxy -> { onEvent = { viewModel.onEvent(it) }
// TODO: Navigate to proxy settings
}
RestoreViaQrScreenEvents.DismissError -> {
// TODO: Clear error state
}
}
}
) )
} }

View File

@@ -8,15 +8,19 @@ package org.signal.registration
import android.app.backup.BackupManager import android.app.backup.BackupManager
import android.content.Context import android.content.Context
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey import org.signal.core.models.MasterKey
import org.signal.core.models.ServiceId.ACI import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.registration.NetworkController.AccountAttributes import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.CreateSessionError import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.MasterKeyResponse import org.signal.registration.NetworkController.MasterKeyResponse
import org.signal.registration.NetworkController.PreKeyCollection import org.signal.registration.NetworkController.PreKeyCollection
import org.signal.registration.NetworkController.ProvisioningEvent
import org.signal.registration.NetworkController.RegisterAccountError import org.signal.registration.NetworkController.RegisterAccountError
import org.signal.registration.NetworkController.RegisterAccountResponse import org.signal.registration.NetworkController.RegisterAccountResponse
import org.signal.registration.NetworkController.RegistrationNetworkResult import org.signal.registration.NetworkController.RegistrationNetworkResult
@@ -155,7 +159,16 @@ class RegistrationRepository(val context: Context, val networkController: Networ
skipDeviceTransfer: Boolean = true, skipDeviceTransfer: Boolean = true,
preExistingRegistrationData: PreExistingRegistrationData? = null preExistingRegistrationData: PreExistingRegistrationData? = null
): RegistrationNetworkResult<Pair<RegisterAccountResponse, KeyMaterial>, RegisterAccountError> = withContext(Dispatchers.IO) { ): RegistrationNetworkResult<Pair<RegisterAccountResponse, KeyMaterial>, RegisterAccountError> = withContext(Dispatchers.IO) {
registerAccount(e164, sessionId = null, recoveryPassword, registrationLock, skipDeviceTransfer, preExistingRegistrationData) registerAccount(
e164 = e164,
sessionId = null,
recoveryPassword = recoveryPassword,
registrationLock = registrationLock,
skipDeviceTransfer = skipDeviceTransfer,
existingAccountEntropyPool = preExistingRegistrationData?.aep,
existingAciIdentityKeyPair = preExistingRegistrationData?.aciIdentityKeyPair,
existingPniIdentityKeyPair = preExistingRegistrationData?.pniIdentityKeyPair
)
} }
/** /**
@@ -182,6 +195,42 @@ class RegistrationRepository(val context: Context, val networkController: Networ
registerAccount(e164, sessionId, recoveryPassword = null, registrationLock, skipDeviceTransfer) registerAccount(e164, sessionId, recoveryPassword = null, registrationLock, skipDeviceTransfer)
} }
/**
* Starts a provisioning session for QR-based quick restore.
* See [NetworkController.startProvisioning].
*/
fun startProvisioning(): Flow<ProvisioningEvent> {
return networkController.startProvisioning()
}
/**
* Registers an account using data received from the old device via QR provisioning.
*
* This method:
* 1. Saves provisioning metadata (restore token, backup info) to storage
* 2. Re-uses the identity key pairs and AEP from the old device
* 3. Derives the recovery password from the provisioned AEP
* 4. Registers the account
*/
suspend fun registerAccountWithProvisioningData(
provisioningMessage: NetworkController.ProvisioningMessage
): RegistrationNetworkResult<Pair<RegisterAccountResponse, KeyMaterial>, RegisterAccountError> = withContext(Dispatchers.IO) {
storageController.saveProvisioningData(provisioningMessage)
val aep = AccountEntropyPool(provisioningMessage.accountEntropyPool)
val recoveryPassword = aep.deriveMasterKey().deriveRegistrationRecoveryPassword()
registerAccount(
e164 = provisioningMessage.e164,
sessionId = null,
recoveryPassword = recoveryPassword,
skipDeviceTransfer = true,
existingAccountEntropyPool = aep,
existingAciIdentityKeyPair = provisioningMessage.aciIdentityKeyPair,
existingPniIdentityKeyPair = provisioningMessage.pniIdentityKeyPair
)
}
/** /**
* Registers a new account. * Registers a new account.
* *
@@ -205,17 +254,19 @@ class RegistrationRepository(val context: Context, val networkController: Networ
recoveryPassword: String?, recoveryPassword: String?,
registrationLock: String? = null, registrationLock: String? = null,
skipDeviceTransfer: Boolean = true, skipDeviceTransfer: Boolean = true,
preExistingRegistrationData: PreExistingRegistrationData? = null existingAccountEntropyPool: AccountEntropyPool? = null,
existingAciIdentityKeyPair: IdentityKeyPair? = null,
existingPniIdentityKeyPair: IdentityKeyPair? = null
): RegistrationNetworkResult<Pair<RegisterAccountResponse, KeyMaterial>, RegisterAccountError> = withContext(Dispatchers.IO) { ): RegistrationNetworkResult<Pair<RegisterAccountResponse, KeyMaterial>, RegisterAccountError> = withContext(Dispatchers.IO) {
check(sessionId != null || recoveryPassword != null) { "Either sessionId or recoveryPassword must be provided" } check(sessionId != null || recoveryPassword != null) { "Either sessionId or recoveryPassword must be provided" }
check(sessionId == null || recoveryPassword == null) { "Either sessionId or recoveryPassword must be provided, but not both" } check(sessionId == null || recoveryPassword == null) { "Either sessionId or recoveryPassword must be provided, but not both" }
Log.i(TAG, "[registerAccount] Starting registration for $e164. sessionId: ${sessionId != null}, recoveryPassword: ${recoveryPassword != null}, registrationLock: ${registrationLock != null}, skipDeviceTransfer: $skipDeviceTransfer, preExistingRegistrationData: ${preExistingRegistrationData != null}") Log.i(TAG, "[registerAccount] Starting registration for $e164. sessionId: ${sessionId != null}, recoveryPassword: ${recoveryPassword != null}, registrationLock: ${registrationLock != null}, skipDeviceTransfer: $skipDeviceTransfer, existingAep: ${existingAccountEntropyPool != null}")
val keyMaterial = storageController.generateAndStoreKeyMaterial( val keyMaterial = storageController.generateAndStoreKeyMaterial(
existingAccountEntropyPool = preExistingRegistrationData?.aep, existingAccountEntropyPool = existingAccountEntropyPool,
existingAciIdentityKeyPair = preExistingRegistrationData?.aciIdentityKeyPair, existingAciIdentityKeyPair = existingAciIdentityKeyPair,
existingPniIdentityKeyPair = preExistingRegistrationData?.pniIdentityKeyPair existingPniIdentityKeyPair = existingPniIdentityKeyPair
) )
val fcmToken = networkController.getFcmToken() val fcmToken = networkController.getFcmToken()

View File

@@ -45,6 +45,7 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
} }
fun onEvent(event: RegistrationFlowEvent) { fun onEvent(event: RegistrationFlowEvent) {
Log.d(TAG, "[Event] $event")
_state.value = applyEvent(_state.value, event) _state.value = applyEvent(_state.value, event)
} }

View File

@@ -85,6 +85,15 @@ interface StorageController {
*/ */
suspend fun saveNewlyCreatedPin(pin: String, isAlphanumeric: Boolean) suspend fun saveNewlyCreatedPin(pin: String, isAlphanumeric: Boolean)
/**
* Saves metadata from a provisioning message received during QR-based restore.
*
* This includes the restore method token, backup tier, backup timestamps, and
* platform information from the old device. Called before registering with
* the provisioned data.
*/
suspend fun saveProvisioningData(provisioningMessage: NetworkController.ProvisioningMessage)
/** /**
* Clears all stored registration data, including key material and account information. * Clears all stored registration data, including key material and account information.
*/ */

View File

@@ -21,7 +21,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
/** /**
@@ -86,7 +86,7 @@ fun AccountLockedScreen(
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun AccountLockedScreenPreview() { private fun AccountLockedScreenPreview() {
Previews.Preview { Previews.Preview {

View File

@@ -5,7 +5,9 @@
package org.signal.registration.screens.accountlocked package org.signal.registration.screens.accountlocked
sealed class AccountLockedScreenEvents { import org.signal.registration.util.DebugLoggableModel
sealed class AccountLockedScreenEvents : DebugLoggableModel() {
data object Next : AccountLockedScreenEvents() data object Next : AccountLockedScreenEvents()
data object LearnMore : AccountLockedScreenEvents() data object LearnMore : AccountLockedScreenEvents()
} }

View File

@@ -5,6 +5,8 @@
package org.signal.registration.screens.accountlocked package org.signal.registration.screens.accountlocked
import org.signal.registration.util.DebugLoggableModel
data class AccountLockedState( data class AccountLockedState(
val daysRemaining: Int = 10 val daysRemaining: Int = 10
) ) : DebugLoggableModel()

View File

@@ -28,7 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
/** /**
@@ -133,7 +133,7 @@ fun CaptchaScreen(
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun CaptchaScreenLoadingPreview() { private fun CaptchaScreenLoadingPreview() {
Previews.Preview { Previews.Preview {
@@ -147,7 +147,7 @@ private fun CaptchaScreenLoadingPreview() {
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun CaptchaScreenErrorPreview() { private fun CaptchaScreenErrorPreview() {
Previews.Preview { Previews.Preview {

View File

@@ -5,7 +5,14 @@
package org.signal.registration.screens.captcha package org.signal.registration.screens.captcha
sealed class CaptchaScreenEvents { import org.signal.core.util.censor
data class CaptchaCompleted(val token: String) : CaptchaScreenEvents() import org.signal.registration.util.DebugLoggableModel
sealed class CaptchaScreenEvents : DebugLoggableModel() {
data class CaptchaCompleted(val token: String) : CaptchaScreenEvents() {
override fun toSafeString(): String {
return "CaptchaCompleted(token=${token.censor()})"
}
}
data object Cancel : CaptchaScreenEvents() data object Cancel : CaptchaScreenEvents()
} }

View File

@@ -5,7 +5,9 @@
package org.signal.registration.screens.captcha package org.signal.registration.screens.captcha
sealed class CaptchaLoadState { import org.signal.registration.util.DebugLoggableModel
sealed class CaptchaLoadState : DebugLoggableModel() {
data object Loading : CaptchaLoadState() data object Loading : CaptchaLoadState()
data object Loaded : CaptchaLoadState() data object Loaded : CaptchaLoadState()
data object Error : CaptchaLoadState() data object Error : CaptchaLoadState()
@@ -15,4 +17,4 @@ data class CaptchaState(
val captchaUrl: String, val captchaUrl: String,
val captchaScheme: String = "signalcaptcha://", val captchaScheme: String = "signalcaptcha://",
val loadState: CaptchaLoadState = CaptchaLoadState.Loading val loadState: CaptchaLoadState = CaptchaLoadState.Loading
) ) : DebugLoggableModel()

View File

@@ -49,7 +49,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Dividers import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.IconButtons.IconButton import org.signal.core.ui.compose.IconButtons.IconButton
import org.signal.core.ui.compose.LargeFontPreviews import org.signal.core.ui.compose.LargeFontPreviews
@@ -284,7 +284,7 @@ private fun SearchBar(
) )
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun ScreenPreview() { private fun ScreenPreview() {
Previews.Preview { Previews.Preview {
@@ -305,7 +305,7 @@ private fun ScreenPreview() {
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun LoadingScreenPreview() { private fun LoadingScreenPreview() {
Previews.Preview { Previews.Preview {

View File

@@ -5,8 +5,10 @@
package org.signal.registration.screens.countrycode package org.signal.registration.screens.countrycode
sealed interface CountryCodePickerScreenEvents { import org.signal.registration.util.DebugLoggableModel
data class Search(val query: String) : CountryCodePickerScreenEvents
data class CountrySelected(val country: Country) : CountryCodePickerScreenEvents sealed class CountryCodePickerScreenEvents : DebugLoggableModel() {
data object Dismissed : CountryCodePickerScreenEvents data class Search(val query: String) : CountryCodePickerScreenEvents()
data class CountrySelected(val country: Country) : CountryCodePickerScreenEvents()
data object Dismissed : CountryCodePickerScreenEvents()
} }

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.signal.core.ui.navigation.ResultEventBus import org.signal.core.ui.navigation.ResultEventBus
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.screens.util.navigateBack import org.signal.registration.screens.util.navigateBack
@@ -29,6 +30,10 @@ class CountryCodePickerViewModel(
initialCountry: Country? = null initialCountry: Country? = null
) : ViewModel() { ) : ViewModel() {
companion object {
private val TAG = Log.tag(CountryCodePickerViewModel::class)
}
private val _state = MutableStateFlow(CountryCodeState()) private val _state = MutableStateFlow(CountryCodeState())
val state: StateFlow<CountryCodeState> = _state.asStateFlow() val state: StateFlow<CountryCodeState> = _state.asStateFlow()
@@ -37,6 +42,7 @@ class CountryCodePickerViewModel(
} }
fun onEvent(event: CountryCodePickerScreenEvents) { fun onEvent(event: CountryCodePickerScreenEvents) {
Log.d(TAG, "[Event] $event")
when (event) { when (event) {
is CountryCodePickerScreenEvents.Search -> applySearchEvent(event.query) is CountryCodePickerScreenEvents.Search -> applySearchEvent(event.query)
is CountryCodePickerScreenEvents.CountrySelected -> { is CountryCodePickerScreenEvents.CountrySelected -> {

View File

@@ -5,6 +5,8 @@
package org.signal.registration.screens.countrycode package org.signal.registration.screens.countrycode
import org.signal.registration.util.DebugLoggableModel
/** /**
* State managed by [CountryCodePickerViewModel]. Includes country list and allows for searching * State managed by [CountryCodePickerViewModel]. Includes country list and allows for searching
*/ */
@@ -14,4 +16,4 @@ data class CountryCodeState(
val commonCountryList: List<Country> = emptyList(), val commonCountryList: List<Country> = emptyList(),
val filteredList: List<Country> = emptyList(), val filteredList: List<Country> = emptyList(),
val startingIndex: Int = 0 val startingIndex: Int = 0
) ) : DebugLoggableModel()

View File

@@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.MultiplePermissionsState
import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters import org.signal.core.ui.compose.horizontalGutters
import org.signal.registration.R import org.signal.registration.R
@@ -207,7 +207,7 @@ private fun PermissionRow(
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun PermissionsScreenPreview() { private fun PermissionsScreenPreview() {
Previews.Preview { Previews.Preview {

View File

@@ -48,7 +48,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.CircularProgressWrapper import org.signal.core.ui.compose.CircularProgressWrapper
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.signal.registration.R import org.signal.registration.R
@@ -359,7 +359,7 @@ private fun DropdownTriangle(
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun PhoneNumberScreenPreview() { private fun PhoneNumberScreenPreview() {
Previews.Preview { Previews.Preview {
@@ -370,7 +370,7 @@ private fun PhoneNumberScreenPreview() {
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun PhoneNumberScreenSpinnerPreview() { private fun PhoneNumberScreenSpinnerPreview() {
Previews.Preview { Previews.Preview {

View File

@@ -5,12 +5,18 @@
package org.signal.registration.screens.phonenumber package org.signal.registration.screens.phonenumber
sealed interface PhoneNumberEntryScreenEvents { import org.signal.registration.util.DebugLoggableModel
data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents
data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents sealed class PhoneNumberEntryScreenEvents : DebugLoggableModel() {
data class CountrySelected(val countryCode: Int, val regionCode: String, val countryName: String, val countryEmoji: String) : PhoneNumberEntryScreenEvents data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents()
data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents()
data object CountryPicker : PhoneNumberEntryScreenEvents data class CountrySelected(val countryCode: Int, val regionCode: String, val countryName: String, val countryEmoji: String) : PhoneNumberEntryScreenEvents()
data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents()
data object ConsumeOneTimeEvent : PhoneNumberEntryScreenEvents data object CountryPicker : PhoneNumberEntryScreenEvents()
data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents() {
override fun toSafeString(): String {
return "CaptchaCompleted(token=***)"
}
}
data object ConsumeOneTimeEvent : PhoneNumberEntryScreenEvents()
} }

View File

@@ -8,6 +8,8 @@ package org.signal.registration.screens.phonenumber
import org.signal.registration.NetworkController import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.SessionMetadata import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.PreExistingRegistrationData import org.signal.registration.PreExistingRegistrationData
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.DebugLoggableModel
import kotlin.time.Duration import kotlin.time.Duration
data class PhoneNumberEntryState( data class PhoneNumberEntryState(
@@ -23,8 +25,8 @@ data class PhoneNumberEntryState(
val oneTimeEvent: OneTimeEvent? = null, val oneTimeEvent: OneTimeEvent? = null,
val preExistingRegistrationData: PreExistingRegistrationData? = null, val preExistingRegistrationData: PreExistingRegistrationData? = null,
val restoredSvrCredentials: List<NetworkController.SvrCredentials> = emptyList() val restoredSvrCredentials: List<NetworkController.SvrCredentials> = emptyList()
) { ) : DebugLoggableModel() {
sealed interface OneTimeEvent { sealed interface OneTimeEvent : DebugLoggable {
data object NetworkError : OneTimeEvent data object NetworkError : OneTimeEvent
data object UnknownError : OneTimeEvent data object UnknownError : OneTimeEvent
data class RateLimited(val retryAfter: Duration) : OneTimeEvent data class RateLimited(val retryAfter: Duration) : OneTimeEvent

View File

@@ -57,6 +57,7 @@ class PhoneNumberEntryViewModel(
} }
fun onEvent(event: PhoneNumberEntryScreenEvents) { fun onEvent(event: PhoneNumberEntryScreenEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch { viewModelScope.launch {
val stateEmitter: (PhoneNumberEntryState) -> Unit = { state -> val stateEmitter: (PhoneNumberEntryState) -> Unit = { state ->
_state.value = state _state.value = state

View File

@@ -42,7 +42,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.SignalIcons
@@ -180,7 +180,7 @@ fun PinCreationScreen(
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun PinCreationScreenPreview() { private fun PinCreationScreenPreview() {
Previews.Preview { Previews.Preview {
@@ -193,7 +193,7 @@ private fun PinCreationScreenPreview() {
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun PinCreationScreenAlphanumericPreview() { private fun PinCreationScreenAlphanumericPreview() {
Previews.Preview { Previews.Preview {

View File

@@ -5,7 +5,9 @@
package org.signal.registration.screens.pincreation package org.signal.registration.screens.pincreation
sealed class PinCreationScreenEvents { import org.signal.registration.util.DebugLoggableModel
sealed class PinCreationScreenEvents : DebugLoggableModel() {
data class PinSubmitted(val pin: String) : PinCreationScreenEvents() data class PinSubmitted(val pin: String) : PinCreationScreenEvents()
data object ToggleKeyboard : PinCreationScreenEvents() data object ToggleKeyboard : PinCreationScreenEvents()
data object LearnMore : PinCreationScreenEvents() data object LearnMore : PinCreationScreenEvents()

View File

@@ -6,10 +6,13 @@
package org.signal.registration.screens.pincreation package org.signal.registration.screens.pincreation
import org.signal.core.models.AccountEntropyPool import org.signal.core.models.AccountEntropyPool
import org.signal.registration.BuildConfig
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.DebugLoggableModel
data class PinCreationState( data class PinCreationState(
val isAlphanumericKeyboard: Boolean = false, val isAlphanumericKeyboard: Boolean = false,
val inputLabel: String? = null, val inputLabel: String? = null,
val isConfirmEnabled: Boolean = false, val isConfirmEnabled: Boolean = false,
val accountEntropyPool: AccountEntropyPool? = null val accountEntropyPool: AccountEntropyPool? = null
) ) : DebugLoggableModel()

View File

@@ -51,6 +51,7 @@ class PinCreationViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinCreationState(inputLabel = "PIN must be at least 4 digits")) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinCreationState(inputLabel = "PIN must be at least 4 digits"))
fun onEvent(event: PinCreationScreenEvents) { fun onEvent(event: PinCreationScreenEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch { viewModelScope.launch {
applyEvent(state.value, event) applyEvent(state.value, event)
} }

View File

@@ -53,6 +53,7 @@ class PinEntryForRegistrationLockViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true)) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
fun onEvent(event: PinEntryScreenEvents) { fun onEvent(event: PinEntryScreenEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch { viewModelScope.launch {
val stateEmitter: (PinEntryState) -> Unit = { state -> val stateEmitter: (PinEntryState) -> Unit = { state ->
_state.value = state _state.value = state
@@ -140,7 +141,10 @@ class PinEntryForRegistrationLockViewModel(
val (response, keyMaterial) = registerResult.data val (response, keyMaterial) = registerResult.data
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool)) parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
// TODO storage service restore + profile screen // TODO storage service restore + profile screen
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete) when {
response.reregistration -> parentEventEmitter.navigateTo(RegistrationRoute.ChooseRestoreOptionAfterRegistration)
else -> parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
}
state state
} }
is NetworkController.RegistrationNetworkResult.Failure -> { is NetworkController.RegistrationNetworkResult.Failure -> {

View File

@@ -57,6 +57,7 @@ class PinEntryForSmsBypassViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true)) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
fun onEvent(event: PinEntryScreenEvents) { fun onEvent(event: PinEntryScreenEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch { viewModelScope.launch {
val stateEmitter: (PinEntryState) -> Unit = { _state.value = it } val stateEmitter: (PinEntryState) -> Unit = { _state.value = it }
applyEvent(state.value, event, stateEmitter, parentEventEmitter) applyEvent(state.value, event, stateEmitter, parentEventEmitter)

View File

@@ -50,6 +50,7 @@ class PinEntryForSvrRestoreViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true)) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
fun onEvent(event: PinEntryScreenEvents) { fun onEvent(event: PinEntryScreenEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch { viewModelScope.launch {
val stateEmitter: (PinEntryState) -> Unit = { state -> val stateEmitter: (PinEntryState) -> Unit = { state ->
_state.value = state _state.value = state

View File

@@ -39,7 +39,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.SignalIcons
@@ -196,7 +196,7 @@ fun PinEntryScreen(
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun PinEntryScreenPreview() { private fun PinEntryScreenPreview() {
Previews.Preview { Previews.Preview {
@@ -207,7 +207,7 @@ private fun PinEntryScreenPreview() {
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun PinEntryScreenWithErrorPreview() { private fun PinEntryScreenWithErrorPreview() {
Previews.Preview { Previews.Preview {

View File

@@ -5,7 +5,9 @@
package org.signal.registration.screens.pinentry package org.signal.registration.screens.pinentry
sealed class PinEntryScreenEvents { import org.signal.registration.util.DebugLoggableModel
sealed class PinEntryScreenEvents : DebugLoggableModel() {
data class PinEntered(val pin: String) : PinEntryScreenEvents() data class PinEntered(val pin: String) : PinEntryScreenEvents()
data object ToggleKeyboard : PinEntryScreenEvents() data object ToggleKeyboard : PinEntryScreenEvents()
data object NeedHelp : PinEntryScreenEvents() data object NeedHelp : PinEntryScreenEvents()

View File

@@ -5,6 +5,8 @@
package org.signal.registration.screens.pinentry package org.signal.registration.screens.pinentry
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.DebugLoggableModel
import kotlin.time.Duration import kotlin.time.Duration
data class PinEntryState( data class PinEntryState(
@@ -15,14 +17,14 @@ data class PinEntryState(
val mode: Mode = Mode.SvrRestore, val mode: Mode = Mode.SvrRestore,
val oneTimeEvent: OneTimeEvent? = null, val oneTimeEvent: OneTimeEvent? = null,
val e164: String? = null val e164: String? = null
) { ) : DebugLoggableModel() {
enum class Mode { enum class Mode {
RegistrationLock, RegistrationLock,
SmsBypass, SmsBypass,
SvrRestore SvrRestore
} }
sealed interface OneTimeEvent { sealed interface OneTimeEvent : DebugLoggable {
data object NetworkError : OneTimeEvent data object NetworkError : OneTimeEvent
data class RateLimited(val retryAfter: Duration) : OneTimeEvent data class RateLimited(val retryAfter: Duration) : OneTimeEvent
data object SvrDataMissing : OneTimeEvent data object SvrDataMissing : OneTimeEvent

View File

@@ -0,0 +1,15 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.quickrestore
import org.signal.registration.util.DebugLoggableModel
sealed class QuickRestoreQrEvents : DebugLoggableModel() {
data object RetryQrCode : QuickRestoreQrEvents()
data object Cancel : QuickRestoreQrEvents()
data object UseProxy : QuickRestoreQrEvents()
data object DismissError : QuickRestoreQrEvents()
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
package org.signal.registration.screens.restore package org.signal.registration.screens.quickrestore
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -38,7 +38,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.QrCode import org.signal.core.ui.compose.QrCode
import org.signal.core.ui.compose.QrCodeData import org.signal.core.ui.compose.QrCodeData
@@ -49,9 +49,9 @@ import org.signal.core.ui.compose.SignalIcons
* The old device scans this QR code to initiate the transfer. * The old device scans this QR code to initiate the transfer.
*/ */
@Composable @Composable
fun RestoreViaQrScreen( fun QuickRestoreQrScreen(
state: RestoreViaQrState, state: QuickRestoreQrState,
onEvent: (RestoreViaQrScreenEvents) -> Unit, onEvent: (QuickRestoreQrEvents) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
@@ -123,7 +123,7 @@ fun RestoreViaQrScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) { Button(onClick = { onEvent(QuickRestoreQrEvents.RetryQrCode) }) {
Text("Retry") Text("Retry")
} }
} }
@@ -143,7 +143,7 @@ fun RestoreViaQrScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) { Button(onClick = { onEvent(QuickRestoreQrEvents.RetryQrCode) }) {
Text("Retry") Text("Retry")
} }
} }
@@ -178,7 +178,7 @@ fun RestoreViaQrScreen(
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
TextButton( TextButton(
onClick = { onEvent(RestoreViaQrScreenEvents.Cancel) } onClick = { onEvent(QuickRestoreQrEvents.Cancel) }
) { ) {
Text("Cancel") Text("Cancel")
} }
@@ -208,9 +208,9 @@ fun RestoreViaQrScreen(
// Error dialog // Error dialog
if (state.showRegistrationError) { if (state.showRegistrationError) {
AlertDialog( AlertDialog(
onDismissRequest = { onEvent(RestoreViaQrScreenEvents.DismissError) }, onDismissRequest = { onEvent(QuickRestoreQrEvents.DismissError) },
confirmButton = { confirmButton = {
TextButton(onClick = { onEvent(RestoreViaQrScreenEvents.DismissError) }) { TextButton(onClick = { onEvent(QuickRestoreQrEvents.DismissError) }) {
Text("OK") Text("OK")
} }
}, },
@@ -246,23 +246,23 @@ private fun InstructionRow(
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun RestoreViaQrScreenLoadingPreview() { private fun QuickRestoreQrScreenLoadingPreview() {
Previews.Preview { Previews.Preview {
RestoreViaQrScreen( QuickRestoreQrScreen(
state = RestoreViaQrState(qrState = QrState.Loading), state = QuickRestoreQrState(qrState = QrState.Loading),
onEvent = {} onEvent = {}
) )
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun RestoreViaQrScreenLoadedPreview() { private fun QuickRestoreQrScreenLoadedPreview() {
Previews.Preview { Previews.Preview {
RestoreViaQrScreen( QuickRestoreQrScreen(
state = RestoreViaQrState( state = QuickRestoreQrState(
qrState = QrState.Loaded(QrCodeData.forData("sgnl://rereg?uuid=test&pub_key=test", false)) qrState = QrState.Loaded(QrCodeData.forData("sgnl://rereg?uuid=test&pub_key=test", false))
), ),
onEvent = {} onEvent = {}
@@ -270,23 +270,23 @@ private fun RestoreViaQrScreenLoadedPreview() {
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun RestoreViaQrScreenFailedPreview() { private fun QuickRestoreQrScreenFailedPreview() {
Previews.Preview { Previews.Preview {
RestoreViaQrScreen( QuickRestoreQrScreen(
state = RestoreViaQrState(qrState = QrState.Failed), state = QuickRestoreQrState(qrState = QrState.Failed),
onEvent = {} onEvent = {}
) )
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun RestoreViaQrScreenRegisteringPreview() { private fun QuickRestoreQrScreenRegisteringPreview() {
Previews.Preview { Previews.Preview {
RestoreViaQrScreen( QuickRestoreQrScreen(
state = RestoreViaQrState( state = QuickRestoreQrState(
qrState = QrState.Scanned, qrState = QrState.Scanned,
isRegistering = true isRegistering = true
), ),

View File

@@ -3,20 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
package org.signal.registration.screens.restore package org.signal.registration.screens.quickrestore
import org.signal.core.ui.compose.QrCodeData import org.signal.core.ui.compose.QrCodeData
import org.signal.registration.util.DebugLoggableModel
sealed class QrState { data class QuickRestoreQrState(
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 qrState: QrState = QrState.Loading,
val isRegistering: Boolean = false, val isRegistering: Boolean = false,
val showRegistrationError: Boolean = false, val showRegistrationError: Boolean = false,
val errorMessage: String? = null val errorMessage: String? = null
) ) : DebugLoggableModel()
sealed class QrState : DebugLoggableModel() {
data object Loading : QrState()
data class Loaded(val qrCodeData: QrCodeData) : QrState() {
override fun toSafeString(): String {
return "Loaded(qrCodeData=***)"
}
}
data object Scanned : QrState()
data object Failed : QrState()
}

View File

@@ -0,0 +1,201 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.quickrestore
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.QrCodeData
import org.signal.core.util.logging.Log
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
class QuickRestoreQrViewModel(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModel() {
companion object {
private val TAG = Log.tag(QuickRestoreQrViewModel::class)
}
private val _localState = MutableStateFlow(QuickRestoreQrState())
val state: StateFlow<QuickRestoreQrState> = _localState.asStateFlow()
private var provisioningJob: Job? = null
init {
startProvisioning()
}
fun onEvent(event: QuickRestoreQrEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch {
val stateEmitter: (QuickRestoreQrState) -> Unit = { newState ->
_localState.value = newState
}
applyEvent(state.value, event, stateEmitter)
}
}
@VisibleForTesting
suspend fun applyEvent(state: QuickRestoreQrState, event: QuickRestoreQrEvents, stateEmitter: (QuickRestoreQrState) -> Unit) {
val result = when (event) {
is QuickRestoreQrEvents.RetryQrCode -> {
startProvisioning()
state.copy(qrState = QrState.Loading, showRegistrationError = false, errorMessage = null)
}
is QuickRestoreQrEvents.Cancel -> {
parentEventEmitter.navigateBack()
state
}
is QuickRestoreQrEvents.UseProxy -> {
// TODO [registration] - Navigate to proxy settings
state
}
is QuickRestoreQrEvents.DismissError -> {
startProvisioning()
state.copy(showRegistrationError = false, errorMessage = null)
}
}
stateEmitter(result)
}
private fun startProvisioning() {
provisioningJob?.cancel()
provisioningJob = viewModelScope.launch {
repository.startProvisioning().collect { event ->
when (event) {
is NetworkController.ProvisioningEvent.QrCodeReady -> {
Log.d(TAG, "[Provisioning] QR code ready")
_localState.value = _localState.value.copy(
qrState = QrState.Loaded(
qrCodeData = QrCodeData.forData(data = event.url, supportIconOverlay = false)
)
)
}
is NetworkController.ProvisioningEvent.MessageReceived -> {
Log.i(TAG, "[Provisioning] Message received from old device (platform: ${event.message.platform}, tier: ${event.message.tier})")
handleProvisioningMessage(event.message)
}
is NetworkController.ProvisioningEvent.Error -> {
Log.w(TAG, "[Provisioning] Error", event.cause)
_localState.value = _localState.value.copy(qrState = QrState.Failed)
}
}
}
}
}
private suspend fun handleProvisioningMessage(message: NetworkController.ProvisioningMessage) {
if (message.platform == NetworkController.ProvisioningMessage.Platform.IOS && message.tier == null) {
// iOS without a backup tier cannot do a quick restore — navigate to the choose-restore screen
parentEventEmitter.navigateTo(RegistrationRoute.ChooseRestoreOptionBeforeRegistration)
return
}
_localState.value = _localState.value.copy(isRegistering = true, qrState = QrState.Scanned)
val registerResult = repository.registerAccountWithProvisioningData(message)
when (registerResult) {
is NetworkController.RegistrationNetworkResult.Success -> {
val (response, keyMaterial) = registerResult.data
Log.i(TAG, "[Register] Success! reregistration: ${response.reregistration}")
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
parentEventEmitter.navigateTo(RegistrationRoute.ChooseRestoreOptionAfterRegistration)
}
is NetworkController.RegistrationNetworkResult.Failure -> {
when (registerResult.error) {
is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[Register] Rate limited (retryAfter: ${registerResult.error.retryAfter}).")
_localState.value = _localState.value.copy(
isRegistering = false,
showRegistrationError = true,
errorMessage = null
)
}
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
Log.w(TAG, "[Register] Recovery password incorrect: ${registerResult.error.message}")
_localState.value = _localState.value.copy(
isRegistering = false,
showRegistrationError = true,
errorMessage = null
)
}
is NetworkController.RegisterAccountError.RegistrationLock -> {
Log.w(TAG, "[Register] Registration locked.")
parentEventEmitter.navigateTo(
RegistrationRoute.PinEntryForRegistrationLock(
timeRemaining = registerResult.error.data.timeRemaining,
svrCredentials = registerResult.error.data.svr2Credentials
)
)
}
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
Log.w(TAG, "[Register] Session not found or not verified: ${registerResult.error.message}")
_localState.value = _localState.value.copy(
isRegistering = false,
showRegistrationError = true,
errorMessage = null
)
}
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
Log.w(TAG, "[Register] Device transfer possible. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
}
is NetworkController.RegisterAccountError.InvalidRequest -> {
Log.w(TAG, "[Register] Invalid request: ${registerResult.error.message}")
_localState.value = _localState.value.copy(
isRegistering = false,
showRegistrationError = true,
errorMessage = null
)
}
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "[Register] Network error.", registerResult.exception)
_localState.value = _localState.value.copy(
isRegistering = false,
showRegistrationError = true,
errorMessage = null
)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "[Register] Application error.", registerResult.exception)
_localState.value = _localState.value.copy(
isRegistering = false,
showRegistrationError = true,
errorMessage = null
)
}
}
}
override fun onCleared() {
provisioningJob?.cancel()
}
class Factory(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return QuickRestoreQrViewModel(repository, parentEventEmitter) as T
}
}
}

View File

@@ -1,13 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.restore
sealed class RestoreViaQrScreenEvents {
data object RetryQrCode : RestoreViaQrScreenEvents()
data object Cancel : RestoreViaQrScreenEvents()
data object UseProxy : RestoreViaQrScreenEvents()
data object DismissError : RestoreViaQrScreenEvents()
}

View File

@@ -50,7 +50,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.signal.registration.R import org.signal.registration.R
import org.signal.registration.test.TestTags import org.signal.registration.test.TestTags
@@ -438,7 +438,7 @@ private fun DigitField(
) )
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun VerificationCodeScreenPreview() { private fun VerificationCodeScreenPreview() {
Previews.Preview { Previews.Preview {
@@ -451,7 +451,7 @@ private fun VerificationCodeScreenPreview() {
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun VerificationCodeScreenWithCountdownPreview() { private fun VerificationCodeScreenWithCountdownPreview() {
Previews.Preview { Previews.Preview {
@@ -468,7 +468,7 @@ private fun VerificationCodeScreenWithCountdownPreview() {
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun VerificationCodeScreenSubmittingPreview() { private fun VerificationCodeScreenSubmittingPreview() {
Previews.Preview { Previews.Preview {

View File

@@ -5,7 +5,9 @@
package org.signal.registration.screens.verificationcode package org.signal.registration.screens.verificationcode
sealed class VerificationCodeScreenEvents { import org.signal.registration.util.DebugLoggableModel
sealed class VerificationCodeScreenEvents : DebugLoggableModel() {
data class CodeEntered(val code: String) : VerificationCodeScreenEvents() data class CodeEntered(val code: String) : VerificationCodeScreenEvents()
data object WrongNumber : VerificationCodeScreenEvents() data object WrongNumber : VerificationCodeScreenEvents()
data object ResendSms : VerificationCodeScreenEvents() data object ResendSms : VerificationCodeScreenEvents()

View File

@@ -6,6 +6,8 @@
package org.signal.registration.screens.verificationcode package org.signal.registration.screens.verificationcode
import org.signal.registration.NetworkController.SessionMetadata import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.DebugLoggableModel
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -16,8 +18,8 @@ data class VerificationCodeState(
val rateLimits: SmsAndCallRateLimits = SmsAndCallRateLimits(), val rateLimits: SmsAndCallRateLimits = SmsAndCallRateLimits(),
val incorrectCodeAttempts: Int = 0, val incorrectCodeAttempts: Int = 0,
val oneTimeEvent: OneTimeEvent? = null val oneTimeEvent: OneTimeEvent? = null
) { ) : DebugLoggableModel() {
sealed interface OneTimeEvent { sealed interface OneTimeEvent : DebugLoggable {
data object NetworkError : OneTimeEvent data object NetworkError : OneTimeEvent
data object UnknownError : OneTimeEvent data object UnknownError : OneTimeEvent
data class RateLimited(val retryAfter: Duration) : OneTimeEvent data class RateLimited(val retryAfter: Duration) : OneTimeEvent
@@ -50,4 +52,4 @@ data class VerificationCodeState(
data class SmsAndCallRateLimits( data class SmsAndCallRateLimits(
val smsResendTimeRemaining: Duration = 0.seconds, val smsResendTimeRemaining: Duration = 0.seconds,
val callRequestTimeRemaining: Duration = 0.seconds val callRequestTimeRemaining: Duration = 0.seconds
) ) : DebugLoggableModel()

View File

@@ -49,6 +49,7 @@ class VerificationCodeViewModel(
private var nextCallAvailableAt: Duration = 0.seconds private var nextCallAvailableAt: Duration = 0.seconds
fun onEvent(event: VerificationCodeScreenEvents) { fun onEvent(event: VerificationCodeScreenEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch { viewModelScope.launch {
val stateEmitter: (VerificationCodeState) -> Unit = { newState -> val stateEmitter: (VerificationCodeState) -> Unit = { newState ->
_localState.value = newState _localState.value = newState
@@ -179,10 +180,10 @@ class VerificationCodeViewModel(
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool)) parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
if (response.storageCapable) { when {
parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore) // response.reregistration -> parentEventEmitter.navigateTo(RegistrationRoute.ChooseRestoreOptionAfterRegistration)
} else { response.storageCapable -> parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore)
parentEventEmitter.navigateTo(RegistrationRoute.PinCreate) else -> parentEventEmitter.navigateTo(RegistrationRoute.PinCreate)
} }
state state
} }

View File

@@ -46,7 +46,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.signal.core.ui.compose.BottomSheets import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.dismissWithAnimation import org.signal.core.ui.compose.dismissWithAnimation
@@ -255,7 +255,7 @@ private fun RestoreActionRow(
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun WelcomeScreenPreview() { private fun WelcomeScreenPreview() {
Previews.Preview { Previews.Preview {
@@ -263,7 +263,7 @@ private fun WelcomeScreenPreview() {
} }
} }
@DayNightPreviews @AllDevicePreviews
@Composable @Composable
private fun RestoreOrTransferBottomSheetPreview() { private fun RestoreOrTransferBottomSheetPreview() {
Previews.BottomSheetPreview { Previews.BottomSheetPreview {

View File

@@ -5,7 +5,9 @@
package org.signal.registration.screens.welcome package org.signal.registration.screens.welcome
sealed class WelcomeScreenEvents { import org.signal.registration.util.DebugLoggableModel
sealed class WelcomeScreenEvents : DebugLoggableModel() {
data object Continue : WelcomeScreenEvents() data object Continue : WelcomeScreenEvents()
data object HasOldPhone : WelcomeScreenEvents() data object HasOldPhone : WelcomeScreenEvents()
data object DoesNotHaveOldPhone : WelcomeScreenEvents() data object DoesNotHaveOldPhone : WelcomeScreenEvents()

View File

@@ -0,0 +1,14 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.util
/**
* Interface for objects that can provide a debug-friendly string representation.
*/
interface DebugLoggable {
fun toDebugString(): String = toString()
fun toSafeString(): String = toString()
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.util
import org.signal.registration.BuildConfig
open class DebugLoggableModel : DebugLoggable {
override fun toString(): String {
return if (BuildConfig.DEBUG) {
toDebugString()
} else {
toSafeString()
}
}
}