diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ee2cb903ee..4ab2e46c24 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1189,6 +1189,10 @@
android:name=".service.GenericForegroundService"
android:exported="false"/>
+
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java
index a8587e9503..f5cf9858de 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java
@@ -31,7 +31,7 @@ import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.mms.MediaStream;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.SentMediaQuality;
-import org.thoughtcrime.securesms.service.NotificationController;
+import org.thoughtcrime.securesms.service.AttachmentProgressService;
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.thoughtcrime.securesms.util.FeatureFlags;
@@ -205,9 +205,8 @@ public final class AttachmentCompressionJob extends BaseJob {
return attachment;
}
- try (NotificationController notification = ForegroundServiceUtil.startGenericTaskWhenCapable(context, context.getString(R.string.AttachmentUploadJob_compressing_video_start))) {
-
- notification.setIndeterminateProgress();
+ try (AttachmentProgressService.Controller notification = AttachmentProgressService.start(context, context.getString(R.string.AttachmentUploadJob_compressing_video_start))) {
+ notification.setIndeterminate(true);
try (MediaDataSource dataSource = attachmentDatabase.mediaDataSourceFor(attachment.getAttachmentId(), false)) {
if (dataSource == null) {
@@ -233,7 +232,7 @@ public final class AttachmentCompressionJob extends BaseJob {
try {
try (OutputStream outputStream = ModernEncryptingPartOutputStream.createFor(attachmentSecret, file, true).second) {
transcoder.transcode(percent -> {
- notification.setProgress(100, percent);
+ notification.setProgress(percent / 100f);
eventBus.postSticky(new PartProgressEvent(attachment,
PartProgressEvent.Type.COMPRESSION,
100,
@@ -262,7 +261,7 @@ public final class AttachmentCompressionJob extends BaseJob {
Log.i(TAG, "Compressing with android in-memory muxer");
try (MediaStream mediaStream = transcoder.transcode(percent -> {
- notification.setProgress(100, percent);
+ notification.setProgress(percent/100f);
eventBus.postSticky(new PartProgressEvent(attachment,
PartProgressEvent.Type.COMPRESSION,
100,
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt
index 6b83845dfd..ddb4291203 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt
@@ -22,12 +22,11 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.events.PartProgressEvent
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
-import org.thoughtcrime.securesms.jobs.ForegroundServiceUtil.startGenericTaskWhenCapable
import org.thoughtcrime.securesms.jobs.protos.AttachmentUploadJobData
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.net.NotPushRegisteredException
import org.thoughtcrime.securesms.recipients.Recipient
-import org.thoughtcrime.securesms.service.NotificationController
+import org.thoughtcrime.securesms.service.AttachmentProgressService
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
@@ -144,10 +143,10 @@ class AttachmentUploadJob private constructor(
}
}
- private fun getAttachmentNotificationIfNeeded(attachment: Attachment): NotificationController? {
+ private fun getAttachmentNotificationIfNeeded(attachment: Attachment): AttachmentProgressService.Controller? {
return if (attachment.size >= FOREGROUND_LIMIT) {
try {
- startGenericTaskWhenCapable(context, context.getString(R.string.AttachmentUploadJob_uploading_media))
+ AttachmentProgressService.start(context, context.getString(R.string.AttachmentUploadJob_uploading_media))
} catch (e: UnableToStartException) {
Log.w(TAG, "Unable to start foreground service", e)
null
@@ -168,7 +167,7 @@ class AttachmentUploadJob private constructor(
}
@Throws(InvalidAttachmentException::class)
- private fun buildAttachmentStream(attachment: Attachment, notification: NotificationController?, resumableUploadSpec: ResumableUpload): SignalServiceAttachmentStream {
+ private fun buildAttachmentStream(attachment: Attachment, notification: AttachmentProgressService.Controller?, resumableUploadSpec: ResumableUpload): SignalServiceAttachmentStream {
if (attachment.uri == null || attachment.size == 0L) {
throw InvalidAttachmentException(IOException("Outgoing attachment has no data!"))
}
@@ -192,7 +191,7 @@ class AttachmentUploadJob private constructor(
.withListener(object : SignalServiceAttachment.ProgressListener {
override fun onAttachmentProgress(total: Long, progress: Long) {
EventBus.getDefault().postSticky(PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress))
- notification?.setProgress(total, progress)
+ notification?.progress = (progress.toFloat() / total)
}
override fun shouldCancel(): Boolean {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java
index 7173aeb46a..723747e3c3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java
@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.notifications.v2.ConversationId;
public final class NotificationIds {
public static final int FCM_FAILURE = 12;
+ public static final int ATTACHMENT_PROGRESS = 50;
public static final int APK_UPDATE_PROMPT_INSTALL = 666;
public static final int APK_UPDATE_FAILED_INSTALL = 667;
public static final int APK_UPDATE_SUCCESSFUL_INSTALL = 668;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/AttachmentProgressService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/AttachmentProgressService.kt
new file mode 100644
index 0000000000..af8ca46040
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/AttachmentProgressService.kt
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.service
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import org.signal.core.util.PendingIntentFlags
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.MainActivity
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.jobs.UnableToStartException
+import org.thoughtcrime.securesms.notifications.NotificationChannels
+import org.thoughtcrime.securesms.notifications.NotificationIds
+import java.util.concurrent.CopyOnWriteArraySet
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
+import kotlin.jvm.Throws
+
+/**
+ * A service to show attachment progress. In order to ensure we only show one status notification,
+ * this handles both compression progress and upload progress.
+ *
+ * This class has a bunch of stuff to allow multiple people to "start" this service, but write to a
+ * single notification. That way if something is compressing while something else is uploading,
+ * or if there's two things uploading, we just have the one notification.
+ *
+ * To do this, it maintains a set of controllers. The service is told when those controllers change,
+ * and it will only render the oldest controller in the set.
+ *
+ * We also use the number of controllers to determine if we actually need to start/stop the actual service.
+ */
+class AttachmentProgressService : SafeForegroundService() {
+ companion object {
+ private val TAG = Log.tag(AttachmentProgressService::class.java)
+
+ private var title: String = ""
+ private var progress: Float = 0f
+ set(value) {
+ field = value.coerceIn(0f, 1f)
+ }
+ private var indeterminate: Boolean = false
+
+ private val listeners: MutableSet<() -> Unit> = CopyOnWriteArraySet()
+ private val controllers: LinkedHashSet = linkedSetOf()
+ private val controllerLock = ReentrantLock()
+
+ /**
+ * Start the service with the provided [title]. You will receive a controllers that you can
+ * use to update the notification.
+ *
+ * Important: This could fail to start! We do our best to start the service regardless of context,
+ * but it will fail on some devices, throwing an [UnableToStartException] if it does so.
+ */
+ @JvmStatic
+ @Throws(UnableToStartException::class)
+ fun start(context: Context, title: String): Controller {
+ controllerLock.withLock {
+ if (controllers.isEmpty()) {
+ Log.i(TAG, "[start] First controller. Starting.")
+ SafeForegroundService.start(context, AttachmentProgressService::class.java)
+ } else {
+ Log.i(TAG, "[start] No need to start the service again. Already have an active controller.")
+ }
+
+ val controller = Controller(context, title)
+ controllers += controller
+ onControllersChanged(context)
+ return controller
+ }
+ }
+
+ private fun stop(context: Context) {
+ SafeForegroundService.stop(context, AttachmentProgressService::class.java)
+ }
+
+ private fun onControllersChanged(context: Context) {
+ controllerLock.withLock {
+ if (controllers.isNotEmpty()) {
+ val originalTitle = title
+ val originalProgress = progress
+ val originalIndeterminate = indeterminate
+
+ title = controllers.first().title
+ progress = controllers.first().progress
+ indeterminate = controllers.first().indeterminate
+
+ if (originalTitle != title || originalProgress != progress || originalIndeterminate != indeterminate) {
+ listeners.forEach { it() }
+ }
+ } else {
+ Log.i(TAG, "[onControllersChanged] No controllers remaining. Stopping.")
+ stop(context)
+ }
+ }
+ }
+ }
+
+ val listener = {
+ startForeground(notificationId, getForegroundNotification(Intent()))
+ }
+
+ override val tag: String = TAG
+
+ override val notificationId: Int = NotificationIds.ATTACHMENT_PROGRESS
+
+ override fun getForegroundNotification(intent: Intent): Notification {
+ return NotificationCompat.Builder(this, NotificationChannels.getInstance().OTHER)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(title)
+ .setProgress(100, (progress * 100).toInt(), indeterminate)
+ .setContentIntent(PendingIntent.getActivity(this, 0, MainActivity.clearTop(this), PendingIntentFlags.mutable()))
+ .setVibrate(longArrayOf(0))
+ .build()
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ listeners += listener
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ listeners -= listener
+ }
+
+ class Controller(private val context: Context, title: String) : AutoCloseable {
+ var title: String = title
+ set(value) {
+ field = value
+ onControllersChanged(context)
+ }
+
+ var progress: Float = 0f
+ set(value) {
+ field = value
+ indeterminate = false
+ onControllersChanged(context)
+ }
+
+ var indeterminate: Boolean = false
+ private set
+
+ /** Has to have separate setter to avoid infinite loops when [progress] and [indeterminate] interact. */
+ fun setIndeterminate(value: Boolean) {
+ indeterminate = value
+ progress = 0f
+ onControllersChanged(context)
+ }
+
+ override fun close() {
+ controllerLock.withLock {
+ controllers.remove(this)
+ onControllersChanged(context)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/SafeForegroundService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/SafeForegroundService.kt
new file mode 100644
index 0000000000..eb7d2b2391
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/SafeForegroundService.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.service
+
+import android.app.Notification
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+import androidx.core.app.ServiceCompat
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.jobs.ForegroundServiceUtil
+import org.thoughtcrime.securesms.jobs.UnableToStartException
+import kotlin.jvm.Throws
+
+/**
+ * A simple parent class meant to encourage the safe usage of foreground services.
+ * Specifically, it ensures that both starting and "stopping" are done through
+ * service starts and that we _always_ post a foreground notification.
+ */
+abstract class SafeForegroundService : Service() {
+
+ companion object {
+ private val TAG = Log.tag(SafeForegroundService::class.java)
+
+ private const val ACTION_START = "start"
+ private const val ACTION_STOP = "stop"
+
+ /**
+ * Safety starts the target foreground service.
+ * Important: This operation can fail. If it does, [UnableToStartException] is thrown.
+ */
+ @Throws(UnableToStartException::class)
+ fun start(context: Context, serviceClass: Class) {
+ val intent = Intent(context, serviceClass).apply {
+ action = ACTION_START
+ }
+
+ ForegroundServiceUtil.startWhenCapable(context, intent)
+ }
+
+ /**
+ * Safely stops the service by starting it with an action to stop itself.
+ * This is done to prevent scenarios where you stop the service while
+ * a start is pending, preventing the posting of a foreground notification.
+ */
+ fun stop(context: Context, serviceClass: Class) {
+ val intent = Intent(context, serviceClass).apply {
+ action = ACTION_STOP
+ }
+
+ try {
+ ForegroundServiceUtil.startWhenCapable(context, intent)
+ } catch (e: UnableToStartException) {
+ Log.w(TAG, "Failed to start service class $serviceClass", e)
+ }
+ }
+ }
+
+ override fun onCreate() {
+ Log.d(tag, "[onCreate]")
+ super.onCreate()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ checkNotNull(intent) { "Must have an intent!" }
+
+ Log.d(tag, "[onStartCommand] action: ${intent.action}")
+
+ startForeground(notificationId, getForegroundNotification(intent))
+
+ when (val action = intent.action) {
+ ACTION_START -> {
+ onServiceStarted(intent)
+ }
+ ACTION_STOP -> {
+ onServiceStopped(intent)
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ else -> Log.w(tag, "Unknown action: $action")
+ }
+
+ return START_NOT_STICKY
+ }
+
+ override fun onDestroy() {
+ Log.d(tag, "[onDestroy]")
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ /** Log tag for improved logging */
+ abstract val tag: String
+
+ /** Notification ID to use when posting the foreground notification */
+ abstract val notificationId: Int
+
+ /** Notification to post as our foreground notification. */
+ abstract fun getForegroundNotification(intent: Intent): Notification
+
+ /** Event listener for when the service is started via an intent. */
+ open fun onServiceStarted(intent: Intent) = Unit
+
+ /** Event listener for when the service is stopped via an intent. */
+ open fun onServiceStopped(intent: Intent) = Unit
+}