Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to register a periodic work request with WorkManger system-wide once (i.e. after boot or installation)

I need a component in my Android app that is best being described as a watchdog, i.e. a function that is executed every 30min +/- 5min and asserts that a certain condition is still met. The watchdog must also be executed after the device has been rebooted without the user having explicitly opened the app thereafter. The same must hold for the app's installation. The watchdog must be scheduled for periodic execution even if the app has not been explicitly opened after installation.

I understand that using WorkManager is the best or "modern" way. Without WorkManager I have to write individual code for different API levels, i.e. use BroadcastReceiver for devices with API level <27 and JobScheduler for higher API levels. WorkManager should abstract those differences away.

But I do not understand where to call WorkManager.getInstance().enqueue( myWatchdogRequest );. Using any of the main activitiy's callbacks (i.e. onCreate and similar) is not the right place, because I must not rely on the activity ever being created.

I expected that besides queuing jobs programmatically there should also be a way how to declare those jobs in the manifest and thereby announce them to the system (similar to the old-fashioned BroadcastReceiver). Actually, I would have the same problem with JobScheduler, if I decided to use that approach.

Where do I enqueue the WorkRequest “globally”?

like image 330
user2690527 Avatar asked Oct 29 '18 10:10

user2690527


1 Answers

In the first part, I simply present the solution as snippets of code without much explanation. In the second part, I elaborate on the solution, explain why it is not an exact solution, but the best possible one and point out some errors in Google documentation which led me to the question in the first place.

The Solution

The actual worker that runs every 30 minutes with 10 minutes flexibility:

public class WatchDogWorker extends Worker {
  private static final String uniqueWorkName = "my.package.name.watch_dog_worker";
  private static final long repeatIntervalMin = 30;
  private static final long flexIntervalMin = 10;

  public WatchDogWorker( @NonNull Context context, @NonNull WorkerParameters params) {
    super( context, params );
  }

  private static PeriodicWorkRequest getOwnWorkRequest() {
    return new PeriodicWorkRequest.Builder(
      WatchDogWorker.class, repeatIntervalMin, TimeUnit.MINUTES, flexIntervalMin, TimeUnit.MINUTES
    ).build();
  }

  public static void enqueueSelf() {
    WorkManager.getInstance().enqueueUniquePeriodicWork( uniqueWorkName, ExistingPeriodicWorkPolicy.KEEP, getOwnWorkRequest() );
  }

  public Worker.Result doWork() {
    // Put the actual code of the watchdog that needs to be run every 30mins here
    return Result.SUCCESS;
  }
}

Note: a) As this worker needs to be registered for scheduling at two different points of execution (see below) in the same way, I decided that WatchDogWorker should "know" how to enqueue itself. Therefore it provides the static methods getOwnWorkRequest and enqueueSelf. b) The private, static constants are only needed once, but using constants avoids magic numbers in the code and gives a semantic meaning to the numbers.

To enqueue the WatchDogWorker for scheduling after the device has booted, the following broadcast receiver is required:

public class BootCompleteReceiver extends BroadcastReceiver {
  public void onReceive( Context context, Intent intent ) {
    if( intent.getAction() == null || !intent.getAction().equals( "android.intent.action.BOOT_COMPLETED" ) ) return;
    WatchDogWorker.enqueueSelf();
  }
}

Essentially, the whole magic is a one-liner and calls WatchDogWorker.enqueueSelf. The broadcast receiver is supposed to be called once after boot. To this end the broadcast receiver must be declared in the AndroidManifest.xml such that the Android system knows about the receiver and calls it upon boot:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  package="...">

  ...

  <application>
    ...
    <receiver
      android:name=".BootCompleteReceiver"
      android:enabled="true"
      android:exported="true">
      <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED"/>
      </intent-filter>
    </receiver>
  </application>
</manifest>

However, this is not sufficient. If the user has freshly installed the app, we do not want to wait for the next reboot until the watchdog is scheduled for the first time, but we want it to be scheduled as soon as possible. Hence, WatchDogWorker is also enqueued, if the main activity is created.

public class MainActivity extends AppCompatActivity {
  ...
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    ...

    // Schedule WatchDogWorker (after a fresh install we must not rely on the BootCompleteReceiver)
    WatchDogWorker.enqueueSelf();
  }
}

Note: This solution might invoke the method WatchDogWorker.enqueueSelf multiple times. However, enqueueSelf internally calls enqueueUniquePeriodicWork with ExistingPeriodicWorkPolicy.KEEP. Hence, subsequent calls to enqueueSelf are a no-op and do no harm.

Caveat: The presented solution is only a 95%-solution. If the user never starts the app after installation, i.e. the activity is never created, the WatchDogWorker is never enqueued and does never run. Even, if the device is eventually rebooted at some future point (but the app has never been started), the "boot complete" intent is never received and the WatchDogWorker is not enqueued neither. There is no work-around for this situation. (See next chapter.)

Additional background information

The first problem that led me to the question was how to enqueue the worker, if the device has been rebooted without relying on the activity to be created. I did know about broadcast receivers and especially about the BOOT_COMPLETED-intent. But according to official Android documentation nearly all broadcast receivers have been radically disabled beginning with Android 8. This measurement was part of Google's attempt to improve the power management. In the past, broadcast receivers have allegedly been abused by many less-skilled developers to do insane things that should have been better done in some other way. (Trivial example: Misuse the AlarmManager and the corresponding broadcast receiver to wake up your app every 500ms, simply to check if there are updates available on your server.) Google's countermeasure was to simply cut-off those broadcast receivers. More precisely, a quote from the docs:

Beginning with Android 8.0 [...], the system imposes [...] restrictions on manifest-declared receivers. [...] you cannot use the manifest to declare a receiver for most implicit broadcasts (broadcasts that don't target your app specifically). You can still use a context-registered receiver when the user is actively using your app.

Two aspects are important: The restriction applies to intents that are implicit. Unfortunately, the BOOT_COMPLETED intent is an implicit intent according to docs. Secondly, this restriction can be overcome, but only programmatically or in other words through some executed code of your activity. Unfortunately again, this is not a workaround if the actual goal is not to rely on the activity being started by the user.

This was the point where I thought I was lost. However, there are some exceptions from the rule above and BOOT_COMPLETED belongs to this exceptions. Surpringly, the correct documentation page is called "Implicit Broadcast Exceptions" and even more surpringly is not very easy to find. Anyway, it says

ACTION_LOCKED_BOOT_COMPLETED, ACTION_BOOT_COMPLETED

Exempted because these broadcasts are only sent only once, at first boot, and many apps need to receive this broadcast to schedule jobs, alarms, and so forth.

This is exactly what is needed here and has been noticed by Google. To sum up: Yes, most implicit broadcast receivers have been abandoned, but not all and BOOT_COMPLETED is one of them. It still works and (hopefully) will work in the future.

The second problem is still open: If the user never reboots the device and never starts the app after installation at least once, the WatchDogServer is never enqueued. (This are the missing 5% of my solution to the question.) There is a ACTION_PACKAGE_ADDED-intent, but it does not help here, because the particular app that has been added never receives its "own" intent.

Anyway, the aforementioned drawback cannot be overcome and it is part of Google anti-malware campaign. (Unfortunately, I lost the link to the reference.) It is a pragmatic solution to hinder malware to silently establish background tasks. After a package has been installed it remains in some kind of "semi-installed" state. (It is called "paused" by Google, but do not confuse it with the paused-state of an activity. Here, this refers to the the state of the whole package.) The package remains in this state until the user has manually started the main activity with the android.intent.action.MAIN-intent from the launcher at least once. As long as the package is in the "paused" state, it does not receive any broadcast intents neither. In this particular case, the BOOT_COMPLETED-intent is not received on the next boot. To sum up: You cannot write an app that only consists of background tasks even if this is the whole purpose of your app. Your app requires an activity that needs to be shown to the user at least once. Otherwise nothing will ever run at all. Coincidentally, due to legal reasons most apps in most countries requires some kind of legal note or data policy anyway, so you can use the activity to statically show that. In the app's description in the Playstore ask the user to start the app (and maybe even read your text) in order to complete the installation.

like image 50
user2690527 Avatar answered Sep 21 '22 18:09

user2690527