Add support for remote backup restore to regV5.

This commit is contained in:
Greyson Parrelli
2026-04-16 15:52:12 -04:00
parent 76e30ab09f
commit 82046dd55f
50 changed files with 1922 additions and 262 deletions
@@ -14,6 +14,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
@@ -48,6 +49,7 @@ import org.signal.registration.proto.RegistrationProvisionMessage
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.gcm.FcmUtil
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
@@ -538,7 +540,7 @@ class AppRegistrationNetworkController(
}
}
override suspend fun getRemoteBackupInfo(): RequestResult<NetworkController.GetBackupInfoResponse, NetworkController.GetBackupInfoError> = withContext(Dispatchers.IO) {
override suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult<NetworkController.GetBackupInfoResponse, NetworkController.GetBackupInfoError> = withContext(Dispatchers.IO) {
val aci = SignalStore.account.aci ?: return@withContext RequestResult.ApplicationError(IllegalStateException("ACI not available"))
val currentTime = System.currentTimeMillis()
@@ -589,6 +591,42 @@ class AppRegistrationNetworkController(
}
}
override suspend fun enqueueAccountAttributesSyncJob() {
AppDependencies.jobManager.add(RefreshAttributesJob())
}
override suspend fun getBackupFileLastModified(
aep: AccountEntropyPool,
backupInfo: NetworkController.GetBackupInfoResponse
): RequestResult<Long, NetworkController.GetBackupInfoError> = withContext(Dispatchers.IO) {
val aci = SignalStore.account.aci ?: return@withContext RequestResult.ApplicationError(IllegalStateException("ACI not available"))
val cdn = backupInfo.cdn ?: return@withContext RequestResult.ApplicationError(IllegalStateException("CDN number not available"))
val backupDir = backupInfo.backupDir ?: return@withContext RequestResult.ApplicationError(IllegalStateException("Backup dir not available"))
val backupName = backupInfo.backupName ?: return@withContext RequestResult.ApplicationError(IllegalStateException("Backup name not available"))
val currentTime = System.currentTimeMillis()
val messageCredential = SignalStore.backup.messageCredentials.byDay.getForCurrentTime(currentTime.milliseconds)
?: return@withContext RequestResult.ApplicationError(IllegalStateException("No message credential available"))
val access = ArchiveServiceAccess(messageCredential, SignalStore.backup.messageBackupKey)
val cdnCredentials = when (val cdnResult = SignalNetwork.archive.getCdnReadCredentials(cdn, aci, access)) {
is NetworkResult.Success -> cdnResult.result.headers
is NetworkResult.StatusCodeError -> return@withContext RequestResult.ApplicationError(IllegalStateException("Failed to get CDN credentials: ${cdnResult.code}"))
is NetworkResult.NetworkError -> return@withContext RequestResult.RetryableNetworkError(cdnResult.exception)
is NetworkResult.ApplicationError -> return@withContext RequestResult.ApplicationError(cdnResult.throwable)
}
try {
val lastModified = AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(cdn, cdnCredentials, "backups/$backupDir/$backupName")
RequestResult.Success(lastModified.toInstant().toEpochMilli())
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override fun startProvisioning(): Flow<ProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<java.io.Closeable>()
val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration()
@@ -9,11 +9,17 @@ import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.archive.LocalBackupRestoreProgress
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
@@ -22,8 +28,11 @@ import org.signal.registration.PreExistingRegistrationData
import org.signal.registration.StorageController
import org.signal.registration.proto.RegistrationData
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress
import org.thoughtcrime.securesms.backup.FullBackupImporter
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.RemoteRestoreResult
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
@@ -318,6 +327,57 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
backups.sortedByDescending { it.date }
}
override fun restoreRemoteBackup(aep: AccountEntropyPool): Flow<RemoteBackupRestoreProgress> = callbackFlow {
val subscriber = object {
@Subscribe(threadMode = ThreadMode.POSTING)
fun onRestoreEvent(event: RestoreV2Event) {
val progress = when (event.type) {
RestoreV2Event.Type.PROGRESS_DOWNLOAD -> RemoteBackupRestoreProgress.Downloading(event.count.inWholeBytes, event.estimatedTotalCount.inWholeBytes)
RestoreV2Event.Type.PROGRESS_RESTORE -> RemoteBackupRestoreProgress.Restoring(event.count.inWholeBytes, event.estimatedTotalCount.inWholeBytes)
RestoreV2Event.Type.PROGRESS_FINALIZING -> RemoteBackupRestoreProgress.Finalizing
}
trySend(progress)
}
}
EventBus.getDefault().register(subscriber)
launch(Dispatchers.IO) {
try {
when (BackupRepository.restoreRemoteBackup()) {
RemoteRestoreResult.Success -> {
send(RemoteBackupRestoreProgress.Complete)
}
RemoteRestoreResult.NetworkError -> {
send(RemoteBackupRestoreProgress.NetworkError())
}
RemoteRestoreResult.Canceled -> {
send(RemoteBackupRestoreProgress.Canceled)
}
RemoteRestoreResult.Failure -> {
if (SignalStore.backup.hasInvalidBackupVersion) {
send(RemoteBackupRestoreProgress.InvalidBackupVersion)
} else {
send(RemoteBackupRestoreProgress.GenericError())
}
}
RemoteRestoreResult.PermanentSvrBFailure -> {
send(RemoteBackupRestoreProgress.PermanentSvrBFailure)
}
}
} catch (e: Exception) {
Log.w(TAG, "Remote restore failed", e)
send(RemoteBackupRestoreProgress.GenericError(e))
} finally {
channel.close()
}
}
awaitClose {
EventBus.getDefault().unregister(subscriber)
}
}
private suspend fun writeRegistrationData(data: RegistrationData) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, TEMP_PROTO_FILENAME)
file.writeBytes(RegistrationData.ADAPTER.encode(data))
+30
View File
@@ -0,0 +1,30 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
plugins {
id("java-library")
id("org.jetbrains.kotlin.jvm")
alias(libs.plugins.kotlinx.serialization)
id("ktlint")
}
java {
sourceCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get())
targetCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get())
}
kotlin {
jvmToolchain {
languageVersion = JavaLanguageVersion.of(libs.versions.kotlinJvmTarget.get())
}
}
dependencies {
implementation(project(":core:util-jvm"))
implementation(project(":core:models-jvm"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.libsignal.client)
}
@@ -0,0 +1,26 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.signal.core.models.AccountEntropyPool
class AccountEntropyPoolSerializer : KSerializer<AccountEntropyPool> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("AccountEntropyPool", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): AccountEntropyPool {
return AccountEntropyPool(decoder.decodeString())
}
override fun serialize(encoder: Encoder, value: AccountEntropyPool) {
encoder.encodeString(value.value)
}
}
@@ -0,0 +1,26 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.signal.core.util.Base64
class ByteArrayToBase64Serializer : KSerializer<ByteArray> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ByteArray", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): ByteArray {
return Base64.decode(decoder.decodeString())
}
override fun serialize(encoder: Encoder, value: ByteArray) {
encoder.encodeString(Base64.encodeWithPadding(value))
}
}
@@ -0,0 +1,27 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.signal.core.util.Base64
import org.signal.libsignal.protocol.ecc.ECPublicKey
class ECPublicKeyToBase64Serializer() : KSerializer<ECPublicKey> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ECPublicKey", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): ECPublicKey {
return ECPublicKey(Base64.decode(decoder.decodeString()))
}
override fun serialize(encoder: Encoder, value: ECPublicKey) {
encoder.encodeString(Base64.encodeWithPadding(value.serialize()))
}
}
@@ -0,0 +1,27 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.signal.core.util.Base64
import org.signal.libsignal.protocol.kem.KEMPublicKey
class KEMPublicKeyToBase64Serializer : KSerializer<KEMPublicKey> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("KEMPublicKey", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): KEMPublicKey {
return KEMPublicKey(Base64.decode(decoder.decodeString()))
}
override fun serialize(encoder: Encoder, value: KEMPublicKey) {
encoder.encodeString(Base64.encodeWithPadding(value.serialize()))
}
}
@@ -1,52 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.signal.core.util.Base64
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.protocol.kem.KEMPublicKey
class ByteArrayToBase64Serializer() : KSerializer<ByteArray> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ByteArray", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): ByteArray {
return Base64.decode(decoder.decodeString())
}
override fun serialize(encoder: Encoder, value: ByteArray) {
encoder.encodeString(Base64.encodeWithPadding(value))
}
}
class KEMPublicKeyToBase64Serializer() : KSerializer<KEMPublicKey> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("KEMPublicKey", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): KEMPublicKey {
return KEMPublicKey(Base64.decode(decoder.decodeString()))
}
override fun serialize(encoder: Encoder, value: KEMPublicKey) {
encoder.encodeString(Base64.encodeWithPadding(value.serialize()))
}
}
class ECPublicKeyToBase64Serializer() : KSerializer<ECPublicKey> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ECPublicKey", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): ECPublicKey {
return ECPublicKey(Base64.decode(decoder.decodeString()))
}
override fun serialize(encoder: Encoder, value: ECPublicKey) {
encoder.encodeString(Base64.encodeWithPadding(value.serialize()))
}
}
@@ -6,6 +6,7 @@
package org.signal.registration.sample.debug
import kotlinx.coroutines.flow.Flow
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
@@ -196,6 +197,10 @@ class DebugNetworkController(
return delegate.setAccountAttributes(attributes)
}
override suspend fun enqueueAccountAttributesSyncJob() {
delegate.enqueueAccountAttributesSyncJob()
}
override suspend fun getSvrCredentials(): RequestResult<SvrCredentials, GetSvrCredentialsError> {
NetworkDebugState.getOverride<RequestResult<SvrCredentials, GetSvrCredentialsError>>("getSvrCredentials")?.let {
Log.d(TAG, "[getSvrCredentials] Returning debug override")
@@ -220,11 +225,19 @@ class DebugNetworkController(
return delegate.checkSvrCredentials(e164, credentials)
}
override suspend fun getRemoteBackupInfo(): RequestResult<GetBackupInfoResponse, GetBackupInfoError> {
override suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult<GetBackupInfoResponse, GetBackupInfoError> {
NetworkDebugState.getOverride<RequestResult<GetBackupInfoResponse, GetBackupInfoError>>("getRemoteBackupInfo")?.let {
Log.d(TAG, "[getRemoteBackupInfo] Returning debug override")
return it
}
return delegate.getRemoteBackupInfo()
return delegate.getRemoteBackupInfo(aep)
}
override suspend fun getBackupFileLastModified(aep: AccountEntropyPool, backupInfo: NetworkController.GetBackupInfoResponse): RequestResult<Long, GetBackupInfoError> {
NetworkDebugState.getOverride<RequestResult<Long, GetBackupInfoError>>("getBackupFileLastModified")?.let {
Log.d(TAG, "[getBackupFileLastModified] Returning debug override")
return it
}
return delegate.getBackupFileLastModified(aep, backupInfo)
}
}
@@ -18,7 +18,10 @@ import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.models.ServiceId
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.Network
@@ -851,12 +854,57 @@ class DemoNetworkController(
}
}
override suspend fun getRemoteBackupInfo(): RequestResult<NetworkController.GetBackupInfoResponse, NetworkController.GetBackupInfoError> = withContext(Dispatchers.IO) {
override suspend fun enqueueAccountAttributesSyncJob() = withContext(Dispatchers.IO) {
val result = setAccountAttributes(buildCurrentAccountAttributes())
if (result !is RequestResult.Success) {
Log.w(TAG, "[enqueueAccountAttributesSyncJob] Failed to sync attributes: $result")
}
}
private fun buildCurrentAccountAttributes(): AccountAttributes {
val aep = RegistrationPreferences.aep
val registrationLock = if (RegistrationPreferences.registrationLockEnabled && aep != null) {
aep.deriveMasterKey().deriveRegistrationLock()
} else {
null
}
val recoveryPassword = aep?.deriveMasterKey()?.deriveRegistrationRecoveryPassword()
val profileKey = RegistrationPreferences.profileKey
val unidentifiedAccessKey = profileKey?.let { deriveUnidentifiedAccessKey(it) }
return AccountAttributes(
signalingKey = null,
registrationId = RegistrationPreferences.aciRegistrationId,
fetchesMessages = RegistrationPreferences.fetchesMessages,
registrationLock = registrationLock,
unidentifiedAccessKey = unidentifiedAccessKey,
unrestrictedUnidentifiedAccess = false,
discoverableByPhoneNumber = false,
capabilities = AccountAttributes.Capabilities(
storage = !RegistrationPreferences.pinsOptedOut,
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true
),
name = null,
pniRegistrationId = RegistrationPreferences.pniRegistrationId,
recoveryPassword = recoveryPassword
)
}
private fun deriveUnidentifiedAccessKey(profileKey: org.signal.libsignal.zkgroup.profiles.ProfileKey): ByteArray {
val nonce = ByteArray(12)
val input = ByteArray(16)
val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, javax.crypto.spec.SecretKeySpec(profileKey.serialize(), "AES"), javax.crypto.spec.GCMParameterSpec(128, nonce))
return cipher.doFinal(input).copyOf(16)
}
override suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult<NetworkController.GetBackupInfoResponse, NetworkController.GetBackupInfoError> = withContext(Dispatchers.IO) {
val aci = RegistrationPreferences.aci
val password = RegistrationPreferences.servicePassword
val aep = RegistrationPreferences.aep
if (aci == null || password == null || aep == null) {
if (aci == null || password == null) {
Log.w(TAG, "[getRemoteBackupInfo] Credentials not available")
return@withContext RequestResult.ApplicationError(IllegalStateException("Credentials not available"))
}
@@ -937,8 +985,8 @@ class DemoNetworkController(
* anonymous archive requests.
*/
private fun buildZkAuthHeaders(
messageBackupKey: org.signal.core.models.backup.MessageBackupKey,
aci: org.signal.core.models.ServiceId.ACI,
messageBackupKey: MessageBackupKey,
aci: ServiceId.ACI,
credential: ArchiveCredential
): Map<String, String> {
val backupServerPublicParams = GenericServerPublicParams(serviceConfiguration.backupServerPublicParams)
@@ -960,6 +1008,92 @@ class DemoNetworkController(
)
}
override suspend fun getBackupFileLastModified(
aep: AccountEntropyPool,
backupInfo: NetworkController.GetBackupInfoResponse
): RequestResult<Long, NetworkController.GetBackupInfoError> = withContext(Dispatchers.IO) {
val aci = RegistrationPreferences.aci
val password = RegistrationPreferences.servicePassword
val cdn = backupInfo.cdn
val backupDir = backupInfo.backupDir
val backupName = backupInfo.backupName
if (aci == null || password == null) {
return@withContext RequestResult.ApplicationError(IllegalStateException("Credentials not available"))
}
if (cdn == null || backupDir == null || backupName == null) {
return@withContext RequestResult.ApplicationError(IllegalStateException("Backup info incomplete"))
}
try {
val messageBackupKey = aep.deriveMessageBackupKey()
val credential = fetchArchiveServiceCredential(aci.toString(), password)
?: return@withContext RequestResult.ApplicationError(IllegalStateException("Failed to fetch archive credentials"))
val zkHeaders = buildZkAuthHeaders(messageBackupKey, aci, credential)
val cdnCredentials = fetchCdnReadCredentials(cdn, zkHeaders)
?: return@withContext RequestResult.ApplicationError(IllegalStateException("Failed to fetch CDN read credentials"))
val cdnUrls = serviceConfiguration.signalCdnUrlMap[cdn]
?: return@withContext RequestResult.ApplicationError(IllegalStateException("No CDN URL for CDN $cdn"))
val cdnUrl = cdnUrls[0].url
val request = okhttp3.Request.Builder()
.url("$cdnUrl/backups/$backupDir/$backupName")
.head()
.apply { cdnCredentials.forEach { (k, v) -> header(k, v) } }
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext RequestResult.ApplicationError(IllegalStateException("CDN HEAD failed: ${response.code}"))
}
val lastModified = response.header("Last-Modified")
?: return@withContext RequestResult.ApplicationError(IllegalStateException("No Last-Modified header"))
val dateTime = java.time.ZonedDateTime.parse(lastModified, java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME)
RequestResult.Success(dateTime.toInstant().toEpochMilli())
}
} catch (e: IOException) {
Log.w(TAG, "[getBackupFileLastModified] IOException", e)
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
Log.w(TAG, "[getBackupFileLastModified] Exception", e)
RequestResult.ApplicationError(e)
}
}
/**
* Fetches CDN read credentials via GET /v1/archives/auth/read with ZK auth headers.
*/
private fun fetchCdnReadCredentials(cdn: Int, zkHeaders: Map<String, String>): Map<String, String>? {
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
val request = okhttp3.Request.Builder()
.url("$baseUrl/v1/archives/auth/read?cdn=$cdn")
.get()
.apply { zkHeaders.forEach { (k, v) -> header(k, v) } }
.build()
okHttpClient.newCall(request).execute().use { response ->
if (response.code != 200) {
Log.w(TAG, "[fetchCdnReadCredentials] Unexpected response code: ${response.code}")
return null
}
val body = response.body.string()
val parsed = json.decodeFromString<CdnReadCredentialsResponse>(body)
return parsed.headers
}
}
@Serializable
private data class CdnReadCredentialsResponse(
val headers: Map<String, String>
)
@Serializable
private data class ArchiveCredentialsResponse(
val credentials: Map<String, List<ArchiveCredential>>
@@ -23,6 +23,7 @@ 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
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.registration.NetworkController
import org.signal.registration.NewRegistrationData
import org.signal.registration.PreExistingRegistrationData
@@ -32,6 +33,7 @@ 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.remotebackuprestore.RemoteBackupRestoreProgress
import java.io.File
import java.time.LocalDateTime
@@ -105,6 +107,10 @@ class DemoStorageController(private val context: Context) : StorageController {
if (data.accountEntropyPool.isNotEmpty()) {
RegistrationPreferences.aep = AccountEntropyPool(data.accountEntropyPool)
}
if (data.profileKey.size > 0) {
RegistrationPreferences.profileKey = ProfileKey(data.profileKey.toByteArray())
}
RegistrationPreferences.fetchesMessages = data.fetchesMessages
// Pre-keys
if (data.aciSignedPreKey.size > 0) {
@@ -280,6 +286,28 @@ class DemoStorageController(private val context: Context) : StorageController {
Log.d(TAG, "Simulated V2 restore complete.")
}.flowOn(Dispatchers.IO)
override fun restoreRemoteBackup(aep: AccountEntropyPool): Flow<RemoteBackupRestoreProgress> = flow {
Log.d(TAG, "Starting simulated remote backup restore")
val totalBytes = 10_000_000L
for (i in 1..4) {
emit(RemoteBackupRestoreProgress.Downloading(bytesDownloaded = totalBytes * i / 4, totalBytes = totalBytes))
delay(250)
}
for (i in 1..4) {
emit(RemoteBackupRestoreProgress.Restoring(bytesRead = totalBytes * i / 4, totalBytes = totalBytes))
delay(250)
}
emit(RemoteBackupRestoreProgress.Finalizing)
delay(250)
emit(RemoteBackupRestoreProgress.Complete)
Log.d(TAG, "Simulated remote 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))
@@ -160,15 +160,23 @@ class PinSettingsViewModel(
val newOptedOut = !currentlyOptedOut
viewModelScope.launch {
val profileKey = RegistrationPreferences.profileKey
val unidentifiedAccessKey = profileKey?.let { deriveUnidentifiedAccessKey(it) }
val aep = RegistrationPreferences.aep
val recoveryPassword = aep?.deriveMasterKey()?.deriveRegistrationRecoveryPassword()
val registrationLock = if (RegistrationPreferences.registrationLockEnabled && aep != null) {
aep.deriveMasterKey().deriveRegistrationLock()
} else {
null
}
val attributes = NetworkController.AccountAttributes(
signalingKey = null,
registrationId = RegistrationPreferences.aciRegistrationId,
voice = true,
video = true,
fetchesMessages = true,
registrationLock = null,
unidentifiedAccessKey = null,
unrestrictedUnidentifiedAccess = false,
fetchesMessages = RegistrationPreferences.fetchesMessages,
registrationLock = registrationLock,
unidentifiedAccessKey = unidentifiedAccessKey,
unrestrictedUnidentifiedAccess = unidentifiedAccessKey == null,
discoverableByPhoneNumber = false,
capabilities = NetworkController.AccountAttributes.Capabilities(
storage = !newOptedOut,
@@ -178,7 +186,7 @@ class PinSettingsViewModel(
),
name = null,
pniRegistrationId = RegistrationPreferences.pniRegistrationId,
recoveryPassword = null
recoveryPassword = recoveryPassword
)
when (val result = networkController.setAccountAttributes(attributes)) {
@@ -216,6 +224,14 @@ class PinSettingsViewModel(
}
}
private fun deriveUnidentifiedAccessKey(profileKey: org.signal.libsignal.zkgroup.profiles.ProfileKey): ByteArray {
val nonce = ByteArray(12)
val input = ByteArray(16)
val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, javax.crypto.spec.SecretKeySpec(profileKey.serialize(), "AES"), javax.crypto.spec.GCMParameterSpec(128, nonce))
return cipher.doFinal(input).copyOf(16)
}
private fun dismissMessage() {
_state.value = _state.value.copy(toastMessage = null)
}
@@ -51,6 +51,7 @@ object RegistrationPreferences {
private const val KEY_BACKUP_TIMESTAMP_MS = "backup_timestamp_ms"
private const val KEY_BACKUP_SIZE_BYTES = "backup_size_bytes"
private const val KEY_OTHER_DEVICE_PLATFORM = "other_device_platform"
private const val KEY_FETCHES_MESSAGES = "fetches_messages"
private const val KEY_BACKUP_VERSION = "backup_version"
fun init(context: Application) {
@@ -127,6 +128,10 @@ object RegistrationPreferences {
get() = prefs.getBoolean(KEY_PINS_OPTED_OUT, false)
set(value) = prefs.edit { putBoolean(KEY_PINS_OPTED_OUT, value) }
var fetchesMessages: Boolean
get() = prefs.getBoolean(KEY_FETCHES_MESSAGES, true)
set(value) = prefs.edit { putBoolean(KEY_FETCHES_MESSAGES, value) }
var restoredSvr2Credentials: List<NetworkController.SvrCredentials>
get() = prefs.getStringSet(KEY_SVR2_CREDENTIALS, emptySet())?.mapNotNull { parseCredential(it) } ?: emptyList()
set(value) = prefs.edit { putStringSet(KEY_SVR2_CREDENTIALS, value.map { serializeCredential(it) }.toSet()) }
+1
View File
@@ -44,6 +44,7 @@ dependencies {
implementation(project(":core:ui"))
implementation(project(":core:util"))
implementation(project(":core:models-jvm"))
implementation(project(":core:serialization"))
implementation(libs.libsignal.android)
// Compose BOM
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.util.serialization.ByteArrayToBase64Serializer
import org.signal.libsignal.net.BadRequestError
@@ -182,6 +183,14 @@ interface NetworkController {
*/
suspend fun setAccountAttributes(attributes: AccountAttributes): RequestResult<Unit, SetAccountAttributesError>
/**
* Enqueue a durable unit of work to sync your account attributes based on the current state of your own storage.
* This is typically done at the end of the registration process to clean up any possible changes to the AEP
* that may be made post-registration (for instance, you may restore a backup post-registration with a new AEP that
* we'd like to re-use).
*/
suspend fun enqueueAccountAttributesSyncJob()
/**
* Fetches metadata about your current backup. This will be different for different key/credential pairs. For example, message credentials will always
* return 0 for used space since that is stored under the media key/credential.
@@ -195,7 +204,17 @@ interface NetworkController {
* - 404: No backup
* - 429: Rate limited
*/
suspend fun getRemoteBackupInfo(): RequestResult<GetBackupInfoResponse, GetBackupInfoError>
suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult<GetBackupInfoResponse, GetBackupInfoError>
/**
* Gets the last-modified timestamp of the backup file on the CDN.
* Requires [GetBackupInfoResponse] to know the CDN location of the backup.
*
* @param aep The Account Entropy Pool used to derive backup credentials.
* @param backupInfo The backup info response containing CDN location details.
* @return The last-modified time as epoch milliseconds, or an appropriate error.
*/
suspend fun getBackupFileLastModified(aep: AccountEntropyPool, backupInfo: GetBackupInfoResponse): RequestResult<Long, GetBackupInfoError>
/**
* Starts a provisioning session for QR-based quick restore.
@@ -37,6 +37,12 @@ sealed interface RegistrationFlowEvent : DebugLoggable {
/** 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
/** An AEP was manually input by the user. It has not yet been verified against the server. */
data class UserSuppliedAepSubmitted(val aep: AccountEntropyPool) : RegistrationFlowEvent
/** An AEP that was previously manually input by the user (see [UserSuppliedAepSubmitted]) has been validated. We should use it as the canonical AEP. */
data class UserSuppliedAepVerified(val aep: AccountEntropyPool) : RegistrationFlowEvent
/** Registration has been completed. Will finalize any pending state, then navigate to flow's conclusion. */
data object RegistrationComplete : RegistrationFlowEvent
}
@@ -43,13 +43,13 @@ data class RegistrationFlowState(
/** 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. */
/** The AEP obtained via manual entry for local/remote 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, 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)"
return "RegistrationFlowState(backStack=${backStack.joinToString()}, sessionMetadata=${sessionMetadata.let { "present" }}, sessionE164=$sessionE164, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()}, temporaryMasterKey=${temporaryMasterKey?.toString()?.censor()}, preExistingRegistrationData=${preExistingRegistrationData?.let { "present" }}, doNotAttemptRecoveryPassword=$doNotAttemptRecoveryPassword, pendingRestoreOption=$pendingRestoreOption, unverifiedRestoredAep=${unverifiedRestoredAep?.displayValue?.censor()}, isRestoringNavigation=$isRestoringNavigationState)"
}
}
@@ -29,12 +29,19 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import kotlinx.serialization.Serializable
import org.signal.core.models.AccountEntropyPool
import org.signal.core.ui.navigation.ResultEffect
import org.signal.core.ui.navigation.TransitionSpecs
import org.signal.core.util.serialization.AccountEntropyPoolSerializer
import org.signal.registration.screens.accountlocked.AccountLockedScreen
import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents
import org.signal.registration.screens.accountlocked.AccountLockedState
import org.signal.registration.screens.aepentry.EnterAepForLocalBackupViewModel
import org.signal.registration.screens.aepentry.EnterAepForRemoteBackupPostRegistrationViewModel
import org.signal.registration.screens.aepentry.EnterAepForRemoteBackupPreRegistrationViewModel
import org.signal.registration.screens.aepentry.EnterAepScreen
import org.signal.registration.screens.captcha.CaptchaScreen
import org.signal.registration.screens.captcha.CaptchaScreenEvents
import org.signal.registration.screens.captcha.CaptchaState
@@ -42,8 +49,6 @@ 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
@@ -61,6 +66,8 @@ import org.signal.registration.screens.pinentry.PinEntryForSvrRestoreViewModel
import org.signal.registration.screens.pinentry.PinEntryScreen
import org.signal.registration.screens.quickrestore.QuickRestoreQrScreen
import org.signal.registration.screens.quickrestore.QuickRestoreQrViewModel
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreViewModel
import org.signal.registration.screens.remotebackuprestore.RemoteRestoreScreen
import org.signal.registration.screens.restoreselection.ArchiveRestoreOption
import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionScreen
import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionViewModel
@@ -70,6 +77,7 @@ import org.signal.registration.screens.verificationcode.VerificationCodeScreen
import org.signal.registration.screens.verificationcode.VerificationCodeViewModel
import org.signal.registration.screens.welcome.WelcomeScreen
import org.signal.registration.screens.welcome.WelcomeScreenEvents
import org.signal.registration.util.AccountEntropyPoolParceler
/**
* Navigation routes for the registration flow.
@@ -115,38 +123,41 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
data object PinCreate : RegistrationRoute
@Serializable
data class ArchiveRestoreSelection(val restoreOptions: List<ArchiveRestoreOption>) : RegistrationRoute {
data class ArchiveRestoreSelection(val restoreOptions: List<ArchiveRestoreOption>, val isPreRegistration: Boolean) : RegistrationRoute {
companion object {
fun forQuickRestore(hasRemoteBackup: Boolean): ArchiveRestoreSelection {
return ArchiveRestoreSelection(
buildList {
restoreOptions = buildList {
if (hasRemoteBackup) {
add(ArchiveRestoreOption.SignalSecureBackup)
}
add(ArchiveRestoreOption.LocalBackup)
add(ArchiveRestoreOption.DeviceTransfer)
}
},
isPreRegistration = true
)
}
fun forManualRestore(): ArchiveRestoreSelection {
return ArchiveRestoreSelection(
buildList {
restoreOptions = buildList {
add(ArchiveRestoreOption.SignalSecureBackup)
add(ArchiveRestoreOption.LocalBackup)
add(ArchiveRestoreOption.DeviceTransfer)
}
},
isPreRegistration = true
)
}
fun forPostRegister(): ArchiveRestoreSelection {
return ArchiveRestoreSelection(
buildList {
restoreOptions = buildList {
add(ArchiveRestoreOption.SignalSecureBackup)
add(ArchiveRestoreOption.LocalBackup)
add(ArchiveRestoreOption.DeviceTransfer)
add(ArchiveRestoreOption.None)
}
},
isPreRegistration = false
)
}
}
@@ -159,7 +170,17 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
data object EnterLocalBackupV1Passphrase : RegistrationRoute
@Serializable
data object EnterAepScreen : RegistrationRoute
data object EnterAepForLocalBackup : RegistrationRoute
@Serializable
data class EnterAepForRemoteBackupPreRegistration(val e164: String) : RegistrationRoute
@Serializable
data object EnterAepForRemoteBackupPostRegistration : RegistrationRoute
@Serializable
@TypeParceler<AccountEntropyPool, AccountEntropyPoolParceler>
data class RemoteRestore(@Serializable(with = AccountEntropyPoolSerializer::class) val aep: AccountEntropyPool) : RegistrationRoute
@Serializable
data object QuickRestoreQrScan : RegistrationRoute
@@ -246,7 +267,7 @@ fun RegistrationNavHost(
initialState.key is RegistrationRoute.CountryCodePicker -> {
TransitionSpecs.VerticalSlide.popTransitionSpec.invoke(this)
}
initialState.key == RegistrationRoute.EnterAepScreen.toString() -> {
initialState.key == RegistrationRoute.EnterAepForLocalBackup.toString() || initialState.key == RegistrationRoute.EnterAepForRemoteBackupPreRegistration.toString() -> {
TransitionSpecs.HorizontalSlide.transitionSpec.invoke(this)
}
initialState.key == RegistrationRoute.LocalBackupRestore.toString() && targetState.key == RegistrationRoute.PhoneNumberEntry.toString() -> {
@@ -486,6 +507,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
val viewModel: ArchiveRestoreSelectionViewModel = viewModel(
factory = ArchiveRestoreSelectionViewModel.Factory(
restoreOptions = key.restoreOptions,
isPreRegistration = key.isPreRegistration,
parentEventEmitter = registrationViewModel::onEvent
)
)
@@ -497,12 +519,29 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
}
// -- Remote Restore Screen
entry<RegistrationRoute.RemoteRestore> { key ->
val viewModel: RemoteBackupRestoreViewModel = viewModel(
factory = RemoteBackupRestoreViewModel.Factory(
aep = key.aep,
repository = registrationRepository,
parentState = registrationViewModel.state,
parentEventEmitter = registrationViewModel::onEvent
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
RemoteRestoreScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
// -- 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,
@@ -539,9 +578,9 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
// TODO I think we can re-use the screen but attach different viewmodels to progress forward rather than do for-result flows?
// -- Enter AEP
entry<RegistrationRoute.EnterAepScreen> {
val viewModel: EnterAepViewModel = viewModel(
factory = EnterAepViewModel.Factory(
entry<RegistrationRoute.EnterAepForLocalBackup> {
val viewModel: EnterAepForLocalBackupViewModel = viewModel(
factory = EnterAepForLocalBackupViewModel.Factory(
parentEventEmitter = registrationViewModel::onEvent,
resultBus = registrationViewModel.resultBus,
resultKey = BACKUP_CREDENTIAL_RESULT
@@ -555,6 +594,36 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
}
entry<RegistrationRoute.EnterAepForRemoteBackupPreRegistration> { key ->
val viewModel: EnterAepForRemoteBackupPreRegistrationViewModel = viewModel(
factory = EnterAepForRemoteBackupPreRegistrationViewModel.Factory(
e164 = key.e164,
repository = registrationRepository,
parentEventEmitter = registrationViewModel::onEvent
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
EnterAepScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
entry<RegistrationRoute.EnterAepForRemoteBackupPostRegistration> {
val viewModel: EnterAepForRemoteBackupPostRegistrationViewModel = viewModel(
factory = EnterAepForRemoteBackupPostRegistrationViewModel.Factory(
parentEventEmitter = registrationViewModel::onEvent
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
EnterAepScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
entry<RegistrationRoute.QuickRestoreQrScan> {
val viewModel: QuickRestoreQrViewModel = viewModel(
factory = QuickRestoreQrViewModel.Factory(
@@ -43,6 +43,7 @@ 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.remotebackuprestore.RemoteBackupRestoreProgress
import org.signal.registration.util.SensitiveLog
import java.security.SecureRandom
import java.util.Locale
@@ -338,12 +339,17 @@ class RegistrationRepository(val context: Context, val networkController: Networ
this.aciRegistrationId = keyMaterial.aciRegistrationId
this.pniRegistrationId = keyMaterial.pniRegistrationId
this.unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey.toByteString()
this.profileKey = keyMaterial.profileKey.toByteString()
this.servicePassword = keyMaterial.servicePassword
this.accountEntropyPool = keyMaterial.accountEntropyPool.value
}
val fcmToken = networkController.getFcmToken()
storageController.updateInProgressRegistrationData {
this.fetchesMessages = fcmToken == null
}
val newMasterKey = keyMaterial.accountEntropyPool.deriveMasterKey()
val newRecoveryPassword = newMasterKey.deriveRegistrationRecoveryPassword()
@@ -521,6 +527,30 @@ class RegistrationRepository(val context: Context, val networkController: Networ
storageController.scanLocalBackupFolder(folderUri)
}
suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult<NetworkController.GetBackupInfoResponse, NetworkController.GetBackupInfoError> = withContext(Dispatchers.IO) {
networkController.getRemoteBackupInfo(aep)
}
suspend fun getBackupFileLastModified(aep: AccountEntropyPool, backupInfo: NetworkController.GetBackupInfoResponse): RequestResult<Long, NetworkController.GetBackupInfoError> = withContext(Dispatchers.IO) {
networkController.getBackupFileLastModified(aep, backupInfo)
}
fun restoreRemoteBackup(aep: AccountEntropyPool): Flow<RemoteBackupRestoreProgress> {
return storageController.restoreRemoteBackup(aep)
}
suspend fun saveVerifiedUserSuppliedAep(aep: AccountEntropyPool): Unit = withContext(Dispatchers.IO) {
storageController.updateInProgressRegistrationData {
this.accountEntropyPool = aep.value
}
}
suspend fun commitFinalRegistrationData(): Unit = withContext(Dispatchers.IO) {
storageController.commitRegistrationData()
networkController.enqueueAccountAttributesSyncJob()
networkController.enqueueSvrGuessResetJob()
}
private fun generateKeyMaterial(
existingAccountEntropyPool: AccountEntropyPool? = null,
existingAciIdentityKeyPair: IdentityKeyPair? = null,
@@ -548,6 +578,7 @@ class RegistrationRepository(val context: Context, val networkController: Networ
pniLastResortKyberPreKey = pniLastResortKyberPreKey,
aciRegistrationId = generateRegistrationId(),
pniRegistrationId = generateRegistrationId(),
profileKey = profileKey.serialize(),
unidentifiedAccessKey = deriveUnidentifiedAccessKey(profileKey),
servicePassword = generatePassword(),
accountEntropyPool = accountEntropyPool
@@ -13,19 +13,21 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.signal.core.ui.navigation.ResultEventBus
import org.signal.core.util.logging.Log
import org.signal.registration.screens.EventDrivenViewModel
import kotlin.reflect.KClass
/**
* ViewModel shared across the registration flow.
* Manages state and logic for registration screens.
*/
class RegistrationViewModel(private val repository: RegistrationRepository, savedStateHandle: SavedStateHandle) : ViewModel() {
class RegistrationViewModel(private val repository: RegistrationRepository, savedStateHandle: SavedStateHandle) : EventDrivenViewModel<RegistrationFlowEvent>(TAG) {
companion object {
private val TAG = Log.tag(RegistrationViewModel::class)
@@ -52,16 +54,15 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
}
}
fun onEvent(event: RegistrationFlowEvent) {
Log.d(TAG, "[Event] $event")
override suspend fun processEvent(event: RegistrationFlowEvent) {
_state.value = applyEvent(_state.value, event)
viewModelScope.launch {
viewModelScope.launch(Dispatchers.IO) {
persistFlowState(event)
}
}
fun applyEvent(state: RegistrationFlowState, event: RegistrationFlowEvent): RegistrationFlowState {
suspend fun applyEvent(state: RegistrationFlowState, event: RegistrationFlowEvent): RegistrationFlowState {
return when (event) {
is RegistrationFlowEvent.ResetState -> RegistrationFlowState(isRestoringNavigationState = false)
is RegistrationFlowEvent.SessionUpdated -> state.copy(sessionMetadata = event.session)
@@ -72,7 +73,16 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
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)
is RegistrationFlowEvent.UserSuppliedAepSubmitted -> state.copy(unverifiedRestoredAep = event.aep)
is RegistrationFlowEvent.UserSuppliedAepVerified -> {
repository.saveVerifiedUserSuppliedAep(event.aep)
state.copy(accountEntropyPool = event.aep)
}
is RegistrationFlowEvent.RegistrationComplete -> {
repository.commitFinalRegistrationData()
val completeNavEvent = RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete)
applyNavigationToScreenEvent(state, completeNavEvent)
}
}
}
@@ -159,7 +169,9 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
is RegistrationFlowEvent.E164Chosen,
is RegistrationFlowEvent.RecoveryPasswordInvalid,
is RegistrationFlowEvent.PendingRestoreOptionSelected,
is RegistrationFlowEvent.AepSubmittedViaLocalBackupRestore -> repository.saveFlowState(_state.value)
is RegistrationFlowEvent.UserSuppliedAepSubmitted,
is RegistrationFlowEvent.UserSuppliedAepVerified -> repository.saveFlowState(_state.value)
is RegistrationFlowEvent.RegistrationComplete -> repository.clearFlowState()
// No need to persist anything new, fields accounted for in proto already
is RegistrationFlowEvent.Registered,
@@ -19,6 +19,7 @@ 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.remotebackuprestore.RemoteBackupRestoreProgress
import org.signal.registration.util.ACIParceler
import org.signal.registration.util.AccountEntropyPoolParceler
import org.signal.registration.util.IdentityKeyPairParceler
@@ -101,6 +102,15 @@ interface StorageController {
*/
fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow<LocalBackupRestoreProgress>
/**
* Begins restoring from a remote (server-hosted) backup.
*
* @param aep The Account Entropy Pool used to derive backup keys.
* @return A [Flow] of [RemoteBackupRestoreProgress] that reports the state of the restore
* from download through import, completion, or error.
*/
fun restoreRemoteBackup(aep: AccountEntropyPool): Flow<RemoteBackupRestoreProgress>
/**
* Scans the given folder URI for local backup files, checking for both modern
* folder-based backups and legacy .backup files.
@@ -138,6 +148,8 @@ data class KeyMaterial(
val aciRegistrationId: Int,
/** Registration ID for the PNI. */
val pniRegistrationId: Int,
/** Profile key for sealed sender. */
val profileKey: ByteArray,
/** Unidentified access key (derived from profile key) for sealed sender. */
val unidentifiedAccessKey: ByteArray,
/** Password for basic auth during registration (18 random bytes, base64 encoded). */
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.localbackuprestore
package org.signal.registration.screens.aepentry
import org.signal.registration.util.DebugLoggableModel
@@ -16,4 +16,7 @@ sealed class EnterAepEvents : DebugLoggableModel() {
/** User wants to cancel / no recovery key. */
data object Cancel : EnterAepEvents()
/** Dismiss a registration error dialog. */
data object DismissError : EnterAepEvents()
}
@@ -0,0 +1,62 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.aepentry
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.ui.navigation.ResultEventBus
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.screens.util.navigateBack
class EnterAepForLocalBackupViewModel(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val resultBus: ResultEventBus,
private val resultKey: String
) : ViewModel() {
companion object {
private val TAG = Log.tag(EnterAepForLocalBackupViewModel::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 -> {
_state.update { EnterAepScreenEventHandler.applyEvent(it, event) }
}
is EnterAepEvents.Submit -> {
if (_state.value.isBackupKeyValid) {
resultBus.sendResult(resultKey, _state.value.backupKey)
parentEventEmitter.navigateBack()
}
}
is EnterAepEvents.Cancel -> {
parentEventEmitter.navigateBack()
}
is EnterAepEvents.DismissError -> {
_state.update { EnterAepScreenEventHandler.applyEvent(it, event) }
}
}
}
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 EnterAepForLocalBackupViewModel(parentEventEmitter, resultBus, resultKey) as T
}
}
}
@@ -0,0 +1,60 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.aepentry
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.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.util.navigateTo
class EnterAepForRemoteBackupPostRegistrationViewModel(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModel() {
companion object {
private val TAG = Log.tag(EnterAepForRemoteBackupPostRegistrationViewModel::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 -> {
_state.update { EnterAepScreenEventHandler.applyEvent(it, event) }
}
is EnterAepEvents.Submit -> {
if (_state.value.isBackupKeyValid) {
val aep = AccountEntropyPool(_state.value.backupKey)
parentEventEmitter(RegistrationFlowEvent.UserSuppliedAepSubmitted(aep))
parentEventEmitter.navigateTo(RegistrationRoute.RemoteRestore(aep))
}
}
is EnterAepEvents.Cancel -> {
parentEventEmitter(RegistrationFlowEvent.NavigateBack)
}
is EnterAepEvents.DismissError -> {
_state.update { EnterAepScreenEventHandler.applyEvent(it, event) }
}
}
}
class Factory(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return EnterAepForRemoteBackupPostRegistrationViewModel(parentEventEmitter) as T
}
}
}
@@ -0,0 +1,143 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.aepentry
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
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.navigateTo
class EnterAepForRemoteBackupPreRegistrationViewModel(
private val e164: String,
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<EnterAepEvents>(TAG) {
companion object {
private val TAG = Log.tag(EnterAepForRemoteBackupPreRegistrationViewModel::class)
}
private val _state = MutableStateFlow(EnterAepState())
val state: StateFlow<EnterAepState> = _state.asStateFlow()
override suspend fun processEvent(event: EnterAepEvents) {
applyEvent(_state.value, event) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(inputState: EnterAepState, event: EnterAepEvents, stateEmitter: (EnterAepState) -> Unit) {
when (event) {
is EnterAepEvents.BackupKeyChanged -> {
stateEmitter(EnterAepScreenEventHandler.applyEvent(inputState, event))
}
is EnterAepEvents.Submit -> {
applySubmit(inputState, stateEmitter)
}
is EnterAepEvents.Cancel -> {
parentEventEmitter(RegistrationFlowEvent.NavigateBack)
}
is EnterAepEvents.DismissError -> {
stateEmitter(EnterAepScreenEventHandler.applyEvent(inputState, event))
}
}
}
private suspend fun applySubmit(inputState: EnterAepState, stateEmitter: (EnterAepState) -> Unit) {
check(inputState.isBackupKeyValid) { "AEP is not valid, should not have gotten here." }
val aep = AccountEntropyPool(inputState.backupKey)
val recoveryPassword = aep.deriveMasterKey().deriveRegistrationRecoveryPassword()
stateEmitter(inputState.copy(isRegistering = true))
parentEventEmitter(RegistrationFlowEvent.UserSuppliedAepSubmitted(aep))
Log.i(TAG, "[Submit] Attempting registration with RRP derived from user-supplied AEP.")
when (val result = repository.registerAccountWithRecoveryPassword(e164, recoveryPassword, existingAccountEntropyPool = aep)) {
is RequestResult.Success -> {
Log.i(TAG, "[Submit] Successfully registered using RRP from user-supplied AEP.")
val (_, keyMaterial) = result.result
stateEmitter(inputState.copy(isRegistering = false))
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
parentEventEmitter.navigateTo(RegistrationRoute.RemoteRestore(aep))
}
is RequestResult.NonSuccess -> {
when (val error = result.error) {
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
Log.w(TAG, "[Submit] RRP incorrect.")
stateEmitter(
inputState.copy(
isRegistering = false,
registrationError = RegistrationError.IncorrectRecoveryPassword,
aepValidationError = AepValidationError.Incorrect
)
)
}
is NetworkController.RegisterAccountError.InvalidRequest -> {
Log.w(TAG, "[Submit] Invalid request. Message: ${error.message}")
stateEmitter(
inputState.copy(
isRegistering = false,
registrationError = RegistrationError.UnknownError
)
)
}
is NetworkController.RegisterAccountError.RegistrationLock -> {
Log.w(TAG, "[Submit] Registration locked.")
stateEmitter(inputState.copy(isRegistering = false))
parentEventEmitter.navigateTo(
RegistrationRoute.PinEntryForRegistrationLock(
timeRemaining = error.data.timeRemaining,
svrCredentials = error.data.svr2Credentials
)
)
}
is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[Submit] Rate limited (retryAfter: ${error.retryAfter}).")
stateEmitter(inputState.copy(isRegistering = false, registrationError = RegistrationError.RateLimited))
}
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
error("[Submit] Session not found or not verified. This should not happen with RRP-based registration.")
}
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
error("[Submit] Device transfer possible. This should not happen with RRP-based registration.")
}
}
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[Submit] Network error.", result.networkError)
stateEmitter(inputState.copy(isRegistering = false, registrationError = RegistrationError.NetworkError))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[Submit] Application error.", result.cause)
stateEmitter(inputState.copy(isRegistering = false, registrationError = RegistrationError.UnknownError))
}
}
}
class Factory(
private val e164: String,
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return EnterAepForRemoteBackupPreRegistrationViewModel(e164, repository, parentEventEmitter) as T
}
}
}
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.localbackuprestore
package org.signal.registration.screens.aepentry
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -48,6 +48,8 @@ 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
import org.signal.registration.screens.localbackuprestore.attachBackupKeyAutoFillHelper
import org.signal.registration.screens.localbackuprestore.backupKeyAutoFillHelper
@Composable
fun EnterAepScreen(
@@ -117,6 +119,7 @@ fun EnterAepScreen(
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))
is AepValidationError.Incorrect -> Text(stringResource(R.string.EnterAepScreen__incorrect_recovery_key))
null -> {}
}
},
@@ -151,7 +154,7 @@ fun EnterAepScreen(
Spacer(modifier = Modifier.size(24.dp))
Buttons.LargeTonal(
enabled = state.isBackupKeyValid && state.aepValidationError == null,
enabled = state.isBackupKeyValid && state.aepValidationError == null && !state.isRegistering,
onClick = { onEvent(EnterAepEvents.Submit) }
) {
Text(text = stringResource(R.string.LocalBackupRestoreScreen__next))
@@ -0,0 +1,52 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.aepentry
import org.signal.core.models.AccountEntropyPool
object EnterAepScreenEventHandler {
fun applyEvent(state: EnterAepState, event: EnterAepEvents): EnterAepState {
return when (event) {
is EnterAepEvents.BackupKeyChanged -> applyBackupKeyChanged(state, event.value)
is EnterAepEvents.DismissError -> state.copy(registrationError = null)
else -> throw UnsupportedOperationException("This event is not handled generically!")
}
}
private fun applyBackupKeyChanged(state: EnterAepState, key: String): EnterAepState {
val newKey = AccountEntropyPool.removeIllegalCharacters(key)
.take(AccountEntropyPool.LENGTH + 16)
.lowercase()
val isValid = AccountEntropyPool.isFullyValid(newKey)
val isShort = newKey.length < AccountEntropyPool.LENGTH
val isExact = newKey.length == AccountEntropyPool.LENGTH
val previousError = state.aepValidationError
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
AepValidationError.Incorrect -> null
null -> null
}
if (updatedError == null) {
updatedError = when {
!isShort && !isExact -> AepValidationError.TooLong(newKey.length, AccountEntropyPool.LENGTH)
!isValid && isExact -> AepValidationError.Invalid
else -> null
}
}
return state.copy(
backupKey = newKey,
isBackupKeyValid = isValid,
aepValidationError = updatedError
)
}
}
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.localbackuprestore
package org.signal.registration.screens.aepentry
import org.signal.registration.util.DebugLoggableModel
@@ -11,10 +11,20 @@ data class EnterAepState(
val backupKey: String = "",
val isBackupKeyValid: Boolean = false,
val aepValidationError: AepValidationError? = null,
val chunkLength: Int = 4
val chunkLength: Int = 4,
val isRegistering: Boolean = false,
val registrationError: RegistrationError? = null
) : DebugLoggableModel()
sealed interface AepValidationError {
data class TooLong(val count: Int, val max: Int) : AepValidationError
data object Invalid : AepValidationError
data object Incorrect : AepValidationError
}
sealed interface RegistrationError {
data object IncorrectRecoveryPassword : RegistrationError
data object RateLimited : RegistrationError
data object NetworkError : RegistrationError
data object UnknownError : RegistrationError
}
@@ -1,95 +0,0 @@
/*
* 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
}
}
}
@@ -13,8 +13,6 @@ 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
@@ -23,7 +21,6 @@ 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
@@ -32,7 +29,6 @@ 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,
@@ -44,7 +40,7 @@ class LocalBackupRestoreViewModel(
}
private val _localState = MutableStateFlow(LocalBackupRestoreState())
val state = combine(_localState, parentState) { state, parentState -> applyParentState(state, parentState) }
val state = _localState
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LocalBackupRestoreState())
@@ -54,11 +50,6 @@ class LocalBackupRestoreViewModel(
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) {
@@ -102,7 +93,7 @@ class LocalBackupRestoreViewModel(
val backup = state.backupInfo ?: return
val credentialRoute = when (backup.type) {
LocalBackupInfo.BackupType.V1 -> RegistrationRoute.EnterLocalBackupV1Passphrase
LocalBackupInfo.BackupType.V2 -> RegistrationRoute.EnterAepScreen
LocalBackupInfo.BackupType.V2 -> RegistrationRoute.EnterAepForLocalBackup
}
parentEventEmitter.navigateTo(credentialRoute)
}
@@ -119,11 +110,15 @@ class LocalBackupRestoreViewModel(
}
private fun onRestoreComplete(state: LocalBackupRestoreState) {
if (state.aep != null) {
parentEventEmitter(RegistrationFlowEvent.UserSuppliedAepVerified(state.aep))
}
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")
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
}
@@ -205,14 +200,13 @@ class LocalBackupRestoreViewModel(
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
return LocalBackupRestoreViewModel(repository, parentEventEmitter, isPreRegistration, resultBus, resultKey) as T
}
}
}
@@ -217,7 +217,7 @@ class PhoneNumberEntryViewModel(
when (state.pendingRestoreOption) {
PendingRestoreOption.LocalBackup -> parentEventEmitter.navigateTo(RegistrationRoute.LocalBackupRestore(isPreRegistration = true))
PendingRestoreOption.RemoteBackup -> parentEventEmitter.navigateTo(RegistrationRoute.EnterAepScreen)
PendingRestoreOption.RemoteBackup -> parentEventEmitter.navigateTo(RegistrationRoute.EnterAepForRemoteBackupPreRegistration(e164))
}
return state
@@ -246,14 +246,10 @@ class PhoneNumberEntryViewModel(
is RequestResult.NonSuccess -> {
when (val error = registerResult.error) {
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
Log.w(TAG, "[Register] Got told that our session could not be found when registering with RRP. We should never get into this state. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
return state
error("[Register] Got told that our session could not be found when registering with RRP. We should never get into this state.")
}
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
Log.w(TAG, "[Register] Got told a device transfer is possible. We should never get into this state. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
return state
error("[Register] Got told a device transfer is possible. We should never get into this state.")
}
is NetworkController.RegisterAccountError.RegistrationLock -> {
Log.w(TAG, "[Register] Reglocked. This implies that the user still had reglock enabled despite the pre-existing data not thinking it was.")
@@ -313,7 +309,7 @@ class PhoneNumberEntryViewModel(
return applySessionBasedRegistration(state, e164, parentEventEmitter)
}
parentEventEmitter(RegistrationFlowEvent.AepSubmittedViaLocalBackupRestore(aep))
parentEventEmitter(RegistrationFlowEvent.UserSuppliedAepSubmitted(aep))
Log.i(TAG, "[LocalRestore] Attempting registration with RRP derived from restored AEP.")
@@ -21,9 +21,7 @@ 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
/**
* ViewModel for the PIN creation screen.
@@ -97,7 +95,7 @@ class PinCreationViewModel(
is RequestResult.Success -> {
Log.i(TAG, "[PinSubmitted] Successfully backed up master key to SVR.")
// TODO profile creation
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
state
}
is RequestResult.NonSuccess -> {
@@ -138,7 +138,7 @@ class PinEntryForRegistrationLockViewModel(
// TODO storage service restore + profile screen
when {
response.reregistration -> parentEventEmitter.navigateTo(RegistrationRoute.ArchiveRestoreSelection.forPostRegister())
else -> parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
else -> parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
state
}
@@ -23,10 +23,8 @@ 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.util.SensitiveLog
/**
@@ -152,7 +150,7 @@ class PinEntryForSmsBypassViewModel(
return when (val result = repository.registerAccountWithRecoveryPassword(e164, recoveryPassword, registrationLock, skipDeviceTransfer = true)) {
is RequestResult.Success -> {
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
repository.enqueueSvrResetGuessCountJob()
state
}
@@ -116,7 +116,7 @@ class PinEntryForSvrRestoreViewModel(
Log.i(TAG, "[PinEntered] Successfully restored master key from SVR.")
repository.enqueueSvrResetGuessCountJob()
parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredFromSvr(result.result.masterKey))
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
state
}
is RequestResult.NonSuccess -> {
@@ -0,0 +1,45 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.remotebackuprestore
/**
* Progress events emitted during a remote backup restore operation.
* Each value directly maps to a UI state for the progress dialog.
*/
sealed interface RemoteBackupRestoreProgress {
/** Downloading the backup from the server. */
data class Downloading(val bytesDownloaded: Long, val totalBytes: Long) : RemoteBackupRestoreProgress
/** Importing/restoring messages from the downloaded backup. */
data class Restoring(val bytesRead: Long, val totalBytes: Long) : RemoteBackupRestoreProgress
/** Finalizing the restore (post-import cleanup). */
data object Finalizing : RemoteBackupRestoreProgress
/** Restore completed successfully. */
data object Complete : RemoteBackupRestoreProgress
/** Restore failed due to a network error (e.g. connection lost during download). */
data class NetworkError(val cause: Throwable? = null) : RemoteBackupRestoreProgress
/**
* The backup was created by a newer version of Signal than this client supports.
* The user should be prompted to update.
*/
data object InvalidBackupVersion : RemoteBackupRestoreProgress
/**
* SVR-B has failed permanently, meaning the backup cannot be recovered.
* The user should be prompted to contact support.
*/
data object PermanentSvrBFailure : RemoteBackupRestoreProgress
/** Restore failed for an unknown or generic reason. */
data class GenericError(val cause: Throwable? = null) : RemoteBackupRestoreProgress
/** The restore was canceled by the user or system. */
data object Canceled : RemoteBackupRestoreProgress
}
@@ -0,0 +1,525 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.remotebackuprestore
import android.text.format.DateFormat
import android.text.format.Formatter
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.widthIn
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.models.AccountEntropyPool
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import java.util.Date
@Composable
fun RemoteRestoreScreen(
state: RemoteBackupRestoreState,
onEvent: (RemoteBackupRestoreScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
when (state.loadState) {
RemoteBackupRestoreState.LoadState.Loading -> {
Dialogs.IndeterminateProgressDialog(
message = stringResource(R.string.RemoteRestoreScreen__fetching_backup_details)
)
}
RemoteBackupRestoreState.LoadState.Loaded -> {
val windowBreakpoint = rememberWindowBreakpoint()
when (windowBreakpoint) {
WindowBreakpoint.SMALL -> CompactLayout(state = state, onEvent = onEvent, modifier = modifier)
WindowBreakpoint.MEDIUM -> MediumLayout(state = state, onEvent = onEvent, modifier = modifier)
WindowBreakpoint.LARGE -> LargeLayout(state = state, onEvent = onEvent, modifier = modifier)
}
}
RemoteBackupRestoreState.LoadState.NotFound -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteRestoreScreen__backup_not_found),
body = stringResource(R.string.RemoteRestoreScreen__no_backup_was_found),
confirm = stringResource(android.R.string.ok),
onConfirm = { onEvent(RemoteBackupRestoreScreenEvents.Cancel) },
onDismiss = { onEvent(RemoteBackupRestoreScreenEvents.Cancel) }
)
}
RemoteBackupRestoreState.LoadState.Failure -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteRestoreScreen__cant_restore_backup),
body = stringResource(R.string.RemoteRestoreScreen__your_backup_cant_be_restored_right_now),
confirm = stringResource(R.string.RemoteRestoreScreen__try_again),
dismiss = stringResource(android.R.string.cancel),
onConfirm = { onEvent(RemoteBackupRestoreScreenEvents.Retry) },
onDeny = { onEvent(RemoteBackupRestoreScreenEvents.Cancel) },
onDismissRequest = {}
)
}
}
}
@Composable
private fun CompactLayout(
state: RemoteBackupRestoreState,
onEvent: (RemoteBackupRestoreScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
content = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(48.dp))
BackupInfoContent(state = state)
}
RestoreStateDialogs(state = state, onEvent = onEvent)
},
footer = {
FooterButtons(onEvent = onEvent)
},
modifier = modifier
)
}
@Composable
private fun MediumLayout(
state: RemoteBackupRestoreState,
onEvent: (RemoteBackupRestoreScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
content = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.widthIn(max = 450.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(48.dp))
BackupInfoContent(state = state)
Spacer(modifier = Modifier.height(48.dp))
}
}
RestoreStateDialogs(state = state, onEvent = onEvent)
},
footer = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.widthIn(max = 320.dp)) {
FooterButtons(onEvent = onEvent)
}
}
},
modifier = modifier
)
}
@Composable
private fun LargeLayout(
state: RemoteBackupRestoreState,
onEvent: (RemoteBackupRestoreScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
content = {
Row(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 56.dp)
) {
Spacer(modifier = Modifier.weight(1f))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.widthIn(max = 400.dp)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.weight(1f))
BackupInfoContent(state = state)
Spacer(modifier = Modifier.weight(1f))
FooterButtons(onEvent = onEvent)
Spacer(modifier = Modifier.height(24.dp))
}
}
}
RestoreStateDialogs(state = state, onEvent = onEvent)
},
modifier = modifier
)
}
@Composable
private fun BackupInfoContent(
state: RemoteBackupRestoreState
) {
Icon(
imageVector = SignalIcons.Backup.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.size(64.dp)
.background(color = SignalTheme.colors.colorSurface2, shape = CircleShape)
.padding(12.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.RemoteRestoreScreen__restore_from_backup),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
if (state.backupTime > 0) {
Spacer(modifier = Modifier.height(12.dp))
val context = LocalContext.current
val (dateStr, timeStr) = remember(context, state.backupTime) {
val date = Date(state.backupTime)
val dateFormatted = DateFormat.getMediumDateFormat(context).format(date)
val timeFormatted = DateFormat.getTimeFormat(context).format(date)
dateFormatted to timeFormatted
}
Text(
text = stringResource(R.string.RemoteRestoreScreen__your_last_backup_was_made_on_s_at_s, dateStr, timeStr),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.RemoteRestoreScreen__your_media_will_restore_in_the_background),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
@Composable
private fun FooterButtons(
onEvent: (RemoteBackupRestoreScreenEvents) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = { onEvent(RemoteBackupRestoreScreenEvents.BackupRestoreBackup) },
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(R.string.RemoteRestoreScreen__restore_backup))
}
TextButton(
onClick = { onEvent(RemoteBackupRestoreScreenEvents.Cancel) },
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(android.R.string.cancel))
}
}
}
@Composable
private fun RestoreStateDialogs(
state: RemoteBackupRestoreState,
onEvent: (RemoteBackupRestoreScreenEvents) -> Unit
) {
when (state.restoreState) {
RemoteBackupRestoreState.RestoreState.None -> Unit
RemoteBackupRestoreState.RestoreState.InProgress -> {
RestoreProgressDialog(restoreProgress = state.restoreProgress)
}
RemoteBackupRestoreState.RestoreState.Restored -> Unit
RemoteBackupRestoreState.RestoreState.NetworkFailure -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteRestoreScreen__couldnt_finish_restore),
body = stringResource(R.string.RemoteRestoreScreen__error_connecting),
confirm = stringResource(android.R.string.ok),
onConfirm = { onEvent(RemoteBackupRestoreScreenEvents.DismissError) },
onDismiss = { onEvent(RemoteBackupRestoreScreenEvents.DismissError) }
)
}
RemoteBackupRestoreState.RestoreState.InvalidBackupVersion -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteRestoreScreen__couldnt_restore_this_backup),
body = stringResource(R.string.RemoteRestoreScreen__update_latest),
confirm = stringResource(R.string.RemoteRestoreScreen__update_signal),
dismiss = stringResource(R.string.RemoteRestoreScreen__not_now),
onConfirm = { onEvent(RemoteBackupRestoreScreenEvents.DismissError) },
onDismiss = { onEvent(RemoteBackupRestoreScreenEvents.DismissError) }
)
}
RemoteBackupRestoreState.RestoreState.PermanentSvrBFailure -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteRestoreScreen__cant_restore_this_backup),
body = stringResource(R.string.RemoteRestoreScreen__your_backup_is_not_recoverable),
confirm = stringResource(R.string.RemoteRestoreScreen__contact_support),
dismiss = stringResource(android.R.string.ok),
onConfirm = { onEvent(RemoteBackupRestoreScreenEvents.DismissError) },
onDismiss = { onEvent(RemoteBackupRestoreScreenEvents.DismissError) }
)
}
RemoteBackupRestoreState.RestoreState.Failed -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteRestoreScreen__couldnt_finish_restore),
body = stringResource(R.string.RemoteRestoreScreen__error_occurred),
confirm = stringResource(android.R.string.ok),
onConfirm = { onEvent(RemoteBackupRestoreScreenEvents.DismissError) },
onDismiss = { onEvent(RemoteBackupRestoreScreenEvents.DismissError) }
)
}
}
}
@Composable
private fun RestoreProgressDialog(restoreProgress: RemoteBackupRestoreState.RestoreProgress?) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = {},
confirmButton = {},
dismissButton = {},
text = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.wrapContentSize()
) {
if (restoreProgress == null || restoreProgress.phase == RemoteBackupRestoreState.RestoreProgress.Phase.Finalizing) {
CircularProgressIndicator(
modifier = Modifier
.padding(top = 55.dp, bottom = 16.dp)
.width(48.dp)
.height(48.dp)
)
} else {
CircularProgressIndicator(
progress = { restoreProgress.progress },
modifier = Modifier
.padding(top = 55.dp, bottom = 16.dp)
.width(48.dp)
.height(48.dp)
)
}
val progressText = when (restoreProgress?.phase) {
RemoteBackupRestoreState.RestoreProgress.Phase.Downloading -> stringResource(R.string.RemoteRestoreScreen__downloading_backup)
RemoteBackupRestoreState.RestoreProgress.Phase.Restoring -> stringResource(R.string.RemoteRestoreScreen__restoring_messages)
RemoteBackupRestoreState.RestoreProgress.Phase.Finalizing -> stringResource(R.string.RemoteRestoreScreen__finishing_restore)
null -> stringResource(R.string.RemoteRestoreScreen__restoring)
}
Text(
text = progressText,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 12.dp)
)
if (restoreProgress != null && restoreProgress.phase != RemoteBackupRestoreState.RestoreProgress.Phase.Finalizing && restoreProgress.totalBytes > 0) {
val progressBytes = Formatter.formatShortFileSize(context, restoreProgress.bytesCompleted)
val totalBytes = Formatter.formatShortFileSize(context, restoreProgress.totalBytes)
Text(
text = stringResource(R.string.RemoteRestoreScreen__s_of_s_s, progressBytes, totalBytes, "%.2f%%".format(restoreProgress.progress * 100)),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 12.dp)
)
}
}
}
},
modifier = Modifier.width(212.dp)
)
}
@AllDevicePreviews
@Composable
private fun RemoteRestoreScreenLoadedPreview() {
Previews.Preview {
RemoteRestoreScreen(
state = RemoteBackupRestoreState(
aep = AccountEntropyPool("0000000000000000000000000000000000000000000000000000000000000000"),
loadState = RemoteBackupRestoreState.LoadState.Loaded,
backupTime = System.currentTimeMillis(),
backupSize = 1234567
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun RemoteRestoreScreenLoadedNoTimePreview() {
Previews.Preview {
RemoteRestoreScreen(
state = RemoteBackupRestoreState(
aep = AccountEntropyPool("0000000000000000000000000000000000000000000000000000000000000000"),
loadState = RemoteBackupRestoreState.LoadState.Loaded,
backupSize = 1234567
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun RemoteRestoreScreenLoadingPreview() {
Previews.Preview {
RemoteRestoreScreen(
state = RemoteBackupRestoreState(
aep = AccountEntropyPool.generate(),
loadState = RemoteBackupRestoreState.LoadState.Loading
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun RemoteRestoreScreenNotFoundPreview() {
Previews.Preview {
RemoteRestoreScreen(
state = RemoteBackupRestoreState(
aep = AccountEntropyPool("0000000000000000000000000000000000000000000000000000000000000000"),
loadState = RemoteBackupRestoreState.LoadState.NotFound
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun RemoteRestoreScreenFailurePreview() {
Previews.Preview {
RemoteRestoreScreen(
state = RemoteBackupRestoreState(
aep = AccountEntropyPool("0000000000000000000000000000000000000000000000000000000000000000"),
loadState = RemoteBackupRestoreState.LoadState.Failure
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun RemoteRestoreScreenRestoringPreview() {
Previews.Preview {
RemoteRestoreScreen(
state = RemoteBackupRestoreState(
aep = AccountEntropyPool("0000000000000000000000000000000000000000000000000000000000000000"),
loadState = RemoteBackupRestoreState.LoadState.Loaded,
backupTime = System.currentTimeMillis(),
backupSize = 1234567,
restoreState = RemoteBackupRestoreState.RestoreState.InProgress
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun RemoteRestoreScreenNetworkFailurePreview() {
Previews.Preview {
RemoteRestoreScreen(
state = RemoteBackupRestoreState(
aep = AccountEntropyPool("0000000000000000000000000000000000000000000000000000000000000000"),
loadState = RemoteBackupRestoreState.LoadState.Loaded,
backupTime = System.currentTimeMillis(),
restoreState = RemoteBackupRestoreState.RestoreState.NetworkFailure
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun RemoteRestoreScreenRestoreFailedPreview() {
Previews.Preview {
RemoteRestoreScreen(
state = RemoteBackupRestoreState(
aep = AccountEntropyPool("0000000000000000000000000000000000000000000000000000000000000000"),
loadState = RemoteBackupRestoreState.LoadState.Loaded,
backupTime = System.currentTimeMillis(),
restoreState = RemoteBackupRestoreState.RestoreState.Failed
),
onEvent = {}
)
}
}
@@ -0,0 +1,15 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.remotebackuprestore
import org.signal.registration.util.DebugLoggableModel
sealed class RemoteBackupRestoreScreenEvents : DebugLoggableModel() {
data object BackupRestoreBackup : RemoteBackupRestoreScreenEvents()
data object Retry : RemoteBackupRestoreScreenEvents()
data object Cancel : RemoteBackupRestoreScreenEvents()
data object DismissError : RemoteBackupRestoreScreenEvents()
}
@@ -0,0 +1,53 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.remotebackuprestore
import org.signal.core.models.AccountEntropyPool
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.DebugLoggableModel
data class RemoteBackupRestoreState(
val aep: AccountEntropyPool,
val loadState: LoadState = LoadState.Loading,
val backupTime: Long = -1,
val backupSize: Long = 0,
val restoreState: RestoreState = RestoreState.None,
val restoreProgress: RestoreProgress? = null,
val loadAttempts: Int = 0
) : DebugLoggableModel() {
enum class LoadState {
Loading,
Loaded,
NotFound,
Failure
}
sealed interface RestoreState : DebugLoggable {
data object None : RestoreState
data object InProgress : RestoreState
data object Restored : RestoreState
data object NetworkFailure : RestoreState
data object InvalidBackupVersion : RestoreState
data object PermanentSvrBFailure : RestoreState
data object Failed : RestoreState
}
data class RestoreProgress(
val phase: Phase,
val bytesCompleted: Long,
val totalBytes: Long
) : DebugLoggableModel() {
val progress: Float
get() = if (totalBytes > 0) bytesCompleted.toFloat() / totalBytes.toFloat() else 0f
enum class Phase {
Downloading,
Restoring,
Finalizing
}
}
}
@@ -0,0 +1,219 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.remotebackuprestore
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
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 kotlinx.coroutines.withContext
import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRepository
import org.signal.registration.screens.EventDrivenViewModel
import org.signal.registration.screens.util.navigateBack
class RemoteBackupRestoreViewModel(
private val aep: AccountEntropyPool,
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<RemoteBackupRestoreScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(RemoteBackupRestoreViewModel::class)
}
private val _state = MutableStateFlow(RemoteBackupRestoreState(aep))
val state: StateFlow<RemoteBackupRestoreState> = _state
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RemoteBackupRestoreState(aep))
init {
loadBackupInfo()
}
override suspend fun processEvent(event: RemoteBackupRestoreScreenEvents) {
applyEvent(state.value, event) {
_state.value = it
}
}
@VisibleForTesting
suspend fun applyEvent(state: RemoteBackupRestoreState, event: RemoteBackupRestoreScreenEvents, stateEmitter: (RemoteBackupRestoreState) -> Unit) {
when (event) {
is RemoteBackupRestoreScreenEvents.BackupRestoreBackup -> {
stateEmitter(state.copy(restoreState = RemoteBackupRestoreState.RestoreState.InProgress))
restoreBackup()
}
is RemoteBackupRestoreScreenEvents.Retry -> {
loadBackupInfo()
stateEmitter(state)
}
is RemoteBackupRestoreScreenEvents.Cancel -> {
parentEventEmitter.navigateBack()
stateEmitter(state)
}
is RemoteBackupRestoreScreenEvents.DismissError -> {
stateEmitter(state.copy(restoreState = RemoteBackupRestoreState.RestoreState.None, restoreProgress = null))
}
}
}
private fun restoreBackup() {
viewModelScope.launch {
repository.restoreRemoteBackup(_state.value.aep).collect { progress ->
when (progress) {
is RemoteBackupRestoreProgress.Downloading -> {
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.InProgress,
restoreProgress = RemoteBackupRestoreState.RestoreProgress(
phase = RemoteBackupRestoreState.RestoreProgress.Phase.Downloading,
bytesCompleted = progress.bytesDownloaded,
totalBytes = progress.totalBytes
)
)
}
is RemoteBackupRestoreProgress.Restoring -> {
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.InProgress,
restoreProgress = RemoteBackupRestoreState.RestoreProgress(
phase = RemoteBackupRestoreState.RestoreProgress.Phase.Restoring,
bytesCompleted = progress.bytesRead,
totalBytes = progress.totalBytes
)
)
}
is RemoteBackupRestoreProgress.Finalizing -> {
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.InProgress,
restoreProgress = RemoteBackupRestoreState.RestoreProgress(
phase = RemoteBackupRestoreState.RestoreProgress.Phase.Finalizing,
bytesCompleted = 0,
totalBytes = 0
)
)
}
is RemoteBackupRestoreProgress.Complete -> {
Log.i(TAG, "Remote restore completed successfully")
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.Restored,
restoreProgress = null
)
parentEventEmitter(RegistrationFlowEvent.UserSuppliedAepVerified(aep))
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
is RemoteBackupRestoreProgress.NetworkError -> {
Log.w(TAG, "Remote restore failed with network error", progress.cause)
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.NetworkFailure,
restoreProgress = null
)
}
is RemoteBackupRestoreProgress.InvalidBackupVersion -> {
Log.w(TAG, "Remote restore failed: invalid backup version")
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.InvalidBackupVersion,
restoreProgress = null
)
}
is RemoteBackupRestoreProgress.PermanentSvrBFailure -> {
Log.w(TAG, "Remote restore failed: permanent SVR-B failure")
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.PermanentSvrBFailure,
restoreProgress = null
)
}
is RemoteBackupRestoreProgress.Canceled -> {
Log.w(TAG, "Remote restore was canceled")
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.Failed,
restoreProgress = null
)
}
is RemoteBackupRestoreProgress.GenericError -> {
Log.w(TAG, "Remote restore failed", progress.cause)
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.Failed,
restoreProgress = null
)
}
}
}
}
}
private fun loadBackupInfo() {
viewModelScope.launch {
_state.value = _state.value.copy(loadState = RemoteBackupRestoreState.LoadState.Loading, loadAttempts = _state.value.loadAttempts + 1)
val result = withContext(Dispatchers.IO) {
repository.getRemoteBackupInfo(_state.value.aep)
}
when (result) {
is RequestResult.Success -> {
val info = result.result
// parentEventEmitter(RegistrationFlowEvent)
val lastModifiedResult = withContext(Dispatchers.IO) {
repository.getBackupFileLastModified(_state.value.aep, info)
}
val backupTime = when (lastModifiedResult) {
is RequestResult.Success -> lastModifiedResult.result
else -> {
Log.w(TAG, "Failed to get backup last modified time: $lastModifiedResult")
-1L
}
}
_state.value = _state.value.copy(
loadState = RemoteBackupRestoreState.LoadState.Loaded,
backupSize = info.usedSpace ?: 0,
backupTime = backupTime
)
}
is RequestResult.NonSuccess -> {
_state.value = when (result.error) {
is NetworkController.GetBackupInfoError.NoBackup -> _state.value.copy(loadState = RemoteBackupRestoreState.LoadState.NotFound)
else -> _state.value.copy(loadState = RemoteBackupRestoreState.LoadState.Failure)
}
}
is RequestResult.RetryableNetworkError -> {
_state.value = _state.value.copy(loadState = RemoteBackupRestoreState.LoadState.Failure)
}
is RequestResult.ApplicationError -> {
_state.value = _state.value.copy(loadState = RemoteBackupRestoreState.LoadState.Failure)
}
}
}
}
class Factory(
private val aep: AccountEntropyPool,
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return RemoteBackupRestoreViewModel(aep, repository, parentState, parentEventEmitter) as T
}
}
}
@@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.signal.core.util.logging.Log
import org.signal.registration.PendingRestoreOption
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.EventDrivenViewModel
@@ -23,6 +24,7 @@ import org.signal.registration.screens.util.navigateTo
*/
class ArchiveRestoreSelectionViewModel(
private val restoreOptions: List<ArchiveRestoreOption>,
private val isPreRegistration: Boolean,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<ArchiveRestoreSelectionScreenEvents>(TAG) {
@@ -47,12 +49,22 @@ class ArchiveRestoreSelectionViewModel(
val result = when (event) {
is ArchiveRestoreSelectionScreenEvents.RestoreOptionSelected -> {
when (event.option) {
ArchiveRestoreOption.LocalBackup -> {
parentEventEmitter.navigateTo(RegistrationRoute.LocalBackupRestore(isPreRegistration = false))
ArchiveRestoreOption.SignalSecureBackup -> {
if (isPreRegistration) {
parentEventEmitter(RegistrationFlowEvent.PendingRestoreOptionSelected(PendingRestoreOption.RemoteBackup))
parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry)
} else {
parentEventEmitter.navigateTo(RegistrationRoute.EnterAepForRemoteBackupPostRegistration)
}
state
}
ArchiveRestoreOption.SignalSecureBackup -> {
Log.w(TAG, "Signal secure backup restore not yet implemented")
ArchiveRestoreOption.LocalBackup -> {
if (isPreRegistration) {
parentEventEmitter(RegistrationFlowEvent.PendingRestoreOptionSelected(PendingRestoreOption.LocalBackup))
parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry)
} else {
parentEventEmitter.navigateTo(RegistrationRoute.LocalBackupRestore(isPreRegistration = false))
}
state
}
ArchiveRestoreOption.DeviceTransfer -> {
@@ -80,10 +92,11 @@ class ArchiveRestoreSelectionViewModel(
class Factory(
private val restoreOptions: List<ArchiveRestoreOption>,
private val isPreRegistration: Boolean,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ArchiveRestoreSelectionViewModel(restoreOptions, parentEventEmitter) as T
return ArchiveRestoreSelectionViewModel(restoreOptions, isPreRegistration, parentEventEmitter) as T
}
}
}
@@ -189,9 +189,7 @@ class VerificationCodeViewModel(
throw NotImplementedError("Handle session not found or not verified case.")
}
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
Log.w(TAG, "[Register] Got told a device transfer is possible. We should never get into this state. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
state
error("[Register] Got told a device transfer is possible. We should never get into this state. Resetting.")
}
is NetworkController.RegisterAccountError.RegistrationLock -> {
Log.w(TAG, "[Register] Reglocked.")
@@ -212,9 +210,7 @@ class VerificationCodeViewModel(
state.copy(oneTimeEvent = OneTimeEvent.RegistrationError)
}
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
Log.w(TAG, "[Register] Got told the registration recovery password incorrect. We don't use the RRP in this flow, and should never get this error. Resetting. Message: ${error.message}")
parentEventEmitter(RegistrationFlowEvent.ResetState)
state
error("[Register] Got told the registration recovery password incorrect. We don't use the RRP in this flow, and should never get this error. Resetting. Message: ${error.message}")
}
}
}
@@ -40,6 +40,12 @@ message RegistrationData {
// Provisioning data (from saveProvisioningData)
ProvisioningData provisioningData = 20;
// Profile key (used to derive unidentified access key)
bytes profileKey = 22;
// Whether this device fetches messages (true if no FCM token)
bool fetchesMessages = 23;
// JSON-serialized flow state snapshot (from saveFlowState/restoreFlowState)
string flowStateJson = 21;
}
@@ -197,6 +197,8 @@
<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>
<!-- Error text when key is correct format but does not match account -->
<string name="EnterAepScreen__incorrect_recovery_key">Incorrect recovery key</string>
<!-- Text shown while preparing the restore -->
<string name="LocalBackupRestoreScreen__preparing_restore">Preparing restore…</string>
<!-- Title shown while restore is in progress -->
@@ -219,4 +221,56 @@
<string name="WelcomeScreen__not_on_signal_yet">Not on Signal yet?</string>
<!-- Button to create a new Signal account -->
<string name="WelcomeScreen__create_account">Create account</string>
<!-- RemoteRestoreScreen -->
<!-- Title for the remote backup restore screen -->
<string name="RemoteRestoreScreen__restore_from_backup">Restore from backup</string>
<!-- Shown while fetching backup details from server -->
<string name="RemoteRestoreScreen__fetching_backup_details">Fetching backup details…</string>
<!-- Button to start restoring the backup -->
<string name="RemoteRestoreScreen__restore_backup">Restore backup</string>
<!-- Subtitle showing when the last backup was made, with date and time placeholders -->
<string name="RemoteRestoreScreen__your_last_backup_was_made_on_s_at_s">Your last backup was made on %1$s at %2$s.</string>
<!-- Description telling the user their media will restore in the background -->
<string name="RemoteRestoreScreen__your_media_will_restore_in_the_background">Your media will restore in the background. If you choose not to restore now, you won\'t be able to restore later.</string>
<!-- Title for the backup not found dialog -->
<string name="RemoteRestoreScreen__backup_not_found">Backup not found</string>
<!-- Body for the backup not found dialog -->
<string name="RemoteRestoreScreen__no_backup_was_found">No backup was found for this account.</string>
<!-- Title for the restore failed dialog -->
<string name="RemoteRestoreScreen__cant_restore_backup">Can\'t restore backup</string>
<!-- Body for the restore failed dialog -->
<string name="RemoteRestoreScreen__your_backup_cant_be_restored_right_now">Your backup can\'t be restored right now. Please try again.</string>
<!-- Button to try again -->
<string name="RemoteRestoreScreen__try_again">Try again</string>
<!-- Title for the network error dialog -->
<string name="RemoteRestoreScreen__couldnt_finish_restore">Couldn\'t finish restore</string>
<!-- Body for the network error dialog -->
<string name="RemoteRestoreScreen__error_connecting">An error occurred while connecting to the server. Please check your connection and try again.</string>
<!-- Body for the general restore failure dialog -->
<string name="RemoteRestoreScreen__error_occurred">An error occurred while restoring your backup. Please try again.</string>
<!-- Title for the invalid backup version dialog -->
<string name="RemoteRestoreScreen__couldnt_restore_this_backup">Couldn\'t restore this backup</string>
<!-- Body for the invalid backup version dialog -->
<string name="RemoteRestoreScreen__update_latest">This version of Signal can\'t restore your backup. Update to the latest version and try again.</string>
<!-- Button to update Signal -->
<string name="RemoteRestoreScreen__update_signal">Update Signal</string>
<!-- Button to dismiss update dialog -->
<string name="RemoteRestoreScreen__not_now">Not now</string>
<!-- Title for unrecoverable backup failure dialog -->
<string name="RemoteRestoreScreen__cant_restore_this_backup">Can\'t restore backup</string>
<!-- Body for unrecoverable backup failure dialog prompting user to contact support -->
<string name="RemoteRestoreScreen__your_backup_is_not_recoverable">An error occurred while restoring your backup. Your backup is not recoverable. Please contact support for help.</string>
<!-- Button to contact support -->
<string name="RemoteRestoreScreen__contact_support">Contact support</string>
<!-- Shown while downloading the backup from the server -->
<string name="RemoteRestoreScreen__downloading_backup">Downloading backup…</string>
<!-- Shown while restoring messages from the downloaded backup -->
<string name="RemoteRestoreScreen__restoring_messages">Restoring messages…</string>
<!-- Shown while finalizing the restore -->
<string name="RemoteRestoreScreen__finishing_restore">Finishing…</string>
<!-- Shown as a generic restoring message -->
<string name="RemoteRestoreScreen__restoring">Restoring…</string>
<!-- Byte progress text, e.g. "1.2 MB of 10 MB (12.34%)" -->
<string name="RemoteRestoreScreen__s_of_s_s">%1$s of %2$s (%3$s)</string>
</resources>
@@ -952,8 +952,8 @@ class PhoneNumberEntryViewModelTest {
.isInstanceOf<RegistrationRoute.PinCreate>()
}
@Test
fun `PhoneNumberSubmitted with preExistingRegistrationData and SessionNotFoundOrNotVerified emits ResetState`() = runTest {
@Test(expected = IllegalStateException::class)
fun `PhoneNumberSubmitted with preExistingRegistrationData and SessionNotFoundOrNotVerified throws`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15551234567"
coEvery { registrationLockEnabled } returns false
@@ -971,13 +971,10 @@ class PhoneNumberEntryViewModelTest {
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
}
@Test
fun `PhoneNumberSubmitted with preExistingRegistrationData and DeviceTransferPossible emits ResetState`() = runTest {
@Test(expected = IllegalStateException::class)
fun `PhoneNumberSubmitted with preExistingRegistrationData and DeviceTransferPossible throws`() = runTest {
val preExistingData = mockk<PreExistingRegistrationData>(relaxed = true) {
coEvery { e164 } returns "+15551234567"
coEvery { registrationLockEnabled } returns false
@@ -995,9 +992,6 @@ class PhoneNumberEntryViewModelTest {
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, parentEventEmitter, stateEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
}
@Test
@@ -83,10 +83,7 @@ class PinEntryForRegistrationLockViewModelTest {
assertThat(emittedParentEvents).hasSize(3)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isInstanceOf<RegistrationFlowEvent.Registered>()
assertThat(emittedParentEvents[2])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.FullyComplete>()
assertThat(emittedParentEvents[2]).isInstanceOf<RegistrationFlowEvent.RegistrationComplete>()
}
@Test
@@ -24,7 +24,6 @@ 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 java.io.IOException
import kotlin.time.Duration.Companion.seconds
@@ -79,10 +78,7 @@ class PinEntryForSmsBypassViewModelTest {
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.FullyComplete>()
assertThat(emittedParentEvents[1]).isInstanceOf<RegistrationFlowEvent.RegistrationComplete>()
}
@Test
@@ -286,10 +282,7 @@ class PinEntryForSmsBypassViewModelTest {
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.FullyComplete>()
assertThat(emittedParentEvents[1]).isInstanceOf<RegistrationFlowEvent.RegistrationComplete>()
}
@Test
@@ -74,10 +74,7 @@ class PinEntryForSvrRestoreViewModelTest {
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.FullyComplete>()
assertThat(emittedParentEvents[1]).isInstanceOf<RegistrationFlowEvent.RegistrationComplete>()
}
// ==================== GetSvrCredentials Error Tests ====================
+1
View File
@@ -70,6 +70,7 @@ include(":core:util-jvm")
include(":core:models")
include(":core:models-jvm")
include(":core:ui")
include(":core:serialization")
// Lib modules
include(":lib:libsignal-service")