diff --git a/app/build.gradle b/app/build.gradle index bbebc4d..4cd9209 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 94b0bb9..472a670 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 72d2ebb..d4ab13e 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 0000000..4b1c0c8 --- /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 8a4e094..e631634 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" }