Hebrew version · English chapter index
Previous chapter · Next chapter
On a touch screen, swipe right for the next chapter and left for the previous chapter.
Each Pusher collects 0.20 circles per second, and circles spawn slightly faster than Pushers can collect them. We therefore do not need to draw the game while the app is closed. Save the time when we leave, then calculate how much work could have been completed when the app next opens.
In this chapter, no code runs in the background. No Thread, Service, or WorkManager runs while the app is closed. “Offline progress” means a calculation performed the next time the Activity reaches onStart. Keeping the game running in the background while it is closed would be a significant design error.
Production formula
The automatic collection rate is limited by the slower side:
offline rate = min(spawn rate, all pushers' rate)
For example, two Pushers are able to collect:
2 × 0.20 = 0.40 circles/second
Their spawn rate is 0.44, so the effective rate is min(0.44, 0.40) = 0.40.
1. Keep the rates in one class
In the Android window, under app > kotlin+java > com.example.collectcircles, create a Java Class named ProductionRates:
package com.example.collectcircles;
public final class ProductionRates {
private static final double BASE_SPAWN_RATE = 0.20;
private static final double SPAWN_RATE_PER_PUSHER =
Pusher.CIRCLES_PER_SECOND * 1.10;
/** Prevents creating objects from this utility class. */
private ProductionRates() {
}
/**
* Calculates how many circles the game can create per second.
*
* @param pusherCount number of owned Pushers
* @return circle spawn rate per second
*/
public static double spawnRate(int pusherCount) {
int safeCount = Math.max(0, pusherCount);
return Math.max(BASE_SPAWN_RATE, safeCount * SPAWN_RATE_PER_PUSHER);
}
/**
* Calculates how many circles all Pushers can collect per second.
*
* @param pusherCount number of owned Pushers
* @return combined Pusher collection rate per second
*/
public static double pusherRate(int pusherCount) {
return Math.max(0, pusherCount) * Pusher.CIRCLES_PER_SECOND;
}
/**
* Returns the slower of the spawn and collection rates.
*
* @param pusherCount number of owned Pushers
* @return effective offline collection rate per second
*/
public static double offlineRate(int pusherCount) {
return Math.min(spawnRate(pusherCount), pusherRate(pusherCount));
}
}
The animated game and offline calculation now use the same numbers. If we change a rate later, the screen and calculation will not follow different rules.
In Game, remove BASE_SPAWN_RATE, SPAWN_RATE_PER_PUSHER, and getSpawnRate(). Replace:
- spawnProgress += elapsedSeconds * getSpawnRate();
+ spawnProgress += elapsedSeconds * ProductionRates.spawnRate(pushers.size());
2. Create a calculator that does not depend on Android
In the same package, create a Java Class named OfflineProgressCalculator:
package com.example.collectcircles;
public final class OfflineProgressCalculator {
/** Prevents creating objects from this utility class. */
private OfflineProgressCalculator() {
}
/**
* Calculates whole circles and the remaining fraction for an offline period.
*
* @param elapsedMillis elapsed wall-clock time, in milliseconds
* @param pusherCount number of owned Pushers
* @param previousFraction unfinished progress from the previous calculation
* @return calculated whole circles and the fraction to keep
*/
public static Result calculate(long elapsedMillis, int pusherCount,
double previousFraction) {
long safeElapsedMillis = Math.max(0, elapsedMillis);
double safeFraction = Math.max(0, Math.min(previousFraction, 0.999999));
double produced = safeFraction
+ safeElapsedMillis / 1000.0
* ProductionRates.offlineRate(pusherCount);
if (produced >= Long.MAX_VALUE) {
return new Result(Long.MAX_VALUE, 0);
}
long wholeCircles = (long) Math.floor(produced);
return new Result(wholeCircles, produced - wholeCircles);
}
public static final class Result {
private final long wholeCircles;
private final double fraction;
/**
* Creates an immutable calculation result.
*
* @param wholeCircles number of complete circles produced
* @param fraction unfinished part of the next circle
*/
private Result(long wholeCircles, double fraction) {
this.wholeCircles = wholeCircles;
this.fraction = fraction;
}
/** @return number of complete circles produced */
public long getWholeCircles() {
return wholeCircles;
}
/** @return unfinished part of the next circle */
public double getFraction() {
return fraction;
}
}
}
What is the Result class?
calculate() needs to return two values together: a whole-circle count and a fraction to keep for the next calculation. Instead of an array with two unnamed elements, Result groups them in meaningful fields, accessed through getWholeCircles() and getFraction().
As with the Notifications class in chapter 5, we declare these classes as final. A class declared this way cannot be extended to create a subclass. Both ProductionRates and OfflineProgressCalculator are final, because they are utility classes with static methods, not intended for inheritance. For Result, final means this is a small, closed result type that we do not intend to extend.
The three parts of the declaration play different roles:
static— The nestedResulttype belongs to theOfflineProgressCalculatorclass rather than to a particular instance. Use its full name,OfflineProgressCalculator.Result.final— You cannot declareclass SpecialResult extends Result.- The fields are also
final— each value is set once in the constructor and cannot change afterward. The result object is therefore immutable once created.
The private constructor leaves result creation to calculate(). Other code can read values through getters, but cannot construct a result that violates the calculator’s rules.
This is a pure function: the same inputs always produce the same result, without accessing a screen, clock, file, or SharedPreferences. You can therefore test it with an ordinary unit test and later use it from WorkManager too.
Why keep the fraction?
A Pusher working for 12 seconds produces:
12 × 0.20 = 2.4 circles
Only two whole circles can be added now. Keep 0.4 and add it to the next offline calculation. Without this fraction, repeated short absences would repeatedly lose progress.
3. Add offline state to GameProgress
Add keys and fields:
private static final String LAST_PROGRESS_UPDATE_KEY = "last_progress_update";
private static final String FRACTIONAL_PROGRESS_KEY = "fractional_progress";
private long lastProgressUpdateMillis;
private double fractionalProgress;
Load them at the end of the constructor:
lastProgressUpdateMillis = preferences.getLong(
LAST_PROGRESS_UPDATE_KEY,
System.currentTimeMillis()
);
fractionalProgress = Double.longBitsToDouble(
preferences.getLong(
FRACTIONAL_PROGRESS_KEY,
Double.doubleToRawLongBits(0)
)
);
SharedPreferences does not store double directly. Store its bits in a long, then reconstruct the value the same way.
4. Add several circles in one operation
Update the existing collection method. On the left, it can add only one circle; on the right, that small method delegates to a more general one:
Before
/**
* Records one collected circle in both progress totals.
*/
public void recordCollectedCircle() {
- if (circlesBalance < Long.MAX_VALUE) {
- circlesBalance++;
- }
- if (lifetimeCircles < Long.MAX_VALUE) {
- lifetimeCircles++;
- }
-
- preferences.edit()
- .putLong(CIRCLES_BALANCE_KEY, circlesBalance)
- .putLong(LIFETIME_CIRCLES_KEY, lifetimeCircles)
- .apply();
}
After
/**
* Records one collected circle in both progress totals.
*/
public void recordCollectedCircle() {
+ recordCollectedCircles(1);
}
+/**
+ * Adds several collected circles without overflowing either total.
+ *
+ * @param count number of circles to add
+ */
+public void recordCollectedCircles(long count) {
+ if (count <= 0) {
+ return;
+ }
+
+ circlesBalance = addWithSaturation(circlesBalance, count);
+ lifetimeCircles = addWithSaturation(lifetimeCircles, count);
+ preferences.edit()
+ .putLong(CIRCLES_BALANCE_KEY, circlesBalance)
+ .putLong(LIFETIME_CIRCLES_KEY, lifetimeCircles)
+ .apply();
+}
+
+/**
+ * Adds two non-negative values and stops at the largest long value.
+ *
+ * @param current current stored value
+ * @param addition non-negative amount to add
+ * @return the sum, or Long.MAX_VALUE if the sum would overflow
+ */
+private long addWithSaturation(long current, long addition) {
+ if (Long.MAX_VALUE - current < addition) {
+ return Long.MAX_VALUE;
+ }
+ return current + addition;
+}
A single collection and hundreds of offline circles now follow the same rule, without calling apply() hundreds of times in a loop or overflowing into a negative number.
5. Settle progress for the elapsed time
Add to GameProgress:
/**
* Applies progress earned since the last saved wall-clock timestamp.
*
* @param nowMillis current wall-clock time, in milliseconds
* @return number of whole circles added to the progress totals
*/
public long settleOfflineProgress(long nowMillis) {
long elapsedMillis = Math.max(0, nowMillis - lastProgressUpdateMillis);
lastProgressUpdateMillis = nowMillis;
long collectedCircles = 0;
if (autonomousMode && pusherCount > 0) {
OfflineProgressCalculator.Result result =
OfflineProgressCalculator.calculate(
elapsedMillis,
pusherCount,
fractionalProgress
);
collectedCircles = result.getWholeCircles();
fractionalProgress = result.getFraction();
circlesBalance = addWithSaturation(circlesBalance, collectedCircles);
lifetimeCircles = addWithSaturation(lifetimeCircles, collectedCircles);
}
preferences.edit()
.putLong(CIRCLES_BALANCE_KEY, circlesBalance)
.putLong(LIFETIME_CIRCLES_KEY, lifetimeCircles)
.putLong(LAST_PROGRESS_UPDATE_KEY, lastProgressUpdateMillis)
.putLong(
FRACTIONAL_PROGRESS_KEY,
Double.doubleToRawLongBits(fractionalProgress)
)
.apply();
return collectedCircles;
}
/**
* Saves the moment from which the next offline period will be measured.
*
* @param nowMillis current wall-clock time, in milliseconds
*/
public void markBackgroundStarted(long nowMillis) {
lastProgressUpdateMillis = nowMillis;
preferences.edit()
.putLong(LAST_PROGRESS_UPDATE_KEY, nowMillis)
.apply();
}
If Auto is off or no Pushers are owned, update the timestamp without accumulating work. Treat negative elapsed time—for example, after a manual device-clock change—as zero.
6. Which clock should we use?
For animation, we used SystemClock.elapsedRealtime(). It is monotonic and suitable for measuring frames, but resets after reboot and is not a date that can be compared across sessions.
For offline progress, use System.currentTimeMillis(): it provides an absolute timestamp that remains meaningful across shutdown and reboot. The tradeoff is that the user or network can change the wall clock. In this teaching game, we guard against negative elapsed time without trying to prevent cheating by advancing the clock.
The choice of the clock is part of the model: monotonic time for measuring duration within one run; wall clock for comparing runs.
7. Connect onStart and onStop
In MainActivity add a field:
private boolean enteredBackground;
And in app > res > values > strings.xml:
<string name="offline_progress_message">While you were away, your pushers collected %1$d circles.</string>
onStart before
if (gameProgress.isAutonomousMode()) {
binding.gameBoard.resumeAutonomousGame();
} else if (gameRunning) {
timerUpdate.run();
}
onStart after
+long offlineCircles = gameProgress.settleOfflineProgress(
+ System.currentTimeMillis()
+);
+showProgress();
+showOfflineProgress(offlineCircles);
+
if (gameProgress.isAutonomousMode()) {
+ if (enteredBackground) {
+ startFreshAutonomousBoard();
+ } else {
binding.gameBoard.resumeAutonomousGame();
+ }
} else if (gameRunning) {
timerUpdate.run();
}
+enteredBackground = false;
Add:
/**
* Shows a message when at least one whole offline circle was collected.
*
* @param collectedCircles number of whole circles collected offline
*/
private void showOfflineProgress(long collectedCircles) {
if (collectedCircles <= 0) {
return;
}
Toast.makeText(
this,
getString(R.string.offline_progress_message, collectedCircles),
Toast.LENGTH_LONG
).show();
}
/** Rebuilds the autonomous board after the View has been measured. */
private void startFreshAutonomousBoard() {
binding.gameBoard.post(() -> {
if (gameProgress.isAutonomousMode()) {
binding.gameBoard.startAutonomousGame(gameProgress.getPusherCount());
}
});
}
And in onStop, after stopping the drawing and before super.onStop():
gameProgress.markBackgroundStarted(System.currentTimeMillis());
enteredBackground = true;
Why build a new board?
The screen’s objects stopped when the app left the foreground, possibly with a Pusher midway through a task. The offline calculation has already credited the elapsed time, so continuing that old task would count it again. A fresh board with three circles resets the animation while the fractional progress is stored separately, preventing the same time from being counted twice.
The visible board is still limited to 12 circles. Offline progress represents a continuous flow of spawning and collection rather than placing hundreds of circles on one Canvas.
8. What is really synchronous and what is asynchronous here?
onStart,settleOfflineProgress,showProgressand the presentation of the toast run one after the other on the UI thread.- The calculation is only a few arithmetic operations and therefore does not justify a background thread.
startFreshAutonomousBoardusespostbecause the View must be measured before creating coordinates.postputs the Runnable on the UI queue; it does not create a thread.SharedPreferences.apply()immediately updates the in-memory copy and schedules a disk write without waiting for I/O.- There is no
Service, and we do not keep a process alive after the app closes.
Android calls onStop during a normal transition to the background, but cannot guarantee a final callback if the process is suddenly killed or crashes. This is a limitation of this simple stage. Later chapters add scheduled execution opportunities with WorkManager, which still does not provide exact timing or guarantee that a process stays alive.
9. Focused testing
Add:
/** Verifies rate limiting, whole-circle output, and saved fractions. */
@Test
public void offlineProgressUsesTheSlowerRateAndKeepsFractions() {
OfflineProgressCalculator.Result onePusher =
OfflineProgressCalculator.calculate(12_000, 1, 0);
assertEquals(2, onePusher.getWholeCircles());
assertEquals(0.4, onePusher.getFraction(), 0.0001);
OfflineProgressCalculator.Result twoPushers =
OfflineProgressCalculator.calculate(10_000, 2, 0);
assertEquals(4, twoPushers.getWholeCircles());
OfflineProgressCalculator.Result noPushers =
OfflineProgressCalculator.calculate(60_000, 0, 0);
assertEquals(0, noPushers.getWholeCircles());
}
Run once:
.\gradlew.bat testDebugUnitTest assembleDebug
10. Manual inspection
The number of Pushers can only increase. That’s why the tests are performed in order, before buying the next Pusher:
- Before owning a Pusher, turn Auto on, background the app, and return. There should be no offline progress.
- Buy the first Pusher. Background the app for about 12 seconds, then return. About two circles should be added and a Toast should appear.
- With the same Pusher, repeat some short exits. Make sure the fractions add up and eventually become a complete circle.
- Only after you have finished the single worker tests, buy a second Pusher and check that the rate is about double.
- With both Pushers still owned, turn Auto off, background the app, and return. This time there should be no offline progress.
- Restart Auto and make sure the board is rebuilt and doesn’t show more than 12 circles.
- Close and reopen the app. Check that the balance, Lifetime, and fractional progress persist consistently.
In a unit test, passing 1, 2, or 0 is fine because these calls do not modify a real user account. Each call tests the calculation with an independent input.
The game now settles progress when the user returns without leaving code running in the background. In the next chapter, we will add WorkManager so Android can run a periodic Worker even while the Activity is closed.
Continue to Chapter 14: First eligibility check with WorkManager
Gemini tutor
Open Gem: CollectCircles Background Trainer to get hints, diagnostic questions and explanations appropriate to the stage you are on.