diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 991048644b..655f23847a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -861,7 +861,7 @@
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java
index 777245406c..282e7b1a3b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java
@@ -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() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt
index 277c5d0621..9b7ff32ad1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt
@@ -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()
@@ -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 = 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
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt
index 98255c1d7f..76c83644fc 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt
@@ -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()
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/KeyValueDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/KeyValueDatabase.java
index af7d34e196..57fe09853d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/KeyValueDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/KeyValueDatabase.java
@@ -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, " +
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferViewModel.kt
index fab0b6961b..760d6254f1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferViewModel.kt
@@ -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()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt
index 268a4f3a8c..726a2ecce2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt
@@ -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)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RestoreDecisionStateExt.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RestoreDecisionStateExt.kt
new file mode 100644
index 0000000000..165f8a48f9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RestoreDecisionStateExt.kt
@@ -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)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt
index ee47222ce6..225222fc20 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt
@@ -42,6 +42,10 @@ internal fun SignalStoreValues.protoValue(key: String, adapter: ProtoAdapter
return KeyValueProtoValue(key, adapter, this.store)
}
+internal fun SignalStoreValues.protoValue(key: String, default: M, adapter: ProtoAdapter): SignalStoreValueDelegate {
+ return KeyValueProtoWithDefaultValue(key, default, adapter, this.store)
+}
+
internal fun SignalStoreValueDelegate.withPrecondition(precondition: () -> Boolean): SignalStoreValueDelegate {
return PreconditionDelegate(
delegate = this,
@@ -154,6 +158,29 @@ private class NullableBlobValue(private val key: String, default: ByteArray?, st
}
}
+private class KeyValueProtoWithDefaultValue(
+ private val key: String,
+ default: M,
+ private val adapter: ProtoAdapter,
+ store: KeyValueStore
+) : SignalStoreValueDelegate(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(
private val key: String,
private val adapter: ProtoAdapter,
diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java
index e15293c6d6..1a51ee313f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java
@@ -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);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt
index 5bbeae7c01..7fbc7130e5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt
@@ -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))
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java
index 7acd443935..fcc99addf3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java
@@ -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();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationActivity.kt
index af67401586..f25eaacaf2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationActivity.kt
@@ -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
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationCheckpoint.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationCheckpoint.kt
index 82e04d1eeb..6040c7431c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationCheckpoint.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationCheckpoint.kt
@@ -23,5 +23,6 @@ enum class RegistrationCheckpoint {
PIN_ENTERED,
VERIFICATION_CODE_VALIDATED,
SERVICE_REGISTRATION_COMPLETED,
+ BACKUP_TIER_NOT_RESTORED,
LOCAL_REGISTRATION_COMPLETE
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt
index 328611e130..ca10f50179 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt
@@ -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()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/AccountEntropyPoolVerification.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/AccountEntropyPoolVerification.kt
new file mode 100644
index 0000000000..73ba2e17df
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/AccountEntropyPoolVerification.kt
@@ -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 {
+ 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
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt
index f108c62707..883e5f41b0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt
@@ -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)
+ )
+ }
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyScreen.kt
new file mode 100644
index 0000000000..998d1ec156
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyScreen.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt
index 235f0d026a..e1e619e41d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt
@@ -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 = 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
- }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt
index a333800d0f..da9ff258b0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt
@@ -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) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectManualRestoreMethodFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectManualRestoreMethodFragment.kt
index aa3afca9d8..34721301bb 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectManualRestoreMethodFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectManualRestoreMethodFragment.kt
@@ -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")
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt
index ca657fac80..20b7918efc 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt
@@ -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))
+ }
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt
index c47994300a..5300ffddd8 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt
@@ -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 {
- 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
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyFragment.kt
new file mode 100644
index 0000000000..7fdf727634
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyFragment.kt
@@ -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()
+
+ 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
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyViewModel.kt
new file mode 100644
index 0000000000..02e429229e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyViewModel.kt
@@ -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 = 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
+ )
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupViewModel.kt
index 03673def0b..5555653536 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupViewModel.kt
@@ -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 {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt
index d83f64db2a..2c0ae3090c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt
@@ -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")
diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto
index e0d15f9aa3..61f91fe3d1 100644
--- a/app/src/main/protowire/Database.proto
+++ b/app/src/main/protowire/Database.proto
@@ -536,3 +536,21 @@ message LocalRegistrationMetadata {
string servicePassword = 16;
bool reglockEnabled = 17;
}
+
+message RestoreDecisionState {
+ enum State {
+ START = 0;
+ INTEND_TO_RESTORE = 1;
+ NEW_ACCOUNT = 2;
+ SKIPPED = 3;
+ COMPLETED = 4;
+ }
+
+ message IntendToRestoreData {
+ bool hasOldDevice = 1;
+ optional bool fromRemote = 2;
+ }
+
+ State decisionState = 1;
+ optional IntendToRestoreData intendToRestoreData = 2;
+}
diff --git a/app/src/main/res/navigation/restore.xml b/app/src/main/res/navigation/restore.xml
index 4b0b520697..aaeac87fe8 100644
--- a/app/src/main/res/navigation/restore.xml
+++ b/app/src/main/res/navigation/restore.xml
@@ -79,6 +79,20 @@
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
+
+
+
+
+
+
Too long (%1$d/%2$d)
Invalid backup key
+
+ Backup not found
+
+ The backup key you entered is correct, but there is no backup associated with it. If you still have your old phone, make sure backups are enabled and that a backup has been completed and try again.
+
+ Skip restore
Scan this code with your old phone
diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt
index adc61b2953..2285bb03d2 100644
--- a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt
+++ b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt
@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.database.ProfileKeyCredentialTransformer
import org.thoughtcrime.securesms.database.QueryMonitor
import org.thoughtcrime.securesms.database.RecipientTransformer
import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.database.SignalStoreTransformer
import org.thoughtcrime.securesms.database.TimestampTransformer
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.logging.PersistentLogger
@@ -73,7 +74,7 @@ class SpinnerApplicationContext : ApplicationContext() {
)
),
"jobmanager" to DatabaseConfig(db = { JobDatabase.getInstance(this).sqlCipherDatabase }, columnTransformers = listOf(TimestampTransformer)),
- "keyvalue" to DatabaseConfig(db = { KeyValueDatabase.getInstance(this).sqlCipherDatabase }),
+ "keyvalue" to DatabaseConfig(db = { KeyValueDatabase.getInstance(this).sqlCipherDatabase }, columnTransformers = listOf(SignalStoreTransformer)),
"megaphones" to DatabaseConfig(db = { MegaphoneDatabase.getInstance(this).sqlCipherDatabase }),
"localmetrics" to DatabaseConfig(db = { LocalMetricsDatabase.getInstance(this).sqlCipherDatabase }),
"logs" to DatabaseConfig(
diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/database/SignalStoreTransformer.kt b/app/src/spinner/java/org/thoughtcrime/securesms/database/SignalStoreTransformer.kt
new file mode 100644
index 0000000000..b25e19680f
--- /dev/null
+++ b/app/src/spinner/java/org/thoughtcrime/securesms/database/SignalStoreTransformer.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2025 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.database
+
+import android.database.Cursor
+import org.signal.core.util.requireBlob
+import org.signal.core.util.requireString
+import org.signal.spinner.ColumnTransformer
+import org.signal.spinner.DefaultColumnTransformer
+import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
+import org.thoughtcrime.securesms.keyvalue.RegistrationValues
+
+/**
+ * Transform non-user friendly store values into less-non-user friendly representations.
+ */
+object SignalStoreTransformer : ColumnTransformer {
+ override fun matches(tableName: String?, columnName: String): Boolean {
+ return columnName == KeyValueDatabase.VALUE
+ }
+
+ override fun transform(tableName: String?, columnName: String, cursor: Cursor): String? {
+ return when (cursor.requireString(KeyValueDatabase.KEY)) {
+ RegistrationValues.RESTORE_DECISION_STATE -> transformRestoreDecisionState(cursor)
+ else -> DefaultColumnTransformer.transform(tableName, columnName, cursor)
+ }
+ }
+
+ private fun transformRestoreDecisionState(cursor: Cursor): String? {
+ val restoreDecisionState = cursor.requireBlob(KeyValueDatabase.VALUE)?.let { RestoreDecisionState.ADAPTER.decode(it) }
+ return restoreDecisionState.toString()
+ }
+}
diff --git a/app/src/test/java/org/thoughtcrime/securesms/registration/util/RegistrationUtilTest.kt b/app/src/test/java/org/thoughtcrime/securesms/registration/util/RegistrationUtilTest.kt
index 3457b875e6..37e1f8fe81 100644
--- a/app/src/test/java/org/thoughtcrime/securesms/registration/util/RegistrationUtilTest.kt
+++ b/app/src/test/java/org/thoughtcrime/securesms/registration/util/RegistrationUtilTest.kt
@@ -12,10 +12,9 @@ import assertk.assertions.extracting
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
-import io.mockk.Runs
import io.mockk.every
-import io.mockk.just
import io.mockk.mockkObject
+import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import org.junit.After
@@ -26,7 +25,10 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.util.logging.Log.initialize
+import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
+import org.thoughtcrime.securesms.keyvalue.Skipped
+import org.thoughtcrime.securesms.keyvalue.Start
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testutil.LogRecorder
@@ -48,8 +50,7 @@ class RegistrationUtilTest {
@Before
fun setup() {
mockkObject(Recipient)
- mockkObject(RemoteConfig)
- every { RemoteConfig.init() } just Runs
+ mockkStatic(RemoteConfig::class)
logRecorder = LogRecorder()
initialize(logRecorder)
@@ -94,7 +95,7 @@ class RegistrationUtilTest {
every { Recipient.self() } returns Recipient(profileName = ProfileName.fromParts("Dark", "Helmet"))
every { signalStore.svr.hasOptedInWithAccess() } returns true
every { RemoteConfig.restoreAfterRegistration } returns true
- every { signalStore.registration.hasSkippedTransferOrRestore() } returns true
+ every { signalStore.registration.restoreDecisionState } returns RestoreDecisionState.Skipped
RegistrationUtil.maybeMarkRegistrationComplete()
@@ -121,8 +122,7 @@ class RegistrationUtilTest {
every { signalStore.svr.hasOptedInWithAccess() } returns true
every { RemoteConfig.restoreAfterRegistration } returns true
- every { signalStore.registration.hasSkippedTransferOrRestore() } returns false
- every { signalStore.registration.hasCompletedRestore() } returns false
+ every { signalStore.registration.restoreDecisionState } returns RestoreDecisionState.Start
RegistrationUtil.maybeMarkRegistrationComplete()
diff --git a/app/src/test/java/org/thoughtcrime/securesms/registrationv3/ui/restore/AccountEntropyPoolVerificationTest.kt b/app/src/test/java/org/thoughtcrime/securesms/registrationv3/ui/restore/AccountEntropyPoolVerificationTest.kt
new file mode 100644
index 0000000000..3f4d128455
--- /dev/null
+++ b/app/src/test/java/org/thoughtcrime/securesms/registrationv3/ui/restore/AccountEntropyPoolVerificationTest.kt
@@ -0,0 +1,58 @@
+package org.thoughtcrime.securesms.registrationv3.ui.restore
+
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFalse
+import assertk.assertions.isTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.thoughtcrime.securesms.registrationv3.ui.restore.AccountEntropyPoolVerification.AEPValidationError
+
+@RunWith(Parameterized::class)
+class AccountEntropyPoolVerificationTest(
+ private val inputBackupKey: String,
+ private val inputChanged: Boolean,
+ private val inputPreviousError: AEPValidationError?,
+ private val expectedIsValid: Boolean,
+ private val expectedError: AEPValidationError?
+) {
+
+ @Test
+ fun verifyAEPValid() {
+ val (valid, error) = AccountEntropyPoolVerification.verifyAEP(inputBackupKey, inputChanged, inputPreviousError)
+
+ assertThat(valid).apply { if (expectedIsValid) isTrue() else isFalse() }
+ assertThat(error).isEqualTo(expectedError)
+ }
+
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "data[{index}]: verify(\"{0}\", {1}, {2}) = ({3}, {4})")
+ fun data(): Iterable> = listOf(
+ TestData(inputBackupKey = "", inputChanged = false, inputPreviousError = null, expectedIsValid = false, expectedError = null),
+ TestData(inputBackupKey = "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd", inputChanged = false, inputPreviousError = null, expectedIsValid = true, expectedError = null),
+ TestData(inputBackupKey = "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcda", inputChanged = true, inputPreviousError = null, expectedIsValid = false, expectedError = AEPValidationError.TooLong(65, 64)),
+ TestData(inputBackupKey = "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd", inputChanged = true, inputPreviousError = AEPValidationError.TooLong(65, 64), expectedIsValid = true, expectedError = null),
+ TestData(inputBackupKey = "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd", inputChanged = false, inputPreviousError = AEPValidationError.Incorrect, expectedIsValid = true, expectedError = AEPValidationError.Incorrect),
+ TestData(inputBackupKey = "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd", inputChanged = true, inputPreviousError = AEPValidationError.Incorrect, expectedIsValid = true, expectedError = null),
+ TestData(inputBackupKey = "!@#$!@#!@#!@#%asdf#$@#$@#asdf++dabcdabcdabcdabcdabcdabcdabcdabcd", inputChanged = true, inputPreviousError = null, expectedIsValid = false, expectedError = AEPValidationError.Invalid),
+ TestData(inputBackupKey = "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd", inputChanged = true, inputPreviousError = AEPValidationError.Invalid, expectedIsValid = true, expectedError = null),
+ TestData(inputBackupKey = "!@#$!@#!@#!@#%asdf#$@#$@#asdf++dabcdabcdabcdabcdabcdabcdabcdabcd", inputChanged = true, inputPreviousError = AEPValidationError.Invalid, expectedIsValid = false, expectedError = AEPValidationError.Invalid),
+ TestData(inputBackupKey = "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdab", inputChanged = true, inputPreviousError = AEPValidationError.TooLong(65, 64), expectedIsValid = false, expectedError = AEPValidationError.TooLong(66, 64))
+
+ ).map { it.toArray() }
+ }
+
+ class TestData(
+ private val inputBackupKey: String,
+ private val inputChanged: Boolean,
+ private val inputPreviousError: AEPValidationError?,
+ private val expectedIsValid: Boolean,
+ private val expectedError: AEPValidationError?
+ ) {
+ fun toArray(): Array {
+ return arrayOf(inputBackupKey, inputChanged, inputPreviousError, expectedIsValid, expectedError)
+ }
+ }
+}
diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/AccountEntropyPool.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/AccountEntropyPool.kt
index 9c2aea78ae..bf919c339c 100644
--- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/AccountEntropyPool.kt
+++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/AccountEntropyPool.kt
@@ -16,14 +16,14 @@ class AccountEntropyPool(val value: String) {
companion object {
private val INVALID_CHARACTERS = Regex("[^0-9a-zA-Z]")
- private const val LENGTH = 64
+ const val LENGTH = 64
fun generate(): AccountEntropyPool {
return AccountEntropyPool(LibSignalAccountEntropyPool.generate())
}
fun parseOrNull(input: String): AccountEntropyPool? {
- val stripped = input.replace(INVALID_CHARACTERS, "")
+ val stripped = removeIllegalCharacters(input)
if (stripped.length != LENGTH) {
return null
}