<?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: Siddharth Pandalai</title>
    <description>The latest articles on DEV Community by Siddharth Pandalai (@darkpandawarrior).</description>
    <link>https://dev.to/darkpandawarrior</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4036798%2Fcbc9c397-ab12-4008-91ec-0deac4a666df.jpg</url>
      <title>DEV Community: Siddharth Pandalai</title>
      <link>https://dev.to/darkpandawarrior</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/darkpandawarrior"/>
    <language>en</language>
    <item>
      <title>Every filter needs a documented exception</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Wed, 23 Sep 2026 04:01:24 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/every-filter-needs-a-documented-exception-2j3k</link>
      <guid>https://dev.to/darkpandawarrior/every-filter-needs-a-documented-exception-2j3k</guid>
      <description>&lt;p&gt;A filter with no exceptions has not met production yet. It has only met your test data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9f3fo5c6lvpqftz2168v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9f3fo5c6lvpqftz2168v.png" alt="The Concussed Witness, drawn as a specimen plate. Confident. Cooperative. Often wrong. Labelled THE CONCUSSED WITNESS, LocationManager." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that was right nearly always
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fztgom89i1yy2g17zf1pw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fztgom89i1yy2g17zf1pw.png" alt="The discipline: Every exception earns three things" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GPS reports an accuracy radius with every reading. Ours had a straightforward rule: anything worse than 50 metres is persisted, but does not count toward trusted distance.&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;accuracyGated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accuracyM&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;MAX_ACCURACY_THRESHOLD_M&lt;/span&gt;   &lt;span class="c1"&gt;// 50.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is sound. A 200 metre radius means the phone is in a basement, an underground car park, or surrounded by reflective towers. Counting that as travelled distance produces nonsense.&lt;/p&gt;

&lt;p&gt;Then we hit the legitimate case: a vehicle parked for a long stop. Genuinely stationary. Decent fix. But occasionally reporting a slightly wider radius, and under the blanket rule those readings stopped contributing. The stop appeared as a hole in the journey.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bad fixes and one good one
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Loosen the threshold.&lt;/strong&gt; Move 50 to 70 and the complaint disappears, along with the protection you added the rule for. This is not solving the edge case, it is deleting the rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special-case it silently.&lt;/strong&gt; Add &lt;code&gt;|| fix.speedMps &amp;lt; 0.1&lt;/code&gt; somewhere in the condition, move on. It works today and becomes archaeology within a quarter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Name the exception.&lt;/strong&gt; What we actually did:&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;// Stationary after recent movement with still-reasonable accuracy: the device is&lt;/span&gt;
&lt;span class="c1"&gt;// reliably placed, not drifting, so it may contribute despite the accuracy gate.&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;exceptionalStationary&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speedMps&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nc"&gt;EXCEPTIONAL_STATIONARY_SPEED_MPS&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;   &lt;span class="c1"&gt;// 0.1 m/s: genuinely not moving&lt;/span&gt;
        &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accuracyM&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nc"&gt;EXCEPTIONAL_STATIONARY_ACCURACY_M&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="c1"&gt;// 20m: still a good fix&lt;/span&gt;
        &lt;span class="nf"&gt;hasMovementHistory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                  &lt;span class="c1"&gt;// and it was moving recently&lt;/span&gt;

&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;accuracyGated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accuracyM&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;maxAccuracyThreshold&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;exceptionalStationary&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three conditions, all required, describing exactly one situation. The rule stays strict for everyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the name is the important part
&lt;/h2&gt;

&lt;p&gt;The logic here is four lines. Any competent engineer would arrive at something similar. What makes it survive is that it is called &lt;code&gt;exceptionalStationary&lt;/code&gt; and carries a comment explaining the reasoning.&lt;/p&gt;

&lt;p&gt;An unnamed condition is indistinguishable from a bug, and it gets treated like one. Somebody tidying up in six months sees &lt;code&gt;speedMps &amp;lt;= 0.1f &amp;amp;&amp;amp; accuracyM &amp;lt; 20f &amp;amp;&amp;amp; hasMovementHistory()&lt;/code&gt; inline in a boolean, cannot work out why anyone would want that, and removes it. Tests pass, because nobody wrote a test for a case nobody could explain. The regression surfaces in support tickets a month later.&lt;/p&gt;

&lt;p&gt;A named exception with a comment and a test is a different object entirely. It says: this is deliberate, here is the situation, here is why the general rule is wrong for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other exception in the same pipeline
&lt;/h2&gt;

&lt;p&gt;There is a second one worth showing, because it demonstrates the discipline that keeps exceptions from metastasising. After a pause and resume, the user may genuinely have moved while tracking was off, so the teleport-spike gate is relaxed for a grace window.&lt;/p&gt;

&lt;p&gt;But the accuracy gate stays fully enforced during that window.&lt;/p&gt;

&lt;p&gt;Relaxing one rule must never silently relax the neighbours. An exception should be as narrow as the situation that justifies it, and no narrower than the code makes explicit.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fahjxfg3enh5c18pm7r44.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fahjxfg3enh5c18pm7r44.png" alt="The payload: An undocumented special case looks exactly like a bug" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Write the rule. Then go looking for the legitimate case it breaks, because there is always one. When you find it, do not weaken the rule and do not smuggle in a condition. Give it a name, a comment with your reasoning, and a test.&lt;/p&gt;

&lt;p&gt;Undocumented special cases do not survive contact with a refactor. Documented ones do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 12 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Sensors Who Lie&lt;/em&gt;* · Featuring: The Concussed Witness*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-08-13-accelerometer-outranks-gps/article.md" rel="noopener noreferrer"&gt;When two sensors disagree, rank them&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/sensors-who-lie.md" rel="noopener noreferrer"&gt;Sensors Who Lie&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>filtering</category>
      <category>edgecases</category>
      <category>codequality</category>
    </item>
    <item>
      <title>Never silently change a number someone gets paid on</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Sun, 20 Sep 2026 04:01:23 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/never-silently-change-a-number-someone-gets-paid-on-h4n</link>
      <guid>https://dev.to/darkpandawarrior/never-silently-change-a-number-someone-gets-paid-on-h4n</guid>
      <description>&lt;p&gt;Our algorithm decided a driver had travelled 4km less than they thought. It was probably right. Shipping that silently would still have been wrong.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1qb9xhsqxj5lavrccj5p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1qb9xhsqxj5lavrccj5p.png" alt="The Archivist, drawn as a specimen plate. You cannot backfill confidence you discarded. Labelled THE ARCHIVIST, Provenance." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When cleaning stops being technical
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdtr1nmeqs9es0orqk5ab.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdtr1nmeqs9es0orqk5ab.png" alt="Explicit degradation: Report the delta, not just the answer" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Mileage tracking ends in an expense claim. Someone drives, the app measures, and the measurement becomes money.&lt;/p&gt;

&lt;p&gt;Our pipeline removes several categories of distance: readings from mock location apps, physically implausible jumps, and teleport spikes over five kilometres in a single step. The resulting number is more accurate than the raw sum. That is not in dispute.&lt;/p&gt;

&lt;p&gt;The mistake is thinking accuracy is the whole job.&lt;/p&gt;

&lt;p&gt;A driver who believes they did 40km and sees 36km cannot distinguish between a careful algorithm and an employer quietly trimming expenses. From where they sit, both look identical: a smaller number, no explanation. Being right is invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Explicit degradation, in the UI
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjrvzhpwter9wokoxj7sp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjrvzhpwter9wokoxj7sp.png" alt="What the user is owed: Four things, every time" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix is not better filtering. It is showing the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Show the original next to the cleaned figure.&lt;/strong&gt; Both numbers exist in the data model, so both can appear. The user sees that we measured 40km and are proposing 36km, rather than discovering 36km as if it were the only thing that ever happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Name what was removed.&lt;/strong&gt; Not "adjusted for accuracy" but the actual categories: abnormal distance, mock distance, spike distance. Categories are checkable. Vagueness is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let them overrule it.&lt;/strong&gt; The trip screen has a toggle for whether the abnormal segment is subtracted:&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;var&lt;/span&gt; &lt;span class="py"&gt;final&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;smartAnalysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;originalDistance&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;removeAbnormal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="p"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;smartAnalysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;abnormalDistance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one gets pushback in review. Why let a user override the algorithm? Two reasons. They were physically present and we were not, so on a genuinely ambiguous journey they hold information the pipeline does not. And if our threshold is wrong, this is the cheapest possible feedback channel. The alternative is not that they accept it. The alternative is that they escalate to their finance team, who escalate to us, three weeks later, with no data attached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not cry wolf.&lt;/strong&gt; In the irregularities view, an app-killed event is explicitly classified as not an irregularity, because the tracker recovers on its own. If every notice is alarming, users learn to dismiss all of them, and the disclosure you worked on becomes noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  This needs the data model to cooperate
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5hn35rpw1k15cntsq43l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5hn35rpw1k15cntsq43l.png" alt="The payload: Correctness is table stakes. Transparency ships." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of this is possible if the pipeline throws away what it rejects. Showing original versus cleaned requires both numbers to survive. Naming the categories requires per-category accumulators. Letting the user toggle removal requires the removed distance to still be sitting there, addable back.&lt;/p&gt;

&lt;p&gt;Transparency is a data-modelling decision made months before it becomes a UI decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general case
&lt;/h2&gt;

&lt;p&gt;The pattern applies well beyond mileage. Fraud scores that block a transaction. Ranking systems that decide what a seller earns. Time trackers. Usage-based billing. Anything where a system's output becomes an input to somebody's money, time, or reputation.&lt;/p&gt;

&lt;p&gt;For those systems, correctness is table stakes. The actual product is: show the original, show the delta, show the reason, and let them push back.&lt;/p&gt;

&lt;p&gt;Being right in silence is just asking to be trusted. Showing the working is how you earn it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 11 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Chain Of Custody&lt;/em&gt;* · Featuring: The Archivist*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-08-11-uncertainty-dies-in-data-model/article.md" rel="noopener noreferrer"&gt;Your data model is where uncertainty goes to die&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/chain-of-custody.md" rel="noopener noreferrer"&gt;Chain Of Custody&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productengineering</category>
      <category>trust</category>
      <category>ux</category>
      <category>dataintegrity</category>
    </item>
    <item>
      <title>When two sensors disagree, rank them</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Fri, 18 Sep 2026 06:44:33 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/when-two-sensors-disagree-rank-them-12ki</link>
      <guid>https://dev.to/darkpandawarrior/when-two-sensors-disagree-rank-them-12ki</guid>
      <description>&lt;p&gt;GPS said the car was moving. The accelerometer said the phone had not twitched in ten minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj140hnb1ypnvlbynw601.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj140hnb1ypnvlbynw601.png" alt="The Second Witness, drawn as a specimen plate. Cannot say where. Knows whether. Labelled THE SECOND WITNESS, Sensor.TYPE_ACCELEROMETER." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Asking a sensor to check itself
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffulkaomhbz429tf4aema.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffulkaomhbz429tf4aema.png" alt="The better question: Rank per question, not overall" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Stationary drift is the oldest problem in location tracking. A parked phone reports small movements in every direction. Sum the gaps and a car that never left the car park has quietly driven three kilometres.&lt;/p&gt;

&lt;p&gt;The obvious defence is to look at reported speed and ignore readings that claim you are not moving. It half works, because the drift sometimes carries a plausible speed with it. The fix looks internally consistent while being completely wrong.&lt;/p&gt;

&lt;p&gt;That is the structural problem. A single sensor can only be validated against itself, so every threshold you add is really a guess about your own input. You can spend months making that guess more elaborate without making it more correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second signal that fails differently
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1vl0wesbd6ky4qruhkn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1vl0wesbd6ky4qruhkn.png" alt="The method: Corroborate, do not tune" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The accelerometer is a poor navigator. Integrate it for position and the error compounds within seconds. But ask it a narrower question, "did this device physically move", and it is far better than GPS, because its failure modes have nothing to do with satellites, tunnels, or reflections off a glass tower.&lt;/p&gt;

&lt;p&gt;Two sensors that fail differently can check each other. So the pipeline takes both:&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;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GpsFix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;motionStill&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="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// IMU says physically still&lt;/span&gt;
    &lt;span class="n"&gt;harshAccel&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="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// hard accel or braking this fix&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;ProcessResult&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and when they disagree about movement, the IMU wins:&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;// drop the sub-gate wander when GPS history shows no movement,&lt;/span&gt;
&lt;span class="c1"&gt;// OR the IMU says still, OR this coincides with a harsh accel/brake event&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;displacement&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;stationaryMicroJitter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="nf"&gt;hasMovementHistory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;motionStill&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;harshAccel&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="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment in our source is blunt about why: accelerometer stillness is authoritative.&lt;/p&gt;

&lt;p&gt;Harsh braking gets the same authority for a subtle reason. A hard stop can momentarily look like stillness to a speed gate, and the jitter it produces should not be counted as travelled distance either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rank per question, not overall
&lt;/h2&gt;

&lt;p&gt;The important nuance is that we did not decide the accelerometer is "better". It is useless for position. GPS remains the only thing that knows where you are.&lt;/p&gt;

&lt;p&gt;What we ranked was authority on one specific question:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Authority&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where are we?&lt;/td&gt;
&lt;td&gt;GPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Did we move at all?&lt;/td&gt;
&lt;td&gt;Accelerometer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How fast, over a distance?&lt;/td&gt;
&lt;td&gt;GPS, checked against implied speed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the environment trustworthy?&lt;/td&gt;
&lt;td&gt;Neither, the quality score&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A sensor is not good or bad. It is good at some questions and bad at others, and a fusion strategy is mostly a table like that one made explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheap version of the same idea
&lt;/h2&gt;

&lt;p&gt;You do not need an IMU to use this principle. At journey level we compare total GPS distance against the vehicle odometer reading:&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;discrepancyRatio&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;odometer&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;odometer&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;discrepancyRatio&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LargeDiscrepancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"odometer-vs-gps"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;discrepancyRatio&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two completely independent measurements of one number. When they disagree by more than 30 percent, something is wrong and a human should look, rather than the system silently trusting whichever number it happened to compute.&lt;/p&gt;

&lt;p&gt;Most systems have a signal like this lying around unused. A client-side event count against a server-side one. A cache's view of a value against the source of truth. A model's confidence against observed outcomes.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flatrjzrtk8tiuosr3gtt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flatrjzrtk8tiuosr3gtt.png" alt="The payload: Correlation beats cleverness" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Do not build a smarter threshold on a lonely sensor. Find a second signal that fails differently, and give it authority over the specific question it is genuinely good at.&lt;/p&gt;

&lt;p&gt;Correlation beats cleverness.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 10 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Sensors Who Lie&lt;/em&gt;* · Featuring: The Concussed Witness, The Second Witness*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-08-06-one-global-threshold/article.md" rel="noopener noreferrer"&gt;One global threshold is how you delete valid data&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/sensors-who-lie.md" rel="noopener noreferrer"&gt;Sensors Who Lie&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>sensors</category>
      <category>sensorfusion</category>
      <category>imu</category>
    </item>
    <item>
      <title>Your data model is where uncertainty goes to die</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Wed, 16 Sep 2026 05:08:31 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/your-data-model-is-where-uncertainty-goes-to-die-g6j</link>
      <guid>https://dev.to/darkpandawarrior/your-data-model-is-where-uncertainty-goes-to-die-g6j</guid>
      <description>&lt;p&gt;The sensor knew how much to trust itself. Our database column did not have room for that, so we threw it away on the way in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vdiykxbwetd0i9y2z6h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vdiykxbwetd0i9y2z6h.png" alt="The Archivist, drawn as a specimen plate. You cannot backfill confidence you discarded. Labelled THE ARCHIVIST, Provenance." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything arrives with an error bar
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fomkqoeghzturn7wvhz0f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fomkqoeghzturn7wvhz0f.png" alt="The schema: Design the row to carry doubt" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Android hands you a &lt;code&gt;Location&lt;/code&gt; object that is far richer than a point on a map. It has an accuracy radius in metres, the provider that produced it, a bearing, an altitude, a timestamp, sometimes a speed. The platform is telling you, on every reading, how much to believe it.&lt;/p&gt;

&lt;p&gt;This is not special to GPS. Models return confidences. Caches know their staleness. Upstream APIs know whether they served fresh data or a fallback. Almost every value in a real system arrives with a companion signal about its own reliability.&lt;/p&gt;

&lt;p&gt;And almost every schema drops it.&lt;/p&gt;

&lt;p&gt;Our first table was the obvious one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;LocationData&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;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&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;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&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;speed&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;date&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reasonable. Minimal. Exactly what the feature needed. It also silently discarded the accuracy radius on every write, which meant the discard was permanent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The questions you cannot answer later
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd92f95c5vupogmwi0tt3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd92f95c5vupogmwi0tt3.png" alt="The columns: What every row owes you" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Within a few months, the real questions arrived:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Was this journey tracked well, or was the phone in a basement the whole time?&lt;/li&gt;
&lt;li&gt;Which readings were fused location and which were raw GPS?&lt;/li&gt;
&lt;li&gt;Was the device physically moving when this arrived, or just drifting?&lt;/li&gt;
&lt;li&gt;Why is this trip's distance different from the driver's odometer?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not one of those is an algorithm problem. They are all schema problems. The information existed at write time, in memory, for free, and we did not have a column for it.&lt;/p&gt;

&lt;p&gt;You cannot backfill confidence. Once the row is written without it, that uncertainty is gone for every future reader, forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The row that carries its papers
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcqgxz57c043yj13ch4og.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcqgxz57c043yj13ch4og.png" alt="The payload: You cannot backfill confidence you discarded" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What we persist now:&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;data class&lt;/span&gt; &lt;span class="nc"&gt;LocationData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;// the reading&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&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;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&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;speed&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;bearing&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;altitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&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;locationTime&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="c1"&gt;// how much to trust it, and where it came from&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;provider&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="c1"&gt;// what the device was doing&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gyroscopeX&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gyroscopeY&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gyroscopeZ&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;accelerometerX&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;accelerometerY&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;accelerometerZ&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;batteryPercentage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&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;deviceModel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;appVersionName&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="c1"&gt;// what we already decided about it&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isMock&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isAbnormal&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isPaused&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;displacement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&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;Three groups, and the third is the one people skip. Storing our own verdict next to the evidence means a later reader can see both what we concluded and what we concluded it from. When a threshold turns out to be wrong, every affected row is identifiable rather than theoretical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confidence as a number, not a vibe
&lt;/h2&gt;

&lt;p&gt;Raw columns let you reconstruct the truth. They do not make it usable in a UI. So the environment collapses into a single score, 0 to 100:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Penalty&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Permission missing&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mock location&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Process killed&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restarted&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPS off&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Battery optimised&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Power saver&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;plus an accuracy tier (15m or better is free, worse than 75m costs 20) and a small bonus when the fix stream is stable. Now the app can say "this journey was tracked in poor conditions" instead of presenting a number with false confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Design the row to carry doubt. If a value can be wrong, the schema needs somewhere to record how wrong, where it came from, and what you already decided about it.&lt;/p&gt;

&lt;p&gt;Those columns are cheap. Add them before you need them, because the alternative is discovering at query time that the answer was thrown away at write time, months ago, by a schema that assumed confidence did not matter.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 9 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Chain Of Custody&lt;/em&gt;* · Featuring: The Archivist*&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/chain-of-custody.md" rel="noopener noreferrer"&gt;Chain Of Custody&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>datamodelling</category>
      <category>architecture</category>
      <category>provenance</category>
      <category>sensors</category>
    </item>
    <item>
      <title>One global threshold is how you delete valid data</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Mon, 14 Sep 2026 04:01:20 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/one-global-threshold-is-how-you-delete-valid-data-303h</link>
      <guid>https://dev.to/darkpandawarrior/one-global-threshold-is-how-you-delete-valid-data-303h</guid>
      <description>&lt;p&gt;Our GPS filter worked perfectly, right up until someone sat in Bangalore traffic.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvgwyiz6s1qwxoctebtu7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvgwyiz6s1qwxoctebtu7.png" alt="The Concussed Witness, drawn as a specimen plate. Confident. Cooperative. Often wrong. Labelled THE CONCUSSED WITNESS, LocationManager." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The phantom distance problem
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwy4q91lxheho8om3pg0g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwy4q91lxheho8om3pg0g.png" alt="The claim you made: A constant is an assumption" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A parked phone does not sit still in the data. The reported position wanders a few metres in every direction, and if you naively sum the gaps between readings, a stationary car quietly accumulates kilometres. On a mileage app that becomes an expense claim, so it matters.&lt;/p&gt;

&lt;p&gt;The fix looks trivial. Ignore any step below some minimum:&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;displacement&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nc"&gt;MIN_DISPLACEMENT_M&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;// 5.0, felt about right&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parked cars stop drifting. Tests pass. Ship.&lt;/p&gt;

&lt;p&gt;Then a driver crawls through traffic at walking pace for forty minutes. Every genuine step they take is under five metres. We deleted the whole journey and told them they had not moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constant was making a claim
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feyk7qx388o8rhch4gbf9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feyk7qx388o8rhch4gbf9.png" alt="The fix: Thresholds take context" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MIN_DISPLACEMENT_M = 5.0&lt;/code&gt; looks like a number. It is really an assertion: five metres means the same thing whether you are parked, walking, cycling, on a motorway, or crawling in first gear. That assertion is false, and the filter had no way to know it.&lt;/p&gt;

&lt;p&gt;Every threshold worth keeping is a function of context. Ours ended up depending on three things.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Speed bands
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;minDisplacementForSpeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;speedMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;speedMps&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;    &lt;span class="c1"&gt;// walking&lt;/span&gt;
    &lt;span class="n"&gt;speedMps&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;7.0&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt;    &lt;span class="c1"&gt;// cycling&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;           &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;    &lt;span class="c1"&gt;// driving&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Faster travel expects and tolerates bigger steps between readings. Slower travel needs a tighter gate or you erase it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Time-gap tiers
&lt;/h3&gt;

&lt;p&gt;An implausible speed is only implausible relative to how long you were not looking. Under normal sampling, a 5km jump is a teleport. After a six hour gap it is a commute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;isAbnormal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;displacement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;impliedSpeed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtSec&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="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dtSec&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;      &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5_000&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;impliedSpeed&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;70.0&lt;/span&gt;  &lt;span class="c1"&gt;// ~252 km/h&lt;/span&gt;
    &lt;span class="n"&gt;dtSec&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;    &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;impliedSpeed&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;150.0&lt;/span&gt;
    &lt;span class="n"&gt;dtSec&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3_600&lt;/span&gt;  &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;impliedSpeed&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt;
    &lt;span class="n"&gt;dtSec&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;21_600&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;impliedSpeed&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;60.0&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;            &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;   &lt;span class="c1"&gt;// stop testing speed entirely&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tunnels, killed processes, flights and genuine glitches all produce a jump. The gap is what tells them apart, so the gap has to be an input.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Recent history
&lt;/h3&gt;

&lt;p&gt;This is the one that actually rescued the traffic case. A small step is only treated as jitter when the recent window agrees that nothing is happening:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;hasMovementHistory&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="n"&gt;recentSpeedHistory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;recentSpeedHistory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;average&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;   &lt;span class="c1"&gt;// last 5 fixes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sustained slow movement is movement. Genuine stillness is stillness. A rolling window can tell them apart. A constant never could.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make them configurable while you are at it
&lt;/h2&gt;

&lt;p&gt;Once thresholds are context-dependent there are more of them, and you will get some wrong. Ours live in one serialisable object rather than scattered &lt;code&gt;const val&lt;/code&gt;s:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Serializable&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;AbnormalDetectionConfig&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;walkingMaxMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.5&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;walkingJitterM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&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;cyclingJitterM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;3.0&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;drivingJitterM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;5.0&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;speedHistorySize&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="mi"&gt;5&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;movementHistoryMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.5&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;spikeHardGateM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;5_000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...gap tiers&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tuning becomes a config change rather than a release. The defaults reproduce the old hardcoded values exactly, so extracting them proved nothing changed.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpj7jyv78dfoytsntjsm5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpj7jyv78dfoytsntjsm5.png" alt="The payload: A constant with no context is a bug on a delay" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you write a constant into a filter, ask what context you are assuming. If the answer is "all of them", you have not written a threshold. You have written a bug with a delay on it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 8 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Sensors Who Lie&lt;/em&gt;* · Featuring: The Concussed Witness*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-08-04-filtered-never-deleted/article.md" rel="noopener noreferrer"&gt;Filtered should never mean deleted&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/sensors-who-lie.md" rel="noopener noreferrer"&gt;Sensors Who Lie&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>location</category>
      <category>thresholds</category>
      <category>heuristics</category>
    </item>
    <item>
      <title>Filtered should never mean deleted</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Sun, 06 Sep 2026 06:33:33 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/filtered-should-never-mean-deleted-5081</link>
      <guid>https://dev.to/darkpandawarrior/filtered-should-never-mean-deleted-5081</guid>
      <description>&lt;p&gt;We shipped a filter that threw away bad GPS readings. Months later somebody asked whether it was working, and I could not answer. The evidence was gone.&lt;/p&gt;

&lt;p&gt;That question changed how I build anything that rejects data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqazmdaxrkgqairkuc5tv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqazmdaxrkgqairkuc5tv.png" alt="The Concussed Witness, drawn as a specimen plate. Confident. Cooperative. Often wrong. Labelled THE CONCUSSED WITNESS, LocationManager." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious version, and why it rots
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpd1irso4llzfzsw180mi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpd1irso4llzfzsw180mi.png" alt="The shape that works: Classify. Do not discard." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Mileage tracking depends on trustworthy distance, and GPS lies constantly. So the first version of our cleanup did what everyone's first version does:&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;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isPlausible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous&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;// drop it, move on&lt;/span&gt;
&lt;span class="nf"&gt;accumulateDistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean data comes out the other end. It feels responsible. It is also a trap, because that &lt;code&gt;return&lt;/code&gt; destroys the only record that could ever tell you whether the rejection was correct.&lt;/p&gt;

&lt;p&gt;Six months in, someone asked the reasonable question: is the filter right? I could not say how many readings we had dropped, on which journeys, or whether any of them had been a genuine drive through a tunnel rather than a glitch. We had built a thing that made a judgement call thousands of times a day and kept no record of any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persist, then classify
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn3mt7iqx6udyp0eb5v68.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn3mt7iqx6udyp0eb5v68.png" alt="The pattern: Keep the rejects" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rebuild flipped the default. Rejection stopped being a &lt;code&gt;return&lt;/code&gt; and became a label.&lt;/p&gt;

&lt;p&gt;Only two cases are still deleted, because they cannot physically be real:&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;// impossible coordinates&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;fix&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="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;90.0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mf"&gt;90.0&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;fix&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="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;180.0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mf"&gt;180.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="c1"&gt;// impossible accuracy: too precise to be true, or useless&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;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accuracyM&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1f&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accuracyM&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;250f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire delete list. Everything else is persisted and sorted into named accumulators:&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;originalDistanceM&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt;          &lt;span class="c1"&gt;// every metre we ever saw&lt;/span&gt;
&lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isMock&lt;/span&gt;  &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mockDistanceM&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt;
    &lt;span class="n"&gt;abnormal&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;abnormalDistanceM&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;displacement&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;isHardSpike&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;spikeDistanceM&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;accuracyGated&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* recorded, deliberately not counted */&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;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cleanedDistanceM&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five numbers instead of one. The UI shows &lt;code&gt;cleaned&lt;/code&gt;. The rest live beside it.&lt;/p&gt;

&lt;p&gt;And the row itself keeps its provenance: accuracy, provider, bearing, altitude, the gyroscope and accelerometer snapshot, battery level, device model, plus &lt;code&gt;isMock&lt;/code&gt; and &lt;code&gt;isAbnormal&lt;/code&gt;. A filtered reading is still sitting there, labelled with exactly why it did not count.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that bought
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffhd81zfx9y076i5gsuv0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffhd81zfx9y076i5gsuv0.png" alt="The payload: You cannot tune a filter you cannot audit" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disputes became answerable.&lt;/strong&gt; This is mileage that turns into an expense claim. When a driver says the distance is wrong, we pull the journey and show what was removed and why. Before, the honest answer was "the algorithm decided", which is not an answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thresholds became tunable.&lt;/strong&gt; We could replay real journeys and ask how much distance we were classifying as abnormal. Twice the data said our threshold was too aggressive on genuine motorway driving, and we moved it. That correction is impossible if the rejected steps are gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ratios became alarms.&lt;/strong&gt; Because we keep both numbers, we can assert on their relationship:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cleaned = total - (mock + abnormal)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and warn when abnormal exceeds half a journey, or when GPS distance and the vehicle odometer diverge by more than 30 percent. If most of a real trip is landing in the abnormal bucket, the threshold is wrong, not the driver.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general rule
&lt;/h2&gt;

&lt;p&gt;This is not a GPS lesson. Any system that rejects data has the same shape: fraud scoring, metrics pipelines, log sampling, feature stores, anything with a validation step. The moment you drop a record, you have made an irreversible claim about it and destroyed the ability to check.&lt;/p&gt;

&lt;p&gt;Keeping it is usually cheap. A boolean column and a second accumulator is nothing next to permanently losing the answer to "is this working?"&lt;/p&gt;

&lt;p&gt;So: filtered should never mean deleted. Filtered means "not counted, and here is the reason, written down, next to the thing itself."&lt;/p&gt;

&lt;p&gt;If your pipeline drops rows, find out where they go. "Nowhere" is an answer, just not a good one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 7 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Sensors Who Lie&lt;/em&gt;* · Featuring: The Concussed Witness*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-07-19-mileway-dead-reckoning/article.md" rel="noopener noreferrer"&gt;Teaching a phone to disbelieve its own GPS&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/sensors-who-lie.md" rel="noopener noreferrer"&gt;Sensors Who Lie&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>datamodelling</category>
      <category>filtering</category>
      <category>observability</category>
    </item>
    <item>
      <title>collectAsState is quietly leaking your work</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Fri, 04 Sep 2026 04:43:46 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/collectasstate-is-quietly-leaking-your-work-4ak0</link>
      <guid>https://dev.to/darkpandawarrior/collectasstate-is-quietly-leaking-your-work-4ak0</guid>
      <description>&lt;p&gt;Here is a line you have written a hundred times:&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;state&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collectAsState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks harmless. It is the standard way to turn a &lt;code&gt;Flow&lt;/code&gt; into Compose state. And on a screen with live data, it quietly keeps working long after the user has walked away.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feyxtsb0wytc3inkvbozt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feyxtsb0wytc3inkvbozt.png" alt="The Recomposer, drawn as a specimen plate. It redraws the room every time you blink. Labelled THE RECOMPOSER, @Composable." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The screen nobody is watching
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6yt3wlmslxvs53q1ufy3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6yt3wlmslxvs53q1ufy3.png" alt="The one word: collectAsState WithLifecycle" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;collectAsState&lt;/code&gt; starts collecting the flow and stops only when the composable leaves the composition. That sounds right until you remember what backgrounding an app does, which is almost nothing to the composition. Send the app to the background and the composable is still composed. So the collection keeps running. The flow keeps emitting. Your ViewModel keeps fetching, computing, and pushing new state to a screen that is not on screen.&lt;/p&gt;

&lt;p&gt;On a live price ticker or a location feed, that is real battery and mobile data spent in the dark. It is also a quiet source of bugs, because off-screen updates can pile up and then hit the user with a jump of stale changes the moment they return.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-word fix
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx7y0joj1q6f6dvvms6gk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx7y0joj1q6f6dvvms6gk.png" alt="The rule: Make lifecycle the default" width="800" height="1000"&gt;&lt;/a&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;state&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collectAsStateWithLifecycle&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;collectAsStateWithLifecycle&lt;/code&gt; ties the collection to the lifecycle owner. When the lifecycle drops below &lt;code&gt;STARTED&lt;/code&gt;, it stops collecting. When it comes back, it resumes. If your upstream is a &lt;code&gt;stateIn&lt;/code&gt; or &lt;code&gt;shareIn&lt;/code&gt; flow with &lt;code&gt;WhileSubscribed&lt;/code&gt;, the whole pipeline can now go cold while hidden and warm back up on return. Nobody pays for a screen that is not visible.&lt;/p&gt;

&lt;p&gt;It lives in &lt;code&gt;androidx.lifecycle.runtime.compose&lt;/code&gt;. Add the dependency, change the one call, and the leak is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it the default
&lt;/h2&gt;

&lt;p&gt;Treat &lt;code&gt;collectAsStateWithLifecycle&lt;/code&gt; as the default and plain &lt;code&gt;collectAsState&lt;/code&gt; as the exception. The rare time you actually want collection to continue while the screen is hidden should be a deliberate, commented decision, not something you got by reaching for the shorter name out of habit.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6wa0pgr8nzp0sq7hie0m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6wa0pgr8nzp0sq7hie0m.png" alt="The payload: Lifecycle is not a thing you bolt on later" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Lifecycle awareness is not a nice-to-have you bolt on later. It is the line between work that stops when the user leaves and work that runs forever in the background. The Recomposer does not mind an empty room. It will happily keep redrawing it. Your job is to turn the lights off when everyone goes home.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 6 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Ghosts In The Recomposition&lt;/em&gt;* · Featuring: The Recomposer*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-07-24-lazycolumn-recomposition/article.md" rel="noopener noreferrer"&gt;Your LazyColumn recomposes on every scroll&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/ghosts-in-the-recomposition.md" rel="noopener noreferrer"&gt;Ghosts In The Recomposition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>jetpackcompose</category>
      <category>flow</category>
      <category>lifecycle</category>
    </item>
    <item>
      <title>expect/actual is the wrong default in KMP</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Wed, 02 Sep 2026 05:41:21 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/expectactual-is-the-wrong-default-in-kmp-5g7p</link>
      <guid>https://dev.to/darkpandawarrior/expectactual-is-the-wrong-default-in-kmp-5g7p</guid>
      <description>&lt;p&gt;Every Kotlin Multiplatform tutorial teaches &lt;code&gt;expect&lt;/code&gt;/&lt;code&gt;actual&lt;/code&gt; in the first ten minutes. It is the headline feature, the thing that makes KMP feel like magic. So new teams learn it first and then reach for it every single time common code needs to touch a platform.&lt;/p&gt;

&lt;p&gt;That habit is how shared modules turn into a knot. &lt;code&gt;expect&lt;/code&gt;/&lt;code&gt;actual&lt;/code&gt; is a fine tool for a narrow job, and the wrong default for most of them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd7509eo8hydrkk4i0gke.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd7509eo8hydrkk4i0gke.png" alt="The Understudy, drawn as a specimen plate. Cast by name at compile time. No swapping mid-run. Labelled THE UNDERSTUDY, actual." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What expect/actual actually is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr0tgwpyi9pxocjuk1ygu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr0tgwpyi9pxocjuk1ygu.png" alt="The alternative: An interface in common, injected per platform" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is a compile-time binding by name. You declare something in common:&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;// commonMain&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Clipboard&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the compiler requires exactly one &lt;code&gt;actual&lt;/code&gt; per platform:&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;// androidMain&lt;/span&gt;
&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Clipboard&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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="cm"&gt;/* android impl */&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 binding is fixed at build time. There is one &lt;code&gt;actual&lt;/code&gt; for Android, one for iOS, and the compiler wires them in. That sounds convenient, and for the right case it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it hurts
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu4yf2npcmxk8sijnod4u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu4yf2npcmxk8sijnod4u.png" alt="How to choose: Weld, or hinge?" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The moment you want to do anything other than "one fixed body per platform," the weld gets in the way.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You cannot inject a fake.&lt;/strong&gt; There is no test &lt;code&gt;actual&lt;/code&gt;, so your common use case that
depends on &lt;code&gt;Clipboard&lt;/code&gt; cannot be tested in &lt;code&gt;commonTest&lt;/code&gt; without inventing a whole extra source set to satisfy the compiler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot hold two implementations.&lt;/strong&gt; No real one and a fake one, no A/B, no decorator.
The compiler picks one per platform and that is that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your common code is coupled to platform declarations,&lt;/strong&gt; one weld per concern, spread
across storage, networking, clipboard, analytics, and everything else you reached for it with.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I lived this on PaymentsLab. Early common code used &lt;code&gt;expect&lt;/code&gt;/&lt;code&gt;actual&lt;/code&gt; for every platform seam. It compiled and shipped. Then I sat down to unit-test a payment use case in &lt;code&gt;commonTest&lt;/code&gt; and simply could not, because the seams had no fakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The better default: an interface, injected
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Clipboard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CopyReceiptUseCase&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;val&lt;/span&gt; &lt;span class="py"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Clipboard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Receipt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asText&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;Provide the real thing per platform, and hand it in through your DI graph (Koin, in our case):&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;// androidMain&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AndroidClipboard&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;val&lt;/span&gt; &lt;span class="py"&gt;ctx&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="nc"&gt;Clipboard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonTest&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FakeClipboard&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Clipboard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;last&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;null&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;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Test&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;copies_receipt&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;fake&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeClipboard&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nc"&gt;CopyReceiptUseCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fake&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="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&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;Same platform bodies. Same behavior. But now the common code depends on an interface it owns, not on a platform declaration, and it is testable in one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  So when is expect/actual right?
&lt;/h2&gt;

&lt;p&gt;For the small, fixed, one-per-platform joints where a stand-in makes no sense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;typealias&lt;/code&gt; to a platform type (&lt;code&gt;expect class Uuid&lt;/code&gt;, actualized to the platform's UUID).&lt;/li&gt;
&lt;li&gt;A single top-level function with exactly one meaning per platform and nothing to test,
like reading a platform constant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are genuine welds. One joint, permanent, no reason to swap it.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp0wccajlr66ml1x6d3nw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp0wccajlr66ml1x6d3nw.png" alt="The payload: Reach for the tool that keeps your options open" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The KMP question is rarely "how do I reach the platform." It is "what do I hand my common code." Hand it an interface it owns, and inject the platform body. Keep &lt;code&gt;expect&lt;/code&gt;/&lt;code&gt;actual&lt;/code&gt; for the handful of places you actually want a weld.&lt;/p&gt;

&lt;p&gt;The Understudy is perfect for a small fixed role, cast by name, on stage every night. Just do not cast one for every part in the play.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 5 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **One Brain Two Bodies&lt;/em&gt;* · Featuring: The Understudy*&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/one-brain-two-bodies.md" rel="noopener noreferrer"&gt;One Brain Two Bodies&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>kmp</category>
      <category>multiplatform</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your LazyColumn recomposes on every scroll</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Sun, 30 Aug 2026 06:26:12 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/your-lazycolumn-recomposes-on-every-scroll-3amj</link>
      <guid>https://dev.to/darkpandawarrior/your-lazycolumn-recomposes-on-every-scroll-3amj</guid>
      <description>&lt;p&gt;A feed screen felt heavy while scrolling, only on mid-range phones. Layout Inspector told the whole story: every visible row was recomposing on every scroll frame, including rows whose data had not changed at all.&lt;/p&gt;

&lt;p&gt;There was a ghost in the recomposition, and it had one cause.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feh5eio50uw6vpnuiza8d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feh5eio50uw6vpnuiza8d.png" alt="The Recomposer, drawn as a specimen plate. It redraws the room every time you blink. Labelled THE RECOMPOSER, @Composable." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Compose can skip, if you let it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5wxjxpbv2tkw4bpvgh13.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5wxjxpbv2tkw4bpvgh13.png" alt="The cause: A List is not a stable type" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Compose has a quiet superpower called skipping. When a composable's inputs have not changed, Compose skips re-running it entirely. That is most of why it is fast.&lt;/p&gt;

&lt;p&gt;Skipping depends on stability. To skip a composable, Compose has to prove its parameters did not change. Some types make that proof easy. Others make it impossible.&lt;/p&gt;

&lt;p&gt;A plain &lt;code&gt;List&lt;/code&gt; is one of the impossible ones. &lt;code&gt;List&lt;/code&gt; is an interface. The thing behind it could be a mutable list that changed under Compose's feet, and the compiler cannot rule that out. So Compose treats every &lt;code&gt;List&lt;/code&gt; parameter as unstable, assumes it might have changed, and recomposes the rows that read it. On a scrolling feed, that means redrawing everything you can see, constantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is the type
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fitlru8v72otait3qvnqp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fitlru8v72otait3qvnqp.png" alt="The fix: Let it prove nothing changed" width="800" height="1000"&gt;&lt;/a&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;// before: List is unstable, rows cannot skip&lt;/span&gt;
&lt;span class="nd"&gt;@Composable&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// after: an immutable list is stable, skipping works&lt;/span&gt;
&lt;span class="nd"&gt;@Composable&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ImmutableList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&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;Use &lt;code&gt;kotlinx.collections.immutable&lt;/code&gt; and build the list with &lt;code&gt;persistentListOf(...)&lt;/code&gt; or &lt;code&gt;.toImmutableList()&lt;/code&gt;. Now Compose knows the collection cannot change, and it skips rows whose data is unchanged.&lt;/p&gt;

&lt;p&gt;While you are there, give the items a key:&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;LazyColumn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&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;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;PostRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&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;Without keys, Compose tracks rows by position. Insert one at the top and every row below shifts identity, so more of them redraw than needed. A stable key ties a row to its data.&lt;/p&gt;

&lt;h2&gt;
  
  
  See it for yourself
&lt;/h2&gt;

&lt;p&gt;Turn on recomposition counts in Layout Inspector and scroll. With an unstable list, the counter on every row climbs. Switch to an immutable list and add keys, and the counters go still. That stillness is the whole point.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu93heqqj838oo09u28ha.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu93heqqj838oo09u28ha.png" alt="The payload: Compose is fast. You handed it a puzzle" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Compose is not slow. It is fast by default. Almost every "Compose is janky" story is really the same story: someone handed a composable a type it could not reason about, so it stopped skipping and started repainting the house.&lt;/p&gt;

&lt;p&gt;The Recomposer only redraws the room when you give it a reason. Stop giving it reasons.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 4 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Ghosts In The Recomposition&lt;/em&gt;* · Featuring: The Recomposer*&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/ghosts-in-the-recomposition.md" rel="noopener noreferrer"&gt;Ghosts In The Recomposition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>jetpackcompose</category>
      <category>recomposition</category>
      <category>stability</category>
    </item>
    <item>
      <title>The 5-second window that crashes your service</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Fri, 28 Aug 2026 15:00:33 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/the-5-second-window-that-crashes-your-service-5cmm</link>
      <guid>https://dev.to/darkpandawarrior/the-5-second-window-that-crashes-your-service-5cmm</guid>
      <description>&lt;p&gt;Our background service crashed for thousands of users, and the stack trace pointed at a line that does nothing wrong.&lt;/p&gt;

&lt;p&gt;The exception was &lt;code&gt;ForegroundServiceDidNotStartInTimeException&lt;/code&gt;. I had to look it up. And once I understood it, a whole class of "works on my machine" bugs made sense.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fooymtx36mvf9he4zsa5u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fooymtx36mvf9he4zsa5u.png" alt="Doze The Jailer, drawn as a specimen plate. You get five seconds to explain yourself. Labelled DOZE THE JAILER, startForeground()." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The promise you make
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff8cfkxw6qw106h50q7r8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff8cfkxw6qw106h50q7r8.png" alt="The trap: Five seconds spent before the promise" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you start a foreground service, you are telling Android something specific: I am about to show a persistent notification and do work the user cares about, so please do not kill me the moment the screen sleeps. That is a real privilege. Foreground services get to keep running when almost nothing else can.&lt;/p&gt;

&lt;p&gt;Android grants it on one condition. You have about 5 seconds from starting the service to calling &lt;code&gt;startForeground()&lt;/code&gt; with a notification. Keep that promise and you get to stay. Miss it and the system kills the service and throws.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it only broke for some users
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftoafct9ee5saoc4is2gn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftoafct9ee5saoc4is2gn.png" alt="The rules: Working the night shift" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Our service did 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="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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;trip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tripStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restoreLastTrip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// disk + db read&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;             &lt;span class="c1"&gt;// more io&lt;/span&gt;
    &lt;span class="nf"&gt;startForeground&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;buildNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my phone, &lt;code&gt;restoreLastTrip()&lt;/code&gt; and &lt;code&gt;settings.load()&lt;/code&gt; finished in under 100 milliseconds. On a cheap device under memory pressure, with the disk busy and the CPU throttled, they sometimes took four or five seconds. By the time we reached &lt;code&gt;startForeground()&lt;/code&gt;, the window had closed. The crash only ever showed up on low-end devices, which is exactly the population least likely to file a good bug report.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Post the notification first. Always. Then do the slow work.&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;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="nf"&gt;startForeground&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;buildMinimalNotification&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;// keep the promise instantly&lt;/span&gt;

    &lt;span class="n"&gt;serviceScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;trip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tripStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restoreLastTrip&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;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;updateNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// enrich it later&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The minimal notification can say something plain like "Starting trip tracking." The user sees it for a fraction of a second before the real one replaces it. That is a fine price for not crashing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Android 12 twist
&lt;/h2&gt;

&lt;p&gt;On Android 12 and up there is a second rule that catches people. In many cases you cannot start a foreground service from the background at all. If your trigger fires while the app is not visible, the launch itself throws.&lt;/p&gt;

&lt;p&gt;The answer is usually not to fight it. It is to use &lt;code&gt;WorkManager&lt;/code&gt; with an expedited request, which is the platform's sanctioned way to say "this is urgent, but let the system schedule 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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;work&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OneTimeWorkRequestBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TripWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setExpedited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutOfQuotaPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RUN_AS_NON_EXPEDITED_WORK_REQUEST&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="nc"&gt;WorkManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&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="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg2kvvefmjd062uh3khk6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg2kvvefmjd062uh3khk6.png" alt="The payload: The platform is not out to get you" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The platform is not out to get you. Every one of these rules exists because some app, somewhere, tried to run forever and drained a stranger's battery. Post your notification first, respect the background-start limits, and the system leaves your work alone.&lt;/p&gt;

&lt;p&gt;Doze the Jailer runs the night shift. You get 5 seconds to explain why you belong. Explain fast.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 3 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **The Night Shift&lt;/em&gt;* · Featuring: Doze The Jailer*&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/the-night-shift.md" rel="noopener noreferrer"&gt;The Night Shift&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>foregroundservice</category>
      <category>workmanager</category>
      <category>lifecycle</category>
    </item>
    <item>
      <title>CancellationException is not an assassin</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Sun, 02 Aug 2026 07:30:50 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/cancellationexception-is-not-an-assassin-2220</link>
      <guid>https://dev.to/darkpandawarrior/cancellationexception-is-not-an-assassin-2220</guid>
      <description>&lt;p&gt;I cancelled a coroutine. It kept running for 8 more seconds. It just did not care.&lt;/p&gt;

&lt;p&gt;This was a search screen. You type, it fires a network request. Type again, it cancels the old request and fires a new one. Standard stuff. Except the old request kept running, and every so often it finished last and painted stale results right over the fresh ones. I had called cancel. I had watched it run in the debugger. The job was "cancelled." And still it went on.&lt;/p&gt;

&lt;p&gt;The culprit was one line I wrote myself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn96fv3l1f4osytd8fhdn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn96fv3l1f4osytd8fhdn.png" alt="The Messenger, drawn as a specimen plate: a hooded figure in an assassin's cloak, holding out a folded note stamped NOTICE. Labelled THE MESSENGER, CancellationException, exhibit 07." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancellation is a message, not a bullet
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpjwib3xt4588qg4ab02k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpjwib3xt4588qg4ab02k.png" alt="The trap: The line that ate the message" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the part people miss about Kotlin coroutines. Cancelling one does not reach in and kill it. It cannot. The coroutine has to cooperate. So the machinery throws a &lt;code&gt;CancellationException&lt;/code&gt; up through your suspend calls, and that exception is the message: we are done, unwind, release your resources, stop.&lt;/p&gt;

&lt;p&gt;Your job is to let that message travel. Most cancellation bugs are really one bug: you stopped the message from traveling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that ate the message
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqzxi9q4aumy2rlr8rl74.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqzxi9q4aumy2rlr8rl74.png" alt="Pick one: Four ways out" width="800" height="1000"&gt;&lt;/a&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// a suspend call&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"search failed"&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 looks careful. It looks like good defensive code. It is a trap.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CancellationException&lt;/code&gt; extends &lt;code&gt;Exception&lt;/code&gt;. So when cancellation fires mid-request, this &lt;code&gt;catch (e: Exception)&lt;/code&gt; grabs it, logs "search failed", and carries on as if a network error happened. The coroutine never unwinds. The framework thinks you handled something. The work continues.&lt;/p&gt;

&lt;p&gt;You caught the messenger, logged that he looked upset, and locked him in a cell. The message he was carrying never got delivered.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fut7zrmha6p07vnlkcy9x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fut7zrmha6p07vnlkcy9x.png" alt="Diagram: a cancellation signal travelling up the suspend stack from api.search(query), through try, and dying at catch (e: Exception), which is marked " width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways to stop doing this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Catch what you actually expect.&lt;/strong&gt; Ninety percent of the time you do not want &lt;code&gt;Exception&lt;/code&gt;. You want the specific thing that can go wrong.&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;IOException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;showOfflineState&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;CancellationException&lt;/code&gt; is not an &lt;code&gt;IOException&lt;/code&gt;, so it sails right past and does its job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. If you must catch broadly, rethrow cancellation first.&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CancellationException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;                       &lt;span class="c1"&gt;// let the message through&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"search failed"&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;strong&gt;3. Know that &lt;code&gt;runCatching&lt;/code&gt; has the same trap.&lt;/strong&gt; It is a lovely little helper and it catches &lt;code&gt;CancellationException&lt;/code&gt; too.&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;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runCatching&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// also swallows cancellation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use it inside a coroutine, check for cancellation and rethrow, or do not use it there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. For cleanup that must run even while cancelling, use &lt;code&gt;NonCancellable&lt;/code&gt;.&lt;/strong&gt; A naked catch is the wrong tool for "always close 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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;withContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NonCancellable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flushAndClose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;    &lt;span class="c1"&gt;// runs even though the coroutine is cancelling&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8v6wjfbzno4t0ol4lpx7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8v6wjfbzno4t0ol4lpx7.png" alt="Diagram: the same suspend stack, fixed. The cancellation signal now travels all the way up, past catch (e: CancellationException) which rethrows, past catch (e: IOException), and reaches " width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the language made it this way
&lt;/h2&gt;

&lt;p&gt;Structured concurrency is the reason. When a parent scope cancels, every child needs to wind down cleanly and report back up. That only works if cancellation propagates. If any link in the chain swallows the exception, the parent never learns the child stopped, and your careful tree of coroutines turns into orphans that run in the dark.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;CancellationException&lt;/code&gt; is not an error to be defended against. It is the one exception in the system that is doing exactly what you asked. It is the app shutting things down cleanly, on time, on request.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2onz7mu16aw8tyrs5lx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2onz7mu16aw8tyrs5lx.png" alt="The payload: Do not shoot the messenger" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Cancellation is a conversation, not a kill switch. If you swallow the message, the work does not stop. It just stops telling you it is still running.&lt;/p&gt;

&lt;p&gt;Do not shoot the messenger.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 2 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **The Coroutine Court&lt;/em&gt;* · Featuring: The Messenger*&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/the-coroutine-court.md" rel="noopener noreferrer"&gt;The Coroutine Court&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>coroutines</category>
      <category>cancellation</category>
      <category>structuredconcurrency</category>
    </item>
    <item>
      <title>Teaching a phone to disbelieve its own GPS</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Sun, 19 Jul 2026 19:25:38 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/teaching-a-phone-to-disbelieve-its-own-gps-cip</link>
      <guid>https://dev.to/darkpandawarrior/teaching-a-phone-to-disbelieve-its-own-gps-cip</guid>
      <description>&lt;p&gt;The bug report came in on a Tuesday. One line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Your app says I hit 400 kmph. I was at a red light."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He wasn't lying. Neither was the app. The phone genuinely believed it, and that is the interesting part.&lt;/p&gt;

&lt;p&gt;I work on mileage tracking. If you have ever had an app quietly log your drives for expenses or taxes, that is the category. Trip accuracy is not a feature of the product. It is the product. When we started, ours sat around 50 percent, and almost every missing point traced back to the same thing: the phone lying to us with total confidence.&lt;/p&gt;

&lt;p&gt;Here is how we got it to 95, and the one idea underneath all of it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhg0gbc50lvsh9x4z2hix.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhg0gbc50lvsh9x4z2hix.png" alt="The Concussed Witness, drawn as a specimen plate: a satellite with a bandaged, tilted dish and dizzy crossed-out eyes, confidently broadcasting a rejected reading of 400 km/h. Labelled THE CONCUSSED WITNESS, LocationManager, exhibit 01." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  GPS is a witness with a concussion
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6puzzrcxj1hfbngw9274.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6puzzrcxj1hfbngw9274.png" alt="The payload: Assume your inputs will lie" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We treat GPS like a source of truth. Out in the world, where people actually drive, it behaves more like a witness who took a hard knock to the head. Confident. Cooperative. Frequently wrong.&lt;/p&gt;

&lt;p&gt;Three places break it badly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tunnels.&lt;/strong&gt; The receiver loses its view of the sky. Instead of admitting that, it keeps reporting the last position it had, sometimes for 30 to 40 seconds. Then you come out the other end, it reacquires, and it snaps to your real location in a single jump. Take that jump, divide by the tiny time it took, and you get a parked car doing 400 kmph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Urban canyons.&lt;/strong&gt; Signals bounce off glass towers before reaching you (multipath, if you want the word). The phone averages the reflections and places you a street or two over, very sure of itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parking garages.&lt;/strong&gt; Nothing, then noise, then nothing again.&lt;/p&gt;

&lt;p&gt;None of these are edge cases. They are Tuesday. So the question stopped being "how do we get better GPS" and became "how do we stop believing bad GPS."&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: catch the liars
&lt;/h2&gt;

&lt;p&gt;The cheapest win first. For any travel mode there is a physically possible envelope. A person walking does not teleport 200 meters in a second. A car does not accelerate like a missile. So before a reading touches the trip, check whether it implies something physics would not allow.&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="nc"&gt;GpsFix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isImpossible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GpsFix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Mode&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;meters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;haversine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;seconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;coerceAtLeast&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;speed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meters&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxPlausibleSpeed&lt;/span&gt;   &lt;span class="c1"&gt;// walking, cycling, driving each differ&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it is impossible, drop it. This one guard killed the most embarrassing errors on its own, including the 400 kmph report. It is not clever. It just refuses to be gaslit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: dead reckoning, so a gap is not a hole
&lt;/h2&gt;

&lt;p&gt;Rejecting bad points leaves you with gaps, and a tunnel is a big one. If you freeze until GPS comes back, the trip gets a straight line cutting across three streets.&lt;/p&gt;

&lt;p&gt;So when GPS drops out, stop waiting for it and estimate. Take the last good heading and speed, read the accelerometer, and carry the position forward yourself. Old sailors did this by feel, no stars, no landmarks, just direction and time and a good guess. It drifts, so you cannot lean on it for long. But a tunnel is short, and "roughly right for 40 seconds" beats "confidently wrong" or "a hole in the map."&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;deadReckon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;imu&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Imu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dt&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="nc"&gt;Position&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;speed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;imu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;forwardAccel&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;distance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;movedBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;heading&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heading&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;imu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;turnRate&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&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 trick is knowing when to trust it and when to hand control back to GPS the moment GPS is worth trusting again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: fuse, do not just pick
&lt;/h2&gt;

&lt;p&gt;Which brings us to the real fix. Most of the accuracy came from stopping the "believe whoever spoke last" habit and weighing every input by how much it deserves belief right now.&lt;/p&gt;

&lt;p&gt;A clean, consistent GPS fix pulls the estimate hard toward itself. A jittery one barely moves it. The dead-reckoned guess fills the space between. Nobody gets a veto. Everybody gets a vote, weighted by confidence.&lt;/p&gt;

&lt;p&gt;A Kalman filter is the textbook home for this, and if you reach for one, good. But the win is not the filter. The win is the mindset: hold several noisy opinions at once, and lean toward the one that looks trustworthy this second.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unglamorous half
&lt;/h2&gt;

&lt;p&gt;None of this matters if the tracker is dead. On Android, the moment the screen sleeps, the system starts looking for background work to kill, and aggressive OEM skins kill harder. We ran the pipeline in a foreground service with a small floating bubble, partly so the user could see it working, mostly so the OS would leave it alone. A tracker that gets killed is 0 percent accurate no matter how good the math is. I spent nearly as long on staying alive as on the algorithm.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnaa01updei7tu9pcv5b8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnaa01updei7tu9pcv5b8.png" alt="The three fixes as numbered cards: catch the liars, drop a reading that needs rocket acceleration; dead reckoning, estimate from the IMU and last good heading; fuse do not pick, weight each signal by how much you trust it now." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually took us from 50 to 95
&lt;/h2&gt;

&lt;p&gt;Not one silver bullet. Spike detection removed the garbage. Dead reckoning covered the gaps. Fusion made the whole thing coherent. Staying alive made it real. Each one bought a chunk, and they compounded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Steal this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treat every external input as a witness who might be concussed, not a source of truth.&lt;/li&gt;
&lt;li&gt;Give physics a veto. The cheapest correctness check is "could this even happen."&lt;/li&gt;
&lt;li&gt;When a signal drops, degrade to a rough estimate instead of freezing or guessing wildly.&lt;/li&gt;
&lt;li&gt;Weight inputs by live confidence, not recency.&lt;/li&gt;
&lt;li&gt;Keep the thing alive first. Perfect logic in a killed process is worth nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one line I kept after all of it: good systems are not the ones with perfect inputs, because nobody gets perfect inputs. Good systems assume their inputs will lie, and plan for the day they do.&lt;/p&gt;

&lt;p&gt;Trust, but verify. Especially your own sensors.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 1 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Sensors Who Lie&lt;/em&gt;* · Featuring: The Concussed Witness*&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/sensors-who-lie.md" rel="noopener noreferrer"&gt;Sensors Who Lie&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>location</category>
      <category>sensorfusion</category>
      <category>deadreckoning</category>
    </item>
  </channel>
</rss>
