DEV Community

Tal Aizikov
Tal Aizikov

Posted on AI-assisted

Scheduled app blocks in Kotlin: day bitmasks, overnight windows, and a Flow that wakes on time

A one-off app block is simple to model: a package name and a timestamp. When the timestamp passes, the block is over. A recurring block is harder. "Block these apps from 22:00 to 06:00 on weeknights" has to answer questions like: is it active right now, when does it end, what happens when it crosses midnight, and how does a background service find out the moment it starts?

We're building ReClaim, a minimalist Android launcher with app blocking built in. In an earlier post we covered the launcher plumbing: the HOME intent, LauncherApps, usage stats and the accessibility service. This one goes deep on a single feature, scheduled blocks: the Room schema, the time math, and the coroutine Flow that tells enforcement exactly when to wake up.

Stack: Kotlin, Room 2.6, Hilt, coroutines, minSdk 26. Snippets are trimmed from the real code.

1. The schema: minutes from midnight and a 7-bit day mask

A schedule is one row, plus membership rows for the apps (and websites) it covers:

/**
 * A recurring block window. [startMinutes]/[endMinutes] are minutes from midnight.
 * [daysMask] is a 7-bit mask, bit 0 = Monday … bit 6 = Sunday.
 */
@Entity(tableName = "schedules")
data class ScheduleEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val name: String,
    val startMinutes: Int,
    val endMinutes: Int,
    val daysMask: Int,
    val enabled: Boolean = true,
)

@Entity(tableName = "schedule_apps", primaryKeys = ["scheduleId", "packageName"])
data class ScheduleAppEntity(
    val scheduleId: Long,
    val packageName: String,
)
Enter fullscreen mode Exit fullscreen mode

A few deliberate choices here:

  • Times are minutes from midnight, not timestamps. A schedule is a wall-clock rule ("22:00 local"), not an instant, so 1320 means the same thing every week.
  • Days are a bitmask, not seven booleans or a child table. Bit 0 is Monday, which lines up with java.time.DayOfWeek, where MONDAY.value == 1. So "today's bit" is just 1 shl (dayOfWeek.value - 1). Since minSdk is 26, java.time is available without desugaring.
  • Membership uses composite primary keys. (scheduleId, packageName) makes duplicates impossible at the database level, and an app can belong to any number of schedules.

The mask also keeps the UI code small. Toggling a day chip is one xor, and the list screen gets readable summaries from a when over common masks:

fun toggleDay(dayIndex: Int) =
    _draft.update { it.copy(daysMask = it.daysMask xor (1 shl dayIndex)) }

private fun daysSummary(mask: Int): String = when (mask) {
    0b1111111 -> "Every day"
    0b0011111 -> "Weekdays"
    0b1100000 -> "Weekends"
    0 -> "No days"
    else -> DAY_SHORT.filterIndexed { i, _ -> (mask and (1 shl i)) != 0 }.joinToString(", ")
}
Enter fullscreen mode Exit fullscreen mode

The repository exposes a UI-friendly ScheduleView (schedule plus its package and host lists) by combine-ing three Room Flows and grouping membership rows by scheduleId. Because Room's observable queries re-emit when their tables change, any edit to a schedule or its apps flows through without manual refresh.

2. "Is this schedule active right now?"

Same-day windows are easy: the day bit is set and start <= now < end. Overnight windows are the interesting case. If a schedule runs 22:00 to 06:00, then at 02:00 on Saturday the question isn't "is Saturday selected?" It's "was Friday selected?", because that's the night the window started.

private fun isActiveNow(schedule: ScheduleEntity, now: LocalDateTime): Boolean {
    val minutes = now.hour * 60 + now.minute
    val todayBit = 1 shl (now.dayOfWeek.value - 1)          // Monday=1 -> bit 0
    val yesterdayBit = 1 shl ((now.dayOfWeek.value + 5) % 7)
    return if (schedule.startMinutes <= schedule.endMinutes) {
        (schedule.daysMask and todayBit) != 0 &&
            minutes >= schedule.startMinutes && minutes < schedule.endMinutes
    } else {
        // Overnight window (e.g. 22:00–06:00).
        ((schedule.daysMask and todayBit) != 0 && minutes >= schedule.startMinutes) ||
            ((schedule.daysMask and yesterdayBit) != 0 && minutes < schedule.endMinutes)
    }
}
Enter fullscreen mode Exit fullscreen mode

The (value + 5) % 7 expression is "yesterday's bit index" written without branches. Today's index is value - 1. Yesterday's is value - 2, wrapped into 0..6, which is (value - 2 + 7) % 7, or (value + 5) % 7. On Monday (value == 1) that gives 6, Sunday's bit.

So for a Monday–Friday 22:00–06:00 schedule:

  • Friday 23:30 is active (Friday's bit, after the start).
  • Saturday 02:00 is active (yesterday was Friday).
  • Monday 02:00 is not active, because Sunday isn't selected. The weeknight rule doesn't leak into Sunday night.

now is a parameter defaulting to LocalDateTime.now(), and the public functions built on this take it the same way. That keeps the time math independent of the system clock, so you can reason about it (or test it) with fixed dates.

"Which packages are blocked by schedules right now?" is then a filter plus a set:

suspend fun blockedPackagesNow(now: LocalDateTime = LocalDateTime.now()): Set<String> {
    val activeIds = scheduleDao.enabledSchedules()
        .filter { isActiveNow(it, now) }.map { it.id }.toSet()
    if (activeIds.isEmpty()) return emptySet()
    return scheduleDao.allScheduleApps()
        .filter { it.scheduleId in activeIds }
        .map { it.packageName }
        .toSet()
}
Enter fullscreen mode Exit fullscreen mode

3. "When does it end?" and overlapping schedules

The block screen shows a countdown, so it needs an end instant. For an overnight window, that's either this morning (the early-morning tail) or tomorrow morning (the evening part):

private fun windowEndEpochMillis(schedule: ScheduleEntity, now: LocalDateTime): Long {
    val midnight = now.toLocalDate().atStartOfDay()
    val endToday = midnight.plusMinutes(schedule.endMinutes.toLong())
    val end = when {
        schedule.startMinutes <= schedule.endMinutes -> endToday
        now.hour * 60 + now.minute < schedule.endMinutes -> endToday // early-morning tail
        else -> endToday.plusDays(1)
    }
    return end.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
}
Enter fullscreen mode Exit fullscreen mode

Rules are evaluated in local time. Conversion through ZoneId.systemDefault() happens only at the edge, where something needs to wait or count down.

Schedules can overlap, and an app can have a one-off block on top. Whichever ends later wins. scheduledBlockEndMillis() loops over every active schedule covering the package and keeps the latest end. The block repository then merges that with the temporary block:

suspend fun activeBlockUntil(packageName: String): Long? {
    val temp = temporaryBlockUntil(packageName)
    val scheduled = scheduleRepository.scheduledBlockEndMillis(packageName)
    return listOfNotNull(temp, scheduled).maxOrNull()
}
Enter fullscreen mode Exit fullscreen mode

null means "not blocked", which is all the launcher's tap handler checks. The block screen's ViewModel re-reads it every second for its countdown and shows "expired" once it returns null.

4. Waking up exactly when a window starts or ends

The accessibility service that enforces blocks system-wide keeps the blocked set in memory, so each window event is a set lookup. That set comes from a Flow. Our first version recomputed it whenever a block changed and once a minute. That works, but "blocked at 22:00" could mean 22:00:59.

The current version computes when the next transition is due instead. It walks the next eight days for every enabled schedule and collects every future start and end instant:

suspend fun millisUntilNextTransition(now: LocalDateTime = LocalDateTime.now()): Long {
    val zone = ZoneId.systemDefault()
    val nowEpoch = now.atZone(zone).toInstant().toEpochMilli()
    var soonest = Long.MAX_VALUE
    for (schedule in scheduleDao.enabledSchedules()) {
        val overnight = schedule.startMinutes > schedule.endMinutes
        for (dayOffset in 0..7) {
            val date = now.toLocalDate().plusDays(dayOffset.toLong())
            val dayBit = 1 shl (date.dayOfWeek.value - 1)
            if ((schedule.daysMask and dayBit) == 0) continue
            val dayStart = date.atStartOfDay()
            val start = dayStart.plusMinutes(schedule.startMinutes.toLong())
                .atZone(zone).toInstant().toEpochMilli()
            val end = dayStart.plusMinutes(schedule.endMinutes.toLong())
                .let { if (overnight) it.plusDays(1) else it }
                .atZone(zone).toInstant().toEpochMilli()
            if (start > nowEpoch) soonest = minOf(soonest, start)
            if (end > nowEpoch) soonest = minOf(soonest, end)
        }
    }
    return if (soonest == Long.MAX_VALUE) Long.MAX_VALUE else soonest - nowEpoch
}
Enter fullscreen mode Exit fullscreen mode

Why eight days? A Saturday-only schedule still needs a real answer on a Sunday, and scanning 0..7 guarantees every selected weekday appears. Long.MAX_VALUE only means "no enabled schedule has any day selected".

A tiny flow {} turns that into a ticker:

private fun scheduleTicker(): Flow<Unit> = flow {
    while (true) {
        emit(Unit)
        delay(scheduleRepository.millisUntilNextTransition().coerceIn(1_000L, 60_000L))
    }
}
Enter fullscreen mode Exit fullscreen mode

Two details matter:

  • It re-reads live schedule state every iteration, so an edit doesn't leave it waiting on a stale instant.
  • The delay is clamped to 1–60 seconds. The floor stops a tight loop at a boundary. The ceiling means a schedule created after a long wait began is still noticed within a minute, and it doubles as a general safety net.

The ticker is then one input of a three-way combine:

val blockedPackages: Flow<Set<String>> = combine(
    blockDao.observeAll(),          // one-off blocks changed
    scheduleRepository.schedules,   // a schedule was created/edited/toggled/deleted
    scheduleTicker(),               // a start or end boundary was crossed
) { rows, _, _ ->
    val now = System.currentTimeMillis()
    val temporary = rows.filter { it.blockedUntil > now }.map { it.packageName }.toSet()
    temporary + scheduleRepository.blockedPackagesNow()
}
Enter fullscreen mode Exit fullscreen mode

The schedule list and the ticker's Unit are only triggers; the answer is re-queried each time. Turning a schedule off therefore lifts its block immediately through the second input, without waiting for the ticker.

5. Acting on the change, not just the next app switch

An updated set alone isn't enough. If you're already in an app when its schedule starts, staying put isn't a switch, so onAccessibilityEvent() never fires. The service's collector checks the current foreground app the moment the set changes:

scope.launch {
    blockRepository.blockedPackages.collect { newBlocked ->
        blockedPackages = newBlocked
        val fg = lastForegroundPackage
        if (fg != null && fg != packageName && fg in newBlocked) {
            withContext(Dispatchers.Main) { runCatching { redirectToBlocked(fg) } }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

redirectToBlocked() is the same function the window-event path uses, with a one-second per-package debounce. The two paths can fire within the same instant (the set changing is often what an app switch would have seen a moment later), and the debounce keeps them from stacking two block screens.

6. Saving: an @Upsert gotcha and replace-all membership

Saving a schedule upserts the row, then replaces its membership lists:

suspend fun saveSchedule(view: ScheduleView): Long {
    val upserted = scheduleDao.upsertSchedule(
        ScheduleEntity(view.id, view.name.ifBlank { "Schedule" },
            view.startMinutes, view.endMinutes, view.daysMask, view.enabled),
    )
    // @Upsert only reports a real rowid for an insert; an update came back as -1.
    val id = if (view.id != 0L) view.id else upserted
    scheduleDao.clearScheduleApps(id)
    scheduleDao.addScheduleApps(view.packages.map { ScheduleAppEntity(id, it) })
    // ...same for schedule_websites
    return id
}
Enter fullscreen mode Exit fullscreen mode

The gotcha: @Upsert returning Long reads like "the row's id", but in our case an update came back as -1. Using that blindly would write every edited schedule's apps under schedule -1. The fix is to trust the id you already have for edits and only use the return value for fresh inserts.

7. Friction that only goes one way

One product rule shapes the edit screens. Adding apps or tightening hours saves with a single tap. Turning a schedule off, deleting it, or saving an edit that drops apps goes through a HoldToConfirmDialog that needs a five-second press; releasing early resets it.

The edit ViewModel snapshots the schedule's packages when it opens and diffs on save:

fun removedPackages(): List<String> =
    (originalPackages - _draft.value.packages.toSet()).toList()
Enter fullscreen mode Exit fullscreen mode

The comment on the dialog explains the reasoning better than we can here: "the moment of wanting out is exactly the moment the original intent deserves a chance to win."

8. Reusing it for websites

When we added website blocking, schedules got a schedule_websites table, and the website repository got the same combine and ticker. Because the time logic lives in ScheduleRepository, websites inherited overnight windows, overlaps and on-time wake-ups for free.

Wrapping up

The pieces that made scheduled blocks manageable:

  • Store wall-clock rules as minutes plus a day bitmask, and convert to epoch time only at the edges.
  • For overnight windows, check yesterday's bit for the early-morning tail.
  • When schedules overlap, the latest end wins.
  • Compute the next transition and sleep until then, with a clamp as a backstop.
  • React to the blocked set changing, not only to app switches.

If you're curious how this looks from the user's side, we wrote up how blocks and schedules fit together. ReClaim isn't on Google Play yet; there's a waitlist at tryreclaim.io.

How do you handle recurring time windows in your apps? Bitmasks, cron strings, or something else? We'd like to hear about it in the comments.

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

Top comments (0)