diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt index 5135241220..2511be697a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt @@ -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 = withContext(Dispatchers.IO) { + override suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult = 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 = 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 = callbackFlow { val socketHandles = mutableListOf() val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration() diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt index bd05a6f843..5b87770ca4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt @@ -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 = 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)) diff --git a/core/serialization/build.gradle.kts b/core/serialization/build.gradle.kts new file mode 100644 index 0000000000..30b7303c90 --- /dev/null +++ b/core/serialization/build.gradle.kts @@ -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) +} diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/AccountEntropyPoolSerializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/AccountEntropyPoolSerializer.kt new file mode 100644 index 0000000000..d1dd1261ca --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/AccountEntropyPoolSerializer.kt @@ -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 { + 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) + } +} diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/ByteArrayToBase64Serializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/ByteArrayToBase64Serializer.kt new file mode 100644 index 0000000000..4036117352 --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/ByteArrayToBase64Serializer.kt @@ -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 { + 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)) + } +} diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/ECPublicKeyToBase64Serializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/ECPublicKeyToBase64Serializer.kt new file mode 100644 index 0000000000..b64d063891 --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/ECPublicKeyToBase64Serializer.kt @@ -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 { + 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())) + } +} diff --git a/core/util-jvm/src/main/java/org/signal/core/util/serialization/JsonExtensions.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/JsonExtensions.kt similarity index 100% rename from core/util-jvm/src/main/java/org/signal/core/util/serialization/JsonExtensions.kt rename to core/serialization/src/main/java/org/signal/core/util/serialization/JsonExtensions.kt diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/KEMPublicKeyToBase64Serializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/KEMPublicKeyToBase64Serializer.kt new file mode 100644 index 0000000000..1c2c02d123 --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/KEMPublicKeyToBase64Serializer.kt @@ -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 { + 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())) + } +} diff --git a/core/util-jvm/src/main/java/org/signal/core/util/serialization/JsonSerializers.kt b/core/util-jvm/src/main/java/org/signal/core/util/serialization/JsonSerializers.kt deleted file mode 100644 index aef90abb97..0000000000 --- a/core/util-jvm/src/main/java/org/signal/core/util/serialization/JsonSerializers.kt +++ /dev/null @@ -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 { - 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 { - 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 { - 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())) - } -} diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt index 8e5e113f72..d1b733d261 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt @@ -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 { NetworkDebugState.getOverride>("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 { + override suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult { NetworkDebugState.getOverride>("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 { + NetworkDebugState.getOverride>("getBackupFileLastModified")?.let { + Log.d(TAG, "[getBackupFileLastModified] Returning debug override") + return it + } + return delegate.getBackupFileLastModified(aep, backupInfo) } } diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt index 6468f92a00..932b56d963 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt @@ -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 = 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 = 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 { val backupServerPublicParams = GenericServerPublicParams(serviceConfiguration.backupServerPublicParams) @@ -960,6 +1008,92 @@ class DemoNetworkController( ) } + override suspend fun getBackupFileLastModified( + aep: AccountEntropyPool, + backupInfo: NetworkController.GetBackupInfoResponse + ): RequestResult = 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): Map? { + 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(body) + return parsed.headers + } + } + + @Serializable + private data class CdnReadCredentialsResponse( + val headers: Map + ) + @Serializable private data class ArchiveCredentialsResponse( val credentials: Map> diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt index ed9e9c2623..4e3054d950 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt @@ -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 = 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)) diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsViewModel.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsViewModel.kt index b6602bf63b..4d45027fe2 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsViewModel.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsViewModel.kt @@ -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) } diff --git a/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt b/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt index b825690b9f..5fc939bb2d 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt @@ -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 get() = prefs.getStringSet(KEY_SVR2_CREDENTIALS, emptySet())?.mapNotNull { parseCredential(it) } ?: emptyList() set(value) = prefs.edit { putStringSet(KEY_SVR2_CREDENTIALS, value.map { serializeCredential(it) }.toSet()) } diff --git a/feature/registration/build.gradle.kts b/feature/registration/build.gradle.kts index 8449e7cf08..00f9c1c1db 100644 --- a/feature/registration/build.gradle.kts +++ b/feature/registration/build.gradle.kts @@ -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 diff --git a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt index 1c83ad369a..aa992203e1 100644 --- a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt +++ b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt @@ -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 + /** + * 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 + suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult + + /** + * 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 /** * Starts a provisioning session for QR-based quick restore. diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt index cd469e0bbc..914fbc468b 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt @@ -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 } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt index e892457ba4..d242ac3388 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt @@ -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)" } } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index 09de44c953..c3ce0c84cc 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -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) : RegistrationRoute { + data class ArchiveRestoreSelection(val restoreOptions: List, 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 + 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.navigationEntries( val viewModel: ArchiveRestoreSelectionViewModel = viewModel( factory = ArchiveRestoreSelectionViewModel.Factory( restoreOptions = key.restoreOptions, + isPreRegistration = key.isPreRegistration, parentEventEmitter = registrationViewModel::onEvent ) ) @@ -497,12 +519,29 @@ private fun EntryProviderScope.navigationEntries( ) } + // -- Remote Restore Screen + entry { 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 { 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.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 { - val viewModel: EnterAepViewModel = viewModel( - factory = EnterAepViewModel.Factory( + entry { + val viewModel: EnterAepForLocalBackupViewModel = viewModel( + factory = EnterAepForLocalBackupViewModel.Factory( parentEventEmitter = registrationViewModel::onEvent, resultBus = registrationViewModel.resultBus, resultKey = BACKUP_CREDENTIAL_RESULT @@ -555,6 +594,36 @@ private fun EntryProviderScope.navigationEntries( ) } + entry { 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 { + val viewModel: EnterAepForRemoteBackupPostRegistrationViewModel = viewModel( + factory = EnterAepForRemoteBackupPostRegistrationViewModel.Factory( + parentEventEmitter = registrationViewModel::onEvent + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + EnterAepScreen( + state = state, + onEvent = { viewModel.onEvent(it) } + ) + } + entry { val viewModel: QuickRestoreQrViewModel = viewModel( factory = QuickRestoreQrViewModel.Factory( diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index 325630941e..c13ceac246 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -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 = withContext(Dispatchers.IO) { + networkController.getRemoteBackupInfo(aep) + } + + suspend fun getBackupFileLastModified(aep: AccountEntropyPool, backupInfo: NetworkController.GetBackupInfoResponse): RequestResult = withContext(Dispatchers.IO) { + networkController.getBackupFileLastModified(aep, backupInfo) + } + + fun restoreRemoteBackup(aep: AccountEntropyPool): Flow { + 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 diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt index b7533d7ca2..a127ac888a 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt @@ -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(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, diff --git a/feature/registration/src/main/java/org/signal/registration/StorageController.kt b/feature/registration/src/main/java/org/signal/registration/StorageController.kt index e17e4ec188..1388ab0fb6 100644 --- a/feature/registration/src/main/java/org/signal/registration/StorageController.kt +++ b/feature/registration/src/main/java/org/signal/registration/StorageController.kt @@ -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 + /** + * 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 + /** * 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). */ diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepEvents.kt similarity index 77% rename from feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepEvents.kt rename to feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepEvents.kt index 59e87bd6d6..ca81987b5b 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepEvents.kt @@ -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() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt new file mode 100644 index 0000000000..351f1ed807 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt @@ -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 = _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 create(modelClass: Class): T { + return EnterAepForLocalBackupViewModel(parentEventEmitter, resultBus, resultKey) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt new file mode 100644 index 0000000000..aaa5865050 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt @@ -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 = _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 create(modelClass: Class): T { + return EnterAepForRemoteBackupPostRegistrationViewModel(parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt new file mode 100644 index 0000000000..fe5caeb9cf --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt @@ -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(TAG) { + + companion object { + private val TAG = Log.tag(EnterAepForRemoteBackupPreRegistrationViewModel::class) + } + + private val _state = MutableStateFlow(EnterAepState()) + + val state: StateFlow = _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 create(modelClass: Class): T { + return EnterAepForRemoteBackupPreRegistrationViewModel(e164, repository, parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt similarity index 95% rename from feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepScreen.kt rename to feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt index 1baf86eddb..9f040711b7 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt @@ -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)) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandler.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandler.kt new file mode 100644 index 0000000000..5ae398a9ee --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandler.kt @@ -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 + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepState.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt similarity index 51% rename from feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepState.kt rename to feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt index d0e9f091ae..fb814025ef 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt @@ -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 } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepViewModel.kt deleted file mode 100644 index 913b69daaf..0000000000 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterAepViewModel.kt +++ /dev/null @@ -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 = _state.asStateFlow() - - fun onEvent(event: EnterAepEvents) { - Log.d(TAG, "[Event] $event") - when (event) { - is EnterAepEvents.BackupKeyChanged -> applyBackupKeyChanged(event.value) - is EnterAepEvents.Submit -> { - if (_state.value.isBackupKeyValid) { - resultBus.sendResult(resultKey, _state.value.backupKey) - parentEventEmitter.navigateBack() - } - } - is EnterAepEvents.Cancel -> { - parentEventEmitter.navigateBack() - } - } - } - - private fun applyBackupKeyChanged(key: String) { - val newKey = AccountEntropyPool.removeIllegalCharacters(key) - .take(AccountEntropyPool.LENGTH + 16) - .lowercase() - - val changed = newKey != _state.value.backupKey - val isValid = AccountEntropyPool.isFullyValid(newKey) - val isShort = newKey.length < AccountEntropyPool.LENGTH - val isExact = newKey.length == AccountEntropyPool.LENGTH - - val previousError = _state.value.aepValidationError - - // Check if previous error still applies - var updatedError: AepValidationError? = when (previousError) { - is AepValidationError.TooLong -> if (isShort || isExact) null else previousError.copy(count = newKey.length) - AepValidationError.Invalid -> if (isValid) null else previousError - null -> null - } - - // Check for new errors - if (updatedError == null) { - updatedError = when { - !isShort && !isExact -> AepValidationError.TooLong(newKey.length, AccountEntropyPool.LENGTH) - !isValid && isExact -> AepValidationError.Invalid - else -> null - } - } - - _state.update { - it.copy( - backupKey = newKey, - isBackupKeyValid = isValid, - aepValidationError = updatedError - ) - } - } - - class Factory( - private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, - private val resultBus: ResultEventBus, - private val resultKey: String - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return EnterAepViewModel(parentEventEmitter, resultBus, resultKey) as T - } - } -} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt index 0ed81c619b..73f0b05eab 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt @@ -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, 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, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, private val isPreRegistration: Boolean, private val resultBus: ResultEventBus, private val resultKey: String ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return LocalBackupRestoreViewModel(repository, parentState, parentEventEmitter, isPreRegistration, resultBus, resultKey) as T + return LocalBackupRestoreViewModel(repository, parentEventEmitter, isPreRegistration, resultBus, resultKey) as T } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt index 72ebf20faf..3ab02a0407 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt @@ -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.") diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt index 4d709b6dab..50160e9f0f 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt @@ -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 -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt index 0f53a585e0..29e5d9abbb 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt @@ -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 } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt index 6e7eb6215b..aa63c12920 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt @@ -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 } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt index 9c3025f27c..6f5351b982 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt @@ -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 -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreProgress.kt b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreProgress.kt new file mode 100644 index 0000000000..d7f2461e39 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreProgress.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreScreen.kt new file mode 100644 index 0000000000..456d114a28 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreScreen.kt @@ -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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreScreenEvents.kt new file mode 100644 index 0000000000..50a18a8b92 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreState.kt b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreState.kt new file mode 100644 index 0000000000..b3830bd469 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreState.kt @@ -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 + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt new file mode 100644 index 0000000000..910044d66d --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt @@ -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, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(RemoteBackupRestoreViewModel::class) + } + + private val _state = MutableStateFlow(RemoteBackupRestoreState(aep)) + + val state: StateFlow = _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, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return RemoteBackupRestoreViewModel(aep, repository, parentState, parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt index 846ae9998a..8a87928073 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt @@ -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, + private val isPreRegistration: Boolean, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit ) : EventDrivenViewModel(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, + private val isPreRegistration: Boolean, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return ArchiveRestoreSelectionViewModel(restoreOptions, parentEventEmitter) as T + return ArchiveRestoreSelectionViewModel(restoreOptions, isPreRegistration, parentEventEmitter) as T } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt index d052066c03..e8ffb19e2c 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt @@ -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}") } } } diff --git a/feature/registration/src/main/protowire/Registration.proto b/feature/registration/src/main/protowire/Registration.proto index a040327cb3..a65348c895 100644 --- a/feature/registration/src/main/protowire/Registration.proto +++ b/feature/registration/src/main/protowire/Registration.proto @@ -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; } diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 4527e6f739..4e081c1f40 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -197,6 +197,8 @@ Too long (%1$d/%2$d) Invalid recovery key + + Incorrect recovery key Preparing restore… @@ -219,4 +221,56 @@ Not on Signal yet? Create account + + + + Restore from backup + + Fetching backup details… + + Restore backup + + Your last backup was made on %1$s at %2$s. + + Your media will restore in the background. If you choose not to restore now, you won\'t be able to restore later. + + Backup not found + + No backup was found for this account. + + Can\'t restore backup + + Your backup can\'t be restored right now. Please try again. + + Try again + + Couldn\'t finish restore + + An error occurred while connecting to the server. Please check your connection and try again. + + An error occurred while restoring your backup. Please try again. + + Couldn\'t restore this backup + + This version of Signal can\'t restore your backup. Update to the latest version and try again. + + Update Signal + + Not now + + Can\'t restore backup + + An error occurred while restoring your backup. Your backup is not recoverable. Please contact support for help. + + Contact support + + Downloading backup… + + Restoring messages… + + Finishing… + + Restoring… + + %1$s of %2$s (%3$s) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt index 3639734501..c0fc08c28c 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt @@ -952,8 +952,8 @@ class PhoneNumberEntryViewModelTest { .isInstanceOf() } - @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(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(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 diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt index a5b9bc9618..1158d27e80 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt @@ -83,10 +83,7 @@ class PinEntryForRegistrationLockViewModelTest { assertThat(emittedParentEvents).hasSize(3) assertThat(emittedParentEvents[0]).isInstanceOf() assertThat(emittedParentEvents[1]).isInstanceOf() - assertThat(emittedParentEvents[2]) - .isInstanceOf() - .prop(RegistrationFlowEvent.NavigateToScreen::route) - .isInstanceOf() + assertThat(emittedParentEvents[2]).isInstanceOf() } @Test diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt index 1ab177392f..0869a752d6 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt @@ -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() - assertThat(emittedParentEvents[1]) - .isInstanceOf() - .prop(RegistrationFlowEvent.NavigateToScreen::route) - .isInstanceOf() + assertThat(emittedParentEvents[1]).isInstanceOf() } @Test @@ -286,10 +282,7 @@ class PinEntryForSmsBypassViewModelTest { assertThat(emittedParentEvents).hasSize(2) assertThat(emittedParentEvents[0]).isInstanceOf() - assertThat(emittedParentEvents[1]) - .isInstanceOf() - .prop(RegistrationFlowEvent.NavigateToScreen::route) - .isInstanceOf() + assertThat(emittedParentEvents[1]).isInstanceOf() } @Test diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt index 2c72d7e816..0b323c15e3 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt @@ -74,10 +74,7 @@ class PinEntryForSvrRestoreViewModelTest { assertThat(emittedParentEvents).hasSize(2) assertThat(emittedParentEvents[0]).isInstanceOf() - assertThat(emittedParentEvents[1]) - .isInstanceOf() - .prop(RegistrationFlowEvent.NavigateToScreen::route) - .isInstanceOf() + assertThat(emittedParentEvents[1]).isInstanceOf() } // ==================== GetSvrCredentials Error Tests ==================== diff --git a/settings.gradle.kts b/settings.gradle.kts index 9b8b35426f..74bfaed59c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")