Provide retry UX for tier restore network failures.

This commit is contained in:
Cody Henthorne
2025-03-31 11:30:49 -04:00
committed by Greyson Parrelli
parent 9b527f7c6c
commit eb44dd4318
11 changed files with 363 additions and 36 deletions

View File

@@ -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()

View File

@@ -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<RegistrationViewModel>()
private val viewModel by viewModels<EnterBackupKeyViewModel>()
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) {

View File

@@ -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
}
}

View File

@@ -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() {

View File

@@ -51,7 +51,12 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
val state: StateFlow<ScreenState> = 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 {