Keep the push foreground service alive while notifications are fetched (#7639)

* Keep the push foreground service alive while notifications are fetched

* Release the push foreground service as soon as results are handed over

---------

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
This commit is contained in:
Johannes Marbach
2026-09-04 10:56:41 +02:00
committed by GitHub
parent 67f287af39
commit 8a32f5a001
5 changed files with 119 additions and 15 deletions
@@ -8,7 +8,8 @@
package io.element.android.libraries.push.api.push package io.element.android.libraries.push.api.push
/** /**
* A helper to manage the foreground service used to keep the device awake while we schedule and wait for the work to fetch the notification content to run. * A helper to manage the foreground service used to keep the device awake, and the process out of background execution limits, from the moment
* a push is received until the fetched notification content has been handed over for display.
*/ */
interface FetchPushForegroundServiceManager { interface FetchPushForegroundServiceManager {
/** /**
@@ -9,6 +9,7 @@
package io.element.android.libraries.push.impl.workmanager package io.element.android.libraries.push.impl.workmanager
import android.content.Context import android.content.Context
import android.os.Build
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
@@ -39,8 +40,11 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction import io.element.android.services.analytics.api.finishLongRunningTransaction
import io.element.android.services.analytics.api.recordTransaction import io.element.android.services.analytics.api.recordTransaction
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
@@ -59,16 +63,33 @@ class FetchPendingNotificationsWorker(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val systemClock: SystemClock, private val systemClock: SystemClock,
private val fetchPushForegroundServiceManager: FetchPushForegroundServiceManager, private val fetchPushForegroundServiceManager: FetchPushForegroundServiceManager,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) : CoroutineWorker(context, params) { ) : CoroutineWorker(context, params) {
private var foregroundServiceReleased = false
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
Timber.d("FetchNotificationsWorker started") Timber.d("FetchNotificationsWorker started")
return try {
doWorkInternal()
} finally {
// Make sure the foreground service is released on every exit path, including when the system cancels the worker.
withContext(NonCancellable) {
releaseForegroundService()
}
}
}
private suspend fun doWorkInternal(): Result {
// RunCatching for test in debug mode // RunCatching for test in debug mode
val sessionId = runCatchingExceptions { val sessionId = runCatchingExceptions {
inputData.getString(SyncPendingNotificationsRequestBuilder.SESSION_ID)?.let(::SessionId) inputData.getString(SyncPendingNotificationsRequestBuilder.SESSION_ID)?.let(::SessionId)
}.getOrNull() ?: return Result.failure() }.getOrNull() ?: return Result.failure()
// We can stop the foreground service and unlock the wakelock, since the work is now running and the device should be kept awake if (!buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.S)) {
fetchPushForegroundServiceManager.stop() // Before Android 12 the foreground service notification is displayed immediately, so release the service as soon as the work is
// running: the wakelock was only there to make sure the scheduled work starts. See [releaseForegroundService] for newer versions.
releaseForegroundService()
}
// Fetch pending requests in the last 24 hours // Fetch pending requests in the last 24 hours
val fetchSince = Instant.fromEpochMilliseconds(systemClock.epochMillis()).minus(1.days) val fetchSince = Instant.fromEpochMilliseconds(systemClock.epochMillis()).minus(1.days)
@@ -132,6 +153,10 @@ class FetchPendingNotificationsWorker(
pushHistoryService.insertOrUpdatePushRequests(updatedRequests) pushHistoryService.insertOrUpdatePushRequests(updatedRequests)
// The results have been handed over, so release the foreground service before the opportunistic sync, which deliberately takes a while
// in the background. This keeps the service short-lived in the common case, so its notification is normally never displayed.
releaseForegroundService()
analyticsService.recordTransaction("Opportunistic sync", "opportunistic_sync") { analyticsService.recordTransaction("Opportunistic sync", "opportunistic_sync") {
performOpportunisticSyncIfNeeded(mapOf(sessionId to requests)) performOpportunisticSyncIfNeeded(mapOf(sessionId to requests))
} }
@@ -139,6 +164,21 @@ class FetchPendingNotificationsWorker(
return if (updatedRequests.any { it.status == PushRequestStatus.PENDING.value }) Result.retry() else Result.success() return if (updatedRequests.any { it.status == PushRequestStatus.PENDING.value }) Result.retry() else Result.success()
} }
/**
* Release the foreground service, and the wakelock it holds, that was started when the push was received. Only the first call has an effect.
*
* From Android 12 on, the service is kept while the events are resolved: this needs several network round trips (sliding sync, room key
* retrieval, decryption) and, for ringing calls, waiting for the room state to be synced. While the service is running the process is in the
* foreground service state, so the expedited job quota and Doze restrictions don't cut this work short. The system defers the notification
* of a foreground service by 10 seconds on these versions, so it only becomes visible when the fetch is slow, which is exactly when the
* extra time is needed.
*/
private suspend fun releaseForegroundService() {
if (foregroundServiceReleased) return
foregroundServiceReleased = true
fetchPushForegroundServiceManager.stop()
}
private suspend fun performOpportunisticSyncIfNeeded( private suspend fun performOpportunisticSyncIfNeeded(
groupedRequests: Map<SessionId, List<PushRequest>>, groupedRequests: Map<SessionId, List<PushRequest>>,
) { ) {
@@ -91,10 +91,11 @@ class DefaultSyncPendingNotificationsRequestBuilder(
val request = OneTimeWorkRequestBuilder<FetchPendingNotificationsWorker>() val request = OneTimeWorkRequestBuilder<FetchPendingNotificationsWorker>()
.setInputData(workDataOf(SESSION_ID to sessionId.value)) .setInputData(workDataOf(SESSION_ID to sessionId.value))
.apply { .apply {
// Expedited workers aren't needed on Android 12 or lower: // Before Android 12 (API 31), WorkManager runs expedited work as a foreground service with a visible notification, which we
// They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway // don't want and don't support (no `getForegroundInfo` implementation). From API 31 on, expedited work is a native JobScheduler
// expedited job: it starts sooner and is less affected by Doze and App Standby, so use it wherever it is available.
// See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat // See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.S)) {
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
} }
} }
@@ -28,10 +28,10 @@ import org.robolectric.annotation.Config
@Config(sdk = [33]) @Config(sdk = [33])
class DefaultSyncPendingNotificationsRequestBuilderTest : RobolectricTest() { class DefaultSyncPendingNotificationsRequestBuilderTest : RobolectricTest() {
@Test @Test
fun `build - success API 33`() = runTest { fun `build - success API 31`() = runTest {
val request = createSyncPendingNotificationsRequestBuilder( val request = createSyncPendingNotificationsRequestBuilder(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
sdkVersion = 33, sdkVersion = 31,
) )
val results = request.build() val results = request.build()
@@ -42,7 +42,7 @@ class DefaultSyncPendingNotificationsRequestBuilderTest : RobolectricTest() {
assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java)
assertThat(workSpec.input.hasKeyWithValueOfType<String>(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue() assertThat(workSpec.input.hasKeyWithValueOfType<String>(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue()
assertThat(workSpec.hasConstraints()).isTrue() assertThat(workSpec.hasConstraints()).isTrue()
// True in API 33+ // True in API 31+
assertThat(workSpec.expedited).isTrue() assertThat(workSpec.expedited).isTrue()
assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC))
} }
@@ -50,10 +50,10 @@ class DefaultSyncPendingNotificationsRequestBuilderTest : RobolectricTest() {
} }
@Test @Test
fun `build - success API 32 and lower`() = runTest { fun `build - success API 30 and lower`() = runTest {
val request = createSyncPendingNotificationsRequestBuilder( val request = createSyncPendingNotificationsRequestBuilder(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
sdkVersion = 32, sdkVersion = 30,
) )
val results = request.build() val results = request.build()
@@ -65,7 +65,7 @@ class DefaultSyncPendingNotificationsRequestBuilderTest : RobolectricTest() {
assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java)
assertThat(workSpec.input.hasKeyWithValueOfType<String>(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue() assertThat(workSpec.input.hasKeyWithValueOfType<String>(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue()
assertThat(workSpec.hasConstraints()).isTrue() assertThat(workSpec.hasConstraints()).isTrue()
// False before API 33 // False before API 31
assertThat(workSpec.expedited).isFalse() assertThat(workSpec.expedited).isFalse()
assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC))
} }
@@ -31,6 +31,7 @@ import io.element.android.libraries.push.test.push.FakeFetchPushForegroundServic
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
@@ -50,10 +51,18 @@ import kotlin.time.Instant
class FetchPendingNotificationWorkerTest : RobolectricTest() { class FetchPendingNotificationWorkerTest : RobolectricTest() {
@Test @Test
fun `test - success`() = runTest { fun `test - success`() = runTest {
val callOrder = mutableListOf<String>()
var synced = false var synced = false
val syncOnNotifiableEventLambda = SyncOnNotifiableEvent { synced = true } val syncOnNotifiableEventLambda = SyncOnNotifiableEvent {
val emitResultLambda = lambdaRecorder<Map<PushRequest, Result<ResolvedPushEvent>>, Unit> {} synced = true
callOrder += "sync"
}
val emitResultLambda = lambdaRecorder<Map<PushRequest, Result<ResolvedPushEvent>>, Unit> { callOrder += "emit" }
val processor = FakeNotificationResultProcessor(emit = emitResultLambda) val processor = FakeNotificationResultProcessor(emit = emitResultLambda)
val stopForegroundServiceLambda = lambdaRecorder<Boolean> {
callOrder += "stop"
true
}
val getPendingResultsLambda = lambdaRecorder<SessionId, Instant?, Result<List<PushRequest>>> { _, _ -> Result.success(listOf(aPushRequest())) } val getPendingResultsLambda = lambdaRecorder<SessionId, Instant?, Result<List<PushRequest>>> { _, _ -> Result.success(listOf(aPushRequest())) }
val replacePushRequestsLambda = lambdaRecorder<List<PushRequest>, Result<Unit>> { Result.success(Unit) } val replacePushRequestsLambda = lambdaRecorder<List<PushRequest>, Result<Unit>> { Result.success(Unit) }
@@ -69,6 +78,7 @@ class FetchPendingNotificationWorkerTest : RobolectricTest() {
pushHistoryService = pushHistoryService, pushHistoryService = pushHistoryService,
resultProcessor = processor, resultProcessor = processor,
syncOnNotifiableEvent = syncOnNotifiableEventLambda, syncOnNotifiableEvent = syncOnNotifiableEventLambda,
pushHandlingWakeLock = FakeFetchPushForegroundServiceManager(unlock = stopForegroundServiceLambda),
) )
val result = worker.doWork() val result = worker.doWork()
@@ -86,16 +96,61 @@ class FetchPendingNotificationWorkerTest : RobolectricTest() {
// An opportunistic sync was triggered // An opportunistic sync was triggered
assertThat(synced).isTrue() assertThat(synced).isTrue()
// The foreground service is released once, after the results were handed over and before the opportunistic sync
stopForegroundServiceLambda.assertions().isCalledOnce()
assertThat(callOrder).containsExactly("emit", "stop", "sync").inOrder()
}
@Test
fun `test - before Android 12 the foreground service is released as soon as the work runs`() = runTest {
val callOrder = mutableListOf<String>()
val syncOnNotifiableEventLambda = SyncOnNotifiableEvent { callOrder += "sync" }
val emitResultLambda = lambdaRecorder<Map<PushRequest, Result<ResolvedPushEvent>>, Unit> { callOrder += "emit" }
val processor = FakeNotificationResultProcessor(emit = emitResultLambda)
val stopForegroundServiceLambda = lambdaRecorder<Boolean> {
callOrder += "stop"
true
}
val pushHistoryService = FakePushHistoryService(
getPendingPushRequests = { _, _ -> Result.success(listOf(aPushRequest())) },
replacePushRequests = { Result.success(Unit) },
removeOldPushRequests = { Result.success(Unit) },
)
val worker = createWorker(
input = "@alice:matrix.org",
pushHistoryService = pushHistoryService,
resultProcessor = processor,
syncOnNotifiableEvent = syncOnNotifiableEventLambda,
pushHandlingWakeLock = FakeFetchPushForegroundServiceManager(unlock = stopForegroundServiceLambda),
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(30),
)
val result = worker.doWork()
assertThat(result).isEqualTo(ListenableWorker.Result.success())
// The foreground service is released once, before the events are resolved, since its notification would be displayed immediately
stopForegroundServiceLambda.assertions().isCalledOnce()
assertThat(callOrder).containsExactly("stop", "emit", "sync").inOrder()
} }
@Test @Test
fun `test - invalid input fails the work`() = runTest { fun `test - invalid input fails the work`() = runTest {
val worker = createWorker(input = "!alice:matrix.org") val stopForegroundServiceLambda = lambdaRecorder<Boolean> { true }
val worker = createWorker(
input = "!alice:matrix.org",
pushHandlingWakeLock = FakeFetchPushForegroundServiceManager(unlock = stopForegroundServiceLambda),
)
val result = worker.doWork() val result = worker.doWork()
// The process failed // The process failed
assertThat(result).isEqualTo(ListenableWorker.Result.failure()) assertThat(result).isEqualTo(ListenableWorker.Result.failure())
// The foreground service is still stopped
stopForegroundServiceLambda.assertions().isCalledOnce()
} }
@Test @Test
@@ -108,11 +163,13 @@ class FetchPendingNotificationWorkerTest : RobolectricTest() {
replacePushRequests = { Result.success(Unit) }, replacePushRequests = { Result.success(Unit) },
removeOldPushRequests = { Result.success(Unit) }, removeOldPushRequests = { Result.success(Unit) },
) )
val stopForegroundServiceLambda = lambdaRecorder<Boolean> { true }
val worker = createWorker( val worker = createWorker(
input = "@alice:matrix.org", input = "@alice:matrix.org",
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
resultProcessor = processor, resultProcessor = processor,
pushHistoryService = pushHistoryService, pushHistoryService = pushHistoryService,
pushHandlingWakeLock = FakeFetchPushForegroundServiceManager(unlock = stopForegroundServiceLambda),
) )
val result = worker.doWork() val result = worker.doWork()
@@ -121,6 +178,9 @@ class FetchPendingNotificationWorkerTest : RobolectricTest() {
// The process failed due to a timeout in getting the network connectivity, a retry is scheduled // The process failed due to a timeout in getting the network connectivity, a retry is scheduled
assertThat(result).isEqualTo(ListenableWorker.Result.retry()) assertThat(result).isEqualTo(ListenableWorker.Result.retry())
// The foreground service is still stopped
stopForegroundServiceLambda.assertions().isCalledOnce()
} }
@Test @Test
@@ -238,6 +298,7 @@ class FetchPendingNotificationWorkerTest : RobolectricTest() {
resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor(), resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor(),
systemClock: FakeSystemClock = FakeSystemClock(), systemClock: FakeSystemClock = FakeSystemClock(),
pushHandlingWakeLock: FakeFetchPushForegroundServiceManager = FakeFetchPushForegroundServiceManager(), pushHandlingWakeLock: FakeFetchPushForegroundServiceManager = FakeFetchPushForegroundServiceManager(),
buildVersionSdkIntProvider: FakeBuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33),
) = FetchPendingNotificationsWorker( ) = FetchPendingNotificationsWorker(
params = createWorkerParams(workDataOf("session_id" to input)), params = createWorkerParams(workDataOf("session_id" to input)),
context = InstrumentationRegistry.getInstrumentation().context, context = InstrumentationRegistry.getInstrumentation().context,
@@ -249,6 +310,7 @@ class FetchPendingNotificationWorkerTest : RobolectricTest() {
resultProcessor = resultProcessor, resultProcessor = resultProcessor,
systemClock = systemClock, systemClock = systemClock,
fetchPushForegroundServiceManager = pushHandlingWakeLock, fetchPushForegroundServiceManager = pushHandlingWakeLock,
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
) )
private fun TestScope.createWorkerParams( private fun TestScope.createWorkerParams(