Add basic infra for regV5 local restore.

This commit is contained in:
Greyson Parrelli
2026-03-21 13:33:38 -04:00
parent ce6f39ae68
commit bb151c91e9
45 changed files with 2840 additions and 250 deletions
@@ -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<List<LogLine>> callback) {
executor.execute(() -> callback.onResult(getPrefixLogLinesInternal()));
public void getPrefixLogLines(@NonNull Consumer<List<LogLine>> callback) {
executor.execute(() -> callback.accept(getPrefixLogLinesInternal()));
}
public void buildAndSubmitLog(@NonNull Callback<Optional<String>> callback) {
public void buildAndSubmitLog(@NonNull Consumer<Optional<String>> 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<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogFromReaderInternal(logReader, trace)));
public void submitLogFromReader(DebugLogsViewer.LogReader logReader, @Nullable byte[] trace, Consumer<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> callback.accept(submitLogFromReaderInternal(logReader, trace)));
}
public void writeLogToDisk(@NonNull Uri uri, long untilTime, Callback<Boolean> callback) {
public void writeLogToDisk(@NonNull Uri uri, long untilTime, Consumer<Boolean> 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<E> {
void onResult(E result);
}
}
@@ -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)),
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12.683,1.69c-0.276,-0.066 -0.56,-0.065 -0.86,-0.065h-2.56c-0.809,0 -1.469,0 -2.005,0.044 -0.554,0.045 -1.052,0.142 -1.517,0.378a3.875,3.875 0,0 0,-1.694 1.694c-0.236,0.465 -0.333,0.963 -0.378,1.517 -0.044,0.536 -0.044,1.196 -0.044,2.005v9.474c0,0.809 0,1.469 0.044,2.005 0.045,0.554 0.142,1.052 0.378,1.517 0.372,0.73 0.965,1.322 1.694,1.694 0.465,0.236 0.963,0.333 1.517,0.378 0.536,0.044 1.196,0.044 2.005,0.044h5.474c0.809,0 1.469,0 2.005,-0.044 0.554,-0.045 1.052,-0.142 1.517,-0.378a3.875,3.875 0,0 0,1.694 -1.694c0.236,-0.465 0.333,-0.963 0.378,-1.517 0.044,-0.536 0.044,-1.196 0.044,-2.005v-6.56c0,-0.3 0,-0.584 -0.066,-0.86a2.376,2.376 0,0 0,-0.284 -0.687c-0.148,-0.242 -0.35,-0.442 -0.562,-0.655l-5.438,-5.438c-0.213,-0.213 -0.413,-0.413 -0.655,-0.562a2.374,2.374 0,0 0,-0.687 -0.284ZM11.125,3.375L9.3,3.375c-0.855,0 -1.443,0 -1.9,0.038 -0.445,0.036 -0.688,0.103 -0.865,0.194 -0.4,0.203 -0.725,0.528 -0.928,0.928 -0.09,0.177 -0.158,0.42 -0.194,0.866 -0.037,0.456 -0.038,1.045 -0.038,1.899v9.4c0,0.855 0,1.443 0.038,1.9 0.036,0.445 0.103,0.688 0.194,0.865 0.203,0.4 0.528,0.725 0.928,0.928 0.177,0.09 0.42,0.158 0.866,0.194 0.456,0.037 1.044,0.038 1.899,0.038h5.4c0.855,0 1.443,0 1.9,-0.038 0.445,-0.036 0.688,-0.103 0.865,-0.194 0.4,-0.203 0.725,-0.528 0.928,-0.928 0.09,-0.177 0.158,-0.42 0.194,-0.866 0.037,-0.456 0.038,-1.044 0.038,-1.899v-5.825h-1.862c-0.809,0 -1.469,0 -2.005,-0.044 -0.554,-0.045 -1.052,-0.141 -1.517,-0.378a3.875,3.875 0,0 1,-1.694 -1.694c-0.237,-0.465 -0.333,-0.963 -0.378,-1.517 -0.044,-0.536 -0.044,-1.196 -0.044,-2.005L11.125,3.375ZM18.338,9.125L17.013,8l-2.988,-2.988 -1.15,-1.35L12.875,5.2c0,0.855 0,1.443 0.038,1.9 0.036,0.445 0.103,0.688 0.194,0.865 0.203,0.4 0.528,0.725 0.928,0.928 0.177,0.09 0.42,0.158 0.866,0.194 0.456,0.037 1.044,0.038 1.899,0.038h1.538Z"
android:fillColor="#000"
android:fillType="evenOdd"/>
</vector>
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.14,13.14a0.75,0.75 0,0 0,0.36 -0.666l-0.25,-7.25a0.75,0.75 0,0 0,-1.5 0l-0.217,6.321 -4.319,0.206a0.75,0.75 0,0 0,0 1.498l5.25,0.25a0.75,0.75 0,0 0,0.676 -0.359Z"
android:fillColor="#000"/>
<path
android:pathData="M12,1.125C5.994,1.125 1.125,5.994 1.125,12S5.994,22.875 12,22.875 22.875,18.006 22.875,12 18.006,1.125 12,1.125ZM2.875,12a9.125,9.125 0,1 1,18.25 0,9.125 9.125,0 0,1 -18.25,0Z"
android:fillColor="#000"
android:fillType="evenOdd"/>
</vector>
@@ -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<ArchiveRestoreOption> {
return setOf(
ArchiveRestoreOption.SignalSecureBackup,
ArchiveRestoreOption.LocalBackup,
ArchiveRestoreOption.DeviceTransfer
)
}
override suspend fun scanLocalBackupFolder(folderUri: Uri): List<LocalBackupInfo> = 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<LocalBackupInfo>()
// 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<LocalBackupRestoreProgress> = 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<LocalBackupRestoreProgress> = 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))
@@ -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,
+5
View File
@@ -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"))
@@ -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
}
@@ -21,7 +21,9 @@ data class PersistedFlowState(
val backStack: List<RegistrationRoute>,
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) }
)
}
@@ -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
}
@@ -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)"
}
}
@@ -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<NavKey>.navigationEntries(
}
}
ResultEffect<LocalBackupRestoreResult>(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<NavKey>.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<RegistrationRoute.ChooseRestoreOptionBeforeRegistration> {
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<NavKey>.navigationEntries(
}
entry<RegistrationRoute.ChooseRestoreOptionAfterRegistration> {
// TODO: Implement RestoreScreen
TODO("Implement RestoreScreen")
}
// -- Local Backup Restore Screen
entry<RegistrationRoute.LocalBackupRestore> { 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<String?>(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<RegistrationRoute.EnterLocalBackupV1Passphrase> {
EnterLocalBackupV1PassphaseScreen(
onSubmit = { passphrase ->
registrationViewModel.resultBus.sendResult(BACKUP_CREDENTIAL_RESULT, passphrase)
parentEventEmitter.navigateBack()
},
onCancel = {
parentEventEmitter.navigateBack()
}
)
}
// -- Enter AEP
entry<RegistrationRoute.EnterAepScreen> {
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<RegistrationRoute.QuickRestoreQrScan> {
@@ -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<SessionMetadata, CreateSessionError> = 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<Pair<RegisterAccountResponse, KeyMaterial>, 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<ArchiveRestoreOption> = withContext(Dispatchers.IO) {
storageController.getAvailableRestoreOptions()
}
fun restoreV1Backup(uri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> {
return storageController.restoreLocalBackupV1(uri, passphrase)
}
fun restoreV2Backup(rootUri: Uri, backupUri: Uri, aep: String): Flow<LocalBackupRestoreProgress> {
return storageController.restoreLocalBackupV2(rootUri, backupUri, aep)
}
suspend fun scanLocalBackupFolder(folderUri: Uri): List<LocalBackupInfo> = 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 }
}
}
@@ -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,
@@ -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<ArchiveRestoreOption>
/**
* 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<LocalBackupRestoreProgress>
/**
* 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<LocalBackupRestoreProgress>
/**
* 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<LocalBackupInfo>
}
/**
@@ -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<E : DebugLoggable>(
private val tag: String
) : ViewModel() {
private val eventChannel = Channel<E>(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)
}
@@ -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)
}
@@ -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()
}
@@ -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 = {}
)
}
}
@@ -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
}
@@ -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<EnterAepState> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
return EnterAepViewModel(parentEventEmitter, resultBus, resultKey) as T
}
}
}
@@ -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))
}
}
}
@@ -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
}
}
@@ -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()
}
@@ -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
}
@@ -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<LocalBackupInfo>,
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<LocalBackupInfo>,
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<LocalBackupInfo>,
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 = {}
)
}
}
@@ -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<LocalBackupInfo> = 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
}
}
@@ -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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val isPreRegistration: Boolean,
private val resultBus: ResultEventBus,
private val resultKey: String
) : EventDrivenViewModel<LocalBackupRestoreEvents>(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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val isPreRegistration: Boolean,
private val resultBus: ResultEventBus,
private val resultKey: String
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return LocalBackupRestoreViewModel(repository, parentState, parentEventEmitter, isPreRegistration, resultBus, resultKey) as T
}
}
}
@@ -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))
}
}
@@ -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()
}
@@ -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<NetworkController.SvrCredentials> = emptyList()
val restoredSvrCredentials: List<NetworkController.SvrCredentials> = emptyList(),
val pendingRestoreOption: PendingRestoreOption? = null
) : DebugLoggableModel() {
sealed interface OneTimeEvent : DebugLoggable {
data object NetworkError : OneTimeEvent
@@ -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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModel() {
) : EventDrivenViewModel<PhoneNumberEntryScreenEvents>(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()) {
@@ -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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModel() {
) : EventDrivenViewModel<PinCreationScreenEvents>(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
@@ -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<PinEntryScreenEvents>(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)
@@ -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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val svrCredentials: NetworkController.SvrCredentials
) : ViewModel() {
) : EventDrivenViewModel<PinEntryScreenEvents>(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 -> {
@@ -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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModel() {
) : EventDrivenViewModel<PinEntryScreenEvents>(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 -> {
@@ -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<QuickRestoreQrEvents>(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
@@ -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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModel() {
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val isPreRegistration: Boolean
) : EventDrivenViewModel<ArchiveRestoreSelectionScreenEvents>(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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val isPreRegistration: Boolean
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ArchiveRestoreSelectionViewModel(repository, parentState, parentEventEmitter) as T
return ArchiveRestoreSelectionViewModel(repository, parentState, parentEventEmitter, isPreRegistration) as T
}
}
}
@@ -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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val clock: () -> Long = { System.currentTimeMillis() }
) : ViewModel() {
) : EventDrivenViewModel<VerificationCodeScreenEvents>(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
@@ -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"
}
@@ -130,4 +130,79 @@
<string name="ArchiveRestoreSelectionScreen__skip_restore_dialog_warning">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.</string>
<!-- Confirm button text for the skip restore dialog -->
<string name="ArchiveRestoreSelectionScreen__skip_restore_dialog_confirm_button">Skip restore</string>
<!-- LocalBackupRestoreScreen -->
<!-- Title for the local backup restore screen -->
<string name="LocalBackupRestoreScreen__restore_on_device_backup">Restore on-device backup</string>
<!-- Description on the folder selection screen -->
<string name="LocalBackupRestoreScreen__select_folder_description">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.</string>
<!-- Title for the choose backup folder card -->
<string name="LocalBackupRestoreScreen__choose_backup_folder">Choose backup folder</string>
<!-- Subtitle for the choose backup folder card -->
<string name="LocalBackupRestoreScreen__choose_folder_subtitle">Select the folder on your device where your backup is stored</string>
<!-- Text shown while scanning for backups -->
<string name="LocalBackupRestoreScreen__scanning_folder">Looking for backups…</string>
<!-- Description shown when a backup is found -->
<string name="LocalBackupRestoreScreen__backup_found_description">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.</string>
<!-- Title for the latest backup card -->
<string name="LocalBackupRestoreScreen__your_latest_backup">Your latest backup:</string>
<!-- Label for a legacy backup type -->
<string name="LocalBackupRestoreScreen__legacy_backup">Legacy backup</string>
<!-- Restore backup button text -->
<string name="LocalBackupRestoreScreen__restore_backup">Restore backup</string>
<!-- Link to choose an earlier backup -->
<string name="LocalBackupRestoreScreen__choose_earlier_backup">Choose an earlier backup</string>
<!-- Title for the backup picker bottom sheet -->
<string name="LocalBackupRestoreScreen__choose_a_backup_to_restore">Choose a backup to restore</string>
<!-- Warning text in the backup picker bottom sheet -->
<string name="LocalBackupRestoreScreen__choosing_an_older_backup_warning">Choosing an older backup may result in lost messages or media.</string>
<!-- Title when no backup is found -->
<string name="LocalBackupRestoreScreen__no_backup_found">No backup found</string>
<!-- Description when no backup is found -->
<string name="LocalBackupRestoreScreen__no_backup_found_description">No Signal backup was found in the selected folder. Please try a different folder.</string>
<!-- Button to try a different folder -->
<string name="LocalBackupRestoreScreen__try_different_folder">Try a different folder</string>
<!-- Title for V1 passphrase entry screen -->
<string name="LocalBackupRestoreScreen__enter_backup_passphrase">Enter backup passphrase</string>
<!-- Description for V1 passphrase entry screen -->
<string name="LocalBackupRestoreScreen__enter_the_30_digit_passphrase">Your local backup passphrase is a 30-digit code required to recover your account and data.</string>
<!-- Label for the recovery key text field -->
<string name="LocalBackupRestoreScreen__recovery_key">Recovery key</string>
<!-- Error text when passphrase is too long -->
<string name="LocalBackupRestoreScreen__too_long">Too long (%1$d/%2$d)</string>
<!-- Link for users who don't have their passphrase -->
<string name="LocalBackupRestoreScreen__no_passphrase">No passphrase?</string>
<!-- Next button text -->
<string name="LocalBackupRestoreScreen__next">Next</string>
<!-- EnterAepScreen -->
<!-- Title for recovery key entry screen -->
<string name="EnterAepScreen__enter_your_recovery_key">Enter your recovery key</string>
<!-- Description for recovery key entry screen -->
<string name="EnterAepScreen__your_recovery_key_is_a_64_character_code">Your recovery key is a 64-character code required to recover your account and data.</string>
<!-- Label for the recovery key text field -->
<string name="EnterAepScreen__recovery_key">Recovery key</string>
<!-- Link for users who don't have their recovery key -->
<string name="EnterAepScreen__no_recovery_key">No recovery key?</string>
<!-- Error text when key is too long -->
<string name="EnterAepScreen__too_long">Too long (%1$d/%2$d)</string>
<!-- Error text when key is invalid -->
<string name="EnterAepScreen__invalid_recovery_key">Invalid recovery key</string>
<!-- Text shown while preparing the restore -->
<string name="LocalBackupRestoreScreen__preparing_restore">Preparing restore…</string>
<!-- Title shown while restore is in progress -->
<string name="LocalBackupRestoreScreen__restoring_backup">Restoring backup</string>
<!-- Description shown while restore is in progress -->
<string name="LocalBackupRestoreScreen__restoring_description">Please wait while your messages and data are being restored.</string>
<!-- Title shown when restore is complete -->
<string name="LocalBackupRestoreScreen__restore_complete">Restore complete</string>
<!-- Description shown when restore is complete -->
<string name="LocalBackupRestoreScreen__restore_complete_description">Your messages and account data have been restored successfully.</string>
<!-- Continue button text after restore is complete -->
<string name="LocalBackupRestoreScreen__continue_button">Continue</string>
<!-- Title shown when restore fails -->
<string name="LocalBackupRestoreScreen__restore_failed">Restore failed</string>
<!-- Description shown when restore fails with an unknown error -->
<string name="LocalBackupRestoreScreen__restore_failed_description">An error occurred while restoring your backup. Please try again.</string>
<!-- Button to retry restore after failure -->
<string name="LocalBackupRestoreScreen__try_again">Try again</string>
</resources>
@@ -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<RegistrationFlowEvent.SessionUpdated>()
@@ -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<RegistrationFlowEvent.Registered>()
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<RegistrationFlowEvent.Registered>()
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<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
@@ -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<RegistrationFlowEvent.E164Chosen>()
@@ -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<RegistrationFlowEvent.NavigateToScreen>()
@@ -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<RegistrationFlowEvent.NavigateToScreen>()
@@ -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<RegistrationFlowEvent.NavigateToScreen>()
@@ -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())
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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)
}
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -281,7 +281,7 @@ class PinEntryForSmsBypassViewModelTest {
coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), registrationLock = any<String>(), 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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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)
}
@@ -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<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
@@ -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)
}