From 47fb0deca4c7b349b962d6918c62a059013413b2 Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Thu, 21 Aug 2025 15:59:58 -0400 Subject: [PATCH] Add foreground service when restoring backup media. --- app/src/main/AndroidManifest.xml | 5 + .../thoughtcrime/securesms/MainActivity.kt | 8 + .../securesms/backup/v2/BackupRepository.kt | 3 + .../securesms/jobs/BackupRestoreMediaJob.kt | 3 + .../jobs/CheckRestoreMediaLeftJob.kt | 2 + .../securesms/jobs/RestoreAttachmentJob.kt | 14 +- .../service/BackupMediaRestoreService.kt | 199 ++++++++++++++++++ .../service/BackupProgressService.kt | 3 + 8 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/BackupMediaRestoreService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d53097848..5446ca6934 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1220,6 +1220,11 @@ android:foregroundServiceType="dataSync" android:exported="false"/> + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index eb429b4e07..144b3e4ec1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -70,6 +70,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.getSerializableCompat import org.signal.core.util.logging.Log import org.signal.donations.StripeApi +import org.thoughtcrime.securesms.backup.RestoreState import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show import org.thoughtcrime.securesms.calls.log.CallLogFilter @@ -127,6 +128,7 @@ import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment +import org.thoughtcrime.securesms.service.BackupMediaRestoreService import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment @@ -269,6 +271,12 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner } } + if (SignalStore.backup.restoreState == RestoreState.RESTORING_MEDIA) { + Log.i(TAG, "Still restoring media, launching a service.") + BackupMediaRestoreService.resetTimeout() + BackupMediaRestoreService.start(this, resources.getString(R.string.BackupStatus__restoring_media)) + } + onBackPressedDispatcher.addCallback(this, callback) shareDataTimestampViewModel.setTimestampFromActivityCreation(savedInstanceState, intent) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 361aaaab7b..081032a94d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -121,6 +121,7 @@ import org.thoughtcrime.securesms.notifications.NotificationIds import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.service.BackupMediaRestoreService import org.thoughtcrime.securesms.service.BackupProgressService import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.RemoteConfig @@ -2127,6 +2128,7 @@ object BackupRepository { SignalStore.backup.restoreState = RestoreState.RESTORING_MEDIA + BackupMediaRestoreService.resetTimeout() AppDependencies.jobManager.add(BackupRestoreMediaJob()) Log.i(TAG, "[remoteRestore] Restore successful") @@ -2203,6 +2205,7 @@ object BackupRepository { SignalStore.backup.restoreState = RestoreState.RESTORING_MEDIA + BackupMediaRestoreService.resetTimeout() AppDependencies.jobManager.add(BackupRestoreMediaJob()) Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful") 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 f25fdbf6ad..409a4bd8b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.jobs import org.signal.core.util.logging.Log import org.signal.core.util.withinTransaction +import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress import org.thoughtcrime.securesms.database.AttachmentTable @@ -17,6 +18,7 @@ import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.net.NotPushRegisteredException +import org.thoughtcrime.securesms.service.BackupMediaRestoreService import kotlin.time.Duration.Companion.days /** @@ -117,6 +119,7 @@ class BackupRestoreMediaJob private constructor(parameters: Parameters) : BaseJo restoreFullAttachmentJobs.forEach { jobManager.add(it) } } while (restoreThumbnailJobs.isNotEmpty() || restoreFullAttachmentJobs.isNotEmpty() || notRestorable.isNotEmpty()) + BackupMediaRestoreService.start(context, context.getString(R.string.BackupStatus__restoring_media)) SignalStore.backup.totalRestorableAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize() RestoreAttachmentJob.Queues.INITIAL_RESTORE.forEach { queue -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CheckRestoreMediaLeftJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/CheckRestoreMediaLeftJob.kt index 80e7aec593..6ec5ff4cea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/CheckRestoreMediaLeftJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CheckRestoreMediaLeftJob.kt @@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.service.BackupMediaRestoreService import kotlin.time.Duration.Companion.seconds /** @@ -49,6 +50,7 @@ class CheckRestoreMediaLeftJob private constructor(parameters: Parameters) : Job SignalStore.backup.totalRestorableAttachmentSize = 0 SignalStore.backup.restoreState = RestoreState.NONE ArchiveRestoreProgress.onProcessEnd() + BackupMediaRestoreService.stop(context) if (SignalStore.backup.deletionState == DeletionState.AWAITING_MEDIA_DOWNLOAD) { SignalStore.backup.deletionState = DeletionState.MEDIA_DOWNLOAD_FINISHED 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 192e472e8d..14bd7cbab2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt @@ -40,6 +40,7 @@ import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.notifications.NotificationChannels import org.thoughtcrime.securesms.notifications.NotificationIds +import org.thoughtcrime.securesms.service.BackupMediaRestoreService import org.thoughtcrime.securesms.transport.RetryLaterException import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.SignalLocalMetrics @@ -225,7 +226,18 @@ class RestoreAttachmentJob private constructor( } SignalLocalMetrics.ArchiveAttachmentRestore.start(attachmentId) - retrieveAttachment(messageId, attachmentId, attachment) + + val progressServiceController = BackupMediaRestoreService.start(context, context.getString(R.string.BackupStatus__restoring_media)) + + if (progressServiceController != null) { + progressServiceController.use { + retrieveAttachment(messageId, attachmentId, attachment) + } + } else { + Log.w(TAG, "Continuing without service.") + retrieveAttachment(messageId, attachmentId, attachment) + } + SignalLocalMetrics.ArchiveAttachmentRestore.end(attachmentId) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/BackupMediaRestoreService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/BackupMediaRestoreService.kt new file mode 100644 index 0000000000..8344d6cc26 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/BackupMediaRestoreService.kt @@ -0,0 +1,199 @@ +package org.thoughtcrime.securesms.service + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import org.signal.core.util.ByteSize +import org.signal.core.util.PendingIntentFlags +import org.signal.core.util.bytes +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.RestoreState +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.notifications.NotificationIds +import org.thoughtcrime.securesms.service.BackupMediaRestoreService.Companion.hasTimedOut +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.locks.ReentrantLock +import javax.annotation.CheckReturnValue +import kotlin.concurrent.withLock + +/** + * Foreground service used to track progress when restoring backup media. + * + * Like [AttachmentProgressService], this is allowed to be started by multiple people and + * supports that by using a set of controllers instead of just one. + * + * Unlike other services, this can be started without opening the app which means that + * it can crash if we hit the 6 hour data sync limit. To handle this, we keep track of [hasTimedOut] + * that gets set in [onTimeout] and is reset every time we open the app / when the user initiates a backup restore. + * + * For the initial restoration of a backup (eg [RestoreState.PENDING] / [RestoreState.RESTORING_DB]), see [BackupProgressService] instead + */ +class BackupMediaRestoreService : SafeForegroundService() { + + companion object { + private val TAG = Log.tag(BackupMediaRestoreService::class) + + private var title: String = "" + private var downloadedBytes = 0.bytes + private var totalBytes = 0.bytes + + private var hasTimedOut: Boolean = false + private val listeners: MutableSet<() -> Unit> = CopyOnWriteArraySet() + private val controllers: LinkedHashSet = linkedSetOf() + private val controllerLock = ReentrantLock() + + @JvmStatic + @CheckReturnValue + fun start(context: Context, startingTitle: String): Controller? { + controllerLock.withLock { + val started = if (hasTimedOut) { + Log.i(TAG, "[start] Service has hit the 6 hour time limit. Cannot start.") + false + } else if (controllers.isEmpty()) { + Log.i(TAG, "[start] First controller. Starting.") + SafeForegroundService.start(context, BackupMediaRestoreService::class.java) + } else { + Log.i(TAG, "[start] No need to start the service again. Already have an active controller.") + true + } + + return if (started) { + val controller = Controller(context, startingTitle) + controllers += controller + onControllersChanged(context) + controller + } else { + null + } + } + } + + @JvmStatic + fun stop(context: Context, fromTimeout: Boolean = false) { + controllers.clear() + stop(context, BackupMediaRestoreService::class.java, fromTimeout) + } + + fun resetTimeout() { + Log.i(TAG, "Resetting the 6 hour timeout limit.") + hasTimedOut = false + } + + private fun onControllersChanged(context: Context) { + controllerLock.withLock { + if (controllers.isNotEmpty()) { + val originalTitle = title + val originalDownloadedBytes = downloadedBytes + val originalTotalBytes = totalBytes + + title = controllers.first().title + downloadedBytes = controllers.first().downloadedBytes + totalBytes = controllers.first().totalBytes + + if (originalTitle != title || originalDownloadedBytes != downloadedBytes || originalTotalBytes != totalBytes) { + listeners.forEach { it() } + } + } else { + Log.i(TAG, "[onControllersChanged] No controllers remaining. Stopping.") + stop(context) + } + } + } + } + + private fun getForegroundNotification(context: Context): Notification { + val progress = (downloadedBytes.inWholeBytes.toFloat() / totalBytes.inWholeBytes).coerceIn(0f, 1f) + val notificationBuilder = NotificationCompat.Builder(context, NotificationChannels.getInstance().OTHER) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setProgress(100, (progress * 100).toInt(), false) + .setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.clearTop(context), PendingIntentFlags.mutable())) + .setVibrate(longArrayOf(0)) + + if (downloadedBytes >= 0.bytes) { + notificationBuilder.setContentText(context.resources.getString(R.string.BackupStatus__status_size_of_size, downloadedBytes.toUnitString(), totalBytes.toUnitString())) + } + + return notificationBuilder.build() + } + + val listener = { + try { + startForeground(notificationId, getForegroundNotification(Intent())) + } catch (e: Exception) { + if (Build.VERSION.SDK_INT >= 31 && e.message?.contains("Time limit", ignoreCase = true) == true) { + Log.w(TAG, "Foreground service timed out, but not in onTimeout call", e) + stopDueToTimeout() + } else { + throw e + } + } + } + + override val tag: String = TAG + override val notificationId: Int = NotificationIds.BACKUP_PROGRESS + + override fun getForegroundNotification(intent: Intent): Notification { + return getForegroundNotification(this) + } + + override fun onCreate() { + super.onCreate() + listeners += listener + } + + override fun onDestroy() { + super.onDestroy() + listeners -= listener + } + + override fun onTimeout(startId: Int, fgsType: Int) { + Log.w(TAG, "BackupProgressService has timed out. startId: $startId, foregroundServiceType: $fgsType") + stopDueToTimeout() + } + + private fun stopDueToTimeout() { + controllerLock.withLock { + hasTimedOut = true + controllers.forEach { it.closeFromTimeout() } + stop(context = this, fromTimeout = true) + } + + listeners -= listener + } + + /** + * Use to update notification progress/state. + */ + class Controller(private val context: Context, startingTitle: String) : AutoCloseable { + + val title: String = startingTitle + val downloadedBytes: ByteSize + get() { + val remainingAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize() + return totalBytes - remainingAttachmentSize.bytes + } + val totalBytes: ByteSize + get() = SignalStore.backup.totalRestorableAttachmentSize.bytes + + fun closeFromTimeout() { + controllerLock.withLock { + controllers.remove(this) + } + } + + override fun close() { + controllerLock.withLock { + controllers.remove(this) + onControllersChanged(context) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/BackupProgressService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/BackupProgressService.kt index 1a5ace9b27..4e50a3d62b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/BackupProgressService.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/BackupProgressService.kt @@ -24,6 +24,9 @@ import kotlin.concurrent.withLock /** * Foreground service to provide "long" run support to backup jobs. + * + * For the restoration of backup media, see [BackupMediaRestoreService] instead + * */ class BackupProgressService : SafeForegroundService() {