androidbroadcastreceiverandroid-widgetandroid-workmanager

Starting WorkManager task from AppWidgetProvider results in endless onUpdate calls


I am trying to create a widget and as stated in this guide,

…If your widget setup process can take several seconds (perhaps while performing web requests) and you require that your process continues, consider starting a Task using WorkManager in the onUpdate() method.

But by enqueueing work requests from within the onUpdate method, I get endless onUpdate calls roughly every half a second with the same intent being passed in. I have tried updating the widget using goAsync() and GlobalScope.launch for my network request and it works just fine. However, it is recommended to do the i/o work using the workmanager api and this is what I am trying to do.

I created an app widget through the New -> Widget -> App Widget menu template, and am trying to queue the work like so:

class NewAppWidget : AppWidgetProvider() {
    override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager,appWidgetIds: IntArray) {
        val inputData = Data.Builder().putIntArray("ids", appWidgetIds).build()
        val workRequest = OneTimeWorkRequestBuilder<WidgetUpdateWorker>().setInputData(inputData).build()
        WorkManager.getInstance(context.applicationContext).enqueue(workRequest)
    }
}

Worker:

class WidgetUpdateWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val appWidgetIds = inputData.getIntArray("ids")
        val appWidgetManager = AppWidgetManager.getInstance(applicationContext)
        delay(200L) // Simulate network call. The behavior is the same with Retrofit and a real network call
        appWidgetIds?.forEach { id ->
            val views = RemoteViews(applicationContext.packageName, R.layout.new_app_widget)
            views.setTextViewText(R.id.appwidget_text, "Setting text from worker")
            appWidgetManager.updateAppWidget(id, views)
        }
        return Result.success()
    }
}

App widget info XML:

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/app_widget_description"
    android:initialKeyguardLayout="@layout/new_app_widget"
    android:initialLayout="@layout/new_app_widget"
    android:minWidth="180dp"
    android:minHeight="110dp"
    android:previewImage="@drawable/example_appwidget_preview"
    android:resizeMode="horizontal|vertical"
    android:updatePeriodMillis="86400000"
    android:widgetCategory="home_screen" />

As a result, I get a constantly blinking and updating widget on the homescreen. This happens on an emulator and a real device as well.

What am I doing wrong? I am targeting API 30 and using WorkManager version androidx.work:work-runtime-ktx:2.6.0


Solution

  • I've found some info about this issue and I think I'll answer my own question for now

    Why this happens

    According to the replies on the issue tracker, this is intended behavior. When there's no pending work, WorkManager disables its internal RescheduleReceiver, and a component's state change triggers onUpdate. onUpdate then enqueues another work request and we end up in an infinite loop. I also recommend reading CommonsWare's post which goes into detail about this.

    The recommended solution by the library developers is to not "unconditionally enqueue work in onUpdate". This may be difficult to implement, and since I hadn't had time to try this advice, I'll provide a workaround below that has worked for me.

    Workaround

    The simplest workaround I've found so far is to set a dummy work request far out into the future in AppWidgetProvider's onEnabled so that there's always a pending work request. For example:

    override fun onEnabled(context: Context) {
        val alwaysPendingWork = OneTimeWorkRequestBuilder<YourWorker>()
            .setInitialDelay(5000L, TimeUnit.DAYS)
            .build()
    
        WorkManager.getInstance(context).enqueueUniqueWork(
            "always_pending_work",
            ExistingWorkPolicy.KEEP,
            alwaysPendingWork
        )
    }
    
    override fun onDisabled(context: Context) {
        WorkManager.getInstance(context).cancelUniqueWork("always_pending_work")
    }
    

    Don't forget that each AppWidgetProvider must provide a unique work name. "${YourWorker::class.simpleName}_work" worked well for me.

    It should also be possible (and is probably a better approach) to use WorkManager's periodic work instead of relying on widget's update interval, but from what I've read it requires additional, more complex state management. You can also consider using JobIntentService or JobScheduler instead of WorkManager here (but I would only use these as a last resort)