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 }