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,30 +73,11 @@ 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")
override fun onCreate() { private fun promoteToForeground(notification: Notification): Boolean {
// Promote to foreground BEFORE Hilt DI (triggered by super.onCreate()) to avoid return try {
// ForegroundServiceDidNotStartInTimeException when DI is slow on backgrounded cold starts.
MonitorNotifications.ensureChannel(this)
val earlyNotification = MonitorNotifications.createEarlyNotification(this)
if (hasApiLevel(29)) {
startForeground(
MonitorNotifications.NOTIFICATION_ID,
earlyNotification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
startForeground(MonitorNotifications.NOTIFICATION_ID, earlyNotification)
}
super.onCreate()
log(TAG, VERBOSE) { "onCreate()" }
// Replace early notification with the full one from injected MonitorNotifications.
// Second startForeground() with the same ID updates the notification in place and is
// preferred over notify() for robust foreground state on OEM variants.
val notification = notifications.getStartupNotification()
if (hasApiLevel(29)) { if (hasApiLevel(29)) {
startForeground( startForeground(
MonitorNotifications.NOTIFICATION_ID, MonitorNotifications.NOTIFICATION_ID,
@@ -103,11 +87,49 @@ class MonitorService : Service() {
} else { } else {
startForeground(MonitorNotifications.NOTIFICATION_ID, notification) 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() {
// Promote to foreground BEFORE Hilt DI (triggered by super.onCreate()) to avoid
// ForegroundServiceDidNotStartInTimeException when DI is slow on backgrounded cold starts.
MonitorNotifications.ensureChannel(this)
if (!promoteToForeground(MonitorNotifications.createEarlyNotification(this))) {
foregroundStartFailed = true
stopSelf()
super.onCreate()
return
}
super.onCreate()
log(TAG, VERBOSE) { "onCreate()" }
// Replace early notification with the full one from injected MonitorNotifications.
// Failure here is non-fatal — the service is already foreground from the early call.
promoteToForeground(notifications.getStartupNotification())
} }
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()
} }
} }
} }