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..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
@@ -4,18 +4,23 @@ 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 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 {
@@ -24,7 +29,19 @@ open class Provider : AppWidgetProvider(), KoinComponent {
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
- requestUpdate(context, appWidgetIds, appWidgetManager)
+ val pendingResult = goAsync()
+ val finished = AtomicBoolean(false)
+ requestUpdate(context, appWidgetIds, appWidgetManager) {
+ // 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)
+ }
+ }
+ }
}
/**
@@ -42,9 +59,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 +73,87 @@ open class Provider : AppWidgetProvider(), KoinComponent {
AppLog.i("appWidgetId: $appWidgetId", tag ="onAppWidgetOptionsChanged")
}
- companion object {
+ companion object : KoinComponent {
+
+ // 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(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)
- } else {
- enqueue(appWidgetIds, context)
+ // 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()
}
}
- 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) {
+ 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)
+ }
}
- AppLog.i("appWidgetIds: ${appWidgetIds.joinToString(",")}", tag = "enqueue")
- UpdateWidgetJob.enqueue(context, appWidgetIds)
}
private fun getComponentName(context: Context): ComponentName {
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..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
@@ -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,41 @@ 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
- }
+ startForeground(ModeDetectorNotification.id, ModeDetectorNotification.create(this))
+ } catch (e: Exception) {
+ AppLog.e(e)
+ stopSelf()
+ 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
+ // 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 +104,38 @@ 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 already unregistered (e.g. double unregister) or was registered
+ // against a different context.
+ 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 +151,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..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
@@ -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")
@@ -108,9 +131,26 @@ 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
+ }
+
+ /**
+ * 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() {
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..3982886d 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
@@ -75,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
@@ -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 (isBluetoothPermissionMissing()) {
+ viewState = viewState.copy(listState = BluetoothDevicesListState.RequiresPermissions)
+ } else {
+ handleEvent(BluetoothDevicesViewEvent.LoadDevices)
+ }
}
}
}
@@ -116,17 +121,29 @@ 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 (isBluetoothPermissionMissing()) {
+ // 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..a5392c29 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,50 @@ class ScreenOrientation(private val context: Context, private val windowManager:
return
}
- overlayView = View(context)
- layoutParams = createLayoutParams()
-
- layoutParams!!.screenOrientation = orientation
+ // 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
+ val currentParams = layoutParams
+ if (viewAdded && currentView != null && currentParams != null) {
+ currentParams.screenOrientation = orientation
+ try {
+ 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.
+ }
+ } 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
+ }
- if (viewAdded) {
- windowManager.updateViewLayout(this.overlayView, this.layoutParams)
- return
+ 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
}
- windowManager.addView(overlayView, layoutParams)
- viewAdded = true
}
private fun createLayoutParams(): WindowManager.LayoutParams {
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`).
---