diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/MediaRestoreProgressBanner.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/MediaRestoreProgressBanner.kt new file mode 100644 index 0000000000..a56b549aa8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/MediaRestoreProgressBanner.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.banner.banners + +import androidx.compose.runtime.Composable +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatus +import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData +import org.thoughtcrime.securesms.banner.Banner +import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore + +class MediaRestoreProgressBanner(private val data: MediaRestoreEvent) : Banner() { + + companion object { + private val TAG = Log.tag(MediaRestoreProgressBanner::class) + + /** + * Create a Lifecycle-aware [Flow] of [MediaRestoreProgressBanner] that observes the database for changes in attachments and emits banners when attachments are updated. + */ + @JvmStatic + fun createLifecycleAwareFlow(lifecycleOwner: LifecycleOwner): Flow { + if (SignalStore.backup.isRestoreInProgress) { + val observer = LifecycleObserver() + lifecycleOwner.lifecycle.addObserver(observer) + return observer.flow + } else { + return emptyFlow() + } + } + } + + override var enabled: Boolean = data.totalBytes > 0L && data.totalBytes != data.completedBytes + + @Composable + override fun DisplayBanner() { + BackupStatus(data = BackupStatusData.RestoringMedia(data.completedBytes, data.totalBytes)) + } + + data class MediaRestoreEvent(val completedBytes: Long, val totalBytes: Long) + + private class LifecycleObserver : DefaultLifecycleObserver { + private var attachmentObserver: DatabaseObserver.Observer? = null + private val _mutableSharedFlow = MutableSharedFlow(replay = 1) + + val flow = _mutableSharedFlow.map { MediaRestoreProgressBanner(it) } + + override fun onStart(owner: LifecycleOwner) { + val queryObserver = DatabaseObserver.Observer { + owner.lifecycleScope.launch { + _mutableSharedFlow.emit(loadData()) + } + } + + attachmentObserver = queryObserver + queryObserver.onChanged() + AppDependencies.databaseObserver.registerAttachmentObserver(queryObserver) + } + + override fun onStop(owner: LifecycleOwner) { + attachmentObserver?.let { + AppDependencies.databaseObserver.unregisterObserver(it) + } + } + + private suspend fun loadData() = withContext(Dispatchers.IO) { + // TODO [backups]: define and query data for interrupted/paused restores + val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize + val remainingAttachmentSize = SignalDatabase.attachments.getTotalRestorableAttachmentSize() + val completedBytes = totalRestoreSize - remainingAttachmentSize + MediaRestoreEvent(completedBytes, totalRestoreSize) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index bd41e9a55d..10b2a91b68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -98,6 +98,7 @@ import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBot import org.thoughtcrime.securesms.banner.Banner; import org.thoughtcrime.securesms.banner.BannerManager; import org.thoughtcrime.securesms.banner.banners.OutdatedBuildBanner; +import org.thoughtcrime.securesms.banner.banners.MediaRestoreProgressBanner; import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog; import org.thoughtcrime.securesms.components.Material3SearchToolbar; import org.thoughtcrime.securesms.components.RatingManager; @@ -882,8 +883,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode private void initializeBanners() { if (RemoteConfig.newBannerUi()) { - final List> bannerRepositories = List.of(OutdatedBuildBanner.createFlow(requireContext())); - final BannerManager bannerManager = new BannerManager(bannerRepositories); + final List> bannerRepositories = List.of(OutdatedBuildBanner.createFlow(requireContext()), + MediaRestoreProgressBanner.createLifecycleAwareFlow(getViewLifecycleOwner())); + final BannerManager bannerManager = new BannerManager(bannerRepositories); bannerManager.setContent(bannerView.get()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt index 50db514432..18f8305364 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -45,6 +45,7 @@ import org.signal.core.util.groupBy import org.signal.core.util.isNull import org.signal.core.util.logging.Log import org.signal.core.util.readToList +import org.signal.core.util.readToSingleLong import org.signal.core.util.readToSingleObject import org.signal.core.util.requireBlob import org.signal.core.util.requireBoolean @@ -454,6 +455,15 @@ class AttachmentTable( }.flatten() } + fun getTotalRestorableAttachmentSize(): Long { + return readableDatabase + .select("SUM($DATA_SIZE)") + .from(TABLE_NAME) + .where("$TRANSFER_STATE = ?", TRANSFER_NEEDS_RESTORE.toString()) + .run() + .readToSingleLong() + } + /** * Finds the next eligible attachment that needs to be uploaded to the archive service. * If it exists, it'll also atomically be marked as [ArchiveTransferState.BACKFILL_UPLOAD_IN_PROGRESS]. diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt index 8dbb398265..a63f53f7d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt @@ -48,6 +48,7 @@ class BackupRestoreMediaJob private constructor(parameters: Parameters) : BaseJo throw NotPushRegisteredException() } + SignalStore.backup.totalRestorableAttachmentSize = SignalDatabase.attachments.getTotalRestorableAttachmentSize() val jobManager = AppDependencies.jobManager val batchSize = 100 val restoreTime = System.currentTimeMillis() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt index 26cc89944b..875fecbdb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt @@ -103,7 +103,7 @@ class RestoreAttachmentJob private constructor( } } - private val attachmentId: Long + private val attachmentId: Long = attachmentId.id constructor(messageId: Long, attachmentId: AttachmentId, manual: Boolean, forceArchiveDownload: Boolean = false, restoreMode: RestoreMode = RestoreMode.ORIGINAL) : this( Parameters.Builder() @@ -119,10 +119,6 @@ class RestoreAttachmentJob private constructor( restoreMode ) - init { - this.attachmentId = attachmentId.id - } - override fun serialize(): ByteArray? { return JsonJobData.Builder() .putLong(KEY_MESSAGE_ID, messageId) @@ -156,6 +152,10 @@ class RestoreAttachmentJob private constructor( if (!SignalDatabase.messages.isStory(messageId)) { AppDependencies.messageNotifier.updateNotification(context, forConversation(0)) } + + if (SignalDatabase.attachments.getTotalRestorableAttachmentSize() == 0L) { + SignalStore.backup.totalRestorableAttachmentSize = 0L + } } @Throws(IOException::class, RetryLaterException::class) 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 8609be2412..7e4e571843 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -27,6 +27,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_NEXT_BACKUP_TIME = "backup.nextBackupTime" private const val KEY_LAST_BACKUP_TIME = "backup.lastBackupTime" private const val KEY_LAST_BACKUP_MEDIA_SYNC_TIME = "backup.lastBackupMediaSyncTime" + private const val KEY_TOTAL_RESTORABLE_ATTACHMENT_SIZE = "backup.totalRestorableAttachmentSize" private const val KEY_BACKUP_FREQUENCY = "backup.backupFrequency" private const val KEY_CDN_BACKUP_DIRECTORY = "backup.cdn.directory" @@ -61,6 +62,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { var nextBackupTime: Long by longValue(KEY_NEXT_BACKUP_TIME, -1) var lastBackupTime: Long by longValue(KEY_LAST_BACKUP_TIME, -1) var lastMediaSyncTime: Long by longValue(KEY_LAST_BACKUP_MEDIA_SYNC_TIME, -1) + var totalRestorableAttachmentSize: Long by longValue(KEY_TOTAL_RESTORABLE_ATTACHMENT_SIZE, 0) var backupFrequency: BackupFrequency by enumValue(KEY_BACKUP_FREQUENCY, BackupFrequency.MANUAL, BackupFrequency.Serializer) var backupTier: MessageBackupTier? by enumValue(KEY_BACKUP_TIER, null, MessageBackupTier.Serializer) @@ -86,6 +88,9 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { var backupsInitialized: Boolean by booleanValue(KEY_BACKUPS_INITIALIZED, false) + val isRestoreInProgress: Boolean + get() = totalRestorableAttachmentSize > 0 + /** * Retrieves the stored credentials, mapped by the day they're valid. The day is represented as * the unix time (in seconds) of the start of the day. Wrapped in a [ArchiveServiceCredentials] diff --git a/app/src/main/res/drawable/restore_reminder_icon.xml b/app/src/main/res/drawable/restore_reminder_icon.xml new file mode 100644 index 0000000000..f02c68dd7b --- /dev/null +++ b/app/src/main/res/drawable/restore_reminder_icon.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/symbol_backup_display_bold_24.xml b/app/src/main/res/drawable/symbol_backup_display_bold_24.xml new file mode 100644 index 0000000000..a45bf60790 --- /dev/null +++ b/app/src/main/res/drawable/symbol_backup_display_bold_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/symbol_backup_error_display_bold_24.xml b/app/src/main/res/drawable/symbol_backup_error_display_bold_24.xml new file mode 100644 index 0000000000..cf5e20b864 --- /dev/null +++ b/app/src/main/res/drawable/symbol_backup_error_display_bold_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2ad0b5817b..1d9474a17e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -816,6 +816,41 @@ Back up now + + Can\'t restore media + + Your device does not have enough free space. Free up storage space to restore your media.\n\nIf you choose “Skip restore” the media in your backup will be deleted the next time your device completes a new backup. + + Your device does not have enough free space. Free up %1$s of space to restore your media.\n\nIf you choose “Skip restore” the media in your backup will be deleted the next time your device completes a new backup. + + OK + + Skip restore + + Backup error icon + + + Free up storage space to restore your media. + + Free up %1$s of space to restore your media. + + + Restoring media + + %1$s of %2$s (%3$d%%) + + Restoring media paused + + Waiting for WiFi… + + Waiting for Internet connection… + + Low battery. Charge your device. + + Free up %1$s to restore your media. + + Skip restore + Chat backups Backups are encrypted with a passphrase and stored on your device.