From 83e2c2a4afa150d11bbbc6ba16b17341d82366bd Mon Sep 17 00:00:00 2001 From: Alex Gavrishev <171704+anod@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:25:47 +0300 Subject: [PATCH 1/5] fix: harden in-car foreground services against Play vitals crashes Address the top crashes in the live release (v3.4.1 / 341004): - ScreenOrientation.set(): reuse the already-added overlay view instead of creating a new View and calling updateViewLayout() on it (which threw IllegalArgumentException "View not attached to window manager"); wrap all WindowManager add/update/remove calls in try/catch. - BluetoothDevicesViewModel: guard getBondedDevices() against SecurityException and re-check the Bluetooth runtime permission on the BT-state-changed path before loading devices. - ModeService: enter the foreground with a dependency-free notification, then stop cleanly (START_NOT_STICKY) when Koin is not started instead of crashing on the first get(); make onDestroy() skip DI-dependent teardown when Koin is absent so the crash is not merely relocated. - BroadcastService: call startForeground() first and unconditionally to avoid ForegroundServiceDidNotStartInTimeException; return START_NOT_STICKY to break the background auto-restart crash loop; guard the background start site against ForegroundServiceStartNotAllowedException; assign the receiver field only after registerReceiver() succeeds; guard unregister()/onDestroy() against absent Koin. - CarWidgetApplication: register notification channels before startKoin so a foreground-service notification can always be posted even if DI init fails. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2a23b4b-49b1-4b4a-85d3-8bd95d5bc5e9 --- .../com/anod/car/home/CarWidgetApplication.kt | 6 +- .../anod/car/home/incar/BroadcastService.kt | 70 +++++++++++++------ .../com/anod/car/home/incar/ModeService.kt | 63 +++++++++++++---- .../incar/BluetoothDevicesViewModel.kt | 22 +++++- .../carwidget/incar/ScreenOrientation.kt | 46 +++++++++--- 5 files changed, 159 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/com/anod/car/home/CarWidgetApplication.kt b/app/src/main/java/com/anod/car/home/CarWidgetApplication.kt index b4f230f9..514df526 100644 --- a/app/src/main/java/com/anod/car/home/CarWidgetApplication.kt +++ b/app/src/main/java/com/anod/car/home/CarWidgetApplication.kt @@ -124,6 +124,11 @@ class CarWidgetApplication : Application(), ApplicationInstance, KoinComponent { AppLog.tag = "CarWidget" AppLog.setDebug(BuildConfig.DEBUG, "CarWidget") + // Register notification channels before Koin so a foreground-service notification can + // always be posted (and never hit "Bad notification for startForeground"), even if DI + // initialization fails. + Channels.register(this) + startKoin { koin.loadModules( modules = listOf( @@ -166,7 +171,6 @@ class CarWidgetApplication : Application(), ApplicationInstance, KoinComponent { ), ) } - Channels.register(this) } private fun createInCarStatus(): InCarStatus { diff --git a/app/src/main/java/com/anod/car/home/incar/BroadcastService.kt b/app/src/main/java/com/anod/car/home/incar/BroadcastService.kt index d731f1ba..0aaaf6e3 100644 --- a/app/src/main/java/com/anod/car/home/incar/BroadcastService.kt +++ b/app/src/main/java/com/anod/car/home/incar/BroadcastService.kt @@ -15,6 +15,7 @@ import info.anodsplace.carwidget.content.preferences.InCarInterface import info.anodsplace.carwidget.content.preferences.InCarSettings import org.koin.core.component.KoinComponent import org.koin.core.component.get +import org.koin.core.context.GlobalContext class BroadcastService : Service(), KoinComponent { @@ -39,25 +40,31 @@ class BroadcastService : Service(), KoinComponent { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Enter the foreground immediately and unconditionally, before any other work, so we never + // miss the start deadline (RemoteServiceException$ForegroundServiceDidNotStartInTimeException). try { - // Start once - if (receiver == null) { - startForeground(ModeDetectorNotification.id, ModeDetectorNotification.create(this)) - if (register(this)) { - return START_STICKY - } - } else { - startForeground(ModeDetectorNotification.id, ModeDetectorNotification.create(this)) - return START_STICKY - } - - stopForeground(STOP_FOREGROUND_REMOVE) + startForeground(ModeDetectorNotification.id, ModeDetectorNotification.create(this)) + } catch (e: Exception) { + AppLog.e(e) stopSelf() return START_NOT_STICKY + } + + try { + if (receiver != null || register(this)) { + // START_NOT_STICKY on purpose: allowing the OS to auto-restart this foreground + // service from the background (START_STICKY) leads to repeated foreground-start + // timeout crashes on API 31+. It is re-started on demand by ModeBroadcastReceiver + // and settings changes when actually required. + return START_NOT_STICKY + } } catch (e: Exception) { AppLog.e(e) - return START_NOT_STICKY } + + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY } override fun onDestroy() { @@ -87,21 +94,37 @@ class BroadcastService : Service(), KoinComponent { filter.addAction(UiModeManager.ACTION_ENTER_CAR_MODE) filter.addAction(UiModeManager.ACTION_EXIT_CAR_MODE) - receiver = ModeBroadcastReceiver() - context.registerReceiver(receiver, filter) + // Assign the field only after registration succeeds so a failed registerReceiver() + // does not leave a non-null-but-unregistered receiver that later crashes unregister(). + val modeReceiver = ModeBroadcastReceiver() + context.registerReceiver(modeReceiver, filter) + receiver = modeReceiver return true } private fun unregister(context: Context) { AppLog.i("Unregister BroadcastService") if (receiver != null) { - context.unregisterReceiver(receiver) + try { + context.unregisterReceiver(receiver) + } catch (e: IllegalArgumentException) { + // Receiver was created but never successfully registered. + AppLog.e(e) + } receiver = null } - val prefs = get() - if (!prefs.isActivityRequired) { - ActivityTransitionTracker(context).stop() + // Skip DI-dependent cleanup when Koin isn't available to avoid crashing in teardown. + if (GlobalContext.getOrNull() == null) { + return + } + try { + val prefs = get() + if (!prefs.isActivityRequired) { + ActivityTransitionTracker(context).stop() + } + } catch (e: Exception) { + AppLog.e(e) } } @@ -117,7 +140,14 @@ class BroadcastService : Service(), KoinComponent { private fun startService(context: Context) { val service = Intent(context.applicationContext, BroadcastService::class.java) - ContextCompat.startForegroundService(context, service) + try { + ContextCompat.startForegroundService(context, service) + } catch (e: Exception) { + // API 31+: ForegroundServiceStartNotAllowedException when started from the + // background without an exemption (the triggering broadcasts - headset plug, + // power, Bluetooth ACL - are not exempt). Nothing actionable; skip rather than crash. + AppLog.e(e) + } } private fun stopService(context: Context) { diff --git a/app/src/main/java/com/anod/car/home/incar/ModeService.kt b/app/src/main/java/com/anod/car/home/incar/ModeService.kt index e245d89a..ee1e4976 100644 --- a/app/src/main/java/com/anod/car/home/incar/ModeService.kt +++ b/app/src/main/java/com/anod/car/home/incar/ModeService.kt @@ -1,5 +1,6 @@ package com.anod.car.home.incar +import android.app.Notification import android.app.NotificationManager import android.app.Service import android.content.Context @@ -9,8 +10,10 @@ import android.os.IBinder import android.os.PowerManager import android.telephony.PhoneStateListener import android.telephony.TelephonyManager +import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import com.anod.car.home.appwidget.Provider +import com.anod.car.home.notifications.Channels import com.anod.car.home.notifications.InCarModeNotificationFactory import info.anodsplace.applog.AppLog import info.anodsplace.carwidget.content.preferences.InCarSettings @@ -26,6 +29,7 @@ import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.component.inject +import org.koin.core.context.GlobalContext class ModeService : Service(), KoinComponent { @@ -46,18 +50,29 @@ class ModeService : Service(), KoinComponent { override fun onDestroy() { serviceScope.cancel() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + sInCarMode = false - val prefs = get() - if (forceState) { - ModeDetector.forceState(prefs, false) - } - ModeDetector.switchOff(prefs, modeHandler) - if (phoneListener != null) { - detachPhoneListener() + // If Koin never started for this (restarted) process, skip all DI-dependent teardown + // instead of crashing again with "KoinApplication has not been started". + if (GlobalContext.getOrNull() == null) { + AppLog.e("Koin is not started, skipping ModeService teardown") + super.onDestroy() + return } - sInCarMode = false - requestWidgetsUpdate() + try { + val prefs = get() + if (forceState) { + ModeDetector.forceState(prefs, false) + } + ModeDetector.switchOff(prefs, modeHandler) + if (phoneListener != null) { + detachPhoneListener() + } + requestWidgetsUpdate() + } catch (e: Exception) { + AppLog.e(e) + } super.onDestroy() } @@ -72,10 +87,18 @@ class ModeService : Service(), KoinComponent { AppLog.i("Start InCar Mode service, sInCarMode = " + sInCarMode + ", redelivered = " + redelivered) - // Enter the foreground immediately with a lightweight notification. Building the rich - // notification touches the database, PackageManager and decodes icons, so it must not - // run on the main thread (ANR / foreground-service start-timeout risk). - startForeground(InCarModeNotificationFactory.id, notificationFactory.createBasic()) + // Enter the foreground immediately with a dependency-free notification so we never miss + // the foreground-service start deadline, even if the OS restarted us before the app + // (Koin/DB) finished initializing. + startForeground(InCarModeNotificationFactory.id, createSafeNotification()) + + // If the OS restarted this service into a process where Koin isn't ready yet, stop + // cleanly instead of crashing on the first get<>() ("KoinApplication has not been started"). + if (GlobalContext.getOrNull() == null) { + AppLog.e("Koin is not started, stopping ModeService") + stopSelf() + return START_NOT_STICKY + } if (intent == null) { AppLog.e("ModeService started without intent") @@ -113,6 +136,20 @@ class ModeService : Service(), KoinComponent { return START_REDELIVER_INTENT } + /** + * Minimal, dependency-free notification used to satisfy the foreground-service start deadline + * before (or without) Koin. Building the rich notification requires Koin-injected collaborators + * and database access, which may not be available when the OS restarts the service. + */ + private fun createSafeNotification(): Notification { + return NotificationCompat.Builder(this, Channels.inCarMode) + .setSmallIcon(info.anodsplace.carwidget.skin.R.drawable.ic_stat_incar) + .setContentTitle(getString(info.anodsplace.carwidget.content.R.string.incar_mode_enabled)) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_MIN) + .build() + } + private fun updateNotification() { serviceScope.launch { try { diff --git a/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/BluetoothDevicesViewModel.kt b/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/BluetoothDevicesViewModel.kt index 0d6ba25e..68917277 100644 --- a/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/BluetoothDevicesViewModel.kt +++ b/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/BluetoothDevicesViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras +import info.anodsplace.applog.AppLog import info.anodsplace.carwidget.content.R import info.anodsplace.carwidget.content.preferences.InCarSettings import info.anodsplace.framework.bluetooth.BtClassType @@ -91,7 +92,11 @@ class BluetoothDevicesViewModel( val state = intent?.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) ?: BluetoothAdapter.ERROR viewState = viewState.copy(btAdapterState = state) if (state == BluetoothAdapter.STATE_ON) { - handleEvent(BluetoothDevicesViewEvent.LoadDevices) + if (checkPermission()) { + viewState = viewState.copy(listState = BluetoothDevicesListState.RequiresPermissions) + } else { + handleEvent(BluetoothDevicesViewEvent.LoadDevices) + } } } } @@ -123,10 +128,21 @@ class BluetoothDevicesViewModel( @SuppressLint("MissingPermission") private suspend fun loadDevices(selectedDevices: ArrayMap): List = withContext(Dispatchers.Default) { + if (checkPermission()) { + // BLUETOOTH_CONNECT / BLUETOOTH_SCAN not granted at runtime. + return@withContext emptyList() + } val btAdapter = bluetoothManager.adapter ?: return@withContext emptyList() - // Get a set of currently paired devices - val pairedDevices = btAdapter.bondedDevices + // Get a set of currently paired devices. Guard against SecurityException: on API 31+ + // getBondedDevices() requires the BLUETOOTH_CONNECT runtime permission, which can be + // revoked between the check above and this call (e.g. via the BT state-changed path). + val pairedDevices = try { + btAdapter.bondedDevices ?: emptySet() + } catch (e: SecurityException) { + AppLog.e(e) + return@withContext emptyList() + } val pairedList = mutableListOf() // If there are paired devices, add each one to the ArrayAdapter diff --git a/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/ScreenOrientation.kt b/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/ScreenOrientation.kt index bab4384d..b2aec991 100644 --- a/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/ScreenOrientation.kt +++ b/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/ScreenOrientation.kt @@ -5,6 +5,7 @@ import android.provider.Settings import android.view.View import android.view.WindowManager import android.widget.Toast +import info.anodsplace.applog.AppLog import info.anodsplace.carwidget.content.preferences.InCarInterface /** @@ -19,8 +20,12 @@ class ScreenOrientation(private val context: Context, private val windowManager: fun set(orientation: Int) { if (orientation == DISABLED) { - if (viewAdded) { - windowManager.removeView(overlayView) + if (viewAdded && overlayView != null) { + try { + windowManager.removeView(overlayView) + } catch (e: IllegalArgumentException) { + AppLog.e(e) + } } overlayView = null viewAdded = false @@ -33,17 +38,36 @@ class ScreenOrientation(private val context: Context, private val windowManager: return } - overlayView = View(context) - layoutParams = createLayoutParams() - - layoutParams!!.screenOrientation = orientation - - if (viewAdded) { - windowManager.updateViewLayout(this.overlayView, this.layoutParams) + // Update the already-added overlay in place. Creating a new View here and calling + // updateViewLayout() on it throws IllegalArgumentException ("View not attached to window + // manager") because that new view was never added. + val currentView = overlayView + if (viewAdded && currentView != null) { + layoutParams?.screenOrientation = orientation + try { + windowManager.updateViewLayout(currentView, layoutParams) + } catch (e: IllegalArgumentException) { + AppLog.e(e) + overlayView = null + layoutParams = null + viewAdded = false + } return } - windowManager.addView(overlayView, layoutParams) - viewAdded = true + + val view = View(context) + val params = createLayoutParams().apply { screenOrientation = orientation } + try { + windowManager.addView(view, params) + overlayView = view + layoutParams = params + viewAdded = true + } catch (e: Exception) { + AppLog.e(e) + overlayView = null + layoutParams = null + viewAdded = false + } } private fun createLayoutParams(): WindowManager.LayoutParams { From 3c9913444a01c0176c192b896a35bd2b5f04f2e4 Mon Sep 17 00:00:00 2001 From: Alex Gavrishev <171704+anod@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:00:09 +0300 Subject: [PATCH 2/5] fix: replace UpdateWidgetJob JobIntentService with goAsync coroutine updater Removes the legacy androidx JobIntentService that caused a Play vitals crash (IllegalArgumentException: Given work is not active) - a race in JobIntentService completeWork. Widget updates now run from Provider.onUpdate() via goAsync() plus the Koin AppCoroutineScope on Dispatchers.Default, with try/finally so the pending result is always finished. Deletes UpdateWidgetJob and its exported manifest service entry. Direct updateAppWidget calls never re-broadcast APPWIDGET_UPDATE, so the old WorkManager update-loop concern (issuetracker 115575872) does not apply. Off-main execution and fire-and-forget semantics for the other callers are preserved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2a23b4b-49b1-4b4a-85d3-8bd95d5bc5e9 --- app/src/main/AndroidManifest.xml | 10 --- .../java/com/anod/car/home/UpdateWidgetJob.kt | 68 ---------------- .../com/anod/car/home/appwidget/Provider.kt | 79 ++++++++++++++----- docs/ANDROID_EXPORTED_GUIDE.md | 14 ++-- 4 files changed, 65 insertions(+), 106 deletions(-) delete mode 100644 app/src/main/java/com/anod/car/home/UpdateWidgetJob.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 41a04d58..a445c4ba 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -228,16 +228,6 @@ android:resource="@xml/carhome_appwidget_info_1x1" /> - - - - () - val shortcutResources = get() - performUpdate(applicationContext, appWidgetManager, shortcutResources, appWidgetIds) - } - - private fun performUpdate(context: Context, appWidgetManager: AppWidgetManager, shortcutResources: ShortcutResources, appWidgetIds: IntArray) = runBlocking { - // Perform this loop procedure for each App Widget that belongs to this - // provider - for (appWidgetId in appWidgetIds) { - AppWidgetIdScope(appWidgetId, instance = Random.nextInt(), existingScope = null).use { - val viewBuilder = WidgetViewBuilder( - context = context, - iconLoader = get(), - appWidgetId = appWidgetId, - bitmapMemoryCache = null, - pendingIntentFactory = ShortcutPendingIntent(context, shortcutResources), - widgetButtonAlternativeHidden = false, - overrideSkin = null, - overrideCount = null, - widgetSettings = it.scope.get(), - inCarSettings = get(), - shortcutsModel = it.scope.get(), - koin = getKoin(), - ) - viewBuilder.firstTimeInit() - val view = viewBuilder.create() - AppLog.i("Performing update for widget #$appWidgetId") - appWidgetManager.updateAppWidget(appWidgetId, view) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/car/home/appwidget/Provider.kt b/app/src/main/java/com/anod/car/home/appwidget/Provider.kt index 9f9251e9..6b6630a2 100644 --- a/app/src/main/java/com/anod/car/home/appwidget/Provider.kt +++ b/app/src/main/java/com/anod/car/home/appwidget/Provider.kt @@ -4,18 +4,20 @@ import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.ComponentName import android.content.Context -import android.content.Intent import android.os.Bundle import com.anod.car.home.LargeProvider -import com.anod.car.home.UpdateWidgetJob import com.anod.car.home.incar.ModeService import info.anodsplace.applog.AppLog import info.anodsplace.carwidget.content.AppCoroutineScope import info.anodsplace.carwidget.content.BroadcastServiceManager +import info.anodsplace.carwidget.content.di.AppWidgetIdScope import info.anodsplace.carwidget.content.preferences.WidgetStorage +import info.anodsplace.carwidget.content.shortcuts.ShortcutResources +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.get +import kotlin.random.Random open class Provider : AppWidgetProvider(), KoinComponent { @@ -24,7 +26,10 @@ open class Provider : AppWidgetProvider(), KoinComponent { } override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - requestUpdate(context, appWidgetIds, appWidgetManager) + val pendingResult = goAsync() + requestUpdate(context, appWidgetIds, appWidgetManager) { + pendingResult.finish() + } } /** @@ -42,9 +47,6 @@ open class Provider : AppWidgetProvider(), KoinComponent { override fun onDisabled(context: Context) { AppLog.i( "", tag = "onDisabled") - val updateIntent = Intent(context, UpdateWidgetJob::class.java) - context.stopService(updateIntent) - getKoin().get().stopService() if (ModeService.sInCarMode) { @@ -59,26 +61,65 @@ open class Provider : AppWidgetProvider(), KoinComponent { AppLog.i("appWidgetId: $appWidgetId", tag ="onAppWidgetOptionsChanged") } - companion object { + companion object : KoinComponent { - fun requestUpdate(context: Context, appWidgetIds: IntArray, appWidgetManager: AppWidgetManager) { + fun requestUpdate( + context: Context, + appWidgetIds: IntArray, + appWidgetManager: AppWidgetManager, + onComplete: (() -> Unit)? = null + ) { AppLog.i("appWidgetIds: ${appWidgetIds.joinToString(",")}", tag = "requestUpdate") - if (appWidgetIds.isEmpty()) { - val thisAppWidget = getComponentName(context) - val allAppWidgetIds = appWidgetManager.getAppWidgetIds(thisAppWidget) - enqueue(allAppWidgetIds, context) + val ids = if (appWidgetIds.isEmpty()) { + appWidgetManager.getAppWidgetIds(getComponentName(context)) } else { - enqueue(appWidgetIds, context) + appWidgetIds + } + if (ids.isEmpty()) { + AppLog.w("appWidgetIds is empty, skipping update", tag = "requestUpdate") + onComplete?.invoke() + return + } + val scope: AppCoroutineScope = get() + scope.launch(Dispatchers.Default) { + try { + performUpdate(context.applicationContext, appWidgetManager, get(), ids) + } catch (e: Exception) { + AppLog.e(e) + } finally { + onComplete?.invoke() + } } } - private fun enqueue(appWidgetIds: IntArray, context: Context) { - if (appWidgetIds.isEmpty()) { - AppLog.w("appWidgetIds is empty, skipp[ing update", tag = "enqueue") - return + private suspend fun performUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + shortcutResources: ShortcutResources, + appWidgetIds: IntArray + ) { + for (appWidgetId in appWidgetIds) { + AppWidgetIdScope(appWidgetId, instance = Random.nextInt(), existingScope = null).use { + val viewBuilder = WidgetViewBuilder( + context = context, + iconLoader = get(), + appWidgetId = appWidgetId, + bitmapMemoryCache = null, + pendingIntentFactory = ShortcutPendingIntent(context, shortcutResources), + widgetButtonAlternativeHidden = false, + overrideSkin = null, + overrideCount = null, + widgetSettings = it.scope.get(), + inCarSettings = get(), + shortcutsModel = it.scope.get(), + koin = getKoin(), + ) + viewBuilder.firstTimeInit() + val view = viewBuilder.create() + AppLog.i("Performing update for widget #$appWidgetId") + appWidgetManager.updateAppWidget(appWidgetId, view) + } } - AppLog.i("appWidgetIds: ${appWidgetIds.joinToString(",")}", tag = "enqueue") - UpdateWidgetJob.enqueue(context, appWidgetIds) } private fun getComponentName(context: Context): ComponentName { diff --git a/docs/ANDROID_EXPORTED_GUIDE.md b/docs/ANDROID_EXPORTED_GUIDE.md index baf908d1..78368323 100644 --- a/docs/ANDROID_EXPORTED_GUIDE.md +++ b/docs/ANDROID_EXPORTED_GUIDE.md @@ -180,15 +180,11 @@ Let me analyze each exported component in your app: --- -#### 8. **UpdateWidgetJob** (Line 233-241) -```xml - -``` -**Why exported?** JobIntentService needs to be bindable by system. -**Security:** Protected by `BIND_JOB_SERVICE` permission ✅ +#### 8. **UpdateWidgetJob** — REMOVED +The `UpdateWidgetJob` `JobIntentService` (and its exported `` entry) was removed. +Widget updates now run via `AppWidgetProvider.onUpdate()` using `goAsync()` plus the app's +Koin `AppCoroutineScope`, so there is no longer an exported service to protect. This also +eliminates a legacy `JobIntentService` crash (`IllegalArgumentException: Given work is not active`). --- From 6b65441c88d92a5f65679ef340ebd9899fe5ee7a Mon Sep 17 00:00:00 2001 From: Alex Gavrishev <171704+anod@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:23:48 +0300 Subject: [PATCH 3/5] fix: harden widget updater against goAsync and concurrency races Addresses rubber-duck review of the JobIntentService replacement: - Wrap the requestUpdate hand-off in try/catch so a synchronous failure (e.g. Koin not ready) still invokes onComplete and finishes the goAsync() broadcast instead of leaving it dangling. - Serialize widget rebuilds with a Mutex held across suspension, since WidgetViewBuilder.firstTimeInit() does a check-then-write on shared storage; concurrent rebuilds of the same widget could duplicate default shortcuts. limitedParallelism(1) would not hold across the inner withContext in create(). - Isolate per-widget failures so one failing widget no longer skips the rest of the batch. - Resolve applicationContext before launching so the coroutine never captures an Activity/Service context. - Make the goAsync completion callback idempotent (AtomicBoolean) and exception-safe so a stray or throwing finish() cannot crash the app. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2a23b4b-49b1-4b4a-85d3-8bd95d5bc5e9 --- .../com/anod/car/home/appwidget/Provider.kt | 106 ++++++++++++------ 1 file changed, 69 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/anod/car/home/appwidget/Provider.kt b/app/src/main/java/com/anod/car/home/appwidget/Provider.kt index 6b6630a2..095d9ecc 100644 --- a/app/src/main/java/com/anod/car/home/appwidget/Provider.kt +++ b/app/src/main/java/com/anod/car/home/appwidget/Provider.kt @@ -15,8 +15,11 @@ import info.anodsplace.carwidget.content.preferences.WidgetStorage import info.anodsplace.carwidget.content.shortcuts.ShortcutResources import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.koin.core.component.KoinComponent import org.koin.core.component.get +import java.util.concurrent.atomic.AtomicBoolean import kotlin.random.Random open class Provider : AppWidgetProvider(), KoinComponent { @@ -27,8 +30,17 @@ open class Provider : AppWidgetProvider(), KoinComponent { override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { val pendingResult = goAsync() + val finished = AtomicBoolean(false) requestUpdate(context, appWidgetIds, appWidgetManager) { - pendingResult.finish() + // Completion may be signalled from the coroutine or a synchronous failure path; keep it + // idempotent and swallow a stray finish() so the callback can never crash the process. + if (finished.compareAndSet(false, true)) { + try { + pendingResult.finish() + } catch (e: Exception) { + AppLog.e(e) + } + } } } @@ -63,6 +75,11 @@ open class Provider : AppWidgetProvider(), KoinComponent { companion object : KoinComponent { + // Serializes widget rebuilds so two concurrent updates of the same widget can't race + // (WidgetViewBuilder.firstTimeInit performs a check-then-write on shared storage). The + // mutex is held across suspension points, which limitedParallelism(1) would not do. + private val updateMutex = Mutex() + fun requestUpdate( context: Context, appWidgetIds: IntArray, @@ -70,25 +87,34 @@ open class Provider : AppWidgetProvider(), KoinComponent { onComplete: (() -> Unit)? = null ) { AppLog.i("appWidgetIds: ${appWidgetIds.joinToString(",")}", tag = "requestUpdate") - val ids = if (appWidgetIds.isEmpty()) { - appWidgetManager.getAppWidgetIds(getComponentName(context)) - } else { - appWidgetIds - } - if (ids.isEmpty()) { - AppLog.w("appWidgetIds is empty, skipping update", tag = "requestUpdate") - onComplete?.invoke() - return - } - val scope: AppCoroutineScope = get() - scope.launch(Dispatchers.Default) { - try { - performUpdate(context.applicationContext, appWidgetManager, get(), ids) - } catch (e: Exception) { - AppLog.e(e) - } finally { + // Guard the whole hand-off: callers such as onUpdate() have already taken a goAsync() + // PendingResult, so a synchronous throw here (e.g. Koin not ready) must never escape + // without invoking onComplete, otherwise the broadcast is left unfinished. + try { + val appContext = context.applicationContext + val ids = if (appWidgetIds.isEmpty()) { + appWidgetManager.getAppWidgetIds(getComponentName(appContext)) + } else { + appWidgetIds + } + if (ids.isEmpty()) { + AppLog.w("appWidgetIds is empty, skipping update", tag = "requestUpdate") onComplete?.invoke() + return + } + val scope: AppCoroutineScope = get() + scope.launch(Dispatchers.Default) { + try { + performUpdate(appContext, appWidgetManager, get(), ids) + } catch (e: Exception) { + AppLog.e(e) + } finally { + onComplete?.invoke() + } } + } catch (e: Exception) { + AppLog.e(e) + onComplete?.invoke() } } @@ -99,25 +125,31 @@ open class Provider : AppWidgetProvider(), KoinComponent { appWidgetIds: IntArray ) { for (appWidgetId in appWidgetIds) { - AppWidgetIdScope(appWidgetId, instance = Random.nextInt(), existingScope = null).use { - val viewBuilder = WidgetViewBuilder( - context = context, - iconLoader = get(), - appWidgetId = appWidgetId, - bitmapMemoryCache = null, - pendingIntentFactory = ShortcutPendingIntent(context, shortcutResources), - widgetButtonAlternativeHidden = false, - overrideSkin = null, - overrideCount = null, - widgetSettings = it.scope.get(), - inCarSettings = get(), - shortcutsModel = it.scope.get(), - koin = getKoin(), - ) - viewBuilder.firstTimeInit() - val view = viewBuilder.create() - AppLog.i("Performing update for widget #$appWidgetId") - appWidgetManager.updateAppWidget(appWidgetId, view) + try { + updateMutex.withLock { + AppWidgetIdScope(appWidgetId, instance = Random.nextInt(), existingScope = null).use { + val viewBuilder = WidgetViewBuilder( + context = context, + iconLoader = get(), + appWidgetId = appWidgetId, + bitmapMemoryCache = null, + pendingIntentFactory = ShortcutPendingIntent(context, shortcutResources), + widgetButtonAlternativeHidden = false, + overrideSkin = null, + overrideCount = null, + widgetSettings = it.scope.get(), + inCarSettings = get(), + shortcutsModel = it.scope.get(), + koin = getKoin(), + ) + viewBuilder.firstTimeInit() + val view = viewBuilder.create() + AppLog.i("Performing update for widget #$appWidgetId") + appWidgetManager.updateAppWidget(appWidgetId, view) + } + } + } catch (e: Exception) { + AppLog.e(e) } } } From 3f012c8256cc3d05c7d967114f4470f1574c1763 Mon Sep 17 00:00:00 2001 From: Alex Gavrishev <171704+anod@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:29:12 +0300 Subject: [PATCH 4/5] fix: return START_NOT_STICKY from ModeService and guard BroadcastService against missing Koin ModeService success path returned START_REDELIVER_INTENT, which lets the OS auto-restart the foreground service from the background and re-exposes the top Play vitals crash (ForegroundServiceDidNotStartInTimeException) that also implicates ModeService. Return START_NOT_STICKY to match BroadcastService and the PR's crash-fix intent; in-car mode is re-triggered on demand. BroadcastService.onStartCommand now explicitly stops when Koin is not started (GlobalContext.getOrNull() == null) before calling register()/get(), mirroring ModeService instead of relying on a downstream throw. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2a23b4b-49b1-4b4a-85d3-8bd95d5bc5e9 --- .../java/com/anod/car/home/incar/BroadcastService.kt | 10 ++++++++++ .../main/java/com/anod/car/home/incar/ModeService.kt | 9 ++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/anod/car/home/incar/BroadcastService.kt b/app/src/main/java/com/anod/car/home/incar/BroadcastService.kt index 0aaaf6e3..4b4f0646 100644 --- a/app/src/main/java/com/anod/car/home/incar/BroadcastService.kt +++ b/app/src/main/java/com/anod/car/home/incar/BroadcastService.kt @@ -50,6 +50,16 @@ class BroadcastService : Service(), KoinComponent { return START_NOT_STICKY } + // If the OS restarted this service into a process where Koin isn't ready yet, stop cleanly + // instead of relying on downstream get() to throw ("KoinApplication has not + // been started"). Mirrors the guard in ModeService. + if (GlobalContext.getOrNull() == null) { + AppLog.e("Koin is not started, stopping BroadcastService") + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + try { if (receiver != null || register(this)) { // START_NOT_STICKY on purpose: allowing the OS to auto-restart this foreground diff --git a/app/src/main/java/com/anod/car/home/incar/ModeService.kt b/app/src/main/java/com/anod/car/home/incar/ModeService.kt index ee1e4976..fc1b28e6 100644 --- a/app/src/main/java/com/anod/car/home/incar/ModeService.kt +++ b/app/src/main/java/com/anod/car/home/incar/ModeService.kt @@ -131,9 +131,12 @@ class ModeService : Service(), KoinComponent { updateNotification() - // We want this service to continue running until it is explicitly - // stopped, so return sticky. - return START_REDELIVER_INTENT + // START_NOT_STICKY on purpose: letting the OS auto-restart this foreground service from the + // background (START_STICKY/START_REDELIVER_INTENT) is the top Play vitals crash on API 31+ + // (ForegroundServiceDidNotStartInTimeException) because a slow cold start misses the + // startForeground() deadline. In-car mode is re-triggered on demand by ModeBroadcastReceiver + // and settings changes, so it does not depend on the OS restarting this service. + return START_NOT_STICKY } /** From 2f403570d8ee1a0151c066f5d538e4f6fa720b7f Mon Sep 17 00:00:00 2001 From: Alex Gavrishev <171704+anod@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:42:29 +0300 Subject: [PATCH 5/5] fix: harden ScreenOrientation against null layoutParams and clarify review nits Address Copilot review feedback: - ScreenOrientation.set() no longer passes a nullable layoutParams to WindowManager.updateViewLayout() (an NPE there would not be caught by the IllegalArgumentException handler). Capture params into a non-null local, and on inconsistent state remove the stale overlay and re-add it instead. - Correct the Provider.updateMutex comment: it serializes all widget rebuilds process-wide, not only same-widget updates. - Correct the BroadcastService.unregister() catch comment to describe the real failure modes (double unregister / context mismatch). - Rename BluetoothDevicesViewModel.checkPermission() to isBluetoothPermissionMissing() so the inverted boolean reads correctly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2a23b4b-49b1-4b4a-85d3-8bd95d5bc5e9 --- .../com/anod/car/home/appwidget/Provider.kt | 8 ++++--- .../anod/car/home/incar/BroadcastService.kt | 3 ++- .../incar/BluetoothDevicesViewModel.kt | 9 ++++---- .../carwidget/incar/ScreenOrientation.kt | 22 +++++++++++++++---- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/anod/car/home/appwidget/Provider.kt b/app/src/main/java/com/anod/car/home/appwidget/Provider.kt index 095d9ecc..b4dd7c0b 100644 --- a/app/src/main/java/com/anod/car/home/appwidget/Provider.kt +++ b/app/src/main/java/com/anod/car/home/appwidget/Provider.kt @@ -75,9 +75,11 @@ open class Provider : AppWidgetProvider(), KoinComponent { companion object : KoinComponent { - // Serializes widget rebuilds so two concurrent updates of the same widget can't race - // (WidgetViewBuilder.firstTimeInit performs a check-then-write on shared storage). The - // mutex is held across suspension points, which limitedParallelism(1) would not do. + // Serializes all widget rebuilds process-wide: a single shared mutex, locked per widget + // in performUpdate. WidgetViewBuilder.firstTimeInit performs a check-then-write on shared + // storage, so overlapping rebuilds -- even of different widgets -- must not run + // concurrently. The lock is held across the suspending create() call, so it also guards + // work that runs on other dispatchers, which limitedParallelism(1) alone would not. private val updateMutex = Mutex() fun requestUpdate( diff --git a/app/src/main/java/com/anod/car/home/incar/BroadcastService.kt b/app/src/main/java/com/anod/car/home/incar/BroadcastService.kt index 4b4f0646..0d79cf96 100644 --- a/app/src/main/java/com/anod/car/home/incar/BroadcastService.kt +++ b/app/src/main/java/com/anod/car/home/incar/BroadcastService.kt @@ -118,7 +118,8 @@ class BroadcastService : Service(), KoinComponent { try { context.unregisterReceiver(receiver) } catch (e: IllegalArgumentException) { - // Receiver was created but never successfully registered. + // Receiver was already unregistered (e.g. double unregister) or was registered + // against a different context. AppLog.e(e) } receiver = null diff --git a/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/BluetoothDevicesViewModel.kt b/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/BluetoothDevicesViewModel.kt index 68917277..3982886d 100644 --- a/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/BluetoothDevicesViewModel.kt +++ b/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/BluetoothDevicesViewModel.kt @@ -76,7 +76,7 @@ class BluetoothDevicesViewModel( init { viewState = BluetoothDevicesViewState( btAdapterState = bluetoothManager.adapter?.state ?: BluetoothAdapter.STATE_OFF, - listState = if (checkPermission()) { + listState = if (isBluetoothPermissionMissing()) { BluetoothDevicesListState.RequiresPermissions } else { if (isBluetoothEnabled) BluetoothDevicesListState.Initial else BluetoothDevicesListState.SwitchedOff @@ -92,7 +92,7 @@ class BluetoothDevicesViewModel( val state = intent?.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) ?: BluetoothAdapter.ERROR viewState = viewState.copy(btAdapterState = state) if (state == BluetoothAdapter.STATE_ON) { - if (checkPermission()) { + if (isBluetoothPermissionMissing()) { viewState = viewState.copy(listState = BluetoothDevicesListState.RequiresPermissions) } else { handleEvent(BluetoothDevicesViewEvent.LoadDevices) @@ -121,14 +121,15 @@ class BluetoothDevicesViewModel( } } - private fun checkPermission(): Boolean { + /** Returns true when the Bluetooth runtime permissions are NOT granted (i.e. missing). */ + private fun isBluetoothPermissionMissing(): Boolean { return (!AppPermissions.isGranted(context, AppPermission.BluetoothScan) || !AppPermissions.isGranted(context, AppPermission.BluetoothConnect)) } @SuppressLint("MissingPermission") private suspend fun loadDevices(selectedDevices: ArrayMap): List = withContext(Dispatchers.Default) { - if (checkPermission()) { + if (isBluetoothPermissionMissing()) { // BLUETOOTH_CONNECT / BLUETOOTH_SCAN not granted at runtime. return@withContext emptyList() } diff --git a/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/ScreenOrientation.kt b/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/ScreenOrientation.kt index b2aec991..a5392c29 100644 --- a/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/ScreenOrientation.kt +++ b/compose/src/androidMain/kotlin/info/anodsplace/carwidget/incar/ScreenOrientation.kt @@ -42,17 +42,31 @@ class ScreenOrientation(private val context: Context, private val windowManager: // updateViewLayout() on it throws IllegalArgumentException ("View not attached to window // manager") because that new view was never added. val currentView = overlayView - if (viewAdded && currentView != null) { - layoutParams?.screenOrientation = orientation + val currentParams = layoutParams + if (viewAdded && currentView != null && currentParams != null) { + currentParams.screenOrientation = orientation try { - windowManager.updateViewLayout(currentView, layoutParams) + windowManager.updateViewLayout(currentView, currentParams) + return } catch (e: IllegalArgumentException) { AppLog.e(e) overlayView = null layoutParams = null viewAdded = false + // Stale/detached view: fall through and re-add the overlay below. } - return + } else if (viewAdded && currentView != null) { + // Inconsistent state (view present but params missing). Remove the stale overlay + // before re-adding so we don't orphan an attached view or pass null params to + // updateViewLayout() (which would crash with an NPE that the catch above misses). + try { + windowManager.removeView(currentView) + } catch (e: IllegalArgumentException) { + AppLog.e(e) + } + overlayView = null + layoutParams = null + viewAdded = false } val view = View(context)