跳到正文
DEV Community· ReClaim·· 3 小時前AI 評分51

ReClaim 修補 Android 重啟後提醒鬧鐘遺失及跨午夜應用程式用量漏算問題

The edge cases after shipping: re-arming Android alarms after a reboot and counting app usage across midnight

AI 導讀

ReClaim 為其 Android launcher 修補重啟後提醒鬧鐘遺失及跨午夜應用程式用量漏算兩個邊界問題。修補以待處理標記和開機接收器重新處理提醒,並將 UsageEvents 查詢起點提前 12 小時,再以區間重疊計算用量;計數邏輯另有七項 JVM 單元測試。目前仍有一項限制,已跨午夜但尚未關閉的應用程式,其即時用量尚未按午夜截斷。

正文

We're building ReClaim, a minimalist Android launcher that's on Google Play. Two earlier posts covered features that measure time: app reminder timers that race AlarmManager against a coroutine, and daily app limits that replay UsageEvents instead of trusting the daily buckets.

Both features worked in the happy path. Both also had a gap that only shows up at a boundary:

  • A reboot. You set a 15-minute reminder for an app, the phone restarts, and the timer is just gone. No alarm, no countdown notification.
  • Midnight. You open an app at 11:40 PM and close it at 12:20 AM. Those 20 minutes after midnight never counted toward the new day.

Both fixes went into a single commit. This post walks through it: a small "pending" flag, a boot receiver, and a pure function with seven JVM unit tests. Stack: Kotlin, Hilt, DataStore, minSdk 26, targetSdk 36. Snippets are trimmed from the real code; // ... marks cuts.

Edge case 1: a reboot wipes the timer

Here's how a reminder gets scheduled. It's unchanged from the earlier post:

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)
    alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
    sessionStore.startSession(packageName, triggerAt, minutes, appLabel)
    ReminderTimerNotification.show(context, packageName, appLabel, triggerAt)
}

There are three pieces of state here. The alarm lives in the system. The end time lives in DataStore, in our app's files. The countdown notification lives in the notification shade. Only one of them survives a restart. The AlarmManager reference says registered alarms "will be cleared if it is turned off and rebooted," and the countdown notification doesn't survive a restart either.

So after a reboot, the DataStore still said "this session ends at 10:42," but nothing was waiting for 10:42. The accessibility service would re-arm its own in-process countdown the next time you switched into that app, because it reads the stored end time on every switch. If you didn't open the app again, nothing happened. Even if you did, the "time remaining" notification was gone until a new session started.

Why a wall-clock end time helps here

One earlier decision made this fix easy. The stored end time is System.currentTimeMillis() plus the duration, and the alarm is RTC_WAKEUP. The SystemClock docs describe elapsedRealtime() as "milliseconds since boot," so it restarts from zero on every boot. An end time stored on that clock would mean nothing after a restart. A wall-clock end time still points at the same moment, so after a reboot we can schedule the alarm again with the stored number as-is.

Step 1: know which sessions still owe an expiry

The obvious way to find "sessions to re-arm" is "every session whose end time is still in the future." That misses one case: a session that ran out while the phone was off. Its end time is in the past, but nobody has handled its "time's up" yet. Re-arming only future sessions would drop it.

So each session gets a pending flag. Starting a session sets it:

private fun pendingKey(packageName: String) = booleanPreferencesKey("$packageName:pending")

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
        it[pendingKey(packageName)] = true
    }
}

The expiry path clears it. fireExpiry is the one function both the alarm receiver and the in-process countdown call when time is up. It now marks the session handled first:

appScope.launch {
    sessionStore.markExpiryHandled(packageName)
    // ... then decide whether to post the "time's up" notification
}

Clearing the flag has one subtle rule:

suspend fun markExpiryHandled(packageName: String) {
    store.edit {
        // Only clear a session that has actually run out: if time was added (a fresh
        // startSession) before this ran, that newer session must stay pending.
        val until = it[key(packageName)] ?: 0L
        if (until <= System.currentTimeMillis() + EXPIRY_TOLERANCE_MILLIS) it.remove(pendingKey(packageName))
    }
}

The comment explains the race. When time runs out, the user can add more time, and that calls startSession again. If the clear ran blindly after that, it would wipe the pending flag of a session that just started. So the clear only happens when the stored end time has actually passed. The 5-second tolerance (EXPIRY_TOLERANCE_MILLIS = 5_000L) exists because, as the code puts it, "the in-process countdown can fire a hair before the stored end time." Doing the check and the remove inside one edit block keeps them together as one read-modify-write.

Reading the pending set back is a scan over the preference keys:

suspend fun pendingSessions(): List<PendingSession> {
    val prefs = store.data.catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }.first()
    return prefs.asMap().keys
        .map { it.name }
        .filter { it.endsWith(":pending") }
        .mapNotNull { name ->
            val pkg = name.removeSuffix(":pending")
            if (prefs[pendingKey(pkg)] != true) return@mapNotNull null
            val until = prefs[key(pkg)] ?: return@mapNotNull null
            PendingSession(pkg, until, prefs[minutesKey(pkg)] ?: 0, prefs[labelKey(pkg)] ?: "your app")
        }
}

Website reminders use the same store with site:-prefixed keys, so they come back through this function too.

Step 2: a receiver for boot and app updates

The manifest gets a permission and a receiver:

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

<receiver
    android:name=".notifications.BootReceiver"
    android:exported="false">
    <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED" />
        <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
    </intent-filter>
</receiver>

A few things we checked in the docs before relying on this:

  • exported="false" still gets the boot broadcast. The <receiver> reference says a non-exported receiver still receives messages "sent by the system, components of the same application, or applications with the same user ID."
  • Manifest registration is allowed. Since Android 8.0, apps mostly can't declare manifest receivers for implicit broadcasts, but ACTION_BOOT_COMPLETED is on the implicit broadcast exceptions list. ACTION_MY_PACKAGE_REPLACED is sent only to the app that was replaced, so it isn't affected by that limit.
  • Storage is unlocked by then. The Intent reference says that when ACTION_BOOT_COMPLETED arrives, "the user is unlocked and both device-protected and credential-protected storage can accessed safely." Our DataStore file sits in normal app storage, which the Direct Boot guide calls credential encrypted storage, "the default storage location." We don't need LOCKED_BOOT_COMPLETED or a direct-boot-aware receiver. Re-arming after the first unlock is fine for a screen-time reminder.
  • It also covers force-stop on newer Android. The same reference notes that from Android 15 (VANILLA_ICE_CREAM), this broadcast is also delivered "when it is removed from the Stopped state," for example on the first launch after a force-stop. One receiver handles that case too.

The scheduling alarms guide adds one more caveat: receiving the boot broadcast "only works if the app has already been launched by the user at least once." For a launcher that already has a running timer, that's always true.

MY_PACKAGE_REPLACED is cheap insurance for updates. Re-arming is idempotent: the alarm's PendingIntent uses the same request code and FLAG_UPDATE_CURRENT, and the set docs say an existing alarm for the same intent "will be removed and replaced by this one." Running it when nothing was lost costs one extra system call per pending session.

The receiver itself is small:

@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {

    @Inject lateinit var reminderScheduler: ReminderScheduler
    @Inject @ApplicationScope lateinit var appScope: CoroutineScope

    override fun onReceive(context: Context, intent: Intent) {
        when (intent.action) {
            Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> Unit
            else -> return
        }
        val pending = goAsync()
        appScope.launch {
            try {
                reminderScheduler.rearmPending()
            } finally {
                pending.finish()
            }
        }
    }
}

The action check follows the BroadcastReceiver.onReceive guidance to "respond only to known actions, ignoring any unexpected Intents." Reading DataStore is a suspend call, so we use goAsync() to move the work off the main thread. The goAsync docs are clear that this doesn't buy unlimited time. The usual limit (around 10 seconds) still applies, counted until finish(). Reading a few preference keys and setting a few alarms fits easily, and the finally makes sure finish() runs even if something throws.

Step 3: re-arm or expire

suspend fun rearmPending() {
    val now = System.currentTimeMillis()
    for (s in sessionStore.pendingSessions()) {
        if (s.untilMillis <= now) {
            fireExpiry(s.packageName, s.label, s.minutes)
        } else {
            alarmManager.setAndAllowWhileIdle(
                AlarmManager.RTC_WAKEUP,
                s.untilMillis,
                alarmPendingIntent(s.packageName, s.label, s.minutes),
            )
            ReminderTimerNotification.show(context, s.packageName, s.label, s.untilMillis)
        }
    }
}

There are two branches and no new logic. A session that's still running gets its alarm and its countdown notification back, pointed at the same end time. A session that ran out while the phone was off goes through fireExpiry, the same function the alarm would have called. That means the per-app "what happens when time's up" setting still applies, and the pending flag gets cleared the usual way. We didn't write a separate "expired during reboot" path, so there's nothing extra to keep in sync.

Edge case 2: a session that crosses midnight

App limits replay MOVE_TO_FOREGROUND and MOVE_TO_BACKGROUND events from UsageStatsManager.queryEvents. Before the fix, the replay queried exactly the window it was measuring:

// before
val events = runCatching { usageStatsManager.queryEvents(start, end) }.getOrNull() ?: return 0L
// ...
UsageEvents.Event.MOVE_TO_FOREGROUND -> {
    if (activeCount == 0) segmentStart = event.timeStamp.coerceIn(start, end)
    activeCount++
}
UsageEvents.Event.MOVE_TO_BACKGROUND -> {
    if (activeCount > 0) {
        // ... close the segment
    }
}

Run that for "today" with the 11:40 PM to 12:20 AM session. The window starts at midnight, so the 11:40 PM foreground event is outside the query. The first event the loop sees for that app is the 12:20 AM MOVE_TO_BACKGROUND, while activeCount is 0. The if (activeCount > 0) guard skips it, and 20 minutes of today's usage disappear. The guard itself is correct, because a stray background event shouldn't count. The query just never showed us the start of the session.

The fix: read earlier, count only inside the window

Two changes. First, query from 12 hours before the window starts:

val queryStart = (start - LOOKBACK_MILLIS).coerceAtLeast(0L)
val events = runCatching { usageStatsManager.queryEvents(queryStart, end) }.getOrNull() ?: return 0L
// ... collect this package's MOVE_TO_FOREGROUND / MOVE_TO_BACKGROUND as ForegroundTransition
return foregroundMillisWithin(transitions, start, end)

companion object {
    /** How far before a window's start to read events, to catch a session already open then. */
    const val LOOKBACK_MILLIS = 12L * 60 * 60 * 1000
}

Second, move the replay into a pure function that clips each session to the window. Clipping is now an overlap calculation instead of coerceIn on each timestamp:

data class ForegroundTransition(val timeStamp: Long, val toForeground: Boolean)

fun foregroundMillisWithin(transitions: Iterable<ForegroundTransition>, start: Long, end: Long): Long {
    if (end <= start) return 0L
    var activeCount = 0
    var segmentStart = -1L
    var total = 0L
    for (t in transitions) {
        if (t.toForeground) {
            if (activeCount == 0) segmentStart = t.timeStamp
            activeCount++
        } else if (activeCount > 0) {
            activeCount--
            if (activeCount == 0 && segmentStart >= 0) {
                total += overlap(segmentStart, t.timeStamp, start, end)
                segmentStart = -1L
            }
        }
    }
    if (activeCount > 0 && segmentStart >= 0) {
        total += overlap(segmentStart, end, start, end)
    }
    return total
}

private fun overlap(segStart: Long, segEnd: Long, start: Long, end: Long): Long =
    (minOf(segEnd, end) - maxOf(segStart, start)).coerceAtLeast(0L)

The per-package activeCount stays. It handles an app moving between its own activities, which produces two foreground events before one background event. What changed is that a session can now start before start and still be counted correctly, and sessions that ended before start add zero.

The same function serves every window: today since midnight, this week since Monday, the 7-day rolling average, and the per-day bars on the reminder screen. Yesterday's bar now gets the 11:40 PM to midnight part of that session, and today's gets the part after midnight. Neither one double counts.

Why pull it out of the Android code

foregroundMillisWithin has no Android types in it, so it runs as a plain JVM unit test. This commit added JUnit to the project for that reason, with seven tests for the boundary cases:

@Test fun sessionSpanningMidnight_countsOnlyTimeAfterMidnight() {
    val events = listOf(fg(midnight - 30 * min), bg(midnight + 20 * min))
    assertEquals(20 * min, foregroundMillisWithin(events, midnight, midnight + 60 * min))
}

@Test fun yesterdayWindow_countsOnlyPartBeforeMidnight() {
    val events = listOf(fg(midnight - 30 * min), bg(midnight + 20 * min))
    assertEquals(30 * min, foregroundMillisWithin(events, midnight - 24 * 60 * min, midnight))
}

@Test fun strayBackgroundWithoutForeground_ignored() {
    val events = listOf(bg(midnight + 5 * min), fg(midnight + 10 * min), bg(midnight + 15 * min))
    assertEquals(5 * min, foregroundMillisWithin(events, midnight, midnight + 60 * min))
}

The other four cover a session still open at "now," a session that ended before midnight (zero), nested activities of the same package (counted once), and an empty window. Testing a midnight bug on a device means waiting for midnight or changing the clock. Here it's a list of timestamps.

The trade-offs of a fixed lookback

  • 12 hours is a bound, not a guarantee. A session that started more than 12 hours before the window still has the old problem. It's a trade-off: long enough to catch an evening session that runs past midnight, short enough to keep the extra events small.
  • Retention. The queryEvents reference says events "are only kept by the system for a few days." Twelve extra hours shouldn't run into that limit for today's window.
  • Cost. Every check now replays 12 extra hours of events. Each query returns events for every app, and we filter by package in Kotlin, so the extra work grows with how much you used your phone the evening before.

What's still open

The live check for an app that's open right now adds the in-progress session's length, which is tracked in memory, on top of the replayed history. That in-memory length isn't clipped at midnight yet. So for an app you've had open since before midnight, the live number can include some of last night's minutes until the session ends and the replay takes over. It's a known gap, and we'd rather say so than claim the fix covers everything.

What we'd tell another team

  • Write down which pieces of your state survive a reboot. For us: DataStore did, the alarm and the notification didn't.
  • Store end times on the wall clock when they have to survive a restart. elapsedRealtime() restarts at boot.
  • Track "expiry handled" separately from "end time passed." A session that ran out while the phone was off still needs its expiry handled.
  • Send the "expired during reboot" case through the same function as a normal expiry.
  • Query a little before the window and clip with an overlap function. Don't clamp event timestamps one by one.
  • Pull the counting logic into a pure function, so midnight is just a number in a unit test.

ReClaim is on Google Play if you want to try reminders and app limits on your own phone.

This post was drafted with AI assistance and reviewed by the ReClaim team.

來源:DEV Community · dev.to