Cleanup FetchPushForegroundService (#6577)

* Rename `PushHandlingWakeLock` to `FetchPushForegroundServiceManager`. Move the start/stop logic from `FetchPushForegroundService.Companion` to it.

* Add more tests using Robolectric.

* Remove `FeatureFlags.SyncNotificationsWithWorkManager` and associated code: this should have been removed in one of the previous refactors, since we don't have the 2 ways to sync notifications anymore, everything uses the `WorkManager`

---------

Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
Jorge Martin Espinosa 2026-04-20 16:03:12 +02:00 committed by GitHub
parent 8853f160e2
commit f80a140cf9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 329 additions and 215 deletions

View file

@ -104,14 +104,6 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
SyncNotificationsWithWorkManager(
key = "feature.sync_notifications_with_workmanager",
title = "Sync notifications with WorkManager",
description = "Use WorkManager to schedule notification sync tasks when a push is received." +
" This should improve reliability and battery usage.",
defaultValue = { true },
isFinished = false,
),
QrCodeLogin(
key = "feature.qr_code_login",
title = "QR Code Login",

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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.
*/
interface FetchPushForegroundServiceManager {
/**
* Start the foreground service to acquire the wakelock. If the device is already awake, this method does nothing.
*
* @return true if the service was started, false otherwise (e.g. if the device was already awake or if starting the service failed).
*/
fun start(): Boolean
/**
* Stop the foreground service to release the wakelock. If the service is not running, this method does nothing.
*
* @return true if the service was stopped, false otherwise (e.g. if the service was not running or if stopping the service failed).
*/
suspend fun stop(): Boolean
}

View file

@ -1,26 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.api.push
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
/**
* Abstraction over wakelocks used for push handling to ensure the device stays awake while we handle the push and schedule and run the work.
*/
interface PushHandlingWakeLock {
/**
* Acquire a wakelock. The wakelock will be held for the given [time] or until [unlock] is called, whichever happens first.
*/
fun lock(time: Duration = 1.minutes)
/**
* Release the wakelock. If no wakelock is associated with the key, this method does nothing.
*/
suspend fun unlock()
}

View file

@ -13,8 +13,6 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
@ -32,7 +30,6 @@ import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEv
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived
import io.element.android.libraries.push.impl.push.OnRedactedEventReceived
import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@ -60,8 +57,6 @@ class DefaultNotificationResultProcessor(
private val userPushStoreFactory: UserPushStoreFactory,
private val onRedactedEventReceived: OnRedactedEventReceived,
private val onNotifiableEventReceived: OnNotifiableEventReceived,
private val featureFlagService: FeatureFlagService,
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val notificationChannels: NotificationChannels,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
@ -215,10 +210,6 @@ class DefaultNotificationResultProcessor(
if (nonRingingCallEvents.isNotEmpty()) {
onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents)
}
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
syncOnNotifiableEvent(results.keys.toList())
}
}
private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {

View file

@ -0,0 +1,95 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.push
import android.app.ActivityManager
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.Context.POWER_SERVICE
import android.content.Intent
import android.os.Build
import android.os.PowerManager
import androidx.core.content.ContextCompat
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.push.api.push.FetchPushForegroundServiceManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultFetchPushForegroundServiceManager(
@ApplicationContext private val context: Context,
) : FetchPushForegroundServiceManager {
private val stopMutex = Mutex()
override fun start(): Boolean {
Timber.d("Acquiring wakelock for push handling, starting service.")
// Don't start the foreground service if the device is already awake
val powerManager = context.getSystemService(POWER_SERVICE) as PowerManager
if (powerManager.isInteractive) {
Timber.d("Device is already in an interactive state, no need to start FetchPushForegroundService")
return false
}
val intent = Intent(context, FetchPushForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
runCatchingExceptions { ContextCompat.startForegroundService(context, intent) }
.onFailure { throwable ->
Timber.e(throwable, "Failed to start FetchPushForegroundService, notifications may take longer than usual to sync")
}
} else {
context.startService(intent)
}
return true
}
override suspend fun stop(): Boolean {
Timber.d("Releasing wakelock used for push handling, stopping service.")
return stopMutex.withLock {
val runningServiceInfo = getRunningServiceInfo(context)
if (runningServiceInfo != null) {
val intent = Intent(context, FetchPushForegroundService::class.java)
// If it's still not running in foreground, it means the service is still starting,
// so we delay the stop to give it time to start and be set as foreground, otherwise we can crash
// with `ForegroundServiceDidNotStartInTimeException`.
var isInForeground = runningServiceInfo.foreground
withTimeoutOrNull(5.seconds) {
while (!isInForeground) {
delay(50)
val updatedServiceInfo = getRunningServiceInfo(context)
if (updatedServiceInfo == null) {
Timber.d("FetchPushForegroundService is no longer running, no need to stop it.")
return@withTimeoutOrNull
}
isInForeground = updatedServiceInfo.foreground == true
}
} ?: Timber.w("FetchPushForegroundService did not start in foreground after 5s, stopping it anyway.")
context.stopService(intent)
} else {
false
}
}
}
@Suppress("DEPRECATION")
private fun getRunningServiceInfo(context: Context): ActivityManager.RunningServiceInfo? {
val activityManager = context.getSystemService(ACTIVITY_SERVICE) as ActivityManager
return activityManager.getRunningServices(Int.MAX_VALUE)
.firstOrNull { it.service.className == FetchPushForegroundService::class.java.name }
}
}

View file

@ -1,33 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.push
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
import timber.log.Timber
import kotlin.time.Duration
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultPushHandlingWakeLock(
@ApplicationContext private val context: Context,
) : PushHandlingWakeLock {
override fun lock(time: Duration) {
Timber.d("Acquiring wakelock for push handling, starting service.")
FetchPushForegroundService.startIfNeeded(context)
}
override suspend fun unlock() {
Timber.d("Releasing wakelock used for push handling.")
FetchPushForegroundService.stop(context)
}
}

View file

@ -7,32 +7,27 @@
package io.element.android.libraries.push.impl.push
import android.app.ActivityManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
import io.element.android.libraries.push.impl.di.PushBindings
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
private const val NOTIFICATION_ID = 1001
@ -48,7 +43,6 @@ class FetchPushForegroundService : Service() {
}
@Inject lateinit var notificationChannels: NotificationChannels
@Inject lateinit var pushHandlingWakeLock: PushHandlingWakeLock
@Inject @AppCoroutineScope lateinit var coroutineScope: CoroutineScope
private val wakelock: PowerManager.WakeLock by lazy {
@ -78,8 +72,13 @@ class FetchPushForegroundService : Service() {
// Try to start the service in foreground. This can fail, even in cases where it's supposed to work according to the docs.
// In those cases we catch the exception and handle the failure so we don't try to start the wakelock or stop the service
// from running in foreground later.
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
} else {
0
}
runCatchingExceptions {
startForeground(NOTIFICATION_ID, notificationCompat)
ServiceCompat.startForeground(this, NOTIFICATION_ID, notificationCompat, serviceType)
}
.onSuccess {
isOnForeground = true
@ -116,7 +115,7 @@ class FetchPushForegroundService : Service() {
override fun stopService(intent: Intent?): Boolean {
if (isOnForeground) {
wakelock.release()
stopForeground(STOP_FOREGROUND_REMOVE)
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
}
return super.stopService(intent)
@ -131,64 +130,7 @@ class FetchPushForegroundService : Service() {
Timber.d("onTimeoutAction, calledByTheSystem: $calledByTheSystem, isOnForeground: $isOnForeground")
if (isOnForeground) {
Timber.d("Wakelock timeout reached, stopping FetchPushForegroundService")
coroutineScope.launch { pushHandlingWakeLock.unlock() }
}
}
companion object {
private val stopMutex = Mutex()
fun startIfNeeded(context: Context) {
// Don't start the foreground service if the device is already awake
val powerManager = context.getSystemService(POWER_SERVICE) as PowerManager
if (powerManager.isInteractive) return
start(context)
}
fun start(context: Context) {
val intent = Intent(context, FetchPushForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
runCatchingExceptions { context.startForegroundService(intent) }
.onFailure { throwable ->
Timber.e(
throwable,
"Failed to start FetchPushForegroundService, notifications may take longer than usual to sync"
)
}
} else {
context.startService(intent)
}
}
suspend fun stop(context: Context) = stopMutex.withLock {
val runningServiceInfo = getRunningServiceInfo(context)
if (runningServiceInfo != null) {
val intent = Intent(context, FetchPushForegroundService::class.java)
// If it's still not running in foreground, it means the service is still starting,
// so we delay the stop to give it time to start and be set as foreground, otherwise we can crash
// with `ForegroundServiceDidNotStartInTimeException`.
var isInForeground = runningServiceInfo.foreground
withTimeoutOrNull(5.seconds) {
while (!isInForeground) {
delay(50)
val updatedServiceInfo = getRunningServiceInfo(context)
if (updatedServiceInfo == null) {
Timber.d("FetchPushForegroundService is no longer running, no need to stop it.")
return@withTimeoutOrNull
}
isInForeground = updatedServiceInfo.foreground == true
}
} ?: Timber.w("FetchPushForegroundService did not start in foreground after 5s, stopping it anyway.")
context.stopService(intent)
}
}
@Suppress("DEPRECATION")
private fun getRunningServiceInfo(context: Context): ActivityManager.RunningServiceInfo? {
val activityManager = context.getSystemService(ACTIVITY_SERVICE) as ActivityManager
return activityManager.getRunningServices(Int.MAX_VALUE)
.firstOrNull { it.service.className == FetchPushForegroundService::class.java.name }
stopSelf()
}
}
}

View file

@ -25,7 +25,7 @@ import io.element.android.libraries.matrix.api.auth.SessionRestorationException
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.api.exception.isNetworkError
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
import io.element.android.libraries.push.api.push.FetchPushForegroundServiceManager
import io.element.android.libraries.push.impl.db.PushRequest
import io.element.android.libraries.push.impl.history.PushHistoryService
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
@ -58,7 +58,7 @@ class FetchPendingNotificationsWorker(
private val resultProcessor: NotificationResultProcessor,
private val analyticsService: AnalyticsService,
private val systemClock: SystemClock,
private val pushHandlingWakeLock: PushHandlingWakeLock,
private val fetchPushForegroundServiceManager: FetchPushForegroundServiceManager,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
Timber.d("FetchNotificationsWorker started")
@ -67,7 +67,8 @@ class FetchPendingNotificationsWorker(
inputData.getString(SyncPendingNotificationsRequestBuilder.SESSION_ID)?.let(::SessionId)
}.getOrNull() ?: return Result.failure()
pushHandlingWakeLock.unlock()
// We can stop the foreground service and unlock the wakelock, since the work is now running and the device should be kept awake
fetchPushForegroundServiceManager.stop()
// Fetch pending requests in the last 24 hours
val fetchSince = Instant.fromEpochMilliseconds(systemClock.epochMillis()).minus(1.days)

View file

@ -10,7 +10,6 @@ package io.element.android.libraries.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.test.FakeElementCallEntryPoint
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
@ -34,7 +33,6 @@ import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEv
import io.element.android.libraries.push.impl.push.FakeMutableBatteryOptimizationStore
import io.element.android.libraries.push.impl.push.FakeOnNotifiableEventReceived
import io.element.android.libraries.push.impl.push.FakeOnRedactedEventReceived
import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.any
@ -289,8 +287,6 @@ class DefaultNotificationResultProcessorTest {
userPushStoreFactory: FakeUserPushStoreFactory = FakeUserPushStoreFactory(),
onRedactedEventReceived: (List<ResolvedPushEvent.Redaction>) -> Unit = {},
onNotifiableEventsReceived: (List<NotifiableEvent>) -> Unit = {},
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
syncOnNotifiableEvent: SyncOnNotifiableEvent = {},
elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(),
notificationChannels: FakeNotificationChannels = FakeNotificationChannels(),
coroutineScope: CoroutineScope = backgroundScope,
@ -301,8 +297,6 @@ class DefaultNotificationResultProcessorTest {
userPushStoreFactory = userPushStoreFactory,
onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventReceived),
onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived),
featureFlagService = featureFlagService,
syncOnNotifiableEvent = syncOnNotifiableEvent,
elementCallEntryPoint = elementCallEntryPoint,
notificationChannels = notificationChannels,
coroutineScope = coroutineScope,

View file

@ -0,0 +1,134 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.push
import android.app.ActivityManager
import android.content.ComponentName
import android.content.Context.ACTIVITY_SERVICE
import android.content.Context.POWER_SERVICE
import android.os.PowerManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows
import org.robolectric.shadows.ShadowActivityManager
import org.robolectric.shadows.ShadowPowerManager
import kotlin.time.Duration.Companion.seconds
@RunWith(AndroidJUnit4::class)
class DefaultFetchPushForegroundServiceManagerTest {
@Test
fun `start should start the service if the device is not interactive`() {
val manager = createDefaultFetchPushForegroundServiceManager()
getShadowPowerManager().turnScreenOn(false)
assertThat(manager.start()).isTrue()
}
@Test
fun `start won't start the service if the device is interactive`() {
val manager = createDefaultFetchPushForegroundServiceManager()
getShadowPowerManager().turnScreenOn(true)
assertThat(manager.start()).isFalse()
}
@Test
fun `stop will stop the service if it's running`() = runTest {
val manager = createDefaultFetchPushForegroundServiceManager()
// Start the service first
getShadowPowerManager().turnScreenOn(false)
manager.start()
getShadowActivityManager().setServices(
listOf(
ActivityManager.RunningServiceInfo().apply {
service = ComponentName(InstrumentationRegistry.getInstrumentation().context, FetchPushForegroundService::class.java)
foreground = true
}
)
)
assertThat(manager.stop()).isTrue()
}
@Test
fun `stop will eventually stop the service once it's on foreground`() = runTest {
val manager = createDefaultFetchPushForegroundServiceManager()
// Start the service first
getShadowPowerManager().turnScreenOn(false)
manager.start()
// The service is started, but not yet in foreground
getShadowActivityManager().setServices(
listOf(
ActivityManager.RunningServiceInfo().apply {
service = ComponentName(InstrumentationRegistry.getInstrumentation().context, FetchPushForegroundService::class.java)
foreground = false
}
)
)
// We call stop, which won't stop the service yet since it's not in foreground
val future = async { manager.stop() }
// Then we set the service as running in foreground, which should allow the stop to complete
getShadowActivityManager().setServices(
listOf(
ActivityManager.RunningServiceInfo().apply {
service = ComponentName(InstrumentationRegistry.getInstrumentation().context, FetchPushForegroundService::class.java)
foreground = true
}
)
)
val stopped = withTimeout(5.seconds) { future.await() }
assertThat(stopped).isTrue()
}
@Test
fun `stop will not stop the service if it's stopped`() = runTest {
val manager = createDefaultFetchPushForegroundServiceManager()
// Set some fake running service data, even if the service is not really running
getShadowActivityManager().setServices(
listOf(
ActivityManager.RunningServiceInfo().apply {
service = ComponentName(InstrumentationRegistry.getInstrumentation().context, FetchPushForegroundService::class.java)
foreground = true
}
)
)
// Since the service was not really running, it was not stopped
assertThat(manager.stop()).isFalse()
}
private fun createDefaultFetchPushForegroundServiceManager() = DefaultFetchPushForegroundServiceManager(
context = InstrumentationRegistry.getInstrumentation().context,
)
private fun getShadowPowerManager(): ShadowPowerManager {
val powerManager = InstrumentationRegistry.getInstrumentation().context.getSystemService(POWER_SERVICE) as PowerManager
return Shadows.shadowOf(powerManager)
}
private fun getShadowActivityManager(): ShadowActivityManager {
val activityManager = InstrumentationRegistry.getInstrumentation().context.getSystemService(ACTIVITY_SERVICE) as ActivityManager
return Shadows.shadowOf(activityManager)
}
}

View file

@ -28,7 +28,7 @@ import io.element.android.libraries.push.impl.notifications.FakeNotificationResu
import io.element.android.libraries.push.impl.notifications.fixtures.aPushRequest
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent
import io.element.android.libraries.push.test.push.FakePushHandlingWakeLock
import io.element.android.libraries.push.test.push.FakeFetchPushForegroundServiceManager
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory
import io.element.android.services.analytics.test.FakeAnalyticsService
@ -239,7 +239,7 @@ class FetchPendingNotificationWorkerTest {
pushHistoryService: FakePushHistoryService = FakePushHistoryService(),
resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor(),
systemClock: FakeSystemClock = FakeSystemClock(),
pushHandlingWakeLock: FakePushHandlingWakeLock = FakePushHandlingWakeLock(),
pushHandlingWakeLock: FakeFetchPushForegroundServiceManager = FakeFetchPushForegroundServiceManager(),
) = FetchPendingNotificationsWorker(
params = createWorkerParams(workDataOf("session_id" to input)),
context = InstrumentationRegistry.getInstrumentation().context,
@ -250,7 +250,7 @@ class FetchPendingNotificationWorkerTest {
pushHistoryService = pushHistoryService,
resultProcessor = resultProcessor,
systemClock = systemClock,
pushHandlingWakeLock = pushHandlingWakeLock,
fetchPushForegroundServiceManager = pushHandlingWakeLock,
)
private fun TestScope.createWorkerParams(

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.test.push
import io.element.android.libraries.push.api.push.FetchPushForegroundServiceManager
class FakeFetchPushForegroundServiceManager(
private val lock: () -> Boolean = { true },
private val unlock: () -> Boolean = { true },
) : FetchPushForegroundServiceManager {
override fun start(): Boolean {
return lock.invoke()
}
override suspend fun stop(): Boolean {
return unlock.invoke()
}
}

View file

@ -1,24 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.test.push
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
import kotlin.time.Duration
class FakePushHandlingWakeLock(
private val lock: (time: Duration) -> Unit = {},
private val unlock: () -> Unit = {},
) : PushHandlingWakeLock {
override fun lock(time: Duration) {
lock.invoke(time)
}
override suspend fun unlock() {
unlock.invoke()
}
}

View file

@ -15,7 +15,7 @@ import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
import io.element.android.libraries.push.api.push.FetchPushForegroundServiceManager
import io.element.android.libraries.pushproviders.api.PushHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -27,7 +27,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
@Inject lateinit var firebaseNewTokenHandler: FirebaseNewTokenHandler
@Inject lateinit var pushParser: FirebasePushParser
@Inject lateinit var pushHandler: PushHandler
@Inject lateinit var pushHandlingWakeLock: PushHandlingWakeLock
@Inject lateinit var fetchPushForegroundServiceManager: FetchPushForegroundServiceManager
@AppCoroutineScope
@Inject lateinit var coroutineScope: CoroutineScope
@ -49,7 +49,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
val isHighPriority = message.priority == PRIORITY_HIGH
if (isHighPriority) {
// Acquire wakelock to ensure the device stays awake while we handle the push and schedule and run the work
pushHandlingWakeLock.lock()
fetchPushForegroundServiceManager.start()
}
coroutineScope.launch {
@ -63,7 +63,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
},
)
if (isHighPriority) {
pushHandlingWakeLock.unlock()
fetchPushForegroundServiceManager.stop()
}
} else {
val handled = pushHandler.handle(
@ -73,7 +73,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
// If we failed to handle the push, we should release the wakelock early to avoid keeping the device awake for too long.
if (!handled && isHighPriority) {
pushHandlingWakeLock.unlock()
fetchPushForegroundServiceManager.stop()
}
}
}

View file

@ -15,7 +15,7 @@ import com.google.firebase.messaging.RemoteMessage
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SECRET
import io.element.android.libraries.push.test.push.FakePushHandlingWakeLock
import io.element.android.libraries.push.test.push.FakeFetchPushForegroundServiceManager
import io.element.android.libraries.push.test.test.FakePushHandler
import io.element.android.libraries.pushproviders.api.PushData
import io.element.android.libraries.pushproviders.api.PushHandler
@ -29,7 +29,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.time.Duration
@RunWith(RobolectricTestRunner::class)
class VectorFirebaseMessagingServiceTest {
@ -81,11 +80,11 @@ class VectorFirebaseMessagingServiceTest {
@Test
fun `test pushHandler returning true locks and does not unlock the wakelock so it continues running`() = runTest {
val lockLambda = lambdaRecorder<Duration, Unit> { _ -> }
val unlockLambda = lambdaRecorder<Unit> { }
val lockLambda = lambdaRecorder<Boolean> { true }
val unlockLambda = lambdaRecorder<Boolean> { true }
val vectorFirebaseMessagingService = createVectorFirebaseMessagingService(
pushHandler = FakePushHandler(handleResult = { _, _ -> true }),
pushHandlingWakeLock = FakePushHandlingWakeLock(
pushHandlingWakeLock = FakeFetchPushForegroundServiceManager(
lock = lockLambda,
unlock = unlockLambda
)
@ -113,11 +112,11 @@ class VectorFirebaseMessagingServiceTest {
@Test
fun `test pushHandler returning false locks and unlocks the wakelock early`() = runTest {
val lockLambda = lambdaRecorder<Duration, Unit> { _ -> }
val unlockLambda = lambdaRecorder<Unit> { }
val lockLambda = lambdaRecorder<Boolean> { true }
val unlockLambda = lambdaRecorder<Boolean> { true }
val vectorFirebaseMessagingService = createVectorFirebaseMessagingService(
pushHandler = FakePushHandler(handleResult = { _, _ -> false }),
pushHandlingWakeLock = FakePushHandlingWakeLock(
pushHandlingWakeLock = FakeFetchPushForegroundServiceManager(
lock = lockLambda,
unlock = unlockLambda
)
@ -145,11 +144,11 @@ class VectorFirebaseMessagingServiceTest {
@Test
fun `test pushHandler with a remote message with normal priority won't lock the wakelock`() = runTest {
val lockLambda = lambdaRecorder<Duration, Unit> { _ -> }
val unlockLambda = lambdaRecorder<Unit> { }
val lockLambda = lambdaRecorder<Boolean> { true }
val unlockLambda = lambdaRecorder<Boolean> { true }
val vectorFirebaseMessagingService = createVectorFirebaseMessagingService(
pushHandler = FakePushHandler(handleResult = { _, _ -> false }),
pushHandlingWakeLock = FakePushHandlingWakeLock(
pushHandlingWakeLock = FakeFetchPushForegroundServiceManager(
lock = lockLambda,
unlock = unlockLambda
)
@ -186,14 +185,14 @@ class VectorFirebaseMessagingServiceTest {
private fun TestScope.createVectorFirebaseMessagingService(
firebaseNewTokenHandler: FirebaseNewTokenHandler = FakeFirebaseNewTokenHandler(),
pushHandler: PushHandler = FakePushHandler(),
pushHandlingWakeLock: FakePushHandlingWakeLock = FakePushHandlingWakeLock(),
pushHandlingWakeLock: FakeFetchPushForegroundServiceManager = FakeFetchPushForegroundServiceManager(),
): VectorFirebaseMessagingService {
return VectorFirebaseMessagingService().apply {
this.firebaseNewTokenHandler = firebaseNewTokenHandler
this.pushParser = FirebasePushParser()
this.pushHandler = pushHandler
this.coroutineScope = this@createVectorFirebaseMessagingService
this.pushHandlingWakeLock = pushHandlingWakeLock
this.fetchPushForegroundServiceManager = pushHandlingWakeLock
}
}
}

View file

@ -14,7 +14,7 @@ import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
import io.element.android.libraries.push.api.push.FetchPushForegroundServiceManager
import io.element.android.libraries.pushproviders.api.PushHandler
import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler
import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult
@ -38,7 +38,7 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
@Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler
@Inject lateinit var removedGatewayHandler: UnifiedPushRemovedGatewayHandler
@Inject lateinit var endpointRegistrationHandler: EndpointRegistrationHandler
@Inject lateinit var pushHandlingWakeLock: PushHandlingWakeLock
@Inject lateinit var fetchPushForegroundServiceManager: FetchPushForegroundServiceManager
@AppCoroutineScope
@Inject lateinit var coroutineScope: CoroutineScope
@ -59,8 +59,8 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
* @param instance connection, for multi-account
*/
override fun onMessage(context: Context, message: PushMessage, instance: String) {
// Acquire wakelock to ensure the device stays awake while we handle the push and schedule and run the work
pushHandlingWakeLock.lock()
// Start the foreground service to ensure the device stays awake while we handle the push and schedule and run the work.
fetchPushForegroundServiceManager.start()
Timber.tag(loggerTag.value).d("New message, decrypted: ${message.decrypted}")
coroutineScope.launch {
@ -71,16 +71,16 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
providerInfo = "${UnifiedPushConfig.NAME} - $instance",
data = String(message.content),
)
pushHandlingWakeLock.unlock()
fetchPushForegroundServiceManager.stop()
} else {
val handled = pushHandler.handle(
pushData = pushData,
providerInfo = "${UnifiedPushConfig.NAME} - $instance",
)
// If we failed to handle the push, we should release the wakelock early to avoid keeping the device awake for too long.
// If we failed to handle the push, we should stop the foreground service early to avoid keeping the device awake for too long.
if (!handled) {
pushHandlingWakeLock.unlock()
fetchPushForegroundServiceManager.stop()
}
}
}

View file

@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SECRET
import io.element.android.libraries.push.test.push.FakePushHandlingWakeLock
import io.element.android.libraries.push.test.push.FakeFetchPushForegroundServiceManager
import io.element.android.libraries.push.test.test.FakePushHandler
import io.element.android.libraries.pushproviders.api.PushData
import io.element.android.libraries.pushproviders.api.PushHandler
@ -39,7 +39,6 @@ import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.data.PublicKeySet
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
import kotlin.time.Duration
@RunWith(RobolectricTestRunner::class)
class VectorUnifiedPushMessagingReceiverTest {
@ -106,13 +105,13 @@ class VectorUnifiedPushMessagingReceiverTest {
fun `pushHandler returning true locks the wake lock but does not unlock it so it continues to run`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val pushHandlerResult = lambdaRecorder<PushData, String, Boolean> { _, _ -> true }
val lockLambda = lambdaRecorder<Duration, Unit> { _ -> }
val unlockLambda = lambdaRecorder<Unit> { }
val lockLambda = lambdaRecorder<Boolean> { true }
val unlockLambda = lambdaRecorder<Boolean> { true }
val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver(
pushHandler = FakePushHandler(
handleResult = pushHandlerResult
),
pushHandlingWakeLock = FakePushHandlingWakeLock(
pushHandlingWakeLock = FakeFetchPushForegroundServiceManager(
lock = lockLambda,
unlock = unlockLambda,
),
@ -133,13 +132,13 @@ class VectorUnifiedPushMessagingReceiverTest {
fun `pushHandler returning false locks and unlocks the wakelock early`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val pushHandlerResult = lambdaRecorder<PushData, String, Boolean> { _, _ -> false }
val lockLambda = lambdaRecorder<Duration, Unit> { _ -> }
val unlockLambda = lambdaRecorder<Unit> { }
val lockLambda = lambdaRecorder<Boolean> { true }
val unlockLambda = lambdaRecorder<Boolean> { true }
val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver(
pushHandler = FakePushHandler(
handleResult = pushHandlerResult
),
pushHandlingWakeLock = FakePushHandlingWakeLock(
pushHandlingWakeLock = FakeFetchPushForegroundServiceManager(
lock = lockLambda,
unlock = unlockLambda,
),
@ -264,7 +263,7 @@ class VectorUnifiedPushMessagingReceiverTest {
unifiedPushNewGatewayHandler: UnifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler(),
endpointRegistrationHandler: EndpointRegistrationHandler = EndpointRegistrationHandler(),
removedGatewayHandler: UnifiedPushRemovedGatewayHandler = UnifiedPushRemovedGatewayHandler { lambdaError() },
pushHandlingWakeLock: FakePushHandlingWakeLock = FakePushHandlingWakeLock(),
pushHandlingWakeLock: FakeFetchPushForegroundServiceManager = FakeFetchPushForegroundServiceManager(),
): VectorUnifiedPushMessagingReceiver {
return VectorUnifiedPushMessagingReceiver().apply {
this.pushParser = unifiedPushParser
@ -277,7 +276,7 @@ class VectorUnifiedPushMessagingReceiverTest {
this.removedGatewayHandler = removedGatewayHandler
this.endpointRegistrationHandler = endpointRegistrationHandler
this.coroutineScope = this@createVectorUnifiedPushMessagingReceiver
this.pushHandlingWakeLock = pushHandlingWakeLock
this.fetchPushForegroundServiceManager = pushHandlingWakeLock
}
}
}