From eb44dd4318f5fbe1e1a6b6c65ec0a3b6ea67fb1e Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Mon, 31 Mar 2025 11:30:49 -0400 Subject: [PATCH] Provide retry UX for tier restore network failures. --- .../contactsupport/ContactSupportDialog.kt | 93 +++++++++++++++++++ .../contactsupport/ContactSupportViewModel.kt | 67 +++++++++++++ .../keyvalue/RestoreDecisionStateExt.kt | 8 ++ .../ui/RegistrationViewModel.kt | 10 +- .../ui/restore/EnterBackupKeyFragment.kt | 71 ++++++++++++-- .../ui/restore/EnterBackupKeyViewModel.kt | 20 ++-- .../ui/restore/RemoteRestoreActivity.kt | 87 +++++++++++++++-- .../ui/restore/RemoteRestoreViewModel.kt | 8 +- .../securesms/restore/RestoreViewModel.kt | 10 +- app/src/main/res/values/strings.xml | 19 ++++ .../org/signal/core/ui/compose/Dialogs.kt | 6 +- 11 files changed, 363 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/contactsupport/ContactSupportDialog.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/contactsupport/ContactSupportViewModel.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/contactsupport/ContactSupportDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/components/contactsupport/ContactSupportDialog.kt new file mode 100644 index 0000000000..30f1a60755 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/contactsupport/ContactSupportDialog.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.contactsupport + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalPreview +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.SupportEmailUtil + +interface ContactSupportCallbacks { + fun submitWithDebuglog() + fun submitWithoutDebuglog() + fun cancel() + + object Empty : ContactSupportCallbacks { + override fun submitWithDebuglog() = Unit + override fun submitWithoutDebuglog() = Unit + override fun cancel() = Unit + } +} + +/** + * Three-option contact support dialog. + */ +@Composable +fun ContactSupportDialog( + showInProgress: Boolean, + callbacks: ContactSupportCallbacks +) { + if (showInProgress) { + Dialogs.IndeterminateProgressDialog() + } else { + Dialogs.AdvancedAlertDialog( + title = stringResource(R.string.ContactSupportDialog_submit_debug_log), + body = stringResource(R.string.ContactSupportDialog_your_debug_logs), + positive = stringResource(R.string.ContactSupportDialog_submit_with_debug), + onPositive = { callbacks.submitWithDebuglog() }, + neutral = stringResource(R.string.ContactSupportDialog_submit_without_debug), + onNeutral = { callbacks.submitWithoutDebuglog() }, + negative = stringResource(android.R.string.cancel), + onNegative = { callbacks.cancel() } + ) + } +} + +/** + * Used in conjunction with [ContactSupportDialog] and [ContactSupportViewModel] to trigger + * sending an email when ready. + */ +@Composable +fun SendSupportEmailEffect( + contactSupportState: ContactSupportViewModel.ContactSupportState, + @StringRes subjectRes: Int, + @StringRes filterRes: Int, + hide: () -> Unit +) { + val context = LocalContext.current + LaunchedEffect(contactSupportState.sendEmail) { + if (contactSupportState.sendEmail) { + val subject = context.getString(subjectRes) + val prefix = if (contactSupportState.debugLogUrl != null) { + "\n${context.getString(R.string.HelpFragment__debug_log)} ${contactSupportState.debugLogUrl}\n\n" + } else { + "" + } + + val body = SupportEmailUtil.generateSupportEmailBody(context, filterRes, prefix, null) + CommunicationActions.openEmail(context, SupportEmailUtil.getSupportEmailAddress(context), subject, body) + hide() + } + } +} + +@SignalPreview +@Composable +private fun ContactSupportDialogPreview() { + Previews.Preview { + ContactSupportDialog( + false, + ContactSupportCallbacks.Empty + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/contactsupport/ContactSupportViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/contactsupport/ContactSupportViewModel.kt new file mode 100644 index 0000000000..2fcba2a180 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/contactsupport/ContactSupportViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.contactsupport + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.util.orNull +import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository + +/** + * Intended to be used to drive [ContactSupportDialog]. + */ +class ContactSupportViewModel : ViewModel(), ContactSupportCallbacks { + private val submitDebugLogRepository: SubmitDebugLogRepository = SubmitDebugLogRepository() + + private val store: MutableStateFlow = MutableStateFlow(ContactSupportState()) + + val state: StateFlow = store.asStateFlow() + + fun showContactSupport() { + store.update { it.copy(show = true) } + } + + fun hideContactSupport() { + store.update { ContactSupportState() } + } + + fun contactSupport(includeLogs: Boolean) { + viewModelScope.launch { + if (includeLogs) { + store.update { it.copy(showAsProgress = true) } + submitDebugLogRepository.buildAndSubmitLog { result -> + store.update { ContactSupportState(sendEmail = true, debugLogUrl = result.orNull()) } + } + } else { + store.update { ContactSupportState(sendEmail = true) } + } + } + } + + override fun submitWithDebuglog() { + contactSupport(true) + } + + override fun submitWithoutDebuglog() { + contactSupport(false) + } + + override fun cancel() { + hideContactSupport() + } + + data class ContactSupportState( + val show: Boolean = false, + val showAsProgress: Boolean = false, + val sendEmail: Boolean = false, + val debugLogUrl: String? = null + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RestoreDecisionStateExt.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RestoreDecisionStateExt.kt index 165f8a48f9..8b5f60aa2e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RestoreDecisionStateExt.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RestoreDecisionStateExt.kt @@ -32,6 +32,14 @@ val RestoreDecisionState.isWantingManualRemoteRestore: Boolean else -> false } +val RestoreDecisionState.includeDeviceToDeviceTransfer: Boolean + get() = when (this.decisionState) { + RestoreDecisionState.State.INTEND_TO_RESTORE -> { + this.intendToRestoreData?.hasOldDevice == true + } + else -> true + } + /** Has a final decision been made regarding restoring. */ val RestoreDecisionState.isTerminal: Boolean get() = !isDecisionPending diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt index e21286c694..20b11876e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt @@ -87,6 +87,7 @@ import kotlin.jvm.optionals.getOrNull import kotlin.math.max import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds /** * ViewModel shared across all of registration. @@ -898,7 +899,14 @@ class RegistrationViewModel : ViewModel() { if (SignalStore.account.restoredAccountEntropyPool) { Log.d(TAG, "Restoring backup tier") - BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) + var tries = 0 + while (tries < 3 && !SignalStore.backup.isBackupTierRestored) { + if (tries > 0) { + delay(1.seconds) + } + BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) + tries++ + } } refreshRemoteConfig() diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt index 1f5f0fc3f4..ccaadf9fbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt @@ -23,6 +23,9 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.signal.core.ui.compose.Dialogs import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.contactsupport.ContactSupportDialog +import org.thoughtcrime.securesms.components.contactsupport.ContactSupportViewModel +import org.thoughtcrime.securesms.components.contactsupport.SendSupportEmailEffect import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint @@ -42,6 +45,7 @@ class EnterBackupKeyFragment : ComposeFragment() { private val sharedViewModel by activityViewModels() private val viewModel by viewModels() + private val contactSupportViewModel: ContactSupportViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -75,6 +79,15 @@ class EnterBackupKeyFragment : ComposeFragment() { override fun FragmentContent() { val state by viewModel.state.collectAsStateWithLifecycle() val sharedState by sharedViewModel.state.collectAsStateWithLifecycle() + val contactSupportState: ContactSupportViewModel.ContactSupportState by contactSupportViewModel.state.collectAsStateWithLifecycle() + + SendSupportEmailEffect( + contactSupportState = contactSupportState, + subjectRes = R.string.EnterBackupKey_network_failure_support_email, + filterRes = R.string.EnterBackupKey_network_failure_support_email_filter + ) { + contactSupportViewModel.hideContactSupport() + } EnterBackupKeyScreen( backupKey = viewModel.backupKey, @@ -92,27 +105,31 @@ class EnterBackupKeyFragment : ComposeFragment() { pin = null ) }, - onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) }, onSkip = { sharedViewModel.skipRestore() findNavController().safeNavigate(EnterBackupKeyFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION)) }, dialogContent = { - if (state.showStorageAccountRestoreProgress) { - Dialogs.IndeterminateProgressDialog() + if (contactSupportState.show) { + ContactSupportDialog( + showInProgress = contactSupportState.showAsProgress, + callbacks = contactSupportViewModel + ) } else { ErrorContent( state = state, - onBackupTierRetry = { sharedViewModel.restoreBackupTier() }, - onSkipRestoreAfterRegistration = { + onBackupTierRetry = { + viewModel.incrementBackupTierRetry() + sharedViewModel.restoreBackupTier() + }, + onAbandonRemoteRestoreAfterRegistration = { viewLifecycleOwner.lifecycleScope.launch { - sharedViewModel.skipRestore() - viewModel.performStorageServiceAccountRestoreIfNeeded() sharedViewModel.resumeNormalRegistration() } }, onBackupTierNotRestoredDismiss = viewModel::hideRestoreBackupKeyFailed, + onContactSupport = { contactSupportViewModel.showContactSupport() }, onRegistrationErrorDismiss = viewModel::clearRegistrationError, onBackupKeyHelp = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) } ) @@ -126,19 +143,53 @@ class EnterBackupKeyFragment : ComposeFragment() { private fun ErrorContent( state: EnterBackupKeyViewModel.EnterBackupKeyState, onBackupTierRetry: () -> Unit = {}, - onSkipRestoreAfterRegistration: () -> Unit = {}, + onAbandonRemoteRestoreAfterRegistration: () -> Unit = {}, onBackupTierNotRestoredDismiss: () -> Unit = {}, + onContactSupport: () -> Unit = {}, onRegistrationErrorDismiss: () -> Unit = {}, onBackupKeyHelp: () -> Unit = {} ) { - if (state.showBackupTierNotRestoreError) { + if (state.showBackupTierNotRestoreError == EnterBackupKeyViewModel.TierRestoreError.NETWORK_ERROR) { + if (state.tierRetryAttempts > 1) { + Dialogs.AdvancedAlertDialog( + title = stringResource(R.string.EnterBackupKey_cant_restore_backup), + body = stringResource(R.string.EnterBackupKey_your_backup_cant_be_restored_right_now), + positive = stringResource(R.string.EnterBackupKey_try_again), + neutral = stringResource(R.string.EnterBackupKey_contact_support), + negative = stringResource(android.R.string.cancel), + onPositive = { + onBackupTierNotRestoredDismiss() + onBackupTierRetry() + }, + onNeutral = { + onBackupTierNotRestoredDismiss() + onContactSupport() + }, + onNegative = { + onBackupTierNotRestoredDismiss() + onAbandonRemoteRestoreAfterRegistration() + } + ) + } else { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.EnterBackupKey_cant_restore_backup), + body = stringResource(R.string.EnterBackupKey_your_backup_cant_be_restored_right_now), + confirm = stringResource(R.string.EnterBackupKey_try_again), + dismiss = stringResource(android.R.string.cancel), + onConfirm = onBackupTierRetry, + onDeny = onAbandonRemoteRestoreAfterRegistration, + onDismiss = onBackupTierNotRestoredDismiss, + onDismissRequest = {} + ) + } + } else if (state.showBackupTierNotRestoreError == EnterBackupKeyViewModel.TierRestoreError.NOT_FOUND) { 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, + onDeny = onAbandonRemoteRestoreAfterRegistration, onDismiss = onBackupTierNotRestoredDismiss ) } else if (state.showRegistrationError) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt index 7dac3d8cd4..6d1a72d2a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt @@ -88,7 +88,7 @@ class EnterBackupKeyViewModel : ViewModel() { fun handleBackupTierNotRestored() { store.update { it.copy( - showBackupTierNotRestoreError = true + showBackupTierNotRestoreError = if (SignalStore.backup.isBackupTierRestored) TierRestoreError.NOT_FOUND else TierRestoreError.NETWORK_ERROR ) } } @@ -96,16 +96,13 @@ class EnterBackupKeyViewModel : ViewModel() { fun hideRestoreBackupKeyFailed() { store.update { it.copy( - showBackupTierNotRestoreError = false + showBackupTierNotRestoreError = null ) } } - suspend fun performStorageServiceAccountRestoreIfNeeded() { - if (SignalStore.account.restoredAccountEntropyPool || SignalStore.svr.masterKeyForInitialDataRestore != null) { - store.update { it.copy(showBackupTierNotRestoreError = false, showStorageAccountRestoreProgress = true) } - StorageServiceRestore.restore() - } + fun incrementBackupTierRetry() { + store.update { it.copy(tierRetryAttempts = it.tierRetryAttempts + 1) } } data class EnterBackupKeyState( @@ -114,9 +111,14 @@ class EnterBackupKeyViewModel : ViewModel() { val chunkLength: Int, val isRegistering: Boolean = false, val showRegistrationError: Boolean = false, - val showBackupTierNotRestoreError: Boolean = false, + val showBackupTierNotRestoreError: TierRestoreError? = null, val registerAccountResult: RegisterAccountResult? = null, val aepValidationError: AEPValidationError? = null, - val showStorageAccountRestoreProgress: Boolean = false + val tierRetryAttempts: Int = 0 ) + + enum class TierRestoreError { + NOT_FOUND, + NETWORK_ERROR + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt index f602800ddc..5962feed50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt @@ -9,6 +9,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -54,6 +55,10 @@ import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.backup.v2.RestoreV2Event import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow +import org.thoughtcrime.securesms.components.contactsupport.ContactSupportCallbacks +import org.thoughtcrime.securesms.components.contactsupport.ContactSupportDialog +import org.thoughtcrime.securesms.components.contactsupport.ContactSupportViewModel +import org.thoughtcrime.securesms.components.contactsupport.SendSupportEmailEffect import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen @@ -81,6 +86,8 @@ class RemoteRestoreActivity : BaseActivity() { RemoteRestoreViewModel(intent.getBooleanExtra(KEY_ONLY_OPTION, false)) } + private val contactSupportViewModel: ContactSupportViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -99,12 +106,24 @@ class RemoteRestoreActivity : BaseActivity() { setContent { val state: RemoteRestoreViewModel.ScreenState by viewModel.state.collectAsStateWithLifecycle() + val contactSupportState: ContactSupportViewModel.ContactSupportState by contactSupportViewModel.state.collectAsStateWithLifecycle() + + SendSupportEmailEffect( + contactSupportState = contactSupportState, + subjectRes = R.string.EnterBackupKey_network_failure_support_email, + filterRes = R.string.EnterBackupKey_network_failure_support_email_filter + ) { + contactSupportViewModel.hideContactSupport() + } SignalTheme { Surface { RestoreFromBackupContent( state = state, + contactSupportState = contactSupportState, onRestoreBackupClick = { viewModel.restore() }, + onRetryRestoreTier = { viewModel.reload() }, + onContactSupport = { contactSupportViewModel.showContactSupport() }, onCancelClick = { lifecycleScope.launch { if (state.isRemoteRestoreOnlyOption) { @@ -116,10 +135,11 @@ class RemoteRestoreActivity : BaseActivity() { finish() } }, - onErrorDialogDismiss = { viewModel.clearError() }, + onImportErrorDialogDismiss = { viewModel.clearError() }, onUpdateSignal = { PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(this) - } + }, + contactSupportCallbacks = contactSupportViewModel ) } } @@ -137,10 +157,14 @@ class RemoteRestoreActivity : BaseActivity() { @Composable private fun RestoreFromBackupContent( state: RemoteRestoreViewModel.ScreenState, + contactSupportState: ContactSupportViewModel.ContactSupportState = ContactSupportViewModel.ContactSupportState(), onRestoreBackupClick: () -> Unit = {}, + onRetryRestoreTier: () -> Unit = {}, + onContactSupport: () -> Unit = {}, onCancelClick: () -> Unit = {}, - onErrorDialogDismiss: () -> Unit = {}, - onUpdateSignal: () -> Unit = {} + onImportErrorDialogDismiss: () -> Unit = {}, + onUpdateSignal: () -> Unit = {}, + contactSupportCallbacks: ContactSupportCallbacks = ContactSupportCallbacks.Empty ) { when (state.loadState) { RemoteRestoreViewModel.ScreenState.LoadState.LOADING -> { @@ -154,7 +178,7 @@ private fun RestoreFromBackupContent( state = state, onRestoreBackupClick = onRestoreBackupClick, onCancelClick = onCancelClick, - onErrorDialogDismiss = onErrorDialogDismiss, + onImportErrorDialogDismiss = onImportErrorDialogDismiss, onUpdateSignal = onUpdateSignal ) } @@ -164,7 +188,19 @@ private fun RestoreFromBackupContent( } RemoteRestoreViewModel.ScreenState.LoadState.FAILURE -> { - RestoreFailedDialog(onDismiss = onCancelClick) + if (contactSupportState.show) { + ContactSupportDialog( + showInProgress = contactSupportState.showAsProgress, + callbacks = contactSupportCallbacks + ) + } else { + TierRestoreFailedDialog( + loadAttempts = state.loadAttempts, + onRetryRestore = onRetryRestoreTier, + onContactSupport = onContactSupport, + onCancel = onCancelClick + ) + } } RemoteRestoreViewModel.ScreenState.LoadState.STORAGE_SERVICE_RESTORE -> { @@ -178,7 +214,7 @@ private fun BackupAvailableContent( state: RemoteRestoreViewModel.ScreenState, onRestoreBackupClick: () -> Unit, onCancelClick: () -> Unit, - onErrorDialogDismiss: () -> Unit, + onImportErrorDialogDismiss: () -> Unit, onUpdateSignal: () -> Unit ) { val subtitle = if (state.backupSize.bytes > 0) { @@ -247,9 +283,9 @@ private fun BackupAvailableContent( is RemoteRestoreViewModel.ImportState.Restored -> Unit RemoteRestoreViewModel.ImportState.Failed -> { if (SignalStore.backup.hasInvalidBackupVersion) { - InvalidBackupVersionDialog(onUpdateSignal = onUpdateSignal, onDismiss = onErrorDialogDismiss) + InvalidBackupVersionDialog(onUpdateSignal = onUpdateSignal, onDismiss = onImportErrorDialogDismiss) } else { - RestoreFailedDialog(onDismiss = onErrorDialogDismiss) + RestoreFailedDialog(onDismiss = onImportErrorDialogDismiss) } } } @@ -370,7 +406,7 @@ private fun RestoreProgressDialog(restoreProgress: RestoreV2Event?) { val progressBytes = restoreProgress.count.toUnitString() val totalBytes = restoreProgress.estimatedTotalCount.toUnitString() Text( - text = stringResource(id = R.string.RemoteRestoreActivity__s_of_s_s, progressBytes, totalBytes, "%.2f%%".format(restoreProgress.getProgress())), + text = stringResource(id = R.string.RemoteRestoreActivity__s_of_s_s, progressBytes, totalBytes, "%.2f%%".format(restoreProgress.getProgress() * 100)), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 12.dp) ) @@ -422,6 +458,37 @@ fun RestoreFailedDialog( ) } +@Composable +fun TierRestoreFailedDialog( + loadAttempts: Int = 0, + onRetryRestore: () -> Unit = {}, + onContactSupport: () -> Unit = {}, + onCancel: () -> Unit = {} +) { + if (loadAttempts > 2) { + Dialogs.AdvancedAlertDialog( + title = stringResource(R.string.EnterBackupKey_cant_restore_backup), + body = stringResource(R.string.EnterBackupKey_your_backup_cant_be_restored_right_now), + positive = stringResource(R.string.EnterBackupKey_try_again), + neutral = stringResource(R.string.EnterBackupKey_contact_support), + negative = stringResource(android.R.string.cancel), + onPositive = onRetryRestore, + onNeutral = onContactSupport, + onNegative = onCancel + ) + } else { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.EnterBackupKey_cant_restore_backup), + body = stringResource(R.string.EnterBackupKey_your_backup_cant_be_restored_right_now), + confirm = stringResource(R.string.EnterBackupKey_try_again), + dismiss = stringResource(android.R.string.cancel), + onConfirm = onRetryRestore, + onDeny = onCancel, + onDismissRequest = {} + ) + } +} + @SignalPreview @Composable private fun RestoreFailedDialogPreview() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt index 8af49dea6c..0e54a9471c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt @@ -51,7 +51,12 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() { val state: StateFlow = store.asStateFlow() init { + reload() + } + + fun reload() { viewModelScope.launch(Dispatchers.IO) { + store.update { it.copy(loadState = ScreenState.LoadState.LOADING, loadAttempts = it.loadAttempts + 1) } val tier: MessageBackupTier? = BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) store.update { if (tier != null && SignalStore.backup.lastBackupTime > 0) { @@ -161,7 +166,8 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() { val backupSize: ByteSize = 0.bytes, val importState: ImportState = ImportState.None, val restoreProgress: RestoreV2Event? = null, - val loadState: LoadState = if (backupTier != null) LoadState.LOADED else LoadState.LOADING + val loadState: LoadState = if (backupTier != null) LoadState.LOADED else LoadState.LOADING, + val loadAttempts: Int = 0 ) { fun isLoaded(): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt index b363b1ff87..0d944a4818 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt @@ -20,6 +20,7 @@ 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.includeDeviceToDeviceTransfer import org.thoughtcrime.securesms.keyvalue.skippedRestoreChoice import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository import org.thoughtcrime.securesms.registrationv3.ui.restore.RestoreMethod @@ -74,8 +75,13 @@ class RestoreViewModel : ViewModel() { } fun getAvailableRestoreMethods(): List { - if (SignalStore.registration.isOtherDeviceAndroid || SignalStore.registration.restoreDecisionState.skippedRestoreChoice) { - val methods = mutableListOf(RestoreMethod.FROM_OLD_DEVICE, RestoreMethod.FROM_LOCAL_BACKUP_V1) + if (SignalStore.registration.isOtherDeviceAndroid || SignalStore.registration.restoreDecisionState.skippedRestoreChoice || !SignalStore.backup.isBackupTierRestored) { + val methods = mutableListOf(RestoreMethod.FROM_LOCAL_BACKUP_V1) + + if (SignalStore.registration.restoreDecisionState.includeDeviceToDeviceTransfer) { + methods.add(0, RestoreMethod.FROM_OLD_DEVICE) + } + when (SignalStore.backup.backupTier) { MessageBackupTier.FREE -> methods.add(1, RestoreMethod.FROM_SIGNAL_BACKUPS) MessageBackupTier.PAID -> methods.add(0, RestoreMethod.FROM_SIGNAL_BACKUPS) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 41ced23210..831ed62fce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8292,6 +8292,25 @@ The backup key you entered is correct, but there is no backup associated with it. If you still have your old phone, make sure backups are enabled and that a backup has been completed and try again. Skip restore + + Can\'t restore backup + + Your backup can\'t be restored right now. Check your internet connection and try again. + + Contact support + + + Signal Android Backup restore network error + Signal Android Backup restore network error + + + Submit debug log? + + Your debug logs will help us troubleshoot your issue faster. Submitting your logs is optional. + + Submit with debug log + + Submit without debug log Scan this code with your old phone diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt index f8cd572e15..3ed19a1db2 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt @@ -388,9 +388,9 @@ private fun AdvancedAlertDialogPreview() { AdvancedAlertDialog( title = "Title text", body = "Body message text.", - positive = "Continue", - neutral = "Learn more", - negative = "Not now", + positive = "Positive", + neutral = "Neutral", + negative = "Negative", onPositive = {}, onNegative = {}, onNeutral = {}