Do regv3 storage service restore flows right.

This commit is contained in:
Cody Henthorne
2025-02-26 09:45:14 -05:00
committed by Greyson Parrelli
parent a31ed28b5f
commit 0b3a949264
24 changed files with 291 additions and 176 deletions

View File

@@ -168,10 +168,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_UI_BLOCKING_UPGRADE;
} else if (!TextSecurePreferences.hasPromptedPushRegistration(this)) {
return STATE_WELCOME_PUSH_SCREEN;
} else if (SignalStore.storageService().getNeedsAccountRestore()) {
return STATE_ENTER_SIGNAL_PIN;
} else if (userCanTransferOrRestore()) {
return STATE_TRANSFER_OR_RESTORE;
} else if (SignalStore.storageService().getNeedsAccountRestore()) {
return STATE_ENTER_SIGNAL_PIN;
} else if (userMustSetProfileName()) {
return STATE_CREATE_PROFILE_NAME;
} else if (userMustCreateSignalPin()) {

View File

@@ -1313,7 +1313,7 @@ object BackupRepository {
val timestampResult = getBackupFileLastModified()
when {
timestampResult is NetworkResult.Success -> {
timestampResult.result?.let { SignalStore.backup.lastBackupTime = it.toMillis() }
SignalStore.backup.lastBackupTime = timestampResult.result?.toMillis() ?: 0L
}
timestampResult is NetworkResult.StatusCodeError && timestampResult.code == 404 -> {

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.keyvalue
import androidx.annotation.CheckResult
import androidx.annotation.VisibleForTesting
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -9,6 +10,8 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
class RegistrationValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
companion object {
private val TAG = Log.tag(RegistrationValues::class)
private const val REGISTRATION_COMPLETE = "registration.complete"
private const val PIN_REQUIRED = "registration.pin_required"
private const val HAS_UPLOADED_PROFILE = "registration.has_uploaded_profile"
@@ -72,7 +75,17 @@ class RegistrationValues internal constructor(store: KeyValueStore) : SignalStor
@get:JvmName("isRestoringOnNewDevice")
var restoringOnNewDevice: Boolean by booleanValue(RESTORING_ON_NEW_DEVICE, false)
var restoreDecisionState: RestoreDecisionState by protoValue(RESTORE_DECISION_STATE, RestoreDecisionState.Skipped, RestoreDecisionState.ADAPTER) { newValue ->
AppDependencies.incomingMessageObserver.notifyRegistrationStateChanged()
}
var restoreDecisionState: RestoreDecisionState
get() = store.getBlob(RESTORE_DECISION_STATE, null)?.let { RestoreDecisionState.ADAPTER.decode(it) } ?: RestoreDecisionState.Skipped
set(newValue) {
if (isRegistrationComplete) {
Log.w(TAG, "Registration was completed, cannot change initial restore decision state")
} else {
Log.v(TAG, "Restore decision set: $newValue", Throwable())
store.beginWrite()
.putBlob(RESTORE_DECISION_STATE, newValue.encode())
.apply()
AppDependencies.incomingMessageObserver.notifyRegistrationStateChanged()
}
}
}

View File

@@ -8,24 +8,23 @@ package org.thoughtcrime.securesms.pin
import android.app.backup.BackupManager
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import kotlinx.coroutines.runBlocking
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.jobs.Svr2MirrorJob
import org.thoughtcrime.securesms.jobs.Svr3MirrorJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.registrationv3.ui.restore.StorageServiceRestore
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.svr.SecureValueRecovery
@@ -168,7 +167,6 @@ object SvrRepository {
SignalStore.svr.isRegistrationLockEnabled = false
SignalStore.pin.resetPinReminders()
SignalStore.pin.keyboardType = pinKeyboardType
SignalStore.storageService.needsAccountRestore = false
when (implementation.svrVersion) {
SvrVersion.SVR2 -> SignalStore.svr.appendSvr2AuthTokenToList(response.authorization.asBasic())
@@ -178,15 +176,8 @@ object SvrRepository {
AppDependencies.jobManager.add(ResetSvrGuessCountJob())
stopwatch.split("metadata")
AppDependencies.jobManager.runSynchronously(StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN)
stopwatch.split("account-restore")
AppDependencies
.jobManager
.startChain(StorageSyncJob())
.then(ReclaimUsernameAndLinkJob())
.enqueueAndBlockUntilCompletion(TimeUnit.SECONDS.toMillis(10))
stopwatch.split("contact-restore")
runBlocking { StorageServiceRestore.restore() }
stopwatch.split("restore-account")
if (implementation.svrVersion != SvrVersion.SVR2 && Svr3Migration.shouldWriteToSvr2) {
AppDependencies.jobManager.add(Svr2MirrorJob())

View File

@@ -33,7 +33,7 @@ public final class RegistrationUtil {
SignalStore.account().isRegistered() &&
!Recipient.self().getProfileName().isEmpty() &&
(SignalStore.svr().hasOptedInWithAccess() || SignalStore.svr().hasOptedOut()) &&
(!RemoteConfig.INSTANCE.restoreAfterRegistration() || RestoreDecisionStateUtil.isTerminal(SignalStore.registration().getRestoreDecisionState())))
(!RemoteConfig.restoreAfterRegistration() || RestoreDecisionStateUtil.isTerminal(SignalStore.registration().getRestoreDecisionState())))
{
Log.i(TAG, "Marking registration completed.", new Throwable());
SignalStore.registration().markRegistrationComplete();

View File

@@ -17,16 +17,8 @@ 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
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.sms.SmsRetrieverReceiver
import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Activity to hold the entire registration process.
@@ -69,40 +61,9 @@ class RegistrationActivity : BaseActivity() {
SignalStore.misc.shouldShowLinkedDevicesReminder = sharedViewModel.isReregister
}
if (SignalStore.storageService.needsAccountRestore) {
Log.i(TAG, "Performing pin restore.")
startActivity(Intent(this, PinRestoreActivity::class.java))
finish()
} else {
val isProfileNameEmpty = Recipient.self().profileName.isEmpty
val isAvatarEmpty = !AvatarHelper.hasAvatar(this, Recipient.self().id)
val needsProfile = isProfileNameEmpty || isAvatarEmpty
val needsPin = !SignalStore.svr.hasOptedInWithAccess()
Log.i(TAG, "Pin restore flow not required. Profile name empty: $isProfileNameEmpty | Profile avatar empty: $isAvatarEmpty | Needs PIN: $needsPin")
if (!needsProfile && !needsPin) {
sharedViewModel.completeRegistration()
}
val startIntent = MainActivity.clearTop(this)
val nextIntent: Intent? = when {
needsPin -> CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationActivity)
SignalStore.registration.restoreDecisionState.isDecisionPending && RemoteConfig.messageBackups -> RemoteRestoreActivity.getIntent(this@RegistrationActivity)
needsProfile -> CreateProfileActivity.getIntentForUserProfile(this@RegistrationActivity)
else -> null
}
if (nextIntent != null) {
startIntent.putExtra("next_intent", nextIntent)
}
Log.d(TAG, "Launching ${startIntent.component} with next_intent: ${nextIntent?.component}")
startActivity(startIntent)
finish()
ActivityNavigator.applyPopAnimationsToPendingTransition(this)
}
startActivity(MainActivity.clearTop(this))
finish()
ActivityNavigator.applyPopAnimationsToPendingTransition(this)
}
private inner class SmsRetrieverObserver : DefaultLifecycleObserver {

View File

@@ -75,4 +75,22 @@ data class RegistrationState(
}
}
}
fun toNavigationStateOnly(): NavigationState {
return NavigationState(challengesRequested, challengesPresented, captchaToken, registrationCheckpoint, canSkipSms)
}
/**
* Subset of [RegistrationState] useful for deciding on navigation. Prevents other properties updating from re-triggering
* navigation decisions.
*/
data class NavigationState(
val challengesRequested: List<Challenge>,
val challengesPresented: Set<Challenge>,
val captchaToken: String? = null,
val registrationCheckpoint: RegistrationCheckpoint,
val canSkipSms: Boolean
) {
val challengesRemaining: List<Challenge> = challengesRequested.filterNot { it in challengesPresented }
}
}

View File

@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.Base64
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
@@ -32,9 +31,6 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
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
@@ -75,6 +71,7 @@ import org.thoughtcrime.securesms.registration.ui.toE164
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
import org.thoughtcrime.securesms.registrationv3.ui.restore.StorageServiceRestore
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
@@ -85,7 +82,6 @@ import org.whispersystems.signalservice.api.svr.Svr3Credentials
import org.whispersystems.signalservice.internal.push.AuthCredentials
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
@@ -880,37 +876,29 @@ class RegistrationViewModel : ViewModel() {
RegistrationRepository.registerAccountLocally(context, metadata)
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
Log.v(TAG, "Not storage capable and still pending restore decision, likely an account with no data to restore, skipping post register restore")
SignalStore.registration.restoreDecisionState = RestoreDecisionState.NewAccount
}
if (reglockEnabled || SignalStore.svr.hasOptedInWithAccess()) {
SignalStore.onboarding.clearAll()
if (!SignalStore.registration.restoreDecisionState.isDecisionPending) {
Log.d(TAG, "No pending restore decisions, can restore account from storage service")
StorageServiceRestore.restore()
}
}
if (reglockEnabled || SignalStore.svr.hasOptedInWithAccess()) {
val stopwatch = Stopwatch("post-reg-storage-service")
AppDependencies.jobManager.runSynchronously(StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN)
stopwatch.split("account-restore")
AppDependencies.jobManager
.startChain(StorageSyncJob())
.then(ReclaimUsernameAndLinkJob())
.enqueueAndBlockUntilCompletion(TimeUnit.SECONDS.toMillis(10))
stopwatch.split("storage-sync")
if (SignalStore.account.restoredAccountEntropyPool) {
Log.d(TAG, "Restoring backup tier")
BackupRepository.restoreBackupTier(SignalStore.account.requireAci())
stopwatch.split("backup-tier")
stopwatch.stop(TAG)
}
refreshRemoteConfig()
val checkpoint = if (SignalStore.registration.restoreDecisionState.isDecisionPending &&
SignalStore.registration.restoreDecisionState.isWantingManualRemoteRestore &&
!SignalStore.backup.isBackupTierRestored
(!SignalStore.backup.isBackupTierRestored || SignalStore.backup.lastBackupTime == 0L)
) {
RegistrationCheckpoint.BACKUP_TIER_NOT_RESTORED
} else {
@@ -932,8 +920,11 @@ class RegistrationViewModel : ViewModel() {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.intendToRestore(hasOldDevice, fromRemote)
}
fun skipRestoreAfterRegistration() {
fun skipRestore() {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped
}
fun resumeNormalRegistration() {
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE)
}
@@ -941,7 +932,7 @@ class RegistrationViewModel : ViewModel() {
fun restoreBackupTier() {
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED)
it.copy(inProgress = true, registrationCheckpoint = RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED)
}
viewModelScope.launch {
@@ -949,7 +940,7 @@ class RegistrationViewModel : ViewModel() {
val tierUnknown = BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) == null
delay(max(0L, 500L - (System.currentTimeMillis() - start)))
if (tierUnknown) {
if (tierUnknown || SignalStore.backup.lastBackupTime == 0L) {
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.BACKUP_TIER_NOT_RESTORED)
}

View File

@@ -160,18 +160,24 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
handleRegistrationErrorResponse(it)
sharedViewModel.registerAccountErrorShown()
}
if (sharedState.challengesRequested.contains(Challenge.CAPTCHA) && sharedState.captchaToken.isNotNullOrBlank()) {
sharedViewModel.submitCaptchaToken(requireContext())
} else if (sharedState.challengesRemaining.isNotEmpty()) {
handleChallenges(sharedState.challengesRemaining)
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED && sharedState.canSkipSms) {
moveToEnterPinScreen()
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED) {
moveToVerificationEntryScreen()
}
}
sharedViewModel
.uiState
.map { it.toNavigationStateOnly() }
.distinctUntilChanged()
.observe(viewLifecycleOwner) { sharedState ->
if (sharedState.challengesRequested.contains(Challenge.CAPTCHA) && sharedState.captchaToken.isNotNullOrBlank()) {
sharedViewModel.submitCaptchaToken(requireContext())
} else if (sharedState.challengesRemaining.isNotEmpty()) {
handleChallenges(sharedState.challengesRemaining)
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED && sharedState.canSkipSms) {
moveToEnterPinScreen()
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED) {
moveToVerificationEntryScreen()
}
}
fragmentViewModel
.uiState
.map { it.phoneNumberRegionCode }

View File

@@ -47,7 +47,7 @@ class EnterBackupKeyFragment : ComposeFragment() {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
sharedViewModel
.state
.map { it.registerAccountError }
@@ -60,7 +60,7 @@ class EnterBackupKeyFragment : ComposeFragment() {
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
sharedViewModel
.state
.filter { it.registrationCheckpoint == RegistrationCheckpoint.BACKUP_TIER_NOT_RESTORED }
@@ -94,18 +94,31 @@ class EnterBackupKeyFragment : ComposeFragment() {
},
onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
onSkip = { findNavController().safeNavigate(EnterBackupKeyFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION)) }
) {
ErrorContent(
state = state,
onBackupTierRetry = { sharedViewModel.restoreBackupTier() },
onSkipRestoreAfterRegistration = sharedViewModel::skipRestoreAfterRegistration,
onBackupTierNotRestoredDismiss = viewModel::hideRestoreBackupKeyFailed,
onRegistrationErrorDismiss = viewModel::clearRegistrationError,
onBackupKeyHelp = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) }
)
}
onSkip = {
sharedViewModel.skipRestore()
findNavController().safeNavigate(EnterBackupKeyFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION))
},
dialogContent = {
if (state.showStorageAccountRestoreProgress) {
Dialogs.IndeterminateProgressDialog()
} else {
ErrorContent(
state = state,
onBackupTierRetry = { sharedViewModel.restoreBackupTier() },
onSkipRestoreAfterRegistration = {
viewLifecycleOwner.lifecycleScope.launch {
sharedViewModel.skipRestore()
viewModel.performStorageServiceAccountRestoreIfNeeded()
sharedViewModel.resumeNormalRegistration()
}
},
onBackupTierNotRestoredDismiss = viewModel::hideRestoreBackupKeyFailed,
onRegistrationErrorDismiss = viewModel::clearRegistrationError,
onBackupKeyHelp = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) }
)
}
}
)
}
}

View File

@@ -73,7 +73,7 @@ fun EnterBackupKeyScreen(
onNextClicked: () -> Unit = {},
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {},
errorContent: @Composable () -> Unit
dialogContent: @Composable () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
@@ -185,7 +185,7 @@ fun EnterBackupKeyScreen(
}
}
errorContent()
dialogContent()
}
}

View File

@@ -101,6 +101,13 @@ class EnterBackupKeyViewModel : ViewModel() {
}
}
suspend fun performStorageServiceAccountRestoreIfNeeded() {
if (SignalStore.account.restoredAccountEntropyPool || SignalStore.svr.masterKeyForInitialDataRestore != null) {
store.update { it.copy(showBackupTierNotRestoreError = false, showStorageAccountRestoreProgress = true) }
StorageServiceRestore.restore()
}
}
data class EnterBackupKeyState(
val backupKeyValid: Boolean = false,
val requiredLength: Int,
@@ -109,6 +116,7 @@ class EnterBackupKeyViewModel : ViewModel() {
val showRegistrationError: Boolean = false,
val showBackupTierNotRestoreError: Boolean = false,
val registerAccountResult: RegisterAccountResult? = null,
val aepValidationError: AEPValidationError? = null
val aepValidationError: AEPValidationError? = null,
val showStorageAccountRestoreProgress: Boolean = false
)
}

View File

@@ -56,7 +56,6 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFe
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.PlayStoreUtil
@@ -93,7 +92,8 @@ class RemoteRestoreActivity : BaseActivity() {
.firstOrNull()
if (restored != null) {
continueRegistration(restored.missingProfileData)
startActivity(MainActivity.clearTop(this@RemoteRestoreActivity))
finish()
}
}
@@ -106,12 +106,15 @@ class RemoteRestoreActivity : BaseActivity() {
state = state,
onRestoreBackupClick = { viewModel.restore() },
onCancelClick = {
if (state.isRemoteRestoreOnlyOption) {
viewModel.skipRestore()
startActivity(MainActivity.clearTop(this))
}
lifecycleScope.launch {
if (state.isRemoteRestoreOnlyOption) {
viewModel.skipRestore()
viewModel.performStorageServiceAccountRestoreIfNeeded()
startActivity(MainActivity.clearTop(this@RemoteRestoreActivity))
}
finish()
finish()
}
},
onErrorDialogDismiss = { viewModel.clearError() },
onUpdateSignal = {
@@ -129,20 +132,6 @@ class RemoteRestoreActivity : BaseActivity() {
fun onEvent(restoreEvent: RestoreV2Event) {
viewModel.updateRestoreProgress(restoreEvent)
}
private fun continueRegistration(missingProfileData: Boolean) {
val main = MainActivity.clearTop(this)
if (missingProfileData) {
val profile = CreateProfileActivity.getIntentForUserProfile(this)
profile.putExtra("next_intent", main)
startActivity(profile)
} else {
startActivity(main)
}
finish()
}
}
@Composable
@@ -171,12 +160,16 @@ private fun RestoreFromBackupContent(
}
RemoteRestoreViewModel.ScreenState.LoadState.NOT_FOUND -> {
RestoreFailedDialog(onDismiss = onCancelClick)
BackupNotFoundDialog(onDismiss = onCancelClick)
}
RemoteRestoreViewModel.ScreenState.LoadState.FAILURE -> {
RestoreFailedDialog(onDismiss = onCancelClick)
}
RemoteRestoreViewModel.ScreenState.LoadState.STORAGE_SERVICE_RESTORE -> {
Dialogs.IndeterminateProgressDialog()
}
}
}
@@ -403,6 +396,19 @@ private fun ProgressDialogPreview() {
}
}
@Composable
fun BackupNotFoundDialog(
onDismiss: () -> Unit = {}
) {
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(android.R.string.ok),
onConfirm = onDismiss,
onDismiss = onDismiss
)
}
@Composable
fun RestoreFailedDialog(
onDismiss: () -> Unit = {}

View File

@@ -27,14 +27,11 @@ 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
import org.whispersystems.signalservice.api.registration.RestoreMethod
class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
@@ -58,7 +55,7 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
val tier: MessageBackupTier? = BackupRepository.restoreBackupTier(SignalStore.account.requireAci())
store.update {
if (tier != null) {
if (tier != null && SignalStore.backup.lastBackupTime > 0) {
it.copy(
loadState = ScreenState.LoadState.LOADED,
backupTier = SignalStore.backup.backupTier,
@@ -66,7 +63,7 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
backupSize = SignalStore.backup.totalBackupSize.bytes
)
} else {
if (SignalStore.backup.isBackupTierRestored) {
if (SignalStore.backup.isBackupTierRestored || SignalStore.backup.lastBackupTime == 0L) {
it.copy(loadState = ScreenState.LoadState.NOT_FOUND)
} else if (it.loadState == ScreenState.LoadState.LOADING) {
it.copy(loadState = ScreenState.LoadState.FAILURE)
@@ -108,12 +105,9 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
Log.i(TAG, "Restore successful")
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed
if (!RegistrationRepository.isMissingProfileData()) {
RegistrationUtil.maybeMarkRegistrationComplete()
AppDependencies.jobManager.add(ProfileUploadJob())
}
StorageServiceRestore.restore()
store.update { it.copy(importState = ImportState.Restored(RegistrationRepository.isMissingProfileData())) }
store.update { it.copy(importState = ImportState.Restored) }
}
JobTracker.JobState.PENDING,
@@ -155,6 +149,13 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
}
}
suspend fun performStorageServiceAccountRestoreIfNeeded() {
if (SignalStore.account.restoredAccountEntropyPool || SignalStore.svr.masterKeyForInitialDataRestore != null) {
store.update { it.copy(loadState = ScreenState.LoadState.STORAGE_SERVICE_RESTORE) }
StorageServiceRestore.restore()
}
}
data class ScreenState(
val isRemoteRestoreOnlyOption: Boolean = false,
val backupTier: MessageBackupTier? = null,
@@ -170,14 +171,14 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
}
enum class LoadState {
LOADING, LOADED, NOT_FOUND, FAILURE
LOADING, LOADED, NOT_FOUND, FAILURE, STORAGE_SERVICE_RESTORE
}
}
sealed interface ImportState {
data object None : ImportState
data object InProgress : ImportState
data class Restored(val missingProfileData: Boolean) : ImportState
data object Restored : ImportState
data object Failed : ImportState
}
}

View File

@@ -29,7 +29,7 @@ class SelectManualRestoreMethodFragment : ComposeFragment() {
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val launchRestoreActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
private val localBackupRestore = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
when (val resultCode = result.resultCode) {
Activity.RESULT_OK -> {
sharedViewModel.onBackupSuccessfullyRestored()
@@ -47,7 +47,10 @@ class SelectManualRestoreMethodFragment : ComposeFragment() {
SelectRestoreMethodScreen(
restoreMethods = listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1),
onRestoreMethodClicked = this::startRestoreMethod,
onSkip = { findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL)) }
onSkip = {
sharedViewModel.skipRestore()
findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL))
}
)
}
@@ -59,7 +62,7 @@ class SelectManualRestoreMethodFragment : ComposeFragment() {
}
RestoreMethod.FROM_LOCAL_BACKUP_V1 -> {
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false)
launchRestoreActivity.launch(RestoreActivity.getLocalRestoreIntent(requireContext()))
localBackupRestore.launch(RestoreActivity.getLocalRestoreIntent(requireContext()))
}
RestoreMethod.FROM_OLD_DEVICE -> error("Device transfer not supported in manual restore flow")
RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported")

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.registrationv3.ui.restore
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -24,7 +25,8 @@ import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
fun SelectRestoreMethodScreen(
restoreMethods: List<RestoreMethod>,
onRestoreMethodClicked: (RestoreMethod) -> Unit = {},
onSkip: () -> Unit = {}
onSkip: () -> Unit = {},
extraContent: @Composable ColumnScope.() -> Unit = {}
) {
RegistrationScreen(
title = stringResource(id = R.string.SelectRestoreMethodFragment__restore_or_transfer_account),
@@ -46,6 +48,8 @@ fun SelectRestoreMethodScreen(
onRowClick = { onRestoreMethodClicked(method) }
)
}
extraContent()
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.enqueueBlocking
import org.thoughtcrime.securesms.jobmanager.runJobBlocking
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.SignalStore
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
object StorageServiceRestore {
private val TAG = Log.tag(StorageServiceRestore::class)
/**
* Restore account data from Storage Service in a quasi-blocking manner. Uses existing jobs
* to perform the restore but will not wait indefinitely for them to finish so may return prior
* to completing the restore.
*/
suspend fun restore() {
withContext(Dispatchers.IO) {
val stopwatch = Stopwatch("storage-service-restore")
SignalStore.storageService.needsAccountRestore = false
AppDependencies.jobManager.runJobBlocking(StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN.milliseconds)
stopwatch.split("account-restore")
AppDependencies
.jobManager
.startChain(StorageSyncJob())
.then(ReclaimUsernameAndLinkJob())
.enqueueBlocking(10.seconds)
stopwatch.split("storage-sync-restore")
stopwatch.stop(TAG)
val isMissingProfileData = RegistrationRepository.isMissingProfileData()
if (!isMissingProfileData) {
RegistrationUtil.maybeMarkRegistrationComplete()
AppDependencies.jobManager.add(ProfileUploadJob())
}
}
}
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
@@ -49,7 +50,7 @@ fun RegistrationScreen(
title: String,
subtitle: String,
bottomContent: @Composable (BoxScope.() -> Unit),
mainContent: @Composable () -> Unit
mainContent: @Composable ColumnScope.() -> Unit
) {
RegistrationScreen(title, AnnotatedString(subtitle), bottomContent, mainContent)
}
@@ -62,7 +63,7 @@ fun RegistrationScreen(
title: String,
subtitle: AnnotatedString?,
bottomContent: @Composable (BoxScope.() -> Unit),
mainContent: @Composable () -> Unit
mainContent: @Composable ColumnScope.() -> Unit
) {
Surface {
Column(

View File

@@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumber
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
/**
* First screen that is displayed on the very first app launch.
@@ -49,6 +50,7 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v
binding.welcomeContinueButton.setOnClickListener { onContinueClicked() }
binding.welcomeTermsButton.setOnClickListener { onTermsClicked() }
binding.welcomeTransferOrRestore.setOnClickListener { onRestoreOrTransferClicked() }
binding.welcomeTransferOrRestore.visible = !sharedViewModel.isReregister
childFragmentManager.setFragmentResultListener(RestoreWelcomeBottomSheet.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle ->
if (requestKey == RestoreWelcomeBottomSheet.REQUEST_KEY) {

View File

@@ -7,15 +7,25 @@ package org.thoughtcrime.securesms.restore
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
import org.thoughtcrime.securesms.keyvalue.skippedRestoreChoice
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
import org.thoughtcrime.securesms.registrationv3.ui.restore.RestoreMethod
import org.thoughtcrime.securesms.registrationv3.ui.restore.StorageServiceRestore
import org.thoughtcrime.securesms.restore.transferorrestore.BackupRestorationType
import org.whispersystems.signalservice.api.registration.RestoreMethod as ApiRestoreMethod
/**
* Shared view model for the restore flow.
@@ -24,6 +34,9 @@ class RestoreViewModel : ViewModel() {
private val store = MutableStateFlow(RestoreState())
val uiState = store.asLiveData()
var showStorageAccountRestoreProgress by mutableStateOf(false)
private set
fun setNextIntent(nextIntent: Intent) {
store.update {
it.copy(nextIntent = nextIntent)
@@ -84,4 +97,19 @@ class RestoreViewModel : ViewModel() {
fun hasRestoredAccountEntropyPool(): Boolean {
return SignalStore.account.restoredAccountEntropyPool
}
fun skipRestore() {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped
viewModelScope.launch {
QuickRegistrationRepository.setRestoreMethodForOldDevice(ApiRestoreMethod.DECLINE)
}
}
suspend fun performStorageServiceAccountRestoreIfNeeded() {
if (hasRestoredAccountEntropyPool() || SignalStore.svr.masterKeyForInitialDataRestore != null) {
showStorageAccountRestoreProgress = true
StorageServiceRestore.restore()
}
}
}

View File

@@ -25,8 +25,8 @@ 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.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.AccountEntropyPool
/**
@@ -44,7 +44,7 @@ class PostRegistrationEnterBackupKeyFragment : ComposeFragment() {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
val successful = viewModel
.state
.map { it.restoreBackupTierSuccessful }
@@ -52,8 +52,8 @@ class PostRegistrationEnterBackupKeyFragment : ComposeFragment() {
.firstOrNull() ?: false
if (successful) {
Log.i(TAG, "Successfully restored AEP, moving to remote restore")
startActivity(RemoteRestoreActivity.getIntent(requireContext()))
Log.i(TAG, "Successfully restored an AEP, moving to remote restore")
findNavController().safeNavigate(PostRegistrationEnterBackupKeyFragmentDirections.goToRemoteRestoreActivity())
}
}
}
@@ -78,7 +78,7 @@ class PostRegistrationEnterBackupKeyFragment : ComposeFragment() {
) {
ErrorContent(
showBackupTierNotRestoreError = state.showBackupTierNotRestoreError,
onBackupTierRetry = { /*viewModel.restoreBackupTier()*/ }, // TODO
onBackupTierRetry = { viewModel.restoreBackupTier() },
onCancel = { findNavController().popBackStack() },
onBackupTierNotRestoredDismiss = viewModel::hideRestoreBackupTierFailed
)

View File

@@ -18,9 +18,6 @@ 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
@@ -70,9 +67,6 @@ class PostRegistrationEnterBackupKeyViewModel : ViewModel() {
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")

View File

@@ -9,12 +9,13 @@ import androidx.compose.runtime.Composable
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.ui.Dialogs
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
@@ -24,7 +25,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 during quick/post registration.
* Provide options to select restore/transfer operation during quick/post registration.
*/
class SelectRestoreMethodFragment : ComposeFragment() {
@@ -36,16 +37,23 @@ class SelectRestoreMethodFragment : ComposeFragment() {
restoreMethods = viewModel.getAvailableRestoreMethods(),
onRestoreMethodClicked = this::startRestoreMethod,
onSkip = {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped
viewLifecycleOwner.lifecycleScope.launch {
viewModel.skipRestore()
viewModel.performStorageServiceAccountRestoreIfNeeded()
lifecycleScope.launch {
QuickRegistrationRepository.setRestoreMethodForOldDevice(ApiRestoreMethod.DECLINE)
if (isActive) {
withContext(Dispatchers.Main) {
startActivity(MainActivity.clearTop(requireContext()))
activity?.finish()
}
}
}
startActivity(MainActivity.clearTop(requireContext()))
activity?.finish()
}
)
) {
if (viewModel.showStorageAccountRestoreProgress) {
Dialogs.IndeterminateProgressDialog()
}
}
}
private fun startRestoreMethod(method: RestoreMethod) {