<?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: Itai Hoffman</title>
    <description>The latest articles on DEV Community by Itai Hoffman (@itai_hoffman_f850e88eac45).</description>
    <link>https://dev.to/itai_hoffman_f850e88eac45</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%2F4164541%2F4277a4fd-9334-4804-899c-6dbb56d2fce5.jpg</url>
      <title>DEV Community: Itai Hoffman</title>
      <link>https://dev.to/itai_hoffman_f850e88eac45</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/itai_hoffman_f850e88eac45"/>
    <language>en</language>
    <item>
      <title>If you fail, you owe money to the cause you hate most. So every screen-time bug in my app is a billing bug</title>
      <dc:creator>Itai Hoffman</dc:creator>
      <pubDate>Tue, 06 Oct 2026 11:21:12 +0000</pubDate>
      <link>https://dev.to/itai_hoffman_f850e88eac45/if-you-fail-you-owe-money-to-the-cause-you-hate-most-so-every-screen-time-bug-in-my-app-is-a-2ne7</link>
      <guid>https://dev.to/itai_hoffman_f850e88eac45/if-you-fail-you-owe-money-to-the-cause-you-hate-most-so-every-screen-time-bug-in-my-app-is-a-2ne7</guid>
      <description>&lt;p&gt;Go one minute over your Instagram limit and you owe $15 to the NRA. Or to Everytown. Or to whichever organisation makes your skin crawl. You picked it yourself when you set the goal.&lt;/p&gt;

&lt;p&gt;That's Punish, an Android app I built alone. You set a goal ("Instagram under 30 minutes a day"), put money on it, and choose the organisation you'd hate to fund. Go over and you owe them. You pay it yourself through every.org; the app never touches the stake.&lt;/p&gt;

&lt;p&gt;Which means I'm not allowed to get the number wrong. In a normal screen-time app, a number that's a bit off is a cosmetic bug. In mine, a number that's too high sends someone's money to people they despise, for time they never spent. A number that's too low turns the whole threat into a joke. So the native layer runs on one rule: &lt;strong&gt;when you can't tell, under-count, and when you can't measure at all, say so out loud.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The app is React Native + Expo. The measuring lives in two Kotlin modules. Here's where Android lied to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Android's own totals lie. Walk the events.
&lt;/h2&gt;

&lt;p&gt;The obvious API is &lt;code&gt;UsageStatsManager.queryAndAggregateUsageStats()&lt;/code&gt;, which hands you &lt;code&gt;totalTimeInForeground&lt;/code&gt; per package. On my phone it read clearly higher than Digital Wellbeing, because an app left resumed behind a dark screen still counts as "in front". Ship that, and you're billing people for a phone lying face-down on a table.&lt;/p&gt;

&lt;p&gt;So daily goals walk the raw stream from &lt;code&gt;queryEvents()&lt;/code&gt; and track exactly one current activity. Simplified:&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;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;UsageEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTIVITY_RESUMED&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;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;curPkg&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;curCls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;curSince&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;UsageEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTIVITY_PAUSED&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;curPkg&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;className&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;className&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;curCls&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// Arrives AFTER the next RESUMED. Acting on it ends the wrong session.&lt;/span&gt;
  &lt;span class="nc"&gt;UsageEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTIVITY_STOPPED&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nc"&gt;UsageEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SCREEN_NON_INTERACTIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;UsageEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;KEYGUARD_SHOWN&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&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="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;What bit me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;STOPPED lands after the next RESUMED.&lt;/strong&gt; When the new activity was another instance of the same class (two windows of one browser), the old one's late STOPPED matched and closed the session that had just begun. Forty minutes counted as a third of a second. Only PAUSED closes a session now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions that started before the window.&lt;/strong&gt; If an app was open at midnight, its first event in the new day is a PAUSED with no RESUMED. Ignore that and every session crossing the day boundary loses its head.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The phone dying.&lt;/strong&gt; A battery cut sends no PAUSED and no screen-off, so the app in front "runs" until the phone boots again. That's ten hours of Instagram for a night the phone was dead, and somebody's money on its way to their least favourite cause. On Android 10 and later the stream logs &lt;code&gt;DEVICE_SHUTDOWN&lt;/code&gt; and &lt;code&gt;DEVICE_STARTUP&lt;/code&gt; (event types 26 and 27). At a startup, the open session ends at the last event seen before it. It under-counts the downtime and never bills it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No permission is an empty stream, not an error.&lt;/strong&gt; Without usage access, &lt;code&gt;queryEvents()&lt;/code&gt; quietly returns nothing, which my code read as zero minutes: a perfect day. The module now checks the app op first and throws &lt;code&gt;ERR_NO_USAGE_ACCESS&lt;/code&gt;, so "can't read" can never look like "didn't use". (Also: &lt;code&gt;unsafeCheckOpNoThrow&lt;/code&gt; is API 29. On Android 7 to 9 you need the deprecated &lt;code&gt;checkOpNoThrow&lt;/code&gt;.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The event stream only goes back about a week. For a 28-day baseline I use &lt;code&gt;queryUsageStats(INTERVAL_BEST, from, to)&lt;/code&gt;, which reads Android's rolled-up buckets. Two catches. Android widens the range to whole buckets, so divide by the span the buckets really cover (&lt;code&gt;firstTimeStamp&lt;/code&gt; to &lt;code&gt;lastTimeStamp&lt;/code&gt;), not the 28 days you asked for. And the buckets carry the same screen-off inflation, so I scale them by how the event walk compares with the aggregate over the last week. Only ever down.&lt;/p&gt;

&lt;p&gt;One more: the home screen. Samsung's One UI Home declares a HOME activity and no LAUNCHER one, so it's missing from any launchable-apps list, and whole-phone totals left it out: 13 minutes and 214 visits in one day on my phone. I resolve the active default with &lt;code&gt;resolveActivity()&lt;/code&gt; on &lt;code&gt;CATEGORY_HOME&lt;/code&gt;, not a query for every HOME activity, because that query also returns Settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Websites: read the address bar, and nothing else
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;UsageStatsManager&lt;/code&gt; knows packages and nothing finer. An hour of Wikipedia and an hour of Reddit are both "Chrome". The only option for a normal app is an &lt;code&gt;AccessibilityService&lt;/code&gt; that reads the browser's address bar.&lt;/p&gt;

&lt;p&gt;Mine doesn't block anything and performs no accessibility actions. It watches and times, and at set moments puts its own message on screen, which you can always continue past.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Which apps are browsers:&lt;/strong&gt; whatever answers an &lt;code&gt;ACTION_VIEW&lt;/code&gt; for an http link, from the package manager, cached for ten minutes, plus a seed list in case Android 11 package visibility filters the query. A hardcoded list breaks the day someone installs a second browser, which is the cheapest bypass there is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bar:&lt;/strong&gt; Chromium forks keep the resource id &lt;code&gt;url_bar&lt;/code&gt;, so one &lt;code&gt;findAccessibilityNodeInfosByViewId()&lt;/code&gt; covers Chrome, Edge, Brave, Opera and Vivaldi. Firefox and Samsung Internet get their own ids. Unknown browsers fall back to a bounded walk (depth 12, 400 nodes) that looks only at editable or address-like fields, never page text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bar text is not a URL.&lt;/strong&gt; It can be a search, a page title or placeholder text. Anything with a space is a search, so "how to block reddit.com" isn't a visit. Google Translate's proxy, &lt;code&gt;reddit-com.translate.goog&lt;/code&gt;, is unwrapped back to &lt;code&gt;reddit.com&lt;/code&gt;, because that's the bypass people actually reach for when there's money on the line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throttle content events.&lt;/strong&gt; Reading the bar is a synchronous trip into the browser's process. With Chrome in front, the handler averaged 1.7 to 4.9 ms an event, spiking to 136 ms, at seven to nine events a second while a page renders. A navigation always brings a window-state change, so content events now get one read a second. The average dropped under a millisecond.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screen-off isn't an accessibility event.&lt;/strong&gt; Without an &lt;code&gt;ACTION_SCREEN_OFF&lt;/code&gt; receiver, a tab left open at night kept counting until the next morning.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The watcher will die. Write down when it was alive.
&lt;/h2&gt;

&lt;p&gt;Vendors and battery optimisation kill accessibility services. After an app update, Settings can still show the service as enabled while &lt;code&gt;dumpsys accessibility&lt;/code&gt; shows it isn't bound. A dead watcher reports zero browsing, which looks exactly like a perfect day. Great for the user's wallet, fatal for the product.&lt;/p&gt;

&lt;p&gt;So the service keeps coverage windows: opened when it connects, kept alive by a heartbeat on each event, closed when it unbinds. A website goal's day the watcher missed more than 5% of is treated as unmeasured, never as kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide which way you're allowed to be wrong
&lt;/h2&gt;

&lt;p&gt;Before writing any of this, pick your failure direction. Mine: under-count rather than over-count, and "unknown" rather than zero. Most of the code above is that one sentence applied to one edge case at a time.&lt;/p&gt;

&lt;p&gt;Punish is in closed beta on Android, iPhone in progress: punishapp.com.&lt;/p&gt;

&lt;p&gt;If you've fought &lt;code&gt;UsageStatsManager&lt;/code&gt; on other OEMs, tell me what broke. And tell me which organisation you'd hate to fund. I'm collecting.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>android</category>
      <category>kotlin</category>
      <category>reactnative</category>
    </item>
  </channel>
</rss>
