
Welcome to the second part ofUpdating 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 inpersistent work, which means, that the things to be done remain scheduled through app restarts and system reboots. Google recommendsJetpack 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")
The next step is to define aWorker
. The actual work takes place insidedoWork()
.
privateconstvalWORK_NAME="update-battery-meter-widget"classBatteryMeterWorker(privatevalcontext:Context,workerParams:WorkerParameters,):Worker(context,workerParams){overridefundoWork():Result{context.getSharedPreferences(PREFS_NAME,Context.MODE_PRIVATE).edit().putLong(LAST_UPDATED,System.currentTimeMillis()).apply()context.updateXMLBatteryMeterWidget()returnResult.success()}}
The widget is updated by callingcontext.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 aResult
. I am taking it easy by always usingResult.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.
TheAppWidgetProvider
class offers two related methods we can override:
onEnabled()
is called when an appwidget is instantiatedonDisabled()
will be invoked when the last widget instance is deleted
overridefunonEnabled(context:Context){super.onEnabled(context)enqueueUpdateXMLBatteryMeterWidgetRequest(context)}overridefunonDisabled(context:Context){super.onDisabled(context)cancelUpdateXMLBatteryWidgetRequest(context)}
Here is howenqueueUpdateXMLBatteryMeterWidgetRequest()
andcancelUpdateXMLBatteryWidgetRequest()
are implemented:
funenqueueUpdateXMLBatteryMeterWidgetRequest(context:Context){valrequest=PeriodicWorkRequestBuilder<BatteryMeterWorker>(MIN_PERIODIC_INTERVAL_MILLIS,TimeUnit.MILLISECONDS).build()WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME,ExistingPeriodicWorkPolicy.UPDATE,request)}funcancelUpdateXMLBatteryWidgetRequest(context:Context){WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)}
We are either creating (build()
) and enqueuing (enqueueUniquePeriodicWork()
), or cancelling (cancelUniqueWork()
) arequest. 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 appearexactly after 15 minutes.
Here's how my updated example looks like. You can find thesource code on GitHub. The app contains two versions of Battery Meter, a Glance widget and a version based onView
s. For now we will be focusing on the latter one. I'll turn to Glance in a later part of this series.
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 beenactively 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 insidedoWork()
.
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 unforceadb 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 modehere. 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 calledApp 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 toUnderstanding 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 toPower management restrictions and have a look at table sectionApp Standby Buckets.
As mentioned inApp 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
10
meansActive. Please refer toSTANDBY_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 theWorking 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 theRare bucket. However, during my experiments, issuingadb shell am get-standby-bucket
immediately afterwords always returned10
, wheresSTANDBY_BUCKET_RARE
is40
.
Now, where does this leave us?
Wrap up
Jetpack WorkManager is really easy to use. Scheduling and cancelling requests fits nicely with theAppWidgetProvider
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 testsBattery Meter was in theActive 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 alast option. Are there other ones? Please stay tuned.
Top comments(1)
For further actions, you may consider blocking this person and/orreporting abuse