diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogRepository.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogRepository.java index cbf315f53c..5b16ca2095 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogRepository.java @@ -38,6 +38,7 @@ import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; +import java.util.function.Consumer; import java.util.regex.Pattern; import java.util.zip.GZIPOutputStream; import java.util.zip.ZipEntry; @@ -114,15 +115,15 @@ public class SubmitDebugLogRepository { this.executor = SignalExecutors.SERIAL; } - public void getPrefixLogLines(@NonNull Callback> callback) { - executor.execute(() -> callback.onResult(getPrefixLogLinesInternal())); + public void getPrefixLogLines(@NonNull Consumer> callback) { + executor.execute(() -> callback.accept(getPrefixLogLinesInternal())); } - public void buildAndSubmitLog(@NonNull Callback> callback) { + public void buildAndSubmitLog(@NonNull Consumer> callback) { SignalExecutors.UNBOUNDED.execute(() -> { Log.blockUntilAllWritesFinished(); LogDatabase.getInstance(context).logs().trimToSize(); - callback.onResult(submitLogInternal(System.currentTimeMillis(), getPrefixLogLinesInternal(), Tracer.getInstance().serialize())); + callback.accept(submitLogInternal(System.currentTimeMillis(), getPrefixLogLinesInternal(), Tracer.getInstance().serialize())); }); } @@ -133,11 +134,11 @@ public class SubmitDebugLogRepository { return submitLogInternal(untilTime, getPrefixLogLinesInternal(), Tracer.getInstance().serialize()); } - public void submitLogFromReader(DebugLogsViewer.LogReader logReader, @Nullable byte[] trace, Callback> callback) { - SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogFromReaderInternal(logReader, trace))); + public void submitLogFromReader(DebugLogsViewer.LogReader logReader, @Nullable byte[] trace, Consumer> callback) { + SignalExecutors.UNBOUNDED.execute(() -> callback.accept(submitLogFromReaderInternal(logReader, trace))); } - public void writeLogToDisk(@NonNull Uri uri, long untilTime, Callback callback) { + public void writeLogToDisk(@NonNull Uri uri, long untilTime, Consumer callback) { SignalExecutors.UNBOUNDED.execute(() -> { try (ZipOutputStream outputStream = new ZipOutputStream(context.getContentResolver().openOutputStream(uri))) { StringBuilder prefixLines = linesToStringBuilder(getPrefixLogLinesInternal(), null); @@ -152,7 +153,7 @@ public class SubmitDebugLogRepository { } } catch (IllegalStateException e) { Log.e(TAG, "Failed to read row!", e); - callback.onResult(false); + callback.accept(false); return; } @@ -162,9 +163,9 @@ public class SubmitDebugLogRepository { outputStream.write(Tracer.getInstance().serialize()); outputStream.closeEntry(); - callback.onResult(true); + callback.accept(true); } catch (IOException e) { - callback.onResult(false); + callback.accept(false); } }); } @@ -449,8 +450,4 @@ public class SubmitDebugLogRepository { return stringBuilder; } - - public interface Callback { - void onResult(E result); - } } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt index e0024c8ec6..762016d642 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -45,6 +45,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { ErrorCircle(icon(R.drawable.symbol_error_circle_fill_24)), FlashAuto(icon(R.drawable.symbol_flash_auto_24)), FlashOff(icon(R.drawable.symbol_flash_slash_24)), + File(icon(R.drawable.symbol_file_24)), FlashOn(icon(R.drawable.symbol_flash_24)), Forward(icon(R.drawable.symbol_forward_24)), Info(icon(R.drawable.symbol_info_24)), @@ -55,6 +56,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { Phone(icon(R.drawable.symbol_phone_24)), Plus(icon(R.drawable.symbol_plus_24)), QrCode(icon(R.drawable.symbol_qrcode_24)), + Recent(icon(R.drawable.symbol_recent_24)), Search(icon(R.drawable.symbol_search_24)), Settings(icon(R.drawable.symbol_settings_android_24)), Share(icon(R.drawable.symbol_share_android_24)), diff --git a/core/ui/src/main/res/drawable/symbol_file_24.xml b/core/ui/src/main/res/drawable/symbol_file_24.xml new file mode 100644 index 0000000000..2db98174d9 --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_file_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/symbol_recent_24.xml b/core/ui/src/main/res/drawable/symbol_recent_24.xml new file mode 100644 index 0000000000..99db4809c5 --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_recent_24.xml @@ -0,0 +1,13 @@ + + + + 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 3defe85640..fd64e996d8 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 @@ -6,12 +6,20 @@ package org.signal.registration.sample.dependencies import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext +import org.signal.archive.LocalBackupRestoreProgress 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.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord @@ -23,7 +31,10 @@ import org.signal.registration.proto.ProvisioningData import org.signal.registration.proto.RegistrationData import org.signal.registration.sample.storage.RegistrationDatabase import org.signal.registration.sample.storage.RegistrationPreferences +import org.signal.registration.screens.localbackuprestore.LocalBackupInfo +import org.signal.registration.screens.restoreselection.ArchiveRestoreOption import java.io.File +import java.time.LocalDateTime /** * Implementation of [StorageController] that persists registration data using @@ -32,7 +43,11 @@ import java.io.File class DemoStorageController(private val context: Context) : StorageController { companion object { + private val TAG = Log.tag(DemoStorageController::class) private const val TEMP_PROTO_FILENAME = "registration_data.pb" + private const val SIMULATED_STAGE_DELAY_MS = 500L + private val MODERN_BACKUP_PATTERN = Regex("^signal-backup-(\\d{4})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})$") + private val LEGACY_BACKUP_PATTERN = Regex("^signal-(\\d{4})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})\\.backup$") } private val db = RegistrationDatabase(context) @@ -51,7 +66,12 @@ class DemoStorageController(private val context: Context) : StorageController { override suspend fun readInProgressRegistrationData(): RegistrationData = withContext(Dispatchers.IO) { val file = File(context.filesDir, TEMP_PROTO_FILENAME) if (file.exists()) { - RegistrationData.ADAPTER.decode(file.readBytes()) + try { + RegistrationData.ADAPTER.decode(file.readBytes()) + } catch (e: Exception) { + Log.w(TAG, "Failed to decode registration data, returning empty.", e) + RegistrationData() + } } else { RegistrationData() } @@ -161,6 +181,114 @@ class DemoStorageController(private val context: Context) : StorageController { Unit } + override suspend fun getAvailableRestoreOptions(): Set { + return setOf( + ArchiveRestoreOption.SignalSecureBackup, + ArchiveRestoreOption.LocalBackup, + ArchiveRestoreOption.DeviceTransfer + ) + } + + override suspend fun scanLocalBackupFolder(folderUri: Uri): List = withContext(Dispatchers.IO) { + val folder = DocumentFile.fromTreeUri(context, folderUri) ?: return@withContext emptyList() + val children = folder.listFiles() + + // If the selected folder contains a SignalBackups directory, use that instead + val signalBackupsDir = children.firstOrNull { it.isDirectory && it.name == "SignalBackups" } + val effectiveChildren = if (signalBackupsDir != null) { + Log.d(TAG, "Found SignalBackups directory, using it as the effective folder") + signalBackupsDir.listFiles() + } else { + children + } + + val backups = mutableListOf() + + // Check for modern backups: requires a 'files' directory and signal-backup-* directories + val hasFilesDir = effectiveChildren.any { it.isDirectory && it.name == "files" } + if (hasFilesDir) { + for (child in effectiveChildren) { + if (!child.isDirectory) continue + val name = child.name ?: continue + val match = MODERN_BACKUP_PATTERN.matchEntire(name) ?: continue + val (year, month, day, hour, minute, second) = match.destructured + try { + val date = LocalDateTime.of(year.toInt(), month.toInt(), day.toInt(), hour.toInt(), minute.toInt(), second.toInt()) + backups.add( + LocalBackupInfo( + type = LocalBackupInfo.BackupType.V2, + date = date, + name = name, + uri = child.uri + ) + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse date from modern backup name: $name", e) + } + } + } + + // Check for legacy backups: signal-yyyy-MM-dd-HH-mm-ss.backup files + for (child in effectiveChildren) { + if (!child.isFile) continue + val name = child.name ?: continue + val match = LEGACY_BACKUP_PATTERN.matchEntire(name) ?: continue + val (year, month, day, hour, minute, second) = match.destructured + try { + val date = LocalDateTime.of(year.toInt(), month.toInt(), day.toInt(), hour.toInt(), minute.toInt(), second.toInt()) + backups.add( + LocalBackupInfo( + type = LocalBackupInfo.BackupType.V1, + date = date, + name = name, + uri = child.uri, + sizeBytes = child.length() + ) + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse date from legacy backup name: $name", e) + } + } + + backups.sortedByDescending { it.date } + } + + override fun restoreLocalBackupV1(uri: Uri, passphrase: String): Flow = flow { + Log.d(TAG, "Starting simulated V1 local backup restore from: $uri") + + require(DocumentFile.fromSingleUri(context, uri)?.exists() == true) { "Backup file does not exist: $uri" } + + emit(LocalBackupRestoreProgress.Preparing) + delay(SIMULATED_STAGE_DELAY_MS) + + val totalBytes = 100L + for (i in 1..4) { + emit(LocalBackupRestoreProgress.InProgress(bytesRead = totalBytes * i / 4, totalBytes = totalBytes)) + delay(SIMULATED_STAGE_DELAY_MS) + } + + emit(LocalBackupRestoreProgress.Complete) + Log.d(TAG, "Simulated V1 restore complete.") + }.flowOn(Dispatchers.IO) + + override fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: String): Flow = flow { + Log.d(TAG, "Starting simulated V2 local backup restore from backup=$backupUri, root=$rootUri") + + require(DocumentFile.fromTreeUri(context, backupUri)?.exists() == true) { "Backup directory does not exist: $backupUri" } + + emit(LocalBackupRestoreProgress.Preparing) + delay(SIMULATED_STAGE_DELAY_MS) + + val totalBytes = 100L + for (i in 1..4) { + emit(LocalBackupRestoreProgress.InProgress(bytesRead = totalBytes * i / 4, totalBytes = totalBytes)) + delay(SIMULATED_STAGE_DELAY_MS) + } + + emit(LocalBackupRestoreProgress.Complete) + Log.d(TAG, "Simulated V2 restore complete.") + }.flowOn(Dispatchers.IO) + private suspend fun writeRegistrationData(data: RegistrationData) = withContext(Dispatchers.IO) { val file = File(context.filesDir, TEMP_PROTO_FILENAME) file.writeBytes(RegistrationData.ADAPTER.encode(data)) 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 de32f414d7..492e1b065f 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,7 +5,10 @@ package org.signal.registration.sample.screens.main +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -34,6 +37,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -224,9 +230,24 @@ private fun RegistrationInfo(data: MainScreenState.ExistingRegistrationState) { } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun RegistrationField(label: String, value: String) { - Column(modifier = Modifier.padding(vertical = 4.dp)) { + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + + Column( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = {}, + onLongClick = { + clipboardManager.setText(AnnotatedString(value)) + Toast.makeText(context, "Copied $label", Toast.LENGTH_SHORT).show() + } + ) + .padding(vertical = 4.dp) + ) { Text( text = label, style = MaterialTheme.typography.labelSmall, diff --git a/feature/registration/build.gradle.kts b/feature/registration/build.gradle.kts index 90bd3e2d0b..8449e7cf08 100644 --- a/feature/registration/build.gradle.kts +++ b/feature/registration/build.gradle.kts @@ -14,6 +14,10 @@ android { buildConfig = true } + lint { + disable += "StopShip" + } + testOptions { unitTests { isIncludeAndroidResources = true @@ -36,6 +40,7 @@ dependencies { lintChecks(project(":lintchecks")) // Project dependencies + api(project(":lib:archive")) implementation(project(":core:ui")) implementation(project(":core:util")) implementation(project(":core:models-jvm")) diff --git a/feature/registration/src/main/java/org/signal/registration/PendingRestoreOption.kt b/feature/registration/src/main/java/org/signal/registration/PendingRestoreOption.kt new file mode 100644 index 0000000000..5746cca811 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/PendingRestoreOption.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration + +import kotlinx.serialization.Serializable + +/** + * Represents a restore option the user selected before entering their phone number. + * After phone number entry, the registration flow will navigate to the appropriate + * restore flow based on this selection. + */ +@Serializable +enum class PendingRestoreOption { + LocalBackup, + RemoteBackup +} diff --git a/feature/registration/src/main/java/org/signal/registration/PersistedFlowState.kt b/feature/registration/src/main/java/org/signal/registration/PersistedFlowState.kt index 1753fd93b2..185cba7b03 100644 --- a/feature/registration/src/main/java/org/signal/registration/PersistedFlowState.kt +++ b/feature/registration/src/main/java/org/signal/registration/PersistedFlowState.kt @@ -21,7 +21,9 @@ data class PersistedFlowState( val backStack: List, val sessionMetadata: NetworkController.SessionMetadata?, val sessionE164: String?, - val doNotAttemptRecoveryPassword: Boolean + val doNotAttemptRecoveryPassword: Boolean, + val pendingRestoreOption: PendingRestoreOption? = null, + val restoredAepValue: String? = null ) /** @@ -32,7 +34,9 @@ fun RegistrationFlowState.toPersistedFlowState(): PersistedFlowState { backStack = backStack, sessionMetadata = sessionMetadata, sessionE164 = sessionE164, - doNotAttemptRecoveryPassword = doNotAttemptRecoveryPassword + doNotAttemptRecoveryPassword = doNotAttemptRecoveryPassword, + pendingRestoreOption = pendingRestoreOption, + restoredAepValue = unverifiedRestoredAep?.value ) } @@ -55,6 +59,8 @@ fun PersistedFlowState.toRegistrationFlowState( accountEntropyPool = accountEntropyPool, temporaryMasterKey = temporaryMasterKey, preExistingRegistrationData = preExistingRegistrationData, - doNotAttemptRecoveryPassword = doNotAttemptRecoveryPassword + doNotAttemptRecoveryPassword = doNotAttemptRecoveryPassword, + pendingRestoreOption = pendingRestoreOption, + unverifiedRestoredAep = restoredAepValue?.let { AccountEntropyPool(it) } ) } 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 82d4a44f83..cd469e0bbc 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt @@ -33,4 +33,10 @@ sealed interface RegistrationFlowEvent : DebugLoggable { /** We've discovered that RRP-based registration is not possible for this account. */ data object RecoveryPasswordInvalid : RegistrationFlowEvent + + /** The user selected (or cleared) a restore option before entering their phone number. */ + data class PendingRestoreOptionSelected(val option: PendingRestoreOption?) : RegistrationFlowEvent + + /** An AEP was obtained from a local backup restore. It has not yet been verified against the server. */ + data class AepSubmittedViaLocalBackupRestore(val aep: AccountEntropyPool) : 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 2186c4675e..e892457ba4 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt @@ -10,8 +10,9 @@ import kotlinx.parcelize.Parcelize import kotlinx.parcelize.TypeParceler import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey +import org.signal.core.util.censor import org.signal.registration.util.AccountEntropyPoolParceler -import org.signal.registration.util.DebugLoggable +import org.signal.registration.util.DebugLoggableModel import org.signal.registration.util.MasterKeyParceler @Parcelize @@ -39,6 +40,16 @@ data class RegistrationFlowState( /** If true, do not attempt any flows where we generate RRP's. Create a session instead. */ val doNotAttemptRecoveryPassword: Boolean = false, + /** If set, the user selected a restore option before entering their phone number. After phone number entry, the flow will navigate to this restore flow. */ + val pendingRestoreOption: PendingRestoreOption? = null, + + /** The AEP obtained from a local backup restore. May or may not be valid for the current phone number. */ + val unverifiedRestoredAep: AccountEntropyPool? = null, + /** If true, the ViewModel is still deciding whether to restore a previous flow or start fresh. */ val isRestoringNavigationState: Boolean = true -) : Parcelable, DebugLoggable +) : Parcelable, DebugLoggableModel() { + override fun toSafeString(): String { + return "RegistrationFlowState(backStack=${backStack.joinToString()}, sessionMetadata=${sessionMetadata.let { "present" }}, sessionE164=$sessionE164, accountEntropyPool=${accountEntropyPool?.toString()?.censor()}, temporaryMasterKey=${temporaryMasterKey?.toString()?.censor()}, preExistingRegistrationData=${preExistingRegistrationData?.let { "present" }}, doNotAttemptRecoveryPassword=$doNotAttemptRecoveryPassword, pendingRestoreOption=$pendingRestoreOption, unverifiedRestoredAep=${unverifiedRestoredAep?.toString()?.censor()}, isRestoringNavigation=$isRestoringNavigationState)" + } +} 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 c0b0d9a256..29f743ee94 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -41,6 +41,13 @@ import org.signal.registration.screens.countrycode.Country import org.signal.registration.screens.countrycode.CountryCodePickerRepository import org.signal.registration.screens.countrycode.CountryCodePickerScreen import org.signal.registration.screens.countrycode.CountryCodePickerViewModel +import org.signal.registration.screens.localbackuprestore.EnterAepScreen +import org.signal.registration.screens.localbackuprestore.EnterAepViewModel +import org.signal.registration.screens.localbackuprestore.EnterLocalBackupV1PassphaseScreen +import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreEvents +import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreResult +import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreScreen +import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreViewModel import org.signal.registration.screens.permissions.PermissionsScreen import org.signal.registration.screens.phonenumber.PhoneNumberEntryScreenEvents import org.signal.registration.screens.phonenumber.PhoneNumberEntryViewModel @@ -114,6 +121,15 @@ sealed interface RegistrationRoute : NavKey, Parcelable { @Serializable data object ChooseRestoreOptionAfterRegistration : RegistrationRoute + @Serializable + data class LocalBackupRestore(val isPreRegistration: Boolean) : RegistrationRoute + + @Serializable + data object EnterLocalBackupV1Passphrase : RegistrationRoute + + @Serializable + data object EnterAepScreen : RegistrationRoute + @Serializable data object QuickRestoreQrScan : RegistrationRoute @@ -129,6 +145,8 @@ sealed interface RegistrationRoute : NavKey, Parcelable { private const val CAPTCHA_RESULT = "captcha_token" private const val COUNTRY_CODE_RESULT = "country_code_result" +private const val BACKUP_CREDENTIAL_RESULT = "backup_credential_result" +private const val LOCAL_BACKUP_RESTORE_RESULT = "local_backup_restore_result" /** * Sets up the navigation graph for the registration flow using Navigation 3. @@ -193,17 +211,25 @@ fun RegistrationNavHost( } }, popTransitionSpec = { - if (initialState.key == RegistrationRoute.CountryCodePicker.toString()) { - TransitionSpecs.VerticalSlide.popTransitionSpec.invoke(this) - } else { - TransitionSpecs.HorizontalSlide.popTransitionSpec.invoke(this) + when { + initialState.key == RegistrationRoute.CountryCodePicker.toString() -> { + TransitionSpecs.VerticalSlide.popTransitionSpec.invoke(this) + } + initialState.key == RegistrationRoute.EnterAepScreen.toString() -> { + TransitionSpecs.HorizontalSlide.transitionSpec.invoke(this) + } + initialState.key == RegistrationRoute.LocalBackupRestore.toString() && targetState.key == RegistrationRoute.PhoneNumberEntry.toString() -> { + TransitionSpecs.HorizontalSlide.transitionSpec.invoke(this) + } + else -> { + TransitionSpecs.HorizontalSlide.popTransitionSpec.invoke(this) + } } }, predictivePopTransitionSpec = { - if (initialState.key == RegistrationRoute.CountryCodePicker.toString()) { - TransitionSpecs.VerticalSlide.predictivePopTransitionSpec.invoke(this, it) - } else { - TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec.invoke(this, it) + when (initialState.key) { + RegistrationRoute.CountryCodePicker.toString() -> TransitionSpecs.VerticalSlide.predictivePopTransitionSpec.invoke(this, it) + else -> TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec.invoke(this, it) } } ) @@ -263,6 +289,10 @@ private fun EntryProviderScope.navigationEntries( } } + ResultEffect(registrationViewModel.resultBus, LOCAL_BACKUP_RESTORE_RESULT) { result -> + viewModel.onEvent(PhoneNumberEntryScreenEvents.LocalBackupRestoreCompleted(result)) + } + PhoneNumberScreen( state = state, onEvent = { viewModel.onEvent(it) } @@ -420,7 +450,26 @@ private fun EntryProviderScope.navigationEntries( factory = ArchiveRestoreSelectionViewModel.Factory( repository = registrationRepository, parentState = registrationViewModel.state, - parentEventEmitter = registrationViewModel::onEvent + parentEventEmitter = registrationViewModel::onEvent, + isPreRegistration = false + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + ArchiveRestoreSelectionScreen( + state = state, + onEvent = { viewModel.onEvent(it) } + ) + } + + // -- Choose Restore Option Before Registration (saves selection, then navigates to phone number entry) + entry { + val viewModel: ArchiveRestoreSelectionViewModel = viewModel( + factory = ArchiveRestoreSelectionViewModel.Factory( + repository = registrationRepository, + parentState = registrationViewModel.state, + parentEventEmitter = registrationViewModel::onEvent, + isPreRegistration = true ) ) val state by viewModel.state.collectAsStateWithLifecycle() @@ -432,7 +481,63 @@ private fun EntryProviderScope.navigationEntries( } entry { - // TODO: Implement RestoreScreen + TODO("Implement RestoreScreen") + } + + // -- Local Backup Restore Screen + entry { key -> + val viewModel: LocalBackupRestoreViewModel = viewModel( + factory = LocalBackupRestoreViewModel.Factory( + repository = registrationRepository, + parentState = registrationViewModel.state, + parentEventEmitter = registrationViewModel::onEvent, + isPreRegistration = key.isPreRegistration, + resultBus = registrationViewModel.resultBus, + resultKey = LOCAL_BACKUP_RESTORE_RESULT + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + ResultEffect(registrationViewModel.resultBus, BACKUP_CREDENTIAL_RESULT) { passphrase -> + if (passphrase != null) { + viewModel.onEvent(LocalBackupRestoreEvents.PassphraseSubmitted(passphrase)) + } + } + + LocalBackupRestoreScreen( + state = state, + onEvent = { viewModel.onEvent(it) } + ) + } + + // -- Enter Backup Passphrase (V1) + entry { + EnterLocalBackupV1PassphaseScreen( + onSubmit = { passphrase -> + registrationViewModel.resultBus.sendResult(BACKUP_CREDENTIAL_RESULT, passphrase) + parentEventEmitter.navigateBack() + }, + onCancel = { + parentEventEmitter.navigateBack() + } + ) + } + + // -- Enter AEP + entry { + val viewModel: EnterAepViewModel = viewModel( + factory = EnterAepViewModel.Factory( + parentEventEmitter = registrationViewModel::onEvent, + resultBus = registrationViewModel.resultBus, + resultKey = BACKUP_CREDENTIAL_RESULT + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + EnterAepScreen( + state = state, + onEvent = { viewModel.onEvent(it) } + ) } entry { 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 6372625f35..66069733e6 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -7,11 +7,13 @@ package org.signal.registration import android.app.backup.BackupManager import android.content.Context +import android.net.Uri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okio.ByteString.Companion.toByteString +import org.signal.archive.LocalBackupRestoreProgress import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey import org.signal.core.util.Base64 @@ -38,6 +40,8 @@ import org.signal.registration.NetworkController.SvrCredentials import org.signal.registration.NetworkController.UpdateSessionError import org.signal.registration.proto.ProvisioningData import org.signal.registration.proto.SvrCredential +import org.signal.registration.screens.localbackuprestore.LocalBackupInfo +import org.signal.registration.screens.restoreselection.ArchiveRestoreOption import org.signal.registration.util.SensitiveLog import java.security.SecureRandom import java.util.Locale @@ -47,6 +51,11 @@ import javax.crypto.spec.SecretKeySpec class RegistrationRepository(val context: Context, val networkController: NetworkController, val storageController: StorageController) { + companion object { + private val TAG = Log.tag(RegistrationRepository::class) + private val json = Json { ignoreUnknownKeys = true } + } + suspend fun createSession(e164: String): RegistrationNetworkResult = withContext(Dispatchers.IO) { val fcmToken = networkController.getFcmToken() networkController.createSession( @@ -178,7 +187,8 @@ class RegistrationRepository(val context: Context, val networkController: Networ recoveryPassword: String, registrationLock: String? = null, skipDeviceTransfer: Boolean = true, - preExistingRegistrationData: PreExistingRegistrationData? = null + preExistingRegistrationData: PreExistingRegistrationData? = null, + existingAccountEntropyPool: AccountEntropyPool? = null ): RegistrationNetworkResult, RegisterAccountError> = withContext(Dispatchers.IO) { registerAccount( e164 = e164, @@ -186,7 +196,7 @@ class RegistrationRepository(val context: Context, val networkController: Networ recoveryPassword = recoveryPassword, registrationLock = registrationLock, skipDeviceTransfer = skipDeviceTransfer, - existingAccountEntropyPool = preExistingRegistrationData?.aep, + existingAccountEntropyPool = existingAccountEntropyPool ?: preExistingRegistrationData?.aep, existingAciIdentityKeyPair = preExistingRegistrationData?.aciIdentityKeyPair, existingPniIdentityKeyPair = preExistingRegistrationData?.pniIdentityKeyPair ) @@ -415,13 +425,14 @@ class RegistrationRepository(val context: Context, val networkController: Networ * Persists the current flow state as JSON in the in-progress registration data proto. */ suspend fun saveFlowState(state: RegistrationFlowState) = withContext(Dispatchers.IO) { + Log.d(TAG, "[saveFlowState] Saving flow state: $state") try { - val json = flowStateJson.encodeToString(PersistedFlowState.serializer(), state.toPersistedFlowState()) + val json = json.encodeToString(PersistedFlowState.serializer(), state.toPersistedFlowState()) storageController.updateInProgressRegistrationData { flowStateJson = json } } catch (e: Exception) { - Log.w(TAG, "Failed to save flow state", e) + Log.w(TAG, "[saveFlowState] Failed to save flow state.", e) } } @@ -436,7 +447,7 @@ class RegistrationRepository(val context: Context, val networkController: Networ val data = storageController.readInProgressRegistrationData() if (data.flowStateJson.isEmpty()) return@withContext null - val persisted = flowStateJson.decodeFromString(PersistedFlowState.serializer(), data.flowStateJson) + val persisted = json.decodeFromString(PersistedFlowState.serializer(), data.flowStateJson) val aep = data.accountEntropyPool.takeIf { it.isNotEmpty() }?.let { AccountEntropyPool(it) } val masterKey = data.temporaryMasterKey.takeIf { it.size > 0 }?.let { MasterKey(it.toByteArray()) } @@ -486,6 +497,22 @@ class RegistrationRepository(val context: Context, val networkController: Networ data.aci.isNotEmpty() && data.pni.isNotEmpty() } + suspend fun getAvailableRestoreOptions(): Set = withContext(Dispatchers.IO) { + storageController.getAvailableRestoreOptions() + } + + fun restoreV1Backup(uri: Uri, passphrase: String): Flow { + return storageController.restoreLocalBackupV1(uri, passphrase) + } + + fun restoreV2Backup(rootUri: Uri, backupUri: Uri, aep: String): Flow { + return storageController.restoreLocalBackupV2(rootUri, backupUri, aep) + } + + suspend fun scanLocalBackupFolder(folderUri: Uri): List = withContext(Dispatchers.IO) { + storageController.scanLocalBackupFolder(folderUri) + } + private fun generateKeyMaterial( existingAccountEntropyPool: AccountEntropyPool? = null, existingAciIdentityKeyPair: IdentityKeyPair? = null, @@ -561,9 +588,4 @@ class RegistrationRepository(val context: Context, val networkController: Networ val ciphertext = cipher.doFinal(input) return ciphertext.copyOf(16) } - - companion object { - private val TAG = Log.tag(RegistrationRepository::class) - private val flowStateJson = Json { ignoreUnknownKeys = true } - } } 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 6b284ba29e..07bc3af846 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt @@ -72,6 +72,8 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save is RegistrationFlowEvent.NavigateToScreen -> applyNavigationToScreenEvent(state, event) is RegistrationFlowEvent.NavigateBack -> state.copy(backStack = state.backStack.dropLast(1)) is RegistrationFlowEvent.RecoveryPasswordInvalid -> state.copy(doNotAttemptRecoveryPassword = true) + is RegistrationFlowEvent.PendingRestoreOptionSelected -> state.copy(pendingRestoreOption = event.option) + is RegistrationFlowEvent.AepSubmittedViaLocalBackupRestore -> state.copy(unverifiedRestoredAep = event.aep) } } @@ -156,7 +158,9 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save is RegistrationFlowEvent.NavigateBack, is RegistrationFlowEvent.SessionUpdated, is RegistrationFlowEvent.E164Chosen, - is RegistrationFlowEvent.RecoveryPasswordInvalid -> repository.saveFlowState(_state.value) + is RegistrationFlowEvent.RecoveryPasswordInvalid, + is RegistrationFlowEvent.PendingRestoreOptionSelected, + is RegistrationFlowEvent.AepSubmittedViaLocalBackupRestore -> repository.saveFlowState(_state.value) // No need to persist anything new, fields accounted for in proto already is RegistrationFlowEvent.Registered, 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 d97edb0dc9..74471f25ba 100644 --- a/feature/registration/src/main/java/org/signal/registration/StorageController.kt +++ b/feature/registration/src/main/java/org/signal/registration/StorageController.kt @@ -5,9 +5,12 @@ package org.signal.registration +import android.net.Uri import android.os.Parcelable +import kotlinx.coroutines.flow.Flow import kotlinx.parcelize.Parcelize import kotlinx.parcelize.TypeParceler +import org.signal.archive.LocalBackupRestoreProgress import org.signal.core.models.AccountEntropyPool import org.signal.core.models.ServiceId.ACI import org.signal.core.models.ServiceId.PNI @@ -15,6 +18,8 @@ import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord import org.signal.registration.proto.RegistrationData +import org.signal.registration.screens.localbackuprestore.LocalBackupInfo +import org.signal.registration.screens.restoreselection.ArchiveRestoreOption import org.signal.registration.util.ACIParceler import org.signal.registration.util.AccountEntropyPoolParceler import org.signal.registration.util.IdentityKeyPairParceler @@ -77,6 +82,42 @@ interface StorageController { * separately committed. */ suspend fun commitRegistrationData() + + /** + * Returns the set of restore options that are currently available to the user. + * For example, if a local backup file is present on the device, [ArchiveRestoreOption.LocalBackup] should be included. + */ + suspend fun getAvailableRestoreOptions(): Set + + /** + * Begins restoring from a V1 (.backup) file identified by the given [uri]. + * + * Returns a [Flow] of [LocalBackupRestoreProgress] that reports the state of the restore operation + * from preparation through completion or error. + */ + fun restoreLocalBackupV1(uri: Uri, passphrase: String): Flow + + /** + * Begins restoring from a V2 (folder-based) backup. + * + * @param rootUri The root backup directory that contains shared files used across multiple backups. + * @param backupUri The specific backup folder (e.g. signal-backup-yyyy-MM-dd-HH-mm-ss) to restore from. + * @param aep The Account Entropy Pool used to decrypt the backup. + * @return A [Flow] of [LocalBackupRestoreProgress] that reports the state of the restore operation + * from preparation through completion or error. + */ + fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: String): Flow + + /** + * Scans the given folder URI for local backup files, checking for both modern + * folder-based backups and legacy .backup files. + * + * If the folder contains a "SignalBackups" subdirectory, that directory is used + * as the effective scan target. + * + * @return A list of [LocalBackupInfo] sorted by date descending (most recent first). + */ + suspend fun scanLocalBackupFolder(folderUri: Uri): List } /** diff --git a/feature/registration/src/main/java/org/signal/registration/screens/EventDrivenViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/EventDrivenViewModel.kt new file mode 100644 index 0000000000..670238a08a --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/EventDrivenViewModel.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import org.signal.core.util.logging.Log +import org.signal.registration.util.DebugLoggable + +/** + * Base view model that helps one implement an Elm-like architecture, where events are processed and + * new models are emitted. In particular, this base class exists to setup the core event channel + * to avoid gotcha's around threading and race conditions. + */ +abstract class EventDrivenViewModel( + private val tag: String +) : ViewModel() { + + private val eventChannel = Channel(Channel.UNLIMITED) + + init { + viewModelScope.launch { + for (event in eventChannel) { + Log.d(tag, "[Event] $event") + processEvent(event) + } + } + } + + fun onEvent(event: E) { + eventChannel.trySend(event) + } + + /** + * Handle the event how you wish. It's recommended that you use the event to emit a new state model + * to be observed by the view. + */ + protected abstract suspend fun processEvent(event: E) +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/BackupKeyAutoFill.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/BackupKeyAutoFill.kt new file mode 100644 index 0000000000..a04fda9e3a --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/BackupKeyAutoFill.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.view.autofill.AutofillManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalAutofill +import androidx.compose.ui.platform.LocalAutofillTree +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.content.ContextCompat +import kotlin.math.roundToInt +import android.graphics.Rect as ViewRect +import androidx.compose.ui.geometry.Rect as ComposeRect + +@OptIn(ExperimentalComposeUiApi::class) +@SuppressLint("NewApi") +@Composable +fun backupKeyAutoFillHelper( + onFill: (String) -> Unit +): BackupKeyAutoFillHelper { + val view = LocalView.current + val context = LocalContext.current + val autofill: Autofill? = LocalAutofill.current + + val node = remember { AutofillNode(autofillTypes = listOf(AutofillType.Password), onFill = onFill) } + LocalAutofillTree.current += node + + return remember { + object : BackupKeyAutoFillHelper(context) { + override fun request() { + if (node.boundingBox != null) { + autofill?.requestAutofillForNode(node) + } + } + + override fun cancel() { + autofill?.cancelAutofillForNode(node) + } + + override fun requestDirectly() { + val bounds = node.boundingBox?.let { ViewRect(it.left.roundToInt(), it.top.roundToInt(), it.right.roundToInt(), it.bottom.roundToInt()) } + if (bounds != null) { + autoFillManager?.requestAutofill(view, node.id, bounds) + } + } + + override fun updateNodeBounds(boundsInWindow: ComposeRect) { + node.boundingBox = boundsInWindow + } + } + } +} + +fun Modifier.attachBackupKeyAutoFillHelper(helper: BackupKeyAutoFillHelper): Modifier { + return this.then( + Modifier + .onFocusChanged { + if (it.isFocused) { + helper.request() + } else { + helper.cancel() + } + } + .onGloballyPositioned { + helper.updateNodeBounds(it.boundsInWindow()) + } + ) +} + +abstract class BackupKeyAutoFillHelper(context: Context) { + protected val autoFillManager: AutofillManager? = if (Build.VERSION.SDK_INT >= 26) { + ContextCompat.getSystemService(context, AutofillManager::class.java) + } else { + null + } + + fun onValueChanged(value: String) { + if (value.isEmpty()) { + requestDirectly() + } + } + + abstract fun request() + abstract fun cancel() + abstract fun requestDirectly() + abstract fun updateNodeBounds(boundsInWindow: ComposeRect) +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepEvents.kt new file mode 100644 index 0000000000..59e87bd6d6 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepEvents.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import org.signal.registration.util.DebugLoggableModel + +sealed class EnterAepEvents : DebugLoggableModel() { + /** User changed the backup key text. */ + data class BackupKeyChanged(val value: String) : EnterAepEvents() + + /** User submitted the backup key. */ + data object Submit : EnterAepEvents() + + /** User wants to cancel / no recovery key. */ + data object Cancel : EnterAepEvents() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepScreen.kt new file mode 100644 index 0000000000..1baf86eddb --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepScreen.kt @@ -0,0 +1,240 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.Previews +import org.signal.registration.R + +@Composable +fun EnterAepScreen( + state: EnterAepState, + onEvent: (EnterAepEvents) -> Unit, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + val visualTransform = remember(state.chunkLength) { AepVisualTransformation(state.chunkLength) } + val focusRequester = remember { FocusRequester() } + var requestFocus by remember { mutableStateOf(true) } + val keyboardController = LocalSoftwareKeyboardController.current + val autoFillHelper = backupKeyAutoFillHelper { onEvent(EnterAepEvents.BackupKeyChanged(it)) } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(40.dp)) + + Text( + text = stringResource(R.string.EnterAepScreen__enter_your_recovery_key), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.EnterAepScreen__your_recovery_key_is_a_64_character_code), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(24.dp)) + + TextField( + value = state.backupKey, + onValueChange = { + onEvent(EnterAepEvents.BackupKeyChanged(it)) + autoFillHelper.onValueChanged(it) + }, + label = { Text(stringResource(R.string.EnterAepScreen__recovery_key)) }, + textStyle = MaterialTheme.typography.bodyLarge.copy( + fontFamily = FontFamily.Monospace, + lineHeight = 36.sp + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Next, + autoCorrectEnabled = false + ), + keyboardActions = KeyboardActions( + onNext = { + if (state.isBackupKeyValid) { + keyboardController?.hide() + onEvent(EnterAepEvents.Submit) + } + } + ), + supportingText = { + when (val error = state.aepValidationError) { + is AepValidationError.TooLong -> Text(stringResource(R.string.EnterAepScreen__too_long, error.count, error.max)) + is AepValidationError.Invalid -> Text(stringResource(R.string.EnterAepScreen__invalid_recovery_key)) + null -> {} + } + }, + isError = state.aepValidationError != null, + minLines = 4, + visualTransformation = visualTransform, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .attachBackupKeyAutoFillHelper(autoFillHelper) + .onGloballyPositioned { + if (requestFocus) { + focusRequester.requestFocus() + requestFocus = false + } + } + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + TextButton( + modifier = Modifier.weight(weight = 1f, fill = false), + onClick = { onEvent(EnterAepEvents.Cancel) } + ) { + Text(text = stringResource(R.string.EnterAepScreen__no_recovery_key)) + } + + Spacer(modifier = Modifier.size(24.dp)) + + Buttons.LargeTonal( + enabled = state.isBackupKeyValid && state.aepValidationError == null, + onClick = { onEvent(EnterAepEvents.Submit) } + ) { + Text(text = stringResource(R.string.LocalBackupRestoreScreen__next)) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +/** + * Visual formatter for backup keys — groups characters with spaces. + */ +private class AepVisualTransformation(private val chunkSize: Int) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + var output = "" + for ((i, c) in text.withIndex()) { + output += c + if (i % chunkSize == chunkSize - 1) { + output += " " + } + } + + val transformed = output.trimEnd().uppercase() + + return TransformedText( + text = AnnotatedString(transformed), + offsetMapping = AepOffsetMapping(chunkSize, text.length) + ) + } + + private class AepOffsetMapping(private val chunkSize: Int, private val inputSize: Int) : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + val transformed = offset + (offset / chunkSize) + return when { + inputSize == 0 -> 0 + offset == inputSize && offset >= chunkSize && offset % chunkSize == 0 -> transformed - 1 + else -> transformed + } + } + + override fun transformedToOriginal(offset: Int): Int { + return offset - (offset / (chunkSize + 1)) + } + } +} + +@AllDevicePreviews +@Composable +private fun EnterAepScreenPreview() { + Previews.Preview { + EnterAepScreen( + state = EnterAepState(), + onEvent = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun EnterAepScreenFilledPreview() { + Previews.Preview { + EnterAepScreen( + state = EnterAepState( + backupKey = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", + isBackupKeyValid = true + ), + onEvent = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun EnterAepScreenErrorPreview() { + Previews.Preview { + EnterAepScreen( + state = EnterAepState( + backupKey = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", + isBackupKeyValid = false, + aepValidationError = AepValidationError.Invalid + ), + onEvent = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepState.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepState.kt new file mode 100644 index 0000000000..d0e9f091ae --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepState.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import org.signal.registration.util.DebugLoggableModel + +data class EnterAepState( + val backupKey: String = "", + val isBackupKeyValid: Boolean = false, + val aepValidationError: AepValidationError? = null, + val chunkLength: Int = 4 +) : DebugLoggableModel() + +sealed interface AepValidationError { + data class TooLong(val count: Int, val max: Int) : AepValidationError + data object Invalid : AepValidationError +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepViewModel.kt new file mode 100644 index 0000000000..913b69daaf --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepViewModel.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.signal.core.models.AccountEntropyPool +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 + +class EnterAepViewModel( + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val resultBus: ResultEventBus, + private val resultKey: String +) : ViewModel() { + + companion object { + private val TAG = Log.tag(EnterAepViewModel::class) + } + + private val _state = MutableStateFlow(EnterAepState()) + val state: StateFlow = _state.asStateFlow() + + fun onEvent(event: EnterAepEvents) { + Log.d(TAG, "[Event] $event") + when (event) { + is EnterAepEvents.BackupKeyChanged -> applyBackupKeyChanged(event.value) + is EnterAepEvents.Submit -> { + if (_state.value.isBackupKeyValid) { + resultBus.sendResult(resultKey, _state.value.backupKey) + parentEventEmitter.navigateBack() + } + } + is EnterAepEvents.Cancel -> { + parentEventEmitter.navigateBack() + } + } + } + + private fun applyBackupKeyChanged(key: String) { + val newKey = AccountEntropyPool.removeIllegalCharacters(key) + .take(AccountEntropyPool.LENGTH + 16) + .lowercase() + + val changed = newKey != _state.value.backupKey + val isValid = AccountEntropyPool.isFullyValid(newKey) + val isShort = newKey.length < AccountEntropyPool.LENGTH + val isExact = newKey.length == AccountEntropyPool.LENGTH + + val previousError = _state.value.aepValidationError + + // Check if previous error still applies + var updatedError: AepValidationError? = when (previousError) { + is AepValidationError.TooLong -> if (isShort || isExact) null else previousError.copy(count = newKey.length) + AepValidationError.Invalid -> if (isValid) null else previousError + null -> null + } + + // Check for new errors + if (updatedError == null) { + updatedError = when { + !isShort && !isExact -> AepValidationError.TooLong(newKey.length, AccountEntropyPool.LENGTH) + !isValid && isExact -> AepValidationError.Invalid + else -> null + } + } + + _state.update { + it.copy( + backupKey = newKey, + isBackupKeyValid = isValid, + aepValidationError = updatedError + ) + } + } + + class Factory( + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val resultBus: ResultEventBus, + private val resultKey: String + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return EnterAepViewModel(parentEventEmitter, resultBus, resultKey) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterLocalBackupV1PassphaseScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterLocalBackupV1PassphaseScreen.kt new file mode 100644 index 0000000000..9705a60a12 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterLocalBackupV1PassphaseScreen.kt @@ -0,0 +1,284 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.Previews +import org.signal.registration.R + +private const val PASSPHRASE_LENGTH = 30 +private const val CHUNK_SIZE = 5 + +@Composable +fun EnterLocalBackupV1PassphaseScreen( + onSubmit: (String) -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + var passphrase by rememberSaveable { mutableStateOf("") } + val scrollState = rememberScrollState() + val visualTransform = remember { PassphraseVisualTransformation(CHUNK_SIZE) } + val focusRequester = remember { FocusRequester() } + var requestFocus by remember { mutableStateOf(true) } + val keyboardController = LocalSoftwareKeyboardController.current + val isValid = passphrase.length == PASSPHRASE_LENGTH + val isTooLong = passphrase.length > PASSPHRASE_LENGTH + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(40.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__enter_backup_passphrase), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__enter_the_30_digit_passphrase), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(24.dp)) + + TextField( + value = passphrase, + onValueChange = { newValue -> + passphrase = newValue.filter { it.isDigit() } + }, + label = { Text(stringResource(R.string.LocalBackupRestoreScreen__recovery_key)) }, + textStyle = MaterialTheme.typography.bodyLarge.copy( + fontFamily = FontFamily.Monospace, + lineHeight = 36.sp + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + autoCorrectEnabled = false + ), + keyboardActions = KeyboardActions( + onNext = { + if (isValid) { + keyboardController?.hide() + onSubmit(passphrase) + } + } + ), + supportingText = { + if (isTooLong) { + Text(stringResource(R.string.LocalBackupRestoreScreen__too_long, passphrase.length, PASSPHRASE_LENGTH)) + } + }, + isError = isTooLong, + minLines = 2, + visualTransformation = visualTransform, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onGloballyPositioned { + if (requestFocus) { + focusRequester.requestFocus() + requestFocus = false + } + } + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + TextButton( + modifier = Modifier.weight(weight = 1f, fill = false), + onClick = onCancel + ) { + Text(text = stringResource(R.string.LocalBackupRestoreScreen__no_passphrase)) + } + + Spacer(modifier = Modifier.size(24.dp)) + + Buttons.LargeTonal( + enabled = isValid, + onClick = { onSubmit(passphrase) } + ) { + Text(text = stringResource(R.string.LocalBackupRestoreScreen__next)) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +/** + * Visual formatter for passphrases — groups digits with spaces. + */ +private class PassphraseVisualTransformation(private val chunkSize: Int) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + var output = "" + for ((i, c) in text.withIndex()) { + output += c + if (i % chunkSize == chunkSize - 1) { + output += " " + } + } + + val transformed = output.trimEnd() + + return TransformedText( + text = AnnotatedString(transformed), + offsetMapping = PassphraseOffsetMapping(chunkSize, text.length) + ) + } + + private class PassphraseOffsetMapping(private val chunkSize: Int, private val inputSize: Int) : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + val transformed = offset + (offset / chunkSize) + return when { + inputSize == 0 -> 0 + offset == inputSize && offset >= chunkSize && offset % chunkSize == 0 -> transformed - 1 + else -> transformed + } + } + + override fun transformedToOriginal(offset: Int): Int { + return offset - (offset / (chunkSize + 1)) + } + } +} + +@AllDevicePreviews +@Composable +private fun EnterLocalBackupV1PassphaseScreenPreview() { + Previews.Preview { + EnterLocalBackupV1PassphaseScreen( + onSubmit = {}, + onCancel = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun EnterLocalBackupV1PassphaseScreenFilledPreview() { + Previews.Preview { + val visualTransform = remember { PassphraseVisualTransformation(CHUNK_SIZE) } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(40.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__enter_backup_passphrase), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__enter_the_30_digit_passphrase), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(24.dp)) + + TextField( + value = "814680481455087435556426352670", + onValueChange = {}, + label = { Text(stringResource(R.string.LocalBackupRestoreScreen__recovery_key)) }, + textStyle = MaterialTheme.typography.bodyLarge.copy( + fontFamily = FontFamily.Monospace, + lineHeight = 36.sp + ), + minLines = 2, + visualTransformation = visualTransform, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + TextButton( + modifier = Modifier.weight(weight = 1f, fill = false), + onClick = {} + ) { + Text(text = stringResource(R.string.LocalBackupRestoreScreen__no_passphrase)) + } + + Spacer(modifier = Modifier.size(24.dp)) + + Buttons.LargeTonal( + enabled = true, + onClick = {} + ) { + Text(text = stringResource(R.string.LocalBackupRestoreScreen__next)) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupInfo.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupInfo.kt new file mode 100644 index 0000000000..9429b04ebf --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupInfo.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import android.net.Uri +import java.time.LocalDateTime + +/** + * Describes a local backup found on the device. + */ +data class LocalBackupInfo( + val type: BackupType, + val date: LocalDateTime, + val name: String, + val uri: Uri, + val sizeBytes: Long? = null +) { + enum class BackupType { + /** V1 .backup file format (signal-yyyy-MM-dd-HH-mm-ss.backup) */ + V1, + + /** V2 folder-based format (signal-backup-yyyy-MM-dd-HH-mm-ss) */ + V2 + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreEvents.kt new file mode 100644 index 0000000000..6453b2a792 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreEvents.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import android.net.Uri +import org.signal.registration.util.DebugLoggableModel + +sealed class LocalBackupRestoreEvents : DebugLoggableModel() { + /** User tapped the button to pick a backup folder. */ + data object PickBackupFolder : LocalBackupRestoreEvents() + + /** User selected a backup folder via the folder picker. */ + data class BackupFolderSelected(val uri: Uri) : LocalBackupRestoreEvents() + + /** User wants to restore the found backup (navigates to credential entry). */ + data object RestoreBackup : LocalBackupRestoreEvents() + + /** User wants to choose a different folder. */ + data object ChooseDifferentFolder : LocalBackupRestoreEvents() + + /** User selected a specific backup from the backup picker. */ + data class BackupSelected(val backup: LocalBackupInfo) : LocalBackupRestoreEvents() + + /** A credential (passphrase or AEP) was received from the credential entry screen. */ + data class PassphraseSubmitted(val credential: String) : LocalBackupRestoreEvents() + + /** The folder picker was dismissed without selecting a folder. */ + data object FolderPickerDismissed : LocalBackupRestoreEvents() + + /** User wants to cancel. */ + data object Cancel : LocalBackupRestoreEvents() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreResult.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreResult.kt new file mode 100644 index 0000000000..f8106f5423 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreResult.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import org.signal.core.models.AccountEntropyPool + +/** + * Result communicated back from the pre-registration local backup restore flow + * to the phone number entry screen via the result bus. + */ +sealed interface LocalBackupRestoreResult { + /** The restore completed successfully. Contains the AEP if V2 backup, null if V1. */ + data class Success(val aep: AccountEntropyPool?) : LocalBackupRestoreResult + + /** The user canceled the restore flow. The pending restore option should be cleared. */ + data object Canceled : LocalBackupRestoreResult +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreScreen.kt new file mode 100644 index 0000000000..676450d805 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreScreen.kt @@ -0,0 +1,828 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import android.net.Uri +import android.text.format.Formatter +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalIcons +import org.signal.core.util.mebiBytes +import org.signal.registration.R +import org.signal.registration.test.TestTags +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocalBackupRestoreScreen( + state: LocalBackupRestoreState, + onEvent: (LocalBackupRestoreEvents) -> Unit, + modifier: Modifier = Modifier +) { + val folderPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree() + ) { uri: Uri? -> + if (uri != null) { + onEvent(LocalBackupRestoreEvents.BackupFolderSelected(uri)) + } else { + onEvent(LocalBackupRestoreEvents.FolderPickerDismissed) + } + } + + LaunchedEffect(state.launchFolderPicker) { + if (state.launchFolderPicker) { + folderPickerLauncher.launch(null) + } + } + + when (state.restorePhase) { + LocalBackupRestoreState.RestorePhase.SelectFolder -> { + SelectFolderContent(onEvent = onEvent, modifier = modifier) + } + LocalBackupRestoreState.RestorePhase.Scanning -> { + ScanningContent(modifier = modifier) + } + LocalBackupRestoreState.RestorePhase.BackupFound -> { + BackupFoundContent(backupInfo = state.backupInfo!!, allBackups = state.allBackups, onEvent = onEvent, modifier = modifier) + } + LocalBackupRestoreState.RestorePhase.NoBackupFound -> { + NoBackupFoundContent(onEvent = onEvent, modifier = modifier) + } + LocalBackupRestoreState.RestorePhase.Preparing -> { + PreparingContent(modifier = modifier) + } + LocalBackupRestoreState.RestorePhase.InProgress -> { + InProgressContent(progressFraction = state.progressFraction, onEvent = onEvent, modifier = modifier) + } + LocalBackupRestoreState.RestorePhase.Error -> { + ErrorContent(errorMessage = state.errorMessage, onEvent = onEvent, modifier = modifier) + } + } +} + +@Composable +private fun SelectFolderContent( + onEvent: (LocalBackupRestoreEvents) -> Unit, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp) + .testTag(TestTags.LOCAL_BACKUP_RESTORE_SCREEN), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(40.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__restore_on_device_backup), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__select_folder_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(28.dp)) + + BackupOptionCard( + icon = { + Icon( + painter = painterResource(R.drawable.symbol_folder_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + }, + title = stringResource(R.string.LocalBackupRestoreScreen__choose_backup_folder), + subtitle = stringResource(R.string.LocalBackupRestoreScreen__choose_folder_subtitle), + onClick = { onEvent(LocalBackupRestoreEvents.PickBackupFolder) }, + modifier = Modifier.testTag(TestTags.LOCAL_BACKUP_RESTORE_SELECT_FOLDER_BUTTON) + ) + + Spacer(modifier = Modifier.weight(1f)) + + TextButton( + onClick = { onEvent(LocalBackupRestoreEvents.Cancel) }, + modifier = Modifier.padding(bottom = 32.dp) + ) { + Text( + text = stringResource(android.R.string.cancel), + color = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Composable +private fun ScanningContent(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .testTag(TestTags.LOCAL_BACKUP_RESTORE_SCREEN), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__scanning_folder), + style = MaterialTheme.typography.bodyLarge + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BackupFoundContent( + backupInfo: LocalBackupInfo, + allBackups: List, + onEvent: (LocalBackupRestoreEvents) -> Unit, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + var showBottomSheet by remember { mutableStateOf(false) } + + if (showBottomSheet) { + BackupPickerBottomSheet( + allBackups = allBackups, + initialSelection = backupInfo, + sheetState = sheetState, + onConfirm = { backup -> + onEvent(LocalBackupRestoreEvents.BackupSelected(backup)) + coroutineScope.launch { + sheetState.hide() + showBottomSheet = false + } + }, + onDismiss = { + coroutineScope.launch { + sheetState.hide() + showBottomSheet = false + } + } + ) + } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp) + .testTag(TestTags.LOCAL_BACKUP_RESTORE_SCREEN), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(40.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__restore_on_device_backup), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__backup_found_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(28.dp)) + + BackupInfoCard(backupInfo = backupInfo) + + Spacer(modifier = Modifier.height(16.dp)) + + if (allBackups.size > 1) { + TextButton( + onClick = { showBottomSheet = true } + ) { + Icon( + painter = SignalIcons.Backup.painter, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__choose_earlier_backup) + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Buttons.LargeTonal( + onClick = { onEvent(LocalBackupRestoreEvents.RestoreBackup) }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.LOCAL_BACKUP_RESTORE_RESTORE_BUTTON) + ) { + Text(text = stringResource(R.string.LocalBackupRestoreScreen__restore_backup)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton( + onClick = { onEvent(LocalBackupRestoreEvents.Cancel) }, + modifier = Modifier.padding(bottom = 32.dp) + ) { + Text( + text = stringResource(android.R.string.cancel), + color = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Composable +private fun BackupInfoCard( + backupInfo: LocalBackupInfo, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val formattedDate = remember(backupInfo.date) { + backupInfo.date.format(DateTimeFormatter.ofPattern("MMM d, yyyy · h:mm a")) + } + val formattedSize = remember(backupInfo.sizeBytes) { + backupInfo.sizeBytes?.let { Formatter.formatShortFileSize(context, it) } + } + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + modifier = modifier + .fillMaxWidth() + .testTag(TestTags.LOCAL_BACKUP_RESTORE_BACKUP_INFO_CARD) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__your_latest_backup), + style = MaterialTheme.typography.bodyLarge + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = SignalIcons.Recent.painter, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = formattedDate, + style = MaterialTheme.typography.bodyMedium + ) + } + + if (formattedSize != null) { + Spacer(modifier = Modifier.height(8.dp)) + + val sizeIcon = if (backupInfo.type == LocalBackupInfo.BackupType.V1) { + SignalIcons.File.painter + } else { + painterResource(R.drawable.symbol_folder_24) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = sizeIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = formattedSize, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BackupPickerBottomSheet( + allBackups: List, + initialSelection: LocalBackupInfo, + sheetState: androidx.compose.material3.SheetState, + onConfirm: (LocalBackupInfo) -> Unit, + onDismiss: () -> Unit +) { + var selected by remember(initialSelection) { mutableStateOf(initialSelection) } + + BottomSheets.BottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + BackupPickerSheetContent( + allBackups = allBackups, + selected = selected, + onSelect = { selected = it }, + onConfirm = { onConfirm(selected) } + ) + } +} + +@Composable +private fun BackupPickerSheetContent( + allBackups: List, + selected: LocalBackupInfo, + onSelect: (LocalBackupInfo) -> Unit, + onConfirm: () -> Unit +) { + val context = LocalContext.current + + Spacer(modifier = Modifier.height(24.dp)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 24.dp) + ) { + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__choose_a_backup_to_restore), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__choosing_an_older_backup_warning), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(vertical = 4.dp) + .background(MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(18.dp)) + ) { + allBackups.forEach { backup -> + val isSelected = backup == selected + val formattedDate = remember(backup.date) { + backup.date.format(DateTimeFormatter.ofPattern("MMM d, yyyy · h:mm a")) + } + val formattedSize = remember(backup.sizeBytes) { + backup.sizeBytes?.let { Formatter.formatShortFileSize(context, it) } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(backup) } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + RadioButton( + selected = isSelected, + onClick = { onSelect(backup) } + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = formattedDate, + style = MaterialTheme.typography.bodyLarge + ) + if (formattedSize != null) { + Text( + text = formattedSize, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Buttons.LargeTonal( + onClick = onConfirm, + modifier = Modifier.defaultMinSize(220.dp) + ) { + Text(text = stringResource(R.string.LocalBackupRestoreScreen__continue_button)) + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +@Composable +private fun NoBackupFoundContent( + onEvent: (LocalBackupRestoreEvents) -> Unit, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp) + .testTag(TestTags.LOCAL_BACKUP_RESTORE_SCREEN), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(40.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__no_backup_found), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__no_backup_found_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedButton( + onClick = { onEvent(LocalBackupRestoreEvents.PickBackupFolder) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.LocalBackupRestoreScreen__try_different_folder)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton( + onClick = { onEvent(LocalBackupRestoreEvents.Cancel) } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + } +} + +@Composable +private fun BackupOptionCard( + icon: @Composable () -> Unit, + title: String, + subtitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp) + ) { + icon() + + Spacer(modifier = Modifier.width(16.dp)) + + Column { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun PreparingContent(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .testTag(TestTags.LOCAL_BACKUP_RESTORE_SCREEN), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__preparing_restore), + style = MaterialTheme.typography.bodyLarge + ) + } +} + +@Composable +private fun InProgressContent( + progressFraction: Float, + onEvent: (LocalBackupRestoreEvents) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .testTag(TestTags.LOCAL_BACKUP_RESTORE_SCREEN), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__restoring_backup), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__restoring_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + LinearProgressIndicator( + progress = { progressFraction }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.LOCAL_BACKUP_RESTORE_PROGRESS_BAR) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "${(progressFraction * 100).toInt()}%", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(32.dp)) + + TextButton( + onClick = { onEvent(LocalBackupRestoreEvents.Cancel) } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + } +} + +@Composable +private fun ErrorContent( + errorMessage: String?, + onEvent: (LocalBackupRestoreEvents) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .testTag(TestTags.LOCAL_BACKUP_RESTORE_SCREEN), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.LocalBackupRestoreScreen__restore_failed), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = errorMessage ?: stringResource(R.string.LocalBackupRestoreScreen__restore_failed_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedButton( + onClick = { onEvent(LocalBackupRestoreEvents.PickBackupFolder) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.LocalBackupRestoreScreen__try_again)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton( + onClick = { onEvent(LocalBackupRestoreEvents.Cancel) } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + } +} + +@AllDevicePreviews +@Composable +private fun LocalBackupRestoreScreenSelectFolderPreview() { + Previews.Preview { + LocalBackupRestoreScreen( + state = LocalBackupRestoreState(restorePhase = LocalBackupRestoreState.RestorePhase.SelectFolder), + onEvent = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun LocalBackupRestoreScreenBackupFoundPreview() { + Previews.Preview { + LocalBackupRestoreScreen( + state = LocalBackupRestoreState( + restorePhase = LocalBackupRestoreState.RestorePhase.BackupFound, + backupInfo = LocalBackupInfo( + type = LocalBackupInfo.BackupType.V2, + date = LocalDateTime.of(2026, 3, 15, 14, 30, 0), + name = "signal-backup-2026-03-15-14-30-00", + uri = Uri.EMPTY, + sizeBytes = 511.mebiBytes.bytes + ) + ), + onEvent = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun LocalBackupRestoreScreenBackupFoundLegacyPreview() { + Previews.Preview { + LocalBackupRestoreScreen( + state = LocalBackupRestoreState( + restorePhase = LocalBackupRestoreState.RestorePhase.BackupFound, + backupInfo = LocalBackupInfo( + type = LocalBackupInfo.BackupType.V1, + date = LocalDateTime.of(2026, 3, 15, 14, 30, 0), + name = "signal-2026-03-15-14-30-00.backup", + uri = Uri.EMPTY, + sizeBytes = 1_482_184_499 + ) + ), + onEvent = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun BackupPickerBottomSheetContentPreview() { + Previews.BottomSheetPreview { + val backups = listOf( + LocalBackupInfo( + type = LocalBackupInfo.BackupType.V2, + date = LocalDateTime.of(2026, 3, 24, 3, 38, 0), + name = "signal-backup-2026-03-24-03-38-00", + uri = Uri.EMPTY, + sizeBytes = 1_482_184_499 + ), + LocalBackupInfo( + type = LocalBackupInfo.BackupType.V2, + date = LocalDateTime.of(2024, 8, 13, 3, 21, 0), + name = "signal-backup-2024-08-13-03-21-00", + uri = Uri.EMPTY, + sizeBytes = 1_439_432_704 + ) + ) + BackupPickerSheetContent( + allBackups = backups, + selected = backups[1], + onSelect = {}, + onConfirm = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun LocalBackupRestoreScreenNoBackupFoundPreview() { + Previews.Preview { + LocalBackupRestoreScreen( + state = LocalBackupRestoreState(restorePhase = LocalBackupRestoreState.RestorePhase.NoBackupFound), + onEvent = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun LocalBackupRestoreScreenInProgressPreview() { + Previews.Preview { + LocalBackupRestoreScreen( + state = LocalBackupRestoreState(restorePhase = LocalBackupRestoreState.RestorePhase.InProgress, progressFraction = 0.65f), + onEvent = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun LocalBackupRestoreScreenErrorPreview() { + Previews.Preview { + LocalBackupRestoreScreen( + state = LocalBackupRestoreState(restorePhase = LocalBackupRestoreState.RestorePhase.Error, errorMessage = "Backup file is corrupted"), + onEvent = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreState.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreState.kt new file mode 100644 index 0000000000..33f01b5b41 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreState.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import android.net.Uri +import org.signal.core.models.AccountEntropyPool +import org.signal.registration.util.DebugLoggableModel + +data class LocalBackupRestoreState( + val restorePhase: RestorePhase = RestorePhase.SelectFolder, + val backupInfo: LocalBackupInfo? = null, + val allBackups: List = emptyList(), + val selectedFolderUri: Uri? = null, + val progressFraction: Float = 0f, + val errorMessage: String? = null, + val launchFolderPicker: Boolean = false, + val aep: AccountEntropyPool? = null, + val v1Passphrase: String? = null +) : DebugLoggableModel() { + + enum class RestorePhase { + /** Waiting for user to select a backup folder. */ + SelectFolder, + + /** Scanning the selected folder for backups. */ + Scanning, + + /** A backup was found and is being displayed. */ + BackupFound, + + /** No backups were found in the selected folder. */ + NoBackupFound, + + /** Preparing the restore (reading metadata, validating). */ + Preparing, + + /** Restore is actively in progress. */ + InProgress, + + /** Restore failed. */ + Error + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt new file mode 100644 index 0000000000..e240fdc048 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt @@ -0,0 +1,217 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import android.net.Uri +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.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.signal.archive.LocalBackupRestoreProgress +import org.signal.core.models.AccountEntropyPool +import org.signal.core.ui.navigation.ResultEventBus +import org.signal.core.util.logging.Log +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationFlowState +import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.EventDrivenViewModel +import org.signal.registration.screens.util.navigateBack +import org.signal.registration.screens.util.navigateTo + +class LocalBackupRestoreViewModel( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val isPreRegistration: Boolean, + private val resultBus: ResultEventBus, + private val resultKey: String +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(LocalBackupRestoreViewModel::class) + } + + private val _localState = MutableStateFlow(LocalBackupRestoreState()) + val state = combine(_localState, parentState) { state, parentState -> applyParentState(state, parentState) } + .onEach { Log.d(TAG, "[State] $it") } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LocalBackupRestoreState()) + + private var restoreJob: Job? = null + + override suspend fun processEvent(event: LocalBackupRestoreEvents) { + applyEvent(state.value, event) { _localState.value = it } + } + + @VisibleForTesting + fun applyParentState(state: LocalBackupRestoreState, parentState: RegistrationFlowState): LocalBackupRestoreState { + return state + } + + @VisibleForTesting + suspend fun applyEvent(state: LocalBackupRestoreState, event: LocalBackupRestoreEvents, stateEmitter: (LocalBackupRestoreState) -> Unit) { + when (event) { + is LocalBackupRestoreEvents.PickBackupFolder -> { + stateEmitter(state.copy(launchFolderPicker = true)) + } + is LocalBackupRestoreEvents.BackupFolderSelected -> { + stateEmitter(applyBackupFolderSelected(state, event.uri)) + } + is LocalBackupRestoreEvents.RestoreBackup -> { + applyRestoreBackup(state) + } + is LocalBackupRestoreEvents.PassphraseSubmitted -> { + applyPassphraseSubmitted(state, event.credential, stateEmitter) + } + is LocalBackupRestoreEvents.ChooseDifferentFolder -> { + stateEmitter(LocalBackupRestoreState(launchFolderPicker = true)) + } + is LocalBackupRestoreEvents.BackupSelected -> { + stateEmitter(state.copy(backupInfo = event.backup)) + } + is LocalBackupRestoreEvents.FolderPickerDismissed -> { + stateEmitter(state.copy(launchFolderPicker = false)) + } + is LocalBackupRestoreEvents.Cancel -> { + applyCancel(stateEmitter) + } + } + } + + private fun applyBackupFolderSelected(state: LocalBackupRestoreState, uri: Uri): LocalBackupRestoreState { + scanFolder(uri) + return state.copy( + launchFolderPicker = false, + restorePhase = LocalBackupRestoreState.RestorePhase.Scanning, + selectedFolderUri = uri + ) + } + + private fun applyRestoreBackup(state: LocalBackupRestoreState) { + val backup = state.backupInfo ?: return + val credentialRoute = when (backup.type) { + LocalBackupInfo.BackupType.V1 -> RegistrationRoute.EnterLocalBackupV1Passphrase + LocalBackupInfo.BackupType.V2 -> RegistrationRoute.EnterAepScreen + } + parentEventEmitter.navigateTo(credentialRoute) + } + + private fun applyPassphraseSubmitted(state: LocalBackupRestoreState, credential: String, stateEmitter: (LocalBackupRestoreState) -> Unit) { + val backup = state.backupInfo ?: return + val updatedState = when (backup.type) { + LocalBackupInfo.BackupType.V1 -> state.copy(v1Passphrase = credential) + LocalBackupInfo.BackupType.V2 -> state.copy(aep = AccountEntropyPool(credential)) + } + stateEmitter(updatedState) + startRestore(backup, state.selectedFolderUri, credential) + } + + private fun onRestoreComplete(state: LocalBackupRestoreState) { + if (isPreRegistration) { + resultBus.sendResult(resultKey, LocalBackupRestoreResult.Success(state.aep)) + parentEventEmitter.navigateBack() + } else { + TODO("Have to pipe some information in to know where to navigate next") + } + } + + private fun applyCancel(stateEmitter: (LocalBackupRestoreState) -> Unit) { + restoreJob?.cancel() + stateEmitter(LocalBackupRestoreState()) + if (isPreRegistration) { + resultBus.sendResult(resultKey, LocalBackupRestoreResult.Canceled) + } + parentEventEmitter(RegistrationFlowEvent.NavigateBack) + } + + private fun scanFolder(uri: Uri) { + viewModelScope.launch { + try { + val backups = repository.scanLocalBackupFolder(uri) + val mostRecent = backups.firstOrNull() + if (mostRecent != null) { + _localState.value = LocalBackupRestoreState( + restorePhase = LocalBackupRestoreState.RestorePhase.BackupFound, + backupInfo = mostRecent, + allBackups = backups, + selectedFolderUri = uri + ) + } else { + _localState.value = LocalBackupRestoreState( + restorePhase = LocalBackupRestoreState.RestorePhase.NoBackupFound, + selectedFolderUri = uri + ) + } + } catch (e: Exception) { + Log.w(TAG, "Error scanning backup folder", e) + _localState.value = LocalBackupRestoreState( + restorePhase = LocalBackupRestoreState.RestorePhase.Error, + errorMessage = e.message + ) + } + } + } + + private fun startRestore(backup: LocalBackupInfo, rootUri: Uri?, credential: String) { + restoreJob?.cancel() + restoreJob = viewModelScope.launch { + val currentState = _localState.value + val restoreFlow = when (backup.type) { + LocalBackupInfo.BackupType.V1 -> repository.restoreV1Backup(backup.uri, passphrase = credential) + LocalBackupInfo.BackupType.V2 -> repository.restoreV2Backup(rootUri = rootUri!!, backupUri = backup.uri, aep = credential) + } + restoreFlow.collect { progress -> + _localState.value = when (progress) { + is LocalBackupRestoreProgress.Preparing -> LocalBackupRestoreState( + restorePhase = LocalBackupRestoreState.RestorePhase.Preparing, + aep = currentState.aep, + v1Passphrase = currentState.v1Passphrase + ) + is LocalBackupRestoreProgress.InProgress -> LocalBackupRestoreState( + restorePhase = LocalBackupRestoreState.RestorePhase.InProgress, + progressFraction = progress.progressFraction, + aep = currentState.aep, + v1Passphrase = currentState.v1Passphrase + ) + is LocalBackupRestoreProgress.Complete -> { + onRestoreComplete(_localState.value.copy(aep = currentState.aep, v1Passphrase = currentState.v1Passphrase)) + _localState.value + } + is LocalBackupRestoreProgress.Error -> { + Log.w(TAG, "Restore failed", progress.cause) + LocalBackupRestoreState( + restorePhase = LocalBackupRestoreState.RestorePhase.Error, + errorMessage = progress.cause.message, + aep = currentState.aep, + v1Passphrase = currentState.v1Passphrase + ) + } + } + } + } + } + + class Factory( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val isPreRegistration: Boolean, + private val resultBus: ResultEventBus, + private val resultKey: String + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return LocalBackupRestoreViewModel(repository, parentState, parentEventEmitter, isPreRegistration, resultBus, resultKey) as T + } + } +} 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 f28e139413..72959eaadf 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 @@ -24,6 +24,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -48,7 +49,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Buttons -import org.signal.core.ui.compose.CircularProgressWrapper import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews import org.signal.registration.R @@ -164,14 +164,18 @@ private fun ScreenContent(state: PhoneNumberEntryState, onEvent: (PhoneNumberEnt horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { - CircularProgressWrapper( - isLoading = state.showSpinner + Buttons.LargeTonal( + onClick = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) }, + enabled = !state.showSpinner && state.countryCode.isNotEmpty() && state.nationalNumber.isNotEmpty(), + modifier = Modifier.testTag(TestTags.PHONE_NUMBER_NEXT_BUTTON) ) { - Buttons.LargeTonal( - onClick = { onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) }, - enabled = state.countryCode.isNotEmpty() && state.nationalNumber.isNotEmpty(), - modifier = Modifier.testTag(TestTags.PHONE_NUMBER_NEXT_BUTTON) - ) { + if (state.showSpinner) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { Text(stringResource(R.string.RegistrationActivity_next)) } } 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 835fd05292..b88d912f61 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,6 +5,7 @@ package org.signal.registration.screens.phonenumber +import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreResult import org.signal.registration.util.DebugLoggableModel sealed class PhoneNumberEntryScreenEvents : DebugLoggableModel() { @@ -18,5 +19,8 @@ sealed class PhoneNumberEntryScreenEvents : DebugLoggableModel() { return "CaptchaCompleted(token=***)" } } + + /** The pre-registration local backup restore flow returned a result. */ + data class LocalBackupRestoreCompleted(val result: LocalBackupRestoreResult) : PhoneNumberEntryScreenEvents() 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 163a5761c0..1f16f005db 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 @@ -7,6 +7,7 @@ package org.signal.registration.screens.phonenumber import org.signal.registration.NetworkController import org.signal.registration.NetworkController.SessionMetadata +import org.signal.registration.PendingRestoreOption import org.signal.registration.PreExistingRegistrationData import org.signal.registration.util.DebugLoggable import org.signal.registration.util.DebugLoggableModel @@ -24,7 +25,8 @@ data class PhoneNumberEntryState( val showSpinner: Boolean = false, val oneTimeEvent: OneTimeEvent? = null, val preExistingRegistrationData: PreExistingRegistrationData? = null, - val restoredSvrCredentials: List = emptyList() + val restoredSvrCredentials: List = emptyList(), + val pendingRestoreOption: PendingRestoreOption? = null ) : DebugLoggableModel() { sealed interface OneTimeEvent : DebugLoggable { data object NetworkError : 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 6cdd90944e..796c43a866 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 @@ -19,12 +19,16 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +import org.signal.core.models.AccountEntropyPool import org.signal.core.util.logging.Log import org.signal.registration.NetworkController +import org.signal.registration.PendingRestoreOption import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowState import org.signal.registration.RegistrationRepository import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.EventDrivenViewModel +import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreResult import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent import org.signal.registration.screens.util.navigateTo @@ -32,7 +36,7 @@ class PhoneNumberEntryViewModel( val repository: RegistrationRepository, private val parentState: StateFlow, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit -) : ViewModel() { +) : EventDrivenViewModel(TAG) { companion object { private val TAG = Log.tag(PhoneNumberEntryViewModel::class) @@ -56,18 +60,14 @@ class PhoneNumberEntryViewModel( } } - fun onEvent(event: PhoneNumberEntryScreenEvents) { - Log.d(TAG, "[Event] $event") - viewModelScope.launch { - val stateEmitter: (PhoneNumberEntryState) -> Unit = { state -> - _state.value = state - } - applyEvent(state.value, event, stateEmitter, parentEventEmitter) + override suspend fun processEvent(event: PhoneNumberEntryScreenEvents) { + applyEvent(state.value, event, parentEventEmitter) { + _state.value = it } } @VisibleForTesting - suspend fun applyEvent(state: PhoneNumberEntryState, event: PhoneNumberEntryScreenEvents, stateEmitter: (PhoneNumberEntryState) -> Unit, parentEventEmitter: (RegistrationFlowEvent) -> Unit) { + suspend fun applyEvent(state: PhoneNumberEntryState, event: PhoneNumberEntryScreenEvents, parentEventEmitter: (RegistrationFlowEvent) -> Unit, stateEmitter: (PhoneNumberEntryState) -> Unit) { when (event) { is PhoneNumberEntryScreenEvents.CountryCodeChanged -> { stateEmitter(applyCountryCodeChanged(state, event.value)) @@ -90,6 +90,19 @@ class PhoneNumberEntryViewModel( is PhoneNumberEntryScreenEvents.CaptchaCompleted -> { stateEmitter(applyCaptchaCompleted(state, event.token, parentEventEmitter)) } + is PhoneNumberEntryScreenEvents.LocalBackupRestoreCompleted -> { + when (event.result) { + is LocalBackupRestoreResult.Success -> { + var localState = state.copy(showSpinner = true) + stateEmitter(localState) + localState = applyLocalBackupRestoreCompleted(localState, event.result.aep, parentEventEmitter) + stateEmitter(localState.copy(showSpinner = false)) + } + is LocalBackupRestoreResult.Canceled -> { + parentEventEmitter(RegistrationFlowEvent.PendingRestoreOptionSelected(null)) + } + } + } is PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent -> { stateEmitter(state.copy(oneTimeEvent = null)) } @@ -102,7 +115,8 @@ class PhoneNumberEntryViewModel( sessionE164 = parentState.sessionE164, sessionMetadata = parentState.sessionMetadata, preExistingRegistrationData = parentState.preExistingRegistrationData, - restoredSvrCredentials = state.restoredSvrCredentials.takeUnless { parentState.doNotAttemptRecoveryPassword } ?: emptyList() + restoredSvrCredentials = state.restoredSvrCredentials.takeUnless { parentState.doNotAttemptRecoveryPassword } ?: emptyList(), + pendingRestoreOption = parentState.pendingRestoreOption ) } @@ -162,6 +176,18 @@ class PhoneNumberEntryViewModel( val e164 = "+${inputState.countryCode}${inputState.nationalNumber}" var state = inputState.copy() + // If the user selected a restore option before entering their phone number, navigate to the restore flow + if (state.pendingRestoreOption != null) { + parentEventEmitter(RegistrationFlowEvent.E164Chosen(e164)) + when (state.pendingRestoreOption) { + PendingRestoreOption.LocalBackup -> parentEventEmitter.navigateTo(RegistrationRoute.LocalBackupRestore(isPreRegistration = true)) + PendingRestoreOption.RemoteBackup -> { + Log.w(TAG, "[PendingRestore] Remote backup restore not yet implemented") + } + } + return state + } + // If we're re-registering for the same number we used to be registered for, we should try to skip right to registration if (state.preExistingRegistrationData?.e164 == e164) { val masterKey = state.preExistingRegistrationData.aep.deriveMasterKey() @@ -231,6 +257,105 @@ class PhoneNumberEntryViewModel( } } + return applySessionBasedRegistration(state, e164, parentEventEmitter) + } + + /** + * Handles the result of a pre-registration local backup restore. + * If an AEP was obtained (V2 backup), attempts RRP-based registration. + * Falls back to SVR check and SMS verification if RRP fails or no AEP is available. + */ + private suspend fun applyLocalBackupRestoreCompleted( + inputState: PhoneNumberEntryState, + aep: AccountEntropyPool?, + parentEventEmitter: (RegistrationFlowEvent) -> Unit + ): PhoneNumberEntryState { + val e164 = inputState.sessionE164 ?: "+${inputState.countryCode}${inputState.nationalNumber}" + val state = inputState.copy() + + if (aep == null) { + Log.i(TAG, "[LocalRestore] No AEP available (V1 backup). Proceeding to session-based registration.") + return applySessionBasedRegistration(state, e164, parentEventEmitter) + } + + parentEventEmitter(RegistrationFlowEvent.AepSubmittedViaLocalBackupRestore(aep)) + + Log.i(TAG, "[LocalRestore] Attempting registration with RRP derived from restored AEP.") + + val recoveryPassword = aep.deriveMasterKey().deriveRegistrationRecoveryPassword() + + return when (val result = repository.registerAccountWithRecoveryPassword(e164, recoveryPassword, existingAccountEntropyPool = aep)) { + is NetworkController.RegistrationNetworkResult.Success -> { + Log.i(TAG, "[LocalRestore] Successfully registered using RRP from restored AEP.") + val (response, keyMaterial) = result.data + + parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool)) + + if (response.storageCapable) { + parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore) + } else { + parentEventEmitter.navigateTo(RegistrationRoute.PinCreate) + } + state + } + is NetworkController.RegistrationNetworkResult.Failure -> { + when (result.error) { + is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> { + Log.w(TAG, "[LocalRestore] RRP incorrect. Falling back to session-based registration.") + parentEventEmitter(RegistrationFlowEvent.RecoveryPasswordInvalid) + applySessionBasedRegistration(state, e164, parentEventEmitter) + } + is NetworkController.RegisterAccountError.InvalidRequest -> { + Log.w(TAG, "[LocalRestore] Invalid request. Falling back to session-based registration. Message: ${result.error.message}") + parentEventEmitter(RegistrationFlowEvent.RecoveryPasswordInvalid) + applySessionBasedRegistration(state, e164, parentEventEmitter) + } + is NetworkController.RegisterAccountError.RegistrationLock -> { + Log.w(TAG, "[LocalRestore] Registration locked.") + parentEventEmitter.navigateTo( + RegistrationRoute.PinEntryForRegistrationLock( + timeRemaining = result.error.data.timeRemaining, + svrCredentials = result.error.data.svr2Credentials + ) + ) + state + } + is NetworkController.RegisterAccountError.RateLimited -> { + Log.w(TAG, "[LocalRestore] Rate limited (retryAfter: ${result.error.retryAfter}).") + state.copy(oneTimeEvent = OneTimeEvent.RateLimited(result.error.retryAfter)) + } + is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> { + Log.w(TAG, "[LocalRestore] Session not found. Falling back to session-based registration.") + applySessionBasedRegistration(state, e164, parentEventEmitter) + } + is NetworkController.RegisterAccountError.DeviceTransferPossible -> { + Log.w(TAG, "[LocalRestore] Device transfer possible. Falling back to session-based registration.") + applySessionBasedRegistration(state, e164, parentEventEmitter) + } + } + } + is NetworkController.RegistrationNetworkResult.NetworkError -> { + Log.w(TAG, "[LocalRestore] Network error.", result.exception) + state.copy(oneTimeEvent = OneTimeEvent.NetworkError) + } + is NetworkController.RegistrationNetworkResult.ApplicationError -> { + Log.w(TAG, "[LocalRestore] Application error.", result.exception) + state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + } + } + + /** + * Checks SVR credentials, then creates a session and requests an SMS verification code. + * This is the shared fallback path used by both phone number submission and local backup restore completion. + */ + private suspend fun applySessionBasedRegistration( + inputState: PhoneNumberEntryState, + e164: String, + parentEventEmitter: (RegistrationFlowEvent) -> Unit + ): PhoneNumberEntryState { + var state = inputState.copy() + // Detect if we have valid SVR credentials for the current number. If so, we can go right to the PIN entry screen. // If they successfully restore the master key at that screen, we can use that to build the RRP and register without SMS. if (state.restoredSvrCredentials.isNotEmpty()) { 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 05fc991da5..3e75c9bfb6 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 @@ -15,13 +15,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import org.signal.core.util.logging.Log import org.signal.registration.NetworkController import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowState import org.signal.registration.RegistrationRepository import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.EventDrivenViewModel import org.signal.registration.screens.util.navigateTo /** @@ -33,7 +33,7 @@ class PinCreationViewModel( private val repository: RegistrationRepository, private val parentState: StateFlow, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit -) : ViewModel() { +) : EventDrivenViewModel(TAG) { companion object { private val TAG = Log.tag(PinCreationViewModel::class) @@ -50,11 +50,8 @@ class PinCreationViewModel( .onEach { Log.d(TAG, "[State] $it") } .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) - } + override suspend fun processEvent(event: PinCreationScreenEvents) { + applyEvent(state.value, event) } @VisibleForTesting 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 00048b63c6..940f7a4512 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 @@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import org.signal.core.models.MasterKey import org.signal.core.util.logging.Log import org.signal.registration.NetworkController @@ -22,6 +21,7 @@ import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowState import org.signal.registration.RegistrationRepository import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.EventDrivenViewModel import org.signal.registration.screens.util.navigateTo /** @@ -36,7 +36,7 @@ class PinEntryForRegistrationLockViewModel( private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, private val timeRemaining: Long, private val svrCredentials: NetworkController.SvrCredentials -) : ViewModel() { +) : EventDrivenViewModel(TAG) { companion object { private val TAG = Log.tag(PinEntryForRegistrationLockViewModel::class) @@ -52,18 +52,12 @@ class PinEntryForRegistrationLockViewModel( .onEach { Log.d(TAG, "[State] $it") } .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 - } - applyEvent(state.value, event, stateEmitter, parentEventEmitter) - } + override suspend fun processEvent(event: PinEntryScreenEvents) { + applyEvent(state.value, event, parentEventEmitter) { _state.value = it } } @VisibleForTesting - suspend fun applyEvent(state: PinEntryState, event: PinEntryScreenEvents, stateEmitter: (PinEntryState) -> Unit, parentEventEmitter: (RegistrationFlowEvent) -> Unit) { + suspend fun applyEvent(state: PinEntryState, event: PinEntryScreenEvents, parentEventEmitter: (RegistrationFlowEvent) -> Unit, stateEmitter: (PinEntryState) -> Unit) { when (event) { is PinEntryScreenEvents.PinEntered -> { var localState = state.copy(loading = true) 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 d205b9aa4d..a5b47f9acf 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 @@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import org.signal.core.models.MasterKey import org.signal.core.util.logging.Log import org.signal.libsignal.protocol.util.Hex @@ -24,6 +23,7 @@ import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowState import org.signal.registration.RegistrationRepository import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.EventDrivenViewModel import org.signal.registration.screens.util.navigateBack import org.signal.registration.screens.util.navigateTo import org.signal.registration.util.SensitiveLog @@ -39,7 +39,7 @@ class PinEntryForSmsBypassViewModel( private val parentState: StateFlow, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, private val svrCredentials: NetworkController.SvrCredentials -) : ViewModel() { +) : EventDrivenViewModel(TAG) { companion object { private val TAG = Log.tag(PinEntryForSmsBypassViewModel::class) @@ -56,20 +56,16 @@ class PinEntryForSmsBypassViewModel( .onEach { Log.d(TAG, "[State] $it") } .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) - } + override suspend fun processEvent(event: PinEntryScreenEvents) { + applyEvent(state.value, event, parentEventEmitter) { _state.value = it } } @VisibleForTesting suspend fun applyEvent( state: PinEntryState, event: PinEntryScreenEvents, - stateEmitter: (PinEntryState) -> Unit, - parentEventEmitter: (RegistrationFlowEvent) -> Unit + parentEventEmitter: (RegistrationFlowEvent) -> Unit, + stateEmitter: (PinEntryState) -> Unit ) { when (event) { is PinEntryScreenEvents.PinEntered -> { 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 962297c3d7..28dce14b59 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 @@ -14,13 +14,13 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import org.signal.core.util.logging.Log import org.signal.registration.NetworkController import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowState import org.signal.registration.RegistrationRepository import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.EventDrivenViewModel import org.signal.registration.screens.util.navigateTo /** @@ -33,7 +33,7 @@ class PinEntryForSvrRestoreViewModel( private val repository: RegistrationRepository, private val parentState: StateFlow, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit -) : ViewModel() { +) : EventDrivenViewModel(TAG) { companion object { private val TAG = Log.tag(PinEntryForSvrRestoreViewModel::class) @@ -49,22 +49,16 @@ class PinEntryForSvrRestoreViewModel( .onEach { Log.d(TAG, "[State] $it") } .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 - } - applyEvent(state.value, event, stateEmitter, parentEventEmitter) - } + override suspend fun processEvent(event: PinEntryScreenEvents) { + applyEvent(state.value, event, parentEventEmitter) { _state.value = it } } @VisibleForTesting suspend fun applyEvent( state: PinEntryState, event: PinEntryScreenEvents, - stateEmitter: (PinEntryState) -> Unit, - parentEventEmitter: (RegistrationFlowEvent) -> Unit + parentEventEmitter: (RegistrationFlowEvent) -> Unit, + stateEmitter: (PinEntryState) -> Unit ) { when (event) { is PinEntryScreenEvents.PinEntered -> { 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 index 8a1e6f1dc9..8bc76b06f0 100644 --- 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 @@ -20,13 +20,14 @@ 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.EventDrivenViewModel 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() { +) : EventDrivenViewModel(TAG) { companion object { private val TAG = Log.tag(QuickRestoreQrViewModel::class) @@ -41,14 +42,8 @@ class QuickRestoreQrViewModel( 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) - } + override suspend fun processEvent(event: QuickRestoreQrEvents) { + applyEvent(state.value, event) { _localState.value = it } } @VisibleForTesting diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt index badb9b828f..1e09c6b4ec 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt @@ -17,17 +17,20 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.signal.core.util.logging.Log +import org.signal.registration.PendingRestoreOption import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowState import org.signal.registration.RegistrationRepository import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.EventDrivenViewModel import org.signal.registration.screens.util.navigateTo class ArchiveRestoreSelectionViewModel( private val repository: RegistrationRepository, private val parentState: StateFlow, - private val parentEventEmitter: (RegistrationFlowEvent) -> Unit -) : ViewModel() { + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val isPreRegistration: Boolean +) : EventDrivenViewModel(TAG) { companion object { private val TAG = Log.tag(ArchiveRestoreSelectionViewModel::class) @@ -39,31 +42,60 @@ class ArchiveRestoreSelectionViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ArchiveRestoreSelectionState()) init { -// viewModelScope.launch { -// val options = repository.isSignalSecureBackupAvailable() -// _localState.value = _localState.value.copy(restoreOptions = options) -// } + viewModelScope.launch { + val options = repository.getAvailableRestoreOptions() + _localState.value = _localState.value.copy(restoreOptions = options.toList()) + } } - fun onEvent(event: ArchiveRestoreSelectionScreenEvents) { - Log.d(TAG, "[Event] $event") - viewModelScope.launch { - val stateEmitter: (ArchiveRestoreSelectionState) -> Unit = { newState -> - _localState.value = newState - } - applyEvent(state.value, event, stateEmitter) - } + override suspend fun processEvent(event: ArchiveRestoreSelectionScreenEvents) { + applyEvent(state.value, event) { _localState.value = it } } @VisibleForTesting suspend fun applyEvent(state: ArchiveRestoreSelectionState, event: ArchiveRestoreSelectionScreenEvents, stateEmitter: (ArchiveRestoreSelectionState) -> Unit) { val result = when (event) { is ArchiveRestoreSelectionScreenEvents.RestoreOptionSelected -> { - Log.w(TAG, "Restore option selected: ${event.option}, but flow not yet implemented") // TODO [registration] - Handle restore option selection - state + when (event.option) { + ArchiveRestoreOption.LocalBackup -> { + if (isPreRegistration) { + parentEventEmitter(RegistrationFlowEvent.PendingRestoreOptionSelected(PendingRestoreOption.LocalBackup)) + parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) + } else { + parentEventEmitter.navigateTo(RegistrationRoute.LocalBackupRestore(isPreRegistration = false)) + } + state + } + ArchiveRestoreOption.SignalSecureBackup -> { + if (isPreRegistration) { + parentEventEmitter(RegistrationFlowEvent.PendingRestoreOptionSelected(PendingRestoreOption.RemoteBackup)) + parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) + } else { + Log.w(TAG, "Signal secure backup restore not yet implemented") + } + state + } + ArchiveRestoreOption.DeviceTransfer -> { + Log.w(TAG, "Device transfer not yet implemented") + state + } + ArchiveRestoreOption.None -> { + if (isPreRegistration) { + parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) + state + } else { + state.copy(showSkipRestoreWarning = true) + } + } + } } is ArchiveRestoreSelectionScreenEvents.Skip -> { - state.copy(showSkipRestoreWarning = true) + if (isPreRegistration) { + parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) + state + } else { + state.copy(showSkipRestoreWarning = true) + } } is ArchiveRestoreSelectionScreenEvents.ConfirmSkip -> { parentEventEmitter.navigateTo(RegistrationRoute.PinCreate) @@ -84,10 +116,11 @@ class ArchiveRestoreSelectionViewModel( class Factory( private val repository: RegistrationRepository, private val parentState: StateFlow, - private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val isPreRegistration: Boolean ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return ArchiveRestoreSelectionViewModel(repository, parentState, parentEventEmitter) as T + return ArchiveRestoreSelectionViewModel(repository, parentState, parentEventEmitter, isPreRegistration) as T } } } 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 32609549d6..0f5edd4204 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 @@ -15,13 +15,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import org.signal.core.util.logging.Log import org.signal.registration.NetworkController import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowState import org.signal.registration.RegistrationRepository import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.EventDrivenViewModel import org.signal.registration.screens.util.navigateBack import org.signal.registration.screens.util.navigateTo import org.signal.registration.screens.verificationcode.VerificationCodeState.OneTimeEvent @@ -34,7 +34,7 @@ class VerificationCodeViewModel( private val parentState: StateFlow, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, private val clock: () -> Long = { System.currentTimeMillis() } -) : ViewModel() { +) : EventDrivenViewModel(TAG) { companion object { private val TAG = Log.tag(VerificationCodeViewModel::class) @@ -48,14 +48,8 @@ class VerificationCodeViewModel( private var nextSmsAvailableAt: Duration = 0.seconds 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 - } - applyEvent(state.value, event, stateEmitter) - } + override suspend fun processEvent(event: VerificationCodeScreenEvents) { + applyEvent(state.value, event) { _localState.value = it } } @VisibleForTesting diff --git a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt index 8eaac7b23a..b24728beda 100644 --- a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt +++ b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt @@ -50,4 +50,12 @@ object TestTags { const val ARCHIVE_RESTORE_SELECTION_FROM_BACKUP_FILE = "archive_restore_selection_from_backup_file" const val ARCHIVE_RESTORE_SELECTION_DEVICE_TRANSFER = "archive_restore_selection_device_transfer" const val ARCHIVE_RESTORE_SELECTION_SKIP = "archive_restore_selection_skip" + + // Local Backup Restore Screen + const val LOCAL_BACKUP_RESTORE_SCREEN = "local_backup_restore_screen" + const val LOCAL_BACKUP_RESTORE_SELECT_FOLDER_BUTTON = "local_backup_restore_select_folder_button" + const val LOCAL_BACKUP_RESTORE_BACKUP_INFO_CARD = "local_backup_restore_backup_info_card" + const val LOCAL_BACKUP_RESTORE_RESTORE_BUTTON = "local_backup_restore_restore_button" + const val LOCAL_BACKUP_RESTORE_PROGRESS_BAR = "local_backup_restore_progress_bar" + const val LOCAL_BACKUP_RESTORE_CONTINUE_BUTTON = "local_backup_restore_continue_button" } diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 35bd9bbe2d..8304f2ab9a 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -130,4 +130,79 @@ If you skip restore now you will not be able to restore later. If you re-enable backups after skipping restore, your current backup will be replaced with your new messaging history. Skip restore + + + + Restore on-device backup + + Restore your messages from the backup you saved on your device. If you don\'t restore now, you won\'t be able to restore later. + + Choose backup folder + + Select the folder on your device where your backup is stored + + Looking for backups… + + Restore your messages from the backup folder you saved on your device. If you don\'t restore now, you won\'t be able to restore later. + + Your latest backup: + + Legacy backup + + Restore backup + + Choose an earlier backup + + Choose a backup to restore + + Choosing an older backup may result in lost messages or media. + + No backup found + + No Signal backup was found in the selected folder. Please try a different folder. + + Try a different folder + + Enter backup passphrase + + Your local backup passphrase is a 30-digit code required to recover your account and data. + + Recovery key + + Too long (%1$d/%2$d) + + No passphrase? + + Next + + + Enter your recovery key + + Your recovery key is a 64-character code required to recover your account and data. + + Recovery key + + No recovery key? + + Too long (%1$d/%2$d) + + Invalid recovery key + + Preparing restore… + + Restoring backup + + Please wait while your messages and data are being restored. + + Restore complete + + Your messages and account data have been restored successfully. + + Continue + + Restore failed + + An error occurred while restoring your backup. Please try again. + + Try again diff --git a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt index 33414c1041..0b9bf20cfb 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt @@ -70,8 +70,8 @@ class PhoneNumberEntryViewModelTest { viewModel.applyEvent( initialState, PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567"), - stateEmitter, - parentEventEmitter + parentEventEmitter, + stateEmitter ) assertThat(emittedStates).hasSize(1) @@ -86,8 +86,8 @@ class PhoneNumberEntryViewModelTest { viewModel.applyEvent( initialState, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551234567"), - stateEmitter, - parentEventEmitter + parentEventEmitter, + stateEmitter ) assertThat(emittedStates).hasSize(1) @@ -99,25 +99,25 @@ class PhoneNumberEntryViewModelTest { fun `PhoneNumberChanged formats progressively as digits are added`() = runTest { var state = PhoneNumberEntryState() - viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5"), parentEventEmitter, stateEmitter) state = emittedStates.last() assertThat(state.nationalNumber).isEqualTo("5") - viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55"), parentEventEmitter, stateEmitter) state = emittedStates.last() assertThat(state.nationalNumber).isEqualTo("55") - viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("555"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("555"), parentEventEmitter, stateEmitter) state = emittedStates.last() assertThat(state.nationalNumber).isEqualTo("555") - viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551"), parentEventEmitter, stateEmitter) state = emittedStates.last() assertThat(state.nationalNumber).isEqualTo("5551") // libphonenumber formats progressively - at 4 digits it's still building the format assertThat(state.formattedNumber).isEqualTo("555-1") - viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55512"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55512"), parentEventEmitter, stateEmitter) state = emittedStates.last() assertThat(state.nationalNumber).isEqualTo("55512") assertThat(state.formattedNumber).isEqualTo("555-12") @@ -130,8 +130,8 @@ class PhoneNumberEntryViewModelTest { viewModel.applyEvent( initialState, PhoneNumberEntryScreenEvents.PhoneNumberChanged("(555) abc 123-4567!"), - stateEmitter, - parentEventEmitter + parentEventEmitter, + stateEmitter ) assertThat(emittedStates).hasSize(1) @@ -145,8 +145,8 @@ class PhoneNumberEntryViewModelTest { viewModel.applyEvent( initialState, PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567"), - stateEmitter, - parentEventEmitter + parentEventEmitter, + stateEmitter ) // Should emit the same state since digits haven't changed @@ -161,8 +161,8 @@ class PhoneNumberEntryViewModelTest { viewModel.applyEvent( initialState, PhoneNumberEntryScreenEvents.CountryCodeChanged("44"), - stateEmitter, - parentEventEmitter + parentEventEmitter, + stateEmitter ) assertThat(emittedStates).hasSize(1) @@ -177,8 +177,8 @@ class PhoneNumberEntryViewModelTest { viewModel.applyEvent( initialState, PhoneNumberEntryScreenEvents.CountryCodeChanged("+44abc"), - stateEmitter, - parentEventEmitter + parentEventEmitter, + stateEmitter ) assertThat(emittedStates).hasSize(1) @@ -192,8 +192,8 @@ class PhoneNumberEntryViewModelTest { viewModel.applyEvent( initialState, PhoneNumberEntryScreenEvents.CountryCodeChanged("12345"), - stateEmitter, - parentEventEmitter + parentEventEmitter, + stateEmitter ) assertThat(emittedStates).hasSize(1) @@ -211,7 +211,7 @@ class PhoneNumberEntryViewModelTest { ) // Change to UK - viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("44"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("44"), parentEventEmitter, stateEmitter) assertThat(emittedStates).hasSize(1) assertThat(emittedStates.last().countryCode).isEqualTo("44") @@ -227,8 +227,8 @@ class PhoneNumberEntryViewModelTest { viewModel.applyEvent( initialState, PhoneNumberEntryScreenEvents.CountryPicker, - stateEmitter, - parentEventEmitter + parentEventEmitter, + stateEmitter ) assertThat(emittedEvents).hasSize(1) @@ -246,8 +246,8 @@ class PhoneNumberEntryViewModelTest { viewModel.applyEvent( initialState, PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent, - stateEmitter, - parentEventEmitter + parentEventEmitter, + stateEmitter ) assertThat(emittedStates).hasSize(1) @@ -259,12 +259,12 @@ class PhoneNumberEntryViewModelTest { var state = PhoneNumberEntryState() // Set German country code - viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("49"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("49"), parentEventEmitter, stateEmitter) state = emittedStates.last() assertThat(state.regionCode).isEqualTo("DE") // Enter a German number - viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("15123456789"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("15123456789"), parentEventEmitter, stateEmitter) state = emittedStates.last() assertThat(state.nationalNumber).isEqualTo("15123456789") } @@ -285,7 +285,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -313,7 +313,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -338,7 +338,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -362,7 +362,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -381,7 +381,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -400,7 +400,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -421,7 +421,7 @@ class PhoneNumberEntryViewModelTest { coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns NetworkController.RegistrationNetworkResult.Success(existingSession) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -453,7 +453,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -478,7 +478,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -504,7 +504,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -531,7 +531,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -560,7 +560,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -594,7 +594,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -632,7 +632,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -665,7 +665,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -698,7 +698,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -730,7 +730,7 @@ class PhoneNumberEntryViewModelTest { nationalNumber = "5551234567" ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Verify spinner states assertThat(emittedStates.first().showSpinner).isTrue() @@ -756,7 +756,7 @@ class PhoneNumberEntryViewModelTest { coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns NetworkController.RegistrationNetworkResult.Success(sessionMetadata) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), parentEventEmitter, stateEmitter) assertThat(emittedEvents).hasSize(3) assertThat(emittedEvents[0]).isInstanceOf() @@ -771,7 +771,7 @@ class PhoneNumberEntryViewModelTest { fun `CaptchaCompleted returns error when no session exists`() = runTest { val initialState = PhoneNumberEntryState(sessionMetadata = null) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), parentEventEmitter, stateEmitter) assertThat(emittedStates).hasSize(1) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError) @@ -785,7 +785,7 @@ class PhoneNumberEntryViewModelTest { coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns NetworkController.RegistrationNetworkResult.Success(sessionWithCaptcha) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), parentEventEmitter, stateEmitter) assertThat(emittedEvents).hasSize(1) assertThat(emittedEvents.first()) @@ -804,7 +804,7 @@ class PhoneNumberEntryViewModelTest { NetworkController.UpdateSessionError.RateLimited(45.seconds, sessionMetadata) ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), parentEventEmitter, stateEmitter) assertThat(emittedStates).hasSize(1) assertThat(emittedStates.last().oneTimeEvent).isNotNull() @@ -823,7 +823,7 @@ class PhoneNumberEntryViewModelTest { NetworkController.UpdateSessionError.RejectedUpdate("Invalid captcha") ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), parentEventEmitter, stateEmitter) assertThat(emittedStates).hasSize(1) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError) @@ -837,7 +837,7 @@ class PhoneNumberEntryViewModelTest { coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Connection lost")) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), parentEventEmitter, stateEmitter) assertThat(emittedStates).hasSize(1) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError) @@ -902,7 +902,7 @@ class PhoneNumberEntryViewModelTest { preExistingRegistrationData = preExistingData ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedEvents.first()).isInstanceOf() assertThat(emittedEvents[1]) @@ -929,7 +929,7 @@ class PhoneNumberEntryViewModelTest { preExistingRegistrationData = preExistingData ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedEvents.first()).isInstanceOf() assertThat(emittedEvents[1]) @@ -956,7 +956,7 @@ class PhoneNumberEntryViewModelTest { preExistingRegistrationData = preExistingData ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedEvents).hasSize(1) assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState) @@ -980,7 +980,7 @@ class PhoneNumberEntryViewModelTest { preExistingRegistrationData = preExistingData ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedEvents).hasSize(1) assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState) @@ -1009,7 +1009,7 @@ class PhoneNumberEntryViewModelTest { preExistingRegistrationData = preExistingData ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedEvents).hasSize(1) assertThat(emittedEvents.first()) @@ -1036,7 +1036,7 @@ class PhoneNumberEntryViewModelTest { preExistingRegistrationData = preExistingData ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedStates.last().oneTimeEvent).isNotNull() .isInstanceOf() @@ -1067,7 +1067,7 @@ class PhoneNumberEntryViewModelTest { preExistingRegistrationData = preExistingData ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Should emit RecoveryPasswordInvalid and then continue to session creation assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.RecoveryPasswordInvalid) @@ -1098,7 +1098,7 @@ class PhoneNumberEntryViewModelTest { preExistingRegistrationData = preExistingData ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.RecoveryPasswordInvalid) assertThat(emittedStates.last().preExistingRegistrationData).isNull() @@ -1120,7 +1120,7 @@ class PhoneNumberEntryViewModelTest { preExistingRegistrationData = preExistingData ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError) } @@ -1141,7 +1141,7 @@ class PhoneNumberEntryViewModelTest { preExistingRegistrationData = preExistingData ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError) } @@ -1165,7 +1165,7 @@ class PhoneNumberEntryViewModelTest { preExistingRegistrationData = preExistingData ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Should skip RRP and go to session creation flow coVerify(exactly = 0) { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any()) } @@ -1196,7 +1196,7 @@ class PhoneNumberEntryViewModelTest { restoredSvrCredentials = svrCredentials ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedEvents).hasSize(2) assertThat(emittedEvents[0]).isInstanceOf() @@ -1229,7 +1229,7 @@ class PhoneNumberEntryViewModelTest { restoredSvrCredentials = svrCredentials ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Should fall through to session creation assertThat(emittedEvents.last()) @@ -1258,7 +1258,7 @@ class PhoneNumberEntryViewModelTest { restoredSvrCredentials = svrCredentials ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) // Should ignore error and fall through assertThat(emittedEvents.last()) @@ -1287,7 +1287,7 @@ class PhoneNumberEntryViewModelTest { restoredSvrCredentials = svrCredentials ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedEvents.last()) .isInstanceOf() @@ -1317,7 +1317,7 @@ class PhoneNumberEntryViewModelTest { restoredSvrCredentials = svrCredentials ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedEvents.last()) .isInstanceOf() @@ -1347,7 +1347,7 @@ class PhoneNumberEntryViewModelTest { restoredSvrCredentials = svrCredentials ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) assertThat(emittedEvents.last()) .isInstanceOf() @@ -1370,7 +1370,7 @@ class PhoneNumberEntryViewModelTest { restoredSvrCredentials = emptyList() ) - viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter) coVerify(exactly = 0) { mockRepository.checkSvrCredentials(any(), any()) } assertThat(emittedEvents.last()) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt index fe1af6c4d9..b7db543478 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt @@ -77,7 +77,7 @@ class PinEntryForRegistrationLockViewModelTest { coEvery { mockRepository.registerAccountWithSession(any(), any(), any(), any()) } returns NetworkController.RegistrationNetworkResult.Success(registerResponse to keyMaterial) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(3) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -98,7 +98,7 @@ class PinEntryForRegistrationLockViewModelTest { NetworkController.RestoreMasterKeyError.WrongPin(triesRemaining) ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("wrong-pin"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("wrong-pin"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(0) assertThat(emittedStates.last().triesRemaining).isEqualTo(triesRemaining) @@ -113,7 +113,7 @@ class PinEntryForRegistrationLockViewModelTest { NetworkController.RestoreMasterKeyError.NoDataFound ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents.first()) @@ -131,7 +131,7 @@ class PinEntryForRegistrationLockViewModelTest { coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = true) } returns NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error")) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(0) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError) @@ -144,7 +144,7 @@ class PinEntryForRegistrationLockViewModelTest { coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = true) } returns NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected")) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(0) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError) @@ -163,7 +163,7 @@ class PinEntryForRegistrationLockViewModelTest { coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = true) } returns NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey)) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(2) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -183,7 +183,7 @@ class PinEntryForRegistrationLockViewModelTest { coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = true) } returns NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey)) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(2) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -205,7 +205,7 @@ class PinEntryForRegistrationLockViewModelTest { NetworkController.RegisterAccountError.RegistrationLock(registrationLockData) ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(2) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -225,7 +225,7 @@ class PinEntryForRegistrationLockViewModelTest { NetworkController.RegisterAccountError.RateLimited(retryAfter) ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -247,7 +247,7 @@ class PinEntryForRegistrationLockViewModelTest { NetworkController.RegisterAccountError.InvalidRequest("Bad request") ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -266,7 +266,7 @@ class PinEntryForRegistrationLockViewModelTest { NetworkController.RegisterAccountError.DeviceTransferPossible ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -283,7 +283,7 @@ class PinEntryForRegistrationLockViewModelTest { coEvery { mockRepository.registerAccountWithSession(any(), any(), any(), any()) } returns NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error")) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -300,7 +300,7 @@ class PinEntryForRegistrationLockViewModelTest { coEvery { mockRepository.registerAccountWithSession(any(), any(), any(), any()) } returns NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected")) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -313,7 +313,7 @@ class PinEntryForRegistrationLockViewModelTest { fun `ToggleKeyboard toggles isAlphanumericKeyboard from false to true`() = runTest { val initialState = PinEntryState(isAlphanumericKeyboard = false) - viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, parentEventEmitter, stateEmitter) assertThat(emittedStates.last().isAlphanumericKeyboard).isEqualTo(true) } @@ -322,7 +322,7 @@ class PinEntryForRegistrationLockViewModelTest { fun `ToggleKeyboard toggles isAlphanumericKeyboard from true to false`() = runTest { val initialState = PinEntryState(isAlphanumericKeyboard = true) - viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, parentEventEmitter, stateEmitter) assertThat(emittedStates.last().isAlphanumericKeyboard).isEqualTo(false) } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt index aaf5fd6a49..40463f9bda 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt @@ -74,7 +74,7 @@ class PinEntryForSmsBypassViewModelTest { coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns NetworkController.RegistrationNetworkResult.Success(mockk(relaxed = true)) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(2) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -94,7 +94,7 @@ class PinEntryForSmsBypassViewModelTest { coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns NetworkController.RegistrationNetworkResult.Success(mockk(relaxed = true)) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) coVerify { mockRepository.enqueueSvrResetGuessCountJob() } } @@ -109,7 +109,7 @@ class PinEntryForSmsBypassViewModelTest { NetworkController.RestoreMasterKeyError.WrongPin(triesRemaining) ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("wrong-pin"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("wrong-pin"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(0) assertThat(emittedStates.last().triesRemaining).isEqualTo(triesRemaining) @@ -124,7 +124,7 @@ class PinEntryForSmsBypassViewModelTest { NetworkController.RestoreMasterKeyError.NoDataFound ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(2) assertThat(emittedParentEvents[0]).isEqualTo(RegistrationFlowEvent.RecoveryPasswordInvalid) @@ -138,7 +138,7 @@ class PinEntryForSmsBypassViewModelTest { coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns NetworkController.RegistrationNetworkResult.NetworkError(IOException("Network error")) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(0) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError) @@ -151,7 +151,7 @@ class PinEntryForSmsBypassViewModelTest { coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected")) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(0) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError) @@ -161,7 +161,7 @@ class PinEntryForSmsBypassViewModelTest { fun `PinEntered with missing e164 emits ResetState`() = runTest { val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = null) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents[0]).isEqualTo(RegistrationFlowEvent.ResetState) @@ -179,7 +179,7 @@ class PinEntryForSmsBypassViewModelTest { coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns NetworkController.RegistrationNetworkResult.NetworkError(IOException("Network error")) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -196,7 +196,7 @@ class PinEntryForSmsBypassViewModelTest { coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any()) } returns NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected")) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -215,7 +215,7 @@ class PinEntryForSmsBypassViewModelTest { NetworkController.RegisterAccountError.DeviceTransferPossible ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(2) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -234,7 +234,7 @@ class PinEntryForSmsBypassViewModelTest { NetworkController.RegisterAccountError.InvalidRequest("Bad request") ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(3) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -255,7 +255,7 @@ class PinEntryForSmsBypassViewModelTest { NetworkController.RegisterAccountError.RateLimited(retryAfter) ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -281,7 +281,7 @@ class PinEntryForSmsBypassViewModelTest { coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), registrationLock = any(), any()) } returns NetworkController.RegistrationNetworkResult.Success(mockk(relaxed = true)) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(2) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -305,7 +305,7 @@ class PinEntryForSmsBypassViewModelTest { NetworkController.RegisterAccountError.RegistrationLock(registrationLockData) ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) // First retry (without reglock) -> reglock error -> retry with reglock -> reglock error again -> RecoveryPasswordInvalid + NavigateBack assertThat(emittedParentEvents).hasSize(3) @@ -326,7 +326,7 @@ class PinEntryForSmsBypassViewModelTest { NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect("Wrong password") ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(3) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -346,7 +346,7 @@ class PinEntryForSmsBypassViewModelTest { NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified("Not found") ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(2) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -381,7 +381,7 @@ class PinEntryForSmsBypassViewModelTest { fun `ToggleKeyboard toggles isAlphanumericKeyboard from false to true`() = runTest { val initialState = PinEntryState(isAlphanumericKeyboard = false) - viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, parentEventEmitter, stateEmitter) assertThat(emittedStates.last().isAlphanumericKeyboard).isEqualTo(true) } @@ -390,7 +390,7 @@ class PinEntryForSmsBypassViewModelTest { fun `ToggleKeyboard toggles isAlphanumericKeyboard from true to false`() = runTest { val initialState = PinEntryState(isAlphanumericKeyboard = true) - viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, parentEventEmitter, stateEmitter) assertThat(emittedStates.last().isAlphanumericKeyboard).isEqualTo(false) } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt index 8f20d95c20..6f32985dda 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt @@ -69,7 +69,7 @@ class PinEntryForSvrRestoreViewModelTest { coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns NetworkController.RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey)) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(2) assertThat(emittedParentEvents[0]).isInstanceOf() @@ -90,7 +90,7 @@ class PinEntryForSvrRestoreViewModelTest { NetworkController.GetSvrCredentialsError.NoServiceCredentialsAvailable ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState) @@ -105,7 +105,7 @@ class PinEntryForSvrRestoreViewModelTest { NetworkController.GetSvrCredentialsError.Unauthorized ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState) @@ -118,7 +118,7 @@ class PinEntryForSvrRestoreViewModelTest { coEvery { mockRepository.getSvrCredentials() } returns NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error")) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(0) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError) @@ -131,7 +131,7 @@ class PinEntryForSvrRestoreViewModelTest { coEvery { mockRepository.getSvrCredentials() } returns NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected")) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(0) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError) @@ -155,7 +155,7 @@ class PinEntryForSvrRestoreViewModelTest { NetworkController.RestoreMasterKeyError.WrongPin(triesRemaining) ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("wrong-pin"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("wrong-pin"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(0) assertThat(emittedStates.last().triesRemaining).isEqualTo(triesRemaining) @@ -176,7 +176,7 @@ class PinEntryForSvrRestoreViewModelTest { NetworkController.RestoreMasterKeyError.NoDataFound ) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents.first()) @@ -198,7 +198,7 @@ class PinEntryForSvrRestoreViewModelTest { coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error")) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(0) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.NetworkError) @@ -217,7 +217,7 @@ class PinEntryForSvrRestoreViewModelTest { coEvery { mockRepository.restoreMasterKeyFromSvr(any(), any(), any(), forRegistrationLock = false) } returns NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected")) - viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter) assertThat(emittedParentEvents).hasSize(0) assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError) @@ -229,7 +229,7 @@ class PinEntryForSvrRestoreViewModelTest { fun `ToggleKeyboard toggles isAlphanumericKeyboard from false to true`() = runTest { val initialState = PinEntryState(isAlphanumericKeyboard = false) - viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, parentEventEmitter, stateEmitter) assertThat(emittedStates.last().isAlphanumericKeyboard).isEqualTo(true) } @@ -238,7 +238,7 @@ class PinEntryForSvrRestoreViewModelTest { fun `ToggleKeyboard toggles isAlphanumericKeyboard from true to false`() = runTest { val initialState = PinEntryState(isAlphanumericKeyboard = true) - viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, stateEmitter, parentEventEmitter) + viewModel.applyEvent(initialState, PinEntryScreenEvents.ToggleKeyboard, parentEventEmitter, stateEmitter) assertThat(emittedStates.last().isAlphanumericKeyboard).isEqualTo(false) }