<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ReClaim</title>
    <description>The latest articles on DEV Community by ReClaim (@tryreclaim).</description>
    <link>https://dev.to/tryreclaim</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4182581%2F0c14cf4a-d4d3-4c34-a0d0-6121752f9f84.png</url>
      <title>DEV Community: ReClaim</title>
      <link>https://dev.to/tryreclaim</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tryreclaim"/>
    <language>en</language>
    <item>
      <title>Racing AlarmManager with a coroutine: app reminder timers on Android</title>
      <dc:creator>ReClaim</dc:creator>
      <pubDate>Thu, 15 Oct 2026 15:20:28 +0000</pubDate>
      <link>https://dev.to/tryreclaim/racing-alarmmanager-with-a-coroutine-app-reminder-timers-on-android-3i08</link>
      <guid>https://dev.to/tryreclaim/racing-alarmmanager-with-a-coroutine-app-reminder-timers-on-android-3i08</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;We're building &lt;a href="https://www.tryreclaim.io" rel="noopener noreferrer"&gt;ReClaim&lt;/a&gt;, 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 &lt;code&gt;AlarmManager&lt;/code&gt;, and how the ongoing notification shows a live countdown without us re-posting it every second.&lt;/p&gt;

&lt;p&gt;Stack: Kotlin, Hilt, Room, DataStore, coroutines, &lt;code&gt;minSdk 26&lt;/code&gt;, &lt;code&gt;targetSdk 36&lt;/code&gt;. Snippets are trimmed from the real code; &lt;code&gt;// ...&lt;/code&gt; marks cuts.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One commitment, two timers
&lt;/h2&gt;

&lt;p&gt;Exact alarms (&lt;code&gt;setExact&lt;/code&gt;, &lt;code&gt;setExactAndAllowWhileIdle&lt;/code&gt;) are the obvious tool, but apps targeting Android 12+ need the &lt;a href="https://developer.android.com/about/versions/12/behavior-changes-12#exact-alarm-permission" rel="noopener noreferrer"&gt;&lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; permission&lt;/a&gt; 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 &lt;em&gt;inexact&lt;/em&gt; wake-up that's still allowed to run in Doze:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;appLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;triggerAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60_000L&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;pendingIntent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;alarmPendingIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;appLabel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// Inexact-but-idle-friendly: no special exact-alarm permission required. The OS is free to&lt;/span&gt;
    &lt;span class="c1"&gt;// defer this by minutes (observed on-device), so it's a reliability backstop, not the primary&lt;/span&gt;
    &lt;span class="c1"&gt;// enforcement — see BlockAccessibilityService's precise in-process countdown for that.&lt;/span&gt;
    &lt;span class="n"&gt;alarmManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAndAllowWhileIdle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AlarmManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RTC_WAKEUP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;triggerAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pendingIntent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// So the prompt doesn't ask again — from any trigger path — while this commitment is live.&lt;/span&gt;
    &lt;span class="n"&gt;sessionStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;triggerAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;appLabel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// The visible, always-checkable answer to "is my timer still running?" — survives the&lt;/span&gt;
    &lt;span class="c1"&gt;// target app (or ReClaim itself) being backgrounded, closed from Recents, or killed.&lt;/span&gt;
    &lt;span class="nc"&gt;ReminderTimerNotification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;appLabel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;triggerAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per the &lt;a href="https://developer.android.com/reference/android/app/AlarmManager#setAndAllowWhileIdle%28int,%20long,%20android.app.PendingIntent%29" rel="noopener noreferrer"&gt;&lt;code&gt;AlarmManager&lt;/code&gt; reference&lt;/a&gt;, &lt;code&gt;setAndAllowWhileIdle&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;scheduleReminderCloseJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;activeUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;reminderCloseJobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;remaining&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;activeUntil&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;reminderCloseJobs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// No safety buffer here on purpose — the countdown notification's chronometer keeps&lt;/span&gt;
        &lt;span class="c1"&gt;// ticking (into visibly-wrong negative time) for as long as this takes to fire.&lt;/span&gt;
        &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// Re-verify against a fresh read — the session may have been extended, or already handled&lt;/span&gt;
        &lt;span class="c1"&gt;// by the alarm winning the race — before doing anything visible.&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;stillActive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reminderSessionStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activeUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stillActive&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;stillActive&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="nd"&gt;@launch&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;label&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reminderSessionStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="nf"&gt;labelOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;minutes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reminderSessionStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;reminderScheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fireExpiry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;strong&gt;not&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Persist the session, not just the alarm
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * 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.
 */&lt;/span&gt;
&lt;span class="nd"&gt;@Singleton&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReminderSessionStore&lt;/span&gt; &lt;span class="nd"&gt;@Inject&lt;/span&gt; &lt;span class="k"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@ApplicationContext&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;startSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;untilMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;edit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;untilMillis&lt;/span&gt;
            &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;minutesKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;
            &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;labelKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a reminded app comes to the front, the service either re-arms the countdown for whatever time is left, or shows the prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;evaluateReminderIfNeeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reminderPackages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;activeUntil&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reminderSessionStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activeUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;activeUntil&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;activeUntil&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;scheduleReminderCloseJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;activeUntil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="nd"&gt;@launch&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;intent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ReminderPromptActivity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="nd"&gt;@BlockAccessibilityService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addFlags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FLAG_ACTIVITY_NEW_TASK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;withContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Dispatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Main&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;runCatching&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;startActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-arming on every switch means the countdown always reflects the current commitment, including one just extended with "add time." Every confirm path calls &lt;code&gt;schedule()&lt;/code&gt; again. The broadcast &lt;code&gt;PendingIntent&lt;/code&gt; is built with request code &lt;code&gt;packageName.hashCode()&lt;/code&gt; and &lt;code&gt;FLAG_UPDATE_CURRENT&lt;/code&gt;, so a new schedule for the same app replaces its old alarm instead of adding a second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Let the OS draw the countdown
&lt;/h2&gt;

&lt;p&gt;Users need a way to check the timer is still running. ReClaim posts an ongoing notification and lets the system render the clock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;notification&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NotificationCompat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CHANNEL_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setSmallIcon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drawable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ic_notification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setContentTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setContentText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Reminder running — closes automatically when time's up"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setOngoing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setOnlyAlertOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setSilent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPriority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationCompat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PRIORITY_LOW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationCompat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CATEGORY_PROGRESS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUsesChronometer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setChronometerCountDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setWhen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setShowWhen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// Belt-and-suspenders against the chronometer ever visibly counting into negative time:&lt;/span&gt;
    &lt;span class="c1"&gt;// the OS clears this on its own right at expiry even if nothing in-app gets a chance to&lt;/span&gt;
    &lt;span class="c1"&gt;// run (process killed, service off) — see ReminderScheduler.fireExpiry for the normal path.&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeoutAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expiresAt&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contentIntent&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setContentIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contentIntent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder#setUsesChronometer%28boolean%29" rel="noopener noreferrer"&gt;&lt;code&gt;setUsesChronometer(true)&lt;/code&gt;&lt;/a&gt; shows the &lt;code&gt;when&lt;/code&gt; field as an automatically updating timer, and &lt;a href="https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder#setChronometerCountDown%28boolean%29" rel="noopener noreferrer"&gt;&lt;code&gt;setChronometerCountDown(true)&lt;/code&gt;&lt;/a&gt; (API 24+) makes it count down toward &lt;code&gt;setWhen(expiresAt)&lt;/code&gt;. Nothing in our process ticks. &lt;code&gt;setTimeoutAfter&lt;/code&gt; (API 26+, which matches our &lt;code&gt;minSdk&lt;/code&gt;) cancels the notification at the deadline even if ReClaim never gets to run again.&lt;/p&gt;

&lt;p&gt;The notification goes on a low-importance channel created in &lt;code&gt;Application.onCreate()&lt;/code&gt;, described as "Ongoing countdown while an App Reminder session is running, and a quiet note when it ends."&lt;/p&gt;

&lt;h2&gt;
  
  
  4. One expiry function, two callers
&lt;/h2&gt;

&lt;p&gt;The alarm lands in a Hilt-injected receiver, which hands off immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@AndroidEntryPoint&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReminderReceiver&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BroadcastReceiver&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Inject&lt;/span&gt; &lt;span class="k"&gt;lateinit&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;reminderScheduler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ReminderScheduler&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onReceive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;packageName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStringExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EXTRA_PACKAGE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;label&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStringExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EXTRA_LABEL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="s"&gt;"your app"&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;minutes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getIntExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EXTRA_MINUTES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;reminderScheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fireExpiry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fireExpiry&lt;/code&gt; is where the race gets settled. Its KDoc says it's "safe to run twice in the rare case both fire close together":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;fireExpiry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;cancelAlarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;ReminderTimerNotification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;reminderRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notifyExpired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;appScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hostFromSiteKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;websiteReminderRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expireAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;reminderRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expireAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;ReminderExpireAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CLOSE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="nd"&gt;@launch&lt;/span&gt;
        &lt;span class="nf"&gt;postExpiredNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cancel the alarm first.&lt;/strong&gt; If the coroutine won, the broadcast never arrives.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cancel the countdown notification.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emit&lt;/strong&gt; an &lt;code&gt;ExpiredReminder&lt;/code&gt; on a &lt;code&gt;MutableSharedFlow(extraBufferCapacity = 4)&lt;/code&gt; so the accessibility service can react. &lt;code&gt;tryEmit&lt;/code&gt; never suspends, so the receiver path stays short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post a silent "Time's up on …" notification&lt;/strong&gt; that opens the takeover screen, but only for the default &lt;em&gt;Prompt&lt;/em&gt; action. For &lt;em&gt;Close&lt;/em&gt; 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."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;hostFromSiteKey&lt;/code&gt; branch exists because website reminders reuse this path under a &lt;code&gt;site:&lt;/code&gt; key (see our &lt;a href="https://dev.to/talaizikov/blocking-websites-on-android-by-reading-only-the-address-bar-mkg"&gt;website-blocking post&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Don't yank the user out of something else
&lt;/h2&gt;

&lt;p&gt;Expiry doesn't always mean "takeover right now." The service collects &lt;code&gt;reminderRepository.expired&lt;/code&gt; and checks what's in front:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;lastForegroundPackage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Still on the reminded app right now — this is exactly the moment its&lt;/span&gt;
    &lt;span class="c1"&gt;// configured action (takeover, or straight home) is expected to happen.&lt;/span&gt;
    &lt;span class="nf"&gt;withContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Dispatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Main&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;runCatching&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;handleExpiredReminder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The user already moved on to something else — a full-screen takeover now would&lt;/span&gt;
    &lt;span class="c1"&gt;// interrupt unrelated work. Enforce it instead the moment they switch back into&lt;/span&gt;
    &lt;span class="c1"&gt;// this app (see onAccessibilityEvent), so a minimized/backgrounded reminder app&lt;/span&gt;
    &lt;span class="c1"&gt;// still can't just be silently resumed later with no consequence.&lt;/span&gt;
    &lt;span class="nf"&gt;markPendingExpired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;com.android.systemui&lt;/code&gt; are ignored before &lt;code&gt;lastForegroundPackage&lt;/code&gt; is updated.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Prompt or close
&lt;/h2&gt;

&lt;p&gt;What happens at expiry is a per-app setting, stored as a nullable string in Room and resolved to a small enum. &lt;code&gt;null&lt;/code&gt; means the default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReminderExpireAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;PROMPT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Prompt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"The reminder screen pops up instead of Home — leave, or add more time."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;CLOSE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Close"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"Closes the app and takes you straight to the Home screen."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the accessibility service acts on it, because it's the one place that already knows what's in front:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;handleExpiredReminder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ExpiredReminder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reminderExpireActions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="nc"&gt;ReminderExpireAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PROMPT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;ReminderExpireAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PROMPT&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;showExpiredTakeover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nc"&gt;ReminderExpireAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CLOSE&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;closeToHome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Close" doesn't kill anything, since Android gives third-party apps no API to force-stop another app. &lt;code&gt;closeToHome&lt;/code&gt; sends a media pause key (&lt;code&gt;KEYCODE_MEDIA_PAUSE&lt;/code&gt;, not the play/pause toggle, which could &lt;em&gt;start&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Late without the service.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A brief negative countdown.&lt;/strong&gt; The countdown has no safety buffer, so if it fires a little after &lt;code&gt;expiresAt&lt;/code&gt;, the chronometer can briefly show negative time. Cancelling in &lt;code&gt;fireExpiry&lt;/code&gt; plus &lt;code&gt;setTimeoutAfter&lt;/code&gt; keeps that window short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two clocks, one decision.&lt;/strong&gt; &lt;code&gt;delay()&lt;/code&gt; measures elapsed time, while the session's end is wall-clock millis. The close job resolves that by re-reading DataStore and comparing against &lt;code&gt;System.currentTimeMillis()&lt;/code&gt; before acting.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use an inexact, Doze-friendly alarm as the backstop for process death, not as the clock the user feels.&lt;/li&gt;
&lt;li&gt;Race it with a precise in-process &lt;code&gt;delay()&lt;/code&gt; while your long-lived service is running, and make expiry idempotent: cancel the alarm first.&lt;/li&gt;
&lt;li&gt;Persist the commitment so Recents and notifications don't re-prompt mid-session.&lt;/li&gt;
&lt;li&gt;Let &lt;code&gt;NotificationCompat&lt;/code&gt;'s chronometer draw the countdown.&lt;/li&gt;
&lt;li&gt;Defer the takeover when the user is somewhere else, and don't count System UI as an app switch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the product side of timers versus blocks versus limits, we wrote up &lt;a href="https://www.tryreclaim.io/blog/app-limits-vs-blocking-vs-reminders/" rel="noopener noreferrer"&gt;app limits vs. blocking vs. reminders&lt;/a&gt;. ReClaim isn't on Google Play yet; it's waitlist-only for now at &lt;a href="https://www.tryreclaim.io" rel="noopener noreferrer"&gt;tryreclaim.io&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed by the ReClaim team.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>coroutines</category>
      <category>notifications</category>
    </item>
    <item>
      <title>Why I Built an Android Launcher That Makes My Phone Harder to Use</title>
      <dc:creator>ReClaim</dc:creator>
      <pubDate>Tue, 13 Oct 2026 15:43:56 +0000</pubDate>
      <link>https://dev.to/tryreclaim/why-i-built-an-android-launcher-that-makes-my-phone-harder-to-use-glk</link>
      <guid>https://dev.to/tryreclaim/why-i-built-an-android-launcher-that-makes-my-phone-harder-to-use-glk</guid>
      <description>&lt;p&gt;Most software I've worked on had the same goal: remove friction. Fewer taps, faster load, smoother onboarding. So it felt strange to start a project whose whole point is to &lt;strong&gt;add&lt;/strong&gt; friction, deliberately, in exactly the right places.&lt;/p&gt;

&lt;p&gt;That project is &lt;a href="https://tryreclaim.io" rel="noopener noreferrer"&gt;ReClaim&lt;/a&gt;, a minimalist Android launcher for people who want to use their phone on purpose. This post is about why I built it as a launcher instead of yet another blocker app, the design decisions that shaped it, and what I learned about building software that wants you to use it &lt;em&gt;less&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: the friction is backwards
&lt;/h2&gt;

&lt;p&gt;Here's the observation that started it. On a typical phone, the most distracting apps are the easiest to reach. They sit on the home screen with bright icons and notification badges, one thumb-tap away. Meanwhile the genuinely useful stuff — your notes, your calendar, your banking app — ends up buried in a folder on page three.&lt;/p&gt;

&lt;p&gt;Nobody designed that layout to hurt you. It just accumulates. But the result is that the path of least resistance almost always leads to a feed.&lt;/p&gt;

&lt;p&gt;I didn't want to rely on willpower to fix that. Willpower is a finite resource and it's usually lowest exactly when you reach for your phone: tired, bored, waiting in line. What I wanted was to change &lt;em&gt;which actions are easy&lt;/em&gt;. Keep productive apps one tap away. Make everything else require a moment of intention first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a launcher, not a blocker
&lt;/h2&gt;

&lt;p&gt;There are plenty of app blockers on Android. I've used several. The recurring problem I ran into is that they sit &lt;em&gt;beside&lt;/em&gt; your home screen rather than &lt;em&gt;being&lt;/em&gt; it. You still see the icons. You still get the pull. The blocker only steps in after you've already tapped.&lt;/p&gt;

&lt;p&gt;A launcher is different. It's the first thing you see every time you unlock your phone. If the home screen itself is calm, a lot of the problem disappears before any blocking kicks in. And because a launcher is your home screen, the rules apply system-wide — set them once, and they're enforced without you having to remember to open another app.&lt;/p&gt;

&lt;p&gt;So ReClaim's home screen is intentionally boring: a clock, the favorites you choose, and a plain, icon-free alphabetical app list. No colourful grid, no badges competing for attention. Finding an app means reading its name, which turns out to be a tiny but meaningful speed bump.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing the levels of friction
&lt;/h2&gt;

&lt;p&gt;The hardest design question was: how much friction is the &lt;em&gt;right&lt;/em&gt; amount? Too little and it's pointless. Too much and people uninstall it in a frustrated moment. I ended up with several tools, each suited to a different relationship with an app.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Hard blocks with a timer
&lt;/h3&gt;

&lt;p&gt;Sometimes you just want an app gone. In ReClaim you pick an app and a duration — anywhere from 5 minutes up to a year — and it's unavailable until the timer runs out. There are 18 preset durations, because I found that making people type a custom number was itself a decision point where they'd talk themselves out of it.&lt;/p&gt;

&lt;p&gt;One small detail I like: when you block an app, ReClaim suggests other apps you'll probably want to block alongside it. If you block one social app, you're likely to just drift to the next one otherwise.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Daily and weekly limits
&lt;/h3&gt;

&lt;p&gt;Not every app deserves a full block. Maybe 20 minutes of a given app per day is fine. With limits, once your time is used up, ReClaim closes the app automatically. No "five more minutes" prompt to negotiate with. And if you genuinely decide you need more, raising the limit lifts the block immediately — the control stays with you, it just requires a conscious choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Intention reminders
&lt;/h3&gt;

&lt;p&gt;This one is my favourite because it's the gentlest. Before you open an app with a reminder set, ReClaim asks: &lt;em&gt;"How long do you intend to use this?"&lt;/em&gt; Then it nudges you when that time is up.&lt;/p&gt;

&lt;p&gt;It's a check-in, not a lockout. In my experience, just being asked that question is often enough to change what happens next. Sometimes the honest answer is "I don't actually need to open this."&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Scheduled blocking
&lt;/h3&gt;

&lt;p&gt;Work hours, bedtime, weekends — recurring windows where specific apps simply aren't available. This removes the daily decision entirely. You decide once, on a calm Sunday afternoon, and future-you doesn't have to fight about it at 11:40 PM.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Three levels of hidden apps
&lt;/h3&gt;

&lt;p&gt;Some apps you want to keep installed but out of sight. ReClaim has three levels of hiding friction: hidden behind nothing (just out of the list), hidden behind a puzzle, or hidden behind a real deterrent. The idea is to match the friction to how strong the habit is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails I refused to compromise on
&lt;/h2&gt;

&lt;p&gt;Building something that restricts access to a phone comes with responsibility. A few rules I set early:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Emergency apps are never blocked by default.&lt;/strong&gt; A focus tool should never get between you and a call you actually need to make.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your other apps work normally.&lt;/strong&gt; ReClaim changes how you &lt;em&gt;reach&lt;/em&gt; apps, not how they behave once open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No ads, ever.&lt;/strong&gt; It would be absurd to build a tool against attention-harvesting and then monetise attention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No account required.&lt;/strong&gt; You shouldn't need to sign up for anything to have a calmer home screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last two shaped a lot of decisions. If there's nothing to gain from keeping people on-screen, you stop designing for engagement and start designing for the moment someone puts the phone down.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned about "anti-engagement" design
&lt;/h2&gt;

&lt;p&gt;A few lessons that I think apply beyond launchers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Friction is a design material.&lt;/strong&gt; We usually treat it as a bug. But a well-placed pause is one of the most respectful things software can offer. The trick is placing it where the user would &lt;em&gt;want&lt;/em&gt; it, if they were thinking clearly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choices beat walls.&lt;/strong&gt; The features people (including me) stick with are the ones that preserve agency. A reminder that asks a question respects you more than a lock that assumes you can't be trusted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defaults matter more than features.&lt;/strong&gt; The plain, text-only app list does more quiet work than any individual blocking feature, simply because it's what you see every single time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the escape hatch honest.&lt;/strong&gt; If raising a limit is possible, make it clear and immediate. Hidden escape hatches feel manipulative; honest ones build trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it's at
&lt;/h2&gt;

&lt;p&gt;ReClaim is free, and you can follow along or join the waitlist at &lt;a href="https://tryreclaim.io" rel="noopener noreferrer"&gt;tryreclaim.io&lt;/a&gt;. If you've built anything in the digital wellbeing space, or have strong opinions about how much friction is too much, I'd genuinely love to hear them in the comments — this is the part of the design I'm still learning the most about.&lt;/p&gt;

&lt;p&gt;The question I keep coming back to is the one the whole launcher is built around: &lt;em&gt;does this make it easier to use my phone on purpose, or easier to lose an hour to it?&lt;/em&gt; It turns out that's a pretty good question for most software.&lt;/p&gt;

</description>
      <category>android</category>
      <category>productivity</category>
      <category>ux</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
