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`). ---