From 0659edb762a9bf72de4821bb05dc00a96b788da4 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 1 Nov 2023 13:26:56 -0700 Subject: [PATCH] Add a new foreground service for attachment progress. --- app/src/main/AndroidManifest.xml | 4 + .../jobs/AttachmentCompressionJob.java | 11 +- .../securesms/jobs/AttachmentUploadJob.kt | 11 +- .../notifications/NotificationIds.java | 1 + .../service/AttachmentProgressService.kt | 163 ++++++++++++++++++ .../service/SafeForegroundService.kt | 111 ++++++++++++ 6 files changed, 289 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/AttachmentProgressService.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/SafeForegroundService.kt 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 +}