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