Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -228,16 +228,6 @@
android:resource="@xml/carhome_appwidget_info_1x1" />
</receiver>

<service
android:name=".UpdateWidgetJob"
android:exported="true"
android:permission="android.permission.BIND_JOB_SERVICE"
android:foregroundServiceType="specialUse"
>
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="JobIntentService work service to update app widget when settings are changed"/>
</service>

<receiver
android:name=".BootCompleted"
android:exported="true"
Expand Down
6 changes: 5 additions & 1 deletion app/src/main/java/com/anod/car/home/CarWidgetApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -166,7 +171,6 @@ class CarWidgetApplication : Application(), ApplicationInstance, KoinComponent {
),
)
}
Channels.register(this)
}

private fun createInCarStatus(): InCarStatus {
Expand Down
68 changes: 0 additions & 68 deletions app/src/main/java/com/anod/car/home/UpdateWidgetJob.kt

This file was deleted.

115 changes: 95 additions & 20 deletions app/src/main/java/com/anod/car/home/appwidget/Provider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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)
}
}
}
Comment thread
anod marked this conversation as resolved.
}

/**
Expand All @@ -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<BroadcastServiceManager>().stopService()

if (ModeService.sInCarMode) {
Expand All @@ -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 {
Expand Down
77 changes: 59 additions & 18 deletions app/src/main/java/com/anod/car/home/incar/BroadcastService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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<InCarSettings>() 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() {
Expand Down Expand Up @@ -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<InCarSettings>()

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<InCarSettings>()
if (!prefs.isActivityRequired) {
ActivityTransitionTracker(context).stop()
}
} catch (e: Exception) {
AppLog.e(e)
}
}

Expand All @@ -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) {
Expand Down
Loading