mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-14 12:10:36 +01:00
Add basic infra for regV5 local restore.
This commit is contained in:
+11
-14
@@ -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>
|
||||
+129
-1
@@ -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))
|
||||
|
||||
+22
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+13
-2
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
+115
-10
@@ -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> {
|
||||
|
||||
+32
-10
@@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+44
@@ -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)
|
||||
}
|
||||
+103
@@ -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)
|
||||
}
|
||||
+19
@@ -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()
|
||||
}
|
||||
+240
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
+95
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+284
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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
|
||||
}
|
||||
}
|
||||
+35
@@ -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()
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
+828
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
}
|
||||
+217
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-8
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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()
|
||||
}
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+135
-10
@@ -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()) {
|
||||
|
||||
+4
-7
@@ -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
|
||||
|
||||
+5
-11
@@ -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)
|
||||
|
||||
+6
-10
@@ -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 -> {
|
||||
|
||||
+6
-12
@@ -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 -> {
|
||||
|
||||
+4
-9
@@ -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
|
||||
|
||||
+52
-19
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-10
@@ -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>
|
||||
|
||||
+67
-67
@@ -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())
|
||||
|
||||
+15
-15
@@ -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)
|
||||
}
|
||||
|
||||
+18
-18
@@ -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)
|
||||
}
|
||||
|
||||
+11
-11
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user