diff --git a/core/util-jvm/src/main/java/org/signal/core/util/StringExtensions.kt b/core/util-jvm/src/main/java/org/signal/core/util/StringExtensions.kt index 32e7dd928a..d62ba56eac 100644 --- a/core/util-jvm/src/main/java/org/signal/core/util/StringExtensions.kt +++ b/core/util-jvm/src/main/java/org/signal/core/util/StringExtensions.kt @@ -119,3 +119,11 @@ fun String.splitByByteLength(byteLength: Int): Pair { val remainder = this.substring(firstPart.length) 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 }) +} diff --git a/demo/registration/build.gradle.kts b/demo/registration/build.gradle.kts index 7bc8cabf47..4be392c5f2 100644 --- a/demo/registration/build.gradle.kts +++ b/demo/registration/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { implementation(project(":core:util")) implementation(project(":core:models-jvm")) implementation(project(":lib:libsignal-service")) + implementation(project(":lib:qr")) // libsignal-protocol for PreKeyCollection types implementation(libs.libsignal.client) diff --git a/demo/registration/src/main/AndroidManifest.xml b/demo/registration/src/main/AndroidManifest.xml index 45a07e2054..37d9f43c48 100644 --- a/demo/registration/src/main/AndroidManifest.xml +++ b/demo/registration/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + { + val viewModel: TransferAccountViewModel = viewModel( + factory = TransferAccountViewModel.Factory( + onBack = { backStack.removeLastOrNull() } + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + TransferAccountScreen( + state = state, + onEvent = { viewModel.onEvent(it) } + ) + } + entry( metadata = BottomSheetTransitionSpec ) { diff --git a/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt b/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt index 68f352162a..5b7299278a 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt @@ -35,6 +35,9 @@ class RegistrationApplication : Application() { companion object { // Staging SVR2 mrEnclave value private const val SVR2_MRENCLAVE = "97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535" + + lateinit var serviceConfiguration: SignalServiceConfiguration + private set } override fun onCreate() { @@ -46,6 +49,7 @@ class RegistrationApplication : Application() { val trustStore = SampleTrustStore() val configuration = createServiceConfiguration(trustStore) + serviceConfiguration = configuration val pushServiceSocket = createPushServiceSocket(configuration) val demoNetworkController = DemoNetworkController(this, pushServiceSocket, configuration, SVR2_MRENCLAVE) val networkController = DebugNetworkController(demoNetworkController) diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt index 84f21a7e50..d3a8d59d3a 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt @@ -5,6 +5,7 @@ package org.signal.registration.sample.debug +import kotlinx.coroutines.flow.Flow import org.signal.core.models.MasterKey import org.signal.core.util.logging.Log 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.MasterKeyResponse import org.signal.registration.NetworkController.PreKeyCollection +import org.signal.registration.NetworkController.ProvisioningEvent import org.signal.registration.NetworkController.RegisterAccountError import org.signal.registration.NetworkController.RegisterAccountResponse import org.signal.registration.NetworkController.RegistrationNetworkResult @@ -197,6 +199,10 @@ class DebugNetworkController( return delegate.getSvrCredentials() } + override fun startProvisioning(): Flow { + return delegate.startProvisioning() + } + override suspend fun checkSvrCredentials( e164: String, credentials: List diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt index afb96cfee5..2e411be41e 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt @@ -6,6 +6,12 @@ package org.signal.registration.sample.dependencies 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.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -14,6 +20,9 @@ import okhttp3.Response import org.signal.core.models.MasterKey import org.signal.core.util.logging.Log 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.registration.NetworkController 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.GetSessionStatusError 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.RegisterAccountResponse 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.UpdateSessionError 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.PushChallengeReceiver 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.RestoreResponse 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.WebSocketFactory 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.PushServiceSocket import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider @@ -368,6 +382,88 @@ class DemoNetworkController( return "https://signalcaptchas.org/staging/registration/generate.html" } + override fun startProvisioning(): Flow = callbackFlow { + val socketHandles = mutableListOf() + + fun startSocket() { + val handle = ProvisioningSocket.start( + 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( svrCredentials: NetworkController.SvrCredentials, pin: String diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt index aa3434ba84..f02c9358e0 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt @@ -124,6 +124,10 @@ class DemoStorageController(context: Context) : StorageController { RegistrationPreferences.pinAlphanumeric = isAlphanumeric } + override suspend fun saveProvisioningData(provisioningMessage: NetworkController.ProvisioningMessage) = withContext(Dispatchers.IO) { + RegistrationPreferences.saveProvisioningData(provisioningMessage) + } + private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) { // Clear existing data RegistrationPreferences.clearKeyMaterial() diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt index 2a460ee064..cec539d041 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt @@ -5,13 +5,16 @@ package org.signal.registration.sample.screens.main +import androidx.compose.foundation.background 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.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -93,6 +96,26 @@ fun MainScreen( } 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) Spacer(modifier = Modifier.height(24.dp)) @@ -104,6 +127,13 @@ fun MainScreen( Text("Re-register") } + OutlinedButton( + onClick = { onEvent(MainScreenEvents.TransferAccount) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Transfer to New Device") + } + OutlinedButton( onClick = { onEvent(MainScreenEvents.OpenPinSettings) }, modifier = Modifier.fillMaxWidth() diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenEvents.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenEvents.kt index 3bb7243ba1..555ec9e6dd 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenEvents.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenEvents.kt @@ -7,6 +7,7 @@ package org.signal.registration.sample.screens.main sealed interface MainScreenEvents { data object LaunchRegistration : MainScreenEvents + data object TransferAccount : MainScreenEvents data object OpenPinSettings : MainScreenEvents data object ClearAllData : MainScreenEvents } diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt index 78fe6fa9b3..5668cec604 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt @@ -6,7 +6,8 @@ package org.signal.registration.sample.screens.main data class MainScreenState( - val existingRegistrationState: ExistingRegistrationState? = null + val existingRegistrationState: ExistingRegistrationState? = null, + val registrationExpired: Boolean = false ) { data class ExistingRegistrationState( val phoneNumber: String, diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt index 7aac3bea41..8003bfb198 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt @@ -13,15 +13,23 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch 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.sample.storage.RegistrationPreferences class MainScreenViewModel( private val storageController: StorageController, + private val networkController: NetworkController, private val onLaunchRegistration: () -> Unit, + private val onTransferAccount: () -> Unit, private val onOpenPinSettings: () -> Unit ) : ViewModel() { + companion object { + private val TAG = Log.tag(MainScreenViewModel::class) + } + private val _state = MutableStateFlow(MainScreenState()) val state: StateFlow = _state.asStateFlow() @@ -37,6 +45,7 @@ class MainScreenViewModel( viewModelScope.launch { when (event) { MainScreenEvents.LaunchRegistration -> onLaunchRegistration() + MainScreenEvents.TransferAccount -> onTransferAccount() MainScreenEvents.OpenPinSettings -> onOpenPinSettings() MainScreenEvents.ClearAllData -> { storageController.clearAllData() @@ -65,18 +74,51 @@ class MainScreenViewModel( ) } else { 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( private val storageController: StorageController, + private val networkController: NetworkController, private val onLaunchRegistration: () -> Unit, + private val onTransferAccount: () -> Unit, private val onOpenPinSettings: () -> Unit ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return MainScreenViewModel(storageController, onLaunchRegistration, onOpenPinSettings) as T + return MainScreenViewModel(storageController, networkController, onLaunchRegistration, onTransferAccount, onOpenPinSettings) as T } } } diff --git a/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt b/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt index f831ccc8f5..b825690b9f 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt @@ -46,6 +46,12 @@ object RegistrationPreferences { private const val KEY_PIN_ALPHANUMERIC = "pin_alphanumeric" private const val KEY_PINS_OPTED_OUT = "pins_opted_out" 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) { 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() { prefs.edit { remove(KEY_PROFILE_KEY) diff --git a/feature/registration/build.gradle.kts b/feature/registration/build.gradle.kts index 7fe7b25d94..32c84ffb53 100644 --- a/feature/registration/build.gradle.kts +++ b/feature/registration/build.gradle.kts @@ -10,6 +10,7 @@ android { buildFeatures { compose = true + buildConfig = true } testOptions { diff --git a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt index 987696f569..8aefe5029a 100644 --- a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt +++ b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt @@ -6,12 +6,14 @@ package org.signal.registration import android.os.Parcelable +import kotlinx.coroutines.flow.Flow 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.IdentityKeyPair import org.signal.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord import java.io.IOException @@ -179,6 +181,19 @@ interface NetworkController { */ suspend fun setAccountAttributes(attributes: AccountAttributes): RegistrationNetworkResult + /** + * 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 + // /** // * Set [RestoreMethod] enum on the server for use by the old device to update UX. // */ @@ -431,4 +446,38 @@ interface NetworkController { enum class VerificationCodeTransport { 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 + } } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt index 4110230970..82d4a44f83 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt @@ -7,8 +7,9 @@ package org.signal.registration import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey +import org.signal.registration.util.DebugLoggable -sealed interface RegistrationFlowEvent { +sealed interface RegistrationFlowEvent : DebugLoggable { /** Navigate to a specific screen. */ data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt index 633021380e..4f9f6ddec7 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt @@ -11,6 +11,7 @@ import kotlinx.parcelize.TypeParceler import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey import org.signal.registration.util.AccountEntropyPoolParceler +import org.signal.registration.util.DebugLoggable import org.signal.registration.util.MasterKeyParceler @Parcelize @@ -37,4 +38,4 @@ data class RegistrationFlowState( /** If true, do not attempt any flows where we generate RRP's. Create a session instead. */ val doNotAttemptRecoveryPassword: Boolean = false -) : Parcelable +) : Parcelable, DebugLoggable diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index 3be4b22b01..e3279e284c 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -30,6 +30,9 @@ import org.signal.core.ui.navigation.TransitionSpecs import org.signal.registration.screens.accountlocked.AccountLockedScreen import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents 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.CaptchaScreenEvents 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.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.quickrestore.QuickRestoreQrScreen +import org.signal.registration.screens.quickrestore.QuickRestoreQrViewModel import org.signal.registration.screens.util.navigateBack import org.signal.registration.screens.util.navigateTo import org.signal.registration.screens.verificationcode.VerificationCodeScreen @@ -99,6 +101,10 @@ sealed interface RegistrationRoute : NavKey, Parcelable { @Serializable data object PinCreate : RegistrationRoute + // TODO [regV5] Uncomment when restore selection flow is ready + // @Serializable + // data object ArchiveRestoreSelection : RegistrationRoute + @Serializable data object ChooseRestoreOptionBeforeRegistration : RegistrationRoute @@ -398,29 +404,39 @@ private fun EntryProviderScope.navigationEntries( ) } + // TODO [regV5] Uncomment when restore selection flow is ready + // entry { + // 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 { // TODO: Implement RestoreScreen } entry { - 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 - } - } - } + val viewModel: QuickRestoreQrViewModel = viewModel( + factory = QuickRestoreQrViewModel.Factory( + repository = registrationRepository, + parentEventEmitter = registrationViewModel::onEvent + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + QuickRestoreQrScreen( + state = state, + onEvent = { viewModel.onEvent(it) } ) } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index 102e11e852..a513a82fd8 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -8,15 +8,19 @@ package org.signal.registration import android.app.backup.BackupManager import android.content.Context import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext +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.core.util.logging.Log +import org.signal.libsignal.protocol.IdentityKeyPair 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.ProvisioningEvent import org.signal.registration.NetworkController.RegisterAccountError import org.signal.registration.NetworkController.RegisterAccountResponse import org.signal.registration.NetworkController.RegistrationNetworkResult @@ -155,7 +159,16 @@ class RegistrationRepository(val context: Context, val networkController: Networ skipDeviceTransfer: Boolean = true, preExistingRegistrationData: PreExistingRegistrationData? = null ): RegistrationNetworkResult, 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) } + /** + * Starts a provisioning session for QR-based quick restore. + * See [NetworkController.startProvisioning]. + */ + fun startProvisioning(): Flow { + 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, 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. * @@ -205,17 +254,19 @@ class RegistrationRepository(val context: Context, val networkController: Networ recoveryPassword: String?, registrationLock: String? = null, skipDeviceTransfer: Boolean = true, - preExistingRegistrationData: PreExistingRegistrationData? = null + existingAccountEntropyPool: AccountEntropyPool? = null, + existingAciIdentityKeyPair: IdentityKeyPair? = null, + existingPniIdentityKeyPair: IdentityKeyPair? = null ): RegistrationNetworkResult, 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, 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( - existingAccountEntropyPool = preExistingRegistrationData?.aep, - existingAciIdentityKeyPair = preExistingRegistrationData?.aciIdentityKeyPair, - existingPniIdentityKeyPair = preExistingRegistrationData?.pniIdentityKeyPair + existingAccountEntropyPool = existingAccountEntropyPool, + existingAciIdentityKeyPair = existingAciIdentityKeyPair, + existingPniIdentityKeyPair = existingPniIdentityKeyPair ) val fcmToken = networkController.getFcmToken() diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt index 099246d426..5d3498bc3a 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt @@ -45,6 +45,7 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save } fun onEvent(event: RegistrationFlowEvent) { + Log.d(TAG, "[Event] $event") _state.value = applyEvent(_state.value, event) } diff --git a/feature/registration/src/main/java/org/signal/registration/StorageController.kt b/feature/registration/src/main/java/org/signal/registration/StorageController.kt index 7ba35b101b..852cfa2113 100644 --- a/feature/registration/src/main/java/org/signal/registration/StorageController.kt +++ b/feature/registration/src/main/java/org/signal/registration/StorageController.kt @@ -85,6 +85,15 @@ interface StorageController { */ 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. */ diff --git a/feature/registration/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreen.kt index 12b5bc44ed..846e355518 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreen.kt @@ -21,7 +21,7 @@ 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.AllDevicePreviews import org.signal.core.ui.compose.Previews /** @@ -86,7 +86,7 @@ fun AccountLockedScreen( } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun AccountLockedScreenPreview() { Previews.Preview { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreenEvents.kt index 3be0b6b690..7c19757dfd 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedScreenEvents.kt @@ -5,7 +5,9 @@ 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 LearnMore : AccountLockedScreenEvents() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedState.kt b/feature/registration/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedState.kt index adfcd66d7b..01492e73d1 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/accountlocked/AccountLockedState.kt @@ -5,6 +5,8 @@ package org.signal.registration.screens.accountlocked +import org.signal.registration.util.DebugLoggableModel + data class AccountLockedState( val daysRemaining: Int = 10 -) +) : DebugLoggableModel() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/captcha/CaptchaScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/captcha/CaptchaScreen.kt index 268132a4d1..d966bb3005 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/captcha/CaptchaScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/captcha/CaptchaScreen.kt @@ -28,7 +28,7 @@ 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.AllDevicePreviews import org.signal.core.ui.compose.Previews /** @@ -133,7 +133,7 @@ fun CaptchaScreen( } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun CaptchaScreenLoadingPreview() { Previews.Preview { @@ -147,7 +147,7 @@ private fun CaptchaScreenLoadingPreview() { } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun CaptchaScreenErrorPreview() { Previews.Preview { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/captcha/CaptchaScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/captcha/CaptchaScreenEvents.kt index 407f27486f..9c82f53d44 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/captcha/CaptchaScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/captcha/CaptchaScreenEvents.kt @@ -5,7 +5,14 @@ package org.signal.registration.screens.captcha -sealed class CaptchaScreenEvents { - data class CaptchaCompleted(val token: String) : CaptchaScreenEvents() +import org.signal.core.util.censor +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() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/captcha/CaptchaState.kt b/feature/registration/src/main/java/org/signal/registration/screens/captcha/CaptchaState.kt index 690c107279..6969a781c0 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/captcha/CaptchaState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/captcha/CaptchaState.kt @@ -5,7 +5,9 @@ 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 Loaded : CaptchaLoadState() data object Error : CaptchaLoadState() @@ -15,4 +17,4 @@ data class CaptchaState( val captchaUrl: String, val captchaScheme: String = "signalcaptcha://", val loadState: CaptchaLoadState = CaptchaLoadState.Loading -) +) : DebugLoggableModel() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreen.kt index 5e52b9248c..7833d19c38 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreen.kt @@ -49,7 +49,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp 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.IconButtons.IconButton import org.signal.core.ui.compose.LargeFontPreviews @@ -284,7 +284,7 @@ private fun SearchBar( ) } -@DayNightPreviews +@AllDevicePreviews @Composable private fun ScreenPreview() { Previews.Preview { @@ -305,7 +305,7 @@ private fun ScreenPreview() { } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun LoadingScreenPreview() { Previews.Preview { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreenEvents.kt index 6d370f54f3..634572edb5 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreenEvents.kt @@ -5,8 +5,10 @@ package org.signal.registration.screens.countrycode -sealed interface CountryCodePickerScreenEvents { - data class Search(val query: String) : CountryCodePickerScreenEvents - data class CountrySelected(val country: Country) : CountryCodePickerScreenEvents - data object Dismissed : CountryCodePickerScreenEvents +import org.signal.registration.util.DebugLoggableModel + +sealed class CountryCodePickerScreenEvents : DebugLoggableModel() { + data class Search(val query: String) : CountryCodePickerScreenEvents() + data class CountrySelected(val country: Country) : CountryCodePickerScreenEvents() + data object Dismissed : CountryCodePickerScreenEvents() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerViewModel.kt index cf67af0093..0ca9b9dbb1 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.signal.core.ui.navigation.ResultEventBus +import org.signal.core.util.logging.Log import org.signal.registration.RegistrationFlowEvent import org.signal.registration.screens.util.navigateBack @@ -29,6 +30,10 @@ class CountryCodePickerViewModel( initialCountry: Country? = null ) : ViewModel() { + companion object { + private val TAG = Log.tag(CountryCodePickerViewModel::class) + } + private val _state = MutableStateFlow(CountryCodeState()) val state: StateFlow = _state.asStateFlow() @@ -37,6 +42,7 @@ class CountryCodePickerViewModel( } fun onEvent(event: CountryCodePickerScreenEvents) { + Log.d(TAG, "[Event] $event") when (event) { is CountryCodePickerScreenEvents.Search -> applySearchEvent(event.query) is CountryCodePickerScreenEvents.CountrySelected -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodeState.kt b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodeState.kt index 8063dbb26f..033161daa1 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodeState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodeState.kt @@ -5,6 +5,8 @@ package org.signal.registration.screens.countrycode +import org.signal.registration.util.DebugLoggableModel + /** * State managed by [CountryCodePickerViewModel]. Includes country list and allows for searching */ @@ -14,4 +16,4 @@ data class CountryCodeState( val commonCountryList: List = emptyList(), val filteredList: List = emptyList(), val startingIndex: Int = 0 -) +) : DebugLoggableModel() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt index f9ea114734..0ea837d79c 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState 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.horizontalGutters import org.signal.registration.R @@ -207,7 +207,7 @@ private fun PermissionRow( } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun PermissionsScreenPreview() { Previews.Preview { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt index aa8cdf7d99..c632c195cb 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt @@ -48,7 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.signal.core.ui.compose.Buttons 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.Previews import org.signal.registration.R @@ -359,7 +359,7 @@ private fun DropdownTriangle( } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun PhoneNumberScreenPreview() { Previews.Preview { @@ -370,7 +370,7 @@ private fun PhoneNumberScreenPreview() { } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun PhoneNumberScreenSpinnerPreview() { Previews.Preview { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt index 26caae414f..835fd05292 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt @@ -5,12 +5,18 @@ package org.signal.registration.screens.phonenumber -sealed interface PhoneNumberEntryScreenEvents { - data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents - data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents - data class CountrySelected(val countryCode: Int, val regionCode: String, val countryName: String, val countryEmoji: String) : PhoneNumberEntryScreenEvents - data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents - data object CountryPicker : PhoneNumberEntryScreenEvents - data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents - data object ConsumeOneTimeEvent : PhoneNumberEntryScreenEvents +import org.signal.registration.util.DebugLoggableModel + +sealed class PhoneNumberEntryScreenEvents : DebugLoggableModel() { + data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents() + data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents() + data class CountrySelected(val countryCode: Int, val regionCode: String, val countryName: String, val countryEmoji: String) : PhoneNumberEntryScreenEvents() + data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents() + data object CountryPicker : PhoneNumberEntryScreenEvents() + data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents() { + override fun toSafeString(): String { + return "CaptchaCompleted(token=***)" + } + } + data object ConsumeOneTimeEvent : PhoneNumberEntryScreenEvents() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt index 3a7e30821c..163a5761c0 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt @@ -8,6 +8,8 @@ package org.signal.registration.screens.phonenumber import org.signal.registration.NetworkController import org.signal.registration.NetworkController.SessionMetadata import org.signal.registration.PreExistingRegistrationData +import org.signal.registration.util.DebugLoggable +import org.signal.registration.util.DebugLoggableModel import kotlin.time.Duration data class PhoneNumberEntryState( @@ -23,8 +25,8 @@ data class PhoneNumberEntryState( val oneTimeEvent: OneTimeEvent? = null, val preExistingRegistrationData: PreExistingRegistrationData? = null, val restoredSvrCredentials: List = emptyList() -) { - sealed interface OneTimeEvent { +) : DebugLoggableModel() { + sealed interface OneTimeEvent : DebugLoggable { data object NetworkError : OneTimeEvent data object UnknownError : OneTimeEvent data class RateLimited(val retryAfter: Duration) : OneTimeEvent diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt index 368130cac1..a54ba0435a 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt @@ -57,6 +57,7 @@ class PhoneNumberEntryViewModel( } fun onEvent(event: PhoneNumberEntryScreenEvents) { + Log.d(TAG, "[Event] $event") viewModelScope.launch { val stateEmitter: (PhoneNumberEntryState) -> Unit = { state -> _state.value = state diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt index e740566c48..39a323f293 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt @@ -42,7 +42,7 @@ 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.AllDevicePreviews import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.SignalIcons @@ -180,7 +180,7 @@ fun PinCreationScreen( } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun PinCreationScreenPreview() { Previews.Preview { @@ -193,7 +193,7 @@ private fun PinCreationScreenPreview() { } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun PinCreationScreenAlphanumericPreview() { Previews.Preview { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt index 563df9f183..057537d8ea 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt @@ -5,7 +5,9 @@ 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 object ToggleKeyboard : PinCreationScreenEvents() data object LearnMore : PinCreationScreenEvents() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt index 347b358d4f..d90ae0ad7a 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt @@ -6,10 +6,13 @@ package org.signal.registration.screens.pincreation 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( val isAlphanumericKeyboard: Boolean = false, val inputLabel: String? = null, val isConfirmEnabled: Boolean = false, val accountEntropyPool: AccountEntropyPool? = null -) +) : DebugLoggableModel() \ No newline at end of file diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt index 85b5c49d09..05fc991da5 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt @@ -51,6 +51,7 @@ class PinCreationViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinCreationState(inputLabel = "PIN must be at least 4 digits")) fun onEvent(event: PinCreationScreenEvents) { + Log.d(TAG, "[Event] $event") viewModelScope.launch { applyEvent(state.value, event) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt index 86e60250a5..00048b63c6 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt @@ -53,6 +53,7 @@ class PinEntryForRegistrationLockViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true)) fun onEvent(event: PinEntryScreenEvents) { + Log.d(TAG, "[Event] $event") viewModelScope.launch { val stateEmitter: (PinEntryState) -> Unit = { state -> _state.value = state @@ -140,7 +141,10 @@ class PinEntryForRegistrationLockViewModel( val (response, keyMaterial) = registerResult.data parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool)) // TODO storage service restore + profile screen - parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete) + when { + response.reregistration -> parentEventEmitter.navigateTo(RegistrationRoute.ChooseRestoreOptionAfterRegistration) + else -> parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete) + } state } is NetworkController.RegistrationNetworkResult.Failure -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt index 3bab0d2655..d205b9aa4d 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt @@ -57,6 +57,7 @@ class PinEntryForSmsBypassViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true)) fun onEvent(event: PinEntryScreenEvents) { + Log.d(TAG, "[Event] $event") viewModelScope.launch { val stateEmitter: (PinEntryState) -> Unit = { _state.value = it } applyEvent(state.value, event, stateEmitter, parentEventEmitter) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt index bf5862012c..962297c3d7 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt @@ -50,6 +50,7 @@ class PinEntryForSvrRestoreViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true)) fun onEvent(event: PinEntryScreenEvents) { + Log.d(TAG, "[Event] $event") viewModelScope.launch { val stateEmitter: (PinEntryState) -> Unit = { state -> _state.value = state diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt index bf4ec3d67f..4917786ae5 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt @@ -39,7 +39,7 @@ 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.AllDevicePreviews import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.SignalIcons @@ -196,7 +196,7 @@ fun PinEntryScreen( } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun PinEntryScreenPreview() { Previews.Preview { @@ -207,7 +207,7 @@ private fun PinEntryScreenPreview() { } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun PinEntryScreenWithErrorPreview() { Previews.Preview { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEvents.kt index b95032474b..b2ef3a3fb6 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEvents.kt @@ -5,7 +5,9 @@ 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 object ToggleKeyboard : PinEntryScreenEvents() data object NeedHelp : PinEntryScreenEvents() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt index 5a7d9ab027..da00cf3528 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt @@ -5,6 +5,8 @@ package org.signal.registration.screens.pinentry +import org.signal.registration.util.DebugLoggable +import org.signal.registration.util.DebugLoggableModel import kotlin.time.Duration data class PinEntryState( @@ -15,14 +17,14 @@ data class PinEntryState( val mode: Mode = Mode.SvrRestore, val oneTimeEvent: OneTimeEvent? = null, val e164: String? = null -) { +) : DebugLoggableModel() { enum class Mode { RegistrationLock, SmsBypass, SvrRestore } - sealed interface OneTimeEvent { + sealed interface OneTimeEvent : DebugLoggable { data object NetworkError : OneTimeEvent data class RateLimited(val retryAfter: Duration) : OneTimeEvent data object SvrDataMissing : OneTimeEvent diff --git a/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrEvents.kt new file mode 100644 index 0000000000..5cd90b39c6 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restore/RestoreViaQrScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrScreen.kt similarity index 86% rename from feature/registration/src/main/java/org/signal/registration/screens/restore/RestoreViaQrScreen.kt rename to feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrScreen.kt index b40c03a69c..2e8f206284 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/restore/RestoreViaQrScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrScreen.kt @@ -3,7 +3,7 @@ * 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.foundation.background @@ -38,7 +38,7 @@ 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.AllDevicePreviews import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.QrCode 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. */ @Composable -fun RestoreViaQrScreen( - state: RestoreViaQrState, - onEvent: (RestoreViaQrScreenEvents) -> Unit, +fun QuickRestoreQrScreen( + state: QuickRestoreQrState, + onEvent: (QuickRestoreQrEvents) -> Unit, modifier: Modifier = Modifier ) { val scrollState = rememberScrollState() @@ -123,7 +123,7 @@ fun RestoreViaQrScreen( Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) { + Button(onClick = { onEvent(QuickRestoreQrEvents.RetryQrCode) }) { Text("Retry") } } @@ -143,7 +143,7 @@ fun RestoreViaQrScreen( Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) { + Button(onClick = { onEvent(QuickRestoreQrEvents.RetryQrCode) }) { Text("Retry") } } @@ -178,7 +178,7 @@ fun RestoreViaQrScreen( Spacer(modifier = Modifier.weight(1f)) TextButton( - onClick = { onEvent(RestoreViaQrScreenEvents.Cancel) } + onClick = { onEvent(QuickRestoreQrEvents.Cancel) } ) { Text("Cancel") } @@ -208,9 +208,9 @@ fun RestoreViaQrScreen( // Error dialog if (state.showRegistrationError) { AlertDialog( - onDismissRequest = { onEvent(RestoreViaQrScreenEvents.DismissError) }, + onDismissRequest = { onEvent(QuickRestoreQrEvents.DismissError) }, confirmButton = { - TextButton(onClick = { onEvent(RestoreViaQrScreenEvents.DismissError) }) { + TextButton(onClick = { onEvent(QuickRestoreQrEvents.DismissError) }) { Text("OK") } }, @@ -246,23 +246,23 @@ private fun InstructionRow( } } -@DayNightPreviews +@AllDevicePreviews @Composable -private fun RestoreViaQrScreenLoadingPreview() { +private fun QuickRestoreQrScreenLoadingPreview() { Previews.Preview { - RestoreViaQrScreen( - state = RestoreViaQrState(qrState = QrState.Loading), + QuickRestoreQrScreen( + state = QuickRestoreQrState(qrState = QrState.Loading), onEvent = {} ) } } -@DayNightPreviews +@AllDevicePreviews @Composable -private fun RestoreViaQrScreenLoadedPreview() { +private fun QuickRestoreQrScreenLoadedPreview() { Previews.Preview { - RestoreViaQrScreen( - state = RestoreViaQrState( + QuickRestoreQrScreen( + state = QuickRestoreQrState( qrState = QrState.Loaded(QrCodeData.forData("sgnl://rereg?uuid=test&pub_key=test", false)) ), onEvent = {} @@ -270,23 +270,23 @@ private fun RestoreViaQrScreenLoadedPreview() { } } -@DayNightPreviews +@AllDevicePreviews @Composable -private fun RestoreViaQrScreenFailedPreview() { +private fun QuickRestoreQrScreenFailedPreview() { Previews.Preview { - RestoreViaQrScreen( - state = RestoreViaQrState(qrState = QrState.Failed), + QuickRestoreQrScreen( + state = QuickRestoreQrState(qrState = QrState.Failed), onEvent = {} ) } } -@DayNightPreviews +@AllDevicePreviews @Composable -private fun RestoreViaQrScreenRegisteringPreview() { +private fun QuickRestoreQrScreenRegisteringPreview() { Previews.Preview { - RestoreViaQrScreen( - state = RestoreViaQrState( + QuickRestoreQrScreen( + state = QuickRestoreQrState( qrState = QrState.Scanned, isRegistering = true ), diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restore/RestoreViaQrState.kt b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrState.kt similarity index 52% rename from feature/registration/src/main/java/org/signal/registration/screens/restore/RestoreViaQrState.kt rename to feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrState.kt index 7b483d6673..7b41fcaeae 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/restore/RestoreViaQrState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrState.kt @@ -3,20 +3,26 @@ * 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.registration.util.DebugLoggableModel -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( +data class QuickRestoreQrState( val qrState: QrState = QrState.Loading, val isRegistering: Boolean = false, val showRegistrationError: Boolean = false, 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() +} + diff --git a/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt new file mode 100644 index 0000000000..8a1e6f1dc9 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt @@ -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 = _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 create(modelClass: Class): T { + return QuickRestoreQrViewModel(repository, parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restore/RestoreViaQrScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/restore/RestoreViaQrScreenEvents.kt deleted file mode 100644 index 5f065590b7..0000000000 --- a/feature/registration/src/main/java/org/signal/registration/screens/restore/RestoreViaQrScreenEvents.kt +++ /dev/null @@ -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() -} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt index bb01b89d9d..dca35d717c 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt @@ -50,7 +50,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp 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.registration.R import org.signal.registration.test.TestTags @@ -438,7 +438,7 @@ private fun DigitField( ) } -@DayNightPreviews +@AllDevicePreviews @Composable private fun VerificationCodeScreenPreview() { Previews.Preview { @@ -451,7 +451,7 @@ private fun VerificationCodeScreenPreview() { } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun VerificationCodeScreenWithCountdownPreview() { Previews.Preview { @@ -468,7 +468,7 @@ private fun VerificationCodeScreenWithCountdownPreview() { } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun VerificationCodeScreenSubmittingPreview() { Previews.Preview { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt index 5ed7f8955c..7449596ebc 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt @@ -5,7 +5,9 @@ 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 object WrongNumber : VerificationCodeScreenEvents() data object ResendSms : VerificationCodeScreenEvents() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt index 20aba7eebe..f1d0c915d6 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt @@ -6,6 +6,8 @@ package org.signal.registration.screens.verificationcode 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.Companion.seconds @@ -16,8 +18,8 @@ data class VerificationCodeState( val rateLimits: SmsAndCallRateLimits = SmsAndCallRateLimits(), val incorrectCodeAttempts: Int = 0, val oneTimeEvent: OneTimeEvent? = null -) { - sealed interface OneTimeEvent { +) : DebugLoggableModel() { + sealed interface OneTimeEvent : DebugLoggable { data object NetworkError : OneTimeEvent data object UnknownError : OneTimeEvent data class RateLimited(val retryAfter: Duration) : OneTimeEvent @@ -50,4 +52,4 @@ data class VerificationCodeState( data class SmsAndCallRateLimits( val smsResendTimeRemaining: Duration = 0.seconds, val callRequestTimeRemaining: Duration = 0.seconds -) +) : DebugLoggableModel() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt index 7f4ce51747..32609549d6 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt @@ -49,6 +49,7 @@ class VerificationCodeViewModel( private var nextCallAvailableAt: Duration = 0.seconds fun onEvent(event: VerificationCodeScreenEvents) { + Log.d(TAG, "[Event] $event") viewModelScope.launch { val stateEmitter: (VerificationCodeState) -> Unit = { newState -> _localState.value = newState @@ -179,10 +180,10 @@ class VerificationCodeViewModel( parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool)) - if (response.storageCapable) { - parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore) - } else { - parentEventEmitter.navigateTo(RegistrationRoute.PinCreate) + when { +// response.reregistration -> parentEventEmitter.navigateTo(RegistrationRoute.ChooseRestoreOptionAfterRegistration) + response.storageCapable -> parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore) + else -> parentEventEmitter.navigateTo(RegistrationRoute.PinCreate) } state } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt index 0598ec5116..ef110348d7 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt @@ -46,7 +46,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import org.signal.core.ui.compose.BottomSheets 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.SignalIcons import org.signal.core.ui.compose.dismissWithAnimation @@ -255,7 +255,7 @@ private fun RestoreActionRow( } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun WelcomeScreenPreview() { Previews.Preview { @@ -263,7 +263,7 @@ private fun WelcomeScreenPreview() { } } -@DayNightPreviews +@AllDevicePreviews @Composable private fun RestoreOrTransferBottomSheetPreview() { Previews.BottomSheetPreview { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenEvents.kt index 32ec21bf46..d091c7bffd 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenEvents.kt @@ -5,7 +5,9 @@ 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 HasOldPhone : WelcomeScreenEvents() data object DoesNotHaveOldPhone : WelcomeScreenEvents() diff --git a/feature/registration/src/main/java/org/signal/registration/util/DebugLoggable.kt b/feature/registration/src/main/java/org/signal/registration/util/DebugLoggable.kt new file mode 100644 index 0000000000..f07f0a9cbd --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/util/DebugLoggable.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/util/DebugLoggableModel.kt b/feature/registration/src/main/java/org/signal/registration/util/DebugLoggableModel.kt new file mode 100644 index 0000000000..bbc1e10d4d --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/util/DebugLoggableModel.kt @@ -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() + } + } +} \ No newline at end of file