An app timer on Android sounds simple: pick a number of minutes, wait, then interrupt. In practice three clocks have to agree: the one the user sees, the one that survives your process being killed, and the one that fires on time while they're still in the app.
We're building ReClaim, a minimalist Android launcher with intentional-use reminders. When you open a reminded app, ReClaim asks how long you mean to stay. When that commitment runs out, it either shows a "time's up" screen or sends you Home. This post is the plumbing behind that timer: why it skips exact alarms, how an in-process countdown races AlarmManager, and how the ongoing notification shows a live countdown without us re-posting it every second.
Stack: Kotlin, Hilt, Room, DataStore, coroutines, minSdk 26, targetSdk 36. Snippets are trimmed from the real code; // ... marks cuts.
1. One commitment, two timers
Exact alarms (setExact, setExactAndAllowWhileIdle) are the obvious tool, but apps targeting Android 12+ need the SCHEDULE_EXACT_ALARM permission for them (unless they're exempt from battery restrictions), and the docs reserve them for user-facing features with a strong need for exact timing, like an alarm clock. ReClaim schedules an inexact wake-up that's still allowed to run in Doze:
suspend fun schedule(packageName: String, appLabel: String, minutes: Int) {
if (minutes <= 0) return
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
val pendingIntent = alarmPendingIntent(packageName, appLabel, minutes)
// Inexact-but-idle-friendly: no special exact-alarm permission required. The OS is free to
// defer this by minutes (observed on-device), so it's a reliability backstop, not the primary
// enforcement — see BlockAccessibilityService's precise in-process countdown for that.
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
// So the prompt doesn't ask again — from any trigger path — while this commitment is live.
sessionStore.startSession(packageName, triggerAt, minutes, appLabel)
// The visible, always-checkable answer to "is my timer still running?" — survives the
// target app (or ReClaim itself) being backgrounded, closed from Recents, or killed.
ReminderTimerNotification.show(context, packageName, appLabel, triggerAt)
}
Per the AlarmManager reference, setAndAllowWhileIdle always allows batching. Android won't dispatch these alarms more than about once a minute under normal operation, and in low-power idle that gap "may be significantly longer, such as 15 minutes." That's fine for a backstop that has to survive process death. It's not fine as the moment the user feels.
So the precise path lives in the accessibility service ReClaim already uses to watch which app is in front. While that service is alive, a coroutine waits out the remaining wall-clock time and calls the same expiry function the alarm uses:
private suspend fun scheduleReminderCloseJob(pkg: String, activeUntil: Long) {
reminderCloseJobs.remove(pkg)?.cancel()
val remaining = activeUntil - System.currentTimeMillis()
if (remaining <= 0) return
reminderCloseJobs[pkg] = scope.launch {
// No safety buffer here on purpose — the countdown notification's chronometer keeps
// ticking (into visibly-wrong negative time) for as long as this takes to fire.
delay(remaining)
// Re-verify against a fresh read — the session may have been extended, or already handled
// by the alarm winning the race — before doing anything visible.
val stillActive = reminderSessionStore.activeUntil(pkg)
if (stillActive != null && stillActive > System.currentTimeMillis()) return@launch
val label = reminderSessionStore.label(pkg) ?: labelOf(pkg)
val minutes = reminderSessionStore.minutes(pkg) ?: 0
reminderScheduler.fireExpiry(pkg, label, minutes)
}
}
One detail that's easy to get wrong: ReClaim's App Limit timer is cancelled when the app leaves the foreground, because limit usage only builds up while you're looking at the app. The reminder job is deliberately not cancelled. The field's comment calls a reminder "a wall-clock commitment that keeps counting down in the background, by design." Ten minutes means ten minutes, even if you hop over to Maps halfway through.
2. Persist the session, not just the alarm
The "how long?" prompt mustn't ask again every time Recents brings the app back. The end time, the minutes and the label go into DataStore, keyed by package name:
/**
* Tracks, per app, when its current "how long" commitment runs out — so the reminder prompt only
* asks again once that time has actually elapsed, no matter how the app was reopened (ReClaim's own
* UI, Recent Apps, a notification, another launcher, …). Kept in DataStore rather than memory so it
* survives the accessibility service's process being killed mid-session.
*/
@Singleton
class ReminderSessionStore @Inject constructor(
@ApplicationContext private val context: Context,
) {
// ...
suspend fun startSession(packageName: String, untilMillis: Long, minutes: Int, label: String) {
store.edit {
it[key(packageName)] = untilMillis
it[minutesKey(packageName)] = minutes
it[labelKey(packageName)] = label
}
}
}
When a reminded app comes to the front, the service either re-arms the countdown for whatever time is left, or shows the prompt:
private fun evaluateReminderIfNeeded(pkg: String) {
if (pkg !in reminderPackages) return
scope.launch {
val activeUntil = reminderSessionStore.activeUntil(pkg)
if (activeUntil != null && activeUntil > System.currentTimeMillis()) {
scheduleReminderCloseJob(pkg, activeUntil)
return@launch
}
val intent = ReminderPromptActivity.intent(this@BlockAccessibilityService, pkg)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
withContext(Dispatchers.Main) { runCatching { startActivity(intent) } }
}
}
Re-arming on every switch means the countdown always reflects the current commitment, including one just extended with "add time." Every confirm path calls schedule() again. The broadcast PendingIntent is built with request code packageName.hashCode() and FLAG_UPDATE_CURRENT, so a new schedule for the same app replaces its old alarm instead of adding a second one.
3. Let the OS draw the countdown
Users need a way to check the timer is still running. ReClaim posts an ongoing notification and lets the system render the clock:
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(label)
.setContentText("Reminder running — closes automatically when time's up")
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setUsesChronometer(true)
.setChronometerCountDown(true)
.setWhen(expiresAt)
.setShowWhen(true)
// Belt-and-suspenders against the chronometer ever visibly counting into negative time:
// the OS clears this on its own right at expiry even if nothing in-app gets a chance to
// run (process killed, service off) — see ReminderScheduler.fireExpiry for the normal path.
.setTimeoutAfter(expiresAt - System.currentTimeMillis())
.apply { if (contentIntent != null) setContentIntent(contentIntent) }
.build()
setUsesChronometer(true) shows the when field as an automatically updating timer, and setChronometerCountDown(true) (API 24+) makes it count down toward setWhen(expiresAt). Nothing in our process ticks. setTimeoutAfter (API 26+, which matches our minSdk) cancels the notification at the deadline even if ReClaim never gets to run again.
The notification goes on a low-importance channel created in Application.onCreate(), described as "Ongoing countdown while an App Reminder session is running, and a quiet note when it ends."
4. One expiry function, two callers
The alarm lands in a Hilt-injected receiver, which hands off immediately:
@AndroidEntryPoint
class ReminderReceiver : BroadcastReceiver() {
@Inject lateinit var reminderScheduler: ReminderScheduler
override fun onReceive(context: Context, intent: Intent) {
val packageName = intent.getStringExtra(EXTRA_PACKAGE) ?: return
val label = intent.getStringExtra(EXTRA_LABEL) ?: "your app"
val minutes = intent.getIntExtra(EXTRA_MINUTES, 0)
reminderScheduler.fireExpiry(packageName, label, minutes)
}
// ...
}
fireExpiry is where the race gets settled. Its KDoc says it's "safe to run twice in the rare case both fire close together":
fun fireExpiry(packageName: String, label: String, minutes: Int) {
cancelAlarm(packageName)
ReminderTimerNotification.cancel(context, packageName)
reminderRepository.notifyExpired(packageName, label, minutes)
appScope.launch {
val host = hostFromSiteKey(packageName)
val action = if (host != null) {
websiteReminderRepository.expireAction(host)
} else {
reminderRepository.expireAction(packageName)
}
if (action == ReminderExpireAction.CLOSE) return@launch
postExpiredNotification(packageName, label, minutes)
}
}
The order matters:
- Cancel the alarm first. If the coroutine won, the broadcast never arrives.
- Cancel the countdown notification.
-
Emit an
ExpiredReminderon aMutableSharedFlow(extraBufferCapacity = 4)so the accessibility service can react.tryEmitnever suspends, so the receiver path stays short. - Post a silent "Time's up on …" notification that opens the takeover screen, but only for the default Prompt action. For Close it's skipped on purpose. The comment explains that tapping it "would otherwise reopen the very 'add time?' prompt CLOSE exists to skip, through a back door."
The hostFromSiteKey branch exists because website reminders reuse this path under a site: key (see our website-blocking post).
5. Don't yank the user out of something else
Expiry doesn't always mean "takeover right now." The service collects reminderRepository.expired and checks what's in front:
if (expired.packageName == lastForegroundPackage) {
// Still on the reminded app right now — this is exactly the moment its
// configured action (takeover, or straight home) is expected to happen.
withContext(Dispatchers.Main) { runCatching { handleExpiredReminder(expired) } }
} else {
// The user already moved on to something else — a full-screen takeover now would
// interrupt unrelated work. Enforce it instead the moment they switch back into
// this app (see onAccessibilityEvent), so a minimized/backgrounded reminder app
// still can't just be silently resumed later with no consequence.
markPendingExpired(expired)
}
A deferred expiry gets handled the next time that package comes to the front, unless a new session is already active. That check came from a real bug. Reopening through ReClaim's own UI asks "how long?" without looking at the deferred queue, so the old expiry would fire right after the new commitment. In the code's words, that "showed up as 'how long?' and 'your time is up' appearing back to back."
The second trap was System UI. The notification shade, quick settings and volume panel briefly take window focus without the user leaving their app. Counting that as a switch meant a reminder that expired while the shade was open got deferred, and the takeover only showed up at the next real app switch, "well after its own 'time's up' notification." Now window events from com.android.systemui are ignored before lastForegroundPackage is updated.
6. Prompt or close
What happens at expiry is a per-app setting, stored as a nullable string in Room and resolved to a small enum. null means the default:
enum class ReminderExpireAction(val label: String, val description: String) {
PROMPT(
"Prompt",
"The reminder screen pops up instead of Home — leave, or add more time.",
),
CLOSE(
"Close",
"Closes the app and takes you straight to the Home screen.",
),
}
Only the accessibility service acts on it, because it's the one place that already knows what's in front:
private fun handleExpiredReminder(expired: ExpiredReminder) {
when (reminderExpireActions[expired.packageName] ?: ReminderExpireAction.PROMPT) {
ReminderExpireAction.PROMPT -> showExpiredTakeover(expired)
ReminderExpireAction.CLOSE -> closeToHome(expired.packageName)
}
}
"Close" doesn't kill anything, since Android gives third-party apps no API to force-stop another app. closeToHome sends a media pause key (KEYCODE_MEDIA_PAUSE, not the play/pause toggle, which could start playback), briefly arms a picture-in-picture dismiss for that one package, and starts the Home intent. The takeover path does the same pause and PiP step before showing its screen.
7. Trade-offs
- Late without the service. If the accessibility service isn't running, expiry depends on the inexact alarm alone and can arrive minutes late. That's the cost of not asking for exact-alarm access.
-
A brief negative countdown. The countdown has no safety buffer, so if it fires a little after
expiresAt, the chronometer can briefly show negative time. Cancelling infireExpiryplussetTimeoutAfterkeeps that window short. -
Two clocks, one decision.
delay()measures elapsed time, while the session's end is wall-clock millis. The close job resolves that by re-reading DataStore and comparing againstSystem.currentTimeMillis()before acting.
Wrapping up
- Use an inexact, Doze-friendly alarm as the backstop for process death, not as the clock the user feels.
- Race it with a precise in-process
delay()while your long-lived service is running, and make expiry idempotent: cancel the alarm first. - Persist the commitment so Recents and notifications don't re-prompt mid-session.
- Let
NotificationCompat's chronometer draw the countdown. - Defer the takeover when the user is somewhere else, and don't count System UI as an app switch.
For the product side of timers versus blocks versus limits, we wrote up app limits vs. blocking vs. reminders. ReClaim isn't on Google Play yet; it's waitlist-only for now at tryreclaim.io.
Have you shipped a user-facing timer without exact alarms, or found a cleaner way to keep a countdown honest across process death? Tell us in the comments.
This article was drafted with AI assistance and reviewed by the ReClaim team.
Top comments (1)
Letting the notification chronometer draw the countdown, with setTimeoutAfter as cleanup, is a neat trick, nothing in your process has to tick. One edge case I am curious about: alarms are cleared when the phone reboots, and RTC_WAKEUP follows the wall clock if the user changes the time. Do you re-schedule from the session store on BOOT_COMPLETED, or let the commitment simply end?