Revamp restore decisions state and flesh out post registration restore options.

This commit is contained in:
Cody Henthorne
2025-02-04 13:26:36 -05:00
committed by Greyson Parrelli
parent b78747fda2
commit fe44789d88
35 changed files with 1071 additions and 411 deletions

View File

@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
import org.thoughtcrime.securesms.keyvalue.RestoreDecisionStateUtil;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
@@ -187,7 +188,9 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private boolean userCanTransferOrRestore() {
return !SignalStore.registration().isRegistrationComplete() && RemoteConfig.restoreAfterRegistration() && !SignalStore.registration().hasSkippedTransferOrRestore() && !SignalStore.registration().hasCompletedRestore();
return !SignalStore.registration().isRegistrationComplete() &&
RemoteConfig.restoreAfterRegistration() &&
RestoreDecisionStateUtil.isDecisionPending(SignalStore.registration().getRestoreDecisionState());
}
private boolean userMustCreateSignalPin() {

View File

@@ -71,13 +71,16 @@ import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
import org.thoughtcrime.securesms.keyvalue.BackupValues.ArchiveServiceCredentials
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.toMillis
import org.whispersystems.signalservice.api.AccountEntropyPool
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.StatusCodeErrorAction
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
@@ -85,6 +88,7 @@ import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccessPair
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
import org.whispersystems.signalservice.api.backup.MediaName
@@ -758,6 +762,7 @@ object BackupRepository {
RecipientId.clearCache()
AppDependencies.recipientCache.clear()
AppDependencies.recipientCache.clearSelf()
SignalDatabase.threads.clearCache()
stopwatch.split("drop-data")
@@ -894,6 +899,7 @@ object BackupRepository {
AppDependencies.recipientCache.clear()
AppDependencies.recipientCache.warmUp()
SignalDatabase.threads.clearCache()
val groupJobs = SignalDatabase.groups.getGroups().use { groups ->
val jobs = mutableListOf<Job>()
@@ -1319,6 +1325,42 @@ object BackupRepository {
return SignalStore.backup.backupTier
}
fun verifyBackupKeyAssociatedWithAccount(aci: ACI, aep: AccountEntropyPool): MessageBackupTier? {
val currentTime = System.currentTimeMillis()
val messageBackupKey = aep.deriveMessageBackupKey()
val result: NetworkResult<MessageBackupTier> = SignalNetwork.archive.getServiceCredentials(currentTime)
.then { result ->
val credential: ArchiveServiceCredential? = ArchiveServiceCredentials(result.messageCredentials.associateBy { it.redemptionTime }).getForCurrentTime(currentTime.milliseconds)
if (credential == null) {
NetworkResult.ApplicationError(NullPointerException("No credential available for current time."))
} else {
NetworkResult.Success(
ArchiveServiceAccess(
credential = credential,
backupKey = messageBackupKey
)
)
}
}
.map { messageAccess ->
val zkCredential = SignalNetwork.archive.getZkCredential(aci, messageAccess)
if (zkCredential.backupLevel == BackupLevel.PAID) {
MessageBackupTier.PAID
} else {
MessageBackupTier.FREE
}
}
return if (result is NetworkResult.Success) {
result.result
} else {
Log.i(TAG, "Unable to verify backup key", result.getCause())
null
}
}
/**
* Retrieves media-specific cdn path, preferring cached value if available.
*
@@ -1461,8 +1503,7 @@ object BackupRepository {
private fun isPreRestoreDuringRegistration(): Boolean {
return !SignalStore.registration.isRegistrationComplete &&
!SignalStore.registration.hasCompletedRestore() &&
!SignalStore.registration.hasSkippedTransferOrRestore() &&
SignalStore.registration.restoreDecisionState.isDecisionPending &&
RemoteConfig.restoreAfterRegistration
}

View File

@@ -868,7 +868,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
SignalStore.account.setRegistered(false)
SignalStore.registration.clearRegistrationComplete()
SignalStore.registration.hasUploadedProfile = false
SignalStore.registration.debugClearSkippedTransferOrRestore()
Toast.makeText(context, "Unregistered!", Toast.LENGTH_SHORT).show()
}

View File

@@ -36,8 +36,8 @@ public class KeyValueDatabase extends SQLiteOpenHelper implements SignalDatabase
private static final String TABLE_NAME = "key_value";
private static final String ID = "_id";
private static final String KEY = "key";
private static final String VALUE = "value";
public static final String KEY = "key";
public static final String VALUE = "value";
private static final String TYPE = "type";
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +

View File

@@ -11,6 +11,8 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
@@ -24,7 +26,7 @@ class NewDeviceTransferViewModel : ViewModel() {
RegistrationUtil.maybeMarkRegistrationComplete()
}
SignalStore.registration.markRestoreCompleted()
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed
withContext(Dispatchers.Main) {
onComplete()

View File

@@ -1,7 +1,9 @@
package org.thoughtcrime.securesms.keyvalue
import androidx.annotation.CheckResult
import androidx.annotation.VisibleForTesting
import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
class RegistrationValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
@@ -11,12 +13,13 @@ class RegistrationValues internal constructor(store: KeyValueStore) : SignalStor
private const val HAS_UPLOADED_PROFILE = "registration.has_uploaded_profile"
private const val SESSION_E164 = "registration.session_e164"
private const val SESSION_ID = "registration.session_id"
private const val SKIPPED_TRANSFER_OR_RESTORE = "registration.has_skipped_transfer_or_restore"
private const val LOCAL_REGISTRATION_DATA = "registration.local_registration_data"
private const val RESTORE_COMPLETED = "registration.backup_restore_completed"
private const val RESTORE_METHOD_TOKEN = "registration.restore_method_token"
private const val IS_OTHER_DEVICE_ANDROID = "registration.is_other_device_android"
private const val RESTORING_ON_NEW_DEVICE = "registration.restoring_on_new_device"
@VisibleForTesting
const val RESTORE_DECISION_STATE = "registration.restore_decision_state"
}
@Synchronized
@@ -26,7 +29,7 @@ class RegistrationValues internal constructor(store: KeyValueStore) : SignalStor
.putBoolean(HAS_UPLOADED_PROFILE, false)
.putBoolean(REGISTRATION_COMPLETE, false)
.putBoolean(PIN_REQUIRED, true)
.putBoolean(SKIPPED_TRANSFER_OR_RESTORE, false)
.putBlob(RESTORE_DECISION_STATE, RestoreDecisionState.Start.encode())
.commit()
}
@@ -68,23 +71,5 @@ class RegistrationValues internal constructor(store: KeyValueStore) : SignalStor
@get:JvmName("isRestoringOnNewDevice")
var restoringOnNewDevice: Boolean by booleanValue(RESTORING_ON_NEW_DEVICE, false)
fun hasSkippedTransferOrRestore(): Boolean {
return getBoolean(SKIPPED_TRANSFER_OR_RESTORE, false)
}
fun markSkippedTransferOrRestore() {
putBoolean(SKIPPED_TRANSFER_OR_RESTORE, true)
}
fun debugClearSkippedTransferOrRestore() {
putBoolean(SKIPPED_TRANSFER_OR_RESTORE, false)
}
fun hasCompletedRestore(): Boolean {
return getBoolean(RESTORE_COMPLETED, false)
}
fun markRestoreCompleted() {
putBoolean(RESTORE_COMPLETED, true)
}
var restoreDecisionState: RestoreDecisionState by protoValue(RESTORE_DECISION_STATE, RestoreDecisionState.Skipped, RestoreDecisionState.ADAPTER)
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
@file:JvmName("RestoreDecisionStateUtil")
package org.thoughtcrime.securesms.keyvalue
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
/** Are we still awaiting a final decision about restore. */
val RestoreDecisionState.isDecisionPending: Boolean
get() = when (this.decisionState) {
RestoreDecisionState.State.START -> true
RestoreDecisionState.State.INTEND_TO_RESTORE -> true
RestoreDecisionState.State.NEW_ACCOUNT -> false
RestoreDecisionState.State.SKIPPED -> false
RestoreDecisionState.State.COMPLETED -> false
}
/** Has the user skiped the restore flow and continued on through normal registration. */
val RestoreDecisionState.skippedRestoreChoice: Boolean
get() = this.decisionState == RestoreDecisionState.State.START
/** Has the user indicated they want a manual remote restore but not via quick restore. */
val RestoreDecisionState.isWantingManualRemoteRestore: Boolean
get() = when (this.decisionState) {
RestoreDecisionState.State.INTEND_TO_RESTORE -> {
this.intendToRestoreData?.fromRemote == true && !this.intendToRestoreData.hasOldDevice
}
else -> false
}
/** Has a final decision been made regarding restoring. */
val RestoreDecisionState.isTerminal: Boolean
get() = !isDecisionPending
/** Start of the decision state 'machine'. Should really only be necessary on fresh install first launch. */
val RestoreDecisionState.Companion.Start: RestoreDecisionState
get() = RestoreDecisionState(RestoreDecisionState.State.START)
/** Helper to create a [RestoreDecisionState.State.INTEND_TO_RESTORE] with appropriate data. */
fun RestoreDecisionState.Companion.intendToRestore(hasOldDevice: Boolean, fromRemote: Boolean?): RestoreDecisionState {
return RestoreDecisionState(
decisionState = RestoreDecisionState.State.INTEND_TO_RESTORE,
intendToRestoreData = RestoreDecisionState.IntendToRestoreData(hasOldDevice = hasOldDevice, fromRemote = fromRemote)
)
}
/** Terminal decision made for the user if we think this is a registration without a backup */
val RestoreDecisionState.Companion.NewAccount: RestoreDecisionState
get() = RestoreDecisionState(RestoreDecisionState.State.NEW_ACCOUNT)
/** User elected not to restore any backup. */
val RestoreDecisionState.Companion.Skipped: RestoreDecisionState
get() = RestoreDecisionState(RestoreDecisionState.State.SKIPPED)
/** User elected to and successful completed restoring data in some form. */
val RestoreDecisionState.Companion.Completed: RestoreDecisionState
get() = RestoreDecisionState(RestoreDecisionState.State.COMPLETED)

View File

@@ -42,6 +42,10 @@ internal fun <M> SignalStoreValues.protoValue(key: String, adapter: ProtoAdapter
return KeyValueProtoValue(key, adapter, this.store)
}
internal fun <M> SignalStoreValues.protoValue(key: String, default: M, adapter: ProtoAdapter<M>): SignalStoreValueDelegate<M> {
return KeyValueProtoWithDefaultValue(key, default, adapter, this.store)
}
internal fun <T> SignalStoreValueDelegate<T>.withPrecondition(precondition: () -> Boolean): SignalStoreValueDelegate<T> {
return PreconditionDelegate(
delegate = this,
@@ -154,6 +158,29 @@ private class NullableBlobValue(private val key: String, default: ByteArray?, st
}
}
private class KeyValueProtoWithDefaultValue<M>(
private val key: String,
default: M,
private val adapter: ProtoAdapter<M>,
store: KeyValueStore
) : SignalStoreValueDelegate<M>(store, default) {
override fun getValue(values: KeyValueStore): M {
return if (values.containsKey(key)) {
adapter.decode(values.getBlob(key, null))
} else {
default
}
}
override fun setValue(values: KeyValueStore, value: M) {
if (value != null) {
values.beginWrite().putBlob(key, adapter.encode(value)).apply()
} else {
values.beginWrite().remove(key).apply()
}
}
}
private class KeyValueProtoValue<M>(
private val key: String,
private val adapter: ProtoAdapter<M>,

View File

@@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
import org.thoughtcrime.securesms.keyvalue.RestoreDecisionStateUtil;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType;
import org.thoughtcrime.securesms.lock.v2.SvrConstants;
@@ -239,7 +240,7 @@ public class PinRestoreEntryFragment extends LoggingFragment {
Activity activity = requireActivity();
if (RemoteConfig.messageBackups() && !SignalStore.registration().hasCompletedRestore()) {
if (RemoteConfig.messageBackups() && RestoreDecisionStateUtil.isDecisionPending(SignalStore.registration().getRestoreDecisionState())) {
final Intent transferOrRestore = RestoreActivity.getRestoreIntent(activity);
transferOrRestore.putExtra(PassphraseRequiredActivity.NEXT_INTENT_EXTRA, MainActivity.clearTop(requireContext()));
startActivity(transferOrRestore);

View File

@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.pin.PinRestoreActivity
import org.thoughtcrime.securesms.profiles.AvatarHelper
@@ -87,7 +88,7 @@ class RegistrationActivity : BaseActivity() {
val startIntent = MainActivity.clearTop(this).apply {
if (needsPin) {
putExtra("next_intent", CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationActivity))
} else if (!SignalStore.registration.hasSkippedTransferOrRestore() && RemoteConfig.messageBackups) {
} else if (SignalStore.registration.restoreDecisionState.isDecisionPending && RemoteConfig.messageBackups) {
putExtra("next_intent", RemoteRestoreActivity.getIntent(this@RegistrationActivity))
} else if (needsProfile) {
putExtra("next_intent", CreateProfileActivity.getIntentForUserProfile(this@RegistrationActivity))

View File

@@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode;
import org.thoughtcrime.securesms.keyvalue.RestoreDecisionStateUtil;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.RemoteConfig;
@@ -32,7 +33,7 @@ public final class RegistrationUtil {
SignalStore.account().isRegistered() &&
!Recipient.self().getProfileName().isEmpty() &&
(SignalStore.svr().hasOptedInWithAccess() || SignalStore.svr().hasOptedOut()) &&
(!RemoteConfig.restoreAfterRegistration() || (SignalStore.registration().hasSkippedTransferOrRestore() || SignalStore.registration().hasCompletedRestore())))
(!RemoteConfig.INSTANCE.restoreAfterRegistration() || RestoreDecisionStateUtil.isTerminal(SignalStore.registration().getRestoreDecisionState())))
{
Log.i(TAG, "Marking registration completed.", new Throwable());
SignalStore.registration().markRegistrationComplete();

View File

@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.pin.PinRestoreActivity
import org.thoughtcrime.securesms.profiles.AvatarHelper
@@ -88,7 +89,7 @@ class RegistrationActivity : BaseActivity() {
val nextIntent: Intent? = when {
needsPin -> CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationActivity)
!SignalStore.registration.hasSkippedTransferOrRestore() && RemoteConfig.messageBackups -> RemoteRestoreActivity.getIntent(this@RegistrationActivity)
SignalStore.registration.restoreDecisionState.isDecisionPending && RemoteConfig.messageBackups -> RemoteRestoreActivity.getIntent(this@RegistrationActivity)
needsProfile -> CreateProfileActivity.getIntentForUserProfile(this@RegistrationActivity)
else -> null
}

View File

@@ -23,5 +23,6 @@ enum class RegistrationCheckpoint {
PIN_ENTERED,
VERIFICATION_CODE_VALIDATED,
SERVICE_REGISTRATION_COMPLETED,
BACKUP_TIER_NOT_RESTORED,
LOCAL_REGISTRATION_COMPLETE
}

View File

@@ -14,6 +14,7 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@@ -26,6 +27,7 @@ import org.signal.core.util.Stopwatch
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
@@ -33,7 +35,13 @@ import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob
import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.keyvalue.NewAccount
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
import org.thoughtcrime.securesms.keyvalue.Start
import org.thoughtcrime.securesms.keyvalue.intendToRestore
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
import org.thoughtcrime.securesms.keyvalue.isWantingManualRemoteRestore
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.pin.SvrWrongPinException
@@ -80,6 +88,7 @@ import java.io.IOException
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
import kotlin.jvm.optionals.getOrNull
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
@@ -863,9 +872,9 @@ class RegistrationViewModel : ViewModel() {
SignalStore.registration.localRegistrationMetadata = metadata
RegistrationRepository.registerAccountLocally(context, metadata)
if (!remoteResult.storageCapable && !SignalStore.registration.hasCompletedRestore()) {
if (!remoteResult.storageCapable && SignalStore.registration.restoreDecisionState.isDecisionPending) {
// Not being storage capable is a high signal that account is new and there's no data to restore
SignalStore.registration.markSkippedTransferOrRestore()
SignalStore.registration.restoreDecisionState = RestoreDecisionState.NewAccount
}
if (reglockEnabled || SignalStore.svr.hasOptedInWithAccess()) {
@@ -892,13 +901,59 @@ class RegistrationViewModel : ViewModel() {
refreshRemoteConfig()
val checkpoint = if (SignalStore.registration.restoreDecisionState.isDecisionPending &&
SignalStore.registration.restoreDecisionState.isWantingManualRemoteRestore &&
!SignalStore.backup.isBackupTierRestored
) {
RegistrationCheckpoint.BACKUP_TIER_NOT_RESTORED
} else {
RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE
}
store.update {
it.copy(
registrationCheckpoint = RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE
registrationCheckpoint = checkpoint
)
}
}
fun resetRestoreDecision() {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Start
}
fun intendToRestore(hasOldDevice: Boolean, fromRemote: Boolean? = null) {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.intendToRestore(hasOldDevice, fromRemote)
}
fun skipRestoreAfterRegistration() {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE)
}
}
fun restoreBackupTier() {
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED)
}
viewModelScope.launch {
val start = System.currentTimeMillis()
val tierUnknown = BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) == null
delay(max(0L, 500L - (System.currentTimeMillis() - start)))
if (tierUnknown) {
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.BACKUP_TIER_NOT_RESTORED)
}
} else {
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE)
}
}
}
}
fun completeRegistration() {
AppDependencies.jobManager.startChain(ProfileUploadJob()).then(listOf(MultiDeviceProfileKeyUpdateJob(), MultiDeviceProfileContentUpdateJob())).enqueue()
RegistrationUtil.maybeMarkRegistrationComplete()

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import org.thoughtcrime.securesms.restore.enterbackupkey.PostRegistrationEnterBackupKeyViewModel
import org.whispersystems.signalservice.api.AccountEntropyPool
/**
* Help verify a potential string could be an [AccountEntropyPool] string. Intended only
* for use in [EnterBackupKeyViewModel] and [PostRegistrationEnterBackupKeyViewModel].
*/
object AccountEntropyPoolVerification {
/**
* Given a backup key and metadata around it's previous verification state, provide an updated or new state.
*
* @param backupKey key to verify
* @param changed if the key has changed from the previous verification attempt
* @param previousAEPValidationError the error if any of the previous verification attempt
* @return [Pair] of is contents generally valid and any still present or new validation error
*/
fun verifyAEP(backupKey: String, changed: Boolean, previousAEPValidationError: AEPValidationError?): Pair<Boolean, AEPValidationError?> {
val isValid = validateContents(backupKey)
val isShort = backupKey.length < AccountEntropyPool.LENGTH
val isExact = backupKey.length == AccountEntropyPool.LENGTH
var updatedError: AEPValidationError? = checkErrorStillApplies(backupKey, previousAEPValidationError, isShort || isExact, isValid, changed)
if (updatedError == null) {
updatedError = checkForNewError(backupKey, isShort, isExact, isValid)
}
return isValid to updatedError
}
private fun validateContents(backupKey: String): Boolean {
return AccountEntropyPool.isFullyValid(backupKey)
}
private fun checkErrorStillApplies(backupKey: String, error: AEPValidationError?, isShortOrExact: Boolean, isValid: Boolean, isChanged: Boolean): AEPValidationError? {
return when (error) {
is AEPValidationError.TooLong -> if (isShortOrExact) null else error.copy(count = backupKey.length)
AEPValidationError.Invalid -> if (isValid) null else error
AEPValidationError.Incorrect -> if (isChanged) null else error
null -> null
}
}
private fun checkForNewError(backupKey: String, isShort: Boolean, isExact: Boolean, isValid: Boolean): AEPValidationError? {
return if (!isShort && !isExact) {
AEPValidationError.TooLong(backupKey.length, AccountEntropyPool.LENGTH)
} else if (!isValid && isExact) {
AEPValidationError.Invalid
} else {
null
}
}
sealed interface AEPValidationError {
data class TooLong(val count: Int, val max: Int) : AEPValidationError
data object Invalid : AEPValidationError
data object Incorrect : AEPValidationError
}
}

View File

@@ -5,51 +5,11 @@
package org.thoughtcrime.securesms.registrationv3.ui.restore
import android.graphics.Typeface
import android.os.Bundle
import android.view.View
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
@@ -57,24 +17,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.registrationv3.ui.restore.EnterBackupKeyViewModel.EnterBackupKeyState
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -105,6 +58,17 @@ class EnterBackupKeyFragment : ComposeFragment() {
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
sharedViewModel
.state
.filter { it.registrationCheckpoint == RegistrationCheckpoint.BACKUP_TIER_NOT_RESTORED }
.collect {
viewModel.handleBackupTierNotRestored()
}
}
}
}
@Composable
@@ -114,8 +78,10 @@ class EnterBackupKeyFragment : ComposeFragment() {
EnterBackupKeyScreen(
backupKey = viewModel.backupKey,
state = state,
sharedState = sharedState,
inProgress = sharedState.inProgress,
isBackupKeyValid = state.backupKeyValid,
chunkLength = state.chunkLength,
aepValidationError = state.aepValidationError,
onBackupKeyChanged = viewModel::updateBackupKey,
onNextClicked = {
viewModel.registering()
@@ -126,276 +92,64 @@ class EnterBackupKeyFragment : ComposeFragment() {
pin = null
)
},
onRegistrationErrorDismiss = viewModel::clearRegistrationError,
onBackupKeyHelp = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
onSkip = { findNavController().safeNavigate(EnterBackupKeyFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION)) }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EnterBackupKeyScreen(
backupKey: String,
state: EnterBackupKeyState,
sharedState: RegistrationState,
onBackupKeyChanged: (String) -> Unit = {},
onRegistrationErrorDismiss: () -> Unit = {},
onBackupKeyHelp: () -> Unit = {},
onNextClicked: () -> Unit = {},
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {}
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
RegistrationScreen(
title = stringResource(R.string.EnterBackupKey_title),
subtitle = stringResource(R.string.EnterBackupKey_subtitle),
bottomContent = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
enabled = !sharedState.inProgress,
onClick = {
coroutineScope.launch {
sheetState.show()
}
}
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_no_backup_key)
)
}
AnimatedContent(
targetState = state.isRegistering,
label = "next-progress"
) { isRegistering ->
if (isRegistering) {
CircularProgressIndicator(
modifier = Modifier.size(40.dp)
)
} else {
Buttons.LargeTonal(
enabled = state.backupKeyValid && state.aepValidationError == null,
onClick = onNextClicked
) {
Text(
text = stringResource(id = R.string.RegistrationActivity_next)
)
}
}
}
}
}
) {
val focusRequester = remember { FocusRequester() }
val visualTransform = remember(state.chunkLength) { BackupKeyVisualTransformation(chunkSize = state.chunkLength) }
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = backupKey,
onValueChange = onBackupKeyChanged,
label = {
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
},
textStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily(typeface = Typeface.MONOSPACE),
lineHeight = 36.sp
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Next,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onNext = {
if (state.backupKeyValid) {
keyboardController?.hide()
onNextClicked()
}
}
),
supportingText = { state.aepValidationError?.ValidationErrorMessage() },
isError = state.aepValidationError != null,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
if (sheetState.isVisible) {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
}
}
) {
NoBackupKeyBottomSheet(
onLearnMore = {
coroutineScope.launch {
sheetState.hide()
}
onLearnMore()
},
onSkip = onSkip
)
}
}
if (state.showRegistrationError) {
if (state.registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_incorrect_backup_key_title),
body = stringResource(R.string.EnterBackupKey_incorrect_backup_key_message),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(R.string.EnterBackupKey_backup_key_help),
onConfirm = {},
onDeny = onBackupKeyHelp,
onDismiss = onRegistrationErrorDismiss
)
} else {
val message = when (state.registerAccountResult) {
is RegisterAccountResult.RateLimited -> stringResource(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
else -> stringResource(R.string.RegistrationActivity_error_connecting_to_service)
}
Dialogs.SimpleMessageDialog(
message = message,
onDismiss = onRegistrationErrorDismiss,
dismiss = stringResource(android.R.string.ok)
)
}
}
}
}
@Composable
private fun EnterBackupKeyViewModel.AEPValidationError.ValidationErrorMessage() {
when (this) {
is EnterBackupKeyViewModel.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, this.count, this.max))
EnterBackupKeyViewModel.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
EnterBackupKeyViewModel.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
}
}
@SignalPreview
@Composable
private fun EnterBackupKeyScreenPreview() {
Previews.Preview {
EnterBackupKeyScreen(
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
state = EnterBackupKeyState(requiredLength = 64, chunkLength = 4),
sharedState = RegistrationState(phoneNumber = null, recoveryPassword = null)
)
}
}
@SignalPreview
@Composable
private fun EnterBackupKeyScreenErrorPreview() {
Previews.Preview {
EnterBackupKeyScreen(
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
state = EnterBackupKeyState(requiredLength = 64, chunkLength = 4, aepValidationError = EnterBackupKeyViewModel.AEPValidationError.Invalid),
sharedState = RegistrationState(phoneNumber = null, recoveryPassword = null)
)
}
}
@Composable
private fun NoBackupKeyBottomSheet(
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
) {
BottomSheets.Handle()
Icon(
painter = painterResource(id = R.drawable.symbol_key_24),
tint = BackupsIconColors.Success.foreground,
contentDescription = null,
modifier = Modifier
.padding(top = 18.dp, bottom = 16.dp)
.size(88.dp)
.background(
color = BackupsIconColors.Success.background,
shape = CircleShape
)
.padding(20.dp)
)
Text(
text = stringResource(R.string.EnterBackupKey_no_backup_key),
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(36.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
TextButton(
onClick = onLearnMore
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_learn_more)
)
}
TextButton(
onClick = onSkip
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_skip_and_dont_restore)
)
}
ErrorContent(
state = state,
onBackupTierRetry = { sharedViewModel.restoreBackupTier() },
onSkipRestoreAfterRegistration = sharedViewModel::skipRestoreAfterRegistration,
onBackupTierNotRestoredDismiss = viewModel::hideRestoreBackupKeyFailed,
onRegistrationErrorDismiss = viewModel::clearRegistrationError,
onBackupKeyHelp = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) }
)
}
}
}
@SignalPreview
@Composable
private fun NoBackupKeyBottomSheetPreview() {
Previews.BottomSheetPreview {
NoBackupKeyBottomSheet()
private fun ErrorContent(
state: EnterBackupKeyViewModel.EnterBackupKeyState,
onBackupTierRetry: () -> Unit = {},
onSkipRestoreAfterRegistration: () -> Unit = {},
onBackupTierNotRestoredDismiss: () -> Unit = {},
onRegistrationErrorDismiss: () -> Unit = {},
onBackupKeyHelp: () -> Unit = {}
) {
if (state.showBackupTierNotRestoreError) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_backup_not_found),
body = stringResource(R.string.EnterBackupKey_backup_key_you_entered_is_correct_but_no_backup),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(R.string.EnterBackupKey_skip_restore),
onConfirm = onBackupTierRetry,
onDeny = onSkipRestoreAfterRegistration,
onDismiss = onBackupTierNotRestoredDismiss
)
} else if (state.showRegistrationError) {
if (state.registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_incorrect_backup_key_title),
body = stringResource(R.string.EnterBackupKey_incorrect_backup_key_message),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(R.string.EnterBackupKey_backup_key_help),
onConfirm = {},
onDeny = onBackupKeyHelp,
onDismiss = onRegistrationErrorDismiss
)
} else {
val message = when (state.registerAccountResult) {
is RegisterAccountResult.RateLimited -> stringResource(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
else -> stringResource(R.string.RegistrationActivity_error_connecting_to_service)
}
Dialogs.SimpleMessageDialog(
message = message,
onDismiss = onRegistrationErrorDismiss,
dismiss = stringResource(android.R.string.ok)
)
}
}
}

View File

@@ -0,0 +1,310 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import android.graphics.Typeface
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
import org.whispersystems.signalservice.api.AccountEntropyPool
/**
* Shared screen infrastructure for entering an [AccountEntropyPool].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EnterBackupKeyScreen(
backupKey: String,
inProgress: Boolean,
isBackupKeyValid: Boolean,
chunkLength: Int,
aepValidationError: AccountEntropyPoolVerification.AEPValidationError?,
onBackupKeyChanged: (String) -> Unit = {},
onNextClicked: () -> Unit = {},
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {},
errorContent: @Composable () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
RegistrationScreen(
title = stringResource(R.string.EnterBackupKey_title),
subtitle = stringResource(R.string.EnterBackupKey_subtitle),
bottomContent = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
enabled = !inProgress,
onClick = {
coroutineScope.launch {
sheetState.show()
}
}
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_no_backup_key)
)
}
AnimatedContent(
targetState = inProgress,
label = "next-progress"
) { inProgress ->
if (inProgress) {
CircularProgressIndicator(
modifier = Modifier.size(40.dp)
)
} else {
Buttons.LargeTonal(
enabled = isBackupKeyValid && aepValidationError == null,
onClick = onNextClicked
) {
Text(
text = stringResource(id = R.string.RegistrationActivity_next)
)
}
}
}
}
}
) {
val focusRequester = remember { FocusRequester() }
val visualTransform = remember(chunkLength) { BackupKeyVisualTransformation(chunkSize = chunkLength) }
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = backupKey,
onValueChange = onBackupKeyChanged,
label = {
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
},
textStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily(typeface = Typeface.MONOSPACE),
lineHeight = 36.sp
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Next,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onNext = {
if (isBackupKeyValid) {
keyboardController?.hide()
onNextClicked()
}
}
),
supportingText = { aepValidationError?.ValidationErrorMessage() },
isError = aepValidationError != null,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
if (sheetState.isVisible) {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
}
}
) {
NoBackupKeyBottomSheet(
onLearnMore = {
coroutineScope.launch {
sheetState.hide()
}
onLearnMore()
},
onSkip = onSkip
)
}
}
errorContent()
}
}
@Composable
private fun AccountEntropyPoolVerification.AEPValidationError.ValidationErrorMessage() {
when (this) {
is AccountEntropyPoolVerification.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, this.count, this.max))
AccountEntropyPoolVerification.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
AccountEntropyPoolVerification.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
}
}
@SignalPreview
@Composable
private fun EnterBackupKeyScreenPreview() {
Previews.Preview {
EnterBackupKeyScreen(
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
isBackupKeyValid = true,
inProgress = false,
chunkLength = 4,
aepValidationError = null
) {}
}
}
@SignalPreview
@Composable
private fun EnterBackupKeyScreenErrorPreview() {
Previews.Preview {
EnterBackupKeyScreen(
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
isBackupKeyValid = true,
inProgress = false,
chunkLength = 4,
aepValidationError = AccountEntropyPoolVerification.AEPValidationError.Invalid
) {}
}
}
@Composable
private fun NoBackupKeyBottomSheet(
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
) {
BottomSheets.Handle()
Icon(
painter = painterResource(id = R.drawable.symbol_key_24),
tint = BackupsIconColors.Success.foreground,
contentDescription = null,
modifier = Modifier
.padding(top = 18.dp, bottom = 16.dp)
.size(88.dp)
.background(
color = BackupsIconColors.Success.background,
shape = CircleShape
)
.padding(20.dp)
)
Text(
text = stringResource(R.string.EnterBackupKey_no_backup_key),
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(36.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
TextButton(
onClick = onLearnMore
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_learn_more)
)
}
TextButton(
onClick = onSkip
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_skip_and_dont_restore)
)
}
}
}
}
@SignalPreview
@Composable
private fun NoBackupKeyBottomSheetPreview() {
Previews.BottomSheetPreview {
NoBackupKeyBottomSheet()
}
}

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registrationv3.ui.restore.AccountEntropyPoolVerification.AEPValidationError
import org.whispersystems.signalservice.api.AccountEntropyPool
class EnterBackupKeyViewModel : ViewModel() {
@@ -36,46 +37,19 @@ class EnterBackupKeyViewModel : ViewModel() {
val state: StateFlow<EnterBackupKeyState> = store
fun updateBackupKey(key: String) {
val newKey = AccountEntropyPool.removeIllegalCharacters(key).lowercase()
val newKey = AccountEntropyPool.removeIllegalCharacters(key).take(AccountEntropyPool.LENGTH + 16).lowercase()
val changed = newKey != backupKey
backupKey = newKey
store.update {
val isValid = validateContents(backupKey)
val isShort = backupKey.length < it.requiredLength
val isExact = backupKey.length == it.requiredLength
var updatedError: AEPValidationError? = checkErrorStillApplies(it.aepValidationError, isShort || isExact, isValid, changed)
if (updatedError == null) {
updatedError = checkForNewError(isShort, isExact, isValid, it.requiredLength)
}
val (isValid, updatedError) = AccountEntropyPoolVerification.verifyAEP(
backupKey = backupKey,
changed = changed,
previousAEPValidationError = it.aepValidationError
)
it.copy(backupKeyValid = isValid, aepValidationError = updatedError)
}
}
private fun validateContents(backupKey: String): Boolean {
return AccountEntropyPool.isFullyValid(backupKey)
}
private fun checkErrorStillApplies(error: AEPValidationError?, isShortOrExact: Boolean, isValid: Boolean, isChanged: Boolean): AEPValidationError? {
return when (error) {
is AEPValidationError.TooLong -> if (isShortOrExact) null else error.copy(count = backupKey.length)
AEPValidationError.Invalid -> if (isValid) null else error
AEPValidationError.Incorrect -> if (isChanged) null else error
null -> null
}
}
private fun checkForNewError(isShort: Boolean, isExact: Boolean, isValid: Boolean, requiredLength: Int): AEPValidationError? {
return if (!isShort && !isExact) {
AEPValidationError.TooLong(backupKey.length, requiredLength)
} else if (!isValid && isExact) {
AEPValidationError.Invalid
} else {
null
}
}
fun registering() {
store.update { it.copy(isRegistering = true) }
}
@@ -111,19 +85,30 @@ class EnterBackupKeyViewModel : ViewModel() {
}
}
fun handleBackupTierNotRestored() {
store.update {
it.copy(
showBackupTierNotRestoreError = true
)
}
}
fun hideRestoreBackupKeyFailed() {
store.update {
it.copy(
showBackupTierNotRestoreError = false
)
}
}
data class EnterBackupKeyState(
val backupKeyValid: Boolean = false,
val requiredLength: Int,
val chunkLength: Int,
val isRegistering: Boolean = false,
val showRegistrationError: Boolean = false,
val showBackupTierNotRestoreError: Boolean = false,
val registerAccountResult: RegisterAccountResult? = null,
val aepValidationError: AEPValidationError? = null
)
sealed interface AEPValidationError {
data class TooLong(val count: Int, val max: Int) : AEPValidationError
data object Invalid : AEPValidationError
data object Incorrect : AEPValidationError
}
}

View File

@@ -22,13 +22,16 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.BackupRestoreJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
@@ -103,7 +106,7 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
when (state) {
JobTracker.JobState.SUCCESS -> {
Log.i(TAG, "Restore successful")
SignalStore.registration.markRestoreCompleted()
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed
if (!RegistrationRepository.isMissingProfileData()) {
RegistrationUtil.maybeMarkRegistrationComplete()
@@ -135,7 +138,7 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
}
fun cancel() {
SignalStore.registration.markSkippedTransferOrRestore()
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped
}
fun clearError() {
@@ -143,7 +146,7 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
}
fun skipRestore() {
SignalStore.registration.markSkippedTransferOrRestore()
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped
viewModelScope.launch {
withContext(Dispatchers.IO) {

View File

@@ -53,8 +53,14 @@ class SelectManualRestoreMethodFragment : ComposeFragment() {
private fun startRestoreMethod(method: RestoreMethod) {
when (method) {
RestoreMethod.FROM_SIGNAL_BACKUPS -> findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE))
RestoreMethod.FROM_LOCAL_BACKUP_V1 -> launchRestoreActivity.launch(RestoreActivity.getLocalRestoreIntent(requireContext()))
RestoreMethod.FROM_SIGNAL_BACKUPS -> {
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = true)
findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE))
}
RestoreMethod.FROM_LOCAL_BACKUP_V1 -> {
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false)
launchRestoreActivity.launch(RestoreActivity.getLocalRestoreIntent(requireContext()))
}
RestoreMethod.FROM_OLD_DEVICE -> error("Device transfer not supported in manual restore flow")
RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported")
}

View File

@@ -83,6 +83,7 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v
}
private fun navigateToNextScreenViaContinue() {
sharedViewModel.resetRestoreDecision()
sharedViewModel.maybePrefillE164(requireContext())
findNavController().safeNavigate(WelcomeFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL))
}
@@ -109,8 +110,14 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v
when (userSelection) {
WelcomeUserSelection.CONTINUE -> throw IllegalArgumentException()
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE -> findNavController().safeNavigate(WelcomeFragmentDirections.goToRestoreViaQr())
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> findNavController().safeNavigate(WelcomeFragmentDirections.goToSelectRestoreMethod(userSelection))
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE -> {
sharedViewModel.intendToRestore(hasOldDevice = true, fromRemote = true)
findNavController().safeNavigate(WelcomeFragmentDirections.goToRestoreViaQr())
}
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> {
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = true)
findNavController().safeNavigate(WelcomeFragmentDirections.goToSelectRestoreMethod(userSelection))
}
}
}

View File

@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.skippedRestoreChoice
import org.thoughtcrime.securesms.registrationv3.ui.restore.RestoreMethod
import org.thoughtcrime.securesms.restore.transferorrestore.BackupRestorationType
@@ -60,7 +61,7 @@ class RestoreViewModel : ViewModel() {
}
fun getAvailableRestoreMethods(): List<RestoreMethod> {
if (SignalStore.registration.isOtherDeviceAndroid) {
if (SignalStore.registration.isOtherDeviceAndroid || SignalStore.registration.restoreDecisionState.skippedRestoreChoice) {
val methods = mutableListOf(RestoreMethod.FROM_OLD_DEVICE, RestoreMethod.FROM_LOCAL_BACKUP_V1)
when (SignalStore.backup.backupTier) {
MessageBackupTier.FREE -> methods.add(1, RestoreMethod.FROM_SIGNAL_BACKUPS)
@@ -79,4 +80,8 @@ class RestoreViewModel : ViewModel() {
return emptyList()
}
fun hasRestoredAccountEntropyPool(): Boolean {
return SignalStore.account.restoredAccountEntropyPool
}
}

View File

@@ -0,0 +1,107 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.restore.enterbackupkey
import android.os.Bundle
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.signal.core.ui.Dialogs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registrationv3.ui.restore.EnterBackupKeyScreen
import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.whispersystems.signalservice.api.AccountEntropyPool
/**
* Collect user's [AccountEntropyPool] string for use in a post-registration manual remote backup restore flow.
*/
class PostRegistrationEnterBackupKeyFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(PostRegistrationEnterBackupKeyFragment::class)
private const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752"
}
private val viewModel by viewModels<PostRegistrationEnterBackupKeyViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
val successful = viewModel
.state
.map { it.restoreBackupTierSuccessful }
.filter { it }
.firstOrNull() ?: false
if (successful) {
Log.i(TAG, "Successfully restored AEP, moving to remote restore")
startActivity(RemoteRestoreActivity.getIntent(requireContext()))
}
}
}
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
EnterBackupKeyScreen(
backupKey = viewModel.backupKey,
isBackupKeyValid = state.backupKeyValid,
inProgress = state.inProgress,
chunkLength = 4,
aepValidationError = state.aepValidationError,
onBackupKeyChanged = viewModel::updateBackupKey,
onNextClicked = {
viewModel.restoreBackupTier()
},
onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
onSkip = { findNavController().popBackStack() }
) {
ErrorContent(
showBackupTierNotRestoreError = state.showBackupTierNotRestoreError,
onBackupTierRetry = { /*viewModel.restoreBackupTier()*/ }, // TODO
onCancel = { findNavController().popBackStack() },
onBackupTierNotRestoredDismiss = viewModel::hideRestoreBackupTierFailed
)
}
}
}
@Composable
private fun ErrorContent(
showBackupTierNotRestoreError: Boolean,
onBackupTierRetry: () -> Unit = {},
onCancel: () -> Unit = {},
onBackupTierNotRestoredDismiss: () -> Unit = {}
) {
if (showBackupTierNotRestoreError) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_backup_not_found),
body = stringResource(R.string.EnterBackupKey_backup_key_you_entered_is_correct_but_no_backup),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(R.string.EnterBackupKey_skip_restore),
onConfirm = onBackupTierRetry,
onDeny = onCancel,
onDismiss = onBackupTierNotRestoredDismiss
)
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.restore.enterbackupkey
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
import org.thoughtcrime.securesms.jobs.Svr2MirrorJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registrationv3.ui.restore.AccountEntropyPoolVerification
import org.thoughtcrime.securesms.registrationv3.ui.restore.AccountEntropyPoolVerification.AEPValidationError
import org.whispersystems.signalservice.api.AccountEntropyPool
class PostRegistrationEnterBackupKeyViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(PostRegistrationEnterBackupKeyViewModel::class)
}
private val store = MutableStateFlow(
PostRegistrationEnterBackupKeyState()
)
var backupKey by mutableStateOf("")
private set
val state: StateFlow<PostRegistrationEnterBackupKeyState> = store
fun updateBackupKey(key: String) {
val newKey = AccountEntropyPool.removeIllegalCharacters(key).take(AccountEntropyPool.LENGTH + 16).lowercase()
val changed = newKey != backupKey
backupKey = newKey
store.update {
val (isValid, updatedError) = AccountEntropyPoolVerification.verifyAEP(
backupKey = backupKey,
changed = changed,
previousAEPValidationError = it.aepValidationError
)
it.copy(backupKeyValid = isValid, aepValidationError = updatedError)
}
}
fun restoreBackupTier() {
store.update { it.copy(inProgress = true) }
viewModelScope.launch(Dispatchers.IO) {
val aep = AccountEntropyPool.parseOrNull(backupKey)
val backupTier = withContext(Dispatchers.IO) {
if (aep != null) {
BackupRepository.verifyBackupKeyAssociatedWithAccount(SignalStore.account.requireAci(), aep)
} else {
Log.w(TAG, "Parsed AEP is null, failing")
null
}
}
if (backupTier != null) {
Log.i(TAG, "Backup tier found with entered AEP, migrating to new AEP and moving on to restore")
SignalStore.account.restoreAccountEntropyPool(aep!!)
AppDependencies.jobManager.add(Svr2MirrorJob())
AppDependencies.jobManager.add(StorageForcePushJob())
store.update { it.copy(restoreBackupTierSuccessful = true) }
} else {
Log.w(TAG, "Unable to validate AEP against currently registered account")
store.update { it.copy(showBackupTierNotRestoreError = true) }
}
}
}
fun hideRestoreBackupTierFailed() {
store.update {
it.copy(showBackupTierNotRestoreError = false, inProgress = false)
}
}
data class PostRegistrationEnterBackupKeyState(
val backupKeyValid: Boolean = false,
val inProgress: Boolean = false,
val restoreBackupTierSuccessful: Boolean = false,
val showBackupTierNotRestoreError: Boolean = false,
val aepValidationError: AEPValidationError? = null
)
}

View File

@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.BackupEvent
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
@@ -94,7 +96,7 @@ class RestoreLocalBackupViewModel(fileBackupUri: Uri) : ViewModel() {
RegistrationUtil.maybeMarkRegistrationComplete()
}
SignalStore.registration.markRestoreCompleted()
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed
}
store.update {

View File

@@ -12,7 +12,9 @@ import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.registrationv3.ui.restore.RestoreMethod
@@ -22,7 +24,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.registration.RestoreMethod as ApiRestoreMethod
/**
* Provide options to select restore/transfer operation and flow during quick registration.
* Provide options to select restore/transfer operation and during quick/post registration.
*/
class SelectRestoreMethodFragment : ComposeFragment() {
@@ -34,7 +36,7 @@ class SelectRestoreMethodFragment : ComposeFragment() {
restoreMethods = viewModel.getAvailableRestoreMethods(),
onRestoreMethodClicked = this::startRestoreMethod,
onSkip = {
SignalStore.registration.markSkippedTransferOrRestore()
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped
lifecycleScope.launch {
QuickRegistrationRepository.setRestoreMethodForOldDevice(ApiRestoreMethod.DECLINE)
@@ -58,7 +60,13 @@ class SelectRestoreMethodFragment : ComposeFragment() {
}
when (method) {
RestoreMethod.FROM_SIGNAL_BACKUPS -> startActivity(RemoteRestoreActivity.getIntent(requireContext()))
RestoreMethod.FROM_SIGNAL_BACKUPS -> {
if (viewModel.hasRestoredAccountEntropyPool()) {
startActivity(RemoteRestoreActivity.getIntent(requireContext()))
} else {
findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToPostRestoreEnterBackupKey())
}
}
RestoreMethod.FROM_OLD_DEVICE -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToDeviceTransfer())
RestoreMethod.FROM_LOCAL_BACKUP_V1 -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToLocalBackupRestore())
RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported")