fix(monitor): Prevent crash when OS denies foreground service start

Add promoteToForeground() helper that catches ForegroundServiceStartNotAllowedException
(Android 12+) and SecurityException, allowing graceful service exit instead of crashing.
This commit is contained in:
darken
2026-02-22 06:24:51 +01:00
committed by Matthias Urhahn
parent ab24c48024
commit 5b59ddab0f
2 changed files with 50 additions and 28 deletions
@@ -1,11 +1,14 @@
package eu.darken.capod.monitor.core.worker package eu.darken.capod.monitor.core.worker
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.ForegroundServiceStartNotAllowedException
import android.app.Notification
import android.app.NotificationManager import android.app.NotificationManager
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder import android.os.IBinder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.bluetooth.BluetoothDevice2 import eu.darken.capod.common.bluetooth.BluetoothDevice2
@@ -70,44 +73,63 @@ class MonitorService : Service() {
private val monitorScope = MonitorCoroutineScope() private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null private var monitoringJob: Job? = null
@Volatile private var monitorGeneration = 0 @Volatile private var monitorGeneration = 0
private var foregroundStartFailed = false
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private fun promoteToForeground(notification: Notification): Boolean {
return try {
if (hasApiLevel(29)) {
startForeground(
MonitorNotifications.NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
startForeground(MonitorNotifications.NOTIFICATION_ID, notification)
}
true
} catch (e: IllegalStateException) {
@Suppress("NewApi")
if (Build.VERSION.SDK_INT >= 31 && e is ForegroundServiceStartNotAllowedException) {
log(TAG, WARN) { "Foreground service start denied by OS: ${e.message}" }
false
} else {
throw e
}
} catch (e: SecurityException) {
log(TAG, WARN) { "Foreground service start denied (security): ${e.message}" }
false
}
}
override fun onCreate() { override fun onCreate() {
// Promote to foreground BEFORE Hilt DI (triggered by super.onCreate()) to avoid // Promote to foreground BEFORE Hilt DI (triggered by super.onCreate()) to avoid
// ForegroundServiceDidNotStartInTimeException when DI is slow on backgrounded cold starts. // ForegroundServiceDidNotStartInTimeException when DI is slow on backgrounded cold starts.
MonitorNotifications.ensureChannel(this) MonitorNotifications.ensureChannel(this)
val earlyNotification = MonitorNotifications.createEarlyNotification(this) if (!promoteToForeground(MonitorNotifications.createEarlyNotification(this))) {
if (hasApiLevel(29)) { foregroundStartFailed = true
startForeground( stopSelf()
MonitorNotifications.NOTIFICATION_ID, super.onCreate()
earlyNotification, return
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
startForeground(MonitorNotifications.NOTIFICATION_ID, earlyNotification)
} }
super.onCreate() super.onCreate()
log(TAG, VERBOSE) { "onCreate()" } log(TAG, VERBOSE) { "onCreate()" }
// Replace early notification with the full one from injected MonitorNotifications. // Replace early notification with the full one from injected MonitorNotifications.
// Second startForeground() with the same ID updates the notification in place and is // Failure here is non-fatal — the service is already foreground from the early call.
// preferred over notify() for robust foreground state on OEM variants. promoteToForeground(notifications.getStartupNotification())
val notification = notifications.getStartupNotification()
if (hasApiLevel(29)) {
startForeground(
MonitorNotifications.NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
startForeground(MonitorNotifications.NOTIFICATION_ID, notification)
}
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
log(TAG, VERBOSE) { "onStartCommand(intent=$intent, flags=$flags, startId=$startId)" } log(TAG, VERBOSE) { "onStartCommand(intent=$intent, flags=$flags, startId=$startId)" }
if (foregroundStartFailed) {
log(TAG, WARN) { "Skipping monitor start, foreground promotion was denied." }
stopSelf(startId)
return START_NOT_STICKY
}
val forceStart = intent?.getBooleanExtra(EXTRA_FORCE_START, false) ?: false val forceStart = intent?.getBooleanExtra(EXTRA_FORCE_START, false) ?: false
if (monitoringJob?.isActive == true && !forceStart) { if (monitoringJob?.isActive == true && !forceStart) {
@@ -184,13 +184,13 @@ class MonitorNotifications @Inject constructor(
Intent(context, MainActivity::class.java), Intent(context, MainActivity::class.java),
PendingIntentCompat.FLAG_IMMUTABLE, PendingIntentCompat.FLAG_IMMUTABLE,
) )
return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID).apply {
.setContentIntent(openPi) setContentIntent(openPi)
.setSmallIcon(R.drawable.device_earbuds_generic_both) setSmallIcon(R.drawable.device_earbuds_generic_both)
.setContentTitle(context.getString(R.string.app_name)) setContentTitle(context.getString(R.string.app_name))
.setPriority(NotificationCompat.PRIORITY_LOW) setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true) setOngoing(true)
.build() }.build()
} }
} }
} }