Updating widgets with Jetpack WorkManager

tkuenneth

Thomas Künneth

Posted on May 26, 2023

Updating widgets with Jetpack WorkManager

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 for persistent work.

Jetpack WorkManager and appwidgets

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



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


Enter fullscreen mode Exit fullscreen mode

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



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()
  }
}


Enter fullscreen mode Exit fullscreen mode

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


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

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


Enter fullscreen mode Exit fullscreen mode

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



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)
}


Enter fullscreen mode Exit fullscreen mode

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 on GitHub. The app contains two versions of Battery Meter, a Glance widget and a version based on Views. 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

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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. 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 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 and have a look at table section App Standby Buckets.

As mentioned in App Standby 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


Enter fullscreen mode Exit fullscreen mode

A macOS Terminal window

10 means Active. Please refer to 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


Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
tkuenneth
Thomas Künneth

Posted on May 26, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related