diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index 7389256aa5..5b390f3645 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -96,6 +96,8 @@ import org.signal.core.util.logging.Log import org.signal.donations.StripeApi import org.signal.mediasend.MediaSendActivityContract import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress +import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState +import org.thoughtcrime.securesms.backup.v2.ui.CouldNotCompleteBackupRestoreSheet import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show import org.thoughtcrime.securesms.calls.log.CallLogFilter @@ -342,6 +344,19 @@ class MainActivity : } } } + + launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + ArchiveRestoreProgress + .stateFlow + .filter { it.restoreStatus == ArchiveRestoreProgressState.RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE } + .collect { + ArchiveRestoreProgress.clearLocalRestoreDirectoryError() + CouldNotCompleteBackupRestoreSheet().show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + Log.i(TAG, "Local restore directory became unavailable.") + } + } + } } supportFragmentManager.setFragmentResultListener( diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveRestoreProgress.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveRestoreProgress.kt index 8d9e9ece1c..5000dc057c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveRestoreProgress.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveRestoreProgress.kt @@ -157,6 +157,11 @@ object ArchiveRestoreProgress { update() } + fun clearLocalRestoreDirectoryError() { + SignalStore.backup.localRestoreDirectoryError = false + update() + } + fun clearFinishedStatus() { store.update { state -> if (state.restoreStatus == ArchiveRestoreProgressState.RestoreStatus.FINISHED) { @@ -193,7 +198,11 @@ object ArchiveRestoreProgress { !NetworkConstraint.isMet(AppDependencies.application) -> ArchiveRestoreProgressState.RestoreStatus.WAITING_FOR_INTERNET !BatteryNotLowConstraint.isMet() -> ArchiveRestoreProgressState.RestoreStatus.LOW_BATTERY !DiskSpaceNotLowConstraint.isMet() -> ArchiveRestoreProgressState.RestoreStatus.NOT_ENOUGH_DISK_SPACE - restoreState == RestoreState.NONE -> if (state.hasActivelyRestoredThisRun) ArchiveRestoreProgressState.RestoreStatus.FINISHED else ArchiveRestoreProgressState.RestoreStatus.NONE + restoreState == RestoreState.NONE -> when { + SignalStore.backup.localRestoreDirectoryError -> ArchiveRestoreProgressState.RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE + state.hasActivelyRestoredThisRun -> ArchiveRestoreProgressState.RestoreStatus.FINISHED + else -> ArchiveRestoreProgressState.RestoreStatus.NONE + } else -> { val availableBytes = SignalStore.backup.spaceAvailableOnDiskBytes diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveRestoreProgressState.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveRestoreProgressState.kt index dcddae8f01..077c9e2626 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveRestoreProgressState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveRestoreProgressState.kt @@ -69,6 +69,7 @@ data class ArchiveRestoreProgressState( WAITING_FOR_INTERNET, WAITING_FOR_WIFI, NOT_ENOUGH_DISK_SPACE, - FINISHED + FINISHED, + LOCAL_RESTORE_DIRECTORY_UNAVAILABLE } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/CouldNotCompleteBackupRestoreSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/CouldNotCompleteBackupRestoreSheet.kt new file mode 100644 index 0000000000..367c7e5480 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/CouldNotCompleteBackupRestoreSheet.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.CommunicationActions + +/** + * Sheet displayed when the user's backup restoration failed during media import. Generally due + * to the files no longer being available. + */ +class CouldNotCompleteBackupRestoreSheet : ComposeBottomSheetDialogFragment() { + @Composable + override fun SheetContent() { + CouldNotCompleteBackupRestoreSheetContent( + onOkClick = { dismiss() }, + onLearnMoreClick = { + dismiss() + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.backup_support_url)) + } + ) + } +} + +@Composable +private fun CouldNotCompleteBackupRestoreSheetContent( + onOkClick: () -> Unit = {}, + onLearnMoreClick: () -> Unit = {} +) { + val ok = stringResource(android.R.string.ok) + val primaryActionButtonState: BackupAlertActionButtonState = remember(ok, onOkClick) { + BackupAlertActionButtonState( + label = ok, + callback = onOkClick + ) + } + + val learnMore = stringResource(R.string.preferences__app_icon_learn_more) + val secondaryActionButtonState: BackupAlertActionButtonState = remember(learnMore, onLearnMoreClick) { + BackupAlertActionButtonState( + label = learnMore, + callback = onLearnMoreClick + ) + } + + BackupAlertBottomSheetContainer( + icon = { + BackupAlertIcon(iconColors = BackupsIconColors.Error) + }, + title = stringResource(R.string.CouldNotCompleteBackupRestoreSheet__title), + primaryActionButtonState = primaryActionButtonState, + secondaryActionButtonState = secondaryActionButtonState + ) { + Text( + text = stringResource(R.string.CouldNotCompleteBackupRestoreSheet__body_error) + ) + + Text( + text = stringResource(R.string.CouldNotCompleteBackupRestoreSheet__body_retry) + ) + } +} + +@DayNightPreviews +@Composable +private fun CouldNotCompleteBackupRestoreSheetContentPreview() { + Previews.BottomSheetContentPreview { + CouldNotCompleteBackupRestoreSheetContent() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveRestoreStatusBannerView.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveRestoreStatusBannerView.kt index 6618351f4f..ccf3dc950d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveRestoreStatusBannerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveRestoreStatusBannerView.kt @@ -164,10 +164,10 @@ private fun ArchiveRestoreProgressState.iconResource(): Int { RestoreStatus.WAITING_FOR_INTERNET, RestoreStatus.WAITING_FOR_WIFI, RestoreStatus.LOW_BATTERY -> R.drawable.symbol_backup_light - RestoreStatus.NOT_ENOUGH_DISK_SPACE -> R.drawable.symbol_backup_error_24 RestoreStatus.FINISHED -> CoreUiR.drawable.symbol_check_circle_24 - RestoreStatus.NONE -> throw IllegalStateException() + RestoreStatus.NONE, + RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException() } } @@ -199,7 +199,8 @@ private fun ArchiveRestoreProgressState.iconColor(): Color { RestoreStatus.NOT_ENOUGH_DISK_SPACE -> BackupsIconColors.Warning.foreground RestoreStatus.FINISHED -> BackupsIconColors.Success.foreground - RestoreStatus.NONE -> throw IllegalStateException() + RestoreStatus.NONE, + RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException() } } @@ -233,7 +234,8 @@ private fun ArchiveRestoreProgressState.title(): String { } RestoreStatus.FINISHED -> stringResource(R.string.BackupStatus__restore_complete) - RestoreStatus.NONE -> throw IllegalStateException() + RestoreStatus.NONE, + RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException() } } @@ -277,7 +279,8 @@ private fun ArchiveRestoreProgressState.status(): String? { RestoreStatus.LOW_BATTERY -> stringResource(R.string.BackupStatus__status_device_has_low_battery) RestoreStatus.NOT_ENOUGH_DISK_SPACE -> null RestoreStatus.FINISHED -> this.totalToRestoreThisRun.toUnitString() - RestoreStatus.NONE -> throw IllegalStateException() + RestoreStatus.NONE, + RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusRow.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusRow.kt index a34f896db3..0e0360049a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusRow.kt @@ -217,7 +217,8 @@ private fun progressColor(backupStatusData: ArchiveRestoreProgressState): Color RestoreStatus.LOW_BATTERY, RestoreStatus.NOT_ENOUGH_DISK_SPACE -> BackupsIconColors.Warning.foreground RestoreStatus.FINISHED -> BackupsIconColors.Success.foreground - RestoreStatus.NONE -> BackupsIconColors.Normal.foreground + RestoreStatus.NONE, + RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> BackupsIconColors.Normal.foreground } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt index a60caaf09e..de92185f8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt @@ -73,6 +73,7 @@ import org.signal.core.util.Util import org.signal.core.util.getLength import org.thoughtcrime.securesms.MainActivity import org.thoughtcrime.securesms.backup.isIdle +import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet @@ -270,6 +271,10 @@ class InternalBackupPlaygroundFragment : ComposeFragment() { .setNegativeButton("Cancel", null) .show() }, + onTriggerLocalRestoreDirectoryError = { + SignalStore.backup.localRestoreDirectoryError = true + ArchiveRestoreProgress.forceUpdate() + }, onDisplayInitialBackupFailureSheet = { BackupRepository.displayInitialBackupFailureNotification() BackupAlertBottomSheet @@ -366,6 +371,7 @@ fun Screen( onImportEncryptedBackupFromDiskConfirmed: (aci: String, backupKey: String) -> Unit = { _, _ -> }, onClearLocalMediaBackupState: () -> Unit = {}, onDeleteRemoteBackup: () -> Unit = {}, + onTriggerLocalRestoreDirectoryError: () -> Unit = {}, onDisplayInitialBackupFailureSheet: () -> Unit = {} ) { val context = LocalContext.current @@ -584,6 +590,12 @@ fun Screen( onClick = onClearLocalMediaBackupState ) + Rows.TextRow( + text = "Trigger local restore directory error", + label = "Simulates the restore directory becoming inaccessible during a local backup restore.", + onClick = onTriggerLocalRestoreDirectoryError + ) + Dividers.Default() Rows.TextRow( diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupRestoreMediaJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupRestoreMediaJob.kt index 848473bea9..c5a38397a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupRestoreMediaJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupRestoreMediaJob.kt @@ -6,9 +6,11 @@ package org.thoughtcrime.securesms.jobs import android.net.Uri import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobs.protos.LocalBackupRestoreMediaJobData +import org.thoughtcrime.securesms.keyvalue.SignalStore import java.io.File /** @@ -46,6 +48,7 @@ class LocalBackupRestoreMediaJob private constructor( val archiveFileSystem = when (backupDirectoryUri.scheme) { "content" -> ArchiveFileSystem.openForRestore(context, backupDirectoryUri) ?: run { Log.w(TAG, "Unable to open backup directory: $backupDirectoryUri") + SignalStore.backup.localRestoreDirectoryError = true return Result.failure() } else -> ArchiveFileSystem.fromFile(context, File(backupDirectoryUri.path!!)) @@ -56,7 +59,11 @@ class LocalBackupRestoreMediaJob private constructor( return Result.success() } - override fun onFailure() = Unit + override fun onFailure() { + ArchiveRestoreProgress.allMediaRestored() + // forceUpdate in case restoreState was already NONE and allMediaRestored() skipped its update() + ArchiveRestoreProgress.forceUpdate() + } class Factory : Job.Factory { override fun create(parameters: Parameters, serializedData: ByteArray?): LocalBackupRestoreMediaJob { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreLocalAttachmentJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreLocalAttachmentJob.kt index ea4e945108..15655cf55d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreLocalAttachmentJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreLocalAttachmentJob.kt @@ -143,7 +143,9 @@ class RestoreLocalAttachmentJob private constructor( return Result.success() } - val streamSupplier = StreamSupplier { ArchiveFileSystem.openInputStream(context, restoreUri) ?: throw IOException("Unable to open stream") } + val streamSupplier = StreamSupplier { + ArchiveFileSystem.openInputStream(context, restoreUri) ?: throw IOException("Unable to open stream for $restoreUri") + } try { val iv = ByteArray(16) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index 35167fc2f3..5b7eae26da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -105,6 +105,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_NEW_LOCAL_BACKUPS_LAST_BACKUP_TIME = "backup.new_local_backups_last_backup_time" private const val KEY_NEW_LOCAL_BACKUPS_SELECTED_SNAPSHOT_TIMESTAMP = "backup.new_local_backups_selected_snapshot_timestamp" private const val KEY_LOCAL_RESTORE_ACCOUNT_ENTROPY_POOL = "backup.local_restore_account_entropy_pool" + private const val KEY_LOCAL_RESTORE_DIRECTORY_ERROR = "backup.local_restore_directory_error" private const val KEY_UPLOAD_BANNER_VISIBLE = "backup.upload_banner_visible" @@ -498,6 +499,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { * the account AEP because the local backup may belong to a different account (e.g., after ACI change). */ var localRestoreAccountEntropyPool: String? by stringValue(KEY_LOCAL_RESTORE_ACCOUNT_ENTROPY_POOL, null as String?) + var localRestoreDirectoryError: Boolean by booleanValue(KEY_LOCAL_RESTORE_DIRECTORY_ERROR, false) /** * When we are told by the server that we are out of storage space, we should show diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7a9f5d53d..62919c2fcb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9806,5 +9806,14 @@ Groups with same members (Labs) + + Can\'t restore backup + + + An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring. + + + To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\". +