Improve FetchPushForegroundService's reliability (#6757)

* Improve `FetchPushForegroundService`'s reliability

- Don't use DI, we can just create the notification channel. This should speed up the creation of the service and reduce the number of `ForegroundServiceDidNotStartInTimeException` received. Also use `MainScope` instead of the app's coroutine scope.
- Move the wakelock releasing mechanism to `onDestroy` so it's always used. Previously, this would only happen when `stopService` was called, which would only happen when `stopSelf()` is called, but not when the OS or the service manager stops the service.

* Add fallback value for the notification channel title

* Replace the wrong string for the notification/channel title

---------

Co-authored-by: Benoit Marty <benoitm@element.io>
This commit is contained in:
Jorge Martin Espinosa 2026-05-12 11:40:46 +02:00 committed by GitHub
parent 11476c73cf
commit 77b444581d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 60 additions and 62 deletions

View file

@ -1,17 +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.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import io.element.android.libraries.push.impl.push.FetchPushForegroundService
@ContributesTo(AppScope::class)
interface PushBindings {
fun inject(fetchPushForegroundService: FetchPushForegroundService)
}

View file

@ -13,17 +13,14 @@ import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
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.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.MainScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
@ -34,6 +31,12 @@ private const val NOTIFICATION_ID = 1001
// This kind of foreground service can only last up to 3 minutes before onTimeout is called
private val wakelockTimeout = 3.minutes.inWholeMilliseconds
// The channel ID to use for the notification of the foreground service.
private const val CHANNEL_ID = "fetch_push_notification_channel"
// The tag to use for the wakelock, this is used for debugging purposes and should be unique to this service.
private const val WAKELOCK_TAG = "FetchPushService:WakeLock"
/**
* Foreground service used to ensure the device stays awake while we handle the pushes and schedule and run the work to fetch the notification content.
*/
@ -42,28 +45,35 @@ class FetchPushForegroundService : Service() {
return null
}
@Inject lateinit var notificationChannels: NotificationChannels
@Inject @AppCoroutineScope lateinit var coroutineScope: CoroutineScope
private val wakelock: PowerManager.WakeLock by lazy {
val powerManager = getSystemService(POWER_SERVICE) as PowerManager
powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "FetchPushService:WakeLock").apply {
powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG).apply {
setReferenceCounted(false)
}
}
private var isOnForeground = false
private fun ensureNotificationChannelExists() {
NotificationManagerCompat.from(this).createNotificationChannelsCompat(
listOf(
NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(CommonStrings.common_fetching_notifications_title_android).ifEmpty { "Syncing notifications…" })
.setVibrationEnabled(false)
.setSound(null, null)
.build()
)
)
}
override fun onCreate() {
Timber.d("Creating FetchPushForegroundService")
Timber.i("Creating FetchPushForegroundService to handle incoming push, acquiring wakelock for up to $wakelockTimeout ms")
ensureNotificationChannelExists()
bindings<PushBindings>().inject(this)
Timber.d("Starting FetchPushForegroundService with wakelock timeout of $wakelockTimeout ms")
// Start the foreground service as soon as possible
val notificationCompat = NotificationCompat.Builder(this, notificationChannels.getSilentChannelId())
val notificationCompat = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(CommonDrawables.ic_notification)
.setContentTitle(getString(CommonStrings.common_android_fetching_notifications_title))
.setContentTitle(getString(CommonStrings.common_fetching_notifications_title_android).ifEmpty { "Syncing notifications…" })
.setProgress(0, 0, true)
.setVibrate(longArrayOf(0))
.setSound(null)
@ -103,7 +113,7 @@ class FetchPushForegroundService : Service() {
// The timeout is not automatic before Android 15, so we need to schedule it ourselves
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
coroutineScope.launch {
MainScope().launch {
delay(wakelockTimeout)
onTimeoutAction(calledByTheSystem = false)
}
@ -112,13 +122,18 @@ class FetchPushForegroundService : Service() {
return START_NOT_STICKY
}
override fun stopService(intent: Intent?): Boolean {
if (isOnForeground) {
wakelock.release()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
}
override fun onDestroy() {
super.onDestroy()
return super.stopService(intent)
if (isOnForeground) {
Timber.i("Destroying FetchPushForegroundService, releasing wakelock and stopping foreground")
if (wakelock.isHeld) {
wakelock.release()
}
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
} else {
Timber.w("Destroying FetchPushForegroundService that was not running in foreground, this is unexpected")
}
}
override fun onTimeout(startId: Int) {
@ -127,9 +142,9 @@ class FetchPushForegroundService : Service() {
}
private fun onTimeoutAction(calledByTheSystem: Boolean) {
Timber.d("onTimeoutAction, calledByTheSystem: $calledByTheSystem, isOnForeground: $isOnForeground")
Timber.w("onTimeoutAction, calledByTheSystem: $calledByTheSystem, isOnForeground: $isOnForeground")
if (isOnForeground) {
Timber.d("Wakelock timeout reached, stopping FetchPushForegroundService")
Timber.w("Wakelock timeout reached, stopping FetchPushForegroundService")
stopSelf()
}
}