"Thirty minutes of YouTube a day" sounds like one comparison. Enforcing it on Android means answering three questions: how long has the app really been in front today, when exactly does "today" start, and what makes the block land the moment the limit is crossed rather than at the next app switch?
We're building ReClaim, a minimalist Android launcher that's now on Google Play. Its App Limits are a per-app daily budget, with an optional weekly cap, that blocks the app, notifies you, or only tracks. This post is the code behind them: we replay the raw event log instead of trusting aggregate buckets, pin the boundaries to local midnight and Monday, and add the still-running session on top.
Stack: Kotlin, Hilt, Room, coroutines, minSdk 26, targetSdk 36. Snippets are trimmed from the real code; // ... marks cuts.
1. Usage access is a special permission
Foreground time comes from UsageStatsManager. The manifest declares PACKAGE_USAGE_STATS, but per the reference, "declaring the permission implies intention to use the API and the user of the device still needs to grant permission through the Settings application." So we check the app op ourselves and send the user to the system screen:
fun hasPermission(): Boolean {
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
appOps.unsafeCheckOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName,
)
} else {
@Suppress("DEPRECATION")
appOps.checkOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName,
)
}
return mode == AppOpsManager.MODE_ALLOWED
}
// ...
fun requestPermissionIntent(): Intent =
Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
Every read below starts with if (!hasPermission()) return@withContext 0L. Without usage access, ReClaim sees zero historical usage.
2. Why we don't use the daily buckets
The obvious call is queryAndAggregateUsageStats(startOfToday, now) and reading totalTimeInForeground. The class docs explain why that's risky for a limit that resets at midnight. Usage data "is aggregated into time intervals: days, weeks, months, and years," and "a request for data in the middle of a time interval will include that interval." queryUsageStats adds that the begin and end times "may be expanded to the nearest whole interval period."
The KDoc on our summing function records what that looked like for us: the aggregate API "can bleed a prior day's total into a query that starts at today's midnight until that bucket rolls over, which silently breaks a 'reset at midnight' budget."
We still use the buckets where precision doesn't matter: the Blocking screen's "block together" suggestions rank last week's most-used apps with queryUsageStats(INTERVAL_DAILY, ...). Enforcement needs something stricter.
3. Replaying the event log
queryEvents(begin, end) returns the raw transitions, each with a timestamp. We walk them for one package and sum the time between "moved to foreground" and "moved to background", clipped to the window:
private fun sumForegroundMillis(packageName: String, start: Long, end: Long): Long {
if (end <= start) return 0L
val events = runCatching { usageStatsManager.queryEvents(start, end) }.getOrNull() ?: return 0L
val event = UsageEvents.Event()
var activeCount = 0
var segmentStart = -1L
var total = 0L
while (events.hasNextEvent()) {
events.getNextEvent(event)
if (event.packageName != packageName) continue
when (event.eventType) {
UsageEvents.Event.MOVE_TO_FOREGROUND -> {
if (activeCount == 0) segmentStart = event.timeStamp.coerceIn(start, end)
activeCount++
}
UsageEvents.Event.MOVE_TO_BACKGROUND -> {
if (activeCount > 0) {
activeCount--
if (activeCount == 0 && segmentStart >= 0) {
total += (event.timeStamp.coerceIn(start, end) - segmentStart).coerceAtLeast(0L)
segmentStart = -1L
}
}
}
}
}
if (activeCount > 0 && segmentStart >= 0) {
total += (end - segmentStart).coerceAtLeast(0L)
}
return total
}
A few details:
-
A counter, not pairs. In the
UsageEvents.Eventreference,MOVE_TO_FOREGROUNDandMOVE_TO_BACKGROUNDare deprecated in API 29 in favour ofACTIVITY_RESUMEDandACTIVITY_PAUSED, with the same constant values (1 and 2). The newer docs note that "if a package has multiple activities, this event is reported for each activity." So we count active activities and only open or close a segment at zero; the KDoc's example is Chrome hopping between its "tabbed activity <-> first-run activity". -
Clipping. Timestamps are coerced into
[start, end), and a segment that's still open when the log ends is counted up toend. -
Null-safe. The reference says that from Android 11,
queryEventsreturns null when the user isn't unlocked. The?: return 0Lcovers that.
4. Where "today" and "this week" start
The window is a fixed calendar period in the device's zone, not "the last 24 hours":
suspend fun todayForegroundMillis(packageName: String): Long = withContext(Dispatchers.IO) {
if (!hasPermission()) return@withContext 0L
val zone = ZoneId.systemDefault()
val startOfToday = LocalDate.now(zone).atStartOfDay(zone).toInstant().toEpochMilli()
val now = System.currentTimeMillis()
sumForegroundMillis(packageName, startOfToday, now)
}
/** Foreground time for [packageName] since this calendar week's Monday, in millis. For the weekly budget cap — a fixed, predictable reset beats a rolling window when something is actually enforcing a limit. */
suspend fun weekForegroundMillis(packageName: String): Long = withContext(Dispatchers.IO) {
if (!hasPermission()) return@withContext 0L
val zone = ZoneId.systemDefault()
val startOfWeek = LocalDate.now(zone)
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
.atStartOfDay(zone).toInstant().toEpochMilli()
val now = System.currentTimeMillis()
sumForegroundMillis(packageName, startOfWeek, now)
}
We use atStartOfDay(zone) rather than building midnight by hand. The LocalDate reference says it returns "the earliest valid time according to the rules in the time-zone," which "may not be midnight" in a daylight-saving gap.
Rolling windows still have one job: when you add a limit for one app, the editor suggests a starting value from averageDailyForegroundMillis over the last seven days, rounded down to five minutes.
The budget itself is a Room row (app_budgets, keyed by packageName) with dailyMinutes, a nullable weeklyMinutes, an action and a warn flag. Saving fixes one config mistake:
suspend fun save(budget: AppBudget) = budgetDao.upsert(
AppBudgetEntity(
packageName = budget.packageName,
dailyMinutes = budget.dailyMinutes.coerceAtLeast(1),
// A weekly cap smaller than the daily one would be self-defeating (the daily limit alone
// would already block reaching it) — floor it at the daily minutes.
weeklyMinutes = budget.weeklyMinutes?.coerceAtLeast(budget.dailyMinutes),
action = budget.action.name,
warnBeforeLimit = budget.warnBeforeLimit,
),
)
5. Counting the session that hasn't ended
Enforcement lives in the accessibility service ReClaim already uses to see which app is in front. The comment on one of its fields describes the problem it works around: "UsageStatsManager only reliably reports a session's foreground time once that session has ended — querying it mid-session can under-report by the session's entire duration so far."
So the service tracks the live part with the monotonic clock:
@Volatile private var currentSessionStartElapsed: Long = SystemClock.elapsedRealtime()
/** How long [pkg] has been continuously in the foreground right now, or 0 if it isn't. */
private fun ongoingSessionMillis(pkg: String): Long =
if (pkg == lastForegroundPackage) {
(SystemClock.elapsedRealtime() - currentSessionStartElapsed).coerceAtLeast(0L)
} else {
0L
}
currentSessionStartElapsed resets on every real app switch; System UI window events (shade, quick settings) are ignored first, so pulling down the shade isn't a new session. The check takes the live portion as a parameter:
suspend fun overLimit(budget: AppBudget, extraDailyMillis: Long = 0L, extraWeeklyMillis: Long = 0L): Result? {
val weeklyLimit = budget.weeklyMillis
if (weeklyLimit != null && usageStatsSource.weekForegroundMillis(budget.packageName) + extraWeeklyMillis >= weeklyLimit) {
return Result(Period.WEEKLY)
}
if (usageStatsSource.todayForegroundMillis(budget.packageName) + extraDailyMillis >= budget.dailyMillis) {
return Result(Period.DAILY)
}
return null
}
Weekly is checked first because, as the KDoc puts it, "that's the one that actually determines when access returns." That choice decides which message the block screen shows: "Limit reached — more time tomorrow" or "Limit reached — more time next week."
6. Landing the block on time
Accessibility events only arrive when something changes, and as a comment in the service puts it, the next one "might never come if the user just sits on one screen." So when a budgeted app comes to the front under its limit, the service arms a timer for the remaining time:
private suspend fun scheduleBudgetCloseJob(pkg: String, budget: AppBudget) {
budgetCloseJobs.remove(pkg)?.cancel()
if (budget.action != BudgetAction.BLOCK) return
val dailyRemaining = budget.dailyMillis - usageStatsSource.todayForegroundMillis(pkg)
val weeklyRemaining = budget.weeklyMillis?.let { it - usageStatsSource.weekForegroundMillis(pkg) }
val remaining = listOfNotNull(dailyRemaining, weeklyRemaining).filter { it > 0 }.minOrNull() ?: return
budgetCloseJobs[pkg] = scope.launch {
delay(remaining + 250)
// ...
for (attempt in 0 until RECHECK_ATTEMPTS) {
if (lastForegroundPackage != pkg) return@launch
val fresh = budgetRepository.budget(pkg) ?: return@launch
val ongoing = ongoingSessionMillis(pkg)
val overLimit = budgetLimitChecker.overLimit(fresh, extraDailyMillis = ongoing, extraWeeklyMillis = ongoing)
if (overLimit != null) {
handleBudgetLimitReached(pkg, overLimit.period)
return@launch
}
delay(RECHECK_INTERVAL_MS)
}
}
}
When it fires, it re-reads the budget from Room (it may have been raised or deleted), confirms the app is still in front, and runs the live check again. If the read comes up just short, it retries five times, two seconds apart.
The job is cancelled when the app leaves the foreground, because a budget only accrues on screen. Our reminder timers do the opposite and keep counting in the background.
Two more paths cover the gaps. Window events inside a budgeted app trigger evaluateBudgetIfDue, at most once every 15 seconds per app. And a limit crossed while the user is elsewhere is deferred:
private suspend fun handleBudgetLimitReached(pkg: String, period: BudgetLimitChecker.Period) {
if (pkg == lastForegroundPackage) {
withContext(Dispatchers.Main) { runCatching { showLimitReachedTakeover(pkg, period) } }
} else {
markPendingBudgetLimitReached(pkg, period)
}
}
A pending limit is the first thing checked when that app is next switched into.
There is no stored "blocked until midnight" flag. A comment in the service says "a block must never be a separately-tracked fact that can go stale," and the checker's KDoc spells out the payoff: "a raised limit lifts a block the moment it's saved." The settings list of limit-blocked apps re-runs overLimit every 20 seconds instead of reading a table.
7. Notify, track, and milestone nudges
NOTIFY posts "Daily limit reached" (or weekly) and lets the app keep running. TRACK only counts; every budgeted app also gets a progress ring on its Home tile. With "Notify me at 50% and 80% of the limit" on, the same 15-second check posts milestone nudges, deduped per period:
@Synchronized
private fun notifyOncePeriod(period: String, kind: String, pkg: String, title: String, text: String) {
val today = LocalDate.now().toString()
if (today != notifiedDate) {
notifiedDate = today
notifiedKeys.removeAll { it.startsWith("daily|") }
}
val weekTag = currentWeekStart().toString()
if (weekTag != notifiedWeekTag) {
notifiedWeekTag = weekTag
notifiedKeys.removeAll { it.startsWith("weekly|") }
}
val key = "$period|$kind|$pkg"
if (!notifiedKeys.add(key)) return
postNotification(title, text, key)
}
8. Trade-offs
-
No usage access, no history. Without the permission every historical read is
0L, so a limit only sees the current continuous session. -
Event retention. The
queryEventsreference says "Events are only kept by the system for a few days." A weekly cap replays everything since Monday, so how much of the log a device keeps limits how accurate the weekly total can be late in the week. - Cost. Each check replays the log since midnight or Monday and filters by package in Kotlin. We accept that work for numbers that reset exactly when we say they do.
- It needs the service. The close job, deferred takeover and nudges all run in the accessibility service.
Wrapping up
- For a limit that resets at a fixed time, replay
queryEventsand clip to your window instead of trusting interval buckets. - Count active activities per package rather than pairing events.
- Use
atStartOfDay(zone)and a fixed Monday for boundaries; keep rolling windows for suggestions. - Add the live session from
elapsedRealtime()on top of the historical total. - Arm a timer for the remaining time, re-verify when it fires, and never store "blocked" as a fact.
For the product side of limits versus blocks versus reminders, see app limits vs. blocking vs. reminders.
How do you measure foreground time on Android: events, aggregates, or something else? And how do you handle the day boundary? Tell us in the comments.
This article was drafted with AI assistance and reviewed by the ReClaim team.
Top comments (0)