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