From c428d946b8bd34cd374b667187368826a4bb09f0 Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 21:41:51 +0200 Subject: [PATCH] Stop crashing when a widget is removed from the home screen Since the move to Glance, GlanceAppWidgetReceiver.onDeleted takes the broadcast's goAsync() itself, so DDWidgetProvider's own goAsync() returned null and finishing it crashed the app every time a widget was removed. The provider now leaves the broadcast to Glance and queues CleanupWidgetsWorker, which deletes the records of widgets no longer on the home screen. The startup cleanup was never run either: its one-off work shared the unique name of the periodic work, whose REPLACE policy cancelled it. It now has its own name. Co-Authored-By: Claude Opus 5.5 --- app/build.gradle | 1 + .../appwidget/CleanupWidgetsWorker.kt | 16 +++- .../devdrawer/appwidget/DDWidgetProvider.kt | 26 ++----- .../devdrawer/appwidget/WidgetDeletionTest.kt | 78 +++++++++++++++++++ gradle/libs.versions.toml | 1 + 5 files changed, 98 insertions(+), 24 deletions(-) create mode 100644 app/src/test/java/de/psdev/devdrawer/appwidget/WidgetDeletionTest.kt diff --git a/app/build.gradle b/app/build.gradle index bbebc4d7..4cd92090 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -128,6 +128,7 @@ dependencies { testImplementation libs.mockk testImplementation libs.kotlinx.coroutines.test testImplementation libs.androidx.room.testing + testImplementation libs.androidx.work.testing // // Runtime dependencies diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/CleanupWidgetsWorker.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/CleanupWidgetsWorker.kt index 94b0bb98..472a6700 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/CleanupWidgetsWorker.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/CleanupWidgetsWorker.kt @@ -28,14 +28,22 @@ class CleanupWidgetsWorker @AssistedInject constructor( @JvmField val TAG: String = CleanupWidgetsWorker::class.java.simpleName - fun enableWorker(application: Application) { - val workManager = WorkManager.getInstance(application) + /** Unique name of a one-off cleanup; separate from [TAG] so the periodic work can't cancel it. */ + const val CLEANUP_NOW = "CleanupWidgetsWorker.now" - workManager.enqueueUniqueWork( - TAG, + /** Removes the records of widgets that are no longer on the home screen, as soon as possible. */ + fun runOnce(context: Context) { + WorkManager.getInstance(context).enqueueUniqueWork( + CLEANUP_NOW, ExistingWorkPolicy.APPEND_OR_REPLACE, OneTimeWorkRequestBuilder().build() ) + } + + fun enableWorker(application: Application) { + val workManager = WorkManager.getInstance(application) + + runOnce(application) workManager.enqueueUniquePeriodicWork( TAG, ExistingPeriodicWorkPolicy.REPLACE, diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt index 72d2ebb1..d4ab13ed 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt @@ -3,39 +3,25 @@ package de.psdev.devdrawer.appwidget import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver -import dagger.hilt.android.AndroidEntryPoint import de.psdev.devdrawer.appwidget.glance.DevDrawerGlanceWidget -import de.psdev.devdrawer.database.DevDrawerDatabase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import mu.KLogging -import javax.inject.Inject /** * NOTE: Never rename this as it will break existing widgets. */ -@AndroidEntryPoint class DDWidgetProvider : GlanceAppWidgetReceiver() { - @Inject - lateinit var devDrawerDatabase: DevDrawerDatabase - companion object : KLogging() override val glanceAppWidget: GlanceAppWidget = DevDrawerGlanceWidget() + /** + * Glance's own [onDeleted] already takes the broadcast's goAsync() to clear the widget's state; a second + * goAsync() returns null. The database records are removed by a worker instead. + */ override fun onDeleted(context: Context, appWidgetIds: IntArray) { super.onDeleted(context, appWidgetIds) - logger.warn { "Deleted widgets ${appWidgetIds.joinToString()}" } - val pendingResult = goAsync() - CoroutineScope(SupervisorJob()).launch(Dispatchers.IO) { - try { - devDrawerDatabase.widgetDao().deleteByIds(appWidgetIds.toList()) - } finally { - pendingResult.finish() - } - } + logger.info { "Deleted widgets ${appWidgetIds.joinToString()}" } + CleanupWidgetsWorker.runOnce(context) } } diff --git a/app/src/test/java/de/psdev/devdrawer/appwidget/WidgetDeletionTest.kt b/app/src/test/java/de/psdev/devdrawer/appwidget/WidgetDeletionTest.kt new file mode 100644 index 00000000..4b1c0c80 --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/appwidget/WidgetDeletionTest.kt @@ -0,0 +1,78 @@ +package de.psdev.devdrawer.appwidget + +import android.app.Application +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class WidgetDeletionTest { + + private val application: Application = ApplicationProvider.getApplicationContext() + private val workManager get() = WorkManager.getInstance(application) + + @Before + fun setUp() { + // Stands in for the Hilt worker factory: every worker just succeeds, so its state shows it ran. + val factory = object : WorkerFactory() { + override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) = + object : Worker(appContext, workerParameters) { + override fun doWork() = Result.success() + } + } + WorkManagerTestInitHelper.initializeTestWorkManager( + application, + Configuration.Builder().setWorkerFactory(factory).setExecutor(SynchronousExecutor()).build() + ) + } + + private fun cleanupStates() = + workManager.getWorkInfosForUniqueWork(CleanupWidgetsWorker.CLEANUP_NOW).get().map { it.state } + + @Test + fun `given a widget removed from the home screen, when the provider is told, then it does not crash and runs a cleanup`() { + // Given + val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_DELETED) + .setComponent(ComponentName(application, DDWidgetProvider::class.java)) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 7) + + // When + application.sendBroadcast(intent) + shadowOf(application.mainLooper).idle() + + // Then + assertEquals(listOf(WorkInfo.State.SUCCEEDED), cleanupStates()) + } + + @Test + fun `given the app starts, when the cleanup workers are enabled, then the one-off cleanup runs and is not cancelled by the periodic one`() { + // Given / When + CleanupWidgetsWorker.enableWorker(application) + + // Then + assertEquals(listOf(WorkInfo.State.SUCCEEDED), cleanupStates()) + assertEquals( + listOf(WorkInfo.State.ENQUEUED), + workManager.getWorkInfosForUniqueWork(CleanupWidgetsWorker.TAG).get().map { it.state } + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a4e0945..e6316343 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,6 +61,7 @@ androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } +androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" } app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "playUpdate" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3" }