Add ability to cancel a link+sync.

This commit is contained in:
Michelle Tang 2025-01-09 17:25:43 -05:00 committed by Greyson Parrelli
parent d473ff6e86
commit fe5de65273
7 changed files with 159 additions and 16 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1016,6 +1016,10 @@
<string name="LinkDeviceFragment__sync_failure_dismiss_button">Continue without transferring</string>
<!-- Option in context menu to edit the name of a linked device -->
<string name="LinkDeviceFragment__edit_name">Edit name</string>
<!-- Toast shown when the process of linking a device has been cancelled -->
<string name="LinkDeviceFragment__linking_cancelled">Linking cancelled</string>
<!-- Message shown in progress dialog telling users to avoid closing the app while messages are being synced -->
<string name="LinkDeviceFragment__do_not_close">Do not close app</string>
<!-- EditDeviceNameFragment -->
<!-- App bar title when editing the name of a device -->

View file

@ -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") {}
}