mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-23 10:20:25 +01:00
Revamp restore decisions state and flesh out post registration restore options.
This commit is contained in:
committed by
Greyson Parrelli
parent
b78747fda2
commit
fe44789d88
@@ -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() {
|
||||
|
||||
@@ -71,13 +71,16 @@ import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
|
||||
import org.thoughtcrime.securesms.keyvalue.BackupValues.ArchiveServiceCredentials
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.StatusCodeErrorAction
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
||||
@@ -85,6 +88,7 @@ import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccessPair
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
|
||||
import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest
|
||||
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
|
||||
import org.whispersystems.signalservice.api.backup.MediaName
|
||||
@@ -758,6 +762,7 @@ object BackupRepository {
|
||||
RecipientId.clearCache()
|
||||
AppDependencies.recipientCache.clear()
|
||||
AppDependencies.recipientCache.clearSelf()
|
||||
SignalDatabase.threads.clearCache()
|
||||
|
||||
stopwatch.split("drop-data")
|
||||
|
||||
@@ -894,6 +899,7 @@ object BackupRepository {
|
||||
|
||||
AppDependencies.recipientCache.clear()
|
||||
AppDependencies.recipientCache.warmUp()
|
||||
SignalDatabase.threads.clearCache()
|
||||
|
||||
val groupJobs = SignalDatabase.groups.getGroups().use { groups ->
|
||||
val jobs = mutableListOf<Job>()
|
||||
@@ -1319,6 +1325,42 @@ object BackupRepository {
|
||||
return SignalStore.backup.backupTier
|
||||
}
|
||||
|
||||
fun verifyBackupKeyAssociatedWithAccount(aci: ACI, aep: AccountEntropyPool): MessageBackupTier? {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val messageBackupKey = aep.deriveMessageBackupKey()
|
||||
|
||||
val result: NetworkResult<MessageBackupTier> = SignalNetwork.archive.getServiceCredentials(currentTime)
|
||||
.then { result ->
|
||||
val credential: ArchiveServiceCredential? = ArchiveServiceCredentials(result.messageCredentials.associateBy { it.redemptionTime }).getForCurrentTime(currentTime.milliseconds)
|
||||
|
||||
if (credential == null) {
|
||||
NetworkResult.ApplicationError(NullPointerException("No credential available for current time."))
|
||||
} else {
|
||||
NetworkResult.Success(
|
||||
ArchiveServiceAccess(
|
||||
credential = credential,
|
||||
backupKey = messageBackupKey
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.map { messageAccess ->
|
||||
val zkCredential = SignalNetwork.archive.getZkCredential(aci, messageAccess)
|
||||
if (zkCredential.backupLevel == BackupLevel.PAID) {
|
||||
MessageBackupTier.PAID
|
||||
} else {
|
||||
MessageBackupTier.FREE
|
||||
}
|
||||
}
|
||||
|
||||
return if (result is NetworkResult.Success) {
|
||||
result.result
|
||||
} else {
|
||||
Log.i(TAG, "Unable to verify backup key", result.getCause())
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves media-specific cdn path, preferring cached value if available.
|
||||
*
|
||||
@@ -1461,8 +1503,7 @@ object BackupRepository {
|
||||
|
||||
private fun isPreRestoreDuringRegistration(): Boolean {
|
||||
return !SignalStore.registration.isRegistrationComplete &&
|
||||
!SignalStore.registration.hasCompletedRestore() &&
|
||||
!SignalStore.registration.hasSkippedTransferOrRestore() &&
|
||||
SignalStore.registration.restoreDecisionState.isDecisionPending &&
|
||||
RemoteConfig.restoreAfterRegistration
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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, " +
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -42,6 +42,10 @@ internal fun <M> SignalStoreValues.protoValue(key: String, adapter: ProtoAdapter
|
||||
return KeyValueProtoValue(key, adapter, this.store)
|
||||
}
|
||||
|
||||
internal fun <M> SignalStoreValues.protoValue(key: String, default: M, adapter: ProtoAdapter<M>): SignalStoreValueDelegate<M> {
|
||||
return KeyValueProtoWithDefaultValue(key, default, adapter, this.store)
|
||||
}
|
||||
|
||||
internal fun <T> SignalStoreValueDelegate<T>.withPrecondition(precondition: () -> Boolean): SignalStoreValueDelegate<T> {
|
||||
return PreconditionDelegate(
|
||||
delegate = this,
|
||||
@@ -154,6 +158,29 @@ private class NullableBlobValue(private val key: String, default: ByteArray?, st
|
||||
}
|
||||
}
|
||||
|
||||
private class KeyValueProtoWithDefaultValue<M>(
|
||||
private val key: String,
|
||||
default: M,
|
||||
private val adapter: ProtoAdapter<M>,
|
||||
store: KeyValueStore
|
||||
) : SignalStoreValueDelegate<M>(store, default) {
|
||||
override fun getValue(values: KeyValueStore): M {
|
||||
return if (values.containsKey(key)) {
|
||||
adapter.decode(values.getBlob(key, null))
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
override fun setValue(values: KeyValueStore, value: M) {
|
||||
if (value != null) {
|
||||
values.beginWrite().putBlob(key, adapter.encode(value)).apply()
|
||||
} else {
|
||||
values.beginWrite().remove(key).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class KeyValueProtoValue<M>(
|
||||
private val key: String,
|
||||
private val adapter: ProtoAdapter<M>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -23,5 +23,6 @@ enum class RegistrationCheckpoint {
|
||||
PIN_ENTERED,
|
||||
VERIFICATION_CODE_VALIDATED,
|
||||
SERVICE_REGISTRATION_COMPLETED,
|
||||
BACKUP_TIER_NOT_RESTORED,
|
||||
LOCAL_REGISTRATION_COMPLETE
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import org.thoughtcrime.securesms.restore.enterbackupkey.PostRegistrationEnterBackupKeyViewModel
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
|
||||
/**
|
||||
* Help verify a potential string could be an [AccountEntropyPool] string. Intended only
|
||||
* for use in [EnterBackupKeyViewModel] and [PostRegistrationEnterBackupKeyViewModel].
|
||||
*/
|
||||
object AccountEntropyPoolVerification {
|
||||
|
||||
/**
|
||||
* Given a backup key and metadata around it's previous verification state, provide an updated or new state.
|
||||
*
|
||||
* @param backupKey key to verify
|
||||
* @param changed if the key has changed from the previous verification attempt
|
||||
* @param previousAEPValidationError the error if any of the previous verification attempt
|
||||
* @return [Pair] of is contents generally valid and any still present or new validation error
|
||||
*/
|
||||
fun verifyAEP(backupKey: String, changed: Boolean, previousAEPValidationError: AEPValidationError?): Pair<Boolean, AEPValidationError?> {
|
||||
val isValid = validateContents(backupKey)
|
||||
val isShort = backupKey.length < AccountEntropyPool.LENGTH
|
||||
val isExact = backupKey.length == AccountEntropyPool.LENGTH
|
||||
|
||||
var updatedError: AEPValidationError? = checkErrorStillApplies(backupKey, previousAEPValidationError, isShort || isExact, isValid, changed)
|
||||
if (updatedError == null) {
|
||||
updatedError = checkForNewError(backupKey, isShort, isExact, isValid)
|
||||
}
|
||||
|
||||
return isValid to updatedError
|
||||
}
|
||||
|
||||
private fun validateContents(backupKey: String): Boolean {
|
||||
return AccountEntropyPool.isFullyValid(backupKey)
|
||||
}
|
||||
|
||||
private fun checkErrorStillApplies(backupKey: String, error: AEPValidationError?, isShortOrExact: Boolean, isValid: Boolean, isChanged: Boolean): AEPValidationError? {
|
||||
return when (error) {
|
||||
is AEPValidationError.TooLong -> if (isShortOrExact) null else error.copy(count = backupKey.length)
|
||||
AEPValidationError.Invalid -> if (isValid) null else error
|
||||
AEPValidationError.Incorrect -> if (isChanged) null else error
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkForNewError(backupKey: String, isShort: Boolean, isExact: Boolean, isValid: Boolean): AEPValidationError? {
|
||||
return if (!isShort && !isExact) {
|
||||
AEPValidationError.TooLong(backupKey.length, AccountEntropyPool.LENGTH)
|
||||
} else if (!isValid && isExact) {
|
||||
AEPValidationError.Invalid
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface AEPValidationError {
|
||||
data class TooLong(val count: Int, val max: Int) : AEPValidationError
|
||||
data object Invalid : AEPValidationError
|
||||
data object Incorrect : AEPValidationError
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.AccountEntropyPoolVerification.AEPValidationError
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
|
||||
class EnterBackupKeyViewModel : ViewModel() {
|
||||
@@ -36,46 +37,19 @@ class EnterBackupKeyViewModel : ViewModel() {
|
||||
val state: StateFlow<EnterBackupKeyState> = store
|
||||
|
||||
fun updateBackupKey(key: String) {
|
||||
val newKey = AccountEntropyPool.removeIllegalCharacters(key).lowercase()
|
||||
val newKey = AccountEntropyPool.removeIllegalCharacters(key).take(AccountEntropyPool.LENGTH + 16).lowercase()
|
||||
val changed = newKey != backupKey
|
||||
backupKey = newKey
|
||||
store.update {
|
||||
val isValid = validateContents(backupKey)
|
||||
val isShort = backupKey.length < it.requiredLength
|
||||
val isExact = backupKey.length == it.requiredLength
|
||||
|
||||
var updatedError: AEPValidationError? = checkErrorStillApplies(it.aepValidationError, isShort || isExact, isValid, changed)
|
||||
if (updatedError == null) {
|
||||
updatedError = checkForNewError(isShort, isExact, isValid, it.requiredLength)
|
||||
}
|
||||
|
||||
val (isValid, updatedError) = AccountEntropyPoolVerification.verifyAEP(
|
||||
backupKey = backupKey,
|
||||
changed = changed,
|
||||
previousAEPValidationError = it.aepValidationError
|
||||
)
|
||||
it.copy(backupKeyValid = isValid, aepValidationError = updatedError)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateContents(backupKey: String): Boolean {
|
||||
return AccountEntropyPool.isFullyValid(backupKey)
|
||||
}
|
||||
|
||||
private fun checkErrorStillApplies(error: AEPValidationError?, isShortOrExact: Boolean, isValid: Boolean, isChanged: Boolean): AEPValidationError? {
|
||||
return when (error) {
|
||||
is AEPValidationError.TooLong -> if (isShortOrExact) null else error.copy(count = backupKey.length)
|
||||
AEPValidationError.Invalid -> if (isValid) null else error
|
||||
AEPValidationError.Incorrect -> if (isChanged) null else error
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkForNewError(isShort: Boolean, isExact: Boolean, isValid: Boolean, requiredLength: Int): AEPValidationError? {
|
||||
return if (!isShort && !isExact) {
|
||||
AEPValidationError.TooLong(backupKey.length, requiredLength)
|
||||
} else if (!isValid && isExact) {
|
||||
AEPValidationError.Invalid
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun registering() {
|
||||
store.update { it.copy(isRegistering = true) }
|
||||
}
|
||||
@@ -111,19 +85,30 @@ class EnterBackupKeyViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun handleBackupTierNotRestored() {
|
||||
store.update {
|
||||
it.copy(
|
||||
showBackupTierNotRestoreError = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun hideRestoreBackupKeyFailed() {
|
||||
store.update {
|
||||
it.copy(
|
||||
showBackupTierNotRestoreError = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class EnterBackupKeyState(
|
||||
val backupKeyValid: Boolean = false,
|
||||
val requiredLength: Int,
|
||||
val chunkLength: Int,
|
||||
val isRegistering: Boolean = false,
|
||||
val showRegistrationError: Boolean = false,
|
||||
val showBackupTierNotRestoreError: Boolean = false,
|
||||
val registerAccountResult: RegisterAccountResult? = null,
|
||||
val aepValidationError: AEPValidationError? = null
|
||||
)
|
||||
|
||||
sealed interface AEPValidationError {
|
||||
data class TooLong(val count: Int, val max: Int) : AEPValidationError
|
||||
data object Invalid : AEPValidationError
|
||||
data object Incorrect : AEPValidationError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.skippedRestoreChoice
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.RestoreMethod
|
||||
import org.thoughtcrime.securesms.restore.transferorrestore.BackupRestorationType
|
||||
|
||||
@@ -60,7 +61,7 @@ class RestoreViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun getAvailableRestoreMethods(): List<RestoreMethod> {
|
||||
if (SignalStore.registration.isOtherDeviceAndroid) {
|
||||
if (SignalStore.registration.isOtherDeviceAndroid || SignalStore.registration.restoreDecisionState.skippedRestoreChoice) {
|
||||
val methods = mutableListOf(RestoreMethod.FROM_OLD_DEVICE, RestoreMethod.FROM_LOCAL_BACKUP_V1)
|
||||
when (SignalStore.backup.backupTier) {
|
||||
MessageBackupTier.FREE -> methods.add(1, RestoreMethod.FROM_SIGNAL_BACKUPS)
|
||||
@@ -79,4 +80,8 @@ class RestoreViewModel : ViewModel() {
|
||||
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
fun hasRestoredAccountEntropyPool(): Boolean {
|
||||
return SignalStore.account.restoredAccountEntropyPool
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.restore.enterbackupkey
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.EnterBackupKeyScreen
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
|
||||
/**
|
||||
* Collect user's [AccountEntropyPool] string for use in a post-registration manual remote backup restore flow.
|
||||
*/
|
||||
class PostRegistrationEnterBackupKeyFragment : ComposeFragment() {
|
||||
companion object {
|
||||
private val TAG = Log.tag(PostRegistrationEnterBackupKeyFragment::class)
|
||||
private const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752"
|
||||
}
|
||||
|
||||
private val viewModel by viewModels<PostRegistrationEnterBackupKeyViewModel>()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
val successful = viewModel
|
||||
.state
|
||||
.map { it.restoreBackupTierSuccessful }
|
||||
.filter { it }
|
||||
.firstOrNull() ?: false
|
||||
|
||||
if (successful) {
|
||||
Log.i(TAG, "Successfully restored AEP, moving to remote restore")
|
||||
startActivity(RemoteRestoreActivity.getIntent(requireContext()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
EnterBackupKeyScreen(
|
||||
backupKey = viewModel.backupKey,
|
||||
isBackupKeyValid = state.backupKeyValid,
|
||||
inProgress = state.inProgress,
|
||||
chunkLength = 4,
|
||||
aepValidationError = state.aepValidationError,
|
||||
onBackupKeyChanged = viewModel::updateBackupKey,
|
||||
onNextClicked = {
|
||||
viewModel.restoreBackupTier()
|
||||
},
|
||||
onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
|
||||
onSkip = { findNavController().popBackStack() }
|
||||
) {
|
||||
ErrorContent(
|
||||
showBackupTierNotRestoreError = state.showBackupTierNotRestoreError,
|
||||
onBackupTierRetry = { /*viewModel.restoreBackupTier()*/ }, // TODO
|
||||
onCancel = { findNavController().popBackStack() },
|
||||
onBackupTierNotRestoredDismiss = viewModel::hideRestoreBackupTierFailed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorContent(
|
||||
showBackupTierNotRestoreError: Boolean,
|
||||
onBackupTierRetry: () -> Unit = {},
|
||||
onCancel: () -> Unit = {},
|
||||
onBackupTierNotRestoredDismiss: () -> Unit = {}
|
||||
) {
|
||||
if (showBackupTierNotRestoreError) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.EnterBackupKey_backup_not_found),
|
||||
body = stringResource(R.string.EnterBackupKey_backup_key_you_entered_is_correct_but_no_backup),
|
||||
confirm = stringResource(R.string.EnterBackupKey_try_again),
|
||||
dismiss = stringResource(R.string.EnterBackupKey_skip_restore),
|
||||
onConfirm = onBackupTierRetry,
|
||||
onDeny = onCancel,
|
||||
onDismiss = onBackupTierNotRestoredDismiss
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.restore.enterbackupkey
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
|
||||
import org.thoughtcrime.securesms.jobs.Svr2MirrorJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.AccountEntropyPoolVerification
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.AccountEntropyPoolVerification.AEPValidationError
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
|
||||
class PostRegistrationEnterBackupKeyViewModel : ViewModel() {
|
||||
companion object {
|
||||
private val TAG = Log.tag(PostRegistrationEnterBackupKeyViewModel::class)
|
||||
}
|
||||
|
||||
private val store = MutableStateFlow(
|
||||
PostRegistrationEnterBackupKeyState()
|
||||
)
|
||||
|
||||
var backupKey by mutableStateOf("")
|
||||
private set
|
||||
|
||||
val state: StateFlow<PostRegistrationEnterBackupKeyState> = store
|
||||
|
||||
fun updateBackupKey(key: String) {
|
||||
val newKey = AccountEntropyPool.removeIllegalCharacters(key).take(AccountEntropyPool.LENGTH + 16).lowercase()
|
||||
val changed = newKey != backupKey
|
||||
backupKey = newKey
|
||||
store.update {
|
||||
val (isValid, updatedError) = AccountEntropyPoolVerification.verifyAEP(
|
||||
backupKey = backupKey,
|
||||
changed = changed,
|
||||
previousAEPValidationError = it.aepValidationError
|
||||
)
|
||||
it.copy(backupKeyValid = isValid, aepValidationError = updatedError)
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreBackupTier() {
|
||||
store.update { it.copy(inProgress = true) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val aep = AccountEntropyPool.parseOrNull(backupKey)
|
||||
val backupTier = withContext(Dispatchers.IO) {
|
||||
if (aep != null) {
|
||||
BackupRepository.verifyBackupKeyAssociatedWithAccount(SignalStore.account.requireAci(), aep)
|
||||
} else {
|
||||
Log.w(TAG, "Parsed AEP is null, failing")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (backupTier != null) {
|
||||
Log.i(TAG, "Backup tier found with entered AEP, migrating to new AEP and moving on to restore")
|
||||
SignalStore.account.restoreAccountEntropyPool(aep!!)
|
||||
AppDependencies.jobManager.add(Svr2MirrorJob())
|
||||
AppDependencies.jobManager.add(StorageForcePushJob())
|
||||
|
||||
store.update { it.copy(restoreBackupTierSuccessful = true) }
|
||||
} else {
|
||||
Log.w(TAG, "Unable to validate AEP against currently registered account")
|
||||
store.update { it.copy(showBackupTierNotRestoreError = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hideRestoreBackupTierFailed() {
|
||||
store.update {
|
||||
it.copy(showBackupTierNotRestoreError = false, inProgress = false)
|
||||
}
|
||||
}
|
||||
|
||||
data class PostRegistrationEnterBackupKeyState(
|
||||
val backupKeyValid: Boolean = false,
|
||||
val inProgress: Boolean = false,
|
||||
val restoreBackupTierSuccessful: Boolean = false,
|
||||
val showBackupTierNotRestoreError: Boolean = false,
|
||||
val aepValidationError: AEPValidationError? = null
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user