Display progress for RestoreAttachmentJobs as a Banner.

This commit is contained in:
Nicholas Tinsley 2024-08-08 14:50:36 -04:00 committed by mtang-signal
parent 4af6e0480a
commit 7807d92825
10 changed files with 196 additions and 7 deletions

View file

@ -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<MediaRestoreProgressBanner> {
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<MediaRestoreEvent>(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)
}
}
}

View file

@ -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,7 +883,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void initializeBanners() {
if (RemoteConfig.newBannerUi()) {
final List<Flow<? extends Banner>> bannerRepositories = List.of(OutdatedBuildBanner.createFlow(requireContext()));
final List<Flow<? extends Banner>> bannerRepositories = List.of(OutdatedBuildBanner.createFlow(requireContext()),
MediaRestoreProgressBanner.createLifecycleAwareFlow(getViewLifecycleOwner()));
final BannerManager bannerManager = new BannerManager(bannerRepositories);
bannerManager.setContent(bannerView.get());
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2024 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<item>
<shape android:shape="oval">
<solid android:color="@color/signal_colorPrimaryContainer" />
</shape>
</item>
<item android:tint="@color/signal_colorPrimary">
<vector android:drawable="@drawable/symbol_backup_display_bold_24" />
</item>
</layer-list>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="#FF000000"
android:pathData="M4.25 20C4.25 11.3 11.3 4.25 20 4.25S35.75 11.3 35.75 20 28.7 35.75 20 35.75c-1.11 0-2.2-0.12-3.24-0.33-0.68-0.15-1.34 0.29-1.48 0.96-0.14 0.68 0.29 1.34 0.96 1.48 1.22 0.26 2.47 0.39 3.76 0.39 10.08 0 18.25-8.17 18.25-18.25S30.08 1.75 20 1.75 1.75 9.92 1.75 20c0 3.1 0.78 6.03 2.15 8.6 0.85 1.59 2 2.95 3.33 4.15H5.25C4.56 32.75 4 33.31 4 34s0.56 1.25 1.25 1.25h5c0.33 0 0.65-0.13 0.88-0.37 0.24-0.23 0.37-0.55 0.37-0.88v-5c0-0.69-0.56-1.25-1.25-1.25S9 28.31 9 29v2.53c-1.13-1.2-2.1-2.6-2.9-4.11C4.92 25.2 4.25 22.68 4.25 20Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M18.37 8.1C18.4 7.48 18.9 7 19.5 7c0.61 0 1.11 0.48 1.13 1.1l0.33 10.96 7.46 0.31c0.6 0.03 1.08 0.52 1.08 1.13 0 0.6-0.48 1.1-1.08 1.13L19.6 22h-0.1c-0.83 0-1.5-0.67-1.5-1.5v-0.07l0.37-12.34Z"/>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="#FF000000"
android:pathData="M20 4.25C11.3 4.25 4.25 11.3 4.25 20c0 2.68 0.67 5.2 1.85 7.42 0.8 1.5 1.77 2.91 2.9 4.11V29c0-0.69 0.56-1.25 1.25-1.25S11.5 28.31 11.5 29v5c0 0.33-0.13 0.65-0.37 0.88-0.23 0.24-0.55 0.37-0.88 0.37h-5C4.56 35.25 4 34.69 4 34s0.56-1.25 1.25-1.25h1.98c-1.32-1.2-2.48-2.56-3.33-4.15-1.37-2.57-2.15-5.5-2.15-8.6C1.75 9.92 9.92 1.75 20 1.75S38.25 9.92 38.25 20 30.08 38.25 20 38.25c-1.29 0-2.54-0.13-3.76-0.39-0.67-0.14-1.1-0.8-0.96-1.48 0.14-0.67 0.8-1.1 1.48-0.96 1.04 0.21 2.13 0.33 3.24 0.33 8.7 0 15.75-7.05 15.75-15.75S28.7 4.25 20 4.25Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M20 10c-0.98 0-1.76 0.81-1.72 1.8l0.46 10.74c0.03 0.68 0.58 1.21 1.26 1.21s1.23-0.53 1.26-1.2l0.46-10.76C21.76 10.81 20.98 10 20 10Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M20 25.5c-1.1 0-2 0.9-2 2s0.9 2 2 2 2-0.9 2-2-0.9-2-2-2Z"/>
</vector>

View file

@ -816,6 +816,41 @@
<!-- Action button to dismiss sheet and back up now -->
<string name="CreateBackupBottomSheet__back_up_now">Back up now</string>
<!-- Headline text for a bottom sheet dialog shown when the restoration of the media backup fails. -->
<string name="RestoreMediaFailedBottomSheet__Cant_restore_media">Can\'t restore media</string>
<!-- Help text for a bottom sheet dialog shown when the restoration of the media backup fails, displayed while the app is calculating the amount of disk space required. -->
<string name="RestoreMediaFailedBottomSheet__Your_device_does_not_have_enough_free_space_placeholder">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.</string>
<!-- Help text for a bottom sheet dialog shown when the restoration of the media backup fails, displayed once the app knows how much disk space is required. The placeholder is a filesize, such as "1.23 GB." -->
<string name="RestoreMediaFailedBottomSheet__Your_device_does_not_have_enough_free_space">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.</string>
<!-- Confirmation button to dismiss bottom sheet dialog without aborting the restoration process. -->
<string name="RestoreMediaFailedBottomSheet__Okay">OK</string>
<!-- Negative button on a bottom sheet dialog shown when the restoration of the media backup fails. This aborts the restoration process. -->
<string name="RestoreMediaFailedBottomSheet__Skip_restore">Skip restore</string>
<!-- Accessibility content description for the "Backup Error" icon -->
<string name="RestoreMediaFailedBottomSheet__Backup_error_icon_content_description">Backup error icon</string>
<!-- Banner text that is displayed at the top of the app when trying to restore more media than the device's memory has space for. -->
<string name="RestoreMediaErrorBanner__Free_up_storage_space">Free up storage space to restore your media.</string>
<!-- Banner text that is displayed at the top of the app when trying to restore more media than the device's memory has space for. The placeholder is a filesize, such as "1.23 GB." -->
<string name="RestoreMediaErrorBanner__Free_up_of_space">Free up %1$s of space to restore your media.</string>
<!-- Remote media restoration in-process status title. -->
<string name="RestoreMediaReminder__Restoring_media">Restoring media</string>
<!-- Remote media restoration in-process description. The first to placeholders are filesizes, such as "865 MB" and "2.3 GB". The final placeholder is a whole number representing the percentage progress. -->
<string name="RestoreMediaReminder__Progress_filesize">%1$s of %2$s (%3$d%%)</string>
<!-- Remote media restoration paused status title. -->
<string name="RestoreMediaReminder__Restoring_media_paused">Restoring media paused</string>
<!-- Remote media restoration paused status description for when the restore is paused because unmetered connectivity, such as WiFi, is unavailable. -->
<string name="RestoreMediaReminder__Waiting_for_Wifi">Waiting for WiFi…</string>
<!-- Remote media restoration paused status description for when the restore is paused because internet is unavailable. -->
<string name="RestoreMediaReminder__Waiting_for_internet_connection">Waiting for Internet connection…</string>
<!-- Remote media restoration paused status description for when the device's battery is low -->
<string name="RestoreMediaReminder__low_battery">Low battery. Charge your device.</string>
<!-- Remote media restoration call to action when the device is out of space. The placeholder string is a file size, such as "1.23 GB". -->
<string name="RestoreMediaReminder__Free_up_space">Free up %1$s to restore your media.</string>
<!-- Button label to abort media restoration -->
<string name="RestoreMediaReminder__Skip_restore">Skip restore</string>
<!-- BackupsPreferenceFragment -->
<string name="BackupsPreferenceFragment__chat_backups">Chat backups</string>
<string name="BackupsPreferenceFragment__backups_are_encrypted_with_a_passphrase">Backups are encrypted with a passphrase and stored on your device.</string>