Add ability to cancel a link+sync.
This commit is contained in:
parent
d473ff6e86
commit
fe5de65273
7 changed files with 159 additions and 16 deletions
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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") {}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue