mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-21 15:49:28 +01:00
Add support for remote backup restore to regV5.
This commit is contained in:
+39
-1
@@ -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()
|
||||
|
||||
+60
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
}
|
||||
+26
@@ -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))
|
||||
}
|
||||
}
|
||||
+27
@@ -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()))
|
||||
}
|
||||
}
|
||||
+27
@@ -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()))
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+139
-5
@@ -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>>
|
||||
|
||||
+28
@@ -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))
|
||||
|
||||
+23
-7
@@ -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)
|
||||
}
|
||||
|
||||
+5
@@ -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()) }
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
+84
-15
@@ -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
|
||||
|
||||
+19
-7
@@ -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). */
|
||||
|
||||
+4
-1
@@ -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()
|
||||
}
|
||||
+62
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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))
|
||||
+52
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -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
|
||||
}
|
||||
-95
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-14
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-8
@@ -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.")
|
||||
|
||||
|
||||
+1
-3
@@ -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 -> {
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
+1
-3
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 -> {
|
||||
|
||||
+45
@@ -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
|
||||
}
|
||||
+525
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
@@ -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()
|
||||
}
|
||||
+53
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+219
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-5
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-6
@@ -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>
|
||||
|
||||
+4
-10
@@ -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
|
||||
|
||||
+1
-4
@@ -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
|
||||
|
||||
+2
-9
@@ -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
|
||||
|
||||
+1
-4
@@ -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 ====================
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user