Updating widgets with Jetpack WorkManager
Tags: android, widgets, appwidgets
Published: 2023-05-26 05:11:37

Welcome to the second part of *Updating widgets*. In the first installment, we looked at the anatomy of Android's appwidgets. One important takeaway was, that, while widgets can request updates through their configuration file, the interval may not be smaller than 30 minutes. More frequent updates require a different approach. I somewhat vaguely said, that we could update widgets from activities and services. Still, what if a widget is not a companion but all the app contains? A Weather widget doesn't necessarily need a main activity. Neither does a Battery Meter. Which app component should trigger widget updates in such scenarios?

Let's find out. 

Android has seen quite a few ways of allowing background jobs. For widget updates, we are particularly interested in *persistent work*, which means, that the things to be done remain scheduled through app restarts and system reboots. Google recommends [Jetpack WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager) for persistent work. 

### Jetpack WorkManager and appwidgets

To use WorkManager, we first need to add an implementation dependency:

```groovy
implementation("androidx.work:work-runtime-ktx:2.8.1")
```

The next step is to define a `Worker`. The actual work takes place inside `doWork()`.

```kotlin
private const val WORK_NAME = "update-battery-meter-widget"

class BatteryMeterWorker(
  private val context: Context,
  workerParams: WorkerParameters,
) :
  Worker(context, workerParams) {

  override fun doWork(): Result {
    context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
      .edit()
      .putLong(LAST_UPDATED, System.currentTimeMillis())
      .apply()
    context.updateXMLBatteryMeterWidget()
    return Result.success()
  }
}
```

The widget is updated by calling `context.updateXMLBatteryMeterWidget()`. This call won't take long. The same is true for accessing shared preferences. I will explain a little later why this is done. 

Workers return a `Result`. I am taking it easy by always using `Result.success()`. Depending on what a worker does, this may obviously be not always a clever thing to do. Now that we have defined our persistent work, let's think about how to start and stop it. 

The `AppWidgetProvider` class offers two related methods we can override:

- `onEnabled()` is called when an appwidget is instantiated
- `onDisabled()` will be invoked when the last widget instance is deleted

```kotlin
override fun onEnabled(context: Context) {
  super.onEnabled(context)
  enqueueUpdateXMLBatteryMeterWidgetRequest(context)
}

override fun onDisabled(context: Context) {
  super.onDisabled(context)
  cancelUpdateXMLBatteryWidgetRequest(context)
}
```

Here is how `enqueueUpdateXMLBatteryMeterWidgetRequest()` and `cancelUpdateXMLBatteryWidgetRequest()` are implemented:

```kotlin
fun enqueueUpdateXMLBatteryMeterWidgetRequest(context: Context) {
  val request = PeriodicWorkRequestBuilder<BatteryMeterWorker>(
    MIN_PERIODIC_INTERVAL_MILLIS,
    TimeUnit.MILLISECONDS
  ).build()
  WorkManager
    .getInstance(context)
    .enqueueUniquePeriodicWork(
      WORK_NAME,
      ExistingPeriodicWorkPolicy.UPDATE,
      request
    )
}

fun cancelUpdateXMLBatteryWidgetRequest(context: Context) {
  WorkManager
    .getInstance(context)
    .cancelUniqueWork(WORK_NAME)
}
```

We are either creating (`build()`) and enqueuing (`enqueueUniquePeriodicWork()`), or cancelling (`cancelUniqueWork()`) a *request*. As its name suggests, `PeriodicWorkRequestBuilder` allows us to define a work request that we want to be executed repeatedly. Please note, that the time between two runs must currently be at least 15 minutes (`MIN_PERIODIC_INTERVAL_MILLIS`).

This means, we get updates after half the time of what is possible using the appwidget configuration file (30 minutes). Please keep in mind, though, that the update won't necessarily appear *exactly* after 15 minutes. 

Here's how my updated example looks like. You can find the [source code](https://github.com/tkuenneth/battery_meter) on GitHub. The app contains two versions of Battery Meter, a Glance widget and a version based on `View`s. For now we will be focusing on the latter one. I'll turn to Glance in a later part of this series. 

![The Battery Meter widget XML version](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hhw7a4dsfzwau5g61ho4.png)

As you can see, the widget shows a date and a time. Why?

Recent Android versions limit what apps can do if they have not been in the foreground, that is, have been *actively used* for some time. This begs an important question: will the widget still be updated? 

The small banner shows when the worker was last executed. The widget picks up the value that is written into shared preferences inside `doWork()`.

### Power optimizations

To see how the widget behaves, let's force the system into idle mode (*Doze*) by running the following command:

```
adb shell dumpsys deviceidle force-idle
```

As the worker runs every 15 minutes we should keep the app in Doze mode for at least 30 minutes. The widget won't be updated. After this period, we can exit idle mode by running these commands:

```
adb shell dumpsys deviceidle unforce
adb shell dumpsys battery reset
```

The widget will be updated again. 

While Doze mode is active, the worker will not run every 15 minutes. It may run at greater intervals, though. You can read more about Doze mode [here](https://developer.android.com/training/monitoring-device-state/doze-standby#understand_doze). If the device is idle because it is lying on the desk with the screen turned off, *not* updating the widget is perfectly fine. After all, the user isn't looking at the screen and using the device.

There is, however, another (tongue in cheek) powerful power optimization feature called *App Standby*. Android checks several conditions to determine if an app is being actively used, for example

- Was it recently launched by the user?
- Does the app currently have a process in the foreground?
- Has the app created a notification that is visible to the user?
- Is the app an active device admin app?

Please refer to [Understanding App Standby](https://developer.android.com/training/monitoring-device-state/doze-standby#understand_app_standby) for further details.

Looking at the four bullet points above, none of them seem to apply to my sample, so it's very likely it will enter App Standby at some point. I believe this is a problem, because the user may be looking at a widget practically any time the home screen (launcher) is visible. To get an idea how the power optimizations will impact an app, please refer to [Power management restrictions](https://developer.android.com/topic/performance/power/power-details) and have a look at table section **App Standby Buckets**.

As mentioned in [App Standby Buckets](https://developer.android.com/about/versions/pie/power#buckets), 

> App Standby Buckets helps the system prioritize apps' requests for resources based on how recently and how frequently the apps are used. Based on the app usage patterns, each app is placed in one of five priority buckets. The system limits the device resources available to each app based on which bucket the app is in.

The five buckets are:

- *Active*
- *Working set*
- *Frequent*
- *Rare*
- *Never*

We can find out in which bucket an app currently is by invoking

```
adb shell am get-standby-bucket eu.thomaskuenneth.batterymeter
```

![A macOS Terminal window](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zpk0zy2x1zb8ljx0kuxf.png)

`10` means *Active*. Please refer to [STANDBY_BUCKET_ACTIVE](https://developer.android.com/reference/android/app/usage/UsageStatsManager#STANDBY_BUCKET_ACTIVE) and corresponding constants.

The documentation continues:

> The app was used very recently, currently in use or likely to be used very soon. Standby bucket values that are ≤ `STANDBY_BUCKET_ACTIVE` will not be throttled by the system while they are in this bucket.

If an app is in the *Working set* bucket, it runs often but is not currently active. For this bucket, job execution is Limited to 10 minutes every 2 hours. Also, the app can schedule 10 alarms per hour.

According to the documentation, we can invoke

```
adb shell am set-standby-bucket eu.thomaskuenneth.batterymeter rare
```

to put an app into the *Rare* bucket. However, during my experiments, issuing `adb shell am get-standby-bucket` immediately afterwords always returned `10`, wheres `STANDBY_BUCKET_RARE` is `40`.

Now, where does this leave us?

### Wrap up

Jetpack WorkManager is really easy to use. Scheduling and cancelling requests fits nicely with the `AppWidgetProvider` callbacks. Sadly, widgets are good candidates for App Standby if they don't have activities that are explicitly opened by the user. While during my tests *Battery Meter* was in the *Active* bucket, it is not obvious how long it stays this way.

So what do we do? Google notes that apps on the Doze allowlist are exempted from App Standby bucket-based restrictions. But that sounds like a *last option*. Are there other ones? Please stay tuned.