Hebrew version · English chapter index
Previous chapter · Next chapter
On the touch screen you can swipe right to the next chapter and left to the previous chapter.
In chapter 14, the button requested a one-time eligibility check. Now we will schedule the same unit of work in two periodic schedules: once every three hours when the battery is not low, and once every 30 minutes when the device is connected to the charger.
WorkManager is not an accurate alarm clock. 30 minutes means at least 30 minutes between runs. Android may defer work because of a constraint, Doze or system optimization.
What we will build
flowchart LR
A["MainActivity opens"] --> S["PusherWorkScheduler.schedule"]
S --> B["Check every 3 hours"]
S --> C["Check every 30 minutes"]
B --> D{"Battery not low?"}
C --> E{"Charging?"}
D -- "Yes" --> W["PusherEligibilityWorker"]
E -- "Yes" --> W
D -- "no" --> Q["The request remains pending"]
E -- "no" --> Q
Both requests refer to the same Worker class. The difference is when WorkManager is allowed to run it.
1. Separate Worker from WorkRequest
PusherEligibilityWorkerdefines what to do: read progress, check price, and maybe post a notification.PeriodicWorkRequestdefines when it is allowed to try: time interval and constraints.WorkManagersaves and schedules the request using system services.
According to the official documentation of periodic work, the interval is a minimum time and not an exact time, and the smallest periodic interval allowed is 15 minutes. Therefore 30 minutes is a valid value.
2. Create a scheduling class
In the Android window, under app > kotlin+java > com.example.collectcircles, create a Java Class named PusherWorkScheduler:
package com.example.collectcircles;
import android.content.Context;
import androidx.work.Constraints;
import androidx.work.ExistingPeriodicWorkPolicy;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
import java.util.concurrent.TimeUnit;
public final class PusherWorkScheduler {
private static final String BATTERY_WORK_NAME =
"pusher_check_battery";
private static final String CHARGING_WORK_NAME =
"pusher_check_charging";
private PusherWorkScheduler() {
}
public static void schedule(Context context) {
WorkManager workManager = WorkManager.getInstance(context);
PeriodicWorkRequest batteryRequest = createBatteryRequest();
PeriodicWorkRequest chargingRequest = createChargingRequest();
workManager.enqueueUniquePeriodicWork(
BATTERY_WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
batteryRequest
);
workManager.enqueueUniquePeriodicWork(
CHARGING_WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
chargingRequest
);
}
private static PeriodicWorkRequest createBatteryRequest() {
Constraints constraints = new Constraints.Builder()
.setRequiresBatteryNotLow(true)
.build();
return new PeriodicWorkRequest.Builder(
PusherEligibilityWorker.class,
3,
TimeUnit.HOURS
)
.setConstraints(constraints)
.build();
}
private static PeriodicWorkRequest createChargingRequest() {
Constraints constraints = new Constraints.Builder()
.setRequiresCharging(true)
.build();
return new PeriodicWorkRequest.Builder(
PusherEligibilityWorker.class,
30,
TimeUnit.MINUTES
)
.setConstraints(constraints)
.build();
}
}
This class is neither an Activity, a Service, nor a Worker. It simply builds two requests and submits them to WorkManager.
3. Compare the two requests
Battery track
Constraints constraints = new Constraints.Builder()
.setRequiresBatteryNotLow(true)
.build();
return new PeriodicWorkRequest.Builder(
PusherEligibilityWorker.class,
3,
TimeUnit.HOURS
)
.setConstraints(constraints)
.build();
- Minimum interval: 3 hours
- Allowed when the battery is not low
- Does not require a charger
Charging track
Constraints constraints = new Constraints.Builder()
.setRequiresCharging(true)
.build();
return new PeriodicWorkRequest.Builder(
PusherEligibilityWorker.class,
30,
TimeUnit.MINUTES
)
.setConstraints(constraints)
.build();
- Minimum interval: 30 minutes
- Allowed only while charging
- Suitable for more frequent checks
If the device is charging and the battery is not low, both constraints may be true. This is expected: the two schedules are independent. Both use the same notification ID, so a new notification replaces the previous one.
In a later chapter, the timetable check will decide whether this is a suitable time for the message. For now, we are only testing the scheduling mechanism.
4. Why are unique names used?
schedule() will be called every time the Activity opens. Without a unique name, each opening would add two more identical schedules:
Normal enqueue — does not fit here
workManager.enqueue(batteryRequest);
First opening: one schedule
Second opening: two schedules
Tenth opening: ten schedules
Unique enqueue
workManager.enqueueUniquePeriodicWork(
BATTERY_WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
batteryRequest
);
Every opening refers to the same schedule named pusher_check_battery.
KEEP says: if there is already an active periodic job with this name, keep it and do not replace it with a new request. This way, opening the screen does not reset the interval and does not create duplicates.
The official documentation for managing unique work recommends unique names to avoid enqueuing the same task more than once.
5. Schedule work when the process starts
In MainActivity.onCreate, after loading GameProgress, add:
gameProgress = GameProgress.load(this);
+PusherWorkScheduler.schedule(this);
bestTimeMillis = preferences.getLong(BEST_TIME_KEY, NO_BEST_TIME);
There is no need to wait for a result. schedule() submits the requests to WorkManager, and the Activity continues its initialization.
The life of the Activity
onCreate
onStart
onResume
...
onStop
onDestroy
The screen can close minutes after the work is scheduled.
Work lifetime
ENQUEUED
↓ when the interval and constraint allow
RUNNING
↓ Result.success()
ENQUEUED for the next cycle
The work is saved even after the Activity is destroyed.
6. What happens behind the scenes?
- The UI thread calls
schedule()andenqueueUniquePeriodicWork(). - WorkManager saves the job description in its internal database. The call does not immediately activate
doWork()and does not block until the work runs. - Android chooses an appropriate time according to the interval, battery status, charger and system optimizations.
- If the application process does not exist, the system can create it for WorkManager to run the Worker.
doWork()runs on a background thread provided by WorkManager.- For periodic work,
Result.success()ends the current cycle; the request returns to the waiting state for the next cycle.
There is no thread here that lives for three hours and there is no loop that does sleep. Keeping such a thread would waste resources and break when Android kills the application process. WorkManager saves a small description of a job and asks the system to run it later.
And what about Service?
We do not write Service. WorkManager uses Android’s proper scheduling mechanisms behind a single API. Our code remains a short Worker, designed to finish quickly. Long-running work or work requiring exact timing needs a different solution; checking a few values and posting a notification is a good fit for WorkManager.
7. Can two Workers run concurrently?
Yes. Theoretically a three-hour cycle and a 30-minute cycle can be eligible at the same time, and WorkManager is allowed to run more than one job on its background executor.
At this stage PusherEligibilityWorker only:
- Creates a new snapshot of
GameProgressfromSharedPreferences; - Reads balance and price;
- Publishes a notification.
It does not reduce the balance, buy a Pusher, or settle offline progress. Therefore, two concurrent reads cannot lose an economy update. Both may request a notification, but the same notification ID causes the second notification to replace the first.
When we want the Worker to also write offline progress, separate reading and writing will no longer be enough. We will have to protect the read-modify-write operation as a single unit. We will not add writing in the background until we explain and implement this coordination.
8. What promises does WorkManager not make?
- It does not guarantee to run at exactly
22:00,22:30and so on. - A constraint that is no longer met can delay execution.
- Doze and manufacturer optimizations can delay work further.
- Explicit force-stop of the application stops jobs until the user starts the application again.
- Periodic work is not for animation, screen timer or game that runs 60 times per second.
It is suitable for work that can be postponed and must survive leaving the screen, such as a short eligibility check.
9. Testing without waiting three hours
- Run the application once for
schedule()to be called. - In the Android Studio menu, select View → Tool Windows → App Inspection.
- In the window that opens, select the Background Task Inspector tab, then select the emulator and the application process
com.example.collectcirclesat the top. - Look for two lines of
PusherEligibilityWorker. In the next version of the chapter we will add tags that will explicitly displaypusher_check_batteryandpusher_check_charging. - Check that one has an interval of 3 hours and Battery Not Low, and the other 30 minutes and Charging.
- Close and open the Activity again. The number of schedules should remain two and not increase.
- To check the immediate notification, continue to use the Check button from chapter 14; there is no need to wait for a real cycle for each code change.
If the tab is empty, make sure the application is still running on an emulator with API 26 or higher and that you have selected the correct application process. WorkManager displays the Worker class name here, not necessarily the unique name given to the enqueue.
Checking the exact run time on a device is unreliable, because deferral is expected behavior. In this chapter, verify that the schedules, intervals, and constraints were saved correctly.
Run once:
.\gradlew.bat testDebugUnitTest assembleDebug
The game now has two persistent background schedules that are not duplicated on every launch. In the next chapter we will save the timetable as JSON and allow the Worker to interrupt only during computer science classes.
continue to chapter 16: timetable in JSON
Gemini tutor
Open Gem: CollectCircles Background Trainer to get hints, diagnostic questions and explanations appropriate to the stage you are on.