diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index fc06c438aa..c3395d1007 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -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", diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/FetchPushForegroundServiceManager.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/FetchPushForegroundServiceManager.kt new file mode 100644 index 0000000000..be178bbe6c --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/FetchPushForegroundServiceManager.kt @@ -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 +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/PushHandlingWakeLock.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/PushHandlingWakeLock.kt deleted file mode 100644 index 5c76eb1864..0000000000 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/PushHandlingWakeLock.kt +++ /dev/null @@ -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() -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt index e6a3201cbf..0dd2761446 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt @@ -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) { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultFetchPushForegroundServiceManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultFetchPushForegroundServiceManager.kt new file mode 100644 index 0000000000..968661268a --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultFetchPushForegroundServiceManager.kt @@ -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 } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlingWakeLock.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlingWakeLock.kt deleted file mode 100644 index 27a921c219..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlingWakeLock.kt +++ /dev/null @@ -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) - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/FetchPushForegroundService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/FetchPushForegroundService.kt index d54b7f5497..2b3587837e 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/FetchPushForegroundService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/FetchPushForegroundService.kt @@ -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() } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt index ec57582529..c18a0015aa 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt @@ -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) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt index 5a0d95c017..6acb375bac 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt @@ -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) -> Unit = {}, onNotifiableEventsReceived: (List) -> 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, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultFetchPushForegroundServiceManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultFetchPushForegroundServiceManagerTest.kt new file mode 100644 index 0000000000..63307634f3 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultFetchPushForegroundServiceManagerTest.kt @@ -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) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationWorkerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationWorkerTest.kt index 8168019a99..c0fa5d3442 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationWorkerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationWorkerTest.kt @@ -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( diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/push/FakeFetchPushForegroundServiceManager.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/push/FakeFetchPushForegroundServiceManager.kt new file mode 100644 index 0000000000..d0128b4a09 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/push/FakeFetchPushForegroundServiceManager.kt @@ -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() + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/push/FakePushHandlingWakeLock.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/push/FakePushHandlingWakeLock.kt deleted file mode 100644 index 077c8f661e..0000000000 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/push/FakePushHandlingWakeLock.kt +++ /dev/null @@ -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() - } -} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt index 3961f1f591..975a3c75ca 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt @@ -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() } } } diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt index 798328e626..a04c961ebb 100644 --- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt @@ -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 { _ -> } - val unlockLambda = lambdaRecorder { } + val lockLambda = lambdaRecorder { true } + val unlockLambda = lambdaRecorder { 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 { _ -> } - val unlockLambda = lambdaRecorder { } + val lockLambda = lambdaRecorder { true } + val unlockLambda = lambdaRecorder { 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 { _ -> } - val unlockLambda = lambdaRecorder { } + val lockLambda = lambdaRecorder { true } + val unlockLambda = lambdaRecorder { 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 } } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt index 363400ba13..4288f2b6b8 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -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() } } } diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt index ef81c647b3..cb23289c85 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt @@ -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 { _, _ -> true } - val lockLambda = lambdaRecorder { _ -> } - val unlockLambda = lambdaRecorder { } + val lockLambda = lambdaRecorder { true } + val unlockLambda = lambdaRecorder { 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 { _, _ -> false } - val lockLambda = lambdaRecorder { _ -> } - val unlockLambda = lambdaRecorder { } + val lockLambda = lambdaRecorder { true } + val unlockLambda = lambdaRecorder { 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 } } }