<?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: Stoyan Minchev</title>
    <description>The latest articles on DEV Community by Stoyan Minchev (@stoyan_minchev).</description>
    <link>https://dev.to/stoyan_minchev</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3837616%2Fd4e8aa4b-1bf2-4fba-8492-c70bffc2e5f8.jpg</url>
      <title>DEV Community: Stoyan Minchev</title>
      <link>https://dev.to/stoyan_minchev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stoyan_minchev"/>
    <language>en</language>
    <item>
      <title>Honor watched my Android app come back from the dead — and revoked the battery exemption that let it</title>
      <dc:creator>Stoyan Minchev</dc:creator>
      <pubDate>Wed, 29 Apr 2026 19:18:29 +0000</pubDate>
      <link>https://dev.to/stoyan_minchev/honor-watched-my-android-app-come-back-from-the-dead-and-revoked-the-battery-exemption-that-let-it-10g6</link>
      <guid>https://dev.to/stoyan_minchev/honor-watched-my-android-app-come-back-from-the-dead-and-revoked-the-battery-exemption-that-let-it-10g6</guid>
      <description>&lt;p&gt;I write a safety-critical Android app that watches a phone 24/7 — motion, GPS, screen activity — and emails the family if an elderly person's behavior suddenly looks wrong. Install it on grandma's phone and forget about it. That's the ideas.&lt;/p&gt;

&lt;p&gt;I've written here before about a SAM lambda that hung my geocoder for 21 hours, and about the OTAs that silently strip every battery-optimisation exemption I worked to get. This one is sillier than either. This one is about the time my own service-recovery layers were so visibly enthusiastic that the manufacturer's battery manager looked at them and went, "yeah, that's a zombie, kill it harder."&lt;/p&gt;

&lt;p&gt;The fix shipped last week. The bug is not really a bug - it's a category of mistake - and I think it's worth writing about because every Android dev I know who builds anything that has to run in the background eventually steps in this exact pile.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that should have worked
&lt;/h2&gt;

&lt;p&gt;Fresh install on an Honor phone running MagicOS 9.0 on top of Android 15. Out of the box, brand new, the friendliest possible test.&lt;/p&gt;

&lt;p&gt;The user went through onboarding. Battery optimisation: "Don't optimise." Autostart: on. Recipients added. Setup finished.&lt;/p&gt;

&lt;p&gt;My in-app sanity worker fires one hour after setup completes. It's a simple thing — re-runs every permission probe and snapshots the device state into the diagnostic log. At T+1h it reported &lt;code&gt;health=Healthy&lt;/code&gt;. Battery exemption present. Foreground service running. &lt;code&gt;IMPORTANCE_MIN&lt;/code&gt; notification visible. Everything fine.&lt;/p&gt;

&lt;p&gt;About six hours after that, the family received this email:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Battery optimisation is blocking the app. Tap "Open Settings" below, then set "How Are You?!" to "Don't optimise."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That email is automatic. The app sends it when a runtime health check detects that an exemption it was granted has been quietly revoked. So at some point between hour 1 and hour 7, the OS had said "yes, you have battery exemption" at lunchtime and "no, you don't" by evening, and absolutely nothing the user did caused the change.&lt;/p&gt;

&lt;p&gt;The phone had been sitting on a counter the whole time. The app should have been bored.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened (with timestamps, because Android is a story told in timestamps)
&lt;/h2&gt;

&lt;p&gt;The diagnostic export, when I opened it, was a small horror movie.&lt;/p&gt;

&lt;p&gt;At &lt;strong&gt;17:24:30 UTC&lt;/strong&gt;, MagicOS killed the foreground service. No specific reason in the logs — Honor doesn't bother emitting one. The process just dies.&lt;/p&gt;

&lt;p&gt;Then my 11-layer service-recovery chain woke up, exactly as designed. I built that chain over forty-odd app versions, each layer added because the previous ones weren't enough on some specific device. Eleven layers feels excessive until you ship to a Xiaomi user, after which it feels like a starting point.&lt;/p&gt;

&lt;p&gt;Within the same wall-clock second, three of those layers fired at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;17:24:31  [Application] Recovery: process started, service not running - starting service
17:24:31  [Application] Network recovery: service not running - starting service
17:24:31  [MonitoringService] onStartCommand startId=2
17:24:31  [MonitoringService] onStartCommand startId=3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you're looking at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The process restarted, which fired &lt;code&gt;Application.onCreate&lt;/code&gt; recovery. That layer noticed the foreground service wasn't running and called &lt;code&gt;MonitoringService.start()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A network availability callback fired in the same instant. That layer also noticed the service wasn't running and called &lt;code&gt;MonitoringService.start()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Behind the scenes, a SyncAdapter account-creation poke also fired. Third independent path. Third call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three calls reached the framework. The framework happily delivered all three to &lt;code&gt;onStartCommand&lt;/code&gt;. My init code ran, ran again, ran a third time. Each invocation re-fired &lt;code&gt;startForeground()&lt;/code&gt;. Each invocation re-scheduled the watchdog. Each invocation re-launched the monitoring coroutines.&lt;/p&gt;

&lt;p&gt;I was, from the OS's point of view, &lt;em&gt;aggressively&lt;/em&gt; visible.&lt;/p&gt;

&lt;p&gt;By &lt;strong&gt;19:24 UTC&lt;/strong&gt; — exactly two hours later — the watchdog reported &lt;code&gt;ServiceRecoveryJobService missing - rescheduling&lt;/code&gt;. My battery exemption was gone, and one of my JobScheduler registrations had been ripped out for good measure. The next health check confirmed the regression and shipped the email to the family.&lt;/p&gt;

&lt;p&gt;It took MagicOS less than two hours from "this app got killed once" to "this app's exemption is hereby revoked."&lt;/p&gt;

&lt;h2&gt;
  
  
  The "process resurrection detector"
&lt;/h2&gt;

&lt;p&gt;Here is the unkind realisation that took me an embarrassing length of time to land on.&lt;/p&gt;

&lt;p&gt;Honor PowerGenie, Xiaomi MIUI Power Keeper, Samsung Smart Manager — they all run a heuristic that explicitly looks for what mine does: a process that gets killed and immediately tries to come back via multiple unrelated paths.&lt;/p&gt;

&lt;p&gt;To the OEM, that pattern is one of two things. It's malware-shaped — rootkit-style autostart, ad-stuck zombies, miners that won't quit. Or it's a "stubborn" app that doesn't take the hint that the OS would prefer it to stay down. Either way, it earns demerits. Each concurrent recovery path that lights up in the same second is one tick on a process-resurrection counter. Hit the threshold and the OEM strips your trust.&lt;/p&gt;

&lt;p&gt;Multi-path concurrent recovery is not, in PowerGenie's eyes, a clever defence-in-depth strategy. It's a symptom of a misbehaving app.&lt;/p&gt;

&lt;p&gt;And of course every single one of my eleven layers is genuinely necessary on a different OEM. Pixel App Standby will let &lt;code&gt;BOOT_COMPLETED&lt;/code&gt; fire but quietly throttle the AlarmManager chain into next Tuesday. Samsung lets the AlarmManager fire but JobScheduler registrations vanish through Doze. Xiaomi keeps the JobScheduler but stops delivering &lt;code&gt;onTaskRemoved&lt;/code&gt; after a while. Whenever I removed any of these layers in the past, a different family stopped getting alerts. So the eleven of them are real. The problem is not that they exist — the problem is that, on a process restart, they all fire on the &lt;em&gt;same trigger&lt;/em&gt; in the &lt;em&gt;same second&lt;/em&gt;, and that's the part PowerGenie reads.&lt;/p&gt;

&lt;p&gt;This is a problem you only have if your recovery is good enough that all of it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke, in three signals
&lt;/h2&gt;

&lt;p&gt;Working backwards from the export, I could pin down three specific things MagicOS observed and graded me down on. None of the three was a clever, specific bug. They were the kind of thing you only see once you know to look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signal 1 — multi-path concurrent recovery.&lt;/strong&gt; Three &lt;code&gt;onStartCommand&lt;/code&gt; calls in the same second, two of them firing &lt;code&gt;startForeground()&lt;/code&gt; redundantly, watchdog reschedule re-fired, monitoring coroutines re-launched. To PowerGenie, three increments instead of one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signal 2 — JobScheduler frequency.&lt;/strong&gt; My periodic watchdog ran every fifteen minutes. That's about ninety-six runs per day. According to AOSP &lt;code&gt;BatteryStatsService&lt;/code&gt; telemetry, that puts an app in the top one percent of UIDs by job count. Pixel App Standby Bucket demotion and Samsung's Sleeping Apps classifier read this number directly. The historical reason I picked fifteen minutes was that fifteen is the floor for &lt;code&gt;PeriodicWorkRequest&lt;/code&gt;. I picked the floor because it was the floor, not because anyone measured it against alternatives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signal 3 — time-based location requests with no explicit distance hint.&lt;/strong&gt; My GPS wake probe — a five-second &lt;code&gt;requestLocationUpdates(HIGH_ACCURACY)&lt;/code&gt; that fires at most once every four hours — built its &lt;code&gt;LocationRequest&lt;/code&gt; without &lt;code&gt;setMinUpdateDistanceMeters(0f)&lt;/code&gt;. The default is implementation-defined, undocumented, and on some Play Services versions non-zero. Samsung and Pixel battery analysers flag time-only location requests harder than the equivalent request that explicitly declares its intent. Two other call sites in my code already had the line. The wake probe didn't, because I'd written it earlier and never came back.&lt;/p&gt;

&lt;p&gt;Each signal on its own is small. The three together added up to "this UID is suspicious," and PowerGenie should clear its trust, I hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, three small surfaces
&lt;/h2&gt;

&lt;p&gt;The pleasant surprise was that none of these three needed a clever solution. They needed the smallest possible change at the right place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anti-thrash gate on &lt;code&gt;onStartCommand&lt;/code&gt;.&lt;/strong&gt; Five seconds, monotonic clock, one &lt;code&gt;@Volatile&lt;/code&gt; field on the Service:&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;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;CONCURRENT_START_WINDOW_MS&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5_000L&lt;/span&gt;

&lt;span class="nd"&gt;@Volatile&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;lastStartCommandHandledMs&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="mi"&gt;0L&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;onStartCommand&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="n"&gt;flags&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;startId&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="nc"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onStartCommand&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="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;startId&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;now&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SystemClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;elapsedRealtime&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;sinceLast&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lastStartCommandHandledMs&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;lastStartCommandHandledMs&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;sinceLast&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nc"&gt;CONCURRENT_START_WINDOW_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;DiagnosticLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"MonitoringService"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"onStartCommand startId=$startId — duplicate within ${sinceLast}ms; skipping re-init"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;START_STICKY&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;lastStartCommandHandledMs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;

    &lt;span class="c1"&gt;// ...existing init body unchanged from here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth being specific about.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SystemClock.elapsedRealtime()&lt;/code&gt;, not &lt;code&gt;System.currentTimeMillis()&lt;/code&gt;, because wall clock can jump backwards (NTP sync, DST in a few zones, manual time change), and you do not want a "duplicate" gate that ever rejects more aggressively than the window you wrote down.&lt;/p&gt;

&lt;p&gt;The gate sits at the &lt;strong&gt;convergence point&lt;/strong&gt;, not at every caller. The eleven layers above need to keep firing independently — each one is the only thing that works on some specific OEM, and every gate I added on the divergent caller side immediately created the next OEM-specific gap. Deduplication belongs where the calls converge, not where they diverge. &lt;code&gt;onStartCommand&lt;/code&gt; is the chokepoint where every layer's effort eventually arrives, so it's the right place to be visible-once instead of visible-thrice.&lt;/p&gt;

&lt;p&gt;The framework is fine with this, by the way. AOSP &lt;code&gt;ActivityManagerService.ServiceRecord&lt;/code&gt; tracks one deadline per service, and a single successful &lt;code&gt;startForeground()&lt;/code&gt; call clears every pending deadline regardless of how many &lt;code&gt;startForegroundService()&lt;/code&gt; calls are stacked behind it. The &lt;em&gt;first&lt;/em&gt; &lt;code&gt;onStartCommand&lt;/code&gt; of the burst already satisfied the OS. The duplicates were doing redundant work — work that PowerGenie watched, and that the OS would have been just as happy without.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;setMinUpdateDistanceMeters(0f)&lt;/code&gt; on the GPS wake probe.&lt;/strong&gt; One line:&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="nc"&gt;LocationRequest&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="nc"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PRIORITY_HIGH_ACCURACY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDurationMillis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GPS_WAKE_PROBE_DURATION_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMaxUpdates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMinUpdateDistanceMeters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// declarative intent for OEM analysers&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;Behavior identical. &lt;code&gt;0f&lt;/code&gt; says "deliver every fix regardless of distance," which is exactly what a five-second wake probe wanted anyway. The change is purely about handing the OEM's static analyser something explicit to read instead of letting it infer "time-based, no distance hint, suspicious."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watchdog cadence: 15 → 30 minutes.&lt;/strong&gt; One constant. The watchdog is not the primary FGS-survival layer — that's the AlarmManager 5-min chain plus the 8-hour &lt;code&gt;setAlarmClock()&lt;/code&gt; safety net. The WorkManager job is explicitly the &lt;em&gt;backup&lt;/em&gt;. Halving its cadence (96 runs/day → 48 runs/day) cuts the per-UID job count in half. On Android 12+ the periodic worker is Doze-deferred anyway, so the "15 is more responsive than 30" claim doesn't hold during the exact moments — deep sleep, OEM kill cycle — when revocation actually happens.&lt;/p&gt;

&lt;p&gt;I also flipped &lt;code&gt;ExistingPeriodicWorkPolicy.KEEP&lt;/code&gt; to &lt;code&gt;UPDATE&lt;/code&gt; for this release only, so existing installs migrate to the new cadence on first app open instead of waiting weeks for WorkManager to organically re-evaluate.&lt;/p&gt;

&lt;p&gt;Three fixes. Three small surfaces. Total diff: maybe forty lines, including the KDoc explaining why the constants are what they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Some recovery patterns look exactly like malware to a battery manager.&lt;/strong&gt; I had been thinking of my eleven layers as defence in depth. From PowerGenie's side they look like a process that won't die quietly. The OEM doesn't care which I am. It grades the visible behaviour. Every concurrent path that wakes the same service in the same second is one increment on the resurrection counter — and if your eleven layers all fire on the same trigger (process restart, network change, boot), you have handed the heuristic an unambiguous reading. The fix is not to remove layers. It is to keep them and converge their effect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The floor is not a free choice.&lt;/strong&gt; I picked 15-minute watchdog cadence because it was the lowest periodic interval &lt;code&gt;WorkManager&lt;/code&gt; allows. I never asked whether 15 was better than 30 for what the watchdog actually does. It was the floor and I assumed that was the answer. It wasn't. The floor was &lt;em&gt;available&lt;/em&gt;. The right value was somewhere above it where the protection still held but the OEM signal stopped firing. Every "minimum allowed" comment in your codebase is a candidate for this question: did anyone ever actually pick this number, or did someone just take what was on the bottom shelf?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Declarative intent is cheap and load-bearing.&lt;/strong&gt; &lt;code&gt;setMinUpdateDistanceMeters(0f)&lt;/code&gt; was zero behaviour change. It existed purely to hand a sentence to a machine analyser that was going to read your &lt;code&gt;LocationRequest&lt;/code&gt; whether you liked it or not. The lesson generalises. When a &lt;code&gt;LocationRequest&lt;/code&gt; / &lt;code&gt;JobInfo&lt;/code&gt; / &lt;code&gt;WorkRequest&lt;/code&gt; has explicit fields and you leave them unset, you are letting an OEM-specific default fill in for you. That default is undocumented, varies between platform versions, and biases against you. Set every field that matters even if the value matches the platform default — at least then the analyzer has something concrete to read instead of guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The convergence point is the right place for dedup.&lt;/strong&gt; I genuinely tried, briefly, to dedupe at every caller — gate &lt;code&gt;Application.attemptServiceRecovery&lt;/code&gt;, gate &lt;code&gt;NetworkConnectivityMonitor.onAvailable&lt;/code&gt;, gate the SyncAdapter trigger. Within an hour it was obvious that any one of those gates I added now created the next OEM-specific gap, because each layer is the only thing that works on some specific device. The dedup belongs at the place where the divergent paths converge — &lt;code&gt;onStartCommand&lt;/code&gt; for service restarts, &lt;code&gt;processLocation&lt;/code&gt; for GPS writes (a separate post one day). Wherever the system actually &lt;em&gt;does the thing&lt;/em&gt;. That is the chokepoint where you can be both correct and visible-to-OEM-quiet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single-OEM forensics, multi-OEM patches.&lt;/strong&gt; This whole story came from one Honor diagnostic export. Three of the fixes generalise — Pixel and Samsung both read the same job-count and concurrent-&lt;code&gt;onStartCommand&lt;/code&gt; signals — but I would not have known to look without the Honor evidence. If you are shipping continuous-background work on Android, every diagnostic export from a device that misbehaved is worth more than a thousand emulator hours. The OEM behaviour that actually matters is undocumented, regional, version-specific, and only shows up under the exact conditions of a real install on a real phone in someone's drawer.&lt;/p&gt;

&lt;p&gt;The really uncomfortable part of all of this, the one I keep coming back to, is that on Android &lt;em&gt;good engineering can be the wrong choice&lt;/em&gt;. The recovery layers are correct. The eleven of them, individually, are each load-bearing on some device. Together they are the reason "install and forget" works for the user, in most cases (I can't fight with OTA resetting my configuration silently). And on Honor, the eleven of them firing in the same second of process restart is the trigger for "this is a zombie, lower its trust." There is no platform contract that says "thou shalt not recover thy service via more than two paths at once," and there cannot be, because the OEMs do not write contracts. They write laws! And the laws this week is "concurrent recovery is suspicious."&lt;/p&gt;

&lt;p&gt;So my eleven layers stay. They just learned to whisper.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The app is called "How Are You?!" — it's on Google Play. If you've shipped an Android app that has to run continuously, I'd love to hear which OEM caught you out, what the giveaway was in your logs, and how small the eventual fix turned out to be.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>programming</category>
      <category>debugging</category>
    </item>
    <item>
      <title>I pushed my Android app to production. Then Android and the OEMs spent two weeks tearing it apart.</title>
      <dc:creator>Stoyan Minchev</dc:creator>
      <pubDate>Wed, 22 Apr 2026 20:09:16 +0000</pubDate>
      <link>https://dev.to/stoyan_minchev/i-pushed-my-android-app-to-production-then-android-and-the-oems-spent-two-weeks-tearing-it-apart-1e70</link>
      <guid>https://dev.to/stoyan_minchev/i-pushed-my-android-app-to-production-then-android-and-the-oems-spent-two-weeks-tearing-it-apart-1e70</guid>
      <description>&lt;p&gt;I build a safety-critical Android app that monitors elderly people living alone. It watches the phone 24/7 — motion, GPS, screen activity — and emails their family when something looks wrong. No buttons to press. No wearable to charge. Install it on your mum's phone and forget about it.&lt;/p&gt;

&lt;p&gt;That last sentence — &lt;strong&gt;install and forget&lt;/strong&gt; — is the entire product. It is the only reason this works for the target user, who is 65+ years old, does not open apps she did not put there herself, and will not read an email from us telling her to go into Settings and tap anything. If the app needs her attention to keep working, the app has failed.&lt;/p&gt;

&lt;p&gt;In the last two weeks I found out that Android does not want me to keep that promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke first: the app was too quiet
&lt;/h2&gt;

&lt;p&gt;I shipped a build. A tester installed it, went through setup, and never open the app again, exactly as a real elderly user would. Two and a half weeks later I opened the diagnostic export and found a phone that had, from Android's point of view, essentially disappeared.&lt;/p&gt;

&lt;p&gt;The battery-optimization exemption I had granted at setup was reset. The problem was subtler. Because the app had not been opened in 18 days, every OEM-level "smart battery" heuristic had decided the app was unused and put it into the most aggressive standby bucket the platform has. My auto-recovery layers — WorkManager watchdog, AlarmManager safety net, boot receiver — were all there. They were all throttled to the point of being ornamental.&lt;/p&gt;

&lt;p&gt;My 11 layers of recovery are all triggered by the device doing something. Boot. Screen on. Time change. App opened. Network regained. If the user does not open the app, the device stays in deep doze for days, and my "recovery" is just a sequence of alarms that will eventually fire when the OS decides they can.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;bootstrap paradox of Android background work.&lt;/strong&gt; The app cannot wake itself up reliably. It needs an external caller to poke it. And if you are writing a safety-critical app where the entire value proposition is "you do not have to think about this," the user cannot be the external caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: somebody else has to ring the doorbell
&lt;/h2&gt;

&lt;p&gt;I needed an external, non-user-driven wake source. Something that would touch the phone on a predictable cadence regardless of whether the user opened the app.&lt;/p&gt;

&lt;p&gt;FCM push messages are that thing, if you use them correctly. Here is the shape of it — none of this is private:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A GitHub Actions workflow runs on a cron. Mine is every 6 hours. Its only job is to call the FCM API and send a data message to a topic called &lt;code&gt;heartbeat-all&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The Android app subscribes to that topic at first launch.&lt;/li&gt;
&lt;li&gt;The app registers a &lt;code&gt;FirebaseMessagingService&lt;/code&gt;. When a heartbeat arrives, the service does one thing: it runs my existing recovery classifier. Is the foreground service alive? Are permissions still granted? Is the learning phase progressing? If anything looks wrong, fix it. If everything is fine, do nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few things are worth being specific about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Topic messaging, not device tokens.&lt;/strong&gt; I do not want to store FCM tokens on a server. I have no server. With topic messaging, the server-side cron sends one message and Google's fan-out reaches every subscribed device. Zero per-device state. The external caller (GitHub Actions) does not know who my users are, and I like it that way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data messages with &lt;code&gt;priority: HIGH&lt;/code&gt;, not notification messages.&lt;/strong&gt; Notification messages are shown by the system and give the app almost no work budget. Data messages trigger &lt;code&gt;onMessageReceived&lt;/code&gt; even in Doze, provided the priority is high. You pay for this with a quota — FCM will downgrade high-priority data messages if you overuse them — so 6-hour cadence is the floor. More frequent and you get throttled; less frequent and dormant phones drift too far before the next wake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The handler always checks priority before acting.&lt;/strong&gt; If FCM downgraded the message (which it will, eventually, on some device, for reasons Google does not fully document), the heartbeat handler does not run the full recovery path inline. It enqueues an expedited WorkManager job instead, because WorkManager is exempt from the foreground-service-while-in-use restrictions that killed the naive direct-execution path on Android 14. This one took me a minor-version patch to get right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is a compile-time kill switch.&lt;/strong&gt; &lt;code&gt;ENABLE_FCM_HEARTBEAT&lt;/code&gt; is a &lt;code&gt;BuildConfig&lt;/code&gt; boolean. If a specific OEM starts misbehaving, I can ship a hotfix that disables the entire wake path without touching manifest registrations or Firebase configuration. Keep the lever small and reversible.&lt;/p&gt;

&lt;p&gt;That was roughly a week of work. FCM itself is easy to configure — the hard part is not "how do I send a push message," it is "what is the contract my handler signs when the push arrives, and what does it promise not to do on a misbehaving device." Get that contract right and you have bought yourself a non-user-driven wake channel that survives dormant installs.&lt;/p&gt;

&lt;p&gt;Testing now before I ship it. &lt;/p&gt;

&lt;p&gt;And then I started worrying about everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  I went looking for what else could silently break
&lt;/h2&gt;

&lt;p&gt;Shipping the FCM heartbeat felt too quiet. A fix that works is always a little suspicious, especially on Android, and especially when the failure it is fixing took me two and a half weeks of dormant-phone diagnostics to even notice. So after the heartbeat stabilised I sat down with a blank page and forced myself to answer one question: &lt;strong&gt;what else can invalidate the state my recovery is built on top of, without the user doing anything?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I listed everything the app depends on to keep running. Battery-optimization exemption. Autostart permission on OEMs that have it. Foreground-service type declaration being honoured. Notification channel being visible. The device's standby bucket being something other than "restricted." All of these are things the app establishes at setup and then assumes, forever, because nothing in the normal operation of a phone should flip them back.&lt;/p&gt;

&lt;p&gt;OTAs are not normal operation.&lt;/p&gt;

&lt;p&gt;I do not have a dramatic in-production discovery story for this one. I do not have a log line. The common thread is that the OEM considers battery-optimization exemption to be a privilege the app was granted under the previous system image, and the new system image is entitled to re-evaluate.&lt;/p&gt;

&lt;p&gt;And here is what makes it the worst possible failure mode for my app: &lt;strong&gt;the user will never know.&lt;/strong&gt; The OTA completes overnight. The phone reboots. The boot receiver fires. The foreground service tries to start. On the exempted path, it runs. On the reset path, it either fails silently on Android 14+ because &lt;code&gt;foregroundServiceType=location&lt;/code&gt; requires permissions that are no longer considered granted, or it starts but runs in a standby bucket that throttles every alarm I schedule into next week. The &lt;code&gt;IMPORTANCE_MIN&lt;/code&gt; notification looks the same either way. The elderly user will not notice. The adult child who installed the app months ago has moved on. The app is sitting on the phone doing approximately nothing, and nobody knows.&lt;/p&gt;

&lt;p&gt;A lot of work on 11 layers of recovery. All of it sitting on top of assumptions the OS is allowed to invalidate between Tuesday and Wednesday, without telling me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting an OTA without any help from the OS
&lt;/h2&gt;

&lt;p&gt;If the platform will not tell you an OTA happened, you have to work it out. The trick is &lt;code&gt;Build.FINGERPRINT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Build.FINGERPRINT&lt;/code&gt; is a string that uniquely identifies the system image — OEM, device, build ID, build tags, build date. Every OTA changes it. It changes at a point in time the app cannot be guaranteed to observe directly, but it is stable across every wake once the OTA has completed.&lt;/p&gt;

&lt;p&gt;So: persist the last-seen fingerprint. On every wake — service start, boot receiver, heartbeat handler, everywhere — compare the current fingerprint against the stored one. If they differ, the device has been updated since the last time this app ran. At that instant, the app knows an OTA happened, even though nobody told it.&lt;/p&gt;

&lt;p&gt;That is the detector. The response is where it gets interesting, because you still cannot fix anything from code. The permissions have been revoked. The app cannot silently re-grant them. The user has to go into Settings. But the user will never open the app.&lt;/p&gt;

&lt;p&gt;So the response is an email.&lt;/p&gt;

&lt;p&gt;When the fingerprint changes, the app checks: are the critical exemptions still present? If yes, rotate the stored fingerprint, log it, move on — benign OTA. If no, the app flips a flag (&lt;code&gt;ota_degradation_mode&lt;/code&gt;), emails the family contact with concrete two-step instructions ("Battery → Unrestricted, Autostart → On, single visit to Settings, five minutes"), and schedules a follow-up worker for seven days out. The email does not go to the elderly user. It goes to the adult child who originally set the app up, because they are the one who can either drive over or walk the parent through it on the phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Here is where I get angry
&lt;/h2&gt;

&lt;p&gt;This is my first Android project in Kotlin, since 14 years. I have been a developer for a long time — I have shipped on other platforms, on servers, on the web — but I had not written a line of Kotlin before this one. I came to Android again, with the assumption that a mainstream mobile platform in 2026 would be a reasonably solved environment. It is not. This has been, by a clear margin, the most hostile platform I have ever written code for, and I mean that in a specific way: it is hostile to the developer.&lt;/p&gt;

&lt;p&gt;Not because Android is hard. Android is fine as a platform. The problem is that &lt;strong&gt;there is no platform anymore.&lt;/strong&gt; There are fifteen platforms pretending to be one, and the differences between them are not surface-level. They are the parts of the OS that determine whether your background work runs at all. Samsung, Xiaomi, Honor, OPPO, Vivo, Asus, OnePlus — each one has a proprietary battery manager, a proprietary autostart manager, a proprietary app-standby bucket scheme, and a proprietary philosophy about what apps are allowed to do while the user is not looking. None of these behaviours are documented in a way a developer could build against. All of them change without notice, including as a side effect of an OTA.&lt;/p&gt;

&lt;p&gt;Google's published APIs promise one thing. The OEM fork delivers something weaker. The standards are not enforced. There is no compliance suite the OEM has to pass to ship "Android." The developer is left to reverse-engineer each OEM's behaviour on real hardware, discover the regression via user bug reports or, in my case, a phone not being opened for two and a half weeks, and then add another compatibility layer that will itself need updating the next time the OEM changes its mind.&lt;/p&gt;

&lt;p&gt;I do not think this is by accident. The OEMs compete on battery life — the review sites measure it, the spec sheets advertise it — and the cheapest way to win a battery-life benchmark is to kill background apps harder than the next manufacturer. The developer's app is not a stakeholder in this fight. The developer is collateral. We are victims of the marketing. Good benchmarks, better marketing, more sales -&amp;gt; annoyed developers!&lt;/p&gt;

&lt;p&gt;And here is the thing that really bothers me. My app is not trying to serve ads. It is not mining crypto. It is not stealing the user's contacts. It is trying, on behalf of the user who installed it, to notice if an elderly person stops moving for too long and tell their family. That is the entire feature. And every single one of these OEM-specific battery managers is designed, at its core, to stop exactly this kind of work from happening. Because it looks, from the kernel's perspective, identical to the bad actor.&lt;/p&gt;

&lt;p&gt;I cannot fix that from code. No amount of layered recovery fixes it. The OEMs can invalidate my product's promise between Tuesday and Wednesday by pushing an OTA, and I will find out about it when a family emails me to ask why grandma's phone stopped sending heartbeats three weeks ago.&lt;/p&gt;

&lt;p&gt;I have not seen a serious Android project at my day job in years. I used to wonder why. I do not wonder anymore. If you were deciding, today, where to spend your next year of engineering time, would you pick the platform where your core value proposition can be invalidated by a silent system update from a manufacturer you have no contact with? Or would you pick the one where the platform vendor publishes the API, enforces it, and ships the update themselves?&lt;/p&gt;

&lt;p&gt;The answer is visible in the job listings. Serious product work has moved to iOS and to the web. &lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Install and forget" is achievable on Android, but it is not a one-time promise — it is an ongoing fight.&lt;/strong&gt; You can reach a state where the app survives first install, first reboot, first weekend in a drawer. You cannot reach a state where it is guaranteed to survive the next OTA you did not know was coming. The best you can do is detect degradation on the next wake and recover gracefully, through the family, without the elderly user ever noticing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your background work matters, you need an external wake source.&lt;/strong&gt; Every internal recovery mechanism — AlarmManager, WorkManager, boot receivers, sticky services — is eventually rate-limited by the device's opinion of whether your app matters. A dormant install, on a dormant phone, running aggressive OEM battery management, can reach a state where nothing internal will wake it for days. An external push with a server-side cron is the only thing I have found that reliably breaks that state. FCM topic messaging plus a scheduled cron job is roughly a week of engineering. If your app has safety-critical reliability requirements, it is the cheapest week of engineering you will ever spend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The permission you got at setup is not the permission you will have next month.&lt;/strong&gt; Treat every exemption — battery optimization, autostart, exact alarms, foreground service type — as a fact you re-verify on every wake, not a state you establish once and assume. Compare &lt;code&gt;Build.FINGERPRINT&lt;/code&gt; to the last-seen value, re-check exemptions when it changes, and have an out-of-band path (email, SMS, something outside the device) to tell a human when an exemption has been revoked by the OS without the user's involvement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent degradation is worse than a crash.&lt;/strong&gt; This echoes the last piece I wrote about a different bug, and it keeps being true. If the app had crashed after the OTA, Play Console would have told me. It did not crash. It kept running, doing nothing, drawing its minimum-importance notification, while the thing it was supposed to protect against — an elderly person falling in a quiet house — was no longer being monitored. There is no Play Console alert for "app is running but has stopped doing anything useful." You have to build that alert yourself. I have now built three of them, and I expect to build a fourth before the year is out.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The app is called "How Are You?! Senior Safety" It is on Google Play. If you have shipped an Android app that has to run continuously without the user opening it, I would like to hear what broke for you and how you found out.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>programming</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>A single Kotlin lambda silently broke my app for 21 hours - and I only found the bug because I crossed a border</title>
      <dc:creator>Stoyan Minchev</dc:creator>
      <pubDate>Sun, 12 Apr 2026 20:38:19 +0000</pubDate>
      <link>https://dev.to/stoyan_minchev/a-single-kotlin-lambda-silently-broke-my-app-for-21-hours-and-i-only-found-the-bug-because-3mgj</link>
      <guid>https://dev.to/stoyan_minchev/a-single-kotlin-lambda-silently-broke-my-app-for-21-hours-and-i-only-found-the-bug-because-3mgj</guid>
      <description>&lt;p&gt;I build a safety-critical Android app that monitors elderly people living alone. It watches their phone 24/7 — motion, GPS, screen activity — and emails their family when something looks wrong. No buttons to press, no wearable to charge. Install it on grandma's phone and forget about it.&lt;/p&gt;

&lt;p&gt;I took a trip from Bulgaria to Romania in early April to test the app in real conditions and have a small vacation with my family. I drove across the Danube at the Vidin - Calafat bridge. Everything was working fine. Then at 14:55, the app went completely silent.&lt;/p&gt;

&lt;p&gt;Not crashed. Not killed by the OS. Silent.&lt;/p&gt;

&lt;p&gt;For the next &lt;strong&gt;21 hours and 42 minutes&lt;/strong&gt;, the motion sensor recorded 682 events. The GPS hardware was acquiring satellite fixes with 11-meter accuracy. The app was running, awake, doing its job. But not a single location reached the database.&lt;/p&gt;

&lt;p&gt;The next morning, the AI looked at the last known position — a border crossing — and the 12-hour data gap, and did what it was designed to do: it sent an URGENT alert. Except I was fine. I was in Craiova, 200km away, sleeping in a hotel. The alert was anchored to a stale coordinate from the previous afternoon.&lt;/p&gt;

&lt;p&gt;I spent two days tracing this. The root cause was one line of Kotlin.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interface that lies to you
&lt;/h2&gt;

&lt;p&gt;Android's &lt;code&gt;Geocoder&lt;/code&gt; class converts GPS coordinates into street addresses. On API 33+, there's an async callback version:&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="n"&gt;geocoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFromLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="c1"&gt;// do something with the result&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That trailing lambda is Kotlin's SAM (Single Abstract Method) conversion. It looks clean. It compiles. It works perfectly — until it doesn't.&lt;/p&gt;

&lt;p&gt;The interface behind this lambda is &lt;code&gt;Geocoder.GeocodeListener&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;GeocodeListener&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onGeocode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@NonNull&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Address&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Nullable&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;errorMessage&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See that second method? &lt;code&gt;onError&lt;/code&gt; has a &lt;strong&gt;default empty implementation&lt;/strong&gt;. When you use a SAM lambda, Kotlin only implements the single abstract method — &lt;code&gt;onGeocode&lt;/code&gt;. The default &lt;code&gt;onError&lt;/code&gt; stays empty.&lt;/p&gt;

&lt;p&gt;So what happens when geocoding fails? Network timeout. No roaming data after crossing a border. Play Services killed by the OEM battery manager. Any of a dozen things that go wrong on real Android devices in real countries.&lt;/p&gt;

&lt;p&gt;The framework calls &lt;code&gt;onError()&lt;/code&gt;. The empty default runs. Nothing happens. &lt;strong&gt;The continuation is never resumed. The coroutine hangs forever.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it killed everything, not just geocoding
&lt;/h2&gt;

&lt;p&gt;If the geocoder had hung in isolation, it would have been a minor bug — one address lookup fails, you move on. But my code looked like this:&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="n"&gt;processLocationMutex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withLock&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;address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;reverseGeocode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// hangs here&lt;/span&gt;
    &lt;span class="nf"&gt;insertLocationData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;address&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 &lt;code&gt;processLocationMutex&lt;/code&gt; exists for a good reason. Four independent systems can trigger a GPS write at the same time — the stillness detector, the periodic scheduler, the force probe, and the area stability detector. Without the mutex, they race on the stationarity filter and insert duplicate rows that defeat the drive-through filtering logic.&lt;/p&gt;

&lt;p&gt;But when &lt;code&gt;reverseGeocode()&lt;/code&gt; hung, the mutex was held forever. Every subsequent GPS fix from every trigger path called &lt;code&gt;processLocation()&lt;/code&gt;, tried to acquire the mutex, and blocked. Behind a coroutine that would never wake up.&lt;/p&gt;

&lt;p&gt;No exception. No crash. No log entry. Just a growing queue of frozen coroutines, each holding a perfectly good satellite fix that would never reach the database.&lt;/p&gt;

&lt;p&gt;The motion sensor kept firing. The GPS kept acquiring. The diagnostic logs show two successful HIGH_ACCURACY fixes at 21:37 and 21:38 — 11-meter accuracy, acquired in 2.5 seconds — both of which entered &lt;code&gt;processLocation()&lt;/code&gt; and silently queued behind the hung mutex holder from 7 hours earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only recovery was killing the process
&lt;/h2&gt;

&lt;p&gt;At 12:19 the next day — almost 22 hours after the hang started — I force-stopped the app from Android settings. The process died. The singleton mutex died with it. On restart, everything worked again.&lt;/p&gt;

&lt;p&gt;But by then, the damage was done. The AI had already sent a false URGENT alert based on 12-hour-old coordinates. And a weekly re-calibration job had run during the trip, learning the border crossing drive-through as a "frequent location," which caused a cascade of further false alerts over the following days.&lt;/p&gt;

&lt;p&gt;One hung lambda. One stale coordinate. Days of downstream consequences.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix has three layers
&lt;/h2&gt;

&lt;p&gt;I don't trust single fixes for problems that can kill 21 hours of data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: Explicit object, both methods implemented.&lt;/strong&gt;&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;listener&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="err"&gt;: &lt;/span&gt;&lt;span class="nc"&gt;Geocoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GeocodeListener&lt;/span&gt; &lt;span class="p"&gt;{&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;onGeocode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;MutableList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;&amp;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;hasResumed&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;hasResumed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
            &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;formatAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;firstOrNull&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;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errorMessage&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;hasResumed&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;hasResumed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
            &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&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="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;No SAM conversion. Both callbacks resume the continuation. The &lt;code&gt;hasResumed&lt;/code&gt; flag guards against the race where both fire, or either fires after timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: Hard timeout ceiling.&lt;/strong&gt;&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="nf"&gt;withTimeoutOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10_000L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;suspendCancellableCoroutine&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;geocoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFromLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;listener&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;Even if some future Android version adds a third callback method with another empty default, the coroutine dies after 10 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3: Geocoding moved outside the mutex.&lt;/strong&gt;&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="c1"&gt;// Geocoding is slow and can hang — never inside the mutex&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reverseGeocodingService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reverseGeocode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Only the database insert is protected (50ms critical section, not 10s+)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;acquired&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withTimeoutOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60_000L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;processLocationMutex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withLock&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;insertLocationData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;address&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 mutex timeout is a tripwire. If something else wedges the lock in the future, we log a diagnostic error and drop the fix rather than queuing forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SAM conversion is not a convenience. It's a contract you didn't read.&lt;/strong&gt; When you write a trailing lambda, you're implementing one method and accepting the defaults for everything else. If those defaults are no-ops, you've written code that silently drops errors. The compiler won't warn you. The IDE won't flag it. It works perfectly until it doesn't.&lt;/p&gt;

&lt;p&gt;The scary part is that &lt;code&gt;GeocodeListener&lt;/code&gt; isn't unusual. Android has dozens of interfaces with default error methods. &lt;code&gt;WebViewClient.onReceivedError()&lt;/code&gt; has a default. &lt;code&gt;MediaPlayer.OnErrorListener&lt;/code&gt; has patterns where partial implementation looks complete. Every SAM-converted lambda on an interface with default methods is a potential silent failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mutexes amplify hangs into outages.&lt;/strong&gt; A 10-second geocoding timeout would have been invisible — one null address, one row without a street name, nobody notices. But a mutex turned a local hang into a system-wide 21-hour data loss. If you're using a mutex to serialize writes, the critical section should contain only writes. Anything that touches the network, the filesystem, or a third-party service belongs outside the lock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent failures are worse than crashes.&lt;/strong&gt; If the geocoder had thrown an exception, I would have found it in the first hour. Instead, it hung — producing no error, no log, no crash report. The only evidence was the absence of data in a database table. In a safety-critical app that monitors whether elderly people are still moving, silence is the most dangerous failure mode there is.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The app is called "How Are You?! Senior Safety" — soon it will be released, once I am confident, that there are no bad surprises popping up. Have you ever been bitten by a default interface method you didn't know existed?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>programming</category>
      <category>debug</category>
    </item>
    <item>
      <title>I built a 126K-line Android app with AI — here is the workflow that actually worked for me</title>
      <dc:creator>Stoyan Minchev</dc:creator>
      <pubDate>Sun, 29 Mar 2026 08:58:53 +0000</pubDate>
      <link>https://dev.to/stoyan_minchev/i-built-a-126k-line-android-app-with-ai-here-is-the-workflow-that-actually-works-2llj</link>
      <guid>https://dev.to/stoyan_minchev/i-built-a-126k-line-android-app-with-ai-here-is-the-workflow-that-actually-works-2llj</guid>
      <description>&lt;p&gt;Most developers trying AI coding tools hit the same wall. They open a chat, type "build me a todo app," get something that looks right, and then spend 3 hours fixing the mess. They try again with a bigger project and it falls apart faster. They conclude AI coding is overhyped.&lt;/p&gt;

&lt;p&gt;I had the same experience. Then I changed my approach — not the tool, the process around it.&lt;/p&gt;

&lt;p&gt;Over 4 months I built &lt;a href="https://howareu.app" rel="noopener noreferrer"&gt;How Are You?!&lt;/a&gt;, a safety-critical Android app that monitors elderly people living alone. 126,000 lines of Kotlin. 144 versions. 130 test files. 3 languages. Solo developer with zero Kotlin experience when I started. The entire codebase was AI-generated — I never wrote Kotlin manually.&lt;/p&gt;

&lt;p&gt;This article is not about the app. It is about the workflow that made this possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why most people fail with AI coding
&lt;/h2&gt;

&lt;p&gt;Two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Expectations are wrong.&lt;/strong&gt; People expect to describe a feature in plain English and get production code. That works for a function. It does not work for a system. AI is not a replacement for engineering — it is an amplifier. If your input is vague, the output is vague.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No structure around the AI.&lt;/strong&gt; They open a chat, prompt, get code, paste it, prompt again. There is no architecture. No shared context. No accumulated knowledge. Every conversation starts from zero.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is not better prompting. It is better engineering process — with the AI as a participant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Architecture before code (BMAD)
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of code, I used &lt;a href="https://docs.bmad-method.org/" rel="noopener noreferrer"&gt;BMAD&lt;/a&gt; (a structured methodology for AI-assisted development) to create:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Product Requirements Document&lt;/strong&gt; — what the app does, who it is for, what the constraints are&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture document&lt;/strong&gt; — module boundaries, layer responsibilities, error handling patterns, data flow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project context&lt;/strong&gt; — coding standards, naming conventions, DO/DON'T lists&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This took about a week. It felt slow. It was the most valuable week of the entire project.&lt;/p&gt;

&lt;p&gt;Why? Because every conversation with the AI after that point had a shared foundation. The AI was not guessing what my app looked like — it knew. Module boundaries were defined. Error handling was standardized. The AI could generate code that fit into a real system because the system was documented.&lt;/p&gt;

&lt;p&gt;Without architecture docs, AI generates code that looks correct in isolation but conflicts with everything else. You spend all your time merging inconsistent outputs instead of building features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: CLAUDE.md — the constitution
&lt;/h2&gt;

&lt;p&gt;Claude Code loads a &lt;code&gt;CLAUDE.md&lt;/code&gt; file from your project root at the start of every conversation. This is the most important file in my repository.&lt;/p&gt;

&lt;p&gt;Mine contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Module boundaries&lt;/strong&gt; enforced by Gradle (which module can import what)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Core patterns&lt;/strong&gt; (all use cases return &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt;, ViewModels expose &lt;code&gt;StateFlow&lt;/code&gt;, never &lt;code&gt;GlobalScope&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Critical DON'Ts&lt;/strong&gt; — a condensed list of rules that came from production bugs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subsystem quick reference&lt;/strong&gt; — a table pointing to detailed rules for each area (AlarmManager, sensors, AI, email, billing, GPS, permissions)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every rule in that file exists because I violated it once and something broke. The file grows with the project.&lt;/p&gt;

&lt;p&gt;This is the key insight: &lt;strong&gt;CLAUDE.md turns one-time lessons into permanent constraints.&lt;/strong&gt; The AI never forgets a rule I put there. I forget constantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Living documentation with start/stop commands
&lt;/h2&gt;

&lt;p&gt;I built custom slash commands that bookend every development session:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/howareyou-start&lt;/code&gt;&lt;/strong&gt; — loads the developer briefing, critical rules, release notes, and current version. The AI reads everything before I write a single prompt. It takes 30 seconds and prevents 80% of the mistakes I used to make.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/howareyou-stop&lt;/code&gt;&lt;/strong&gt; — updates release notes, archives old entries, updates CRITICAL_DONTS.md with any new lessons, updates the developer briefing, bumps the version, commits, and pushes.&lt;/p&gt;

&lt;p&gt;The documentation is never stale because updating it is part of the release process, not a separate task. I do not update docs manually. The AI does it as part of shipping.&lt;/p&gt;

&lt;p&gt;This creates a flywheel: better docs -&amp;gt; better AI output -&amp;gt; fewer bugs -&amp;gt; lessons captured -&amp;gt; better docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Concrete technical specs
&lt;/h2&gt;

&lt;p&gt;When I need a new feature, I do not say "add travel detection." I use BMAD's tech spec workflow to produce a document that specifies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exact state machine (HOME -&amp;gt; DAY_1 -&amp;gt; TRAVELING -&amp;gt; TRIP_ENDED)&lt;/li&gt;
&lt;li&gt;Database schema changes (table names, column types, indexes)&lt;/li&gt;
&lt;li&gt;Which existing classes are affected and how&lt;/li&gt;
&lt;li&gt;Edge cases and error handling&lt;/li&gt;
&lt;li&gt;What tests to write&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The spec is 2-5 pages. Writing it takes 30 minutes with BMAD's guided conversation. It saves hours of back-and-forth with the AI during implementation and eliminates the "it generated something but it does not fit" problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; if I cannot describe the feature precisely enough for a spec, I am not ready to build it. I brainstorm first (also with the AI), then spec, then build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Brainstorming sessions
&lt;/h2&gt;

&lt;p&gt;I use BMAD brainstorming for everything — not just code. Pricing strategy. UX decisions. Marketing approaches. Whether to support SMS notifications or stick with email.&lt;/p&gt;

&lt;p&gt;The pattern: open a session, describe the problem, let the AI challenge my assumptions. I keep the transcripts. Some of my best architectural decisions came from brainstorming sessions where the AI pointed out an edge case I had not considered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Automated audits that run weekly
&lt;/h2&gt;

&lt;p&gt;My app has to survive Android OEM battery killers (Samsung, Xiaomi, Honor, OPPO — they all kill background apps differently). These OEMs ship updates constantly that can break my compatibility layer.&lt;/p&gt;

&lt;p&gt;I built two audit commands:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/howareyou-oem-audit&lt;/code&gt;&lt;/strong&gt; — searches the web for recent OEM changelog entries and breaking changes, then scans my codebase for affected areas and proposes fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/howareyou-gps-audit&lt;/code&gt;&lt;/strong&gt; — does the same for GPS and location API changes (FusedLocationProvider updates, OEM GPS power management changes).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/howareyou-full-audit&lt;/code&gt;&lt;/strong&gt; — runs both in parallel and produces a combined report with a prioritized action plan.&lt;/p&gt;

&lt;p&gt;I run these weekly. They have caught breaking changes before they hit my users — Samsung silently resetting battery optimization exemptions after OTA updates, Honor changing wakelock tag whitelisting behavior, Google deprecating location API parameters.&lt;/p&gt;

&lt;p&gt;This is the kind of thing that would take a human developer hours of manual searching. The AI does it in minutes and maps the findings directly to my source code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: One-command publishing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/howareyou-build-test    → builds signed release AAB
/howareyou-publish-testingMode  → uploads to Google Play internal + closed testing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From "the code is ready" to "testers have the update" in under 5 minutes, without leaving the terminal. No browser, no Play Console clicking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Infrastructure monitoring
&lt;/h2&gt;

&lt;p&gt;I use 6 Google Cloud projects for Gemini API key rotation (each project gets 10K free requests/day — 60K total). Things break. Billing gets disabled. Keys expire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/howareyou-monitor&lt;/code&gt;&lt;/strong&gt; — checks all 6 shards, reports which are healthy, which failed, and why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/howareyou-fix-billing&lt;/code&gt;&lt;/strong&gt; — automatically re-links disabled shards to the shared billing account.&lt;/p&gt;

&lt;p&gt;These are not development tasks. They are operational tasks that I handle from the same terminal where I write code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: Code reviews with a second model
&lt;/h2&gt;

&lt;p&gt;After implementing a feature, I run a code review using BMAD's adversarial review workflow. It is configured to find 3-10 specific problems in every review — it never says "looks good." It checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Architecture compliance (are module boundaries respected?)&lt;/li&gt;
&lt;li&gt;Test coverage (are edge cases tested?)&lt;/li&gt;
&lt;li&gt;Security (any hardcoded keys? SQL injection? XSS?)&lt;/li&gt;
&lt;li&gt;Performance (unnecessary allocations? missing indexes?)&lt;/li&gt;
&lt;li&gt;Consistency with project patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This catches things I miss because I have been staring at the code for hours. The adversarial framing is important — a review that always approves is useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 10: Lessons learned as a living document
&lt;/h2&gt;

&lt;p&gt;Every production bug becomes a rule in &lt;code&gt;CRITICAL_DONTS.md&lt;/code&gt;. The file is organized by subsystem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AlarmManager:&lt;/strong&gt; never call &lt;code&gt;setAlarmClock()&lt;/code&gt; more than 3x/day (Honor flags you)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sensor:&lt;/strong&gt; always flush FIFO and discard stale readings (Honor rebases timestamps)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email:&lt;/strong&gt; per-recipient sends, never batch (Resend delivery tracking breaks)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPS:&lt;/strong&gt; full priority fallback chain, never trust a single &lt;code&gt;getCurrentLocation()&lt;/code&gt; call&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are 50+ rules in that file. Each one has a version number (when it was added) and a rationale (why it matters). The AI reads this file at the start of every session via the &lt;code&gt;/howareyou-start&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;This is the most underrated part of the workflow. Most developers keep lessons in their head. Heads forget. Files do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The daily workflow
&lt;/h2&gt;

&lt;p&gt;Here is what a typical development day looks like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;/howareyou-start&lt;/code&gt; — AI loads all context (30 seconds)&lt;/li&gt;
&lt;li&gt;Describe the task — with a tech spec if it is a feature, or a bug description if it is a fix&lt;/li&gt;
&lt;li&gt;AI implements — I review the diff, run tests&lt;/li&gt;
&lt;li&gt;Iterate — usually 1-3 rounds&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/howareyou-stop&lt;/code&gt; — docs updated, version bumped, committed, pushed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/howareyou-publish-testingMode&lt;/code&gt; — testers have the update&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I ship multiple versions per day with this flow. Not because I rush — because the overhead between "code works" and "testers have it" is near zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is NOT
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It is not "no-code.". If you know the language, it is worth checking and correcting if needed. With time, the needed small fixes will become less. It is always good to understand the architecture and to make the design decisions yourself.&lt;/li&gt;
&lt;li&gt;It is not effortless. The workflow took months to build. The documentation is extensive.&lt;/li&gt;
&lt;li&gt;It is not magic. The AI makes mistakes. The difference is that mistakes are caught by the process (tests, reviews, rules, audits) instead of by users.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;126,000 lines&lt;/strong&gt; of Kotlin across 398 files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;45,000 lines&lt;/strong&gt; of tests across 130 files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;144 versions&lt;/strong&gt; shipped&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 languages&lt;/strong&gt; (English, Bulgarian, German)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;50+ production lessons&lt;/strong&gt; captured in CRITICAL_DONTS.md&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4 months&lt;/strong&gt; from zero Kotlin experience to production app on Google Play&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;9 custom commands&lt;/strong&gt; automating the full development lifecycle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 lines&lt;/strong&gt; of Kotlin written manually by me&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;AI coding tools are not magic code generators. They are force multipliers for engineering process. If your process is "open chat, type prompt, hope for the best," you will be disappointed.&lt;/p&gt;

&lt;p&gt;If your process is "document the architecture, define the rules, automate the lifecycle, capture every lesson, review everything adversarially" — the AI becomes unreasonably effective.&lt;/p&gt;

&lt;p&gt;The investment is not in better prompts. It is in better engineering.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The app is &lt;a href="https://howareu.app" rel="noopener noreferrer"&gt;How Are You?!&lt;/a&gt; — AI safety monitoring for elderly parents. It will be released soon. The code workflow described here uses &lt;a href="https://claude.ai/code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; with &lt;a href="https://docs.bmad-method.org/" rel="noopener noreferrer"&gt;BMAD&lt;/a&gt;. Both are tools I use daily and genuinely recommend.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>programming</category>
      <category>devops</category>
    </item>
    <item>
      <title>What Android OEMs do to background apps, and the 11 layers I built to survive it</title>
      <dc:creator>Stoyan Minchev</dc:creator>
      <pubDate>Mon, 23 Mar 2026 12:02:33 +0000</pubDate>
      <link>https://dev.to/stoyan_minchev/what-android-oems-do-to-background-apps-and-the-11-layers-i-built-to-survive-it-28bb</link>
      <guid>https://dev.to/stoyan_minchev/what-android-oems-do-to-background-apps-and-the-11-layers-i-built-to-survive-it-28bb</guid>
      <description>&lt;p&gt;I spent over a year building a safety monitoring app that runs 24/7 on elderly parents' phones. If it gets killed, nobody gets alerted when something goes wrong. That constraint forced me into the deepest, most frustrating corners of Android background execution.&lt;/p&gt;

&lt;p&gt;This article covers what I learned about how Samsung, Xiaomi, Honor, OPPO, and Vivo actively kill background apps, why the standard Android approach is nowhere near sufficient, and the 11-layer recovery architecture I ended up building. I will also cover two related problems that surprised me: GPS hardware that silently stops working, and accelerometer data that lies about its age.&lt;/p&gt;

&lt;p&gt;126,000 lines of Kotlin, 125+ versions, solo developer. The app is called &lt;a href="https://howareu.app" rel="noopener noreferrer"&gt;How Are You?!&lt;/a&gt; — it learns an elderly person's daily routine over 7 days, then monitors around the clock and emails the family if something seems wrong. But this article is about the engineering, not the product.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem: Android wants your app dead
&lt;/h2&gt;

&lt;p&gt;Stock Android already makes continuous background work difficult. Doze mode, App Standby, background execution limits — Google has been tightening the screws since Android 6. A foreground service with &lt;code&gt;REQUEST_IGNORE_BATTERY_OPTIMIZATIONS&lt;/code&gt; is the standard answer.&lt;/p&gt;

&lt;p&gt;That is necessary. It is nowhere near sufficient.&lt;/p&gt;

&lt;p&gt;OEMs add their own proprietary battery management on top of stock Android, and they are far more aggressive. Here is what I encountered on the devices I tested:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Samsung&lt;/strong&gt; maintains a "Sleeping Apps" list. If your app has no foreground activity for 3 days, Samsung kills it. OTA updates silently reset your battery optimization exemption. The user opted you out of optimization? Samsung un-opted you after the update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Xiaomi (MIUI/HyperOS)&lt;/strong&gt; kills background services aggressively and resets autostart permissions after OTA updates. Your app was whitelisted? Not anymore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honor and Huawei&lt;/strong&gt; have PowerGenie, which monitors how often your app wakes the system. Call &lt;code&gt;setAlarmClock()&lt;/code&gt; more than about 3 times per day and you get flagged as "frequently wakes your system." They also have HwPFWService, which kills apps holding wakelocks longer than 60 minutes with non-whitelisted tags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OPPO (ColorOS)&lt;/strong&gt; has "Sleep standby optimization" that freezes apps during the hours the phone detects the user is sleeping. A safety monitoring app for elderly people needs to run &lt;em&gt;especially&lt;/em&gt; during sleep hours — that is when falls and medical events go unnoticed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vivo (Funtouch OS)&lt;/strong&gt; has "AI sleep mode" that does the same thing.&lt;/p&gt;

&lt;p&gt;Each manufacturer found a different way to kill you. No single workaround survives all of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The answer: 11 layers of recovery
&lt;/h2&gt;

&lt;p&gt;The core insight is that no single mechanism is reliable across all OEMs and all device states. The answer is redundancy — each layer catches the failures of the layers above it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: Foreground service with START_STICKY
&lt;/h3&gt;

&lt;p&gt;The foundation. &lt;code&gt;startForeground()&lt;/code&gt; with a persistent notification. The notification channel must use &lt;code&gt;IMPORTANCE_MIN&lt;/code&gt; — not &lt;code&gt;IMPORTANCE_DEFAULT&lt;/code&gt; or higher. Why? OEMs auto-grant &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt; on higher importance channels, bypassing the user's notification settings and making your persistent notification visible. &lt;code&gt;IMPORTANCE_MIN&lt;/code&gt; keeps it silent while &lt;code&gt;startForeground()&lt;/code&gt; still gives your process elevated priority.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;START_STICKY&lt;/code&gt; tells the system to restart the service after a kill. But "restart" can take minutes or never happen on aggressive OEMs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: onDestroy recovery scheduling
&lt;/h3&gt;

&lt;p&gt;When the system kills your service, &lt;code&gt;onDestroy()&lt;/code&gt; fires (most of the time). Use this 50ms window to schedule everything that will bring you back:&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;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onDestroy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onDestroy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nc"&gt;ServiceWatchdogReceiver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleWithBackup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;MotionSnapshotReceiver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&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;This fires both the AlarmManager chain and the motion snapshot chain. If &lt;code&gt;onDestroy()&lt;/code&gt; does not fire (force-stop, OEM kill without callback), the other layers cover it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: AlarmManager watchdog chain
&lt;/h3&gt;

&lt;p&gt;A self-chaining &lt;code&gt;setExactAndAllowWhileIdle()&lt;/code&gt; alarm at 15-minute intervals during active use. When it fires, it checks whether the service is alive and restarts it if not.&lt;/p&gt;

&lt;p&gt;The interval adapts to power state: 15 minutes when active, 30 minutes when idle, 60 minutes during deep sleep. This matters for OEM battery scoring — more frequent alarms get flagged.&lt;/p&gt;

&lt;p&gt;Important: never use &lt;code&gt;Handler.postDelayed()&lt;/code&gt; as a replacement for AlarmManager. Handlers do not fire during CPU deep sleep. I learned this the hard way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 4: WorkManager periodic watchdog
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;PeriodicWorkRequest&lt;/code&gt; at 15-minute intervals that does the same thing — checks the service and restarts if needed. WorkManager survives service kills and uses JobScheduler under the hood, which OEMs are more reluctant to interfere with.&lt;/p&gt;

&lt;p&gt;But there is a subtle trap: &lt;code&gt;ExistingPeriodicWorkPolicy.KEEP&lt;/code&gt; silently discards new requests if a worker is already enqueued, even if the existing one has a stale timer from hours ago. And &lt;code&gt;REPLACE&lt;/code&gt; resets the countdown every time you call &lt;code&gt;schedule()&lt;/code&gt;. The solution: query &lt;code&gt;getWorkInfosForUniqueWork()&lt;/code&gt; first and only schedule when the worker is not already enqueued.&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;workInfos&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;workManager&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getWorkInfosForUniqueWork&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WATCHDOG_WORK_NAME&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;await&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;isEnqueued&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;workInfos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&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="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;WorkInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ENQUEUED&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="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;WorkInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RUNNING&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;isEnqueued&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;workManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueueUniquePeriodicWork&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;WATCHDOG_WORK_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;ExistingPeriodicWorkPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;KEEP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;watchdogRequest&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;h3&gt;
  
  
  Layer 5: Boot recovery
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;BOOT_COMPLETED&lt;/code&gt;, &lt;code&gt;LOCKED_BOOT_COMPLETED&lt;/code&gt;, &lt;code&gt;QUICKBOOT_POWERON&lt;/code&gt;, and &lt;code&gt;MY_PACKAGE_REPLACED&lt;/code&gt; receivers that re-establish the service and all alarm chains after reboot or app update.&lt;/p&gt;

&lt;p&gt;Some OEMs reset permissions after OTA updates. OnePlus, Samsung, Xiaomi, Redmi, and POCO all do this. You need to detect the OTA and re-prompt the user for battery optimization exemption.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 6: SyncAdapter for process priority
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ContentResolver.addPeriodicSync()&lt;/code&gt; gives your process elevated priority through the sync framework. OEMs are reluctant to kill sync adapter processes because the sync framework is a system concept — killing it could break contacts, calendar, and email sync.&lt;/p&gt;

&lt;p&gt;This is a ~1-hour periodic callback that checks service health. It will not bring you back fast, but it is extremely hard for OEMs to suppress.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 7: AlarmClock safety net
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;setAlarmClock()&lt;/code&gt; at 8-hour intervals — approximately 3 calls per day. This is the nuclear option. AlarmClock alarms get the highest delivery priority on Android because they are designed to wake users up.&lt;/p&gt;

&lt;p&gt;Why 8 hours and not shorter? Honor's PowerGenie specifically tracks AlarmClock frequency. At 15-minute intervals, it flags you as "frequently wakes your system" and kills you. At 8-hour intervals (~3/day), you fly under the radar.&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;scheduleSafetyNet&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="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;PendingIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBroadcast&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;REQUEST_CODE_ALARMCLOCK&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;PendingIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FLAG_UPDATE_CURRENT&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="nc"&gt;PendingIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FLAG_IMMUTABLE&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;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="nc"&gt;SAFETY_NET_INTERVAL_MS&lt;/span&gt; &lt;span class="c1"&gt;// 8 hours&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;setAlarmClock&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;AlarmClockInfo&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="k"&gt;null&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Layer 8: Exact alarm permission recovery
&lt;/h3&gt;

&lt;p&gt;When the user revokes &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt;, &lt;strong&gt;all&lt;/strong&gt; pending AlarmManager chains die silently. No callback, no exception. Your watchdog, your snapshot receiver, your safety net — all gone.&lt;/p&gt;

&lt;p&gt;Listen for &lt;code&gt;ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED&lt;/code&gt; and re-establish everything on re-grant:&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;class&lt;/span&gt; &lt;span class="nc"&gt;ExactAlarmPermissionReceiver&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="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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;canScheduleExactAlarms&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;ServiceWatchdogReceiver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleWithBackup&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;MotionSnapshotReceiver&lt;/span&gt;&lt;span class="p"&gt;.&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;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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Layer 9: Batched accelerometer sensing
&lt;/h3&gt;

&lt;p&gt;This is the layer that surprised me most. Keep the accelerometer registered with &lt;code&gt;maxReportLatencyUs&lt;/code&gt; during idle and deep sleep. The sensor HAL continuously samples into a hardware FIFO buffer and delivers readings via a sensor interrupt — this is completely invisible to OEM battery managers because it does not use AlarmManager, WorkManager, or any schedulable mechanism.&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="n"&gt;sensorManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;batchedMotionListener&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;accelerometer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;SensorManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SENSOR_DELAY_NORMAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;maxReportLatencyUs&lt;/span&gt;  &lt;span class="c1"&gt;// 10 min in deep sleep&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HAL batches readings and delivers them all at once when the buffer fills or the latency expires. You get continuous motion awareness with zero wakes visible to the OEM.&lt;/p&gt;

&lt;p&gt;One gotcha: a single SLIGHT_MOVEMENT reading (1.0-3.0 m/s^2) should not exit batched mode. Table vibrations and building micro-movements produce transient spikes. I require 3 consecutive SLIGHT_MOVEMENT readings (~15 seconds) before exiting. Anything above 3.0 m/s^2 (MODERATE_MOVEMENT) exits immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 10: Network restoration and app foreground triggers
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;CONNECTIVITY_ACTION&lt;/code&gt; receiver triggers a service health check when the network comes back. &lt;code&gt;ProcessLifecycleOwner&lt;/code&gt; fires when the user opens the app. These are opportunistic — they catch edge cases where the service died during airplane mode or extended offline periods.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 11: User-facing gap detection
&lt;/h3&gt;

&lt;p&gt;When all 10 layers fail (and on some devices, they do), the app detects the gap and shows the user device-specific instructions: "Your [Manufacturer] phone is stopping background apps. Open Settings &amp;gt; Battery &amp;gt; [OEM-specific path] and disable optimization for How Are You?!"&lt;/p&gt;

&lt;p&gt;This is the least satisfying layer because it requires user action. But on a few particularly aggressive OEM configurations, it is the only thing that works.&lt;/p&gt;




&lt;h2&gt;
  
  
  The wakelock tag problem on Honor
&lt;/h2&gt;

&lt;p&gt;HwPFWService on Honor and Huawei devices maintains a whitelist of allowed wakelock tags. If your app holds a wakelock for more than 60 minutes with a tag that is not on the whitelist, HwPFWService kills your app.&lt;/p&gt;

&lt;p&gt;The solution is embarrassingly simple: use a whitelisted tag on Honor/Huawei, your real tag everywhere else.&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;WAKELOCK_TAG&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="nf"&gt;run&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;manufacturer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MANUFACTURER&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;lowercase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;orEmpty&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;manufacturer&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"huawei"&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;manufacturer&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"honor"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"LocationManagerService"&lt;/span&gt;  &lt;span class="c1"&gt;// Whitelisted by HwPFWService&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="s"&gt;"HowAreYou:PulseBurst"&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;&lt;code&gt;LocationManagerService&lt;/code&gt; is whitelisted because it is a system service tag. I am not proud of this, but it works.&lt;/p&gt;




&lt;h2&gt;
  
  
  getCurrentLocation() hangs forever
&lt;/h2&gt;

&lt;p&gt;Once I had the service staying alive, I discovered a second problem: GPS does not work when you need it.&lt;/p&gt;

&lt;p&gt;At approximately 12% battery on my Honor test device, the OEM battery saver silently killed GPS hardware access. No exception, no error callback, no log entry. The foreground service was alive, the accelerometer worked. But &lt;code&gt;getCurrentLocation(PRIORITY_HIGH_ACCURACY)&lt;/code&gt; simply never completed. The Task from Play Services hung indefinitely — neither &lt;code&gt;onSuccessListener&lt;/code&gt; nor &lt;code&gt;onFailureListener&lt;/code&gt; ever fired.&lt;/p&gt;

&lt;p&gt;The code fell back to &lt;code&gt;getLastLocation()&lt;/code&gt;, which returned a 5-hour-old cached position from a completely different city.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 1: Always timeout
&lt;/h3&gt;

&lt;p&gt;Every &lt;code&gt;getCurrentLocation()&lt;/code&gt; call must be wrapped in a coroutine timeout:&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;getLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priority&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="nc"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;withTimeoutOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30_000L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;suspendCancellableCoroutine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cont&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;fusedClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCurrentLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addOnSuccessListener&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cont&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&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="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addOnFailureListener&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cont&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&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="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;h3&gt;
  
  
  Fix 2: Priority fallback chain
&lt;/h3&gt;

&lt;p&gt;GPS hardware being dead does not mean all location sources are dead. Cell towers and Wi-Fi still work. I built a sequential fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PRIORITY_HIGH_ACCURACY (GPS, ~10m)
    | timeout or null
PRIORITY_BALANCED_POWER_ACCURACY (Wi-Fi + cell, ~40-300m)
    | timeout or null
PRIORITY_LOW_POWER (cell only, ~300m-3km)
    | timeout or null
getLastLocation() (cached, any age)
    | null
TotalFailure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step gets its own 30-second timeout. In practice, when GPS is killed, BALANCED_POWER_ACCURACY returns in 2-3 seconds because Wi-Fi scanning still works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 3: GPS wake probe
&lt;/h3&gt;

&lt;p&gt;Sometimes the GPS hardware is not permanently dead — it has been suspended by the battery manager. A brief &lt;code&gt;requestLocationUpdates&lt;/code&gt; call can wake it:&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;hoursSinceLastFreshGps&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&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;probeRequest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocationRequest&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="nc"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PRIORITY_HIGH_ACCURACY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000L&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDurationMillis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5_000L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMaxUpdates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&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;span class="nf"&gt;withTimeoutOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6_000L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fusedClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestLocationUpdates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;probeRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;looper&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;fusedClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeLocationUpdates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callback&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;Five seconds, maximum once every 4 hours. On Honor, this recovers the GPS roughly 40% of the time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 4: Explicit outcome types
&lt;/h3&gt;

&lt;p&gt;The original code returned &lt;code&gt;Location?&lt;/code&gt;. The caller had no way to distinguish a fresh 10-meter GPS fix from a 5-hour-old cached position. I changed the return type to make the quality of data explicit:&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;sealed&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;GpsLocationOutcome&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;FreshGps&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;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GpsLocationOutcome&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;WakeProbeSuccess&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;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GpsLocationOutcome&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;CellFallback&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;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GpsLocationOutcome&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;StaleLastLocation&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;ageMs&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="nc"&gt;GpsLocationOutcome&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;TotalFailure&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GpsLocationOutcome&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the consumer can make informed decisions. A 3km cell tower reading is low precision, but it answers "is this person in the expected city or 200km away?" For a safety app, that distinction matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  The sensor HAL lies about timestamps
&lt;/h2&gt;

&lt;p&gt;At 3 AM, your app wakes up to check the accelerometer. You call &lt;code&gt;registerListener()&lt;/code&gt;, and the sensor HAL returns data. You check &lt;code&gt;event.timestamp&lt;/code&gt; against &lt;code&gt;SystemClock.elapsedRealtimeNanos()&lt;/code&gt;. The delta is small. The data looks fresh.&lt;/p&gt;

&lt;p&gt;It is not. It is 22-minute-old data sitting in the hardware FIFO buffer since the last time anyone read the sensor.&lt;/p&gt;

&lt;p&gt;This is the normal behavior of hardware sensor FIFOs. When the CPU sleeps, the sensor continues sampling into its buffer. When you register a listener after wakeup, the HAL dumps the entire buffer contents at you. The timestamps are real (the readings were taken at those times), but the data is stale — it describes what happened 22 minutes ago, not what is happening now.&lt;/p&gt;

&lt;p&gt;On most devices, you can catch this by comparing &lt;code&gt;event.timestamp&lt;/code&gt; (CLOCK_BOOTTIME nanoseconds) against &lt;code&gt;SystemClock.elapsedRealtimeNanos()&lt;/code&gt;. If the delta is large, the reading is stale.&lt;/p&gt;

&lt;p&gt;Honor broke this assumption. On Honor devices, the HAL rebases &lt;code&gt;event.timestamp&lt;/code&gt; on FIFO flush, so the delta check shows the data as fresh even when it is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: flush, wait for callback, then collect
&lt;/h3&gt;

&lt;p&gt;Do not trust the first readings after &lt;code&gt;registerListener()&lt;/code&gt;. Instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Call &lt;code&gt;sensorManager.flush(this)&lt;/code&gt; to drain the stale FIFO data&lt;/li&gt;
&lt;li&gt;Wait for the &lt;code&gt;onFlushCompleted()&lt;/code&gt; callback from &lt;code&gt;SensorEventListener2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Only start collecting readings after the flush completes&lt;/li&gt;
&lt;li&gt;Set a 1000ms fallback timer in case the HAL never fires the callback
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MotionSnapshotReceiver&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="nc"&gt;SensorEventListener2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;isFlushPhase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&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;onSensorChanged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SensorEvent&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;isFlushPhase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;// Discard stale FIFO data&lt;/span&gt;
        &lt;span class="nf"&gt;collectReading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&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;onFlushCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sensor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Sensor&lt;/span&gt;&lt;span class="p"&gt;?)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;endFlushPhase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;byHal&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;endFlushPhase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;byHal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&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;isFlushPhase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;// Guard against double-trigger&lt;/span&gt;
        &lt;span class="n"&gt;isFlushPhase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
        &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeCallbacks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flushFallbackRunnable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// Now start collecting real readings&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 fallback timer at 1000ms is important. I originally used 200ms, which was insufficient for Honor devices — their deep FIFO drains at approximately 16Hz, and a full buffer can take over 200ms to flush.&lt;/p&gt;

&lt;p&gt;As a secondary safety net, I use dual-clock comparison: both &lt;code&gt;CLOCK_BOOTTIME&lt;/code&gt; and &lt;code&gt;CLOCK_MONOTONIC&lt;/code&gt; deltas must agree that the reading is fresh. If either delta exceeds 500ms of staleness, the reading is discarded.&lt;/p&gt;




&lt;h2&gt;
  
  
  A race condition in GPS processing
&lt;/h2&gt;

&lt;p&gt;I had multiple independent trigger paths (stillness detector, smart GPS scheduler, area stability detector) that could request GPS concurrently. Two of them fired within 33 milliseconds of each other. Both read the same &lt;code&gt;getLastLocation()&lt;/code&gt;, both passed the stationarity filter, and both inserted a GPS reading.&lt;/p&gt;

&lt;p&gt;My code uses a minimum-readings-per-cluster filter to discard drive-through locations — a place needs at least 2 GPS readings to count as a real visit. The duplicate entry from the race condition defeated this filter. A single drive-by at 60km/h became a "cluster of 2."&lt;/p&gt;

&lt;p&gt;The fix is a Mutex around the entire location processing path:&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;processLocationMutex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Mutex&lt;/span&gt;&lt;span class="p"&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;processLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;processLocationMutex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withLock&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;lastLocation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getLastLocation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;// The second concurrent caller now sees the just-inserted&lt;/span&gt;
        &lt;span class="c1"&gt;// location and correctly skips as duplicate&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;h2&gt;
  
  
  Battery result
&lt;/h2&gt;

&lt;p&gt;After all 11 layers and three tiers of power state, the battery impact is under 1% per day. The key numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before optimization:&lt;/strong&gt; ~4,300 AlarmManager wakes per day. Every active-mode pulse (15s/30s) used AlarmManager. Every watchdog check (every 5 minutes) used AlarmManager. Honor flagged the app within hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After optimization:&lt;/strong&gt; ~240 wakes per day. Active-mode pulses use &lt;code&gt;Handler.postDelayed()&lt;/code&gt; (zero AlarmManager wakes). Watchdog intervals extended from 5 to 15 minutes. AlarmClock safety net reduced from every 15 minutes to every 8 hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a 94% reduction in system wakes while maintaining the same monitoring reliability.&lt;/p&gt;

&lt;p&gt;The insight: aggressive scheduling wastes more battery than it saves in reliability. A three-tier power state that backs off when the device is still (active at 15-second pulses, idle at 5-minute pulses, deep sleep at 30-minute pulses with batched accelerometer as safety net) achieves both low battery impact and high reliability.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Build the OEM compatibility layer first.&lt;/strong&gt; I treated background reliability as something I would fix later. It took 40+ versions across several months to get right. It should have been the architectural foundation from Day 1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test on real OEM devices from the start.&lt;/strong&gt; The Android emulator and Pixel devices tell you nothing about OEM battery management. I did not discover the Honor wakelock whitelist problem, the GPS hardware suspension, or the sensor FIFO timestamp rebasing until I tested on actual devices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never trust a single mechanism.&lt;/strong&gt; Every Android background API has an OEM that breaks it. AlarmManager gets suppressed. WorkManager gets deferred. Foreground services get killed. The only reliable approach is layered redundancy where each mechanism independently tries to recover.&lt;/p&gt;




&lt;p&gt;The app is called &lt;a href="https://howareu.app" rel="noopener noreferrer"&gt;How Are You?!&lt;/a&gt; and is available on Google Play. It is still in closed testing phase — if you have an elderly parent on Android and want to try it, I would appreciate feedback, especially from OEM devices I have not tested yet. Email: &lt;a href="mailto:developer@howareu.app"&gt;developer@howareu.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I am happy to answer questions about any of these techniques. The OEM compatibility rabbit hole goes much deeper than what I have covered here.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kontlin</category>
      <category>sideprojects</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I spent several months building an AI safety app for my elderly parent — here is what I learned</title>
      <dc:creator>Stoyan Minchev</dc:creator>
      <pubDate>Sat, 21 Mar 2026 21:10:16 +0000</pubDate>
      <link>https://dev.to/stoyan_minchev/i-spent-several-months-building-an-ai-safety-app-for-my-elderly-parent-here-is-what-i-learned-2h8a</link>
      <guid>https://dev.to/stoyan_minchev/i-spent-several-months-building-an-ai-safety-app-for-my-elderly-parent-here-is-what-i-learned-2h8a</guid>
      <description>&lt;p&gt;My parent lives alone. After a fall that nobody noticed for hours, I decided to build something that would.&lt;/p&gt;

&lt;p&gt;Four months, 121 versions, and approximately 79,000 lines of Kotlin later, the app is live on Google Play. Here is the story — the technical challenges, the things that broke, and what I would do differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the app does&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Install it on your parent's Android phone. It watches. That is it.&lt;/p&gt;

&lt;p&gt;For 7 days, it learns their routine — when they wake up, how active they are, where they go. After that, it monitors 24/7 and emails your family if something seems wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unusual stillness (potential fall or medical event)&lt;/li&gt;
&lt;li&gt;Did not wake up on time&lt;/li&gt;
&lt;li&gt;At an unfamiliar location at an unusual hour&lt;/li&gt;
&lt;li&gt;Phone silent for too long&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No buttons to press. No wearable to charge. No daily check-in calls. Install and forget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The technical stack&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kotlin&lt;/strong&gt; + Jetpack Compose + Material Design 3&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Room + SQLCipher&lt;/strong&gt; for encrypted local storage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Gemini API&lt;/strong&gt; for behavioral analysis (cloud, anonymized summaries)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resend API&lt;/strong&gt; for transactional email alerts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WorkManager + Foreground Service&lt;/strong&gt; for 24/7 reliability&lt;/li&gt;
&lt;li&gt;Clean architecture: &lt;code&gt;:domain&lt;/code&gt; (pure Kotlin) -&amp;gt; &lt;code&gt;:data&lt;/code&gt; -&amp;gt; &lt;code&gt;:ui&lt;/code&gt; -&amp;gt; &lt;code&gt;:app&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;## The hard part: staying alive on Android&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where 80% of my development time went.&lt;/p&gt;

&lt;p&gt;Android's job is to kill your app. OEMs make it worse. Here is what I learned:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;### Problem 1: OEM battery killers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Samsung, Xiaomi, Honor, OPPO — they all have proprietary battery managers that kill background apps. The standard &lt;code&gt;startForeground()&lt;/code&gt; is not enough.&lt;/p&gt;

&lt;p&gt;My solution: 11-layer service recovery:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Foreground service with IMPORTANCE_MIN channel&lt;/li&gt;
&lt;li&gt;WorkManager periodic watchdog&lt;/li&gt;
&lt;li&gt;AlarmManager backup chains&lt;/li&gt;
&lt;li&gt;BOOT_COMPLETED receiver&lt;/li&gt;
&lt;li&gt;SyncAdapter for process priority boost&lt;/li&gt;
&lt;li&gt;Batched accelerometer sensing (survives CPU sleep)&lt;/li&gt;
&lt;li&gt;Exact alarm permission recovery&lt;/li&gt;
&lt;li&gt;OEM-specific wakelock tag spoofing (Honor whitelists "LocationManagerService")&lt;/li&gt;
&lt;li&gt;START_STICKY restart&lt;/li&gt;
&lt;li&gt;Safety net AlarmClock at 8-hour intervals&lt;/li&gt;
&lt;li&gt;User-facing gap detection with OEM-specific guidance&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each layer was added because the previous ones were not enough on some device.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 2: Sensor data lies to you
&lt;/h3&gt;

&lt;p&gt;At 3 AM, your app wakes up to check the accelerometer. The sensor HAL returns data. You think it is fresh. It is not — it is 22-minute-old data sitting in the hardware FIFO buffer since the last time anyone read the sensor.&lt;/p&gt;

&lt;p&gt;On Honor devices, the HAL even rebases &lt;code&gt;event.timestamp&lt;/code&gt; on flush, so a delta check against &lt;code&gt;elapsedRealtimeNanos()&lt;/code&gt; thinks the data is fresh. The solution: explicit &lt;code&gt;sensorManager.flush()&lt;/code&gt;, discard warm-up readings, use &lt;code&gt;onFlushCompleted()&lt;/code&gt; callback instead of fixed timers, and dual-clock comparison as a safety net.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;### Problem 3: GPS does not work when you need it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;getCurrentLocation(PRIORITY_HIGH_ACCURACY)&lt;/code&gt; returns nothing. The OEM killed the GPS hardware to save power.&lt;/p&gt;

&lt;p&gt;Solution: Priority fallback chain — HIGH_ACCURACY -&amp;gt; wake probe -&amp;gt; BALANCED_POWER -&amp;gt; LOW_POWER -&amp;gt; getLastLocation(). Returns a &lt;code&gt;GpsLocationOutcome&lt;/code&gt; sealed class so the caller knows exactly what happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;## The AI: from on-device to cloud&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I started with Gemini Nano (fully on-device). It worked on Pixels. It did not work on anything else. The addressable market was tiny.&lt;/p&gt;

&lt;p&gt;So I moved to Gemini Flash (cloud API). The privacy trade-off: detailed behavioral data stays on-device in an encrypted database, but anonymized summaries (including location context) are sent to Google's AI for weekly analysis. No names, no personal identifiers.&lt;/p&gt;

&lt;p&gt;The key architectural decision: &lt;strong&gt;API key sharding&lt;/strong&gt;. Each Google Cloud project gets 10,000 requests per day free. I created 6 projects with independent API keys. The app rotates through them on rate-limit errors (429/403). That is 60,000 requests per day — enough for thousands of users at zero cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;## Travel intelligence&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The biggest UX win. Without it, every vacation generated 5 to 7 URGENT emails (one per night when the hard-floor detector fired at an unfamiliar location). By day 5, families ignored all emails.&lt;/p&gt;

&lt;p&gt;Now: Day 1 sends one "your parent appears to be traveling" notification. Days 2 through 6: silence (unless something actually changes). Return home: "they have returned to a familiar area."&lt;/p&gt;

&lt;p&gt;The state machine: HOME -&amp;gt; DAY_1 -&amp;gt; TRAVELING(n) -&amp;gt; TRIP_ENDED. Single-writer rule through &lt;code&gt;TravelStateManager&lt;/code&gt; to prevent state corruption from concurrent assessments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;## What I would do differently&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with cloud AI from Day 1.&lt;/strong&gt; I lost 2 months on Gemini Nano before accepting the device compatibility reality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the OEM compatibility layer first.&lt;/strong&gt; The 11-layer recovery took 40+ versions to get right. It should have been the foundation, not an afterthought.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email before OAuth.&lt;/strong&gt; I started with Gmail OAuth (user signs into their Google account). It was a UX nightmare. Resend API (transactional email, zero auth) took 1 day to implement and just works.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;## Looking for early adopters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The app is free to download with a 21-day free trial (then $49 for the first year, $5 per year after that). I am looking for families to test it — install it on your parent's Android phone (Android 9 or newer), run it for a couple of weeks, and tell me what works and what does not.&lt;/p&gt;

&lt;p&gt;Website: &lt;a href="https://howareu.app" rel="noopener noreferrer"&gt;howareu.app&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>sideprojects</category>
      <category>android</category>
      <category>kotlin</category>
    </item>
  </channel>
</rss>
