<?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: Diven Rastdus</title>
    <description>The latest articles on DEV Community by Diven Rastdus (@diven_rastdus_c5af27d68f3).</description>
    <link>https://dev.to/diven_rastdus_c5af27d68f3</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%2F3807319%2Ff32c855f-6f0d-4c96-8ac1-8bb9bffba0b7.jpg</url>
      <title>DEV Community: Diven Rastdus</title>
      <link>https://dev.to/diven_rastdus_c5af27d68f3</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/diven_rastdus_c5af27d68f3"/>
    <language>en</language>
    <item>
      <title>My Expo Device Tests Could Break Without CI Noticing. Here Is the Fix.</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Thu, 18 Jun 2026 12:09:29 +0000</pubDate>
      <link>https://dev.to/diven_rastdus_c5af27d68f3/my-expo-device-tests-could-break-without-ci-noticing-here-is-the-fix-m48</link>
      <guid>https://dev.to/diven_rastdus_c5af27d68f3/my-expo-device-tests-could-break-without-ci-noticing-here-is-the-fix-m48</guid>
      <description>&lt;p&gt;My on-device QA used to be an AI tapping my app by pixel coordinates. One regression walk logged 83 raw coordinate taps. It was slow, it was flaky, and when it failed I could never tell if the app broke or the tap landed two pixels off a button.&lt;/p&gt;

&lt;p&gt;This is the story of replacing that. The same six launch-blocker flows now run in about 90 seconds, with no model in the loop, plus a Jest guard that makes the whole thing unregressable. The app is Origo, an Expo SDK 56 (React Native 0.85) astrology app I am shipping to the Play Store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the AI fell back to pixel coordinates
&lt;/h2&gt;

&lt;p&gt;I was driving the Pixel 3 with an accessibility-tree tool. Point it at an element, it reads the tree, finds the node, taps the node. On a native app this is fast and stable, because native widgets populate the accessibility tree for free.&lt;/p&gt;

&lt;p&gt;React Native does not. A &lt;code&gt;&amp;lt;Pressable&amp;gt;&lt;/code&gt; shows up as an anonymous view unless you give it a &lt;code&gt;testID&lt;/code&gt;. My app had nine testIDs across 164 files. So the tree-matching found almost nothing, and the tool degraded to its fallback: tap by screen percentage. That is the 83 taps. Every one is a guess at where a button rendered, and every screen size or layout tweak silently invalidates it.&lt;/p&gt;

&lt;p&gt;The lesson is boring and load-bearing: &lt;strong&gt;an RN app is only as queryable as its testIDs.&lt;/strong&gt; Fixing the QA speed was downstream of fixing that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: testIDs on the flows that matter
&lt;/h2&gt;

&lt;p&gt;I did not testID-everything. I picked the six flows that, if broken, block launch. Those are sign in, upgrade-to-pro opening the paywall, a PRO reading hitting the paywall, editing birth data, synastry (the relationship-compatibility screen) add-person refreshing the list, and sign out. Then I added stable testIDs to exactly the elements those flows touch. That took the count to 99 testIDs across 30 files, each one paying for itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: encode the flows in Maestro, self-contained
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://maestro.mobile.dev" rel="noopener noreferrer"&gt;Maestro&lt;/a&gt; (2.6.0) runs YAML flows against a real device. The first version chained flows with &lt;code&gt;clearState&lt;/code&gt; and &lt;code&gt;back&lt;/code&gt;, and it was brittle: one flow would exit on the wrong screen and the next would start from a bad state.&lt;/p&gt;

&lt;p&gt;The fix was to make every flow state-independent. Flow 1 clears state and tests the welcome to sign-in path itself. Flows 2 through 5 start with a plain &lt;code&gt;launchApp&lt;/code&gt;. No state clear. On this app that preserves the signed-in Supabase session and drops you on the Today tab. So each flow re-navigates from a known state and never depends on the previous flow's exit screen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FLOW 2 - Upgrade-to-Pro opens the paywall (self-contained)&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;launchApp&lt;/span&gt;                      &lt;span class="c1"&gt;# preserves session, lands on Today&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;point&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;90%,92%"&lt;/span&gt;             &lt;span class="c1"&gt;# You tab&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upgrade-cta"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;assertVisible&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Restore"&lt;/span&gt;       &lt;span class="c1"&gt;# substring matches both paywall variants&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;assertVisible: "Restore"&lt;/code&gt; is deliberate. Two different paywalls can render depending on whether RevenueCat's offering loaded at runtime, and "Restore" is a substring present in both. Assert the thing that is true in every valid state, not the exact string of one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: an on-device suite can break with zero CI signal
&lt;/h2&gt;

&lt;p&gt;Here is the part that bit me and that I have not seen written down.&lt;/p&gt;

&lt;p&gt;Those testIDs only take effect after a new build. And a Maestro failure only surfaces on-device, when someone bothers to run the suite. So if I rename &lt;code&gt;upgrade-cta&lt;/code&gt; to &lt;code&gt;upgrade-button&lt;/code&gt; during a refactor, nothing fails. Tests pass. CI is green. The regression suite is quietly dead, and I find out the next time I happen to plug in the phone.&lt;/p&gt;

&lt;p&gt;A device suite that can rot silently is worse than no suite, because it tells you that you are covered when you are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: a Jest contract test makes the testIDs unregressable
&lt;/h2&gt;

&lt;p&gt;So I pushed the guarantee down to the unit level, where it runs on every commit with no device. A plain Jest test asserts two things: every testID the Maestro suite depends on still exists in the source file that renders it, and the Maestro YAML references exactly that set and no other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TESTID_SOURCES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;onboarding-sign-in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app/onboarding/index.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;upgrade-cta&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/screens/you/SubscriptionCard.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paywall-purchase&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/screens/onboarding/CustomPaywall.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ... one entry per testID the suite drives&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TESTID_SOURCES&lt;/span&gt;&lt;span class="p"&gt;))(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;testID "%s" is declared in %s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;srcPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`testID="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;testId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;the Maestro flow references only contract testIDs (no drift)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.maestro/origo-regression.yaml&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;referenced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;id:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*"&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"/g&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&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;const&lt;/span&gt; &lt;span class="nx"&gt;contract&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TESTID_SOURCES&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referenced&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&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;It checks the source text, not the rendered output, on purpose. It is the literal &lt;code&gt;testID="..."&lt;/code&gt; string the device tooling matches, so I want to assert that exact string survives, independent of render internals or RN mocks. Rename a testID and forget the YAML, the contract test goes red on the next commit. Add a flow that needs a new testID, add the pair to the map and the guard enforces it forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: run the same flows against the release APK
&lt;/h2&gt;

&lt;p&gt;The last gap: I was testing the dev client over Metro, not the artifact users install. So the runner got a second mode. &lt;code&gt;SMOKE_TARGET=installed&lt;/code&gt; skips all the Metro and dev-launcher setup and drives a plain installed release APK. The flows' Metro-connect step is a conditional subflow, so it is a no-op there, and the exact same six flows validate the CI build.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;EMAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... &lt;span class="nv"&gt;PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... &lt;span class="nv"&gt;SMOKE_TARGET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;installed &lt;span class="se"&gt;\&lt;/span&gt;
  ./scripts/device-smoke.sh .maestro/origo-regression.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of gotchas worth saving you the time: Maestro's GraalJS runtime has no &lt;code&gt;Thread.sleep&lt;/code&gt;, so timed waits go through a small busy-wait helper. And tab-bar taps use a screen percentage rather than a testID, because on the Pixel 3 the tab testID bounds bleed into the Android nav bar and the tap misses. That is the one place coordinates are correct, because the position is fixed and the elements are not individually addressable.&lt;/p&gt;

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

&lt;p&gt;Speed was never the real problem. The real problem was determinism. Three changes fixed it: make the app queryable with testIDs on the flows that matter, encode those flows so they do not depend on each other, and then guard the fragile contract (testIDs that only exist after a build, failures that only show on-device) with a unit test that runs on every commit.&lt;/p&gt;

&lt;p&gt;If you take one thing: an on-device test suite that can break without turning anything red is a liability. Find the part that can drift silently and pin it down a layer, where the cost of breaking it is a failed CI run, not a missed regression in production.&lt;/p&gt;

&lt;p&gt;I am building Origo in the open. Next up: the paywall that renders two different ways depending on whether RevenueCat's offering loaded, and how I stopped guessing which one a user sees. Follow if that is your kind of problem.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>testing</category>
      <category>mobile</category>
    </item>
    <item>
      <title>4 Ways RevenueCat Silently Denies Paying Users Their Entitlements</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Tue, 16 Jun 2026 12:10:50 +0000</pubDate>
      <link>https://dev.to/diven_rastdus_c5af27d68f3/4-ways-revenuecat-silently-denies-paying-users-their-entitlements-3d6e</link>
      <guid>https://dev.to/diven_rastdus_c5af27d68f3/4-ways-revenuecat-silently-denies-paying-users-their-entitlements-3d6e</guid>
      <description>&lt;p&gt;Your store credentials are valid. RevenueCat accepted the service account. The SDK initialized without throwing. And your paying users are still seeing the free tier.&lt;/p&gt;

&lt;p&gt;This is the second layer of the RevenueCat wiring problem. The one that doesn't produce an error. The first layer (invalid credentials, service account propagation delays) has been written about. This one hasn't. These are four bugs I found building Origo, an AI astrology app on Expo + RevenueCat + Supabase. All four were silent. All four passed tests. All four denied real users real value.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. &lt;code&gt;Purchases.logIn()&lt;/code&gt; was never called
&lt;/h2&gt;

&lt;p&gt;This was the most expensive one. Three days of confused billing debugging before I found it.&lt;/p&gt;

&lt;p&gt;Every purchase made before you call &lt;code&gt;Purchases.logIn(userId)&lt;/code&gt; registers under an anonymous RC identity: &lt;code&gt;$RCAnonymousID:some-uuid&lt;/code&gt;. When your RevenueCat webhook fires to update your database, it can't map an anonymous RC id to a real user row. It either skips the event or writes an orphaned record. Your server's entitlement check returns false for that user.&lt;/p&gt;

&lt;p&gt;The purchase exists in RevenueCat's dashboard. The user paid. Nothing shows up on your backend.&lt;/p&gt;

&lt;p&gt;The fix requires understanding what RC does with identity. You want RC's &lt;code&gt;app_user_id&lt;/code&gt; to be your auth system's stable user id, set BEFORE any purchase happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;initialize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;initialized&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;configuredApiKey&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;appUserID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;initialized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;currentAppUserID&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// User signed in after RC was already configured; switch identity&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loginResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;currentAppUserID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;loginResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerInfo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your auth flow allows anonymous purchases (user buys before creating an account), call &lt;code&gt;logIn()&lt;/code&gt; with the stable user id the moment they sign up. RC handles the client-side transfer from anonymous to identified, but your webhook still has to catch the TRANSFER event and re-key the database row. More on that in #4.&lt;/p&gt;

&lt;p&gt;In my case, Supabase's anonymous auth preserves the user id across the anon-to-email upgrade (&lt;code&gt;auth.updateUser()&lt;/code&gt; keeps the same uuid). So I pass the Supabase uuid to RC from the very first session, before they've ever entered an email. When they eventually link an account, the RC identity is already the final, stable uuid.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. No &lt;code&gt;customerInfo&lt;/code&gt; listener
&lt;/h2&gt;

&lt;p&gt;Here's what happens when you skip a &lt;code&gt;customerInfoUpdateListener&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;A user purchases through your native paywall (via &lt;code&gt;Purchases.presentPaywall()&lt;/code&gt;). The purchase succeeds. Your paywall dismisses. The user is back on the main screen, still seeing the free tier, because the component that owns &lt;code&gt;isPro&lt;/code&gt; state hasn't been told anything changed.&lt;/p&gt;

&lt;p&gt;The listener is how RC pushes out-of-band entitlement changes: purchases through the native paywall, server-side grants, trial conversions, grace-period resolutions. Without it, you're only checking entitlements at cold start.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onCustomerInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CustomerInfo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;checkEntitlements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCustomerInfoUpdateListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onCustomerInfo&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="c1"&gt;// SDK not configured in dev; nothing to listen to&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="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeCustomerInfoUpdateListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onCustomerInfo&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="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;checkEntitlements&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;react-native-purchases&lt;/code&gt; v10 gotcha: &lt;code&gt;addCustomerInfoUpdateListener&lt;/code&gt; returns void. You can't call &lt;code&gt;.remove()&lt;/code&gt; on the return value. Cleanup is &lt;code&gt;removeCustomerInfoUpdateListener(sameRef)&lt;/code&gt;, and sameRef has to be the exact same function reference. Define the listener inside the effect, close over it, pass the same reference to both add and remove.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The RC session leaked between users on the same device
&lt;/h2&gt;

&lt;p&gt;This bug is invisible to unit tests. You'd need an integration test that calls &lt;code&gt;configure()&lt;/code&gt;, then &lt;code&gt;logOut()&lt;/code&gt;, then signs in as a different user and checks their entitlements. Most test suites don't go there.&lt;/p&gt;

&lt;p&gt;RC's SDK uses module-level state. Once &lt;code&gt;Purchases.configure()&lt;/code&gt; has run with User A's identity, that configuration persists until you explicitly clear it. If User A signs out and User B signs in, and your initialization check says "already initialized, skip". User B inherits User A's RC session. If User A was a paying subscriber, User B gets &lt;code&gt;isPro: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Reset your initialization bookkeeping on sign-out, right after calling &lt;code&gt;Purchases.logOut()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Call this on every sign-out, after Purchases.logOut()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resetRCSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;initialized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;configuredApiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;currentAppUserID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;Purchases.logOut()&lt;/code&gt; returns RC to an anonymous identity. &lt;code&gt;resetRCSession()&lt;/code&gt; clears your bookkeeping so the next &lt;code&gt;initialize()&lt;/code&gt; doesn't short-circuit on the previous user's state.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The webhook handles purchases but ignores refunds and cancellations
&lt;/h2&gt;

&lt;p&gt;The first three bugs are client-side. This one is server-side, and it's the one that keeps denying access after everything else is right.&lt;/p&gt;

&lt;p&gt;Your subscription table probably gets written on &lt;code&gt;INITIAL_PURCHASE&lt;/code&gt; and &lt;code&gt;RENEWAL&lt;/code&gt;. If you're not handling &lt;code&gt;CANCELLATION&lt;/code&gt;, &lt;code&gt;REFUND&lt;/code&gt;, and &lt;code&gt;TRANSFER&lt;/code&gt;, your database will say &lt;code&gt;is_active: true&lt;/code&gt; for users whose subscriptions have ended. They lose access only when &lt;code&gt;expires_at&lt;/code&gt; passes (if you even store that field).&lt;/p&gt;

&lt;p&gt;The full event set you need, at minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;INITIAL_PURCHASE&lt;/code&gt;: create the subscription row&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RENEWAL&lt;/code&gt;: extend &lt;code&gt;expires_at&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CANCELLATION&lt;/code&gt;: mark inactive (keep the row; they may have time remaining)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;REFUND&lt;/code&gt;: mark inactive immediately&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TRANSFER&lt;/code&gt;: re-key the row when RC reassigns &lt;code&gt;app_user_id&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;TRANSFER&lt;/code&gt; is the one that closes the loop from bug #1. When an anonymous user creates an account and RC transfers their purchase history to a named identity, a TRANSFER event fires with &lt;code&gt;transferred_from&lt;/code&gt; (the old anonymous id) and &lt;code&gt;app_user_id&lt;/code&gt; (the new identified one). Ignore it, and the purchase stays associated with an id that doesn't map to any user row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INITIAL_PURCHASE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RENEWAL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expiresAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiration_at_ms&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiration_at_ms&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsertSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;expiresAt&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CANCELLATION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;REFUND&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;refunded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TRANSFER&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;// transferred_from is an array; the relevant old id is [0]&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;transferSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transferred_from&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping a RevenueCat-gated feature:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is &lt;code&gt;Purchases.logIn(userId)&lt;/code&gt; called with the stable auth user id before any purchase can happen?&lt;/li&gt;
&lt;li&gt;Is there an active &lt;code&gt;customerInfoUpdateListener&lt;/code&gt; on every screen that gates on &lt;code&gt;isPro&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Does sign-out call &lt;code&gt;Purchases.logOut()&lt;/code&gt; AND reset your initialization bookkeeping?&lt;/li&gt;
&lt;li&gt;Does the webhook handler cover CANCELLATION, REFUND, and TRANSFER, not just INITIAL_PURCHASE?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Credentials being valid is table stakes. The silent failures come after.&lt;/p&gt;




&lt;p&gt;Building with RevenueCat on Expo/React Native and hit a different version of these? Comments are open. Also at &lt;a href="https://raeduslabs.com" rel="noopener noreferrer"&gt;raeduslabs.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>revenuecat</category>
      <category>expo</category>
      <category>android</category>
      <category>ios</category>
    </item>
    <item>
      <title>RevenueCat Said My Play Store Credentials Were Invalid. They Weren't.</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Sun, 14 Jun 2026 12:11:02 +0000</pubDate>
      <link>https://dev.to/diven_rastdus_c5af27d68f3/revenuecat-said-my-play-store-credentials-were-invalid-they-werent-1pl4</link>
      <guid>https://dev.to/diven_rastdus_c5af27d68f3/revenuecat-said-my-play-store-credentials-were-invalid-they-werent-1pl4</guid>
      <description>&lt;p&gt;I created a Google service account, uploaded the JSON key to RevenueCat, and watched my Android app reject every purchase. The dashboard said the credentials needed attention. On device, the logs showed a RevenueCat error: &lt;code&gt;7107&lt;/code&gt; / &lt;code&gt;Invalid Play Store credentials&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The credentials weren't invalid. I'd just made the mistake that costs a lot of mobile developers a full day: I expected Google to be fast.&lt;/p&gt;

&lt;p&gt;Here are three Google Play Billing traps from one app launch. Two of them are Google being slow. The third costs money instead of time. All three look exactly like bugs in your code, and none of them are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I'm building Origo, a subscription app on Expo and React Native. Payments run through RevenueCat, which validates every Google Play purchase server-side before it grants entitlements. That server-side check is the whole point. The client can't be trusted to claim "this user paid," so RevenueCat asks Google directly.&lt;/p&gt;

&lt;p&gt;To ask Google, RevenueCat needs a service account with access to your Play Console. You create the service account in Google Cloud, generate a JSON key, grant it permissions in Play Console, and paste the key into RevenueCat. The &lt;a href="https://www.revenuecat.com/docs/service-credentials/creating-play-service-credentials" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; walk through it cleanly.&lt;/p&gt;

&lt;p&gt;Then nothing works, and the docs don't warn you loudly enough about why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: "Invalid Play Store credentials" usually means "wait"
&lt;/h2&gt;

&lt;p&gt;The first purchase test failed instantly. RevenueCat's dashboard showed a banner: &lt;code&gt;Credentials need attention&lt;/code&gt;. On device, the SDK threw &lt;code&gt;Invalid Play Store credentials&lt;/code&gt;. My first instinct was that I'd pasted the wrong key or missed a permission.&lt;/p&gt;

&lt;p&gt;I hadn't. The key was correct and the permissions were correct. The problem was time.&lt;/p&gt;

&lt;p&gt;When you create a fresh service account, Google has to propagate its access to the Play Developer API. RevenueCat's &lt;a href="https://revenuecat.zendesk.com/hc/en-us/articles/360046398913-Invalid-Play-Store-credentials-errors" rel="noopener noreferrer"&gt;support docs&lt;/a&gt; put this at 24 to 48 hours. Until it finishes, every server-side validation returns an error that reads like a configuration mistake. So you "fix" a config that was never broken, re-upload the same key, and wait again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The faster fix:&lt;/strong&gt; you can force Google to re-evaluate the credentials instead of waiting on the background job.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open Play Console and go to your app.&lt;/li&gt;
&lt;li&gt;Go to &lt;code&gt;Monetize → Products → Subscriptions&lt;/code&gt; (or In-app products).&lt;/li&gt;
&lt;li&gt;Edit any product. Change the description. Save.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Saving a product change pokes the billing config and can validate the new credentials right away, per RevenueCat's own guidance. If you remember one thing from this post, remember that the credential error is a clock, not a bug. Editing a product can reset the clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: new products return ITEM_UNAVAILABLE for hours
&lt;/h2&gt;

&lt;p&gt;The second trap looks even more like a real defect. You create a subscription or a one-time product in Play Console, wire it into RevenueCat, run a purchase, and the SDK returns &lt;code&gt;ITEM_UNAVAILABLE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The product exists. You're staring at it in the console. It still says unavailable.&lt;/p&gt;

&lt;p&gt;Same root cause: propagation. A product you created minutes ago hasn't reached the billing backend yet. One-time products in particular can take hours. I hit this with a lifetime product I'd created minutes before the test, started reading my purchase flow line by line, then realized the product was simply too new to buy.&lt;/p&gt;

&lt;p&gt;The rule I follow now: if a product is less than a few hours old, &lt;code&gt;ITEM_UNAVAILABLE&lt;/code&gt; isn't evidence of anything. Retest later before you touch your code. The most expensive debugging is debugging a system that simply isn't ready yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: test with license testers, or you'll charge yourself
&lt;/h2&gt;

&lt;p&gt;The first two were Google being slow. This one's different. It's the mistake that turns a test into a real transaction.&lt;/p&gt;

&lt;p&gt;Google Play has no separate sandbox the way Apple does. A purchase on a live product charges a real card unless the buying account is registered as a license tester. So before any purchase test, I add our test Google account under &lt;code&gt;Play Console → Settings → License testing&lt;/code&gt;. License testers get the full purchase flow, real dialogs, real RevenueCat validation, but no charge and no refund paperwork.&lt;/p&gt;

&lt;p&gt;Skip this step and your "test" buys your own subscription with real money. Then you're filing a refund instead of reading logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern underneath
&lt;/h2&gt;

&lt;p&gt;The two delays share one shape. The symptom shows up at the boundary between your app and a platform you don't control, and that platform is eventually consistent. It says no now and yes in a few hours, with nothing in between that admits it's still thinking.&lt;/p&gt;

&lt;p&gt;Your debugging instinct is to assume the last thing you changed is broken. With Google Play Billing, the last thing you changed is often correct and just not live yet. So I changed my checklist. Before I debug a billing error, I ask one question first: how old is the thing that's failing? If the answer is minutes or hours, I wait and retest before I read a single line of my own code.&lt;/p&gt;

&lt;p&gt;That one question has saved me more time than any fix.&lt;/p&gt;

&lt;p&gt;I'm writing up the real launch problems behind Origo as I hit them. If you're shipping subscriptions on React Native, these propagation traps are the ones I wish someone had warned me about on day one.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>android</category>
      <category>mobile</category>
      <category>expo</category>
    </item>
    <item>
      <title>3 React Native Bugs That Crashed on Device but Passed Every Test</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Sat, 06 Jun 2026 12:11:18 +0000</pubDate>
      <link>https://dev.to/diven_rastdus_c5af27d68f3/3-react-native-bugs-that-crashed-on-device-but-passed-every-test-5fbd</link>
      <guid>https://dev.to/diven_rastdus_c5af27d68f3/3-react-native-bugs-that-crashed-on-device-but-passed-every-test-5fbd</guid>
      <description>&lt;p&gt;Your test suite is green. TypeScript is happy. The web preview looks perfect.&lt;/p&gt;

&lt;p&gt;Then you install the APK on a real phone and the app crashes before the splash screen finishes.&lt;/p&gt;

&lt;p&gt;I shipped an Expo SDK 56 app to Google Play internal testing last week. Three separate bugs made it through 376 passing tests, clean &lt;code&gt;tsc&lt;/code&gt;, and a working web export. None of the three involved broken logic. The code was correct. The runtime wasn't what I thought it was.&lt;/p&gt;

&lt;p&gt;Each bug only surfaced on a physical Android device running Hermes. Here's what they were, why they slipped through, and the fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: The Gold Button That Turned Grey
&lt;/h2&gt;

&lt;p&gt;My app uses a theme system. A &lt;code&gt;configureTheme()&lt;/code&gt; function mutates a shared &lt;code&gt;Colors&lt;/code&gt; object at runtime, and components read from it via &lt;code&gt;StyleSheet.create&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The problem: my main CTA button rendered grey (RGB 231,231,231) instead of gold (RGB 213,195,139) on every screen that used the shared Button component. But screen-specific styles showed the correct gold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; JavaScript module evaluation order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/_layout.tsx (BROKEN version)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@core/ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// Step 1: Button.tsx evaluates&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;configureTheme&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@core/constants&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MY_BRAND&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@app/config/theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;configureTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MY_BRAND&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                 &lt;span class="c1"&gt;// Step 3: Colors mutated... too late&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;_layout.tsx&lt;/code&gt; imports &lt;code&gt;@core/ui&lt;/code&gt;, JavaScript evaluates &lt;code&gt;Button.tsx&lt;/code&gt; immediately. &lt;code&gt;Button.tsx&lt;/code&gt; runs &lt;code&gt;StyleSheet.create&lt;/code&gt; at module scope, which copies the color &lt;em&gt;string&lt;/em&gt; by value. At that point, &lt;code&gt;configureTheme()&lt;/code&gt; hasn't run yet, so the button captures the neutral fallback color.&lt;/p&gt;

&lt;p&gt;Screen-level styles worked fine because route components are lazily imported &lt;em&gt;after&lt;/em&gt; &lt;code&gt;configureTheme()&lt;/code&gt; runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why tests missed it:&lt;/strong&gt; Jest evaluates all modules fresh per test file. Fast Refresh already mutated the &lt;code&gt;Colors&lt;/code&gt; singleton from a previous render. Only a cold start on a real device evaluates modules in the exact import order of your entry point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Create a bootstrap file that runs &lt;code&gt;configureTheme&lt;/code&gt; at module scope, and import it &lt;em&gt;first&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/themeBootstrap.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;configureTheme&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@core/constants&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MY_BRAND&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;configureTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MY_BRAND&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// Runs at module-eval time&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/_layout.tsx (FIXED)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./config/themeBootstrap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// Step 1: theme configured&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@core/ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// Step 2: Button captures real colors&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; Any component that bakes brand colors into &lt;code&gt;StyleSheet.create&lt;/code&gt; must be imported &lt;em&gt;after&lt;/em&gt; &lt;code&gt;configureTheme&lt;/code&gt; runs. Or resolve colors at render time as inline styles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: The Environment Variable That Existed Until It Didn't
&lt;/h2&gt;

&lt;p&gt;The app crashed on launch with a clear error: &lt;code&gt;EXPO_PUBLIC_SUPABASE_URL is required&lt;/code&gt;. But the &lt;code&gt;.env&lt;/code&gt; file had the value. The dev server worked fine. &lt;code&gt;npx expo export --platform web&lt;/code&gt; bundled without errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Metro's static replacement only works with dot notation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// storage/supabase.ts (BROKEN)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readRequiredEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// Dynamic bracket access&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is required`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readRequiredEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EXPO_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Metro (Expo's bundler) statically replaces &lt;code&gt;process.env.EXPO_PUBLIC_SUPABASE_URL&lt;/code&gt; with the literal string value at bundle time. It's a compile-time text substitution, not a runtime lookup.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;process.env[name]&lt;/code&gt; is dynamic. Metro can't know what &lt;code&gt;name&lt;/code&gt; will be at runtime, so it leaves it as-is. In the bundled Hermes bytecode, &lt;code&gt;process.env&lt;/code&gt; is an empty object. The dynamic lookup returns &lt;code&gt;undefined&lt;/code&gt;, and the app throws.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why tests missed it:&lt;/strong&gt; Node.js has real &lt;code&gt;process.env&lt;/code&gt; at runtime. Your test runner loads values from &lt;code&gt;.env&lt;/code&gt; files into the actual process environment. Metro's static replacement is a bundler concern that only affects the production artifact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Static dot notation. No abstraction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// storage/supabase.ts (FIXED)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EXPO_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SUPABASE_ANON_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EXPO_PUBLIC_SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_URL&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EXPO_PUBLIC_SUPABASE_URL is required&lt;/span&gt;&lt;span class="dl"&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;&lt;strong&gt;Rule:&lt;/strong&gt; In Expo/React Native, never wrap &lt;code&gt;process.env.EXPO_PUBLIC_*&lt;/code&gt; in a helper function that uses dynamic access. The DRY instinct is wrong here. Each env var needs its own static &lt;code&gt;process.env.EXPO_PUBLIC_X&lt;/code&gt; line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: The Missing Crypto API
&lt;/h2&gt;

&lt;p&gt;The app launched fine. Onboarding worked. Then the user navigated to the chat screen and got a &lt;code&gt;ReferenceError: crypto is not defined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The &lt;code&gt;uuid&lt;/code&gt; package calls &lt;code&gt;crypto.getRandomValues()&lt;/code&gt; internally. Hermes (React Native's JavaScript engine) doesn't ship the Web Crypto API.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js: has &lt;code&gt;crypto&lt;/code&gt; globally since v19. Tests pass.&lt;/li&gt;
&lt;li&gt;Chrome/Safari (web preview): has &lt;code&gt;crypto&lt;/code&gt; on &lt;code&gt;window&lt;/code&gt;. Web export works.&lt;/li&gt;
&lt;li&gt;Hermes on Android: no &lt;code&gt;crypto&lt;/code&gt; object. Crash.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;ReferenceError: Property 'crypto' doesn't exist
    at v4 (uuid/dist/esm-browser/native.js:1:15)
    at ChatScreen (screens/companion.tsx:42:18)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; One polyfill import at the top of your app entry, before anything else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;react-native-get-random-values
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/_layout.tsx&lt;/span&gt;
&lt;span class="c1"&gt;// This MUST be the first import. It patches globalThis.crypto&lt;/span&gt;
&lt;span class="c1"&gt;// before uuid or any other Web Crypto consumer evaluates.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-get-random-values&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Stack&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-router&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ... rest of imports&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; If any dependency uses &lt;code&gt;crypto.getRandomValues()&lt;/code&gt; (uuid, nanoid, webcrypto-based auth libraries), add this polyfill. It's not optional on Hermes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern
&lt;/h2&gt;

&lt;p&gt;All three bugs share the same shape:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bug&lt;/th&gt;
&lt;th&gt;Dev/Test&lt;/th&gt;
&lt;th&gt;Web Export&lt;/th&gt;
&lt;th&gt;Real Device&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Theme freeze&lt;/td&gt;
&lt;td&gt;Gold (Fast Refresh)&lt;/td&gt;
&lt;td&gt;Gold (lazy eval)&lt;/td&gt;
&lt;td&gt;Grey (cold start)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic env&lt;/td&gt;
&lt;td&gt;Works (real process.env)&lt;/td&gt;
&lt;td&gt;Works (inlined)&lt;/td&gt;
&lt;td&gt;Crash (empty object)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing crypto&lt;/td&gt;
&lt;td&gt;Works (Node has it)&lt;/td&gt;
&lt;td&gt;Works (browser has it)&lt;/td&gt;
&lt;td&gt;Crash (Hermes lacks it)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tests run in Node. Web preview runs in Chrome. Neither environment matches Hermes on Android. Three different reasons, but the same gap: &lt;strong&gt;your test environment is lying about what the production runtime can do.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Do Now
&lt;/h2&gt;

&lt;p&gt;After these three burned a full day each, I added a mandatory step before any release build:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cold-start the app on a real device.&lt;/strong&gt; Not Fast Refresh. Kill the process, relaunch. This catches module-order bugs like the theme freeze.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test the EAS build artifact, not the dev server.&lt;/strong&gt; The dev server has real &lt;code&gt;process.env&lt;/code&gt;. The production bundle has static replacements. They behave differently.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Install a crash monitoring SDK early.&lt;/strong&gt; If you ship to testers without crash reporting, you're running blind. The &lt;code&gt;crypto&lt;/code&gt; bug only triggered on a specific navigation path. A crash report with the Hermes stack trace would have cut debugging time from hours to minutes.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your test suite verifies your &lt;em&gt;logic&lt;/em&gt;. It doesn't verify your &lt;em&gt;runtime&lt;/em&gt;. The device is the only source of truth.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>mobile</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Block YouTube Shorts and Reels on Android (Keep the App)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Mon, 01 Jun 2026 12:07:25 +0000</pubDate>
      <link>https://dev.to/diven_rastdus_c5af27d68f3/block-youtube-shorts-and-reels-on-android-keep-the-app-1b9h</link>
      <guid>https://dev.to/diven_rastdus_c5af27d68f3/block-youtube-shorts-and-reels-on-android-keep-the-app-1b9h</guid>
      <description>&lt;h1&gt;
  
  
  Block YouTube Shorts &amp;amp; Reels on Android (Keep the App)
&lt;/h1&gt;

&lt;p&gt;You don't have an Instagram problem. You have a Reels problem. You opened the app to reply to one DM, and 40 minutes later you're watching a guy power-wash a driveway. The DM is still unanswered. That's why the real fix isn't deleting anything. It's learning how to block YouTube Shorts and Instagram Reels on Android without blocking the whole app.&lt;/p&gt;

&lt;p&gt;Because "just delete the app" doesn't work when you actually need it. You need Instagram for the group chat. You need YouTube for the tutorial you saved. You need TikTok open because a client posts there. The infinite feed is the part that costs you the hour, not the whole app. Kill the feed, keep the inbox.&lt;/p&gt;

&lt;p&gt;Most app blockers can't do that. They block by package name, all or nothing. This walks through the one mechanism that actually targets the feed surface itself, and how to set it up for free with no root.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: you need the app, not the Shorts and Reels feed
&lt;/h2&gt;

&lt;p&gt;Think about what you actually open these apps to do.&lt;/p&gt;

&lt;p&gt;Instagram: reply to DMs, check a specific account, post a story. None of that is Reels. Reels is the tab you tap by accident, or the suggested clip that autoplays when you close a conversation.&lt;/p&gt;

&lt;p&gt;YouTube: a how-to video, a podcast, a saved playlist, your subscriptions. Shorts is the bar that hijacks the home feed and the swipe-up gesture that drops you into an endless vertical scroll.&lt;/p&gt;

&lt;p&gt;TikTok is the one app where the feed &lt;em&gt;is&lt;/em&gt; the product, but plenty of people keep it for the messages, the Following tab, or because they post and need to check comments. The For You page is the part that swallows an hour.&lt;/p&gt;

&lt;p&gt;In every case there's a useful core and a slot-machine wrapper bolted on top. Blocking the whole app throws out the core to kill the wrapper. That's why people uninstall, reinstall three days later, and feel like they failed. They didn't fail. The tool was too blunt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why blocking the whole app fails
&lt;/h2&gt;

&lt;p&gt;There are two reasons this fails, and they feed each other.&lt;/p&gt;

&lt;p&gt;The first is rebound. When you hard-block an app you actually depend on, you create friction you can't sustain. You miss a message. A coworker asks why you didn't see the tag. So you unblock "just for a minute," and the block is dead. App blockers that go all-or-nothing have a short half-life for exactly that reason.&lt;/p&gt;

&lt;p&gt;The second is work. For a lot of people now, Instagram and TikTok are part of the job. Creators, marketers, small business owners, anyone who posts. Telling them to delete the app is telling them to quit their side income. They won't, and they shouldn't. The rebound and the work reason compound: the people who most need these apps open are the ones a blunt block punishes hardest, so they're the first to switch it off.&lt;/p&gt;

&lt;p&gt;The fix isn't more willpower or a harsher block. It's a sharper one. Cut the specific surface that's costing you time and leave the rest alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  How in-app feed blocking works on Android (no root)
&lt;/h2&gt;

&lt;p&gt;Here's the part nobody explains clearly.&lt;/p&gt;

&lt;p&gt;Android has a system feature called the Accessibility API. It was built so screen readers and assistive apps can see what's on screen and act on it for people who can't tap or see normally. It's a legitimate, documented part of the OS. Apps that use it have to declare it, and you have to switch it on by hand in Settings.&lt;/p&gt;

&lt;p&gt;A feed blocker uses that same API to read the structure of the screen, not the pixels and not the content. Every Android view has an ID. When Instagram renders the Reels player, that view has a resource ID. Nudge, the open-source blocker this article walks through, watches for specific IDs like &lt;code&gt;reel_recycler&lt;/code&gt; and &lt;code&gt;reel_player_page_container&lt;/code&gt;. When one of those appears on screen, it means you're looking at the Reels surface. Nudge throws up a block overlay and bounces you out. The DM screen has different IDs, so it's left untouched.&lt;/p&gt;

&lt;p&gt;Same idea for YouTube Shorts and the TikTok For You feed. The blocker isn't reading what the video is about. It's detecting &lt;em&gt;which screen&lt;/em&gt; you landed on by its layout fingerprint, then blocking only that screen.&lt;/p&gt;

&lt;p&gt;No root required. Root is for low-level system access, and this needs none of it. It runs on a stock phone with one permission toggle.&lt;/p&gt;

&lt;p&gt;The honest tradeoffs: this is Android only, because iOS doesn't expose an equivalent screen-reading Accessibility API to third-party apps. And resource IDs can change when an app ships a redesign, so a feed blocker occasionally needs an update to keep matching. That's the nature of the approach. Anyone who tells you their feed blocker will never break is overselling.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Accessibility permission actually sees (and what it can't do)
&lt;/h2&gt;

&lt;p&gt;This is the trust question, and it's a fair one to ask before you toggle anything on. So let's settle it before the setup steps, not after.&lt;/p&gt;

&lt;p&gt;Accessibility permission is powerful. An app that has it &lt;em&gt;can&lt;/em&gt; read a lot. So here's what Nudge does and doesn't do with it. It reads the layout structure of the screen to match those resource IDs. It does not log your DMs, your captions, your search history, or anything you type. It doesn't store screen content. It's matching IDs to decide "block this screen or not," then forgetting the rest.&lt;/p&gt;

&lt;p&gt;You shouldn't take that on faith from any app. Here's why you don't have to.&lt;/p&gt;

&lt;p&gt;Nudge requests zero internet permission. Not "promises not to use the internet." It doesn't request the &lt;code&gt;INTERNET&lt;/code&gt; permission in its manifest at all. Android enforces this at the OS level. An app without that permission physically cannot open a network connection. No servers to phone home to, no analytics, no ads, no account to create.&lt;/p&gt;

&lt;p&gt;So even with Accessibility access, there's nowhere for your data to go. Nudge can read the screen structure to do its job, and it can't transmit anything, because the operating system won't let it. That's a structural guarantee, not a privacy-policy promise.&lt;/p&gt;

&lt;p&gt;And because Nudge is open source, you don't have to believe any of this either. The code is on GitHub. The manifest is right there. Anyone can confirm there's no internet permission and no data collection. If you want the deeper write-up on that design choice, here's the full piece on an &lt;a href="https://raeduslabs.com/blog/open-source-app-blocker-android-no-internet-permission" rel="noopener noreferrer"&gt;open-source app blocker with no internet permission&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step setup (YouTube, Instagram, TikTok)
&lt;/h2&gt;

&lt;p&gt;The first step is the same for all three: install Nudge, open it, and grant the Accessibility permission when it asks. Android routes you to Settings &amp;gt; Accessibility &amp;gt; Nudge, and you flip it on. That's what lets Nudge see the screen structure. After that, each platform is a couple of toggles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Block YouTube Shorts
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;In Nudge, open the in-app blocking section and pick YouTube.&lt;/li&gt;
&lt;li&gt;Toggle on Shorts blocking.&lt;/li&gt;
&lt;li&gt;Open YouTube and swipe into a Short. You'll hit Nudge's block screen instead of the feed. Regular videos, search, subscriptions, and your playlists all still work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. The Shorts shelf on the home page and the swipe-up-into-Shorts gesture both get caught.&lt;/p&gt;

&lt;h3&gt;
  
  
  Block Instagram Reels and Explore
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open Nudge's in-app blocking and pick Instagram.&lt;/li&gt;
&lt;li&gt;Toggle on Reels blocking. Optionally toggle Explore too, since the Explore grid is the other main rabbit hole.&lt;/li&gt;
&lt;li&gt;Open Instagram. DMs, your feed of people you follow, stories, and posting all work. Tap the Reels tab or hit a suggested Reel and you get the block screen.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The point is surgical. You keep the social graph you chose. You lose the algorithmic firehose you didn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Block the TikTok For You feed
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;In Nudge's in-app blocking, pick TikTok.&lt;/li&gt;
&lt;li&gt;Toggle on For You feed blocking.&lt;/li&gt;
&lt;li&gt;Open TikTok. The Following feed, your messages, and your profile stay reachable. The For You page gets blocked.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If TikTok is pure entertainment for you with no work angle, you might want to block the whole app on a schedule instead. But if you keep it for messages or posting, blocking just the For You feed is the move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pairing feed-blocking with a delay-to-open pause
&lt;/h2&gt;

&lt;p&gt;Feed blocking handles the surface. But there's a second habit worth breaking: the reflexive open. The hand reaches for the phone, the thumb finds the icon, and you're in before you decided anything.&lt;/p&gt;

&lt;p&gt;Nudge has a delay-to-open feature for that. When you open a chosen app, it makes you sit through a short breathing pause before the app loads. A few seconds of "do you actually want to be here." It sounds trivial. It isn't. That gap is where the autopilot breaks and you get a chance to choose.&lt;/p&gt;

&lt;p&gt;Stack the two. Block Reels and Shorts so the worst surfaces are gone, and put a delay-to-open pause on Instagram itself so even the useful opens are deliberate. You can also set a daily time budget on an app, so after, say, 20 minutes it locks for the day. Friction where you need it, access where you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nudge vs other feed blockers
&lt;/h2&gt;

&lt;p&gt;Several apps target the feed problem, and they're not all the same.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Feed-level blocking&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Open source&lt;/th&gt;
&lt;th&gt;Internet permission&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nudge&lt;/td&gt;
&lt;td&gt;Shorts, Reels, TikTok feed, Explore&lt;/td&gt;
&lt;td&gt;Free, no tiers&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;None requested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ScreenZen&lt;/td&gt;
&lt;td&gt;Yes, blocks Shorts/Reels surfaces + intervention screens&lt;/td&gt;
&lt;td&gt;Free (donation-supported)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Not audited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shortstop&lt;/td&gt;
&lt;td&gt;Shorts, Reels, TikTok, Snapchat Spotlight, Facebook Reels&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Not audited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ScrollGuard&lt;/td&gt;
&lt;td&gt;Blocks short-form feeds&lt;/td&gt;
&lt;td&gt;Freemium, paid extras&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Not audited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Betimeful&lt;/td&gt;
&lt;td&gt;Hides feeds across apps&lt;/td&gt;
&lt;td&gt;Freemium, paid plans&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Not audited&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few honest notes. ScreenZen is genuinely good and popular, with polished intervention screens, broad app support, and versions across Android, iOS, macOS, and Windows. It's completely free and donation-supported, with no premium tier. Its real tradeoff against Nudge is a closed codebase you can't audit. Shortstop is a solid multi-platform feed blocker, covering YouTube Shorts, Instagram Reels, TikTok, Snapchat Spotlight, and Facebook Reels, and it's free. ScrollGuard and Betimeful both target short-form feeds and both run a freemium model where the better features sit behind a paywall.&lt;/p&gt;

&lt;p&gt;A note on the "internet permission" column. Nudge is marked "None requested" because its open manifest is public and you can confirm the &lt;code&gt;INTERNET&lt;/code&gt; permission is absent. The other apps are closed source, so their exact permission lists aren't independently auditable from the outside, which is why they're marked "not audited" rather than assumed to leak data. ScreenZen, Shortstop, and ScrollGuard all describe on-device or privacy-friendly designs, and several look good on privacy. The difference is that with Nudge you don't have to take it on trust.&lt;/p&gt;

&lt;p&gt;Nudge's edge is narrow and specific. It's fully free with no pro tier, it's open source so you can read exactly what it does, it's Android only, and it requests no internet permission so your data physically can't leave the device. If you want polish and don't mind a closed app you can't inspect, the others are reasonable picks. ScreenZen in particular is a strong free option if cross-platform support matters more to you than an auditable codebase. If you want auditable and free with a verifiable privacy guarantee, that's the gap Nudge fills. For a wider look at the category, see the &lt;a href="https://raeduslabs.com/blog/best-free-app-blockers-android-2026" rel="noopener noreferrer"&gt;best free app blockers for Android in 2026&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I block Shorts but keep YouTube?&lt;/strong&gt;&lt;br&gt;
Yes. That's the entire point of in-app blocking. Nudge blocks the Shorts surface specifically. Regular videos, search, subscriptions, and playlists keep working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this need root?&lt;/strong&gt;&lt;br&gt;
No. It uses Android's Accessibility API, which works on a stock unrooted phone. You toggle one permission in Settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it free?&lt;/strong&gt;&lt;br&gt;
Yes. Nudge is 100% free. No subscription, no pro tier, no paywalled features. Open source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will it block the feed on every app?&lt;/strong&gt;&lt;br&gt;
Nudge covers YouTube Shorts, Instagram Reels and Explore, and the TikTok For You feed. Those are the big short-form surfaces. Coverage depends on the app's current layout, and an app redesign can require an update to keep matching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to grant Accessibility permission?&lt;/strong&gt;&lt;br&gt;
Accessibility is a powerful permission, so the safe move is to only grant it to apps you can verify. Nudge is open source and requests no internet permission, so even with Accessibility access it has no way to send your data anywhere. You can read the code yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just use Screen Time or Digital Wellbeing?&lt;/strong&gt;&lt;br&gt;
Those tools work at the app level, not the feed level. They can limit total YouTube time, but they can't let you watch a tutorial while blocking Shorts. Feed-level blocking is the missing piece.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The fastest way in today is GitHub. Nudge is open source and currently at v1.5.6, so you can read the code, check the manifest, and confirm the no-internet-permission claim before you install anything: &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;github.com/astraedus/nudge&lt;/a&gt;. Build it from source and you're running in a few minutes (the Play Store and F-Droid listings are in review, so GitHub is the quickest route while those land).&lt;/p&gt;

&lt;p&gt;Block the feed. Keep the app. Get your time back.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raeduslabs.com/blog/block-youtube-shorts-instagram-reels-android-without-blocking-app" rel="noopener noreferrer"&gt;raeduslabs.com&lt;/a&gt;. Nudge is open source on &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;; issues and PRs welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>digitalwellness</category>
    </item>
    <item>
      <title>The Best Free ADHD App Blocker for Android</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Mon, 01 Jun 2026 12:06:36 +0000</pubDate>
      <link>https://dev.to/diven_rastdus_c5af27d68f3/the-best-free-adhd-app-blocker-for-android-34o2</link>
      <guid>https://dev.to/diven_rastdus_c5af27d68f3/the-best-free-adhd-app-blocker-for-android-34o2</guid>
      <description>&lt;h1&gt;
  
  
  The Best Free ADHD App Blocker for Android
&lt;/h1&gt;

&lt;p&gt;You went to check the time. Forty minutes later you're watching a video about how octopuses taste with their arms, you have no idea how you got there, and you feel a little sick about it. You didn't decide to do that. Your thumb opened Instagram before the rest of you had a vote.&lt;/p&gt;

&lt;p&gt;If you have ADHD, this isn't a discipline problem. It's how the wiring works. Most app blockers were built for people who don't have your wiring, which is exactly why they keep failing you. This piece covers why the usual Android app blockers fail ADHD brains, and what Nudge, a free ADHD app blocker for Android, does differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why every blocker you've tried failed: one tap to bypass
&lt;/h2&gt;

&lt;p&gt;You've probably installed three or four of these already. They worked for a day. Then you hit the limit, a little dialog popped up, and there was a button that said something like "add 15 minutes" or "I really need this." You tapped it without thinking. Same automatic motion that opened the app in the first place.&lt;/p&gt;

&lt;p&gt;That's the core design flaw for an impulsive brain. If the bypass is one tap, it isn't a blocker. It's a suggestion. And a suggestion loses to a habit every time.&lt;/p&gt;

&lt;p&gt;The other failure mode is guilt. A lot of focus apps lean on shame: streaks you can break, a tree that dies if you leave the app, a guilt trip when you slip. For some people that's fine motivation. For ADHD brains it often does the opposite. Shame is itself dysregulating. You break the streak, you feel like garbage, the discomfort makes you want to numb out, and the fastest way to numb out is the exact app you were trying to avoid. The tool meant to help becomes another source of the bad feeling it's supposed to fix.&lt;/p&gt;

&lt;p&gt;So the bar for a free ADHD app blocker that actually holds is specific. The friction has to be just annoying enough that your conscious brain has time to wake up and ask whether you actually want this. Not a brick wall you'll resent and uninstall. A pause. And it can't run on shame.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your thumb opens Instagram before your brain catches up
&lt;/h2&gt;

&lt;p&gt;The phrase people use when they describe this is "muscle memory," and that's closer to the truth than it sounds. The sequence is: pick up phone, swipe, tap the second icon on the home screen, scroll. You can run that whole sequence while thinking about something else entirely. There was never a moment where you weighed "should I open this app" against "I have a thing due in twenty minutes."&lt;/p&gt;

&lt;p&gt;That's the part that matters. The decision didn't lose to the impulse. The decision never showed up. By the time the conscious part of you notices, you're three Reels deep and the dopamine hit already landed. This is normal, and it's especially normal with ADHD. Understanding why matters more than blaming yourself for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism, in two parts: low baseline dopamine and a broken clock
&lt;/h2&gt;

&lt;p&gt;ADHD brains tend to run lower on baseline dopamine, the chemical that makes ordinary tasks feel worth doing. Boring stuff feels physically harder to start, so the brain hunts for something that hits faster. A feed of short videos is the fastest hit in your pocket, and those feeds are engineered the same way slot machines are: you don't know if the next post is boring or amazing, and that uncertainty is the hook. ADHD brains, already hungry for stimulation, are unusually sensitive to it.&lt;/p&gt;

&lt;p&gt;The second part is time blindness. ADHD messes with the internal sense of how much time has passed. "Five more minutes" genuinely feels like five minutes when it's been forty. The clock in your head is just broken in this specific way. Put those together and "why can't I stop scrolling" stops being a mystery. Low motivation to do the hard thing, plus a machine more rewarding than the hard thing, plus no reliable sense that time is passing. The scroll isn't a character flaw. It's the predictable output of that equation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Friction, not punishment: the case for a pause over a hard block
&lt;/h2&gt;

&lt;p&gt;There are two philosophies here, and they're genuinely different.&lt;/p&gt;

&lt;p&gt;A hard block stops you cold. You can't open the app at all during the blocked window. This works for some people, but for ADHD it has two failure modes: you either rage-quit the blocker the first time it gets in the way of something you legitimately needed, or you learn the bypass and the wall stops meaning anything.&lt;/p&gt;

&lt;p&gt;A pause is different. It lets you through, but slowly, and only after you've noticed what you're doing. The whole bet is that most of your scrolling is automatic, not deliberate. If you interrupt the automatic part, a big chunk of it just evaporates. You go to open TikTok, something makes you wait a few seconds, and in that gap you think "oh, right, I didn't actually want to do this," and you close it. No willpower required. The friction did the work.&lt;/p&gt;

&lt;p&gt;This is how impulse control actually works. The prefrontal cortex, the planning-and-deciding part, is slower than the reflex. It needs a beat to catch up. A pause buys it that beat.&lt;/p&gt;

&lt;h3&gt;
  
  
  Delay-to-open as a prefrontal circuit-breaker
&lt;/h3&gt;

&lt;p&gt;This is the feature that matters most, and it's the one Nudge is built around. When you try to open a distracting app, Nudge makes you wait first. You sit through a short breathing exercise. A few seconds, a slow breath, and then the app opens if you still want it.&lt;/p&gt;

&lt;p&gt;That sounds almost too simple. It works because of the gap it creates. The automatic tap-and-scroll loop depends on speed. Break the speed and you break the loop. During those few seconds, the conscious part of you gets a chance to show up to the meeting it normally misses. Plenty of times you'll just close the app, because the impulse was never that strong. It was just fast.&lt;/p&gt;

&lt;p&gt;It's not magic and it won't catch every time. But a circuit-breaker you have to deliberately override is a fundamentally different thing from a one-tap "skip" button.&lt;/p&gt;

&lt;h3&gt;
  
  
  Killing the slot machine: blocking Shorts, Reels, and TikTok feeds
&lt;/h3&gt;

&lt;p&gt;Sometimes you genuinely need the app. You have to DM someone on Instagram, or post a clip, or reply to a comment. Blocking the whole app is overkill and you'll just turn the blocker off.&lt;/p&gt;

&lt;p&gt;The slot-machine part isn't the app. It's the infinite feed inside it. YouTube Shorts. Instagram Reels. The TikTok For You page. Those are the variable-reward engines doing the real damage.&lt;/p&gt;

&lt;p&gt;Nudge can block those specific surfaces while leaving the rest of the app working. You can open Instagram to message a friend, and Reels just isn't there. YouTube still plays the video you searched for, but the Shorts shelf is gone. You keep the useful parts and lose the parts engineered to keep you pulling the lever. There's a whole separate piece on &lt;a href="https://raeduslabs.com/blog/block-youtube-shorts-instagram-reels-android-without-blocking-app" rel="noopener noreferrer"&gt;how to block YouTube Shorts and Instagram Reels on Android without blocking the whole app&lt;/a&gt; if that's the main thing you're after.&lt;/p&gt;

&lt;h3&gt;
  
  
  Grayscale and time budgets as backup layers (honest expectations)
&lt;/h3&gt;

&lt;p&gt;Two more tools, and I'll be straight about what they do and don't do.&lt;/p&gt;

&lt;p&gt;Grayscale drains the color out of your screen. Color is part of what makes apps compelling, and a gray feed is genuinely less fun to look at. It's a real effect but a mild one. Think of it as a small tax on attention, not a wall. Some people barely notice after a week. Worth trying, don't expect miracles.&lt;/p&gt;

&lt;p&gt;Time budgets let you set a daily cap per app. Thirty minutes of Instagram, then it locks for the day. The honest caveat: a hard daily limit can trip the same rage-quit reflex as a hard block, and if it's bypassable it's another one-tap suggestion. Time budgets work best as a backstop behind the pause, not as your main defense. The pause changes the moment-to-moment behavior. The budget catches the days the pause didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nudge: a free ADHD app blocker for Android, open source, no account
&lt;/h2&gt;

&lt;p&gt;Nudge is a free Android app blocker for ADHD built around the delay-to-open pause, with feature blocking, time budgets, app groups, schedule rules, and grayscale as the supporting layers.&lt;/p&gt;

&lt;p&gt;A few things make it unusual.&lt;/p&gt;

&lt;p&gt;It has zero internet permission. The app literally cannot connect to the internet. Everything you do, every app you block, every minute you spend, stays on your device. There's no server to send it to because there's no connection at all. You can verify this yourself in Android's permission settings, or in the source code.&lt;/p&gt;

&lt;p&gt;It's open source. The whole thing is on GitHub. You, or anyone you trust, can read exactly what it does. For a tool that watches which apps you open, "trust me" isn't good enough. "Read the code" is.&lt;/p&gt;

&lt;p&gt;It's actually free. Not free-with-a-pro-tier, not free-trial, not free-but-the-good-features-cost-money. There's no subscription, no paywall, no upsell. No account to make. No email to hand over.&lt;/p&gt;

&lt;p&gt;Here's an honest comparison against the apps people usually land on.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Nudge&lt;/th&gt;
&lt;th&gt;one sec&lt;/th&gt;
&lt;th&gt;Opal&lt;/th&gt;
&lt;th&gt;Forest&lt;/th&gt;
&lt;th&gt;AppBlock&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;iOS + Android&lt;/td&gt;
&lt;td&gt;iOS + Android (Android newer/limited)&lt;/td&gt;
&lt;td&gt;iOS + Android&lt;/td&gt;
&lt;td&gt;Android + iOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pause before opening&lt;/td&gt;
&lt;td&gt;Yes (core feature)&lt;/td&gt;
&lt;td&gt;Yes (core feature)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block feeds inside apps&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free version&lt;/td&gt;
&lt;td&gt;Fully free&lt;/td&gt;
&lt;td&gt;Limited free, paid upgrade&lt;/td&gt;
&lt;td&gt;Limited free (1 rule), then paid&lt;/td&gt;
&lt;td&gt;Paid (some free)&lt;/td&gt;
&lt;td&gt;Free tier + paid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subscription&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;One-time&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open source&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No internet permission&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To be fair to those apps: &lt;a href="https://raeduslabs.com/blog/one-sec-alternative-android-free" rel="noopener noreferrer"&gt;one sec&lt;/a&gt; had the pause-before-opening idea early and does it well, and if you're on iPhone it's a solid pick. Opal is polished and well designed, but it's iOS-first and its Android app is newer and feature-limited, with real use behind a subscription. Forest is a lovely gamified focus timer, just a different kind of tool than a blocker. AppBlock is full-featured on Android with a free tier, though the strongest features sit behind a subscription and it's closed source.&lt;/p&gt;

&lt;p&gt;Nudge's pitch isn't "better than all of these at everything." It's narrower and more honest: if you want the pause-before-opening mechanic, on Android, for free, with the source code you can read and no way for it to phone home, that exact combination is rare. A handful of open-source Android tools chase parts of it, but the full set, especially the zero internet permission, is hard to find in one app.&lt;/p&gt;

&lt;p&gt;If the automatic scroll is wrecking your focus and you're on Android, try the pause. The code and the latest release are on &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Install it, point it at your two worst apps, and see whether a few seconds of breathing is enough to give you back the choice your thumb keeps making for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest answers to the two questions you're probably asking
&lt;/h2&gt;

&lt;p&gt;Will it actually stop me? It'll stop you a lot of the time, not all of the time. If you're determined to get past it you can, because it's a pause, not a prison. That's by design. A wall you can't get over is a wall you'll uninstall. The goal is to catch the automatic 80% of scrolling, the kind you don't even decide to do, and hand the other 20% back to you as an actual choice. If you want a hard, unbreakable lock, this is the wrong tool. If you want a speed bump that lets the deciding part of your brain catch up, this is built for exactly that.&lt;/p&gt;

&lt;p&gt;Is it safe to install if it's open source and not always on the Play Store yet? Open source is the reason it's safer, not less safe. You can read every line of what it does, which you can't do with any closed-source blocker. The zero-internet-permission design means it can't leak your data even if it wanted to. The Play Store version is in review and F-Droid has an open merge request, so installation will get more convenient over time, but the GitHub release works today.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to start: install and first 10-minute setup
&lt;/h2&gt;

&lt;p&gt;Getting going takes about ten minutes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Install it.&lt;/strong&gt; Right now there are two live install paths: build from source, or sideload the APK from &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The Play Store listing is in review and the F-Droid merge request is open, so those routes are in progress and not live yet. If you're sideloading the APK from GitHub, Android will warn you about installing from outside the Play Store. That's the standard warning for any sideloaded app, not a Nudge-specific red flag.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Grant accessibility access.&lt;/strong&gt; A blocker has to be able to see when you open another app, and on Android that needs the accessibility permission. This is the one permission it asks for. Combined with zero internet permission, it means the app can watch your app-switching to do its job but has no way to send that anywhere.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pick your two or three worst apps.&lt;/strong&gt; Don't block everything on day one. Pick the ones that actually eat your time. For most people that's two or three. Turn on delay-to-open for those.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Turn on feed blocking for the big ones.&lt;/strong&gt; If Reels or Shorts or the TikTok feed is the real problem, switch on in-app feature blocking so you keep the app but lose the slot machine.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Leave the rest for later.&lt;/strong&gt; Add grayscale, time budgets, schedules, and app groups once the basics have settled in for a few days. Stacking everything at once is a recipe for turning the whole thing off in frustration. Start small, let it stick, then layer up. If you came from iPhone or you're weighing the original pause app, there's a full breakdown in &lt;a href="https://raeduslabs.com/blog/one-sec-alternative-android-free" rel="noopener noreferrer"&gt;Nudge vs one sec on Android&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raeduslabs.com/blog/adhd-app-blocker-android-free" rel="noopener noreferrer"&gt;raeduslabs.com&lt;/a&gt;. Nudge is open source on &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;; issues and PRs welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>adhd</category>
      <category>productivity</category>
      <category>android</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Open-Source App Blocker for Android With No Internet Permission</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Mon, 01 Jun 2026 12:06:31 +0000</pubDate>
      <link>https://dev.to/diven_rastdus_c5af27d68f3/open-source-app-blocker-for-android-with-no-internet-permission-2ch2</link>
      <guid>https://dev.to/diven_rastdus_c5af27d68f3/open-source-app-blocker-for-android-with-no-internet-permission-2ch2</guid>
      <description>&lt;h1&gt;
  
  
  Open-Source App Blocker for Android With No Internet Permission
&lt;/h1&gt;

&lt;p&gt;You install an app blocker to spend less time staring at your phone. Then it asks for your email. Then it wants Accessibility access, which on Android means it can read the text on every screen you open. Then you find out it makes money from ads, or a "pro tier," or something murkier. So now the thing you installed to protect your attention is also watching everything you do and shipping it somewhere you can't see.&lt;/p&gt;

&lt;p&gt;Most people never notice they made that trade. An open-source app blocker for Android with no internet permission flips it. Nudge can't track you because the operating system won't let it. There's no account to create, no telemetry, no server it phones home to. The code is on GitHub under GPL-3.0, so you don't have to trust a privacy promise. You can read the manifest yourself before you install.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden cost of free app blockers: you are the product
&lt;/h2&gt;

&lt;p&gt;Building an app costs time. Running servers costs money. So when an app blocker is free and has no visible business model, the money is coming from somewhere. Usually one of three places: ads, a subscription upsell, or your data.&lt;/p&gt;

&lt;p&gt;The data path is the one people underestimate. A blocker needs to know which apps you open and how long you stay in them. That is, by design, a detailed log of your phone habits. Which apps, what time of day, how often you relapse on the ones you're trying to quit. That data has buyers. Analytics firms, ad networks, and "audience" brokers all pay for behavioral signals, and "how compulsively does this person use TikTok" is a clean signal.&lt;/p&gt;

&lt;p&gt;You don't have to assume bad intent. The point is the capability exists, and capability plus a profit motive plus zero transparency is a bad combination for something that sits on top of your entire phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Accessibility permission can see (and why that's the real risk)
&lt;/h2&gt;

&lt;p&gt;Almost every Android app blocker uses the Accessibility Service API. Nudge does too. It's the only reliable way to detect which app is in the foreground and draw a blocking screen over it. There's no sinister alternative hiding behind a flag. This is how the category works.&lt;/p&gt;

&lt;p&gt;Android's own warning label says it grants the ability to "observe your actions," "retrieve window content," and read text on screen. An Accessibility service can see the contents of the apps you use, including text you type, depending on how it's built.&lt;/p&gt;

&lt;p&gt;The permission your blocker needs is the same permission a keylogger would want. That's not a reason to avoid blockers. It's a reason to care a lot about what the blocker does with that access, and whether it has any way to send what it sees off your device.&lt;/p&gt;

&lt;p&gt;This is the whole argument for no internet permission. An app with Accessibility access but no network access can read your screen all it wants. It has nowhere to put the data. It's a security guard who can watch the building but has no phone, no radio, and no exit. The constraint isn't a promise about behavior. It's a wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The StayFree story: when your blocker gets acquired
&lt;/h2&gt;

&lt;p&gt;StayFree is a popular, genuinely useful Android usage tracker and blocker. It's also a useful example of why "this app is fine today" isn't the same as "this app is fine forever."&lt;/p&gt;

&lt;p&gt;Apps change hands. Ownership transfers, privacy policies get rewritten, a free tool adds an ad SDK in version 4.2 that wasn't there in 4.1. The app you vetted last year is not necessarily the app running on your phone today. If a blocker has the internet permission and an analytics pipeline, a change in who profits from your data is one app update away, and the update installs silently.&lt;/p&gt;

&lt;p&gt;StayFree's fine for what it is. The point isn't that StayFree is bad. The point is that any closed-source blocker with network access is one acquisition or one update away from a policy change you won't know about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Policy vs constraint: why "we don't track you" isn't enough
&lt;/h2&gt;

&lt;p&gt;Every app's privacy policy says some version of "we respect your privacy." Freedom says it. AppBlock says it. The words are free to write and impossible to verify from the outside.&lt;/p&gt;

&lt;p&gt;There are two ways to promise privacy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Policy:&lt;/strong&gt; "We could collect your data, but we promise we won't." You're trusting a sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constraint:&lt;/strong&gt; "We can't collect your data, because the app has no way to send it anywhere." You're trusting math.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A policy can be broken quietly, changed in an update, or ignored by a future owner. A constraint is enforced by the Android operating system on every boot, whether anyone's watching or not. No internet permission is a constraint, not a promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero internet permission, explained: the app cannot open a network connection
&lt;/h2&gt;

&lt;p&gt;On Android, an app can only do things it has declared permission for. Network access is &lt;code&gt;android.permission.INTERNET&lt;/code&gt;. If an app doesn't request it in its manifest, the system blocks every attempt to open a socket. No HTTP request, no analytics ping, no silent upload. The call fails at the OS level before a single byte leaves the phone.&lt;/p&gt;

&lt;p&gt;Nudge does not request &lt;code&gt;INTERNET&lt;/code&gt;. That single missing line is the entire privacy story. It's what makes Nudge a true no tracking app blocker rather than a privacy app blocker for Android that only promises good behavior. Everything Nudge tracks (which apps you open, your time budgets, your block schedules) is computed and stored locally, and there is no code path that could send it out even if someone wanted to. There's no server because there's nothing a server could receive.&lt;/p&gt;

&lt;p&gt;This is also why Nudge has no account. Accounts exist to sync data across devices through a server. No server, no sync, no account, no login, no password to leak. Your data lives in one place: your phone.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to verify it yourself in Android Settings before installing
&lt;/h3&gt;

&lt;p&gt;You don't have to trust any of this. Android tells you what an app can do.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install the app (or download the APK without installing).&lt;/li&gt;
&lt;li&gt;Open &lt;strong&gt;Settings&lt;/strong&gt;, then &lt;strong&gt;Apps&lt;/strong&gt;, find the app, and tap &lt;strong&gt;Permissions&lt;/strong&gt;, or &lt;strong&gt;App info&lt;/strong&gt; then &lt;strong&gt;Permissions&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Look for anything network-related. The system groups some permissions, but install-time permissions like internet access are visible in the full permission list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For Nudge specifically, you can check before you even download anything. The AndroidManifest.xml is right there in the public GitHub repo. Search it for &lt;code&gt;INTERNET&lt;/code&gt; and you'll find permissions for things like querying installed apps and the Accessibility service, but no internet access. That's the point, and you can confirm it in about thirty seconds. Once Nudge lands on F-Droid, that listing will show the same declared permission list pulled straight from the manifest, before you install.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading the source: what GPL-3.0 and a public repo actually buy you
&lt;/h3&gt;

&lt;p&gt;"Open source" gets used loosely, so here's the concrete version. Nudge's source code is public on GitHub under the GPL-3.0 license. That gives you three things that closed apps can't offer.&lt;/p&gt;

&lt;p&gt;First, you can read the AndroidManifest.xml yourself and confirm there's no internet permission line. It's a text file. You don't need to be a Kotlin developer to find the word &lt;code&gt;INTERNET&lt;/code&gt; or confirm it's absent.&lt;/p&gt;

&lt;p&gt;Second, GPL-3.0 means the published source has to match the app. The license requires that the code stays open, so a future version can't quietly close up and bolt on tracking without that change being visible in the public commit history. With a closed app, you're shown a polished store listing and nothing underneath.&lt;/p&gt;

&lt;p&gt;Third, anyone can audit it. You personally might never read the code, but the value of open source is that someone can, and the FOSS community around F-Droid is unusually motivated to call out apps that sneak in trackers. Closed source means the only people who've seen the code are the people being paid to ship it.&lt;/p&gt;

&lt;h2&gt;
  
  
  No account, no telemetry, all data local: what stays on your device
&lt;/h2&gt;

&lt;p&gt;Here's everything Nudge knows about you and where it lives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which apps you've set delay-to-open, time budgets, or schedules for. Stored on your device.&lt;/li&gt;
&lt;li&gt;Your daily usage against those budgets. Computed on your device, in memory and local storage.&lt;/li&gt;
&lt;li&gt;Your app groups and block rules. A local config. Yours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no analytics dashboard somewhere with your name on it, because there's no "somewhere." There's no crash reporting SDK quietly uploading stack traces with device fingerprints attached. If you uninstall Nudge, the data is gone, because it was only ever on your phone to begin with.&lt;/p&gt;

&lt;p&gt;That local-first design is also why the friction features can be aggressive without being creepy. The delay-to-open pause (a breathing beat before a distracting app launches), per-app time budgets, grayscale mode, and in-app blocking of things like &lt;a href="https://raeduslabs.com/blog/block-youtube-shorts-instagram-reels-android-without-blocking-app" rel="noopener noreferrer"&gt;YouTube Shorts and Instagram Reels&lt;/a&gt; all run entirely on-device. The blocker is opinionated about your attention and silent about your data.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to install from GitHub today (and what's coming on F-Droid)
&lt;/h2&gt;

&lt;p&gt;If you care enough about privacy to want no internet permission, you probably already prefer these install routes anyway. Right now there are two live paths: build from source, or sideload the APK. The store listings are still in progress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub&lt;/strong&gt; is the most direct path, and the only one that's live today. The repo is at &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;github.com/astraedus/nudge&lt;/a&gt;. You can download a release APK and install it directly (you'll need to allow installs from your browser or file manager once), or build it from source if you want maximum certainty that the APK matches the code. Either way you're getting the app straight from the source, no middleman.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;F-Droid&lt;/strong&gt; is the open-source Android app store. It only lists FOSS apps, shows the full permission list on every page, and builds apps from source itself rather than trusting a developer-uploaded binary. Nudge isn't listed there yet. There's a merge request open to get it in. Once it lands, you'll add F-Droid, search Nudge, and install, and F-Droid will show you the permissions before you commit, which is exactly the verification step from earlier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Play Store&lt;/strong&gt; is in review, not live yet. When it lands it'll be the easiest one-tap install for most people. The tradeoff is that the Play listing won't show you the full manifest the way F-Droid does, so if verification matters to you, GitHub (now) or F-Droid (later) is the better path.&lt;/p&gt;

&lt;p&gt;Current version is v1.5.6, built with Kotlin and Jetpack Compose. Android only. There's no iOS version and won't be one. iOS doesn't allow the Accessibility-based foreground detection and overlay approach Nudge relies on. App limits on iOS only work through Apple's restricted Screen Time APIs (FamilyControls, ManagedSettings, DeviceActivity), which is a different architecture entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can an app with no internet permission still block apps?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, completely. Blocking is a local operation. The app watches which app comes to the foreground (via the Accessibility service) and draws a blocking screen over it (via the overlay permission). None of that needs a network. The internet permission would only ever be used to send your data somewhere, which is the one thing you don't want a blocker doing. Removing it costs you nothing and removes the entire tracking risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it really auditable, or is that just marketing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can check it in minutes. The code is on GitHub under GPL-3.0. Open the AndroidManifest.xml in the repo and search for &lt;code&gt;INTERNET&lt;/code&gt;. If it's not there, the app can't make network calls, full stop. You can also check the declared permissions in Android Settings after install, or on the F-Droid page before install. "Auditable" here means the claims are falsifiable, not that you have to take them on faith.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a FOSS app blocker match Freedom or AppBlock?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For the core job (blocking apps, setting time budgets, adding friction), yes. The table below compares Nudge against Freedom, AppBlock, StayFree, and DigiPaws. Freedom and AppBlock win on iOS support and cross-device sync. Nudge wins on being a free, no-account, no-tracking app blocker with the hard guarantee of zero internet permission.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Internet permission&lt;/th&gt;
&lt;th&gt;Account required&lt;/th&gt;
&lt;th&gt;Open source&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Nudge&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (GPL-3.0)&lt;/td&gt;
&lt;td&gt;Free, no tiers&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freedom&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Limited free, then paid&lt;/td&gt;
&lt;td&gt;Android + iOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AppBlock&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Free with paid pro&lt;/td&gt;
&lt;td&gt;Android + iOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StayFree&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Free with ads / paid&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DigiPaws&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Freedom and AppBlock are polished and cross-platform, and if you want iOS support or sync across devices, they're reasonable picks. DigiPaws is the closest in spirit: also open source, also free, and it skips the internet permission too, with a more gamified, mode-based take on building friction. Nudge's specific edge is the combination, and the calmer design behind it: open source, no account, completely free with no paywalled features, and the hard constraint of zero internet permission that none of the closed options can match. If you want a fuller breakdown, see our &lt;a href="https://raeduslabs.com/blog/best-free-app-blockers-android-2026" rel="noopener noreferrer"&gt;rundown of free Android app blockers for 2026&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will the no-internet-permission guarantee break in a future update?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It can't, quietly. Because the source is public under GPL-3.0, adding the internet permission would mean a visible change to the AndroidManifest.xml in the commit history. With a closed app, that change happens behind a wall and installs in a routine update. With Nudge, it would be on the record. An Android app blocker without an account and without network access has nothing to silently flip.&lt;/p&gt;




&lt;p&gt;If you want an app blocker that physically can't sell your phone habits, this is it. Read the code, check the manifest, and either build from source or sideload the APK from &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; today. The F-Droid and Play Store listings are in progress. It's free, it's Android, and the privacy claims are things you can verify rather than promises you have to believe.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raeduslabs.com/blog/open-source-app-blocker-android-no-internet-permission" rel="noopener noreferrer"&gt;raeduslabs.com&lt;/a&gt;. Nudge is open source on &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;; issues and PRs welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>opensource</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Free Opal Alternative for Android (No $99/yr Subscription)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Fri, 29 May 2026 05:30:23 +0000</pubDate>
      <link>https://dev.to/diven_rastdus_c5af27d68f3/free-opal-alternative-for-android-no-99yr-subscription-4hai</link>
      <guid>https://dev.to/diven_rastdus_c5af27d68f3/free-opal-alternative-for-android-no-99yr-subscription-4hai</guid>
      <description>&lt;p&gt;Someone on Reddit or a productivity YouTuber raved about Opal. The clean focus sessions, the "deep work" timer, the satisfying streak. So you opened the Play Store, found Opal for Android, and then hit the wall everyone hits: the free version barely blocks anything, and the part you actually wanted sits behind a subscription that runs about $99 a year. If you want a free Opal alternative for Android, that's the real problem to solve, not whether Opal exists on your phone.&lt;/p&gt;

&lt;p&gt;Here's the short version before the long one: Opal does have an Android app now, but it's paid and noticeably thinner than the iPhone version. And there's a free, open-source Android app called &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;Nudge&lt;/a&gt; that covers the part of Opal people actually care about, which is putting friction between you and the apps that eat your day, without a subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer: is Opal free on Android?
&lt;/h2&gt;

&lt;p&gt;Mostly no. Opal has an Android app on Google Play, and there's a free tier, but it's close to useless on its own. You get very limited blocking, and the focus features people install Opal for are part of Opal Pro, which is about $99 a year (or $19.99 a month). So if you came looking to actually use Opal on Android without paying, you'll find the free version doesn't do much.&lt;/p&gt;

&lt;p&gt;So the real question isn't "where do I download Opal for Android." It's "what's the Android app that does what Opal does, for free." That's this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  What people love about Opal (and what it costs)
&lt;/h2&gt;

&lt;p&gt;Opal is a good app. I'm not here to trash it. The thing it gets right is intentional friction. You set up focus sessions, and when you try to open Instagram or X during one, Opal stops you with a pause screen. That tiny moment of "do you actually want this right now" is enough to break the reflex scroll for a lot of people. It also has app groups, schedules, and a dashboard that shows where your time went.&lt;/p&gt;

&lt;p&gt;Two catches, though. The first is the price: Opal Pro runs about $99 a year, the free tier is thin, and the features people actually install it for sit behind the subscription. The second is Android specifically. Opal was built iOS-first on Apple's Screen Time API, and the Android app launched later, so it's still catching up on features and the experience isn't as robust. Opal's own team has said the Android version is newer and that they're working toward parity with iOS. That's a fair, honest position. It also means on Android you'd be paying $99/yr for the less complete version of the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Opal's Android app is the weaker one
&lt;/h2&gt;

&lt;p&gt;It helps to know why, so you can decide whether it's worth paying for.&lt;/p&gt;

&lt;p&gt;On iOS, Apple gives apps a sanctioned way to block other apps: the Screen Time / Family Controls API. Opal plugs into that. It's clean, it's system-level, and Apple maintains it.&lt;/p&gt;

&lt;p&gt;Android doesn't have a single equivalent. To block or limit apps on Android, an app has to use the Accessibility Service or Usage Stats permissions, watch what's in the foreground, then throw up its own screen when a blocked app opens. It works, but it's a different architecture entirely. Opal had to build its Android blocking engine separately from the iOS one, which is part of why the Android app shipped later and still trails on features. This isn't unique to Opal: most screen-time apps that started on iPhone are still closing the gap on Android.&lt;/p&gt;

&lt;p&gt;The good news for Android users: that Accessibility-based approach is exactly what the open-source Android blockers already use, and some of them are free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an Android Opal alternative actually needs to match
&lt;/h2&gt;

&lt;p&gt;If you liked the idea of Opal, here's what the Android replacement has to do. Not the marketing checklist, the parts that matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Put friction in front of distracting apps so you pause before opening them.&lt;/li&gt;
&lt;li&gt;Let you set time limits per app, not just an all-or-nothing block.&lt;/li&gt;
&lt;li&gt;Schedule rules so blocking turns on during work or sleep automatically.&lt;/li&gt;
&lt;li&gt;Not be annoying enough that you uninstall it in a week.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A few free Android apps cover pieces of this. &lt;a href="https://dev.to/blog/best-free-app-blockers-android-2026"&gt;ScreenZen&lt;/a&gt; nails the pause-before-opening idea and won't cost you anything. &lt;a href="https://dev.to/blog/one-sec-alternative-android-free"&gt;one sec&lt;/a&gt; popularized the breathing-pause pattern, but its free tier only covers one app and the rest is paywalled. Freedom does the job too, except it wants a subscription. So you're stitching together slices. The one I built, Nudge, goes after the whole list for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nudge: the free Opal alternative built for Android
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;Nudge&lt;/a&gt; is a free Android app blocker built for people who keep opening apps they didn't mean to open. It's written in Kotlin with Jetpack Compose, it's open source so you can read every line, and it's currently on version 1.5.6.&lt;/p&gt;

&lt;p&gt;It's Android-only. I'm saying that plainly because being honest about scope is the whole point of this article. If you're on iOS, Opal and one sec are solid picks. If you're on Android and don't want to pay $99 a year for the less complete Opal experience, keep reading.&lt;/p&gt;

&lt;h3&gt;
  
  
  Delay-to-open instead of Opal's hard sessions
&lt;/h3&gt;

&lt;p&gt;Opal's model is sessions: you commit to a block of focus time, and during it certain apps are off-limits. That works if you plan your day in focus blocks.&lt;/p&gt;

&lt;p&gt;Nudge's main tool is delay-to-open. When you go to open an app you've marked, Nudge shows a short pause first, a breathing beat where you wait a few seconds before the app opens. It's the same psychology as Opal's stop screen, but it doesn't require you to schedule a session in advance. The friction is always there for the apps you chose, every time you reach for them. For reflex-scroll opening, where you unlock your phone and Instagram is open before you decided to open it, the always-on pause tends to work better than a session you forgot to start.&lt;/p&gt;

&lt;p&gt;If you do want session-style blocking, Nudge has schedule rules too. More on that next.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-app daily time budgets and schedule rules
&lt;/h3&gt;

&lt;p&gt;A hard block all day is too blunt for most people. You don't want to never see Instagram. You want to not lose 90 minutes to it.&lt;/p&gt;

&lt;p&gt;Nudge does per-app daily time budgets. Give YouTube 30 minutes a day. When you hit it, Nudge steps in. You can also group apps so a budget covers all your social apps together, not one at a time. And schedule-based rules let you say "block this group from 9 to 5" or "everything off after 11pm," so the right limits switch on without you toggling anything.&lt;/p&gt;

&lt;p&gt;There's one more thing Nudge does that Opal's app-level blocking doesn't: in-app feature blocking. Nudge can block YouTube Shorts, Instagram Reels, and the TikTok feed while leaving the rest of the app working. So you can still message people on Instagram or watch a video someone sent you, without the infinite Reels rabbit hole. There's also a grayscale mode that drains the color out of your screen to make the whole phone less compulsively fun. If you only want the feature-blocking piece, there's a &lt;a href="https://dev.to/blog/block-youtube-shorts-instagram-reels-android-without-blocking-app"&gt;whole writeup on killing Shorts and Reels without nuking the app&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Privacy upgrade: no account, zero internet permission
&lt;/h3&gt;

&lt;p&gt;This is where Nudge isn't just a free copy of Opal, it's a different deal entirely.&lt;/p&gt;

&lt;p&gt;Nudge has zero internet permission. Not "we don't sell your data," not "we anonymize." The app cannot connect to the internet at all, because it doesn't request the permission to. You can verify this yourself in the app's permissions list or in the source code. Which apps you block, how long you spent, your schedules, all of it stays on your device and never leaves it.&lt;/p&gt;

&lt;p&gt;There's no account to create. No email, no sign-in, no cloud sync of your usage habits. No ads. A screen-time app sees the most personal data on your phone, which is literally everything you do on it. The right amount of that to send to a server is none. So Nudge sends none. If that matters to you, there's &lt;a href="https://dev.to/blog/open-source-app-blocker-android-no-internet-permission"&gt;a deeper piece on why an app blocker should have no internet permission&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opal vs Nudge: price, privacy, and what's free
&lt;/h2&gt;

&lt;p&gt;If you want a free Opal alternative on Android, this is the side-by-side that matters.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Opal&lt;/th&gt;
&lt;th&gt;Nudge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;iOS, Android, Mac (Android newer, feature-limited)&lt;/td&gt;
&lt;td&gt;Android only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;~$99/yr for Pro, thin free tier&lt;/td&gt;
&lt;td&gt;Free, no pro tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open source&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, auditable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account required&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internet permission&lt;/td&gt;
&lt;td&gt;Yes (cloud sync)&lt;/td&gt;
&lt;td&gt;None, all local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pause before opening&lt;/td&gt;
&lt;td&gt;Yes (focus sessions)&lt;/td&gt;
&lt;td&gt;Yes (delay-to-open, always on)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-app time limits&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schedules&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block Shorts/Reels in-app&lt;/td&gt;
&lt;td&gt;App-level blocking, not in-feed&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grayscale mode&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read it however you like, but the price and privacy rows settle most of it. Opal on Android is the paid, still-maturing option that syncs to an account. Nudge is free, open source, and never touches the network. The table's real job is to show that the free Android option isn't a sad downgrade. On the things that matter for actually using your phone less, it holds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to install Nudge on Android
&lt;/h2&gt;

&lt;p&gt;A free open-source app sounds like it should be a pain to install. It mostly isn't.&lt;/p&gt;

&lt;p&gt;The Play Store and F-Droid listings are still in progress, so the two live ways to install Nudge today are to build from source or sideload the APK:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub APK (sideload)&lt;/strong&gt;: grab the APK directly from the &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;Nudge releases page on GitHub&lt;/a&gt; and install it. Android will ask you to allow installs from that source, which is a standard one-time prompt for any app outside the Play Store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build from source&lt;/strong&gt;: the full code is on GitHub, so if you'd rather compile it yourself, you can. That's also how you confirm the app does exactly what it says.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The F-Droid merge request and the Play Store listing are both in progress, so those install paths will come later. For now it's sideload or build.&lt;/p&gt;

&lt;p&gt;After install, Nudge will ask for the Accessibility permission. This is the part people get nervous about, so let's be straight about it. Accessibility access is what lets the app see which app is in the foreground so it can show the pause screen at the right moment. Every Android app blocker that isn't Google's own Digital Wellbeing needs it, because Android doesn't expose blocking any other way. The reason you can trust Nudge with it specifically is the zero internet permission plus the open source code. The app physically can't send what it sees anywhere, and you can read the code to confirm that. Broad local permission with no network is the safest version of how these apps have to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Opal free?&lt;/strong&gt;&lt;br&gt;
There's a free tier, but it's very limited, and the focus features people install Opal for are part of Opal Pro, which is about $99 a year. So in practice, no, the version you actually want isn't free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there an Android version of Opal?&lt;/strong&gt;&lt;br&gt;
Yes. Opal has an official Android app on Google Play, alongside its iPhone and Mac apps. The catch is that the Android version launched later than iOS, is still catching up on features, and the useful parts require the paid Pro subscription. If you want the same kind of friction on Android for free, you need a different app like Nudge, ScreenZen, or one sec.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Nudge as good as Opal?&lt;/strong&gt;&lt;br&gt;
For the core job, putting friction between you and Instagram, yes. Opal's dashboard is nicer, no argument. But on Android you'd be paying about $99 a year for Opal's less complete app, while Nudge does the actual job for free: pause before you open something, cap your daily time, and do it without a subscription. See the &lt;a href="https://dev.to/blog/best-free-app-blockers-android-2026"&gt;full rundown of free Android app blockers&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a free open-source app safe to give Accessibility access?&lt;/strong&gt;&lt;br&gt;
This is the right question to ask of any blocker. With Nudge the answer is yes for a concrete reason: it requests no internet permission, so anything it observes can't leave your phone, and the source is public so you can verify that. A closed-source free app with full network access is the thing to be wary of, not this.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing to do next
&lt;/h2&gt;

&lt;p&gt;If you're on Android and you came here looking for Opal, you found the catch: the Android app is paid, and the free version barely does anything. The good news is the part of Opal you wanted, the pause that breaks the scroll, is free on Android. Grab the &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;Nudge APK from GitHub&lt;/a&gt;, read the code if you're the type who reads code, and set a pause on the two apps you reach for without thinking. That's the whole game.&lt;/p&gt;

</description>
      <category>android</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>mentalhealth</category>
    </item>
    <item>
      <title>Free one sec Alternative for Android (Open Source) [2026]</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Fri, 29 May 2026 05:30:22 +0000</pubDate>
      <link>https://dev.to/diven_rastdus_c5af27d68f3/free-one-sec-alternative-for-android-open-source-2026-4cp5</link>
      <guid>https://dev.to/diven_rastdus_c5af27d68f3/free-one-sec-alternative-for-android-open-source-2026-4cp5</guid>
      <description>&lt;p&gt;You saw a one sec demo on iOS, loved the idea of a forced breath before Instagram opens, then went looking for it on Android and found one of three things. The Android app was there, but rougher than the demo you watched. Or it worked, but blocking more than one app meant paying about $20 a year. Or it was installed, working fine, and then your Samsung silently reset its permissions overnight and the pause just stopped firing. If you searched "one sec alternative android free" because of any of those, this is for you. Nudge does the same delay-before-opening pause, on Android, free and open source, with zero tracking.&lt;/p&gt;

&lt;p&gt;I'll be straight about what one sec gets right, where the Android version actually falls down, and where Nudge is honestly a better fit. No trashing. The mechanic one sec popularized is genuinely good. The problem is access, not idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one sec actually does
&lt;/h2&gt;

&lt;p&gt;The core of one sec is one move: you tap a distracting app, and instead of it opening instantly, the screen takes over for a few seconds. It tells you to take a breath. Sometimes it makes you hold the phone still until the animation finishes. Then it asks, gently, whether you still want to open the app. Often you don't. The dopamine loop that made you reach for the phone has already half-dissolved by the time the breath ends.&lt;/p&gt;

&lt;p&gt;That's it. That's the whole trick, and it works because it inserts a conscious choice into a movement that had become automatic. one sec calls it an "intervention." Researchers who studied it found people opened their target apps a lot less when this pause was in the way. The genius is that it doesn't block you. You can always continue. It just makes you decide on purpose instead of on reflex.&lt;/p&gt;

&lt;p&gt;So the idea is sound. The question is whether you can get it, reliably, on the phone you actually own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Android users struggle with one sec
&lt;/h2&gt;

&lt;p&gt;Here's where it gets frustrating. one sec was built iOS-first, and it shows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's paid past one app.&lt;/strong&gt; The free tier limits you to a single app, and one sec pro runs about $20 a year on subscription (or $2.99 a month) to unlock the rest. For one breathing screen, that's a hard sell, especially when the mechanic itself is simple. You're renting a pause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Android build lags the iOS one.&lt;/strong&gt; Features land on iPhone first. The Android app has historically been the second-class citizen, and reviews reflect it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The permission-reset bug.&lt;/strong&gt; This is the big one. On Android, an app like this needs Accessibility permission to detect when you open Instagram or TikTok and slide the pause in front of it. On a lot of Samsung devices (and some other aggressive battery-optimizers), the system kills background apps and quietly revokes Accessibility access. You wake up, open Instagram, and nothing happens. The pause is gone. You have to dig back into Settings and re-grant permission, sometimes every few days. People have reported exactly this, and it turns a habit tool into a chore. A blocker that silently stops working is worse than no blocker, because you stop trusting it.&lt;/p&gt;

&lt;p&gt;None of this is one sec being lazy. It's the reality of building Android digital-wellbeing apps on top of a permission model that fights you. But the result is the same: Android users want the one sec experience and keep hitting a wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nudge: Free one sec Alternative Built for Android
&lt;/h2&gt;

&lt;p&gt;Nudge is an open-source Android app blocker. It's free. Not free-tier-with-an-upsell. There is no pro version, no paywalled feature, no subscription. The whole thing is on &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, so you can read the code if you want to know exactly what it does.&lt;/p&gt;

&lt;p&gt;The headline feature is the one you came for.&lt;/p&gt;

&lt;h3&gt;
  
  
  Delay-to-open with a breathing beat
&lt;/h3&gt;

&lt;p&gt;Open an app you've put under Nudge and a pause screen comes up first: a short breathing beat, then a choice to open it or back out. Same conscious-choice moment one sec built its name on, and it's deliberately so, because the mechanic is the good part. Nudge's contribution isn't reinventing the pause. It's making the pause free, auditable, and native to Android.&lt;/p&gt;

&lt;p&gt;Around the pause, Nudge stacks a few more friction tools for when a single breath isn't enough. Per-app daily time budgets are the one most people reach for: give Instagram 20 minutes a day, and when it's gone, it's gone. You can also bundle all your social apps into one group and govern them together, or set schedule rules so TikTok is off during work hours and after 11pm. If a whole app is too blunt, Nudge can kill just the doomscroll surfaces. YouTube Shorts, Instagram Reels, or the TikTok feed, while keeping the rest of the app usable. And there's a grayscale mode that drains the color out of your phone so it stops looking like a slot machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why a pause works better than a hard block
&lt;/h3&gt;

&lt;p&gt;A wall makes you want to climb it. Most hard blockers get uninstalled in week two because they feel like a parent, and the moment you really want the app you just turn the blocker off, which trains you to override it.&lt;/p&gt;

&lt;p&gt;A pause is different. It doesn't say no. It says "are you sure?" and lets you answer. For impulsive, ADHD-pattern reaching, that gap between the reflex and the action is exactly where the habit lives. You're not fighting yourself. You're giving the slower part of your brain a few seconds to catch up. That's why the one sec mechanic landed in the first place, and it's why Nudge built its core around the same idea instead of a brick wall. If you want the longer version of why friction beats blocking for ADHD brains, there's a separate writeup on &lt;a href="https://dev.to/blog/adhd-app-blocker-android-free"&gt;the best free ADHD app blocker for Android&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Nudge's privacy model differs
&lt;/h2&gt;

&lt;p&gt;Nudge isn't just cheaper. It's a different posture entirely.&lt;/p&gt;

&lt;p&gt;Most of these apps are sitting on an intimate log of your attention. A tool that watches every app you open, where does that data go? With closed-source apps you have to trust a privacy policy and hope.&lt;/p&gt;

&lt;p&gt;Nudge has &lt;strong&gt;no internet permission.&lt;/strong&gt; Not "we promise not to send your data." The app literally cannot reach the network, because that capability isn't in the build. Your list of blocked apps, your usage counts, your schedules, all of it stays on the device. There's nothing to leak, nothing to sell, no server holding your attention log.&lt;/p&gt;

&lt;p&gt;No account. No signup. No email. You install it and it works. And because it's open source, you don't have to take my word for any of this. The code is public. Anyone can check that the network permission isn't there and that nothing's being collected. That's a kind of trust a closed-source app, free or paid, can't offer. The honesty is verifiable, not promised.&lt;/p&gt;

&lt;h2&gt;
  
  
  one sec vs Nudge
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;one sec&lt;/th&gt;
&lt;th&gt;Nudge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Delay-before-opening pause&lt;/td&gt;
&lt;td&gt;Yes (the original)&lt;/td&gt;
&lt;td&gt;Yes (same mechanic)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;iOS-first, Android second&lt;/td&gt;
&lt;td&gt;Android only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS support&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;Free for 1 app, ~$20/yr for pro&lt;/td&gt;
&lt;td&gt;Free, all features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open source&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, on GitHub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account&lt;/td&gt;
&lt;td&gt;Optional (for cross-device pro sync)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internet permission&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-app time budgets&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block Reels/Shorts/TikTok feed&lt;/td&gt;
&lt;td&gt;Not a listed feature&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grayscale mode&lt;/td&gt;
&lt;td&gt;Not a listed feature&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Samsung permission stability&lt;/td&gt;
&lt;td&gt;Reported reset issues&lt;/td&gt;
&lt;td&gt;Same Accessibility model, runs fully on-device with nothing to sync&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two caveats. Nudge is Android only. If you live on an iPhone, one sec is your tool, full stop, and I'm not going to pretend otherwise. And Nudge uses Accessibility permission like every app in this category, so on aggressive battery-optimizing phones you may still need to whitelist it from battery optimization to keep it reliable. What Nudge avoids is any network or sync layer at all. Everything lives on the device, so there's nothing remote that can fall out of sync or quietly change under you.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to set up delay-to-open in Nudge
&lt;/h2&gt;

&lt;p&gt;Right now there's one live route, with two more on the way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub (available now).&lt;/strong&gt; Go to the &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;Nudge repo&lt;/a&gt;, grab the latest release APK, and install it. You'll have to allow "install from unknown sources" once, which Android prompts you through. That warning is generic, not a red flag. It shows up for anything not installed from the Play Store. If you'd rather build it yourself, the full source is in that same repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;F-Droid (in progress).&lt;/strong&gt; F-Droid is a trusted open-source app store for Android. Nudge has a merge request open to land there, so it isn't listed yet. Once it's accepted you'll be able to add Nudge from F-Droid like any other app and get updates automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Play Store (in progress).&lt;/strong&gt; A listing is pending review, not live yet. When it's approved, that'll be the one-tap option for people who don't want to think about sideloading.&lt;/p&gt;

&lt;p&gt;Once installed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open Nudge, grant Accessibility permission when it asks. This is what lets the pause appear over your apps.&lt;/li&gt;
&lt;li&gt;Whitelist Nudge from battery optimization. This is the step that prevents the silent-stop problem on Samsung and similar phones.&lt;/li&gt;
&lt;li&gt;Pick the apps you want a pause on. Instagram, TikTok, YouTube, whatever your weak spots are.&lt;/li&gt;
&lt;li&gt;Turn on delay-to-open. Optionally add a daily time budget or a schedule.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Done. Next time you reflexively tap Instagram, you get a breath and a choice instead of an instant feed.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is there a free one sec for Android?&lt;/strong&gt;&lt;br&gt;
Nudge does exactly that. Delay-before-opening, free, unlimited apps, no subscription. one sec itself has a free tier capped at one app, and its pro version (which lifts that cap) is a subscription around $20/yr, so if you want the mechanic free and unlimited on Android, Nudge is the answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much does one sec cost?&lt;/strong&gt;&lt;br&gt;
one sec is free for a single app. To block more than one app or unlock the extra interventions you need one sec pro, which runs about $20 a year, or $2.99 a month. Nudge gives you the same delay-to-open pause plus time budgets, schedules, and grayscale at no cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Nudge really free forever?&lt;/strong&gt;&lt;br&gt;
Yes. There's no pro tier, no subscription, no paywalled features, no ads. It's open source, so even if the project went quiet, the code stays public and the version you have keeps working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will it actually stop me, or will I just turn it off?&lt;/strong&gt;&lt;br&gt;
Nudge uses a pause, not a wall, on purpose. You can always continue past it. The point isn't to make the app impossible, it's to make opening it a choice instead of a reflex. For impulse habits, the choice usually wins more often than a hard block you eventually disable in frustration. If a pause isn't enough, layer on a time budget or a schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to install an open-source app from GitHub?&lt;/strong&gt;&lt;br&gt;
Open source is the safer option, not the riskier one, because the code is public and auditable. Nudge has no internet permission, so it can't send your data anywhere. The "unknown sources" prompt Android shows is standard for any non-Play-Store install, not a warning about Nudge specifically. If you'd rather skip sideloading entirely, wait for F-Droid or the Play Store listing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is this different from a regular blocker?&lt;/strong&gt;&lt;br&gt;
Most blockers are walls. Nudge's core is a pause, which research and the popularity of the one sec mechanic both suggest works better for impulsive reaching. It also bundles time budgets, schedules, in-app feature blocking for Reels and Shorts, and grayscale. If you're comparing options broadly, here's a roundup of the &lt;a href="https://dev.to/blog/best-free-app-blockers-android-2026"&gt;best free app blockers for Android in 2026&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Nudge
&lt;/h2&gt;

&lt;p&gt;Nudge is on GitHub now. Free, open source, no account needed. The F-Droid listing is in review and the Play Store submission is pending. If you're on Android and wanted one sec, &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;grab the APK from GitHub&lt;/a&gt; and you're set up in under two minutes.&lt;/p&gt;

</description>
      <category>android</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>mentalhealth</category>
    </item>
    <item>
      <title>Best Free App Blockers for Android (2026)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Fri, 29 May 2026 05:28:11 +0000</pubDate>
      <link>https://dev.to/diven_rastdus_c5af27d68f3/best-free-app-blockers-for-android-2026-2eo2</link>
      <guid>https://dev.to/diven_rastdus_c5af27d68f3/best-free-app-blockers-for-android-2026-2eo2</guid>
      <description>&lt;p&gt;You install a screen time app on a Tuesday, full of resolve. By Thursday you've found the off switch, and by the weekend you've uninstalled it because the free tier turned out to block exactly two apps before asking for $40 a year. Sound familiar?&lt;/p&gt;

&lt;p&gt;That's the real problem with most "free" app blockers for Android. The word free is doing heavy lifting. So this is a list of the best free app blockers for Android in 2026 where free actually means free, with the catches spelled out for the ones that aren't. I built one of these tools, so I'll be upfront about that and try to be fair to everyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer: best free Android app blockers in 2026 at a glance
&lt;/h2&gt;

&lt;p&gt;If you just want the picks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Most feature-complete free + private:&lt;/strong&gt; &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;Nudge&lt;/a&gt;. Open source, no subscription, no internet permission, ADHD-focused friction features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanest free option for plain blocking:&lt;/strong&gt; ScreenZen. Good pause feature and per-app daily limits, but no in-feed blocking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The other open-source pick:&lt;/strong&gt; DigiPaws. Promising, also does in-feed Shorts/Reels blocking, but still alpha and no breathing-pause feature yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Already on your phone:&lt;/strong&gt; Google Digital Wellbeing. Fine for awareness, weak at actual enforcement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freemium tools worth knowing:&lt;/strong&gt; AppBlock, Freedom, one sec, Opal. Capable, but the part you want is usually behind a paywall.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest of this is how I tested, what actually separates these, and where each one falls down.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we tested (real devices, what "free" really means)
&lt;/h2&gt;

&lt;p&gt;I ran each app on a Pixel 6 (Android 14) and a Samsung A54 (Android 13) for at least a few days each, doing normal stuff. Open Instagram out of habit, hit the block, see what happens. The questions I cared about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the free version actually block apps, or just count your minutes?&lt;/li&gt;
&lt;li&gt;Can you bypass it in three taps when you're weak?&lt;/li&gt;
&lt;li&gt;Does it need an internet connection or an account?&lt;/li&gt;
&lt;li&gt;Is there a hidden paywall on the feature that made you install it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Free" here means you can do the core job (block or slow down a distracting app) without paying, without a trial clock, and without a "pro" wall on the one setting you need. A tool that blocks one app free and charges for the second one is freemium, not free. I'll say which is which.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to look for: free vs freemium, privacy, bypass-resistance, ADHD design
&lt;/h2&gt;

&lt;p&gt;Four things separate a tool you'll still use in a month from one you'll delete:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free vs freemium.&lt;/strong&gt; Read the Play Store screenshots for the word "Pro" or a price. If the feature you want is gated, budget for it or move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy.&lt;/strong&gt; App blockers see everything you open. Some send that to a server. Check the permissions. An app blocker doesn't need your contacts, and it doesn't need internet at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bypass-resistance.&lt;/strong&gt; The whole point is stopping you in a weak moment. If the off switch is one tap on the block screen, it won't survive a real craving. Good friction makes quitting annoying without making the app a prison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ADHD design.&lt;/strong&gt; If you have ADHD, willpower-based tools fail you specifically. What works is friction at the moment of impulse, a pause that interrupts the autopilot reach for the phone. More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things worth knowing before you pick
&lt;/h2&gt;

&lt;p&gt;Before the tool-by-tool breakdown, three decisions shape which app is right for you. Read these first and the picks below will make more sense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your goal is Shorts and Reels specifically, only two tools here do it.&lt;/strong&gt; Most blockers force you to nuke the whole app. Only in-feed blocking can strip out Shorts or Reels while leaving DMs and search alone. On this list that's Nudge and DigiPaws. DigiPaws is still alpha-stage and skips the breathing pause, while Nudge pairs feed blocking with the delay-to-open pause and per-app budgets, covered in section 1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you have ADHD, friction beats willpower.&lt;/strong&gt; The standard advice to "just use less screen time" is borderline insulting, because the entire issue is that the impulse fires before the deciding part of your brain gets a vote. Willpower-based tools assume you'll see a reminder and choose to stop. ADHD brains often don't get that window. A breathing pause inserts a physical delay between the reach for the phone and the dopamine hit. It's not asking you to be disciplined, it's making the autopilot loop slightly harder to complete, which is often enough to break it. That's the design principle behind delay-to-open and grayscale: change the environment, not the willpower. I wrote more about &lt;a href="https://dev.to/blog/adhd-app-blocker-android-free"&gt;building an ADHD-friendly app blocker setup on Android for free&lt;/a&gt; if this is your situation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every app blocker needs the Accessibility permission, so the real question is what it does with it.&lt;/strong&gt; To detect which app you're opening, all of them use the Accessibility API. That's a powerful permission. It can read what's on your screen. You're trusting whatever app you grant it to. So the privacy question isn't "does it ask for Accessibility," because they all need it. It's "what does it do with that access, and can you check?" An app with no internet permission physically can't send your screen data anywhere. An open-source app lets you read the code and confirm it. With closed-source freemium apps, you're trusting the privacy policy and the company's incentives. Sometimes that's fine. Just know which trust you're extending. There's a &lt;a href="https://dev.to/blog/open-source-app-blocker-android-no-internet-permission"&gt;longer breakdown of open-source app blockers with no internet permission&lt;/a&gt; if you want to go deeper on the threat model.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Nudge: best free + open-source + privacy-first (delay-to-open)
&lt;/h2&gt;

&lt;p&gt;Full disclosure: this is the one I built. Nudge is a free, open-source Android app blocker (v1.5.6 at the time of writing), built in Kotlin and Jetpack Compose. Android only. No iOS version, and I'm not going to pretend otherwise.&lt;/p&gt;

&lt;p&gt;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delay-to-open.&lt;/strong&gt; When you tap a distracting app, Nudge shows a short breathing pause first. That gap is enough to ask yourself if you actually meant to open TikTok or your thumb just did it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-app daily time budgets.&lt;/strong&gt; Give Instagram 20 minutes a day. When it's gone, it's gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App groups and schedules.&lt;/strong&gt; Block a whole cluster of apps during work hours or after 11pm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-app feature blocking.&lt;/strong&gt; Kill YouTube Shorts, Instagram Reels, or the TikTok feed without blocking the entire app. You keep DMs and search, you lose the endless scroll.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grayscale mode.&lt;/strong&gt; Drains the color out of your screen so the dopamine slot machine stops looking fun.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the part that matters most to me: Nudge has zero internet permission. None. The app literally can't phone home because the capability isn't in the manifest, and because it's open source you can verify that yourself instead of trusting my word. All your data stays on the device. No account, no ads, no tracking, no "pro" tier. Everything's free because there's nothing to upsell.&lt;/p&gt;

&lt;p&gt;Where it's weak: it's Android-only, and there's no one-tap store install today. The Play Store listing is pending review and the F-Droid listing is in progress, so for now you either &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;build it from source&lt;/a&gt; or sideload the APK from GitHub. If you want a polished one-tap-from-the-store experience, that's a fair reason to wait.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Nudge blocks YouTube Shorts and Reels without nuking the app
&lt;/h3&gt;

&lt;p&gt;This is the request I hear most. You don't hate YouTube, you hate Shorts. You want to message friends on Instagram without the Reels tab sucking 40 minutes out of your evening. Most app-level blockers force you to block the whole app, which means you lose the useful part to kill the bad part.&lt;/p&gt;

&lt;p&gt;The fix is in-feed blocking, where the tool detects the Shorts or Reels surface specifically and blocks just that, leaving the rest alone. Nudge does this. There's a &lt;a href="https://dev.to/blog/block-youtube-shorts-instagram-reels-android-without-blocking-app"&gt;step-by-step guide to blocking Shorts and Reels without blocking the app&lt;/a&gt; if you want the exact setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. ScreenZen: solid free option, but no in-feed blocking
&lt;/h2&gt;

&lt;p&gt;ScreenZen is a genuinely good free app and probably the closest thing to a direct comparison. It's fully free with no premium tier. Its core idea is the same pause-before-open friction, and it does that well. You set which apps trigger a breathing screen, and it nudges you to confirm you meant to open them. It also has per-app daily limits, so credit where it's due, that's not a gap.&lt;/p&gt;

&lt;p&gt;Where it stops short for me: there's no in-feed blocking, so you can't strip out Reels while keeping the rest of Instagram. It's an app-level tool. If all you want is a pause screen with daily limits, ScreenZen is a clean, no-nonsense choice and I'd recommend it without hesitation. If you specifically need to block the Reels or Shorts feed while keeping the rest of the app, you'll hit its ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. DigiPaws: the other open-source pick (alpha-stage, no delay-to-open)
&lt;/h2&gt;

&lt;p&gt;DigiPaws is the other open-source app blocker worth knowing about, and the open-source corner of this category is thin, so it's worth your attention. It's privacy-respecting and actively developed. It also has an in-app blocker that strips out YouTube Shorts and Instagram Reels while leaving the rest of the app usable, which makes it the only other tool here besides Nudge that does feed-level blocking.&lt;/p&gt;

&lt;p&gt;The catch is maturity. Last I checked it's still alpha-stage, which means rough edges and features in flux. It also doesn't have the breathing delay-to-open pause that Nudge and ScreenZen lean on. If you specifically want open source and you're comfortable with alpha software and reporting bugs, DigiPaws is a legitimate option and the project deserves support. If you want something more settled, it's early days.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Google Digital Wellbeing: built in, but no real enforcement
&lt;/h2&gt;

&lt;p&gt;It's already on most Android phones, it's free, and it costs you nothing to set up. Digital Wellbeing shows you your screen time, lets you set app timers, and has a Focus Mode and a bedtime/grayscale schedule. For pure awareness, it's fine.&lt;/p&gt;

&lt;p&gt;The problem is enforcement. App timers are trivially easy to dismiss with an "ignore for today" tap, and there's no real friction, no breathing pause, no bypass resistance. It's designed to inform, not to stop you. If you have the self-control for a gentle reminder to work, you might not need anything else. If gentle reminders were enough, you probably wouldn't be reading this.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Freemium options to know (AppBlock, Freedom, one sec, Opal) and their catch
&lt;/h2&gt;

&lt;p&gt;These are well-known and several are good products. But they're freemium, and the gate usually sits on the feature you actually came for.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AppBlock&lt;/strong&gt; has a usable free tier, but stricter blocking, multiple profiles, and the harder-to-bypass modes push you toward the paid version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freedom&lt;/strong&gt; is cross-platform and polished, but the free plan caps you at a handful of blocking sessions before it asks for a subscription.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;one sec&lt;/strong&gt; is built around the same pause-before-open idea Nudge and ScreenZen use, and it does it nicely, but the free version limits how many apps you can guard before going premium. (If that limit is your blocker, here's a &lt;a href="https://dev.to/blog/one-sec-alternative-android-free"&gt;free Android alternative to one sec&lt;/a&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opal&lt;/strong&gt; is heavily marketed and slick, but the meaningful enforcement and deep-focus features are paid. (Same story, here's an &lt;a href="https://dev.to/blog/opal-alternative-android-free"&gt;Opal alternative for Android that's free&lt;/a&gt;.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are scams. They're businesses, and the subscription funds the polish. The question is just whether you want to pay a recurring fee to stop yourself opening Instagram, or whether free does the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison table: price, platform, open source, internet permission, key feature
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Real price&lt;/th&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Open source&lt;/th&gt;
&lt;th&gt;Needs internet&lt;/th&gt;
&lt;th&gt;Standout feature&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nudge&lt;/td&gt;
&lt;td&gt;Free, all features&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (zero internet permission)&lt;/td&gt;
&lt;td&gt;Delay-to-open + feed blocking + budgets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ScreenZen&lt;/td&gt;
&lt;td&gt;Free, all features&lt;/td&gt;
&lt;td&gt;Android, iOS, desktop&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Core works offline&lt;/td&gt;
&lt;td&gt;Pause-before-open + daily limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DigiPaws&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Open source feed blocking (alpha)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Digital Wellbeing&lt;/td&gt;
&lt;td&gt;Free, built in&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Tied to Google account&lt;/td&gt;
&lt;td&gt;Already installed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AppBlock&lt;/td&gt;
&lt;td&gt;Freemium&lt;/td&gt;
&lt;td&gt;Android, iOS&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Profiles and schedules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freedom&lt;/td&gt;
&lt;td&gt;Freemium&lt;/td&gt;
&lt;td&gt;Android, iOS, desktop&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Cross-device blocking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;one sec&lt;/td&gt;
&lt;td&gt;Freemium&lt;/td&gt;
&lt;td&gt;Android, iOS&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Breathing pause&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opal&lt;/td&gt;
&lt;td&gt;Freemium&lt;/td&gt;
&lt;td&gt;Android, iOS&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Deep focus sessions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Platform and pricing details for the freemium tools change, so check their current listings. The free/open-source/internet-permission columns are the ones that don't quietly shift on you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which free Android app blocker should you pick?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want maximum features, free, and verifiable privacy:&lt;/strong&gt; Nudge. Android only, and for now you build from source or sideload the APK while the F-Droid and Play Store listings are in progress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a clean pause screen and nothing fancy:&lt;/strong&gt; ScreenZen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want open source and don't mind alpha software:&lt;/strong&gt; DigiPaws.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want zero install effort and just need awareness:&lt;/strong&gt; Digital Wellbeing, already on your phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You don't mind paying for polish:&lt;/strong&gt; any of the freemium options, just confirm the feature you want is in their plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no single right answer. The honest version is that the best free app blocker is the one you'll still have installed next month, which usually means the one that's annoying enough to work but not so annoying you rage-uninstall it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What's the best free app blocker for Android?&lt;/strong&gt;&lt;br&gt;
For genuinely free with no subscription and no paywalled features, Nudge and ScreenZen are the strongest picks. Both have a pause screen and per-app daily limits. Nudge adds in-feed Shorts/Reels blocking and verifiable, no-internet-permission privacy. ScreenZen is simpler and runs on more platforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are any open source?&lt;/strong&gt;&lt;br&gt;
Yes. Nudge and DigiPaws are both open source. That means you can read the code and confirm what the app actually does instead of trusting a privacy policy. DigiPaws is earlier-stage; Nudge is at v1.5.6.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is no-subscription real, or is there always a catch?&lt;/strong&gt;&lt;br&gt;
With most "free" app blockers, the catch is a paywall on the feature you need. With Nudge it's actually free with no pro tier, because it's open source and there's nothing to upsell. The honest tradeoff is that it's Android only and the Play Store listing is still in review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do app blockers need internet access?&lt;/strong&gt;&lt;br&gt;
They don't have to. Most do, for accounts and sync. Nudge ships with zero internet permission, so it can't send your data anywhere even in principle. That's the kind of thing worth checking in any app's permission list.&lt;/p&gt;




&lt;p&gt;If you want the free, open-source, no-internet-permission option, Nudge is on &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. You can read the code, build it yourself, or sideload the APK. The F-Droid and Play Store listings are in progress. No account, no email, no catch. And if you've been deleting screen time apps for years because the free version never did the one thing you needed, that's exactly what this one is built to fix.&lt;/p&gt;

</description>
      <category>android</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>adhd</category>
    </item>
    <item>
      <title>5 Monitoring Blind Spots That Let My Side Projects Fail Silently</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Thu, 28 May 2026 12:10:28 +0000</pubDate>
      <link>https://dev.to/diven_rastdus_c5af27d68f3/5-monitoring-blind-spots-that-let-my-side-projects-fail-silently-km3</link>
      <guid>https://dev.to/diven_rastdus_c5af27d68f3/5-monitoring-blind-spots-that-let-my-side-projects-fail-silently-km3</guid>
      <description>&lt;p&gt;I run four side projects. A journaling app, an Android app blocker, a healthcare AI tool, and a content pipeline. Total monitoring budget: $0.&lt;/p&gt;

&lt;p&gt;Last month, one of them went down for 24 hours. Nobody told me. I found out by accident.&lt;/p&gt;

&lt;p&gt;That scared me enough to audit all four projects. I found the same five blind spots across every single one.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. No Uptime Checks (The "It's Probably Fine" Gap)
&lt;/h2&gt;

&lt;p&gt;My journaling app runs on Supabase's free tier. Free-tier projects auto-pause after 7 days of inactivity. I knew this in theory.&lt;/p&gt;

&lt;p&gt;In practice, I shipped a demo to a potential client. The project had been idle. Supabase paused it. The API returned nothing. The frontend showed a blank screen.&lt;/p&gt;

&lt;p&gt;For 24 hours, anyone visiting saw a broken app. I only discovered it when I opened the dashboard for an unrelated reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: A cron job that hits every critical endpoint every 6 hours.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# health-check.sh - runs via cron every 6h&lt;/span&gt;
&lt;span class="nv"&gt;ENDPOINTS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  &lt;span class="s2"&gt;"https://my-app.vercel.app/api/health"&lt;/span&gt;
  &lt;span class="s2"&gt;"https://my-backend.supabase.co/rest/v1/"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;url &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ENDPOINTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;python3 ~/bin/alert.py &lt;span class="nt"&gt;--message&lt;/span&gt; &lt;span class="s2"&gt;"DOWN: &lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt; returned &lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total cost: $0. Runs on the same machine that runs everything else. Better Stack or UptimeRobot are better options. But this costs nothing and catches 80% of failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Unstructured Logs Across Services
&lt;/h2&gt;

&lt;p&gt;My healthcare AI tool runs three microservices on Cloud Run: an MCP server, an orchestrator agent, and an interaction checker. When a patient reconciliation fails, which service caused it?&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;print()&lt;/code&gt; statements, the answer is "good luck." Cloud Run interleaves logs from all services. One request touches all three. There's no correlation ID linking them.&lt;/p&gt;

&lt;p&gt;I spent 40 minutes tracing a bug that turned out to be a timeout in the MCP server. The orchestrator logged "reconciliation failed." The MCP server logged nothing useful. The interaction checker never got called.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: Structured JSON logs with a request ID passed through every service call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# At the request entry point
&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())[:&lt;/span&gt;&lt;span class="mi"&gt;8&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Reconciliation started&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;patient_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;patient_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Pass request_id to downstream services via header
# X-Request-ID: {request_id}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloud Run (and most log aggregators) parse JSON automatically. Now I can filter by &lt;code&gt;request_id&lt;/code&gt; and see the full trace across services. Structured logging was the single most impactful monitoring improvement I made.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Zero Mobile Crash Visibility
&lt;/h2&gt;

&lt;p&gt;My Android app blocker uses Kotlin and Jetpack Compose. R8 (Android's code shrinker) silently removed a class my accessibility service needed. The app installed fine. It launched fine. The core feature just... didn't work.&lt;/p&gt;

&lt;p&gt;I found this bug during manual testing on a real device. If this had shipped to users, I would have had zero visibility. No crash reports. No error logs. Nothing.&lt;/p&gt;

&lt;p&gt;Android's &lt;code&gt;logcat&lt;/code&gt; only works when you're connected via USB. Once the app is on someone else's phone, you're blind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: At minimum, catch uncaught exceptions and log them somewhere you can read later.&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;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDefaultUncaughtExceptionHandler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;throwable&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;report&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildString&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;appendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Thread: ${thread.name}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;appendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Error: ${throwable.message}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;appendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;throwable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stackTraceToString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Write to local file, upload on next app launch&lt;/span&gt;
    &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filesDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"crash.log"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;writeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&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;Crashlytics, Sentry, or Bugfender give you stack traces, device info, and occurrence counts out of the box. This basic handler still beats flying blind when you're not ready to pay for one.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. API Quota Exhaustion With No Warning
&lt;/h2&gt;

&lt;p&gt;This week, my social media automation stopped working. No errors in my code. No exceptions. Just... nothing posted.&lt;/p&gt;

&lt;p&gt;The X (Twitter) API returns a &lt;code&gt;CreditsDepleted&lt;/code&gt; error when you hit your monthly quota. My posting script caught the error, logged it to a file, and moved on. Nobody reads log files proactively.&lt;/p&gt;

&lt;p&gt;I discovered the issue 2 days later when I manually checked why engagement dropped to zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: Treat quota and billing errors as alerts, not log lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&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;response&lt;/span&gt; &lt;span class="o"&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;post_tweet&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="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ApiError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# CreditsDepleted = all posting dead until cycle resets. Treat as outage.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CreditsDepleted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;str&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="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;send_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;API QUOTA HIT: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. All posting blocked until reset.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tweet failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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 distinction matters. A 500 error is transient. A quota error means everything is broken until the billing cycle resets. That deserves a push notification, not a log line buried in a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. CI Tests That Don't Run Against Production
&lt;/h2&gt;

&lt;p&gt;Last week, my healthcare tool's production API broke. I didn't find out from my CI pipeline. I found out from a GitHub notification that sat unread in my inbox for 3 days.&lt;/p&gt;

&lt;p&gt;The problem: my end-to-end tests run against local Docker containers. They pass every time. But the deployed Cloud Run services had drifted. CI was green. Production was broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: A scheduled workflow that hits the real production URLs every 6 hours.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/e2e-smoke.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;e2e smoke tests&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*/6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;smoke&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install httpx pytest&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pytest tests/e2e/ -v&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;MCP_SERVER_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PROD_MCP_URL }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CI passing on &lt;code&gt;localhost&lt;/code&gt; doesn't mean production works. Scheduled tests against real endpoints catch the drift. I also routed failure notifications to Telegram instead of GitHub's notification bell. GitHub is too noisy. A direct push notification cuts through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern
&lt;/h2&gt;

&lt;p&gt;Every one of these gaps follows the same shape: something fails, nothing tells me, I find out too late.&lt;/p&gt;

&lt;p&gt;The fixes are embarrassingly simple. A cron job. A JSON format string. A try/except that sends a push notification instead of writing to a file. None of this is hard.&lt;/p&gt;

&lt;p&gt;Monitoring isn't about the tool. It's about closing the loop between "something broke" and "someone who can fix it found out." If that loop is open, nothing else matters.&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>devops</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>3 Expo SDK 56 Bugs That Crashed My App Before It Even Launched</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Wed, 27 May 2026 12:07:38 +0000</pubDate>
      <link>https://dev.to/diven_rastdus_c5af27d68f3/3-expo-sdk-56-bugs-that-crashed-my-app-before-it-even-launched-1hbp</link>
      <guid>https://dev.to/diven_rastdus_c5af27d68f3/3-expo-sdk-56-bugs-that-crashed-my-app-before-it-even-launched-1hbp</guid>
      <description>&lt;p&gt;I burned four EAS cloud builds and two hours chasing crashes that had nothing to do with my code. All three bugs came from Expo SDK 56 defaults that silently break Android builds. Here's each one and how to fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: expo-av crashes with NoClassDefFoundError
&lt;/h2&gt;

&lt;p&gt;I added voice recording to a dream journal app. The &lt;a href="https://docs.expo.dev/versions/latest/sdk/audio/" rel="noopener noreferrer"&gt;Expo docs for Audio&lt;/a&gt; still reference &lt;code&gt;expo-av&lt;/code&gt; in some examples. So I installed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-av
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app compiled. TypeScript was happy. Then the EAS build failed on Android with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;java.lang.NoClassDefFoundError: 
  Failed resolution of: Lio/expo/modules/video/VideoViewModel;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;expo-av&lt;/code&gt; package pulls in video dependencies. In SDK 56, the video module was extracted to a separate &lt;code&gt;expo-video&lt;/code&gt; package. The old monolith references classes that no longer exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; &lt;code&gt;expo-av&lt;/code&gt; is deprecated starting SDK 55. Use &lt;code&gt;expo-audio&lt;/code&gt; for audio and &lt;code&gt;expo-video&lt;/code&gt; for video. They're separate packages now.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm uninstall expo-av
npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-audio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API changed too. Old:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Audio&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-av&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recording&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Recording&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;recording&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepareToRecordAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RecordingOptionsPresets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HIGH_QUALITY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;recording&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useAudioRecorder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RecordingPresets&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-audio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAudioRecorder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RecordingPresets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HIGH_QUALITY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new API is hook-based. No more class instances, no manual cleanup. &lt;code&gt;useAudioRecorder&lt;/code&gt; handles permissions, lifecycle, and cleanup on unmount.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time lost:&lt;/strong&gt; 4 EAS builds (~60 minutes). The error message mentions &lt;code&gt;VideoViewModel&lt;/code&gt;, which sent me down a wrong path investigating video dependencies before I realized the entire package was deprecated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: Gradle 9.x silently breaks React Native
&lt;/h2&gt;

&lt;p&gt;After fixing the audio crash, the next build failed with a different &lt;code&gt;NoClassDefFoundError&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;java.lang.NoClassDefFoundError: 
  com/android/build/api/variant/impl/JvmVendorSpec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npx expo prebuild&lt;/code&gt; generated &lt;code&gt;gradle-wrapper.properties&lt;/code&gt; pointing to Gradle 9.3.1. Gradle 9 removed &lt;code&gt;JvmVendorSpec.IBM_SEMERU&lt;/code&gt;, which React Native's Gradle plugin still references internally.&lt;/p&gt;

&lt;p&gt;The error doesn't mention Gradle versions. It doesn't say "incompatible Gradle." It just throws a class-not-found at build time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Pin Gradle to 8.x. After every &lt;code&gt;npx expo prebuild&lt;/code&gt;, check the generated wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check what version prebuild generated&lt;/span&gt;
&lt;span class="nb"&gt;grep &lt;/span&gt;distributionUrl android/gradle/wrapper/gradle-wrapper.properties
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it says anything starting with &lt;code&gt;gradle-9&lt;/code&gt;, change it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;distributionUrl&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="se"&gt;\:&lt;/span&gt;&lt;span class="s"&gt;//services.gradle.org/distributions/gradle-8.13-bin.zip&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add this to CI to catch it automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GRADLE_VER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'gradle-\K[0-9]+'&lt;/span&gt; android/gradle/wrapper/gradle-wrapper.properties&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GRADLE_VER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; 9 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: Gradle &lt;/span&gt;&lt;span class="nv"&gt;$GRADLE_VER&lt;/span&gt;&lt;span class="s2"&gt; breaks React Native. Pin to 8.x"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Time lost:&lt;/strong&gt; 2 builds. The error looks identical to the expo-av crash (both are &lt;code&gt;NoClassDefFoundError&lt;/code&gt;), which made me think I hadn't fully fixed bug #1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: Barrel exports + native modules = cascading crash
&lt;/h2&gt;

&lt;p&gt;I had a standard barrel export file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/dream/components/index.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DreamCard&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./DreamCard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MoodPicker&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./MoodPicker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;VoiceRecorder&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./VoiceRecorder&lt;/span&gt;&lt;span class="dl"&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;VoiceRecorder&lt;/code&gt; imports &lt;code&gt;expo-audio&lt;/code&gt;. Every screen that imported &lt;em&gt;anything&lt;/em&gt; from &lt;code&gt;@dream/components&lt;/code&gt; would trigger the native module resolution for &lt;code&gt;expo-audio&lt;/code&gt;, even screens that never rendered the recorder.&lt;/p&gt;

&lt;p&gt;In Expo Go (no native modules bundled), this crashes the entire app. Not just the recording screen. Every screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Never barrel-export components that depend on native modules. Import them directly and lazy-load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/dream/components/index.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DreamCard&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./DreamCard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MoodPicker&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./MoodPicker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// VoiceRecorder NOT barrel-exported -- requires native module&lt;/span&gt;
&lt;span class="c1"&gt;// Import directly: import { VoiceRecorder } from '@dream/components/VoiceRecorder'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the consuming screen, use React.lazy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Suspense&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VoiceRecorder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@dream/components/VoiceRecorder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VoiceRecorder&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// In render:&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Suspense&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ActivityIndicator&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;VoiceRecorder&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Suspense&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way the native module only loads when the component actually renders, and screens that don't use it never touch &lt;code&gt;expo-audio&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time lost:&lt;/strong&gt; 1 hour. The crash logs pointed to the native module, not the import chain. I kept looking at &lt;code&gt;expo-audio&lt;/code&gt; configuration when the real problem was in &lt;code&gt;index.ts&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I wish I had
&lt;/h2&gt;

&lt;p&gt;Before your next Expo SDK 56 Android build: grep for &lt;code&gt;expo-av&lt;/code&gt; (replace with &lt;code&gt;expo-audio&lt;/code&gt;/&lt;code&gt;expo-video&lt;/code&gt;), check &lt;code&gt;gradle-wrapper.properties&lt;/code&gt; isn't 9.x after prebuild, and audit barrel exports for native module imports.&lt;/p&gt;

&lt;p&gt;But mostly: &lt;strong&gt;check the SDK changelog before choosing packages.&lt;/strong&gt; I would have caught bug #1 in 30 seconds by reading the &lt;a href="https://blog.expo.dev/" rel="noopener noreferrer"&gt;Expo SDK 56 changelog&lt;/a&gt;. The deprecation is documented. I just didn't look.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>android</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
