From fe5de65273241f90184851789f1daabb83fa3d5b Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Thu, 9 Jan 2025 17:25:43 -0500 Subject: [PATCH] Add ability to cancel a link+sync. --- .../linkdevice/EditDeviceNameFragment.kt | 1 + .../linkdevice/LinkDeviceFragment.kt | 41 +++++++++++---- .../linkdevice/LinkDeviceRepository.kt | 31 ++++++++++- .../linkdevice/LinkDeviceSettingsState.kt | 6 ++- .../linkdevice/LinkDeviceViewModel.kt | 40 ++++++++++++-- app/src/main/res/values/strings.xml | 4 ++ .../main/java/org/signal/core/ui/Dialogs.kt | 52 +++++++++++++++++++ 7 files changed, 159 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/EditDeviceNameFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/EditDeviceNameFragment.kt index 7bae8b4053..d56977ac8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/EditDeviceNameFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/EditDeviceNameFragment.kt @@ -75,6 +75,7 @@ class EditDeviceNameFragment : ComposeFragment() { is LinkDeviceSettingsState.OneTimeEvent.ToastLinked -> Unit LinkDeviceSettingsState.OneTimeEvent.ToastNetworkFailed -> Unit is LinkDeviceSettingsState.OneTimeEvent.ToastUnlinked -> Unit + LinkDeviceSettingsState.OneTimeEvent.SnackbarLinkCancelled -> Unit } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt index b9e41f3164..c1da10c389 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt @@ -57,6 +57,7 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.fragment.findNavController +import com.google.android.material.snackbar.Snackbar import org.signal.core.ui.Buttons import org.signal.core.ui.Dialogs import org.signal.core.ui.Dividers @@ -132,7 +133,7 @@ class LinkDeviceFragment : ComposeFragment() { Log.i(TAG, "Releasing wake lock for linked device") linkDeviceWakeLock.release() } - DialogState.SyncingMessages, DialogState.Linking -> { + is DialogState.SyncingMessages, DialogState.Linking -> { Log.i(TAG, "Acquiring wake lock for linked device") linkDeviceWakeLock.acquire() } @@ -151,6 +152,9 @@ class LinkDeviceFragment : ComposeFragment() { is LinkDeviceSettingsState.OneTimeEvent.ToastUnlinked -> { Toast.makeText(context, context.getString(R.string.LinkDeviceFragment__s_unlinked, event.name), Toast.LENGTH_LONG).show() } + LinkDeviceSettingsState.OneTimeEvent.SnackbarLinkCancelled -> { + Snackbar.make(requireView(), context.getString(R.string.LinkDeviceFragment__linking_cancelled), Snackbar.LENGTH_LONG).show() + } LinkDeviceSettingsState.OneTimeEvent.ToastNetworkFailed -> { Toast.makeText(context, context.getString(R.string.DeviceListActivity_network_failed), Toast.LENGTH_LONG).show() } @@ -198,6 +202,7 @@ class LinkDeviceFragment : ComposeFragment() { onDeviceRemovalConfirmed = { device -> viewModel.removeDevice(device) }, onSyncFailureRetryRequested = { viewModel.onSyncErrorRetryRequested() }, onSyncFailureIgnored = { viewModel.onSyncErrorIgnored() }, + onSyncCancelled = { viewModel.onSyncCancelled() }, onEditDevice = { device -> viewModel.setDeviceToEdit(device) navController.safeNavigate(R.id.action_linkDeviceFragment_to_editDeviceNameFragment) @@ -251,6 +256,7 @@ fun DeviceListScreen( onDeviceRemovalConfirmed: (Device) -> Unit = {}, onSyncFailureRetryRequested: () -> Unit = {}, onSyncFailureIgnored: () -> Unit = {}, + onSyncCancelled: () -> Unit = {}, onEditDevice: (Device) -> Unit = {} ) { // If a bottom sheet is showing, we don't want the spinner underneath @@ -265,8 +271,13 @@ fun DeviceListScreen( DialogState.Unlinking -> { Dialogs.IndeterminateProgressDialog(stringResource(id = R.string.DeviceListActivity_unlinking_device)) } - DialogState.SyncingMessages -> { - Dialogs.IndeterminateProgressDialog(stringResource(id = R.string.LinkDeviceFragment__syncing_messages)) + is DialogState.SyncingMessages -> { + Dialogs.IndeterminateProgressDialog( + message = stringResource(id = R.string.LinkDeviceFragment__syncing_messages), + caption = stringResource(id = R.string.LinkDeviceFragment__do_not_close), + dismiss = stringResource(id = android.R.string.cancel), + onDismiss = onSyncCancelled + ) } is DialogState.SyncingFailed, DialogState.SyncingTimedOut -> { @@ -507,7 +518,9 @@ private fun DeviceListScreenPreview() { devices = listOf( Device(1, "Sam's Macbook Pro", 1715793982000, 1716053182000), Device(1, "Sam's iPad", 1715793182000, 1716053122000) - ) + ), + seenQrEducationSheet = true, + seenBioAuthEducationSheet = true ) ) } @@ -519,7 +532,9 @@ private fun DeviceListScreenLoadingPreview() { Previews.Preview { DeviceListScreen( state = LinkDeviceSettingsState( - deviceListLoading = true + deviceListLoading = true, + seenQrEducationSheet = true, + seenBioAuthEducationSheet = true ) ) } @@ -531,7 +546,9 @@ private fun DeviceListScreenLinkingPreview() { Previews.Preview { DeviceListScreen( state = LinkDeviceSettingsState( - dialogState = DialogState.Linking + dialogState = DialogState.Linking, + seenQrEducationSheet = true, + seenBioAuthEducationSheet = true ) ) } @@ -543,7 +560,9 @@ private fun DeviceListScreenUnlinkingPreview() { Previews.Preview { DeviceListScreen( state = LinkDeviceSettingsState( - dialogState = DialogState.Unlinking + dialogState = DialogState.Unlinking, + seenQrEducationSheet = true, + seenBioAuthEducationSheet = true ) ) } @@ -555,7 +574,9 @@ private fun DeviceListScreenSyncingMessagesPreview() { Previews.Preview { DeviceListScreen( state = LinkDeviceSettingsState( - dialogState = DialogState.SyncingMessages + dialogState = DialogState.SyncingMessages(1, 1), + seenQrEducationSheet = true, + seenBioAuthEducationSheet = true ) ) } @@ -567,7 +588,9 @@ private fun DeviceListScreenSyncingFailedPreview() { Previews.Preview { DeviceListScreen( state = LinkDeviceSettingsState( - dialogState = DialogState.SyncingTimedOut + dialogState = DialogState.SyncingTimedOut, + seenQrEducationSheet = true, + seenBioAuthEducationSheet = true ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt index 3428af33e9..fe68def73e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt @@ -241,7 +241,7 @@ object LinkDeviceRepository { /** * Performs the entire process of creating and uploading an archive for a newly-linked device. */ - fun createAndUploadArchive(ephemeralMessageBackupKey: MessageBackupKey, deviceId: Int, deviceCreatedAt: Long): LinkUploadArchiveResult { + fun createAndUploadArchive(ephemeralMessageBackupKey: MessageBackupKey, deviceId: Int, deviceCreatedAt: Long, cancellationSignal: () -> Boolean): LinkUploadArchiveResult { Log.d(TAG, "[createAndUploadArchive] Beginning process.") val stopwatch = Stopwatch("link-archive") val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application) @@ -249,7 +249,13 @@ object LinkDeviceRepository { try { Log.d(TAG, "[createAndUploadArchive] Starting the export.") - BackupRepository.export(outputStream = outputStream, append = { tempBackupFile.appendBytes(it) }, messageBackupKey = ephemeralMessageBackupKey, mediaBackupEnabled = false) + BackupRepository.export( + outputStream = outputStream, + append = { tempBackupFile.appendBytes(it) }, + messageBackupKey = ephemeralMessageBackupKey, + mediaBackupEnabled = false, + cancellationSignal = cancellationSignal + ) } catch (e: Exception) { Log.w(TAG, "[createAndUploadArchive] Failed to export a backup!", e) return LinkUploadArchiveResult.BackupCreationFailure(e) @@ -257,6 +263,11 @@ object LinkDeviceRepository { Log.d(TAG, "[createAndUploadArchive] Successfully created backup.") stopwatch.split("create-backup") + if (cancellationSignal()) { + Log.i(TAG, "[createAndUploadArchive] Backup was cancelled.") + return LinkUploadArchiveResult.BackupCreationCancelled + } + when (val result = ArchiveValidator.validate(tempBackupFile, ephemeralMessageBackupKey)) { ArchiveValidator.ValidationResult.Success -> { Log.d(TAG, "[createAndUploadArchive] Successfully passed validation.") @@ -272,6 +283,11 @@ object LinkDeviceRepository { } stopwatch.split("validate-backup") + if (cancellationSignal()) { + Log.i(TAG, "[createAndUploadArchive] Backup was cancelled.") + return LinkUploadArchiveResult.BackupCreationCancelled + } + Log.d(TAG, "[createAndUploadArchive] Fetching an upload form...") val uploadForm = when (val result = NetworkResult.withRetry { SignalNetwork.attachments.getAttachmentV4UploadForm() }) { is NetworkResult.Success -> result.result.logD(TAG, "[createAndUploadArchive] Successfully retrieved upload form.") @@ -280,6 +296,11 @@ object LinkDeviceRepository { is NetworkResult.StatusCodeError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "[createAndUploadArchive] Status code error when fetching form.", result.exception) } + if (cancellationSignal()) { + Log.i(TAG, "[createAndUploadArchive] Backup was cancelled.") + return LinkUploadArchiveResult.BackupCreationCancelled + } + when (val result = uploadArchive(tempBackupFile, uploadForm)) { is NetworkResult.Success -> Log.i(TAG, "[createAndUploadArchive] Successfully uploaded backup.") is NetworkResult.NetworkError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "[createAndUploadArchive] Network error when uploading archive.", result.exception) @@ -288,6 +309,11 @@ object LinkDeviceRepository { } stopwatch.split("upload-backup") + if (cancellationSignal()) { + Log.i(TAG, "[createAndUploadArchive] Backup was cancelled.") + return LinkUploadArchiveResult.BackupCreationCancelled + } + Log.d(TAG, "[createAndUploadArchive] Setting the transfer archive...") val transferSetResult = NetworkResult.withRetry { SignalNetwork.linkDevice.setTransferArchive( @@ -399,6 +425,7 @@ object LinkDeviceRepository { sealed interface LinkUploadArchiveResult { data object Success : LinkUploadArchiveResult + data object BackupCreationCancelled : LinkUploadArchiveResult data class BackupCreationFailure(val exception: Exception) : LinkUploadArchiveResult data class BadRequest(val exception: IOException) : LinkUploadArchiveResult data class NetworkError(val exception: IOException) : LinkUploadArchiveResult diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt index ecbf53a91c..53b17ad968 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt @@ -21,13 +21,14 @@ data class LinkDeviceSettingsState( val seenBioAuthEducationSheet: Boolean = false, val needsBioAuthEducationSheet: Boolean = !seenBioAuthEducationSheet && !SignalStore.uiHints.hasSeenLinkDeviceAuthSheet() && !SignalStore.account.hasLinkedDevices, val bottomSheetVisible: Boolean = false, - val deviceToEdit: Device? = null + val deviceToEdit: Device? = null, + val shouldCancelArchiveUpload: Boolean = false ) { sealed interface DialogState { data object None : DialogState data object Linking : DialogState data object Unlinking : DialogState - data object SyncingMessages : DialogState + data class SyncingMessages(val deviceId: Int, val deviceCreatedAt: Long) : DialogState data object SyncingTimedOut : DialogState data class SyncingFailed(val deviceId: Int, val deviceCreatedAt: Long) : DialogState } @@ -37,6 +38,7 @@ data class LinkDeviceSettingsState( data object ToastNetworkFailed : OneTimeEvent data class ToastUnlinked(val name: String) : OneTimeEvent data class ToastLinked(val name: String) : OneTimeEvent + data object SnackbarLinkCancelled : OneTimeEvent data object SnackbarNameChangeSuccess : OneTimeEvent data object SnackbarNameChangeFailure : OneTimeEvent data object ShowFinishedSheet : OneTimeEvent diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt index 50658bd80e..36b6bae685 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt @@ -150,7 +150,8 @@ class LinkDeviceViewModel : ViewModel() { it.copy( qrCodeState = QrCodeState.NONE, linkUri = null, - dialogState = DialogState.Linking + dialogState = DialogState.Linking, + shouldCancelArchiveUpload = false ) } @@ -247,12 +248,17 @@ class LinkDeviceViewModel : ViewModel() { _state.update { it.copy( linkDeviceResult = result, - dialogState = DialogState.SyncingMessages + dialogState = DialogState.SyncingMessages(waitResult.id, waitResult.created) ) } Log.d(TAG, "[addDeviceWithSync] Beginning the archive generation process...") - val uploadResult = LinkDeviceRepository.createAndUploadArchive(ephemeralMessageBackupKey, waitResult.id, waitResult.created) + val uploadResult = LinkDeviceRepository.createAndUploadArchive( + ephemeralMessageBackupKey = ephemeralMessageBackupKey, + deviceId = waitResult.id, + deviceCreatedAt = waitResult.created, + cancellationSignal = { _state.value.shouldCancelArchiveUpload } + ) Log.d(TAG, "[addDeviceWithSync] Archive finished with result: $uploadResult") when (uploadResult) { @@ -276,6 +282,14 @@ class LinkDeviceViewModel : ViewModel() { ) } } + LinkDeviceRepository.LinkUploadArchiveResult.BackupCreationCancelled -> { + Log.i(TAG, "[addDeviceWithoutSync] Cancelling archive upload") + _state.update { + it.copy( + dialogState = DialogState.None + ) + } + } } } @@ -363,6 +377,26 @@ class LinkDeviceViewModel : ViewModel() { } } + fun onSyncCancelled() = viewModelScope.launch(Dispatchers.IO) { + Log.i(TAG, "Cancelling sync and removing linked device") + val dialogState = _state.value.dialogState + if (dialogState is DialogState.SyncingMessages) { + val success = LinkDeviceRepository.removeDevice(dialogState.deviceId) + if (success) { + Log.i(TAG, "Removing device after cancelling sync") + _state.update { + it.copy( + oneTimeEvent = OneTimeEvent.SnackbarLinkCancelled, + dialogState = DialogState.None, + shouldCancelArchiveUpload = true + ) + } + } else { + Log.w(TAG, "Unable to remove device after cancelling sync") + } + } + } + fun setDeviceToEdit(device: Device) { _state.update { it.copy( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d09c35acd0..d108d8b4fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1016,6 +1016,10 @@ Continue without transferring Edit name + + Linking cancelled + + Do not close app diff --git a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt index 3bda3aee8b..d261fc660a 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt @@ -172,6 +172,52 @@ object Dialogs { ) } + /** + * Customizable progress spinner that can be dismissed while showing [message] + * and [caption] below the spinner to let users know an action is completing + */ + @Composable + fun IndeterminateProgressDialog(message: String, caption: String = "", dismiss: String, onDismiss: () -> Unit) { + androidx.compose.material3.AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + dismissButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + content = { Text(text = dismiss) } + ) + }, + text = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().fillMaxHeight() + ) { + Spacer(modifier = Modifier.size(32.dp)) + CircularProgressIndicator() + Spacer(modifier = Modifier.size(12.dp)) + Text( + text = message, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (caption.isNotEmpty()) { + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = caption, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + modifier = Modifier.size(200.dp, 250.dp) + ) + } + @OptIn(ExperimentalLayoutApi::class) @Composable fun PermissionRationaleDialog( @@ -287,3 +333,9 @@ private fun IndeterminateProgressDialogPreview() { private fun IndeterminateProgressDialogMessagePreview() { Dialogs.IndeterminateProgressDialog("Completing...") } + +@Preview +@Composable +private fun IndeterminateProgressDialogCancellablePreview() { + Dialogs.IndeterminateProgressDialog("Completing...", "Do not close app", "Cancel") {} +}