<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Farouq Seriki - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Farouq Seriki - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Thu, 15 Oct 2026 18:32:34 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/Fasthedeveloper/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ The iOS NFC Handbook: How to Read, Write and Lock NFC Tags with React Native ]]>
                </title>
                <description>
                    <![CDATA[ Hold an iPhone near a sticker and something happens. A business card lands in your contacts, a focus session ends, or a door opens. The chip costs about twenty pence and holds roughly a hundred and th ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-ios-nfc-handbook-how-to-read-write-and-lock-nfc-tags-with-react-native/</link>
                <guid isPermaLink="false">6aaec3412b999a8f7cf63f8f</guid>
                
                    <category>
                        <![CDATA[ React Native ]]>
                    </category>
                
                    <category>
                        <![CDATA[ iOS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Swift ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Farouq Seriki ]]>
                </dc:creator>
                <pubDate>Sat, 19 Sep 2026 17:15:45 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/6e58f5f5-1bf1-4030-b3d2-f9b1617637c1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Hold an iPhone near a sticker and something happens. A business card lands in your contacts, a focus session ends, or a door opens. The chip costs about twenty pence and holds roughly a hundred and thirty bytes.</p>
<p>Reading one takes two function calls. Earning the right to make those two calls takes considerably longer. And then CoreNFC asks you to earn it a second time.</p>
<p>The first gauntlet is Apple's. A paid developer account, an App ID registered in a web portal, a capability ticked on it, and a provisioning profile regenerated. Get any of it wrong and the build fails with a code-signing error that never says the word "NFC".</p>
<p>The second is CoreNFC's own, and it's the one nobody warns you about. It's a session-based API with delegate callbacks, four nested asynchronous steps, and a set of rules that punish you quietly.</p>
<p>Hold the session in the wrong variable and the system sheet vanishes with no error at all. Let a successful read settle your promise twice and your result is replaced by a failure. Ask for one polling option too many and the whole session refuses to start, without naming the option.</p>
<p>In this handbook, you'll get through both. You'll write an NDEF decoder by hand, which sounds like overkill until you read what the popular library's one actually does to an emoji. You'll write to a tag and discover your business card doesn't fit. You'll build a focus timer you can't stop without walking to another room. You'll delete the NFC dependency entirely and replace it with a native module in Swift. And you'll permanently lock a tag, which is the one thing here that can't be undone.</p>
<p>Two of the things I concluded during this build turned out to be wrong, and both are still in the repository under "superseded" banners. They're in here too, because how I got them wrong is more useful than the what replaced them.</p>
<p>This is an iOS handbook. Everything in the main sections was built, run, and verified on a real iPhone. Android gets its own handbook, and if you can't wait for it there's a preview at the end: the Kotlin counterpart to everything here, with the architectural differences that make the two platforms worth comparing.</p>
<p>Everything below comes from one project, TapCard, which is on GitHub with tagged checkpoints so you can check out the app at any stage and run it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-nfc-tags-actually-are">What NFC Tags Actually Are</a></p>
</li>
<li><p><a href="#heading-where-you-have-already-seen-this">Where You Have Already Seen This</a></p>
</li>
<li><p><a href="#heading-why-there-is-no-simulator-path">Why There Is No Simulator Path</a></p>
</li>
<li><p><a href="#heading-how-the-ios-contract-works">How the iOS Contract Works</a></p>
</li>
<li><p><a href="#heading-how-to-read-your-first-tag">How to Read Your First Tag</a></p>
</li>
<li><p><a href="#heading-what-an-ndef-record-actually-is">What an NDEF Record Actually Is</a></p>
</li>
<li><p><a href="#heading-why-i-wrote-my-own-decoder">Why I Wrote My Own Decoder</a></p>
</li>
<li><p><a href="#heading-how-to-write-a-tag">How to Write a Tag</a></p>
</li>
<li><p><a href="#heading-will-it-fit-137-not-144">Will It Fit? 137, Not 144</a></p>
</li>
<li><p><a href="#heading-a-focus-session-you-cant-end-without-the-tag">A Focus Session You Can't End Without the Tag</a></p>
</li>
<li><p><a href="#heading-why-your-nfc-lock-isnt-secure">Why Your NFC Lock Isn't Secure</a></p>
</li>
<li><p><a href="#heading-four-gotchas-that-cost-me-an-evening-each">Four Gotchas That Cost Me an Evening Each</a></p>
</li>
<li><p><a href="#heading-why-you-cant-build-tap-to-pay">Why You Can't Build Tap to Pay</a></p>
</li>
<li><p><a href="#heading-why-you-might-write-your-own-native-module">Why You Might Write Your Own Native Module</a></p>
</li>
<li><p><a href="#heading-how-to-prove-parity-before-you-switch">How to Prove Parity Before You Switch</a></p>
</li>
<li><p><a href="#heading-what-a-config-plugin-was-doing-for-you">What a Config Plugin Was Doing For You</a></p>
</li>
<li><p><a href="#heading-how-to-lock-a-tag-forever">How to Lock a Tag Forever</a></p>
</li>
<li><p><a href="#heading-how-would-you-actually-ship-this">How Would You Actually Ship This?</a></p>
</li>
<li><p><a href="#heading-a-sneak-peek-at-the-android-side">A Sneak Peek at the Android Side</a></p>
</li>
<li><p><a href="#heading-the-demo-repository">The Demo Repository</a></p>
</li>
<li><p><a href="#heading-what-to-know-before-you-start">What to Know Before You Start</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-sources-and-further-reading">Sources and Further Reading</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along you'll need:</p>
<ul>
<li><p><strong>A physical iPhone</strong>, 7 or newer. Core NFC is iPhone-only. Apple's documentation lists iPhone 7 and later, and an Apple engineer on the developer forums puts it directly: "At this time, CoreNFC functionality is only available on iPhones with NFC capability." No iPad supports it.</p>
</li>
<li><p><strong>A paid Apple Developer account.</strong> NFC isn't available on a free provisioning profile. This isn't a soft requirement, and there's no way around it.</p>
</li>
<li><p><strong>NTAG213 stickers.</strong> Twenty of them cost a few pounds. Buy them before you write any code.</p>
</li>
<li><p><strong>Node 20+ and Xcode.</strong> Working knowledge of TypeScript throughout, and of Swift for the last third.</p>
</li>
<li><p><em>Optional</em>, for the Android preview only: Android Studio and the API 36 SDK.</p>
</li>
</ul>
<p>The versions I used:</p>
<table>
<thead>
<tr>
<th>Package or tool</th>
<th>Version</th>
</tr>
</thead>
<tbody><tr>
<td>Expo SDK</td>
<td>57.0.20</td>
</tr>
<tr>
<td>React Native</td>
<td>0.86.3</td>
</tr>
<tr>
<td>React</td>
<td>19.2.3</td>
</tr>
<tr>
<td>TypeScript</td>
<td>6.0.3</td>
</tr>
<tr>
<td><code>react-native-nfc-manager</code></td>
<td>3.17.2 (removed by the end)</td>
</tr>
<tr>
<td>Xcode</td>
<td>26.6</td>
</tr>
<tr>
<td>JDK</td>
<td>17 (Zulu), Android preview only</td>
</tr>
</tbody></table>
<p>One line in that table is load-bearing and I'll come back to it: the NFC library is listed <em>because it gets deleted</em>. The finished app has <strong>no third-party NFC dependency at all</strong>.</p>
<h2 id="heading-what-nfc-tags-actually-are">What NFC Tags Actually Are</h2>
<p>An NFC tag is a chip with a tiny antenna and no battery. Your phone powers it over the air, and in return it hands back a few dozen bytes. That's the whole device.</p>
<p>Those bytes are almost always formatted as <strong>NDEF</strong>, or NFC Data Exchange Format, which is the thing that makes a tag written by one app readable by every other. An NDEF message is a list of <strong>records</strong>, and each record has a type and a payload.</p>
<p>The tags in this handbook are <strong>NTAG213</strong>, the ones you'll get if you buy stickers online. They hold 144 bytes of user memory. That's <em>not</em> the number you can actually use, and it turns out to matter enormously.</p>
<p>Two things a tag is not:</p>
<ol>
<li><p><strong>It's not a beacon.</strong> It has no power of its own and does nothing until a phone is within a couple cm.</p>
</li>
<li><p><strong>It's not secure by default.</strong> Its identifier is world-readable and copyable with cheap hardware. There's a section on this later, because the obvious use of a tag, as a key, is the one most likely to be built wrong.</p>
</li>
</ol>
<h2 id="heading-where-you-have-already-seen-this">Where You Have Already Seen This</h2>
<p>NFC is in your pocket already, and it's worth separating what you can build from what you can't, because they look identical to a user.</p>
<p>Here are five uses cases you can go and check, each one documented by the company that ships it:</p>
<table>
<thead>
<tr>
<th>Where you have seen it</th>
<th>What happens</th>
<th>Source</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Shortcuts, on your own phone</strong></td>
<td>Scan a tag to fire a personal automation</td>
<td><a href="https://support.apple.com/guide/shortcuts/setting-triggers-apde31e9638b/ios">Setting triggers in Shortcuts</a></td>
</tr>
<tr>
<td><strong>A lost AirTag</strong></td>
<td>Anyone taps it with an NFC phone and a page opens with the owner's details</td>
<td><a href="https://support.apple.com/guide/iphone/mark-an-item-as-lost-iph1b451b75f/ios">Mark an item as lost in Find My</a></td>
</tr>
<tr>
<td><strong>Nintendo amiibo</strong></td>
<td>A figure is tapped to a controller. Some games read it, some write your character back onto it</td>
<td><a href="https://en-americas-support.nintendo.com/app/answers/detail/a_id/13260/">amiibo FAQ</a></td>
</tr>
<tr>
<td><strong>A UK visa application</strong></td>
<td>The "UK Immigration: ID Check" app reads the chip inside your passport</td>
<td><a href="https://www.gov.uk/guidance/using-the-uk-immigration-id-check-app">Using the app</a></td>
</tr>
<tr>
<td><strong>Tap to Pay on iPhone</strong></td>
<td>A shop takes a contactless card payment with no terminal at all</td>
<td><a href="https://www.apple.com/business/tap-to-pay-on-iphone/">Tap to Pay on iPhone</a></td>
</tr>
</tbody></table>
<p>The first three are the thing this handbook builds. A chip holds a few bytes, a reader reads them, and software acts. amiibo is the most complete example of the lot, because some games <strong>write</strong> to the figure as well, which is the second half of what you're about to build.</p>
<p>One detail on that first row is worth pausing on, because it comes back later. Apple's own NFC automation, the one built into every iPhone, says this about the tag you scan:</p>
<blockquote>
<p>"Other than the unique identifier, the contents of the NFC tag are ignored."</p>
</blockquote>
<p>Apple's feature ignores everything this handbook teaches you to write, and keys on the serial number alone. There's <a href="#heading-why-your-nfc-lock-is-not-secure">a section later</a> on what that does and doesn't buy you.</p>
<p>Let's go over two caveats about that table. The passport row is NFC but it's <em>not</em> NDEF: a biometric chip speaks ISO 7816 over the same radio, through <code>NFCISO7816Tag</code> and a different entitlement again, with its own cryptography on top. Same antenna, different world, and out of scope here. And the last row is the half you can't build, which is the rest of this section.</p>
<p>Beyond those five use cases, the pattern repeats everywhere a physical thing needs to say one short sentence to a phone, like museum labels that open an exhibit page, conference badges, product-authentication seals on a bottle, transit posters, restaurant table markers, and the little stickers people put on a desk to start a routine.</p>
<p>What you can't build on iOS is the other half: your phone <em>pretending to be</em> a card. Apple Pay, your bank card in Google Wallet, or a hotel key in your Apple Wallet. Those run on the <strong>Secure Element</strong>, a separate tamper-resistant chip that stores card credentials and answers terminals on its own. On iOS there's no third-party API for it at all. Not restricted: absent. There's a section later on exactly how closed that is.</p>
<p>So when someone says "NFC", they may mean either. This handbook is about the half you can actually write code for, and it'll explain precisely why the other half is closed.</p>
<h2 id="heading-why-there-is-no-simulator-path">Why There Is No Simulator Path</h2>
<p>Don't skip this section, because it changes how you work.</p>
<p><strong>NFC doesn't exist on the iOS Simulator or the Android emulator.</strong> It's not partially supported, nor is it behind a flag. The hardware isn't there and the APIs report it as unavailable. Every single thing you build is tested by holding a physical chip against a physical phone.</p>
<p>The consequences go beyond inconvenience. You can't write a test that proves a tag was read. You can't demo it in CI. If your phone is in another room, you're blocked. When my chips went missing in the post, the project stopped for two weeks.</p>
<p>The app says so permanently, because the question kept coming up:</p>
<pre><code class="language-ts">import * as Device from 'expo-device';

// NFC hardware does not exist on the iOS Simulator or the Android emulator,
// and isSupported() is not reliable there. Bail out early and explicitly.
if (!Device.isDevice) return false;
</code></pre>
<p>So split your code in two.</p>
<p>One part talks to the NFC hardware. Start a session, read the tag, write to it, and close the session. The only way to test that part is to hold a chip against a phone.</p>
<p>Everything else is just data handling: turning the bytes off a tag into a URL, turning a contact into the bytes you write back, checking whether a message fits, turning a native error code into a sentence a user can read, or deciding whether a tap should start a focus session or end one.</p>
<p>None of that needs NFC. Most of it doesn't even need React Native. It's plain functions: values go in, values come out.</p>
<p>Write it that way and you can test it on your laptop, with no phone in the room. In this project, that came to about 2,240 lines of TypeScript and <strong>293 tests that finish in about a second</strong>. The code that actually calls CoreNFC stays as small as I could make it.</p>
<p>This project stalled on hardware three times, including those two weeks waiting on the post. Each time there was still plenty left to build, because most of it never needed a tag. That's the best decision in the codebase, and I didn't make it deliberately. The constraint made it for me.</p>
<blockquote>
<p><strong>Checkpoint:</strong> <code>git checkout step-0-scaffold</code>. The app boots on both platforms and does nothing else.</p>
</blockquote>
<h2 id="heading-how-the-ios-contract-works">How the iOS Contract Works</h2>
<p>Three Apple terms do all the work here, and they're worth pinning down before the steps, because the error messages assume you already know them.</p>
<p>An <strong>App ID</strong> is the identity your app is registered under on Apple's servers, matching the <code>bundleIdentifier</code> in your config. An <strong>entitlement</strong> is a line in your app saying "I intend to use this capability", and NFC is one. A <strong>provisioning profile</strong> is the signed document tying the two together with your developer account, and Xcode embeds it in every build. All three have to agree, or nothing runs on a device.</p>
<p>With that, here's the actual sequence, and every step is important:</p>
<ol>
<li><p>A <strong>paid</strong> Apple Developer account.</p>
</li>
<li><p>An <strong>explicit App ID</strong> registered at developer.apple.com, not a wildcard.</p>
</li>
<li><p><strong>NFC Tag Reading</strong> ticked on that App ID.</p>
</li>
<li><p>A provisioning profile regenerated to include the capability.</p>
</li>
</ol>
<p>Then, in <code>app.json</code>:</p>
<pre><code class="language-json">{
  "expo": {
    "ios": {
      "bundleIdentifier": "com.yourname.tapcard",
      "infoPlist": {
        "NFCReaderUsageDescription": "TapCard uses NFC to read and write your card to a tag."
      },
      "entitlements": {
        "com.apple.developer.nfc.readersession.formats": ["NDEF", "TAG"]
      }
    }
  }
}
</code></pre>
<p>Miss step 2 or 3 and the build fails with this:</p>
<pre><code class="language-text">Provisioning Profile "iOS Team Provisioning Profile: *" does not support
the NFC Tag Reading capability.
</code></pre>
<p><strong>That error points at the wrong machine.</strong> Apple forbids special capabilities on a <em>wildcard</em> App ID, and Xcode silently falls back to one when there's no explicit match.</p>
<p>The message blames your local provisioning profile, but the missing half is on Apple's servers, in a web form you haven't filled in. Nothing in it suggests opening a browser.</p>
<p>A second-order trap while you're there: <strong>App IDs are globally unique across all Apple accounts</strong>, not per-team**.** My first choice was taken by a stranger, and my second was too. I renamed the app's identifier twice.</p>
<p>Those renames were painless, and that's worth a word on how this project is set up. Expo's <strong>Continuous Native Generation</strong> means the <code>ios/</code> and <code>android/</code> folders aren't kept in the repo at all. They're regenerated from <code>app.json</code> by <code>expo prebuild</code> whenever you need them, the way <code>node_modules</code> is regenerated from <code>package.json</code>. So renaming the app was two lines of JSON and one <code>prebuild --clean</code>, which rebuilt the iOS project, the Android package and the entire Kotlin source tree. No Xcode surgery.</p>
<p>For scale, the equivalent on Android is one line in a manifest, with no account, no portal, and no cost:</p>
<pre><code class="language-xml">&lt;uses-permission android:name="android.permission.NFC" /&gt;
</code></pre>
<p>That contrast isn't a complaint. It's worth internalising early, because it tells you where your time will go on this platform: not in the code, which is short, but in the paperwork around it.</p>
<table>
<thead>
<tr>
<th></th>
<th>Android</th>
<th>iOS</th>
</tr>
</thead>
<tbody><tr>
<td>To read one tag</td>
<td>One manifest line</td>
<td>App ID + capability + paid account + profile</td>
</tr>
<tr>
<td>Cost</td>
<td>Free</td>
<td>$99/yr, or local equivalent</td>
</tr>
<tr>
<td>Failure mode</td>
<td>Permission missing</td>
<td><strong>A code-signing error that never says "NFC"</strong></td>
</tr>
</tbody></table>
<h2 id="heading-how-to-read-your-first-tag">How to Read Your First Tag</h2>
<p>The reading code is short. This is all of it:</p>
<pre><code class="language-ts">import NfcManager, { NfcTech, type TagEvent } from 'react-native-nfc-manager';

export async function readTagOnce(): Promise&lt;TagEvent | null&gt; {
  await NfcManager.requestTechnology(NfcTech.Ndef, {
    alertMessage: 'Hold your iPhone near the NFC tag.',
  });

  try {
    return await NfcManager.getTag();
  } finally {
    // throwOnError: false because we are already unwinding. A failure to
    // close the session must not mask the original error.
    await NfcManager.cancelTechnologyRequest({ throwOnError: false });
  }
}
</code></pre>
<p>There are two calls, and they're identical on both platforms. <strong>And what the user sees couldn't be more different.</strong></p>
<p>On <strong>iOS</strong>, <code>requestTechnology</code> hands control to CoreNFC, which draws a system modal sheet. You can't restyle it. Your app isn't on screen.</p>
<p>On <strong>Android</strong>, nothing is drawn at all. Dispatch is silent. If your app doesn't tell the user to tap a tag, nobody does.</p>
<p>So the scanning UI is conditional, and this pattern recurs through the whole project:</p>
<pre><code class="language-tsx">{
  scanning &amp;&amp; (
    &lt;View style={styles.scanCard}&gt;
      &lt;ActivityIndicator /&gt;
      &lt;Text&gt;
        {Platform.OS === 'android'
          ? 'Hold a tag against the back of the phone.'
          : 'Waiting for the system NFC sheet…'}
      &lt;/Text&gt;
      {/* Android draws no system UI, so the app must offer its own way out. */}
      {Platform.OS === 'android' &amp;&amp; &lt;Button title="Cancel" onPress={handleCancel} /&gt;}
    &lt;/View&gt;
  );
}
</code></pre>
<p>That Cancel button exists only on Android, because on iOS the system sheet already has one.</p>
<h3 id="heading-three-things-that-will-catch-you">Three Things That Will Catch You</h3>
<h4 id="heading-1-the-antenna-is-in-different-places">1. The antenna is in different places.</h4>
<p>On iPhone: the <strong>top edge</strong>, near the camera. On Android: the <strong>middle of the back</strong>. "Hold the tag near the phone" isn't actionable, and someone using the wrong end concludes your app is broken. I ended up drawing a small phone outline with the dot in the right place per platform.</p>
<h4 id="heading-2-the-sheet-doesnt-show-the-string-you-think-it-does">2. The sheet doesn't show the string you think it does.</h4>
<p>I assumed it displayed <code>NFCReaderUsageDescription</code> from <code>app.json</code>. It doesn't. It shows the <code>alertMessage</code> passed to each <code>requestTechnology()</code> call. The usage description is a privacy-manifest string: it's mandatory, no session starts without it, and it's never shown to a user.</p>
<p>Both strings were configured correctly and spelled properly, and my assumption about which one appeared was still wrong. I found out by holding a phone. It also means the copy <strong>can differ per scan</strong>. "Hold your iPhone near the tag to write" beats reusing the read copy.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/c4c1ea67-0ab8-42dc-a810-4aaa6551ee2c.png" alt="The iOS system NFC sheet on an iPhone 13 Pro, reading &quot;Ready to Scan&quot; above the line &quot;Hold your iPhone near the NFC tag.&quot;, with a Cancel button. Behind the sheet the app reports real hardware and a green &quot;NFC ready&quot; banner." style="display: block;" width="400" height="813" loading="lazy">

<p>That second line is the <code>alertMessage</code> from the <code>requestTechnology</code> call above, word for word. The usage description from <code>app.json</code> is nowhere on screen.</p>
<h4 id="heading-3-a-blank-tag-reads-as-an-error-on-ios">3. A blank tag reads as an error on iOS.</h4>
<p>A factory-fresh NTAG213 is NDEF-formatted but empty, and <code>readNDEF</code> reports that as a <em>failure</em> rather than an empty message. The distinction has to come from the tag's NDEF <strong>status</strong>, not the read error. Since every tag you buy starts this way, it's the first thing you'll hit.</p>
<h3 id="heading-what-comes-back">What Comes Back</h3>
<p>Here's the real output from an NTAG213 on an iPhone 13 Pro:</p>
<pre><code class="language-json">{
  "id": "04C4FC91DF2A81",
  "tech": "mifare"
}
</code></pre>
<p>Two fields. That's everything iOS gives you from a read. No size, no technology list, and no NDEF type. Android returns all of them from the same chip.</p>
<p><strong>I drew the wrong conclusion from this</strong>: wrote it into three documents, and planned a whole phase of work around it. <a href="#heading-the-bigger-mistake">That comes later</a>, because the mistake is more useful than the fact. Skip ahead if you'd rather have the correction now.</p>
<p>That <code>id</code> is the tag's <strong>UID</strong>, a unique serial number burned into the chip at the factory and readable by anything that asks. The <code>04</code> prefix is NXP's manufacturer code, and a 7-byte UID is the NTAG21x signature, so the tag is what it claimed to be.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/1e3a1e0c-82c8-47f3-b985-1b65ceacc957.png" alt="The app's Read tab after a first scan on an iPhone 13 Pro. The Tag card lists ID 04C4FC91DF2A81, Type (none), Tech types (none), Max size (unknown) and NDEF records 0, above a RAW block containing only the id and tech fields." style="display: block;" width="400" height="595" loading="lazy">

<p>The <code>Max size</code> row is the one to look at. <code>(unknown)</code> is what I built the wrong conclusion on, and it is <a href="#heading-the-bigger-mistake">not what the platform can actually tell you</a>.</p>
<blockquote>
<p><strong>Checkpoint:</strong> <code>git checkout step-1-first-read</code>. Entitlements configured, a raw NDEF dump on screen.</p>
</blockquote>
<h2 id="heading-what-an-ndef-record-actually-is">What an NDEF Record Actually Is</h2>
<p>A tag doesn't store a URL. It stores bytes, and bytes are what your app gets handed. This whole section is about turning those bytes into <code>https://example.com</code> and back, and it's a much smaller job than its reputation suggests.</p>
<p>The unit you work with is a <strong>record</strong>, and a record is three things:</p>
<ul>
<li><p>a <strong>payload</strong>: the raw bytes of the content</p>
</li>
<li><p>a <strong>type</strong>: what that payload is, such as a URI or some text</p>
</li>
<li><p>a <strong>TNF</strong>, or Type Name Format: three bits saying how to read the type field itself</p>
</li>
</ul>
<p>That third one is the one people trip on. Think of the type as a label and the TNF as the rule for reading the label. A type byte of <code>U</code> means "URI", but only because the TNF said "this is a well-known NFC Forum type". The same byte under a different TNF would be the start of a MIME string instead.</p>
<p>Two record layouts carry almost all real traffic, and both are tiny.</p>
<h3 id="heading-a-uri-record">A URI Record</h3>
<pre><code class="language-text">04 65 78 61 6d 70 6c 65 2e 63 6f 6d
│  └──────── "example.com" ────────┘
└─ prefix index → "https://"

→ https://example.com
</code></pre>
<p>The annotations underneath say which byte does what. Everything after the first byte is ordinary text: <code>example.com</code>. The first byte, <code>04</code>, is an index into a 36-entry table in the NFC Forum spec, and entry 4 is <code>https://</code>.</p>
<p>So <strong>the scheme costs one byte instead of eight.</strong> On a tag with 137 usable bytes that isn't a micro-optimisation, it's 5% of your budget.</p>
<h3 id="heading-a-text-record">A Text Record</h3>
<p>Text needs two things a URI doesn't: which language it's in, and how it's encoded. Both get squeezed into the first byte.</p>
<pre><code class="language-text">02 65 6e 48 69
│  └─┬─┘ └─┬─┘
│   "en"  "Hi"
└─ status byte
</code></pre>
<p><strong>Read that first byte as a number.</strong> Here it's 2, and that's how long the language code is. So the next two bytes are the language: <code>65 6e</code>, "en". Everything after that is the text: <code>48 69</code>, "Hi".</p>
<p>The encoding hides in the same byte. If its value is 128 or more, the text is UTF-16 instead of UTF-8. That's all a "status byte" means.</p>
<p>That's the whole format. If you're writing the decoder yourself, mask off the low six bits for the length and test the top bit for the encoding.</p>
<h2 id="heading-why-i-wrote-my-own-decoder">Why I Wrote My Own Decoder</h2>
<p>You reach past a library for one of two reasons. Either it doesn't give you everything you need, or you work somewhere that builds this kind of thing in-house first and takes dependencies second.</p>
<p>The second is more common than the open-source default makes it sound, and for NDEF it has a short answer: yes, you can write this yourself. It's a few hundred lines of pure functions with no platform calls in them, and the format you just read is the whole specification you need.</p>
<p>The first reason is what actually happened here. The library bundles decoders, I read them before using them, and twenty minutes changed the architecture of the app.</p>
<p><strong>It throws away the language code.</strong> In <code>ndef-lib/ndef-text.js</code>:</p>
<pre><code class="language-js">var languageCodeLength = data[0] &amp; 0x3f; // 6 LSBs
// languageCode = data.slice(1, 1 + languageCodeLength),
// utf16 = (data[0] &amp; 0x80) !== 0; // assuming UTF-16BE

// TODO need to deal with UTF in the future
</code></pre>
<p>It measures the language code's length, uses it to skip past the code, and the line that would <em>keep</em> it is commented out. <code>decodePayload</code> returns a bare string, so a caller can't recover the language at all. "Which language is this in?" is precisely the question a record with a language field exists to answer.</p>
<p><strong>That</strong> <code>TODO</code> <strong>is load-bearing.</strong> UTF-16 text records are decoded as UTF-8 regardless, producing interleaved NUL characters.</p>
<p><strong>And the shared byte-to-string helper truncates.</strong> In <code>ndef-lib/util.js</code>:</p>
<pre><code class="language-js">str += String.fromCharCode(ch);
</code></pre>
<p><code>String.fromCharCode</code> only keeps the low 16 bits, and an emoji doesn't fit in 16 bits. So I fed it one:</p>
<pre><code class="language-text">bytes    : 68 69 20 f0 9f 98 80
expected : hi 😀
library  : "hi " codepoints: 68 69 20 f600     ← U+F600, Private Use Area
</code></pre>
<p>U+1F600 became U+F600, an invisible character. Three-byte sequences, such as Arabic and CJK, are fine, so the bug hides until someone uses an emoji.</p>
<p><strong>Its URI decoder, meanwhile, is eight lines and completely correct.</strong> That asymmetry is the interesting part. This isn't a bad library. It's a library with two stale corners. The only way to know which is which was to read it.</p>
<p>So I wrote the decoder and kept the library for the part that actually talks to the hardware. About 530 lines, all of it plain TypeScript with no React Native imports, which means it runs in Node and tests in milliseconds.</p>
<p>Its job is to turn a record into one of a few known shapes, and that's what this type describes:</p>
<pre><code class="language-ts">export type NdefView =
  | { kind: 'empty' }
  | { kind: 'uri'; uri: string }
  | { kind: 'text'; text: string; lang: string; encoding: TextEncodingName }
  | { kind: 'mime'; mime: string; text?: string; bytes: number[] }
  | { kind: 'aar'; packageName: string }
  | { kind: 'unknown'; tnf: number; type: string; payload: number[] };
</code></pre>
<p><strong>Why a union of shapes, rather than one object with lots of optional fields?</strong> Because with optional fields, every screen has to guess which ones are filled in. Here you check <code>kind</code> once and TypeScript knows the rest. On a record where <code>kind</code> is <code>'text'</code>, <code>.uri</code> doesn't exist. So a screen that tries to read it fails to compile instead of rendering <code>undefined</code> to somebody holding a phone.</p>
<p>The <code>unknown</code> case still carries the raw <code>tnf</code>, <code>type</code>, and <code>payload</code>, so a record nothing recognises can at least be shown as a hex dump. A reader that silently drops what it doesn't understand is worse than one that says "I don't know what this is, here are the bytes".</p>
<p>One platform difference is buried inside the decoder itself. A record's <code>type</code> field arrives as raw bytes on Android, and sometimes as an already-decoded string on iOS. So the same URI record turns up as <code>[85]</code> on one platform and <code>'U'</code> on the other, 85 being the byte for the letter U.</p>
<p>That matters because <code>[85] === 'U'</code> is just false. There's no error and no warning: your comparison quietly fails to match, and a perfectly good URI record falls through to "unknown record". Convert the type to a string before you compare it, on both platforms.</p>
<h3 id="heading-test-against-the-thing-you-replaced">Test Against the Thing You Replaced</h3>
<p>This is the technique I'd most like people to steal. The tests come in three groups.</p>
<p><strong>Correctness</strong>: the decoder against hand-built payloads.</p>
<p><strong>Agreement</strong>: where the library is <em>right</em>, prove we match it exactly. Twelve real URIs encoded by the library and decoded by us, plus all 36 prefix indices. That's the cheap way to be confident about a lookup table you typed out by hand.</p>
<p><strong>Divergence</strong> is the strange one. Where the library is wrong, write a test that locks in exactly how it's wrong:</p>
<pre><code class="language-text">✓ the library discards the language code; we keep it
✓ the library truncates 4-byte UTF-8; we do not
✓ both handle 3-byte sequences, so only characters above U+FFFF break
✓ the library ignores the UTF-16 flag; we honour it
</code></pre>
<p>That looks backwards. You're writing tests that pass <strong>because</strong> a dependency is broken, and normally that's a smell.</p>
<p>Two things earn them their place. Each one is a written record of why your own code exists, sitting next to the code instead of in a commit message nobody reads. And because they assert the library's current behaviour, <strong>they start failing the day somebody upstream fixes it.</strong></p>
<p>That failure is the whole point. It isn't a broken test, it's a notification: the reason you wrote your own decoder may have just gone away, and you can go and check. A comment saying "the library is buggy" rots quietly while the library changes around it. A test saying so can't.</p>
<p>One detail decides whether these tests are useful or useless. The emoji test asserts the <strong>exact</strong> wrong answer, the code point <code>0xf600</code>, rather than just "the library disagrees with us".</p>
<p>If it only checked for disagreement, a future version with a <em>different</em> bug would still pass, and you would never notice the behaviour had changed. Pinning the specific wrong value means any change at all shows up, whether upstream fixed the bug or replaced it with a new one.</p>
<blockquote>
<p><strong>Checkpoint:</strong> <code>git checkout step-2-decode</code>. Own decoder, a Tag Info screen showing every field and the raw bytes.</p>
</blockquote>
<h2 id="heading-how-to-write-a-tag">How to Write a Tag</h2>
<p>Now the other direction. Two options, and they're opposite trades rather than variations.</p>
<p><strong>A URL record</strong> is tiny, and every phone opens it with no app installed. It's what most commercial NFC business cards do. It also needs something at the other end: a domain that stays renewed, a server that stays up, or a network connection at the moment someone taps.</p>
<p><strong>A vCard</strong> is the whole card. <code>text/vcard</code> as a MIME record, no server, works on a plane. It's also much bigger.</p>
<p>I built both and let the user choose, because the numbers make the argument better than copy could.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/0f0eb78e-e265-4239-ab2c-4e8b0a9aa155.png" alt="The app's Write tab. Two buttons side by side, URL showing 17 bytes and vCard showing 184 bytes, under the heading &quot;Write to a tag&quot; and the line &quot;Writing replaces whatever the tag holds. It does not lock it.&quot;" style="display: block;" width="520" height="229" loading="lazy">

<p>Seventeen bytes against 184, for the same person's contact details. That gap is the entire trade, and it's why the choice sits in front of the user instead of being made for them.</p>
<h3 id="heading-how-to-encode-a-vcard">How to Encode a vCard</h3>
<p>A vCard is just text. This is what the app actually writes onto the tag:</p>
<pre><code class="language-text">BEGIN:VCARD
VERSION:3.0
N:Doe;Jane;;;
FN:Jane Doe
ORG:Example Ltd
TITLE:Full-stack engineer
TEL;TYPE=CELL:+15550100
EMAIL;TYPE=INTERNET:jane@example.com
URL:https://example.com
END:VCARD
</code></pre>
<p>One field per line: a name, a colon, a value, and a carriage return plus newline at the end of every one. Build that string and you have a working card.</p>
<p>The format is from the 1990s and it shows, though, and three of its rules are easy to get wrong. Your encoder has to handle all three.</p>
<h4 id="heading-1-fold-long-lines-by-octets-not-characters">1. Fold long lines by octets, not characters.</h4>
<p>Any line longer than 75 bytes has to be broken and continued on the next one, starting with a space. The obvious way to do that is <code>line.slice(0, 75)</code>, which counts characters rather than bytes, so it can cut a two-byte character in half and leave invalid UTF-8 on the tag. Walk the string by code point instead, tracking the byte cost as you go.</p>
<h4 id="heading-2-emit-n-and-be-up-front-that-youre-guessing">2. Emit <code>N</code>, and be up front that you're guessing.</h4>
<p>Look at that <code>N:</code> line. vCard 3.0 wants the name split into <code>Family;Given;Additional;Prefix;Suffix</code>, so emitting <code>FN</code> on its own isn't enough. Splitting a display name into those parts is guesswork, and the guess is wrong for Chinese and Hungarian names, for Spanish names with two surnames, and for anyone who goes by a single name. I take the last token, document that it's a guess, and let <code>FN</code>, which is what importers actually display, carry the name exactly as typed.</p>
<h4 id="heading-3-escape-every-semicolon-inside-a-value">3. Escape every semicolon inside a value.</h4>
<p>The semicolons on that <code>N:</code> line are structural. If someone's job title were "Engineer; Lagos", writing it straight through would turn one field into two and an importer would read the rest as part of the name instead. It has to reach the tag as <code>Engineer\; Lagos</code>.</p>
<p>Get those three right and the whole encoder is about 170 lines of pure string handling, testable without a tag anywhere near it.</p>
<h3 id="heading-the-escaping-bug-that-passed-its-tests">The Escaping Bug That Passed Its Tests</h3>
<p>That third rule is where my worst bug lived. My escaper had this line:</p>
<pre><code class="language-ts">.replace(/;/g, '\;')   // ← this escapes nothing
</code></pre>
<p>vCard wants a literal backslash in front of any semicolon inside a value. <code>'\;'</code> looks like it produces one. It doesn't. JavaScript has no <code>\;</code> escape sequence, so it quietly drops the backslash and hands back a plain <code>';'</code>. That line was replacing every semicolon with itself.</p>
<p>The fix is <code>'\\;'</code>, where the first backslash escapes the second.</p>
<p>Then it got worse. I wrote a test for the escaper and asserted the <strong>unescaped</strong> output, because I still believed the line worked. The test agreed with the bug, which means it would have failed against correct code.</p>
<p>Then a third test tried to prove a field was absent with <code>not.toContain('N:')</code>. That can never pass. Every vCard starts with <code>BEGIN:VCARD</code>, and <code>BEGIN:</code> ends in <code>N:</code>.</p>
<p>Three mistakes in ten minutes, all the same misunderstanding.</p>
<p><strong>Test-first wouldn't have saved me.</strong> The test and the code shared the assumption. String escaping is a domain where they usually do.</p>
<h3 id="heading-ask-the-tag-before-you-write-to-it">Ask the Tag Before You Write to It</h3>
<p>A write replaces what's on the chip. The order matters:</p>
<pre><code class="language-text">1. Query the tag:      is it writable, how big is it really?
2. Refuse early:       read-only, or genuinely too small
3. Write
4. Read it back:       compare with what you sent
</code></pre>
<p><strong>Step 2 is the safety property.</strong> A refusal <em>before</em> the write leaves the tag untouched, while a failure <em>during</em> one can leave it half-written.</p>
<p>Step 4 exists because a write that reports success and didn't happen is the worst outcome available. Read back inside the same session and compare <strong>record content, not raw bytes</strong>. A tag may legally return a message whose framing differs from what you sent while carrying identical data.</p>
<p>All four in <strong>one session</strong>, not four. On iOS each <code>requestTechnology</code> puts a system sheet in front of the user, so splitting them means four sheets and four taps for one logical action. Android wouldn't notice the difference, which is exactly the sort of thing that makes an iOS-shaped design look arbitrary until you see it on the other platform.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/1e64e1cc-dae2-47d7-bdb5-76c93e3ca096.png" alt="The app's write screen after a successful write. A byte breakdown reads NDEF message 27 bytes, Tagframing (TLV) 3 bytes, Total needed 27 bytes, Reported capacity 137 bytes. Below it a green cardtitled &quot;Written and verified&quot; reads &quot;27 bytes written. The tag read back exactly what was sent&quot;,followed by reported capacity: 137 bytes and ndef status:2." style="display: block;" width="500" height="367" loading="lazy">

<p>You have all four steps in one screen: the tag was asked, the numbers came back measured rather than assumed, the write went out, and the read-back confirmed it byte for byte.</p>
<blockquote>
<p><strong>Checkpoint:</strong> <code>git checkout step-3-write</code>. Profile editor, vCard encoding, writing with a capacity check.</p>
</blockquote>
<h2 id="heading-will-it-fit-137-not-144">Will It Fit? 137, Not 144</h2>
<p>Here's where the project taught me something.</p>
<p>An NTAG213 has <strong>144 bytes</strong> of user memory. That number is in every spec sheet: 36 pages of 4 bytes, pages 4 through 39. I built the capacity check around it.</p>
<p>A realistic vCard (name, title, company, phone, email, one link) comes to <strong>184 bytes</strong> of text and <strong>202 bytes</strong> once it's wrapped as an NDEF message, which is the figure on the toggle a few sections back.</p>
<p>So an ordinary business card <strong>doesn't fit on an ordinary tag</strong>. That's a genuine product constraint, not a bug, and the app has to say so rather than fail mysteriously.</p>
<p>Except my number was wrong. When I finally asked a real tag how big it was, it said <strong>137</strong>.</p>
<p>144 is the chip's <em>user memory</em>. The number that matters to a writer is the maximum NDEF <strong>message</strong>, which is smaller by the tag's own bookkeeping. I had been <strong>seven bytes too generous</strong>, in the direction that tells someone their card fits when it doesn't.</p>
<p>The error hid a second one. A tag doesn't store your message bare: it wraps it in a few bytes of its own bookkeeping, called <strong>TLV framing</strong>, for type, length and value. I'd been adding those bytes to the message <em>and</em> comparing against user memory. Double-counting. Every capacity figure in play (Android's <code>getMaxSize()</code>, iOS's status query, a sensible assumption) is <em>already</em> a message size with framing excluded.</p>
<h3 id="heading-the-bigger-mistake">The Bigger Mistake</h3>
<p>Worse than the number was what I'd concluded about the platform.</p>
<p>Because iOS's <code>getTag()</code> returns only <code>{ id, tech }</code>, I concluded <strong>iOS can't report tag capacity</strong>. I wrote that in the code, in the platform comparison, and in the app's own UI, and I planned a native module around closing the gap. Here it is, shipped:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/c3845917-23d2-4050-b76d-fa7937b19313.png" alt="A red error card in the app reading &quot;Too big for this tag. 184 bytes, assuming an NTAG213's 144.It is 40 bytes over, shorten the profile or write a URL instead.&quot; Below it, in italics, &quot;iOS doesnot report tag capacity, so this assumes an NTAG213. A larger tag will holdmore.&quot;" style="display: block;" width="560" height="193" loading="lazy">

<p>That italic line is the wrong conclusion, stated to the user as fact. Every number in the card above it rests on the guess it forced.</p>
<p>It's false. <code>ndefHandler.getNdefStatus()</code>, which is CoreNFC's <code>queryNDEFStatus</code>, returns both a read/write status and a real capacity, <strong>inside the session I was already opening</strong>. The capability was one call away from code that had been running for weeks.</p>
<p>Look at the shape of the mistake:</p>
<ul>
<li><p><strong>What I saw:</strong> one function, <code>getTag()</code>, hands back two fields and no size. True.</p>
</li>
<li><p><strong>What I decided:</strong> iOS can't tell you how many bytes fit on a tag. That doesn't follow.</p>
</li>
</ul>
<p>One function not answering a question doesn't mean the platform can't answer it. I tried one door, found it locked, and concluded there was no way into the building.</p>
<p>My rule for this project was that nothing gets written down as fact until I've seen it on a device. I followed it for the thing I measured. I forgot it for the thing I concluded from the measurement.</p>
<p>A conclusion you haven't checked is as dangerous as a number you haven't checked, and harder to catch, because it borrows the credibility of the real measurement sitting underneath it.</p>
<p>So the app now asks the tag, and only assumes when no tag has answered yet. When it assumes, it says so, every time:</p>
<pre><code class="language-text">Too big for this tag
202 bytes, assuming an NTAG213's 137. It is 65 bytes over.
Shorten the profile or write a link instead.

No tag has reported its size yet, so this assumes an NTAG213.
Tags state their real capacity when you write to them.
</code></pre>
<p>The word "assuming" appears whenever the number is assumed. It's repetitive by design: the moment the app stops saying it, a reader starts believing it measured something.</p>
<p>Here's the same card once a tag has answered:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/716bffb6-f7e0-4c24-966f-e93cae6e08b1.png" alt="A red error card reading &quot;Too big for this tag. 202 of the 137 bytes this tag reports. It is 65bytes over, shorten the profile or write a link instead.&quot;" style="display: block;" width="560" height="137" loading="lazy">

<p>"This tag reports" instead of "assuming an NTAG213's". Same card, same layout, and the one word that changes is the one that says whether the number came from a chip or from me.</p>
<h3 id="heading-a-bug-that-made-this-harder-to-find">A Bug That Made This Harder to Find</h3>
<p>My pre-flight check threw the <strong>library's own</strong> <code>TagSizeTooSmall</code> when a message wouldn't fit. So when a write failed, the error said <code>TagSizeTooSmall</code>, exactly what CoreNFC would have produced if <em>it</em> had rejected the write.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/09e44354-736b-4a85-bee3-f97bfdb20b62.png" alt="The app's write screen. A byte breakdown reads NDEF message 202 bytes, Tag framing (TLV) 3 bytes,Total needed 205 bytes, Assumed capacity 144 bytes. Below it a red card titled &quot;Too big for thistag&quot; expands a developer detail section showing NfcError.TagSizeTooSmall and message: &quot;&quot; (empty)." style="display: block;" width="480" height="501" loading="lazy">

<p>There are two things in that developer panel. <code>NfcError.TagSizeTooSmall</code> is the library's class, raised by my own code before the tag was ever consulted. And <code>message: "" (empty)</code> is the defect behind it: the class carried the entire meaning, and the string a screen would print carried none of it.</p>
<p>Our refusal and the tag's refusal were the same string. The one question I was trying to answer (did the platform report a capacity?) was the one the error had erased.</p>
<p><strong>Never throw a dependency's error type from your own logic.</strong> It collapses "we refused" and "they refused" into one signal, and you'll want to tell them apart precisely when something is going wrong.</p>
<p>With a dedicated error type carrying the tag's own numbers, the answer was immediate:</p>
<pre><code class="language-text">WritePreflightError: too-big
tag reported status 2, 137 bytes
needed 202 bytes
</code></pre>
<p>There it was. The platform had been reporting capacity all along.</p>
<p>Same screen, same chip, before and after:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/a39df4b8-8df2-48e5-b8ff-8a0be5848670.png" alt="The app's Tag Info screen before the fix. Capacity reads &quot;Not reported&quot;, with the explanation&quot;CoreNFC does not expose tag capacity&quot; and a note that a later phase will read it from the tag'scapability container instead." style="display: block;" width="500" height="385" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/3e9b42f9-fee3-4f2f-8951-7b9f54a2c092.png" alt="The app's Tag Info screen. Capacity reads 137 bytes, annotated &quot;Reported by the tag itself&quot;.Above it, NDEF type reads &quot;Not reported&quot; with the note &quot;CoreNFC does not report the NDEF type name&quot;.Below, Records 1, Writable Yes, and a chip identified as NTAG21x / MIFARE Ultralight with a 7-byteUID." style="display: block;" width="500" height="436" loading="lazy">

<p>Every word under "Capacity" in the first shot is mine, and the confident one is wrong. The blue line beneath it is worse: a whole phase of native work, scheduled to close a gap that wasn't there.</p>
<p>The second reads a number, annotated <em>reported by the tag itself</em>, and nothing was added to the platform in between. Note what didn't change, though. "NDEF type" still says "Not reported", because that one genuinely isn't available. One question the platform can't answer, sitting directly above one it always could, which is exactly why I believed the wrong thing for so long.</p>
<h2 id="heading-a-focus-session-you-cant-end-without-the-tag">A Focus Session You Can't End Without the Tag</h2>
<p>A business card is a fine demo, but it only exercises half of what tags are good for. The other half is using a chip as a <strong>physical condition</strong>: something that must be true in the real world before software will do a thing.</p>
<p>So the app has a second feature: a focus timer you can't stop without walking to the tag.</p>
<p>You leave the chip somewhere inconvenient. Downstairs, in a drawer, at the back of a cupboard. Tap it to start a session. To end the session, you have to go back to it.</p>
<p>This idea isn't mine. <a href="https://github.com/awaseem/foqos">Foqos</a> (open source, on the App Store), <a href="https://github.com/cajdata/TapBlok/">TapBlok</a>, nfcGuard, and Focusaur all converge on it, and they converge because the insight isn't about NFC at all:</p>
<p><strong>The friction is geography, not the gesture.</strong> Tapping costs a second. What costs you is that the tag is downstairs. The tag's <em>location</em> is the product. NFC is merely what makes a location enforceable.</p>
<p>Three rules follow, and each is a line of code.</p>
<h4 id="heading-1-one-tag-two-meanings">1. One tag, two meanings.</h4>
<p>The same tap starts a session when idle and ends one when focused. Two tags would be two objects to lose and two habits to build. All of the decision lives in one pure function:</p>
<pre><code class="language-ts">export function verdictForTap(
  scannedTagId: string,
  boundTagId: string | null,
  isFocused: boolean
): TapVerdict {
  if (!boundTagId) return { action: 'bind' };

  if (normaliseTagId(scannedTagId) !== normaliseTagId(boundTagId)) {
    return { action: 'wrong-tag', expected: boundTagId };
  }

  return isFocused ? { action: 'end' } : { action: 'start' };
}
</code></pre>
<p>That <code>wrong-tag</code> branch is the rule everything rests on. Accept any tag and the ritual becomes "own a sticker" rather than "go to the place where the sticker lives".</p>
<p>The normalising matters more than it looks. The same physical chip is reported as <code>04C4FC91DF2A81</code> by one read path and <code>04:c4:fc:91:df:2a:81</code> by another. Treating those as different tags is a maddening bug: the right chip, in your hand, silently refused.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/abeb82a9-da3a-40e6-a84e-56988bede20c.png" alt="The app's Focus tab during a session. A large counter reads &quot;5s focused&quot; above a green &quot;Tap tag tofinish&quot; button, with a quieter underlined link below reading &quot;I cannot reach mytag&quot;." style="display: block;" width="480" height="410" loading="lazy">

<p>The green button isn't a button that ends anything. It starts a scan, and the scan is what ends the session. Underneath it, deliberately quieter, is the way out for the airport.</p>
<h4 id="heading-2-the-timer-counts-up-never-down">2. The timer counts up, never down.</h4>
<p>A countdown invites you to wait it out on the sofa, since the session ends whether or not you did anything. Counting up measures what actually happened.</p>
<h4 id="heading-3-a-broken-session-is-recorded-not-prevented">3. A broken session is recorded, not prevented.</h4>
<p>And this is where the design gets interesting.</p>
<h3 id="heading-what-it-can-actually-enforce">What It Can Actually Enforce</h3>
<p>It can't block TikTok. Really blocking another app on iOS requires <code>com.apple.developer.family-controls</code>, a <strong>privileged entitlement</strong> that Apple reviews individually and grants only to apps whose core purpose is digital wellbeing or parental control, and it's required even for TestFlight. Foqos has it. If you're following this handbook, you likely will not, and promising otherwise would be a promise you couldn't keep.</p>
<p>So it enforces the one thing it actually can: <strong>the record</strong>.</p>
<p>There's an escape hatch, because a commitment device with no way out is one you uninstall the first time you're genuinely stuck at an airport. Taking it costs a permanent, visible mark: this session will be recorded as ended early. That mark is permanent and you'll see it every time you open this tab.</p>
<p>And the <strong>history is the vault</strong>, not notes or credentials, but the record itself, because that's the only thing in the app worth protecting from its own user. The protection is asymmetric:</p>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td>Reading the record</td>
<td>Always free. The point is that you look at it</td>
</tr>
<tr>
<td>Adding to it</td>
<td>Only by living through a session</td>
</tr>
<tr>
<td><strong>Clearing it</strong></td>
<td><strong>Requires the tag</strong></td>
</tr>
</tbody></table>
<p>A history you can wipe at 11pm from the sofa records nothing. Erasing it costs exactly what earning it did: the walk.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/fd78fb0a-2aa2-402e-8c6f-c91738a27c49.png" alt="The Focus tab between sessions. Three figures across the top read 1m Focused, 2 Streak, 0 Endedearly. Below, a list headed &quot;The record&quot; holds two entries, &quot;4s focused&quot; and &quot;1m focused&quot;, bothdated 13 Sep, above a collapsed control labelled &quot;Clear therecord&quot;." style="display: block;" width="480" height="471" loading="lazy">

<p>"Ended early" is a column whether or not you have any, which is the point. It sits next to the streak, permanently, so the cost of the escape hatch is visible before you ever use it. And "Clear the record" is the collapsed control at the bottom: the one action in the app that asks for the tag.</p>
<p>Here are two smaller decisions that turned out to matter. <strong>A running session survives a relaunch</strong>, because it's persisted, so force-quitting isn't a silent way out. The only exits are the tag and the hatch, and one leaves a mark.</p>
<p>And <strong>broken sessions still count toward total focused time</strong>, because you <em>were</em> focused until you weren't. Zeroing it would be punitive rather than accurate.</p>
<p>The streak, by contrast, is deliberately harsh: one broken session resets it to zero. A number that can't be lost isn't worth looking at.</p>
<h3 id="heading-two-react-bugs-the-compiler-caught">Two React Bugs the Compiler Caught</h3>
<p>Both are general React bugs rather than NFC ones, and neither was caught by me. The React Compiler ships lint rules through <a href="https://react.dev/reference/eslint-plugin-react-hooks"><code>eslint-plugin-react-hooks</code></a> that flag code it can't safely optimise, and two of them fired here. You don't have to adopt the compiler to get them.</p>
<pre><code class="language-ts">const [now, setNow] = useState(Date.now()); // ✗ impure during render
</code></pre>
<p>That's the <a href="https://react.dev/reference/eslint-plugin-react-hooks/lints/purity"><code>purity</code></a> rule, and it names this exact case: <code>Date.now()</code> is listed alongside <code>Math.random()</code> and <code>crypto.randomUUID()</code> as an API that "returns a different value for the same inputs". Reading the clock during render makes the component produce different output each time.</p>
<p>The obvious fix (setting it at the top of an effect) trips a second rule. <a href="https://react.dev/reference/eslint-plugin-react-hooks/lints/set-state-in-effect"><code>set-state-in-effect</code></a> puts it plainly: "Setting state immediately inside an effect forces React to restart the entire render cycle", producing "an extra render pass that could have been avoided".</p>
<p>The <code>purity</code> page's own fix is a lazy initialiser, <code>useState(() =&gt; Date.now())</code>, and for a clock that starts when the component mounts, that's the right answer. This timer doesn't start at mount, it starts when a session does, so a mount-time stamp would already be stale by then and <code>elapsed</code> would render negative for a frame.</p>
<p>Starting at <code>0</code> is both pure and a usable sentinel: falsy means "no tick yet", so the label shows nothing rather than nonsense.</p>
<p>The version that's both correct and clean defers by one task:</p>
<pre><code class="language-ts">useEffect(() =&gt; {
  if (!session) return;

  // Deferred rather than called straight away: a synchronous setState inside
  // an effect cascades a second render pass. A zero timeout hands it to the
  // next task, which updates just as promptly without the cascade.
  const first = setTimeout(() =&gt; setNow(Date.now()), 0);
  const id = setInterval(() =&gt; setNow(Date.now()), 1000);

  return () =&gt; {
    clearTimeout(first);
    clearInterval(id);
  };
}, [session]);
</code></pre>
<h2 id="heading-why-your-nfc-lock-isnt-secure">Why Your NFC Lock Isn't Secure</h2>
<p>The focus feature uses a tag's identifier as a key. So do most NFC "lock" apps. It's worth being blunt about what that is and isn't.</p>
<p>A tag's UID is world-readable and trivially copied. It's not a secret. It's a serial number, broadcast to anything that asks.</p>
<p>This is also what Apple's own Shortcuts automation keys on, as the first section noted, which tells you how the platform itself rates the guarantee: fine for "turn on my desk lamp", never offered as a credential. A £20 device clones one in seconds, and phones can emulate some of them outright. An access system built on "is this UID correct?" is theatre.</p>
<p>For the focus timer, that's completely fine, and worth saying: <strong>the threat model is you, being lazy, in your own house.</strong> Cloning your own tag to skip a walk is a level of effort that defeats the point of the exercise. Friction is the product, security isn't.</p>
<p>But if you're reaching for a tag as an actual credential (a door, a payment, or a device pairing), the UID is the wrong primitive and you need a chip that can do cryptography.</p>
<p>An <strong>NTAG424 DNA</strong> is the usual answer. Rather than presenting a static number, it computes a message authentication code over a counter that increments on every tap, using a key that never leaves the chip. Each tap produces a different, signed value, so a captured one is useless a second later. That's the difference between an identifier and an authenticator.</p>
<p>So here's what you take away: <strong>A UID answers "which tag is this?" and nothing more.</strong> If your security depends on the answer being unforgeable, you need a chip that signs, not one that announces.</p>
<h2 id="heading-four-gotchas-that-cost-me-an-evening-each">Four Gotchas That Cost Me an Evening Each</h2>
<p>Each of these looked like a bug in my code and was really a quirk of the platform.</p>
<h3 id="heading-gotcha-1-the-system-sheet-vanishes-with-no-error">Gotcha 1: The System Sheet Vanishes With No Error</h3>
<p>You start a scan. The sheet appears for a moment and disappears. No error, no rejection, and nothing in the console.</p>
<p>This happens because the CoreNFC session was <strong>deallocated</strong>. Swift frees an object as soon as nothing is holding a reference to it, which is what Automatic Reference Counting, or ARC, does. So if you keep the session in a local variable inside the function that started it, the variable dies when the function returns, and the session goes with it.</p>
<p>To fix this, retain the session on something that outlives the call. In an Expo module, a property:</p>
<pre><code class="language-swift">// Held for the life of the module, not the scan.
private var readSession: Any?
</code></pre>
<p>This is the single easiest way to get a CoreNFC integration subtly wrong, and nothing tells you.</p>
<h3 id="heading-gotcha-2-your-result-gets-overwritten-by-the-session-ending">Gotcha 2: Your Result Gets Overwritten by the Session Ending</h3>
<p>You read a tag successfully, resolve the promise, and JavaScript receives an error instead.</p>
<p>This happens because <strong>a successful read also invalidates the session</strong>, so <code>didInvalidateWithError</code> fires <em>after</em> your completion handler. If both paths settle the same promise, the later one wins.</p>
<p>To fix this, guard settling so it happens exactly once. One lock, one place:</p>
<pre><code class="language-swift">private func settle(resolving value: [String: Any]) {
  lock.lock()
  defer { lock.unlock() }

  guard let promise else { return }   // already settled: do nothing
  self.promise = nil
  promise.resolve(value)
}
</code></pre>
<p>With four nested asynchronous steps and five failure paths, this has to be enforced rather than assumed.</p>
<h3 id="heading-gotcha-3-missing-required-entitlement-for-an-entitlement-you-have">Gotcha 3: <code>Missing required entitlement</code> for an Entitlement You Have</h3>
<p>You have <code>NDEF</code> and <code>TAG</code> in your entitlements and the session still refuses to start.</p>
<p>This happens because of <strong>polling options</strong>, the list of radio standards you tell CoreNFC to listen for when you open a session. Each one is gated by its own entitlement. I asked for <code>[.iso14443, .iso15693, .iso18092]</code>, and that last one is FeliCa, a standard used mostly in Japan, which additionally requires <code>com.apple.developer.nfc.readersession.felica.systemcodes</code>. Asking for it without that key fails <strong>the entire session</strong>, not just that polling mode, and the error never says "FeliCa".</p>
<p>To fix this, ask only for what you can sign for:</p>
<pre><code class="language-swift">pollingOption: [.iso14443, .iso15693],
</code></pre>
<p><code>.iso14443</code> covers NTAG and MIFARE, which is everything a tag project needs. I'd added the third "so unexpected tags produce better errors". It produced a session that couldn't start.</p>
<p>This is the same lesson as the wildcard-profile error from earlier, arriving from the opposite direction: there, a <em>missing</em> entitlement surfaced as a code-signing failure. Here, an entitlement I never needed surfaced as a runtime one.</p>
<h3 id="heading-gotcha-4-cannot-find-yourclass-in-scope-for-a-file-that-exists">Gotcha 4: <code>cannot find 'YourClass' in scope</code> for a File That Exists</h3>
<p>You add a Swift file to a local Expo module, build, and the compiler insists the class doesn't exist.</p>
<p>This happens because of how iOS dependencies are wired up. CocoaPods is the package manager that builds them, and each one ships a <strong>podspec</strong>, a small file listing which sources belong to it. Yours says "every <code>.swift</code> file in this folder", written as <code>**/*.swift</code>.</p>
<p>The catch: <strong>CocoaPods expands that pattern once, when</strong> <code>pod install</code> <strong>runs.</strong> It's a list, not a live rule. A file you create afterwards isn't in the Xcode build at all, so the compiler is telling the truth. As far as it knows, your class doesn't exist.</p>
<p>To fix this, remember the rule and stop losing ten minutes to it each time:</p>
<blockquote>
<p>Adding a <strong>function</strong> to an existing file → rebuild. Adding a <strong>file</strong> → <code>pod install</code>, <em>then</em> rebuild.</p>
</blockquote>
<p>The error message mentions neither.</p>
<h2 id="heading-why-you-cant-build-tap-to-pay">Why You Can't Build Tap to Pay</h2>
<p>This is where the gap between the two platforms stops being a matter of degree. Know it before you promise anything to a product manager.</p>
<p>"Tap to pay" means two different things.</p>
<p>First, there's <strong>paying with your phone</strong>: Apple Pay, or a bank card in Google Wallet. On iOS the Secure Element is closed. There's no third-party API. Not restricted, not entitled: absent.</p>
<p>Second, there's <strong>accepting payments on your phone</strong>: <em>Tap to Pay on iPhone</em>, or the <code>ProximityReader</code> framework. This exists, and you still can't just build it:</p>
<ul>
<li><p>An <strong>organization-level</strong> developer account, requested as the <strong>Account Holder</strong></p>
</li>
<li><p><strong>Separate TEST and LIVE entitlements</strong>, applied for individually</p>
</li>
<li><p>Apple reviews against predefined criteria, and LIVE approval can take <strong>weeks</strong></p>
</li>
<li><p>You must integrate through an approved payment service provider: Stripe, Adyen, or Square</p>
</li>
</ul>
<p>Now Android. <code>HostApduService</code> lets <strong>any app emulate a card.</strong> No approval, no entitlement, and no PSP. Implement <code>processCommandApdu()</code>, register your <strong>AID</strong>, and you're done. An AID is an Application Identifier, the number a payment terminal asks for to decide which app on the phone should answer.</p>
<p>There's one real gate: AIDs registered under <code>CATEGORY_PAYMENT</code> only work when your app is the default wallet (the Wallet role holder on Android 15+) or is foregrounded and calls <code>setPreferredService</code>. But <code>CATEGORY_OTHER</code>, which covers closed-loop cards, loyalty, access control and stored value, is <strong>open and always active</strong>.</p>
<table>
<thead>
<tr>
<th></th>
<th>iOS</th>
<th>Android</th>
</tr>
</thead>
<tbody><tr>
<td>Emulate a card</td>
<td><strong>Impossible</strong></td>
<td><code>HostApduService</code>, no approval</td>
</tr>
<tr>
<td>Payment AIDs</td>
<td>N/A</td>
<td>Default-wallet gated</td>
</tr>
<tr>
<td>Non-payment AIDs</td>
<td>N/A</td>
<td><strong>Open</strong></td>
</tr>
<tr>
<td>Accept payments</td>
<td>Entitlement + PSP + weeks</td>
<td>Open</td>
</tr>
</tbody></table>
<p>Unlike every other difference in this handbook, this isn't a difference of degree or of shape. <strong>One platform simply doesn't offer the capability.</strong> If your product plan involves a phone pretending to be a card, that plan is Android-only, and it's better to learn it now than after a sprint.</p>
<h2 id="heading-why-you-might-write-your-own-native-module">Why You Might Write Your Own Native Module</h2>
<p>Up to this point everything has run on <code>react-native-nfc-manager</code>. The last third of this project replaced it, and the reason changed partway through.</p>
<p>The original justification was the capacity gap: <em>iOS can't report capacity, so we need our own native code to read it.</em> That argument evaporated, as you've seen. It was an inference, not an observation.</p>
<p>So rather than quietly keeping the work with its motivation gone, here's the real version: <strong>some teams can't take a third-party dependency.</strong> Internal-only policies, audit requirements, or simply a package they can't get a fix merged into on any useful timescale. "How would I build this myself?" is worth answering on its own terms.</p>
<p>And for <em>this</em> dependency specifically, the evidence was already gathered, by reading it:</p>
<table>
<thead>
<tr>
<th>Finding</th>
<th>Where</th>
</tr>
</thead>
<tbody><tr>
<td>Text decoder discards the language code it just measured</td>
<td><code>ndef-lib/ndef-text.js</code></td>
</tr>
<tr>
<td>UTF-16 flag ignored, an open <code>TODO</code></td>
<td><code>ndef-lib/ndef-text.js</code></td>
</tr>
<tr>
<td><code>String.fromCharCode</code> truncates above U+FFFF</td>
<td><code>ndef-lib/util.js</code></td>
</tr>
<tr>
<td><code>index.d.ts</code> is invalid TypeScript, compiles only because <code>skipLibCheck</code> is on</td>
<td><code>index.d.ts</code></td>
</tr>
<tr>
<td>Package root throws outside a native runtime</td>
<td><code>src/NativeNfcManager.js</code></td>
</tr>
<tr>
<td>Every error class carries an empty <code>message</code></td>
<td><code>src/NfcError.js</code></td>
</tr>
</tbody></table>
<p>That last one caused a real bug in Phase 1: a failed scan rendered <strong>nothing at all</strong>, because the screen did <code>setError(err.message)</code>, the message was <code>''</code>, and React treats an empty string as falsy. The meaning lived only in the class.</p>
<p>None of these are fixable from JavaScript.</p>
<h3 id="heading-the-scaffolding-is-no-longer-the-hard-part">The Scaffolding Is No Longer the Hard Part</h3>
<pre><code class="language-bash">npx create-expo-module@latest --local --name NfcNative \
  --package com.you.nfcnative -p apple android --features Function
</code></pre>
<p>There are four files, wired into the build automatically by <code>pod install</code>. No Xcode project surgery, <code>RCT_EXPORT_METHOD</code> macros, or hand-written JSI, the C++ layer that lets JavaScript call native code directly. If you last wrote a React Native native module in the bridge era, that's the headline.</p>
<p>There are two snags, and neither are documented: <code>--name</code> sets the <em>native module</em> name, while the <strong>directory</strong> comes from a positional path argument (so without one it lands in <code>modules/my-module</code>). And the generated podspec declares an iOS deployment target that can silently raise your app's minimum.</p>
<h3 id="heading-typed-errors-that-actually-carry-a-message">Typed Errors That Actually Carry a Message</h3>
<pre><code class="language-swift">internal final class NoNfcSettingsException: Exception {
  override var reason: String {
    "iOS has no NFC setting to open. NFC is available whenever the hardware supports it."
  }
}
</code></pre>
<p>Expo's <code>Exception</code> gives each one a code <em>and</em> a message, both readable from JavaScript. That's the direct fix for the defect behind the silent failure above.</p>
<p>And here's the part that surprised me: <strong>owning the native side doesn't exempt you from error plumbing.</strong> Expo wraps your exception:</p>
<pre><code class="language-text">FunctionCallException: Calling the 'openNfcSettings' function has failed
  → Caused by: NoNfcSettingsException: iOS has no NFC setting to open. …
</code></pre>
<p><code>err.message</code> is now the <em>framework</em> describing its own plumbing. Your sentence is at the end of the cause chain. This is the mirror image of the library's problem. There the message was empty, here it's buried, and both break the same reflex.</p>
<p>Here's that chain rendered in the app, exactly as JavaScript received it:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/6952f351-8efb-41d9-a983-af13129575a2.png" alt="The app's developer panel showing a purple error block: &quot;Error: FunctionCallException: Calling the'openNfcSettings' function has failed (at ExpoModulesCore/AsyncFunctionDefinition.swift:123) →Caused by: NoNfcSettingsException: iOS has no NFC setting to open. NFC is available whenever thehardware supports it. (at NfcNative/NfcNativeModule.swift:58)&quot;" style="display: block;" width="560" height="230" loading="lazy">

<p>We have three lines of framework before a single word a person could use. The fix isn't in Swift, it's in the screen: walk the cause chain, show the deepest message first, and put the rest behind a disclosure for whoever actually wants it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/1a9b90a4-7d80-46db-a63f-34db085f99ef.png" alt="The same panel after the fix, showing only &quot;iOS has no NFC setting to open. NFC is availablewhenever the hardware supports it. (at NfcNative/NfcNativeModule.swift:58)&quot; above a collapsedsection labelled RAW ERROR CHAIN." style="display: block;" width="560" height="157" loading="lazy">

<p>Same failure, same information, with nothing thrown away. The sentence written in Swift is the one that leads, and <code>RAW ERROR CHAIN</code> is one tap away when you need it.</p>
<p>Owning the native side changes which layer surprises you.</p>
<h3 id="heading-the-bug-272-passing-tests-could-not-catch">The Bug 272 Passing Tests Could Not Catch</h3>
<p>After switching the app over, cancelling a scan rendered a red "Could not read the tag" card instead of nothing.</p>
<p>Our Swift throws <code>UserCancelledException</code>. The mapping table was keyed on <code>UserCancelledException</code>. They don't match, because <strong>Expo derives the code</strong>: strip the trailing <code>Exception</code>, split camelCase, upper-case, prefix <code>ERR_</code>:</p>
<pre><code class="language-text">UserCancelledException  →  ERR_USER_CANCELLED
</code></pre>
<p>Why every test passed:</p>
<pre><code class="language-ts">const wrapped = (code, message) =&gt;
  new Error(`Calling the 'readTag' function has failed → Caused by: ${code}: ${message}`);
</code></pre>
<p>There's no <code>code</code> property, because I didn't know Expo set one. <strong>The code and its tests shared a single wrong assumption and agreed with each other perfectly.</strong></p>
<p>A fixture you invented can only prove your code is self-consistent. It took a thumb on a Cancel button.</p>
<p>The fix keeps the table keyed on the Swift class names and <em>derives</em> the <code>ERR_</code> forms, so there's one source of truth instead of two lists that drift. The regression is now pinned by a test using the verbatim device error, <code>code</code> property and all.</p>
<blockquote>
<p><strong>Checkpoint:</strong> <code>git checkout step-4-own-module</code>. The module alongside the library.</p>
</blockquote>
<h2 id="heading-how-to-prove-parity-before-you-switch">How to Prove Parity Before You Switch</h2>
<p>Don't swap implementations on faith. Read the same physical tag through both and diff the results.</p>
<p>The design decision that made this useful: <strong>"different" isn't one outcome.</strong> Our read reports capacity and writability that the library's read path doesn't carry, and scoring that as a mismatch would be actively misleading.</p>
<table>
<thead>
<tr>
<th>Status</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>same</code></td>
<td>Both reported it, they agree</td>
</tr>
<tr>
<td><code>differs</code></td>
<td>Both reported it, they disagree. <strong>The only bad one</strong></td>
</tr>
<tr>
<td><code>native-only</code></td>
<td>Ours knows more. <em>The reason to switch</em></td>
</tr>
<tr>
<td><code>library-only</code></td>
<td>We lost something. <strong>Also blocks the swap</strong></td>
</tr>
<tr>
<td><code>neither</code></td>
<td>Nothing to conclude</td>
</tr>
</tbody></table>
<p><code>library-only</code> blocking the swap matters: losing information is a real problem even though it isn't a contradiction.</p>
<p>The result on a real NTAG213: <strong>four fields are identical, two are reported only by our module, with zero conflicts.</strong> That's the evidence the switch was made on, rather than a feeling that the new code looked right.</p>
<h2 id="heading-what-a-config-plugin-was-doing-for-you">What a Config Plugin Was Doing For You</h2>
<p>The riskiest part of deleting the dependency wasn't code.</p>
<p><code>react-native-nfc-manager</code> ships a <strong>config plugin</strong>, and that plugin was generating the iOS NFC entitlement and <code>NFCReaderUsageDescription</code>. Remove the package and both vanish, and <strong>the app loses NFC with no error at all.</strong> Nothing fails at build time. The sheet simply never appears again.</p>
<p>So the order was: declare them yourself in <code>app.json</code>, prebuild, verify the output is <strong>byte-identical</strong> to what the plugin produced, <em>then</em> remove the plugin, verify again, <em>then</em> remove the package.</p>
<p>Removing a dependency means inheriting its build configuration. The code it exports is the visible half.</p>
<h3 id="heading-keep-the-evidence-after-deleting-the-dependency">Keep the Evidence After Deleting the Dependency</h3>
<p>The argument for replacing that library rests on defects in a package that no longer exists. Delete it and the tests proving those defects stop running, and the <em>agreement</em> tests go too, leaving a hand-typed 36-entry lookup table unverified.</p>
<p>So the repo keeps a frozen copy under <code>vendor/</code>, with its MIT licence, imported by nothing but one test file, and excluded from ESLint and Prettier, because its value is being wrong in documented ways, and reformatting it would destroy the thing it demonstrates.</p>
<pre><code class="language-text">✓ every error class is constructed with an empty message
✓ turns U+1F600 into U+F600, a Private Use Area character
✓ discards the language code it just measured
✓ ignores the UTF-16 flag in the status byte
</code></pre>
<p>If a future version fixes any of that, these fail, which is the signal to reconsider, not a nuisance.</p>
<blockquote>
<p><strong>Checkpoint:</strong> <code>git checkout step-5-no-dependency</code></p>
</blockquote>
<h2 id="heading-how-to-lock-a-tag-forever">How to Lock a Tag Forever</h2>
<p>One operation in this whole field can't be undone. It's the only place where getting the interaction design wrong destroys something physical, which is why it comes last.</p>
<p><code>NFCNDEFTag.writeLock</code> on iOS, <code>Ndef.makeReadOnly()</code> on Android. Both burn the chip's lock bits. The tag can be read forever and never written again: not by your app, or any other app, or any phone. There's no undo and no factory reset.</p>
<p>Real deployments do this constantly. An event badge, a product-authentication seal, or a museum label: anything handed to the public gets locked, because a tag you can rewrite is a tag anyone can rewrite.</p>
<h3 id="heading-the-gate-is-the-feature">The Gate Is the Feature</h3>
<p>The native call is four lines. Everything interesting happens before it.</p>
<p>Two taps guards a write in this app, and that's right for a write: a write is reversible, you simply write something else. It's plainly not enough here. So the gate borrows the pattern GitHub uses for deleting a repository: <strong>type the thing's name to prove you know which thing you're destroying.</strong></p>
<p>The <code>status</code> it takes is the tag's own NDEF state, the same value the write path asks for: not NDEF at all, read-write, or already read-only.</p>
<pre><code class="language-ts">export function lockGate(tagId, status, typed): LockGate {
  if (!tagId) return { state: 'no-tag' };
  if (status === NDEF_STATUS.READ_ONLY) return { state: 'already-locked' };
  if (status === NDEF_STATUS.NOT_SUPPORTED) return { state: 'not-lockable' };

  return normaliseTagId(typed) === normaliseTagId(tagId)
    ? { state: 'armed', tagId }
    : { state: 'needs-confirmation', expected: tagId };
}
</code></pre>
<p>A confirmation dialog measures willingness. Typing the identifier measures <strong>attention</strong>. The failure mode worth designing against isn't someone who wants to lock a tag. It's someone who wants to lock a tag and is holding the wrong one.</p>
<p>Which is also why the screen won't arm until you've <strong>read the tag first</strong>, and shows what's currently on it. An unintended chip announces itself before it can be spent.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/41953d11-0ac3-42ec-be20-a7205b856dd6.png" alt="The app's Lock a tag screen. A red card headed &quot;This cannot be undone&quot; explains that the tag cannever be written again by any app or any phone. Below it the tag's details, Identifier04C4FC91DF2A81 and Status Writable, then a field headed &quot;Type the identifier to continue&quot; with theidentifier typed into it, and a red &quot;Lock this tag forever&quot; button." style="display: block;" width="500" height="761" loading="lazy">

<p>The identifier is on screen and also has to be typed. That looks redundant until you picture the failure it's for: the right person, the wrong chip.</p>
<p>Note the order in that function: <code>already-locked</code> is checked <em>before</em> the typed confirmation, so nobody can type their way into "locking" an already-locked tag and be told it worked. It didn't work. There was nothing to do.</p>
<h3 id="heading-nothing-to-do-is-not-something-went-wrong">"Nothing to Do" Is Not "Something Went Wrong"</h3>
<p><code>AlreadyLockedException</code> is deliberately distinct from <code>LockFailedException</code>, and the UI renders it <strong>green</strong>, with no lock button. The tag is in exactly the state you asked for. Reporting that as a failure would alarm someone about a perfectly good chip.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/3c4efe39-77c7-4aff-9eae-0ce0fbf70bb1.png" alt="The same screen after locking. Status reads &quot;Locked, read-only, permanently&quot;, and a green cardheaded &quot;Already locked&quot; reads &quot;This tag is permanently read-only. Nothing to do, and nothing was changed.&quot;" style="display: block;" width="520" height="281" loading="lazy">

<p>"Nothing to do, and nothing was changed" is doing real work in that sentence. It tells you the app didn't try, which is the part you want to know about an operation that can't be repeated.</p>
<p>This is the same principle as never throwing a dependency's error type from your own logic: two different situations must not arrive as one signal, and the moment you want them apart is the moment something is going wrong.</p>
<h3 id="heading-verify-because-you-cant-retry-to-find-out">Verify, Because You Can't Retry to Find Out</h3>
<p>Both implementations re-read the status afterwards. That check exists in the write path too, but it carries different weight here:</p>
<p>A failed write is recoverable: write again. A lock that reports success and didn't happen sends a tag into the world believing it's protected, and you can't retry to find out, because <strong>retrying is itself the destructive act.</strong></p>
<p>So an unverified lock isn't reported as a soft warning the way an unverified write is. The copy says the chip's state is unknown and to read it before relying on it.</p>
<p>The strongest confirmation comes later, from the tag rather than the app. Try to write to it again and CoreNFC itself refuses, in the system sheet, before your code gets a say:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/541fc3c0-a92a-45e1-8c0e-31e38c7663e2.png" alt="541fc3c0-a92a-45e1-8c0e-31e38c7663e2" style="display: block;" width="480" height="136" loading="lazy">

<p>That sentence is written by iOS, not by this app. It's the only proof that really counts, and the one you can never collect twice.</p>
<h3 id="heading-a-separate-class-not-a-flag">A Separate Class, Not a Flag</h3>
<p><code>NfcLockSession</code> duplicates a fair amount of the write session: setup, delegate, and settle-once discipline. That's deliberate.</p>
<p>The alternative was a <code>lock: Bool</code> on the write session. <strong>A boolean parameter that sometimes destroys the tag is exactly what gets passed by accident in a refactor three months later</strong>, by someone who has never read the file. Two call sites, two intentions, and no shared branch reachable from the wrong place.</p>
<p>Sometimes the right response to "this is nearly the same code" is to let it be nearly the same code.</p>
<h3 id="heading-and-one-test-that-was-right-while-the-code-was-wrong">And One Test That Was Right While the Code Was Wrong</h3>
<pre><code class="language-ts">it('refuses a tag that is not NDEF at all', () =&gt; {
  expect(lockGate(TAG, NDEF_STATUS.NOT_SUPPORTED, TAG).state).toBe('armed');
});
</code></pre>
<p>The name says <em>refuses</em>. The assertion says <em>armed</em>. It passed, because the code did arm, and <code>writeLock</code> is a method on <code>NFCNDEFTag</code>, so a non-NDEF chip should never have been offered it.</p>
<p>The name was right and the code was wrong. It was caught by reading a <strong>passing</strong> test's name next to its assertion, which is the only way it could have been caught: a test that agrees with the wrong code is invisible to a test run.</p>
<h2 id="heading-how-would-you-actually-ship-this">How Would You Actually Ship This?</h2>
<p>Everything above was built locally with <code>expo run:ios</code>, Xcode, CocoaPods, and twice, a completely full disk. The alternative is EAS Build, and it deserves an real comparison rather than a recommendation.</p>
<p>Everything below was run: one production build on EAS, plus a deliberately rigged experiment to test the headline claim rather than assume it. The only thing I have <em>not</em> done is install the resulting <code>.ipa</code>. It's an App Store build, so it can't be side-loaded, and every NFC claim in this handbook still rests on locally-built binaries on a real phone.</p>
<h3 id="heading-the-step-eas-would-have-saved">The Step EAS Would Have Saved</h3>
<p>The worst afternoon of this project was this error:</p>
<pre><code class="language-text">Provisioning Profile "iOS Team Provisioning Profile: *" does not support
the NFC Tag Reading capability.
</code></pre>
<p>The fix was manual and undiscoverable from the message: register an explicit App ID in Apple's portal, tick <strong>NFC Tag Reading</strong>, and regenerate the profile. Nothing in the error suggests opening a browser.</p>
<p><strong>EAS does that step automatically.</strong> If a supported entitlement is in your entitlements file, <code>eas build</code> enables the matching capability on the Apple Developer Console and skips it if it's already on. And <code>com.apple.developer.nfc.readersession.formats</code>, exactly the key this app declares, is on the supported list by name.</p>
<p>So the single most painful manual step in the whole handbook is automated by the thing I didn't use.</p>
<h3 id="heading-how-to-prove-it-rather-than-believe-it">How to Prove It Rather Than Believe It</h3>
<p>Running <code>eas build</code> against TapCard's real bundle identifier can't demonstrate this, and it's worth understanding why before trusting anyone's screenshot of it working:</p>
<pre><code class="language-text">✔ Bundle identifier registered com.nfccard.tap
✔ Synced capabilities: No updates
</code></pre>
<p><code>No updates</code>, because that App ID already had NFC Tag Reading enabled. I enabled it by hand, in a browser, in the chapter this section is about. All this proves is that EAS agrees with work I already did. Plenty of "EAS handles it for you" claims rest on exactly this output.</p>
<p>The real test needs a bundle identifier that has never existed. So: a throwaway project, <code>com.nfccard.tap.eastest</code>, containing essentially nothing but the entitlement, deliberately kept separate from the real app rather than temporarily renaming its bundle ID, which risks muddling stored credentials.</p>
<pre><code class="language-text">✔ Bundle identifier registered com.nfccard.tap.eastest
✔ Synced capabilities: Enabled: NFC Tag Reading
</code></pre>
<p><strong>That's the claim, observed.</strong> An App ID that didn't exist, registered and given the NFC capability from the entitlements file alone, with no browser and no portal. And because the sync runs at the credentials step, that command was <code>eas credentials:configure-build</code>, and it consumed no build.</p>
<p>One practical note if you repeat this: run the <strong>real</strong> build first. Distribution certificates are account-wide and Apple limits how many you may hold, so doing the throwaway first would burn one on an app you intend to delete. Done in that order, the scratch app offered to reuse the real certificate and only created its own provisioning profile, which is per bundle ID.</p>
<h3 id="heading-what-the-cli-actually-does">What the CLI Actually Does</h3>
<p>The mapping isn't magic and it's not buried. It's a lookup table, and NFC has an entry in it (<code>eas-cli/build/credentials/ios/appstore/capabilityList.js</code>):</p>
<pre><code class="language-js">{
  name: 'NFC Tag Reading',
  entitlement: 'com.apple.developer.nfc.readersession.formats',
  capability: CapabilityType.NFC_TAG_READING,
  // Technically it seems only `TAG` is allowed, but many apps and packages tell users to add `NDEF` as well.
  validateOptions: createValidateStringArrayOptions(['NDEF', 'TAG']),
  getSyncOperation: getDefinedValueSyncOperation,
}
</code></pre>
<p>Read that comment again, because it is quietly about you: <code>NDEF</code> <strong>may not be a real value.</strong> Every tutorial tells you to write <code>["NDEF", "TAG"]</code>, this handbook's own <code>app.json</code> writes <code>["NDEF", "TAG"]</code>, and the person maintaining the mapping table clearly suspects only <code>TAG</code> means anything, but accepts both, because refusing the pair would break everybody. That's what a convention looks like when nobody checks the spec.</p>
<p><code>getDefinedValueSyncOperation</code> is the other half. The operation keys off whether the entitlement is <em>defined</em>, which is why the sync runs in both directions rather than only adding things.</p>
<p>And one practical detail the documentation doesn't tell you: the sync is triggered by the <strong>credentials</strong> step, not the build step.</p>
<pre><code class="language-text">SetUpTargetBuildCredentials.runAsync()
  └─ ensureBundleIdExistsAsync({ entitlements, … })
       └─ syncCapabilitiesAsync()
            → "Synced capabilities: Enabled: NFC Tag Reading"    (or "No updates")
</code></pre>
<p>So <code>eas credentials:configure-build --platform ios</code> registers the App ID and syncs the capabilities <strong>without consuming a build</strong>. If all you want is Apple's side of the setup done correctly, you don't have to pay for a build to get it.</p>
<h3 id="heading-the-trap-that-comes-with-it">The Trap That Comes With It</h3>
<p>The sync runs both ways: if a capability is enabled for your app remotely, but not present in the native entitlements file, running <code>eas build</code> will automatically <strong>disable</strong> it.</p>
<p>A team that manages capabilities by hand in the portal <em>and</em> builds with EAS will watch EAS switch things off. The entitlements file becomes the source of truth whether you meant it to or not.</p>
<p><code>EXPO_NO_CAPABILITY_SYNC=1</code> opts out, with the caveat that opting out means remote changes stop syncing, which produces provisioning-profile mismatches later. Pick one owner for capabilities and let it own them.</p>
<h3 id="heading-what-eas-would-not-have-helped-with">What EAS Would Not Have Helped With</h3>
<p>"Use EAS" isn't an answer to most of this project's pain, and pretending otherwise would be selling something:</p>
<table>
<thead>
<tr>
<th>Problem</th>
<th>Would EAS have helped?</th>
</tr>
</thead>
<tbody><tr>
<td>NFC Tag Reading capability on the App ID</td>
<td>✅ automated</td>
</tr>
<tr>
<td>A full disk, twice</td>
<td>✅ builds happen elsewhere</td>
</tr>
<tr>
<td>A Gradle daemon holding memory after the build</td>
<td>✅ nothing runs locally</td>
</tr>
<tr>
<td><code>pod install</code> needed after adding a Swift file</td>
<td>✅ every build is clean</td>
</tr>
<tr>
<td>The deallocated CoreNFC session</td>
<td>❌ a code bug</td>
</tr>
<tr>
<td>Settling a promise twice</td>
<td>❌ a code bug</td>
</tr>
<tr>
<td>The FeliCa polling entitlement</td>
<td>❌ not a capability, a key EAS doesn't manage</td>
</tr>
<tr>
<td><code>String.fromCharCode</code> truncating an emoji</td>
<td>❌ a dependency bug</td>
</tr>
<tr>
<td>Expo deriving <code>ERR_USER_CANCELLED</code></td>
<td>❌ a wrong assumption</td>
</tr>
<tr>
<td>Reading a tag at all</td>
<td>❌ there is no cloud substitute for a chip</td>
</tr>
</tbody></table>
<p><strong>EAS removes machine problems, not NFC problems.</strong> Every finding in this handbook that was actually about NFC would have happened identically.</p>
<h3 id="heading-the-trade">The Trade</h3>
<p>Local builds cost disk, memory, and setup. This project filled a 460 GB disk twice, had three background processes killed under memory pressure, and lost a rebuild to a stale Gradle daemon.</p>
<p>EAS costs queue time and a cloud project, and puts your signing credentials on Expo's servers. What it can't shorten is the loop that actually matters here: <strong>you still have to walk to a phone and hold a chip against it.</strong> A cloud build that goes green tells you nothing about whether the tag read.</p>
<p>For a solo project with a working local toolchain, local wins on iteration speed. For a team, a CI pipeline, or anyone who has just watched their disk hit 100% mid-build, the capability sync alone is a strong argument.</p>
<h2 id="heading-a-sneak-peek-at-the-android-side">A Sneak Peek at the Android Side</h2>
<p>Android deserves its own handbook, and it's getting one. This section is the preview for anyone who doesn't want to wait: the Kotlin counterpart to the Swift above, and the architectural differences that make NFC on the two platforms genuinely different jobs rather than the same job twice.</p>
<p>Read it as a design preview rather than a verified implementation. The Kotlin compiles against the documented API and mirrors Swift that <em>is</em> verified on hardware, so the shapes are right, and the full treatment with a device in hand is the handbook that follows this one.</p>
<p>The differences that matter are visible from the API surface rather than from a device, and they're the ones that decide how you structure the code.</p>
<h3 id="heading-android-has-no-session">Android Has No Session</h3>
<p>iOS hands you a session, and the session is the model: you begin it, the OS draws a sheet, it hands you a tag, and it invalidates itself. Android gives you <strong>reader mode</strong>: a callback bound to your foreground Activity that fires whenever a tag comes near.</p>
<pre><code class="language-kotlin">adapter.enableReaderMode(activity, ::onTagDiscovered, flags, Bundle())
</code></pre>
<p>Everything the iOS session did for you becomes yours:</p>
<table>
<thead>
<tr>
<th></th>
<th>iOS</th>
<th>Android</th>
</tr>
</thead>
<tbody><tr>
<td>Scanning UI</td>
<td>The OS draws a sheet</td>
<td><strong>The app draws everything</strong></td>
</tr>
<tr>
<td>Session end</td>
<td>Automatic after one tag</td>
<td><code>disableReaderMode</code> <strong>on every exit path</strong></td>
</tr>
<tr>
<td>Needs</td>
<td>Nothing on screen</td>
<td><strong>The foreground Activity</strong>, not a Context</td>
</tr>
<tr>
<td>Callback thread</td>
<td>Main</td>
<td><strong>A binder thread</strong></td>
</tr>
<tr>
<td>Cancelling</td>
<td>The system sheet provides it</td>
<td><strong>Build it yourself</strong></td>
</tr>
</tbody></table>
<p>That last row reaches all the way back into the JavaScript. The cross-platform <code>cancelScan()</code> is a real implementation on Android and a <strong>documented no-op on iOS</strong>, where the system sheet owns cancelling and the Swift module deliberately doesn't implement the function at all:</p>
<pre><code class="language-ts">export async function cancelScanNative(): Promise&lt;void&gt; {
  if (Platform.OS !== 'android') return;
  await NfcNative.cancelScan();
}
</code></pre>
<p>Also: pass <code>FLAG_READER_NO_PLATFORM_SOUNDS</code>, or the OS plays its own discovery sound over an app that is already telling the user what to do.</p>
<h3 id="heading-the-kotlin-and-one-design-note">The Kotlin, and One Design Note</h3>
<pre><code class="language-kotlin">private fun onTagDiscovered(tag: Tag) {
  val ndef = Ndef.get(tag) ?: run {
    stopReaderMode(); rejectOnce(NotNdefException()); return
  }

  try {
    ndef.connect()

    val status = NfcTagInfo.status(ndef)
    val capacity = ndef.maxSize

    messageToWrite?.let { message -&gt;
      // Ask before acting, same order as the Swift: refusing leaves the tag
      // untouched, failing partway through a write may not.
      if (!ndef.isWritable) { … }
      if (message.toByteArray().size &gt; capacity) { … }
      ndef.writeNdefMessage(message)
    }

    // Read back in the same connection. For a write this is verification,
    // for a read it is simply the result.
    val onTag = ndef.ndefMessage
    …
  } finally {
    runCatching { ndef.close() }
  }
}
</code></pre>
<p>Android has no equivalent of iOS's <code>NFCNDEFStatus</code>, so the status is <strong>derived</strong> rather than reported.</p>
<pre><code class="language-kotlin">fun status(ndef: Ndef?): Int =
  when {
    ndef == null -&gt; 1     // not NDEF: signalled by Ndef.get() returning null
    ndef.isWritable -&gt; 2  // read-write
    else -&gt; 3             // read-only
  }
</code></pre>
<p>iOS answers that question directly. Android signals "not NDEF" by <code>Ndef.get()</code> returning null and exposes <code>isWritable</code> on a connected tag. Mapping both onto one set of numbers keeps the TypeScript from ever needing to know which platform it's talking to, which is the entire job of a native module.</p>
<h3 id="heading-if-you-port-this-the-differences-that-will-matter">If You Port This, the Differences That Will Matter</h3>
<table>
<thead>
<tr>
<th></th>
<th>iOS</th>
<th>Android</th>
</tr>
</thead>
<tbody><tr>
<td>Permission model</td>
<td>App ID + capability + paid account</td>
<td>One manifest line, free</td>
</tr>
<tr>
<td>Failure when misconfigured</td>
<td>Code-signing error that never says "NFC"</td>
<td>Permission missing</td>
</tr>
<tr>
<td>Entitlement granularity</td>
<td><strong>Per polling option</strong></td>
<td>One permission covers all</td>
</tr>
<tr>
<td>Scanning UI</td>
<td>System sheet, not restylable</td>
<td><strong>None</strong>. You draw everything</td>
</tr>
<tr>
<td>Capacity from a read</td>
<td>❌ (ask the status query)</td>
<td>✅ <code>getMaxSize()</code></td>
</tr>
<tr>
<td>Writable from a read</td>
<td>❌ (ask the status query)</td>
<td>✅ <code>isWritable</code></td>
</tr>
<tr>
<td>Antenna</td>
<td>Top edge</td>
<td>Centre back</td>
</tr>
<tr>
<td>Card emulation</td>
<td><strong>Impossible</strong></td>
<td><code>HostApduService</code>, open</td>
</tr>
<tr>
<td>Permanent locking</td>
<td><code>writeLock</code>, irreversible</td>
<td><code>makeReadOnly()</code>, irreversible</td>
</tr>
</tbody></table>
<p>Two patterns come from that table.</p>
<p><strong>iOS front-loads the pain and Android back-loads it.</strong> Portals, entitlements and signing before you read a byte, versus reader mode, Activity lifecycle and building your own cancel once you're running.</p>
<p><strong>And the capacity difference is narrower than it looks.</strong> It isn't that iOS tells you less. It's that Android volunteers this on an ordinary read while iOS makes you ask a specific question inside a session. Same data, different price of admission. I got that wrong for three documents, as you've seen.</p>
<h2 id="heading-the-demo-repository">The Demo Repository</h2>
<p>Everything above is one project, <a href="https://github.com/FastheDeveloper/nfc"><strong>TapCard</strong></a>, and it's a single app rather than a snippet dump.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Where</th>
<th>What is there</th>
</tr>
</thead>
<tbody><tr>
<td>App</td>
<td><code>app/(tabs)/</code></td>
<td>Read, Write, Focus and Profile screens, plus a Tag Info route</td>
</tr>
<tr>
<td>Pure logic</td>
<td><code>lib/</code></td>
<td>The NDEF decoder and encoder, vCard, capacity, error mapping, focus rules. ~2,240 lines, no React Native imports</td>
</tr>
<tr>
<td>Native module</td>
<td><code>modules/nfc-native/</code></td>
<td>~1,010 lines of Swift and ~475 of Kotlin: sessions, typed exceptions, tag conversions</td>
</tr>
<tr>
<td>State</td>
<td><code>store/</code></td>
<td>Zustand + AsyncStorage: profile, last tag, focus record</td>
</tr>
<tr>
<td>Evidence</td>
<td><code>vendor/</code></td>
<td>The removed dependency, frozen, with the tests that justify its removal</td>
</tr>
<tr>
<td>Notes</td>
<td><code>DEVLOG.md</code>, <code>GOTCHAS.md</code>, <code>PLATFORM-NOTES.md</code></td>
<td>Every command and error verbatim, 75 traps, the running comparison</td>
</tr>
</tbody></table>
<p><strong>293 tests, 15 suites, about a second, and no hardware.</strong> That's the payoff of keeping the decoding pure.</p>
<p>And the tagged checkpoints, so you can read the app at any stage rather than only at the end:</p>
<pre><code class="language-bash">git checkout step-0-scaffold        # boots, no NFC
git checkout step-1-first-read      # entitlements + raw dump
git checkout step-2-decode          # own decoder, Tag Info
git checkout step-3-write           # vCard, capacity checks
git checkout step-4-own-module      # native module alongside the library
git checkout step-5-no-dependency   # library removed
git checkout step-6-android         # Kotlin reader mode
</code></pre>
<p>Clone it, buy a pack of stickers, check out <code>step-1-first-read</code>, and hold a chip against your own phone. That loop is the fastest way to make everything above concrete.</p>
<h2 id="heading-what-to-know-before-you-start">What to Know Before You Start</h2>
<p><strong>Buy the tags first.</strong> There's no simulator. When my chips went missing in the post the project stopped dead for two weeks, and no amount of clever architecture substituted for a chip.</p>
<p><strong>Read your dependencies before trusting them.</strong> Twenty minutes with <code>ndef-lib</code> found three real defects and changed the architecture of the app. None of them were in an issue tracker I'd have thought to search.</p>
<p><strong>Assume the failure will be silent.</strong> Of the seventy-five traps I logged, roughly a dozen produce <strong>no error at all</strong>: the deallocated session, the removed config plugin, the unescaped semicolon, the truncated emoji, the empty error message, and the gitignored native module. In NFC work "nothing happened" is the most common symptom, so instrument accordingly and confirm on the real surface rather than trusting a return value.</p>
<p><strong>Put the platform difference in the type, not in a comment.</strong> Model "this platform doesn't tell us" as a first-class state:</p>
<pre><code class="language-ts">type Fact = {
  label: string;
  value: string | null; // null = the platform did not report it
  unavailable?: string; // why, in plain language
};
</code></pre>
<p>A UI that renders a bare dash for both an absent value and a zero teaches nothing.</p>
<p><strong>And don't let a conclusion inherit the credibility of the observation underneath it.</strong> That one cost the most, and it wasn't about NFC at all.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You now have the whole picture on iOS: the entitlement maze, a decoder written by hand because the popular one loses data, a write path that asks the tag before it acts, a focus timer enforced by geography, a native module in Swift, a dependency deleted with its evidence preserved, and a tag locked forever behind a gate that makes you type its name.</p>
<p>Three things are worth exploring next.</p>
<ol>
<li><p><strong>Background tag reading:</strong> Tap a tag with the app closed. iOS surfaces a notification the user must tap, and only for certain record types. It's the feature that makes NFC feel magic, and the rules around which records qualify are worth a piece of their own.</p>
</li>
<li><p><strong>Cryptographic tags:</strong> An NTAG424 DNA signs a counter on every tap. If you ever want a tag to be a credential rather than a label, that's where to start.</p>
</li>
<li><p><strong>And Android</strong>, which is the next handbook. The preview above is the map, and the full walkthrough on real hardware is what comes after this one.</p>
</li>
</ol>
<p>The interesting part was never <code>requestTechnology</code>. It was everything the two platforms decline to tell you when you get it wrong.</p>
<h2 id="heading-sources-and-further-reading">Sources and Further Reading</h2>
<p><strong>Apple, CoreNFC and entitlements:</strong></p>
<ul>
<li><p><a href="https://developer.apple.com/documentation/corenfc">Core NFC</a>, <a href="https://developer.apple.com/documentation/corenfc/nfctagreadersession"><code>NFCTagReaderSession</code></a>, and <a href="https://developer.apple.com/documentation/corenfc/nfcndeftag"><code>NFCNDEFTag</code></a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/corenfc/nfcndeftag/queryndefstatus(completionhandler:)"><code>queryNDEFStatus</code></a>: the call this project wrongly concluded did not exist</p>
</li>
<li><p><a href="https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.nfc.readersession.formats">Near Field Communication Tag Reader Session Formats entitlement</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/ProximityReader/setting-up-the-entitlement-for-tap-to-pay-on-iPhone">Setting up the entitlement for Tap to Pay on iPhone</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/xcode/configuring-family-controls">Configuring Family Controls</a></p>
</li>
<li><p><a href="https://developer.apple.com/forums/thread/808604">CoreNFC availability on iPad</a>: an Apple engineer confirming the framework is iPhone-only</p>
</li>
</ul>
<p><strong>Android:</strong></p>
<ul>
<li><p><a href="https://developer.android.com/develop/connectivity/nfc/nfc">NFC basics</a> and <a href="https://developer.android.com/develop/connectivity/nfc/advanced-nfc">Advanced NFC</a></p>
</li>
<li><p><a href="https://developer.android.com/reference/android/nfc/NfcAdapter#enableReaderMode(android.app.Activity,%20android.nfc.NfcAdapter.ReaderCallback,%20int,%20android.os.Bundle)"><code>NfcAdapter.enableReaderMode</code></a></p>
</li>
<li><p><a href="https://developer.android.com/reference/android/nfc/tech/Ndef"><code>Ndef</code></a>: <code>getMaxSize()</code>, <code>isWritable()</code></p>
</li>
<li><p><a href="https://developer.android.com/develop/connectivity/nfc/hce">Host-based card emulation</a></p>
</li>
</ul>
<p><strong>The format:</strong></p>
<ul>
<li><p>NFC Forum <a href="https://nfc-forum.org/build/specifications">NDEF and RTD specifications</a>: the URI prefix table and the Text record status byte</p>
</li>
<li><p><a href="https://datatracker.ietf.org/doc/html/rfc2426">RFC 2426</a>: vCard 3.0, including the <code>N</code> field and line folding</p>
</li>
</ul>
<p><strong>Expo and React Native:</strong></p>
<ul>
<li><p><a href="https://react.dev/reference/eslint-plugin-react-hooks"><code>eslint-plugin-react-hooks</code></a>, and the two rules that caught the focus timer: <a href="https://react.dev/reference/eslint-plugin-react-hooks/lints/purity"><code>purity</code></a> and <a href="https://react.dev/reference/eslint-plugin-react-hooks/lints/set-state-in-effect"><code>set-state-in-effect</code></a></p>
</li>
<li><p><a href="https://docs.expo.dev/modules/overview/">Expo Modules API</a> and <a href="https://docs.expo.dev/config-plugins/introduction/">Config plugins</a></p>
</li>
<li><p><a href="https://docs.expo.dev/workflow/continuous-native-generation/">Continuous Native Generation</a></p>
</li>
<li><p><a href="https://docs.expo.dev/build-reference/ios-capabilities/">iOS capabilities on EAS Build</a>: which entitlements EAS syncs, and <code>EXPO_NO_CAPABILITY_SYNC</code></p>
</li>
<li><p><code>expo-modules-core</code>, <code>ios/Core/Exceptions/CodedError.swift</code>: where <code>ERR_USER_CANCELLED</code> comes from</p>
</li>
</ul>
<p><strong>Shipped NFC you can go and check, cited in "Where You Have Already Seen This":</strong></p>
<ul>
<li><p><a href="https://support.apple.com/guide/shortcuts/setting-triggers-apde31e9638b/ios">Setting triggers in Shortcuts</a>, Apple. The NFC trigger needs iPhone XS or later and iOS 13.1</p>
</li>
<li><p><a href="https://support.apple.com/guide/iphone/mark-an-item-as-lost-iph1b451b75f/ios">Mark an AirTag or other item as lost in Find My</a>, Apple</p>
</li>
<li><p><a href="https://en-americas-support.nintendo.com/app/answers/detail/a_id/13260/">amiibo FAQ</a>, Nintendo, on read-only versus read/write figures</p>
</li>
<li><p><a href="https://www.gov.uk/guidance/using-the-uk-immigration-id-check-app">Using the "UK Immigration: ID Check" app</a>, GOV.UK</p>
</li>
<li><p><a href="https://www.apple.com/business/tap-to-pay-on-iphone/">Tap to Pay on iPhone</a> and its <a href="https://developer.apple.com/tap-to-pay/regions/">supported regions</a>, Apple</p>
</li>
</ul>
<p><strong>Prior art for the focus feature:</strong></p>
<ul>
<li><a href="https://github.com/awaseem/foqos">Foqos</a> (open source, <a href="https://apps.apple.com/us/app/foqos-tap-to-block/id6736793117">App Store</a>) and <a href="https://github.com/cajdata/TapBlok/">TapBlok</a></li>
</ul>
<p><strong>The library this project replaced:</strong></p>
<ul>
<li><a href="https://github.com/revtel/react-native-nfc-manager"><code>react-native-nfc-manager</code></a>: MIT. The defects described here were found in 3.17.2 by reading the source, and a frozen copy lives in the demo repo's <code>vendor/</code> with the tests that pin them.</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The React Native Live Activities Handbook: How to Build iOS Live Activities and Android 16 Live Updates ]]>
                </title>
                <description>
                    <![CDATA[ A Live Activity is the card that sits on your lock screen while a delivery rider approaches, updating itself without you opening the app. Apple shipped the API in iOS 16.1. Google shipped its own vers ]]>
                </description>
                <link>https://www.freecodecamp.org/news/react-native-live-activities-handbook/</link>
                <guid isPermaLink="false">6a565e929f58169e255198d4</guid>
                
                    <category>
                        <![CDATA[ React Native ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Expo ]]>
                    </category>
                
                    <category>
                        <![CDATA[ iOS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ push notifications ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Farouq Seriki ]]>
                </dc:creator>
                <pubDate>Tue, 14 Jul 2026 16:06:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/542a6607-0c73-4dbf-822e-d72d2325ef7a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A Live Activity is the card that sits on your lock screen while a delivery rider approaches, updating itself without you opening the app. Apple shipped the API in iOS 16.1. Google shipped its own version, called Live Updates, in Android 16.</p>
<p>The product requirement is identical on both platforms. The contracts underneath are opposites.</p>
<p>On iOS, <a href="https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns">Apple Push Notification service</a> (APNs) updates a system-owned SwiftUI widget for you, and your app code never runs. On Android there's no system-managed remote update at all. A data-only <a href="https://firebase.google.com/docs/cloud-messaging">Firebase Cloud Messaging</a> (FCM) message wakes a background service, and your own code re-posts the notification every single time.</p>
<p>In this handbook you'll build both. You'll write one TypeScript API backed by two native implementations: a Swift one that talks to <a href="https://developer.apple.com/documentation/activitykit">ActivityKit</a> and a Kotlin one that talks to <code>NotificationManager</code>. You'll also write an APNs client and an FCM client from scratch, in about sixty lines each, with no libraries. And you'll learn the silent failure modes that make this API hard, because almost every mistake here produces no error at all.</p>
<p>I built a delivery-tracking demo called DropTrack to work through this. Everything below comes from that build, including a three-device Samsung investigation that ends with a screenshot of a hardcoded allowlist.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-live-activities-and-live-updates-actually-are">What Live Activities and Live Updates Actually Are</a></p>
</li>
<li><p><a href="#heading-where-you-have-already-seen-this-feature">Where You Have Already Seen This Feature</a></p>
</li>
<li><p><a href="#heading-why-you-need-a-custom-native-module">Why You Need a Custom Native Module</a></p>
</li>
<li><p><a href="#heading-how-the-ios-contract-works">How the iOS Contract Works</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-widget-in-swiftui">How to Build the Widget in SwiftUI</a></p>
</li>
<li><p><a href="#heading-how-to-bridge-activitykit-to-javascript">How to Bridge ActivityKit to JavaScript</a></p>
</li>
<li><p><a href="#heading-four-ios-gotchas-that-cost-me-an-evening-each">Four iOS Gotchas That Cost Me an Evening Each</a></p>
</li>
<li><p><a href="#heading-how-to-drive-ios-from-a-server-with-apns">How to Drive iOS From a Server With APNs</a></p>
</li>
<li><p><a href="#heading-how-to-write-an-apns-client-from-scratch">How to Write an APNs Client From Scratch</a></p>
</li>
<li><p><a href="#heading-how-to-test-on-real-hardware">How to Test on Real Hardware</a></p>
</li>
<li><p><a href="#heading-why-android-has-no-activitykit">Why Android Has No ActivityKit</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-kotlin-side">How to Build the Kotlin Side</a></p>
</li>
<li><p><a href="#heading-how-to-drive-android-from-a-server-with-fcm">How to Drive Android From a Server With FCM</a></p>
</li>
<li><p><a href="#heading-three-ux-gaps-the-naive-implementation-leaves">Three UX Gaps the Naive Implementation Leaves</a></p>
</li>
<li><p><a href="#heading-how-to-script-the-simulators-and-devices">How to Script the Simulators and Devices</a></p>
</li>
<li><p><a href="#heading-how-ios-and-android-compare">How iOS and Android Compare</a></p>
</li>
<li><p><a href="#heading-the-samsung-reality-check">The Samsung Reality Check</a></p>
</li>
<li><p><a href="#heading-the-demo-repository">The Demo Repository</a></p>
</li>
<li><p><a href="#heading-what-to-know-before-you-start">What to Know Before You Start</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-sources-and-further-reading">Sources and Further Reading</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you'll need:</p>
<ul>
<li><p>Node.js 20 or later.</p>
</li>
<li><p>Xcode 26 or later, plus a <strong>paid Apple Developer account</strong>. APNs requires a real signing key and a device build. The simulator can't receive Live Activity pushes.</p>
</li>
<li><p>Android Studio with an API 36 emulator built from a <code>google_apis</code> system image. Google Play services are required for FCM, and a bare Android Open Source Project image doesn't include them.</p>
</li>
<li><p>A <a href="https://firebase.google.com/docs/cloud-messaging">Firebase</a> project, for the Android half.</p>
</li>
<li><p>Working knowledge of Swift, Kotlin, and TypeScript. This is a native modules article, not a JavaScript-only one.</p>
</li>
</ul>
<p>The versions I used throughout:</p>
<table>
<thead>
<tr>
<th>Package or tool</th>
<th>Version</th>
</tr>
</thead>
<tbody><tr>
<td>Expo SDK</td>
<td>57.0.4</td>
</tr>
<tr>
<td>React Native</td>
<td>0.86.0</td>
</tr>
<tr>
<td>React</td>
<td>19.2.3</td>
</tr>
<tr>
<td>TypeScript</td>
<td>6.0.3</td>
</tr>
<tr>
<td><a href="https://github.com/EvanBacon/expo-apple-targets"><code>@bacons/apple-targets</code></a></td>
<td>4.0.7</td>
</tr>
<tr>
<td><a href="https://developer.android.com/jetpack/androidx/releases/core"><code>androidx.core</code></a></td>
<td>1.17.0</td>
</tr>
<tr>
<td><code>firebase-bom</code></td>
<td>33.7.0</td>
</tr>
<tr>
<td>Xcode</td>
<td>26.3</td>
</tr>
</tbody></table>
<p>The <code>androidx.core</code> version isn't optional. Version 1.17.0 backports the Android 16 promotion APIs so they compile against base SDK 36. I'll explain why in a moment.</p>
<h2 id="heading-what-live-activities-and-live-updates-actually-are">What Live Activities and Live Updates Actually Are</h2>
<p>A Live Activity isn't a push notification. A notification is a fire-and-forget event. A Live Activity is a persistent, glanceable card with its own state that changes in place, a bounded lifetime, and dedicated system surfaces.</p>
<p>Here's where each platform landed:</p>
<ul>
<li><p>iOS 16.1 introduced lock-screen Live Activities. 16.2 added the Dynamic Island. 17.2 added push-to-start. 18 added broadcast channels. See Apple's <a href="https://developer.apple.com/documentation/activitykit">ActivityKit documentation</a> and the <a href="https://developer.apple.com/design/human-interface-guidelines/live-activities">Live Activities Human Interface Guidelines</a>.</p>
</li>
<li><p>Android 16 (API 36) introduced <a href="https://developer.android.com/reference/android/app/Notification.ProgressStyle"><code>Notification.ProgressStyle</code></a>, the segmented progress bar.</p>
</li>
<li><p>Android 16 QPR1 (API 36.1), the first Quarterly Platform Release, introduced the <em>promotion</em> pipeline: the status-bar chip and the elevated lock-screen slot. Google calls the whole feature <a href="https://developer.android.com/about/versions/16/features/progress-centric-notifications">progress-centric notifications</a>.</p>
</li>
</ul>
<p>I built DropTrack to exercise all of it. An order moves through seven steps, from "Order placed" to "Arriving now". A courier can be reassigned mid-run. The card is driven either locally from an in-app console or remotely from a push.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/ea8d99d3-162d-4104-8402-c38e4ebc39fb.png" alt="DropTrack expanded Dynamic Island on a real iPhone 14 Pro" style="display: block;" width="640" height="1387" loading="lazy">

<h2 id="heading-where-you-have-already-seen-this-feature">Where You Have Already Seen This Feature</h2>
<p>Before any code, it helps to know why this API exists, because "a card on the lock screen" undersells it.</p>
<p>Think about the last time you ordered food. You place the order, lock your phone, and then what? Without a Live Activity, the app's only way to reach you is a push notification per state change. Order confirmed. Restaurant is preparing your food. Rider assigned. Rider is two stops away. Rider has arrived.</p>
<p>That's five notifications for one order. Multiply that by every order, and you've trained the user to mute you.</p>
<p>A Live Activity replaces all five with one card that mutates in place. Same information with one surface and no notification fatigue. That's the entire product argument.</p>
<p>Apple's own <a href="https://developer.apple.com/design/human-interface-guidelines/live-activities">Human Interface Guidelines</a> name the recurring use cases: sports scores, workouts, rides, and deliveries. Google's <a href="https://developer.android.com/about/versions/16/features/progress-centric-notifications">progress-centric notifications</a> documentation converges on almost the same list, stating that "key use cases include rideshare, delivery, and navigation." That page even describes the delivery example in terms of progress <em>points</em> for food preparation and delivery milestones, and <em>segments</em> colored by traffic conditions, which is precisely the widget you're about to build.</p>
<h3 id="heading-apps-that-have-shipped-it">Apps That Have Shipped it</h3>
<p>Rather than repeat the roundup posts, I checked each app's own App Store listing, because a company describing its own feature is the strongest evidence available. Every app below states that it uses Live Activities in its own words:</p>
<table>
<thead>
<tr>
<th>App</th>
<th>What its Live Activity shows</th>
<th>First-party source</th>
</tr>
</thead>
<tbody><tr>
<td>Chowdeck</td>
<td>Delivery progress, "right on your Lock Screen and in the Dynamic Island"</td>
<td><a href="https://apps.apple.com/us/app/chowdeck-food-delivery/id1530676376">App Store listing</a></td>
</tr>
<tr>
<td>Flighty</td>
<td>Departure countdown, gate changes, taxi time, arrival progress</td>
<td><a href="https://flighty.com/help/live-activities-widgets">Flighty's own help docs</a></td>
</tr>
<tr>
<td>ESPN</td>
<td>Key plays and game stats for major soccer leagues, the NHL, and the NBA</td>
<td><a href="https://apps.apple.com/us/app/espn-live-sports-scores/id317469184">App Store listing</a></td>
</tr>
<tr>
<td>MLB</td>
<td>Game updates on the lock screen</td>
<td><a href="https://apps.apple.com/us/app/mlb/id493619333">App Store listing</a></td>
</tr>
<tr>
<td>FotMob</td>
<td>Soccer scores on the lock screen</td>
<td><a href="https://apps.apple.com/us/app/fotmob-soccer-live-scores/id488575683">App Store listing</a></td>
</tr>
<tr>
<td>CARROT Weather</td>
<td>Incoming precipitation and storm intensity</td>
<td><a href="https://apps.apple.com/us/app/carrot-weather-alerts-radar/id961390574">App Store listing</a></td>
</tr>
<tr>
<td>Structured</td>
<td>Pomodoro focus timers</td>
<td><a href="https://apps.apple.com/us/app/structured-daily-planner-todo/id1499198946">App Store listing</a></td>
</tr>
</tbody></table>
<p>Apple ships them in its own software, too. The <a href="https://support.apple.com/guide/apple-sports-app/follow-games-in-real-time-apdc0cb7ad64/web">Apple Sports support page</a> says that with Live Activities turned on "you can get real-time information on your iPhone Lock Screen or your Apple Watch so that you can follow every moment of the game."</p>
<p>Two more are well documented by the technology press rather than by the companies themselves. Uber Eats <a href="https://www.macrumors.com/2023/05/02/uber-eats-live-activities/">rolled out Live Activities in May 2023</a>, showing order status, estimated time of arrival, and the driver's name and photo. DoorDash <a href="https://www.macrumors.com/2023/12/04/doordash-rolling-out-live-activities/">followed in December 2023</a> with a real-time estimated time of arrival in the Dynamic Island.</p>
<p>Two honest caveats about that table. First, an app not mentioning Live Activities in its listing doesn't mean it lacks the feature, only that I couldn't confirm it first-hand. Uber Eats is exactly that case. Second, this reflects the listings as I read them, and any app can add or drop the feature in a release.</p>
<p>Food delivery is the canonical case, and it's what DropTrack models. Ride-hailing is the highest-stakes version: "driver arriving in 3 minutes", the plate number, the trip in progress, glanced at while you stand on a curb with a locked phone. The Dynamic Island's compact presentation exists almost for this: a glyph and a percentage read in half a second.</p>
<p>Finance is the case people forget, and it's where a segmented bar would earn its keep. A crypto deposit isn't "pending, then done". It's "3 of 12 network confirmations", which is a segmented progress bar with discrete steps, mapping cleanly onto <a href="https://developer.android.com/reference/android/app/Notification.ProgressStyle"><code>ProgressStyle</code>'s</a> segments and points and onto the SwiftUI capsules you will write below. The same shape fits a bank transfer clearing or a card payment settling.</p>
<p>I want to be careful here, though: I checked the App Store listings for the major exchanges and neobanks and found none that documents a Live Activity. Treat this one as an obvious fit that the category hasn't yet taken up, not as prior art.</p>
<p>Here's the table I wish someone had shown me before I started:</p>
<table>
<thead>
<tr>
<th>Category</th>
<th>What updates</th>
<th>Who drives the update</th>
<th>Why a card beats notifications</th>
</tr>
</thead>
<tbody><tr>
<td>Food delivery</td>
<td>Courier position, ETA, rider swap</td>
<td>Backend</td>
<td>Five pushes collapse into one mutating card</td>
</tr>
<tr>
<td>Ride-hailing</td>
<td>Driver arriving, trip state, fare</td>
<td>Backend</td>
<td>Glanceable while the phone is locked</td>
</tr>
<tr>
<td>Crypto deposit</td>
<td>Confirmations, 3 of 12</td>
<td>Backend</td>
<td>A progress bar, not a binary "done" ping</td>
</tr>
<tr>
<td>Bank transfer</td>
<td>Settlement stage</td>
<td>Backend</td>
<td>Fewer "has it landed?" support tickets</td>
</tr>
<tr>
<td>Parcel</td>
<td>Out for delivery, then delivered</td>
<td>Backend</td>
<td>Persistent, not buried in the tray</td>
</tr>
<tr>
<td>Timer or workout</td>
<td>Elapsed time</td>
<td>The device</td>
<td>No server needed. This is the easy case</td>
</tr>
</tbody></table>
<p>Look at the third column. In every commercially interesting case the update is driven by your server, not by the device. That means the entire value of the feature lives in the push path. And the push path is exactly where the two platforms diverge, where the failures are silent, and where most of this handbook's pain is concentrated.</p>
<p>The pretty SwiftUI card is the easy half. Getting a backend to reliably mutate that card while the app is dead is the hard half, and it's different on each platform.</p>
<h2 id="heading-why-you-need-a-custom-native-module">Why You Need a Custom Native Module</h2>
<p>Before writing a line of Swift, I evaluated the shortcuts. Both fail, for instructive reasons.</p>
<p><a href="https://github.com/software-mansion-labs/expo-live-activity"><code>expo-live-activity</code></a> ships a predefined widget layout and a fixed content-state shape. DropTrack needs a custom segmented progress bar, a rider-reassignment treatment, and its own Dynamic Island layouts. All of those require owning <code>ActivityAttributes</code> and the SwiftUI that renders it. You reach first render fastest, and then you eject.</p>
<p><a href="https://github.com/invertase/notifee">Notifee</a>, the notification library most React Native developers reach for, is archived. As of this writing its GitHub repository is marked archived, its last published release was <a href="https://www.npmjs.com/package/@notifee/react-native"><code>@notifee/react-native@9.1.8</code></a> in December 2024, and its Android module still targets <code>compileSdk 34</code>. It has no Live Updates support. A custom module is currently the only React Native route to Android 16 Live Updates.</p>
<p>So: one local <a href="https://docs.expo.dev/modules/overview/">Expo module</a>, one TypeScript API, two native backends.</p>
<pre><code class="language-text">modules/droptrack-live/
├── expo-module.config.json   ← autolinking (apple + android)
├── index.ts                  ← the public JS API
├── src/                      ← TS types, native binding, web no-op
├── ios/
│   ├── DeliveryAttributes.swift    ← the ActivityKit contract
│   └── DroptrackLiveModule.swift   ← JS to ActivityKit
└── android/
    ├── DroptrackLiveModule.kt      ← JS to NotificationManager
    ├── DeliveryNotifier.kt         ← the shared notification builder
    └── DroptrackFcmService.kt      ← push to NotificationManager
</code></pre>
<p>The JavaScript surface is deliberately small, and identical across platforms:</p>
<pre><code class="language-typescript">// The dynamic half of the card: every update replaces this object wholesale.
// Keep it small. iOS rejects an ActivityKit push payload over 4 KB.
export type DeliveryState = {
  status: string;           // free text shown as the headline: "Picked up"
  progress: number;         // 0.0 to 1.0, drives the bar on both platforms
  etaEpochMillis: number;   // Unix ms, the shape JS speaks. Swift converts it.
  stopsRemaining: number;   // renders as "2 stops away" / "you're next"
  courierName: string;      // dynamic, because riders get reassigned mid-run
  riderReassigned: boolean; // flips the courier row to the "new rider" style
};

// Three verbs, mirroring the ActivityKit lifecycle. Android fakes the same
// shape with notify / notify / cancel, so callers never branch on platform.

// Starts the activity and returns the system-assigned id. Hold on to it:
// every later call needs it, and it does NOT survive an app restart.
startDelivery(info: DeliveryInfo, state: DeliveryState): Promise&lt;string&gt;;

// Replaces the ContentState in place. The card mutates, it does not re-appear.
updateDelivery(id: string, state: DeliveryState): Promise&lt;void&gt;;

// Final state, then dismissal. Omit dismissAfterSeconds to let the card linger.
endDelivery(id: string, state: DeliveryState, dismissAfterSeconds?: number): Promise&lt;void&gt;;

isSupported(): boolean;          // false on iOS &lt; 16.2, Android &lt; 16, and web
areActivitiesEnabled(): boolean; // false if the USER switched them off in Settings
</code></pre>
<p>Those last two are separate on purpose. A device can support Live Activities while the user has switched them off in Settings, and only <code>areActivitiesEnabled()</code> catches that. <code>Activity.request</code> throws in that case, so <code>startDelivery</code> throws too.</p>
<p>The <code>dismissAfterSeconds</code> argument maps onto ActivityKit's <a href="https://developer.apple.com/documentation/activitykit/activityuidismissalpolicy">dismissal policy</a>. Under the default policy, Apple's documentation says "the system keeps a Live Activity that ended on the Lock Screen for up to four hours after it ends." Passing a value shortens that window.</p>
<p>This project uses Expo's <a href="https://docs.expo.dev/workflow/continuous-native-generation/">Continuous Native Generation</a> (CNG), which means the <code>ios</code> and <code>android</code> folders are generated by <code>prebuild</code> and aren't committed. Every line of native code therefore lives in the module or in a <a href="https://docs.expo.dev/config-plugins/introduction/">config plugin</a>, so it survives <code>prebuild --clean</code>. The widget extension itself is generated by <a href="https://github.com/EvanBacon/expo-apple-targets"><code>@bacons/apple-targets</code></a> for the same reason.</p>
<p>One structural detail matters later: the module compiles as its own CocoaPod, not into the app target.</p>
<h2 id="heading-how-the-ios-contract-works">How the iOS Contract Works</h2>
<p>An ActivityKit activity is split in two. <a href="https://developer.apple.com/documentation/activitykit/activityattributes"><code>ActivityAttributes</code></a> holds static data, set once and never changed. Its nested <code>ContentState</code> holds the dynamic half, which every update replaces wholesale.</p>
<p>Most tutorials state that rule and move on. It's worth understanding <em>why</em> the platform forces it, because the reason tells you exactly which half any given field belongs in.</p>
<h3 id="heading-why-the-split-exists">Why the Split Exists</h3>
<p>The split isn't a style convention. It's about what can physically cross the wire.</p>
<p>When you call <code>Activity.request(...)</code>, you hand ActivityKit two things at once: the attributes and the first content state. The system files the attributes away and never accepts a new copy of them. From that moment on, the only thing you can ever send is a new <code>ContentState</code>.</p>
<p>You can see this in the shape of an <a href="https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications">ActivityKit push payload</a>. Here's what my APNs client actually transmits:</p>
<pre><code class="language-javascript">aps: {
  // Older-than-last timestamps are discarded by APNs (ordering guard).
  timestamp: Math.floor(Date.now() / 1000),
  event,                        // "update" or "end"
  'content-state': contentState, // the only field that carries data
}
</code></pre>
<p>There is no <code>attributes</code> key. There can't be one. Nothing in the payload can reach the static half.</p>
<p>So "should this field be static or dynamic?" is really the question <strong>"will my server ever need to change this value?"</strong> If the answer is yes, the field must live in <code>ContentState</code>, because that's the only thing a push can move.</p>
<p>That's the whole design. <code>ActivityAttributes</code> is the activity's identity, fixed at birth. <code>ContentState</code> is its current reading, replaced wholesale on every update rather than patched. Send a content state that omits a field and you haven't left that field alone, you've failed to decode. The <code>Codable</code> conformance is what lets the system serialize the state across a process boundary, and the <code>Hashable</code> conformance lets it tell whether an incoming state actually differs from the one already on screen.</p>
<h3 id="heading-the-bet-i-lost">The Bet I Lost</h3>
<p>Here's what I wrote first. It looks reasonable:</p>
<pre><code class="language-swift">// BEFORE: courierName is static. This compiles, ships, and works fine,
// right up until a dispatcher reassigns the rider mid-delivery.
struct DeliveryAttributes: ActivityAttributes {
  public struct ContentState: Codable, Hashable {
    var status: String
    var progress: Double
    var eta: Date
    var stopsRemaining: Int
  }
  var orderId: String
  var courierName: String   // the bet: a delivery has one courier
}
</code></pre>
<p>Putting a field in the static half is a bet that it can never change for the activity's lifetime. I bet that a delivery has one courier. Then I built rider reassignment, where a dispatcher swaps the courier mid-run, and the bet came due.</p>
<p>Because <code>courierName</code> was static, there was no way to express the change. Not through <code>activity.update()</code>, not through a push, not at all. The only escape would have been to end the activity and start a new one, which means the card disappears from the lock screen and returns as a different card, mid-delivery. That's not a fix, it's a regression.</p>
<p>So the field moved:</p>
<pre><code class="language-swift">// AFTER: only orderId is genuinely immutable. Everything a dispatcher
// can change lives in ContentState, because ContentState is the only
// thing an APNs push can carry.
struct DeliveryAttributes: ActivityAttributes {
  public struct ContentState: Codable, Hashable {
    /// Human-readable status, e.g. "Picked up", "2 stops away"
    var status: String
    /// Overall delivery progress, 0.0 ... 1.0
    var progress: Double
    /// Estimated arrival time
    var eta: Date
    /// Stops before the courier reaches the user
    var stopsRemaining: Int
    /// Courier display name, dynamic since riders can be reassigned mid-run
    var courierName: String
    /// True right after a reassignment, drives the "new rider" treatment
    var riderReassigned: Bool
  }

  /// Order identifier shown on the card, the only truly immutable fact
  var orderId: String
}
</code></pre>
<p>Moving one field touched twelve files, because the contract is duplicated at every layer that has to encode or decode it:</p>
<pre><code class="language-text">9  DeliveryNotifier.kt          Android notification builder
3  DroptrackLive.types.ts       the TypeScript contract
3  DroptrackLiveModule.swift    the Swift bridge record
3  App.tsx                      React state and the push handler
2  DeliveryLiveActivity.swift   the widget's SwiftUI
2  DeliveryAttributes.swift     the module's copy
2  DeliveryAttributes.swift     the widget's copy (yes, twice)
2  DroptrackLiveModule.kt       the Kotlin bridge record
1  DroptrackFcmService.kt, dispatch-server.mjs, push-update.mjs,
   apns.test.mjs, delivery.ts
</code></pre>
<p>The asymmetry here is what matters. A dynamic field you never change costs you a few bytes in every push. A static field you need to change costs you the entire stack. <strong>When in doubt, put the field in</strong> <code>ContentState</code><strong>.</strong> In the end, only <code>orderId</code> earned its place in the static half.</p>
<h3 id="heading-the-identical-copy-trap">The Identical-Copy Trap</h3>
<p>The widget extension is a separate binary. It's a different process, launched by the system, that doesn't link your app's code. Your app target compiles the module's <code>DeliveryAttributes.swift</code>. The widget target compiles its own. Two compilations produce two independent types that merely share a name.</p>
<p>At runtime, ActivityKit has to connect them. Your app says "start an activity of type <code>DeliveryAttributes</code>", and the system asks the widget whether it has a <a href="https://developer.apple.com/documentation/widgetkit"><code>WidgetConfiguration</code></a> for a type by that name whose <code>ContentState</code> decodes this data. The match is structural, on the type's name and its <code>Codable</code> shape, performed by a system daemon, at runtime, across a process boundary.</p>
<p>Now rename <code>courierName</code> to <code>riderName</code> in one copy and forget the other. The app compiles. The widget compiles. Both are internally consistent. <code>Activity.request()</code> succeeds and returns an id. But when the system hands the encoded state to the widget, the widget's decoder looks for <code>riderName</code>, doesn't find it, and throws inside a daemon in another process, where your breakpoints and your log statements don't exist.</p>
<p>The card never appears. No crash, no warning, no console line, and <code>Activity.activities</code> still lists the activity as running. You'll spend an hour on your SwiftUI layout, and the layout was never the problem.</p>
<p>Two things follow. A shared source file doesn't save you, because the module compiles as its own CocoaPod rather than into the app target, so no build phase naturally covers both. And since the compiler will never catch this, the check has to be external:</p>
<pre><code class="language-shell">diff modules/droptrack-live/ios/DeliveryAttributes.swift \
     targets/widgets/DeliveryAttributes.swift
</code></pre>
<p>If that command ever prints anything, your Live Activity is already broken. It costs nothing to wire into a pre-commit hook, and it's the single highest-value guardrail in this project.</p>
<h2 id="heading-how-to-build-the-widget-in-swiftui">How to Build the Widget in SwiftUI</h2>
<p>The widget declares one <a href="https://developer.apple.com/documentation/widgetkit"><code>Widget</code></a> with two presentations: the lock-screen card and the Dynamic Island.</p>
<p>Before reading the code, hold one idea in your head: <strong>these closures are a pure function from state to view.</strong> The widget extension isn't a running program. The system launches the process, calls your closure with the current <code>ContentState</code>, keeps the rendered result, and kills the process.</p>
<p>When a push delivers a new state, it runs the closure again. There's no <code>@State</code>, no timer, no network call, and no <code>onAppear</code> doing work. That's also why widget extensions have no network access: there's nobody home to make a request.</p>
<p>Two things about the type signature before the code. <code>Widget</code> is a WidgetKit protocol, not a SwiftUI <code>View</code>, and <code>body</code> returns <code>some WidgetConfiguration</code>, not <code>some View</code>. You're not describing pixels, you're declaring what kind of widget this is.</p>
<p>And the <code>for:</code> argument on line 3 is the binding point from the previous section: this is the exact spot where the app's <code>DeliveryAttributes</code> and the widget's copy are matched by name and <code>Codable</code> shape, so it's where the identical-copy trap either works or silently fails.</p>
<pre><code class="language-swift">struct DeliveryLiveActivity: Widget {
  var body: some WidgetConfiguration {
    ActivityConfiguration(for: DeliveryAttributes.self) { context in
      // Closure 1 of 2: the lock screen and banner.
      DeliveryCardView(context: context)
        .activityBackgroundTint(Color(red: 0.07, green: 0.07, blue: 0.12))
        .activitySystemActionForegroundColor(.white)

    } dynamicIsland: { context in          // closure 2 of 2, labelled
      DynamicIsland {
        // Expanded (long-press): four named slots, arranged around the cutout.
        DynamicIslandExpandedRegion(.leading) {
          Image(systemName: "bicycle").foregroundStyle(brandOrange)
        }
        DynamicIslandExpandedRegion(.trailing) {
          Text(context.state.eta, style: .time).font(.callout.bold())
        }
        DynamicIslandExpandedRegion(.center) {
          Text(context.state.status).font(.callout.weight(.semibold)).lineLimit(1)
        }
        DynamicIslandExpandedRegion(.bottom) {
          VStack(spacing: 4) {
            SegmentedProgressBar(progress: context.state.progress)
            HStack {
              CourierLabel(state: context.state, compact: true)
              Spacer()
              Text(stopsLabel(context.state.stopsRemaining))
            }
            .font(.caption2).foregroundStyle(.secondary)
          }
        }
      } compactLeading: {                   // the default pill, left of the cutout
        Image(systemName: "bicycle").foregroundStyle(brandOrange)
      } compactTrailing: {                  // ...and right of it
        Text("\(Int(context.state.progress * 100))%").font(.caption2.bold())
      } minimal: {                          // when another app shares the island
        Image(systemName: "bicycle").foregroundStyle(brandOrange)
      }
      .keylineTint(brandOrange)
    }
  }
}
</code></pre>
<p>That's one state object rendered four ways, and a few of the choices are load-bearing:</p>
<ul>
<li><p><strong>The two closures:</strong> <code>ActivityConfiguration</code> takes a content closure and a <code>dynamicIsland:</code> closure. The bare-then-labelled form is Swift's multiple-trailing-closure syntax, not two separate statements. The first is the lock-screen and banner card, and the second is every Dynamic Island form.</p>
</li>
<li><p><code>context</code><strong>:</strong> Each closure receives <code>context.state</code>, the dynamic <code>ContentState</code> replaced on every update, and <code>context.attributes</code>, the static half, read exactly once, for the order id on the card.</p>
</li>
<li><p><strong>The two</strong> <code>.activity…</code> <strong>modifiers:</strong> These are ActivityKit-specific, not general SwiftUI. You don't draw the system's swipe-to-end chrome, so all you can do is tint it.</p>
</li>
<li><p><code>eta, style: .time</code> renders a localized clock time such as "5:42 PM", honouring the user's 12- or 24-hour setting. It's not a countdown. A ticking countdown would use <code>style: .timer</code>, which the system animates with no push at all, which is how a timer-style activity stays live with zero server traffic.</p>
</li>
<li><p><code>lineLimit(1)</code> isn't decoration. The centre slot is a few dozen points wide, and an unbounded status string wraps and wrecks the layout. The bar lives in <code>.bottom</code> for the opposite reason: it's the only region wide enough.</p>
</li>
<li><p><code>.font</code> <strong>and</strong> <code>.foregroundStyle</code> <strong>on the</strong> <code>HStack</code> apply to both children through SwiftUI's environment, and a child's explicit modifier still wins over the inherited one, which is how <code>CourierLabel</code> overrides the colour on a reassignment.</p>
</li>
<li><p><code>Int(progress * 100)</code> truncates rather than rounds, so 0.999 would render as "99%". The scripted steps end on exactly 1.0 so it lands on 100, but server-computed progress would want <code>.rounded()</code>.</p>
</li>
<li><p><code>minimal</code> is the presentation people forget. When another app's activity is also running, the island shrinks yours to a single circle. Skip it and your activity looks broken whenever a timer is going. <code>keylineTint</code> sets the glow the system draws around the whole island on update.</p>
</li>
</ul>
<h3 id="heading-the-segmented-progress-bar">The Segmented Progress Bar</h3>
<p>The capsule bar is an <code>HStack</code> of rounded rectangles. <code>ContentState</code> carries <code>progress</code> as a double rather than a step index, so the widget derives how many segments to fill.</p>
<pre><code class="language-swift">private struct SegmentedProgressBar: View {
  let progress: Double
  var segments: Int = 7   // one capsule per delivery step

  // ContentState carries a 0...1 fraction, never a step index, so the bar has
  // to work backwards to a segment count. Keeping the wire format numeric means
  // the widget never needs to know what the seven steps are called.
  private var filled: Int {
    // Clamp first: a server that sends 1.4 must not paint 10 of 7 segments.
    let clamped = min(max(progress, 0), 1)
    // .rounded(.up) so a step that has merely BEGUN already lights its capsule.
    // progress 0.05 * 7 = 0.35 -&gt; ceil -&gt; 1 segment lit, not 0.
    // The outer min() guards the exact 1.0 case against a 8th segment.
    return min(segments, Int((clamped * Double(segments)).rounded(.up)))
  }

  var body: some View {
    HStack(spacing: 5) {
      // ForEach over a constant range, so `id: \.self` on the index is safe.
      ForEach(0..&lt;segments, id: \.self) { i in
        RoundedRectangle(cornerRadius: 3, style: .continuous)
          // The only stateful decision in the whole view: lit, or track colour.
          .fill(i &lt; filled ? brandOrange : trackGray)
          // Fixed height, no width: the HStack divides the width evenly, so the
          // same view fits the wide lock-screen card and the narrow island.
          .frame(height: 7)
      }
    }
  }
}
</code></pre>
<p>With progress values of 0.05, 0.15, 0.35, 0.55, 0.7, 0.85, 0.95, and 1.0, rounding <code>progress * 7</code> upward fills one through seven segments in lockstep with the steps. The same view renders on the lock-screen card and inside the expanded island, so the component reads as one thing across surfaces.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/539a1b65-6fdf-440a-8e9b-0ea3a02c918a.png" alt="DropTrack lock-screen card with the segmented progress bar" style="display: block;" width="560" height="1213" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/ef33b5c6-bab4-4df5-9753-ed032256ece2.png" alt="DropTrack compact Dynamic Island pill" style="display: block;" width="640" height="110" loading="lazy">

<h3 id="heading-the-reassignment-treatment">The Reassignment Treatment</h3>
<p>Reassignment is an in-place update: same activity id, same step, new <code>courierName</code>, and <code>riderReassigned</code> set to true. The courier row swaps its icon and copy.</p>
<pre><code class="language-swift">// Shared by the lock-screen card and the expanded island, so a reassignment
// reads identically wherever the user happens to be looking.
private struct CourierLabel: View {
  let state: DeliveryAttributes.ContentState
  let compact: Bool   // true inside the island, where horizontal space is scarce

  var body: some View {
    // Both branches are driven purely by ContentState. No local state, because
    // the widget process does not live long enough to hold any.
    if state.riderReassigned {
      Label(
        // The island cannot fit "New rider · Tunde", so it drops the prefix and
        // leans on the swap icon plus the orange tint to carry the meaning.
        compact ? state.courierName : "New rider · \(state.courierName)",
        systemImage: "arrow.triangle.2.circlepath"
      )
      // An explicit style on the child overrides the .secondary the parent
      // HStack pushed down through the environment. This is what makes the
      // swapped rider the one thing on the card that draws the eye.
      .foregroundStyle(brandOrange).bold()
    } else {
      // Steady state: inherits the parent's caption font and secondary colour.
      Label(state.courierName, systemImage: "bicycle")
    }
  }
}
</code></pre>
<p>Advancing a step retires the badge. That's the whole feature, and the reason <code>courierName</code> had to leave the static half.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/38944dd0-0875-4b92-b1cb-16783123848c.png" alt="DropTrack rider-reassignment treatment on the lock screen" style="display: block;" width="640" height="289" loading="lazy">

<h2 id="heading-how-to-bridge-activitykit-to-javascript">How to Bridge ActivityKit to JavaScript</h2>
<p>Expo's <a href="https://docs.expo.dev/modules/module-api/"><code>Record</code></a> type gives you type-safe bridging. Expo validates the JavaScript object against these fields before your function body runs.</p>
<pre><code class="language-swift">// A Record is Expo's typed bridge struct. Every @Field is decoded out of the
// JS object BEFORE your function body runs, so a missing or wrong-typed key
// fails at the boundary with a clear error rather than deep inside Swift.
// The defaults are what a field falls back to when JS omits it.
struct DeliveryStateRecord: Record {
  @Field var status: String = ""
  @Field var progress: Double = 0
  @Field var etaEpochMillis: Double = 0   // Double, not Int: JS numbers are f64,
                                          // and epoch ms overflows Int32.
  @Field var stopsRemaining: Int = 0
  @Field var courierName: String = ""
  @Field var riderReassigned: Bool = false

  // The bridge type and the ActivityKit type are deliberately NOT the same
  // struct. This function is the single place the two vocabularies meet.
  @available(iOS 16.2, *)
  func toContentState() -&gt; DeliveryAttributes.ContentState {
    DeliveryAttributes.ContentState(
      status: status,
      // Clamp at the boundary, so no downstream view has to defend itself.
      progress: min(max(progress, 0), 1),
      // JS speaks Unix milliseconds; Foundation.Date wants seconds. Divide once,
      // here, rather than scattering /1000 through the codebase.
      eta: Date(timeIntervalSince1970: etaEpochMillis / 1000),
      stopsRemaining: stopsRemaining,
      courierName: courierName,
      riderReassigned: riderReassigned
    )
  }
}
</code></pre>
<p>And the module itself:</p>
<pre><code class="language-swift">AsyncFunction("startDelivery") { (info: DeliveryInfoRecord, state: DeliveryStateRecord) -&gt; String in
  // Runtime OS guard: the module compiles against older targets, so this is
  // what stops it calling into a framework that isn't there.
  guard #available(iOS 16.2, *) else { throw LiveActivityUnsupportedException() }

  let activity = try Activity.request(
    attributes: DeliveryAttributes(orderId: info.orderId),                  // static half, set once
    content: ActivityContent(state: state.toContentState(), staleDate: nil), // dynamic half
    pushType: .token                                                        // ask APNs for a token
  )
  return activity.id   // the only handle JS gets, and the one it forgets on reload
}

AsyncFunction("updateDelivery") { (activityId: String, state: DeliveryStateRecord) in
  let activity = try Self.findActivity(id: activityId)
  // .update() replaces the whole ContentState. There is no partial-patch API.
  await activity.update(ActivityContent(state: state.toContentState(), staleDate: nil))
}
</code></pre>
<p>A few notes on the arguments, so they stay out of the code. <code>AsyncFunction</code> (rather than <code>Function</code>) is what makes the JS side a <code>Promise</code>, and Expo infers the rest of the signature from the parameter types.</p>
<p><code>staleDate: nil</code> means the card is never marked stale. Pass a date and, once it passes, the system flips the activity's state so your widget can read <code>context.isStale</code> and render a degraded view, but it doesn't grey the card out for you.</p>
<p>And <code>pushType: .token</code> is what asks APNs for a per-activity token, where passing <code>nil</code> instead gives you a purely local activity that no server can update.</p>
<p>Notice <code>findActivity</code>. Activities are looked up fresh by id on every call rather than cached in a property, because activity handles live inside <a href="https://developer.apple.com/documentation/activitykit/activity">ActivityKit</a>, not in your process. That distinction matters enormously, as the next section shows.</p>
<pre><code class="language-swift">@available(iOS 16.2, *)
private static func findActivity(id: String) throws -&gt; Activity&lt;DeliveryAttributes&gt; {
  // `Activity.activities` is a live, system-owned list. It survives an app
  // relaunch, a JS reload, and a process kill. Caching a handle in a Swift
  // property would not, which is precisely why this lookup runs every time.
  guard let activity = Activity&lt;DeliveryAttributes&gt;.activities.first(where: { $0.id == id }) else {
    // Reached when the user swiped the card away, or the activity aged out.
    throw ActivityNotFoundException(id)
  }
  return activity
}
</code></pre>
<h2 id="heading-four-ios-gotchas-that-cost-me-an-evening-each">Four iOS Gotchas That Cost Me an Evening Each</h2>
<p>Each of these wasted an evening because the failure looked like a bug in my code when it was really a quirk of the platform. They share a shape, so I've written each one the same way: the symptom you see, why it happens, and what to do about it.</p>
<h3 id="heading-gotcha-1-the-compact-island-looks-broken">Gotcha 1: The Compact Island Looks Broken</h3>
<p>You build the compact Dynamic Island, run the app, and it never appears.</p>
<p>This happens because the compact presentation is hidden while your own app is in the foreground. This is by design, but nothing tells you so, and I spent twenty minutes certain my layout was wrong.</p>
<p>To fix this, lock the device, or switch to another app, before you judge whether the island works. There's nothing to fix in the code.</p>
<h3 id="heading-gotcha-2-an-old-activity-renders-nothing-after-a-rebuild">Gotcha 2: An Old Activity Renders Nothing After a Rebuild</h3>
<p>You change the widget, rebuild, and an activity that was already running goes blank. No error appears anywhere.</p>
<p>This happens when an activity is tied to the exact build that started it. Once you rebuild the widget extension, the running activity no longer matches the code on the device.</p>
<p>To fix this, after any change to the widget extension, end the stale activity and start a fresh one. Don't expect a warning to remind you.</p>
<h3 id="heading-gotcha-3-reinstalling-can-jam-the-system-daemon">Gotcha 3: Reinstalling Can Jam the System Daemon</h3>
<p>Right after <code>simctl install</code>, your first activity refuses to render. <code>Activity.activities</code> says it's active, yet the screen shows nothing.</p>
<p>This happens because Live Activities are drawn by a background system process called <code>chronod</code>. A reinstall can leave it wedged: in my case it threw <code>widgetDescriptorNotFound</code> and logged internal errors, and no amount of ending and restarting the activity cleared it.</p>
<p>This is a fault in the daemon, not in your app, which is why the usual checks mislead you. Listing the extension with <code>pluginkit -m</code> shows it present and healthy while the daemon behind it is stuck.</p>
<p>To fix this, reboot the simulator with <code>simctl shutdown &amp;&amp; boot</code>, then start a fresh activity.</p>
<h3 id="heading-gotcha-4-tapping-the-card-opens-an-app-that-forgot-everything">Gotcha 4: Tapping the Card Opens an App That Forgot Everything</h3>
<p>This is the subtle one, the most visible to users, and the reason for the next two code samples.</p>
<p>The user taps the live card to open your app. The app launches into an empty screen that insists nothing is being tracked, every control disabled, while the card sits right there on the lock screen still updating.</p>
<p>This happens because tapping the card cold-starts the app, which means your JavaScript begins from scratch. The activity id lived in React state, and that state died with the previous process.</p>
<p>The activity itself is completely fine: <code>Activity.activities</code> still lists it, and an APNs push to it still returns <code>200</code>. (A dead activity returns <code>410</code>, which makes a handy liveness probe.) The app simply forgot the id it needs to reconnect.</p>
<p>To fix this, stop treating your in-memory id as the source of truth, and ask the system what is running on launch. Expose the live activities to JavaScript.</p>
<pre><code class="language-swift">AsyncFunction("getRunningActivities") { () -&gt; [[String: Any]] in
  guard #available(iOS 16.2, *) else { return [] }

  // The system's list, populated even on a brand-new process. That is the point.
  return Activity&lt;DeliveryAttributes&gt;.activities.map { activity in
    self.observePushToken(of: activity)   // resubscribe: see note below
    let state = activity.content.state    // the live state, kept current by push
    return [
      "activityId": activity.id,                // the handle JS lost
      "orderId": activity.attributes.orderId,   // the static half
      "status": state.status,
      "progress": state.progress,
      "courierName": state.courierName,
      "riderReassigned": state.riderReassigned,
    ]
  }
}
</code></pre>
<p>That <code>observePushToken</code> call is the line everyone misses. The <code>for await</code> loop watching this activity's push token died with the previous process, so without resubscribing here the card recovers. But the server never learns the token again after a rotation, and pushes quietly stop landing.</p>
<p>Then rehydrate on mount, and again on every foreground, because a push that lands while you're backgrounded updates the widget without running your JavaScript at all. React Native's <a href="https://reactnative.dev/docs/appstate"><code>AppState</code></a> gives you the hook.</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const sync = () =&gt; {
    void DroptrackLive.getRunningActivities().then((running) =&gt; {
      const activity = running[0];
      if (!activity) return;   // nothing running: leave the empty state alone

      setActivityId(activity.activityId);   // this line re-enables the whole UI
      setRider({ name: activity.courierName, justReassigned: activity.riderReassigned });

      // The card carries a status string, not a step index. Guard the -1.
      const index = STEPS.findIndex((s) =&gt; s.status === activity.status);
      if (index &gt;= 0) setStepIndex(index);
    });
  };

  sync();   // case 1: cold start (e.g. the user tapped the card and we launched)
  const sub = AppState.addEventListener("change", (s) =&gt; {
    if (s === "active") sync();   // case 2: resume from background
  });
  return () =&gt; sub.remove();
}, []);
</code></pre>
<p>The whole tracking interface is gated on <code>activityId</code>, so restoring it is what turns the app back on. Miss that one line and the app looks dead while the card is live on the lock screen.</p>
<p>The two call sites cover the two ways state goes stale. <code>sync()</code> on mount handles a cold start, and the <code>AppState</code> listener handles a resume, because the mount effect won't re-run and a push may have advanced the card while you were backgrounded without executing any JavaScript. The empty dependency array means this wiring is set up once, for the app's lifetime.</p>
<p>That last gotcha generalises into the sentence I wish I'd read first: <strong>Live Activities are owned by the system, not by your process.</strong> The API hands you an activity id and it's easy to assume the id is yours to keep. It's not. The activity outlives the variable, and the moment a user is most likely to open your app, by tapping the live card, is precisely the moment your in-memory copy of that id doesn't exist.</p>
<h2 id="heading-how-to-drive-ios-from-a-server-with-apns">How to Drive iOS From a Server With APNs</h2>
<p>Here's the payoff: updating the card while the app is fully force-quit. The API surface is almost insultingly small. You change one argument. Apple documents the flow in <a href="https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications">Starting and updating Live Activities with ActivityKit push notifications</a>.</p>
<pre><code class="language-swift">let activity = try Activity.request(
  attributes: attributes,
  content: ActivityContent(state: state.toContentState(), staleDate: nil),
  pushType: .token   // this was `nil`. That is the entire feature.
)

Task {
  for await tokenData in activity.pushTokenUpdates {
    let token = tokenData.map { String(format: "%02x", $0) }.joined()   // raw Data -&gt; hex
    NSLog("[DropTrack] push token for %@: %@", activity.id, token)       // NSLog, so devicectl sees it
    self.sendEvent("onPushTokenReceived", ["activityId": activity.id, "token": token])
  }
}
</code></pre>
<p><code>pushTokenUpdates</code> is an <code>AsyncSequence</code>, not a one-shot getter, which is why this is a <code>for await</code> loop and not a single read. Reading <code>activity.pushToken</code> right after <code>request()</code> reliably returns nil, because the token hasn't arrived from APNs yet, and it can also rotate mid-flight.</p>
<p>The token arrives as raw <code>Data</code>, so it's hexed before use. It's logged with <code>NSLog</code> rather than <code>print</code> because a standalone Release build has no Metro to receive <code>console.log</code>, and <code>NSLog</code> is what <code>devicectl --console</code> surfaces. And the token is per-activity, so it's keyed by activity id when handed to JS rather than stored globally.</p>
<p>Everything after that is plumbing, and two pieces of it fail without a single error message.</p>
<p><code>pushType: .token</code> <strong>does nothing without the</strong> <a href="https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment"><code>aps-environment</code></a> <strong>entitlement.</strong> <code>Activity.request</code> still succeeds. Local updates still work. The token stream simply never yields. Nothing logs. Add the entitlement in <code>app.json</code>:</p>
<pre><code class="language-json">"ios": {
  "entitlements": {
    "aps-environment": "development",
    "com.apple.security.application-groups": ["group.com.fasarticle.droptrack"]
  }
}
</code></pre>
<p>Two notes on those values, since <code>app.json</code> can't carry comments. <code>aps-environment</code> set to <code>development</code> means your tokens are only valid against <code>api.sandbox.push.apple.com</code>. A shipping build needs <code>production</code>, and mixing the two produces an afternoon of <code>BadDeviceToken</code>. The application group is the shared container between the app and the widget extension, and both targets must carry the identical group id or shared reads silently return nothing.</p>
<p>Then verify that the entitlement survived signing, because an entitlement in your config isn't the same as an entitlement in your binary:</p>
<pre><code class="language-shell"># Reads the entitlements actually baked into the signed .app.
# If aps-environment is absent here, the token stream will never yield,
# no matter what app.json says.
codesign -d --entitlements - --xml Build/Products/Release-iphoneos/DropTrack.app
</code></pre>
<p><strong>The push token is per-activity, not per-device.</strong> It arrives asynchronously after <code>request()</code> returns, and the system can rotate it mid-flight. Consume the <code>pushTokenUpdates</code> async sequence. A single read of <code>activity.pushToken</code> immediately after <code>request()</code> is reliably nil. Every <code>startDelivery</code> mints a brand-new token. I verified that by starting two activities and comparing the hex.</p>
<h2 id="heading-how-to-write-an-apns-client-from-scratch">How to Write an APNs Client From Scratch</h2>
<p>You don't need a library. Two Node.js built-ins and about sixty lines will do it.</p>
<p>Sending one push comes down to three steps. First, sign a token that proves the request is really from you. Second, open a connection to Apple and attach a precise set of headers. Third, send the state you want the card to show. The rest of this section walks each step, then covers the two ways it fails silently.</p>
<p>One thing to get out of the way first: APNs speaks HTTP/2 only, so <code>fetch()</code> can't reach it. You need Node's <a href="https://nodejs.org/api/http2.html"><code>node:http2</code></a> module, which is why this is written by hand rather than as a <code>fetch</code> call.</p>
<h3 id="heading-step-1-sign-a-token-that-proves-who-you-are">Step 1: Sign a Token That Proves Who You Are</h3>
<p>APNs won't accept a push until you prove you own the app. You do that with an ES256 <a href="https://datatracker.ietf.org/doc/html/rfc7519">JSON Web Token</a>, signed by the <code>.p8</code> key you download from Apple, following <a href="https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns">Establishing a token-based connection to APNs</a>. A JWT is just three base64url chunks joined by dots: a header, a claims object, and a signature over the first two.</p>
<pre><code class="language-javascript">import { createPrivateKey, sign } from "node:crypto";

const b64url = (buf) =&gt; Buffer.from(buf).toString("base64url");   // JWT uses base64url, not base64

export function mintJWT({ keyPath, keyId, teamId }) {
  const header = b64url(JSON.stringify({ alg: "ES256", kid: keyId }));   // kid: the 10-char key id
  const claims = b64url(JSON.stringify({ iss: teamId, iat: Math.floor(Date.now() / 1000) }));
  const signingInput = `${header}.${claims}`;   // a JWT signs header AND claims joined

  const signature = sign("sha256", Buffer.from(signingInput), {
    key: createPrivateKey(readFileSync(keyPath, "utf8")),
    dsaEncoding: "ieee-p1363",   // THE line. The default (DER) fails silently. See below.
  });

  return `${signingInput}.${b64url(signature)}`;
}
</code></pre>
<p>That one <code>dsaEncoding</code> line is the whole reason to write this by hand rather than trust a snippet off the internet.</p>
<p>Node signs in DER format by default, which wraps the signature's two halves in an envelope, but the JWT standard (<a href="https://datatracker.ietf.org/doc/html/rfc7518">RFC 7518</a>) wants those two halves raw and joined. Use the wrong one and APNs answers <code>403 InvalidProviderToken</code>, which reads like your key is bad and sends you re-downloading the <code>.p8</code> for an hour, when the key was fine all along.</p>
<p>Two smaller details. <code>kid</code> is the ten-character key id (it is in the <code>.p8</code> filename), and it tells APNs which of your registered keys should check the signature. And the <code>iat</code> timestamp can't be more than an hour old, per Apple, which also asks you to refresh it no more than once every 20 minutes, so cache the token instead of minting a fresh one for every push.</p>
<h3 id="heading-step-2-open-the-connection-and-send-the-push">Step 2: Open the Connection and Send the Push</h3>
<p>With the token in hand, open an HTTP/2 session to the APNs host and make one <code>POST</code>. The headers have to be exact, and the one people get wrong is the topic: it's your bundle id with <code>.push-type.liveactivity</code> appended, not the bundle id on its own.</p>
<pre><code class="language-javascript">// Pseudo-headers (":method", ":path") are HTTP/2's way of encoding the request
// line. `client` here is an http2 session opened against the APNs host.
const req = client.request({
  ":method": "POST",
  // The device token in the path is the PER-ACTIVITY token, not a device token
  // from UNUserNotificationCenter. Those are different values entirely.
  ":path": `/3/device/${token}`,
  authorization: `bearer ${jwt}`,   // lowercase "bearer" is what APNs expects

  // Live Activity pushes get a SUFFIXED topic. Send the bare bundle id and
  // APNs rejects the request outright.
  "apns-topic": `${bundleId}.push-type.liveactivity`,
  "apns-push-type": "liveactivity", // must match the topic suffix
  "apns-priority": "10",            // 10 = deliver immediately, 5 = opportunistic
  "apns-expiration": "0",           // 0 = do not retry a failed push
});

req.end(JSON.stringify({
  aps: {
    // Ordering guard, in SECONDS. APNs discards a push whose timestamp is older
    // than the last one it applied, so out-of-order retries cannot rewind the card.
    timestamp: Math.floor(Date.now() / 1000),
    event: "update",              // "update" mutates the card, "end" finishes it
    // Decoded straight into the widget's Codable ContentState. Field names and
    // types must match that struct EXACTLY. See the eta trap below.
    "content-state": contentState,
  },
}));
</code></pre>
<p>The body is small on purpose. <code>timestamp</code> is an ordering guard in seconds: APNs drops any push older than the last one it applied, so a delayed retry can't rewind the card. <code>event</code> is <code>"update"</code> to change the card or <code>"end"</code> to finish the activity, and there's no <code>"start"</code> here because push-to-start is a separate token that needs iOS 17.2. Everything else lives in <code>content-state</code>, which the widget decodes directly, and that's where the second silent failure hides, below.</p>
<h3 id="heading-how-to-test-your-auth-without-a-phone">How to Test Your Auth Without a Phone</h3>
<p>This one trick saved me the most time, so do it before you involve a device at all. Send a push to the sandbox using a deliberately fake device token, and read the <a href="https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns">response code</a> APNs gives back.</p>
<ul>
<li><p><code>400 BadDeviceToken</code> is the good outcome. It means your key, key id, team id, and JWT encoding are all correct, and APNs got far enough to look up the token and simply not find it. Your auth works.</p>
</li>
<li><p><code>403 InvalidProviderToken</code> means the token itself is wrong, so the problem is your key or your signing, not the device.</p>
</li>
</ul>
<p>That single request separates "my auth is broken" from "my token is broken" with no phone in the room. On this project it returned <code>400</code> on the first try, which told me the auth was solid and I should look elsewhere for the real bug. That was exactly the reassurance I needed.</p>
<h3 id="heading-the-eta-trap-a-200-that-renders-nothing">The <code>eta</code> Trap: a 200 That Renders Nothing</h3>
<p>This is the most instructive bug in the project, and it hit the moment a second code path reused a shared helper.</p>
<p>The same delivery data takes two different routes to the widget, and the routes expect two different shapes. The native module path carries <code>etaEpochMillis</code> (Unix milliseconds), and Swift converts it into a <code>Date</code> on the way through. The push path skips Swift entirely: the JSON <code>content-state</code> is decoded directly by the widget's <code>Codable</code> struct, which expects a field literally named <code>eta</code>, holding seconds since 2001 (Apple's <a href="https://developer.apple.com/documentation/foundation/date">reference date</a>), and knows nothing about <code>etaEpochMillis</code>.</p>
<p>So if you reuse the native-bridge object as a push payload, APNs returns <code>200</code>, iOS quietly throws the update away because it won't decode, and nothing is logged anywhere. The fix is to translate at the boundary, converting to the widget's shape only when building a push.</p>
<pre><code class="language-javascript">const APPLE_EPOCH_OFFSET = 978_307_200;   // seconds between 1970 and 2001

// The shape the WIDGET decodes, not the shape the native bridge takes.
function toContentState({ etaEpochMillis, ...rest }) {
  return {
    ...rest,
    eta: Math.floor(etaEpochMillis / 1000) - APPLE_EPOCH_OFFSET,   // ms-&gt;s, then rebase to 2001
  };
}
</code></pre>
<p>Two details make this work. <code>Foundation.Date</code>'s reference point is 2001-01-01, not the Unix epoch, and Swift's <code>Codable</code> encodes a <code>Date</code> as seconds since that date, which is why the offset is subtracted. And destructuring <code>etaEpochMillis</code> out of <code>rest</code> is what drops the wrong key: leave it in and the widget's <code>Codable</code> init sees an unexpected field next to a missing <code>eta</code>, fails to decode, and the update vanishes after a 200.</p>
<p>The lesson generalises past the <code>eta</code> field. <strong>A</strong> <code>200</code> <strong>from APNs means only that Apple accepted your bytes, not that the card changed.</strong> Any content-state that fails to decode is discarded with no error on any surface, so confirm on the lock screen every time rather than trusting the status code.</p>
<p>Two last environment traps to note. Development-signed builds must talk to <code>api.sandbox.push.apple.com</code>, because the production host returns <code>400 BadDeviceToken</code> for a sandbox token, which looks exactly like a malformed token and sends you debugging the wrong layer. And if you push frequently, add <code>NSSupportsLiveActivitiesFrequentUpdates</code> to your <code>Info.plist</code>, or the system budgets and drops your rapid pushes.</p>
<h2 id="heading-how-to-test-on-real-hardware">How to Test on Real Hardware</h2>
<p>The simulator will lie to you about push, so at some point you need a real phone. Four things can trip you up there that the documentation never mentions. Here is each one with the fix.</p>
<h3 id="heading-build-in-release-not-debug">Build in Release, not Debug</h3>
<p>A Debug build expects to download its JavaScript from Metro over your local network. The build script bakes your Mac's network address into the app, so the moment the phone is on a different network, or simply not tethered to your Mac, it shows a red screen reading <code>No script URL provided</code>. There's no <code>adb reverse</code> equivalent on iOS to paper over this.</p>
<p>Build with <code>--configuration Release</code> instead, which embeds the JavaScript bundle inside the app. That's the more honest test anyway, because a Release build is the only way to fully force-quit the app and prove the push, not a live Metro connection, is doing the work.</p>
<h3 id="heading-read-logs-with-devicectl-not-consolelog">Read Logs with <code>devicectl</code>, Not <code>console.log</code></h3>
<p>Once you drop Metro, <code>console.log</code> has nowhere to go. To read something like a push token off the device, you need the app's real standard output, and <code>NSLog</code> in the Swift code plus <code>devicectl</code> on the Mac is the reliable way to get it.</p>
<pre><code class="language-shell"># --console            stream the app's stdout/stderr back to this terminal
# --terminate-existing kill a running copy first, so we catch launch-time logs
# --device             the COREDEVICE id (see the identifier trap below)
xcrun devicectl device process launch --console --terminate-existing \
  --device &lt;coredevice-id&gt; com.fasarticle.droptrack
# → [DropTrack] push token for 3095ACA0-...: 80875cb137590013a4c9...
#   ^ the hex string the dispatch server scrapes and pushes to
</code></pre>
<h3 id="heading-know-which-of-the-two-device-identifiers-you-need">Know Which of the Two Device Identifiers You Need</h3>
<p>The same phone has two identifiers, and the tools disagree about which they want. <code>expo run:ios --device</code> wants the hardware UDID, listed by <code>xcrun xctrace list devices</code>. The <code>devicectl</code> command above wants the CoreDevice identifier, listed by <code>xcrun devicectl list devices</code>.</p>
<p>Pass one where the other belongs and you get <code>No device UDID or name matching</code>, which reads as though the phone is unplugged. It's not, so check that you copied the right kind of id before you check the cable.</p>
<h3 id="heading-handle-cold-start-deep-links-not-just-live-ones">Handle Cold-start Deep Links, Not Just Live Ones</h3>
<p>If your test driver opens the app with a deep link, note that <code>devicectl ... --payload-url</code> cold-starts the app, so the link arrives through <a href="https://reactnative.dev/docs/linking"><code>Linking.getInitialURL()</code></a> rather than the <code>url</code> event that the simulator's <code>simctl openurl</code> fires. A driver that listens only for the <code>url</code> event will appear to do nothing on a device. Handle both entry points.</p>
<p>With all four handled and the app confirmed fully quit, I drove three pushes through it (a status change, a rider reassignment, and an "arriving now"). Each one rendered on the lock screen and in the Dynamic Island.</p>
<p>One bonus comes free. Sign the Mac into the same Apple ID, and Continuity mirrors the same Live Activity onto the macOS menu bar, segmented bar, and all. One APNs push, three surfaces.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/9aaf2fd9-07b5-49b8-a6cb-6befe06b9e15.png" alt="DropTrack Live Activity on the macOS menu bar via Continuity" style="display: block;" width="1200" height="305" loading="lazy">

<h2 id="heading-why-android-has-no-activitykit">Why Android Has No ActivityKit</h2>
<p>Android has no equivalent of ActivityKit, so there's no system-owned card and no framework keeping it up to date. What you have instead is an ordinary notification that you keep re-posting.</p>
<p>The trick is the notification id. Re-post under the same id and Android replaces the existing notification in place, which reads as an update. Post under a new id and you get a second notification instead. So the three lifecycle verbs map onto plain notification calls:</p>
<ul>
<li><p><strong>start</strong> is <code>notify()</code> with a fresh id.</p>
</li>
<li><p><strong>update</strong> is <code>notify()</code> again under that same id.</p>
</li>
<li><p><strong>end</strong> is a final <code>notify()</code> with <code>ongoing = false</code>, followed by a delayed <code>cancel()</code>.</p>
</li>
</ul>
<p>That is the entire model. The catch is that everything iOS did for you (keeping the card current, recovering it after a restart, and updating it from a push) is now code you have to write.</p>
<h3 id="heading-the-trap-that-shapes-everything-android-16-is-two-releases-in-one">The Trap That Shapes Everything: Android 16 is Two Releases in One</h3>
<p>One versioning quirk shapes the whole Android side, so it's worth thirty seconds up front. "Android 16" ships as two releases that share a single API number:</p>
<table>
<thead>
<tr>
<th>Release</th>
<th>Reports</th>
<th>What it adds</th>
</tr>
</thead>
<tbody><tr>
<td>Android 16 (base)</td>
<td><code>SDK_INT == 36</code></td>
<td><a href="https://developer.android.com/reference/android/app/Notification.ProgressStyle"><code>ProgressStyle</code></a>, the segmented progress bar</td>
</tr>
<tr>
<td>Android 16 QPR1</td>
<td><code>SDK_INT == 36</code></td>
<td>the <em>promotion</em> pipeline: the status-bar chip and the elevated lock-screen slot</td>
</tr>
</tbody></table>
<p>Both report <code>36</code> because <code>Build.VERSION.SDK_INT</code>, the value your code reads to check the OS version, is a plain integer with no room for a <code>.1</code>. Two different releases, one number.</p>
<p>That collision is the entire source of the friction, and it leaves you three things to handle. None is hard once you know it is coming.</p>
<h4 id="heading-1-compile-through-the-backport-not-the-platform-classes">1. Compile through the backport, not the platform classes</h4>
<p>The promotion methods (<code>setRequestPromotedOngoing</code>, <code>setShortCriticalText</code>, and the <code>POST_PROMOTED_NOTIFICATIONS</code> permission) live only in the 36.1 SDK, and React Native 0.86 compiles against base 36. Call them on the platform classes directly and the build fails with "no such method" before the app ever runs. One dependency fixes that:</p>
<pre><code class="language-gradle">dependencies {
  // NotificationCompat backports the 36.1 promotion APIs so they compile
  // against base SDK 36. Without this, ProgressStyle, setRequestPromotedOngoing
  // and setShortCriticalText are simply not on the classpath.
  implementation 'androidx.core:core-ktx:1.17.0'
}
</code></pre>
<p>Call the <a href="https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder"><code>NotificationCompat</code></a> versions rather than the platform ones. They compile against 36, take effect on a real 36.1 device, and are ignored on older releases, so one build is safe everywhere.</p>
<h4 id="heading-2-read-sdkintfull-only-when-you-truly-need-the-minor-version">2. Read <code>SDK_INT_FULL</code> only when you truly need the minor version</h4>
<p>A normal <code>SDK_INT &gt;= 36</code> check can't tell base 36 from 36.1, since both say 36. When you genuinely need to know which release you are on, <a href="https://developer.android.com/reference/android/os/Build.VERSION#SDK_INT_FULL"><code>Build.VERSION.SDK_INT_FULL</code></a> carries the minor version, with named values in <a href="https://developer.android.com/reference/android/os/Build.VERSION_CODES_FULL"><code>Build.VERSION_CODES_FULL</code></a>. Because of rule 1 you rarely need it for the promotion request itself. You reach for it to decide what to show the user, or to guard the call in rule 3.</p>
<h4 id="heading-3-wrap-the-capability-check-so-it-cant-crash-you">3. Wrap the capability check so it can't crash you</h4>
<p><a href="https://developer.android.com/reference/android/app/NotificationManager#canPostPromotedNotifications()"><code>NotificationManager.canPostPromotedNotifications()</code></a> reports whether the device will actually honour a promotion. Its reference lists it as <a href="https://developer.android.com/reference/android/app/NotificationManager#canPostPromotedNotifications()">added in API 36</a>, so in theory it belongs to base Android 16, while the <a href="https://developer.android.com/reference/android/Manifest.permission#POST_PROMOTED_NOTIFICATIONS"><code>POST_PROMOTED_NOTIFICATIONS</code></a> permission it depends on only arrived in 36.1. On the pre-QPR build I tested, though, the call was absent at runtime and threw. A capability probe should never be the thing that crashes your app, so wrap it and default to false:</p>
<pre><code class="language-kotlin">Function("canPostPromotedNotifications") {
  // Function, not AsyncFunction: this is a cheap synchronous read, and the UI
  // wants it during the first render to decide what to disable.
  val manager = notificationManager ?: return@Function false

  // Cheapest gate first: below API 36 there is nothing to promote to.
  if (Build.VERSION.SDK_INT &lt; 36) return@Function false

  // Docs list this as added in API 36, but on the pre-QPR build I tested it was
  // absent at runtime and threw. runCatching stops a capability probe from
  // crashing the app, defaulting to false when the call is unavailable.
  return@Function runCatching { manager.canPostPromotedNotifications() }.getOrDefault(false)
}
</code></pre>
<h2 id="heading-how-to-build-the-kotlin-side">How to Build the Kotlin Side</h2>
<p>Here's the notification builder. Notice how many of these lines are promotion requirements rather than cosmetics.</p>
<pre><code class="language-kotlin">// Whole percent, clamped: a server that sends 1.4 must not become 140.
val progressPercent = (progress.coerceIn(0.0, 1.0) * 100).toInt()

val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
  .setSmallIcon(R.drawable.ic_delivery)
  .setContentTitle(status)                     // promotion REQUIRES a title
  .setContentText(courierLine(courierName, riderReassigned, stopsRemaining))
  .setSubText("Order $orderId")
  .setOngoing(ongoing)                         // promotion REQUIRES ongoing
  .setOnlyAlertOnce(true)                      // buzz once, then update silently
  .setColor(BRAND_ORANGE)                      // NOT setColorized: that disqualifies it
  .setShortCriticalText("$progressPercent%")   // text inside the status-bar chip
  .setRequestPromotedOngoing(ongoing)          // the promotion request itself

// `when` is the ETA in the header. It must be in the future (see note below).
if (etaEpochMillis &gt; System.currentTimeMillis()) {
  builder.setWhen(etaEpochMillis.toLong()).setShowWhen(true)
}

if (Build.VERSION.SDK_INT &gt;= 36) {
  val style = NotificationCompat.ProgressStyle()
    .setStyledByProgress(true)                 // colour follows the value
    .setProgress(progressPercent)
    .setProgressTrackerIcon(IconCompat.createWithResource(ctx, R.drawable.ic_delivery))
    .setProgressSegments(listOf(               // segments: spans of the bar
      NotificationCompat.ProgressStyle.Segment(100).setColor(BRAND_ORANGE)
    ))
    .setProgressPoints(listOf(                 // points: milestone dots on top
      NotificationCompat.ProgressStyle.Point(35).setColor(Color.WHITE)
    ))
  builder.setStyle(style)
} else {
  builder.setProgress(100, progressPercent, false)  // pre-16 fallback bar
}

val notification = builder.build()
if (Build.VERSION.SDK_INT &gt;= 36) {
  Log.d(TAG, "hasPromotableCharacteristics=${notification.hasPromotableCharacteristics()}")
}
manager.notify(notificationIdFor(activityId), notification)   // stable id = update in place
</code></pre>
<p>Several of those lines carry non-obvious weight:</p>
<ul>
<li><p><code>setOnlyAlertOnce(true)</code> stops each of the seven re-posts per delivery from buzzing the phone. Only the first <code>notify()</code> alerts, and the rest land silently.</p>
</li>
<li><p><code>setColor</code> <strong>versus</strong> <code>setColorized</code><strong>:</strong> <code>setColor</code> tints the notification and is fine, but <code>setColorized(true)</code> would <em>disqualify</em> it from promotion. They're not interchangeable.</p>
</li>
<li><p><code>setWhen</code> must be a future timestamp, or the update can be skipped entirely. One UI renders it as an absolute clock time, Pixel as a relative countdown.</p>
</li>
<li><p><code>ProgressStyle</code> <strong>segments versus points:</strong> <em>Segments</em> are spans of the bar (one <code>Segment(100)</code> fills the whole thing, several draw the divided look). <em>Points</em> are milestone dots painted on top at a given percent.</p>
</li>
<li><p><strong>The</strong> <code>else</code> <strong>branch:</strong> Below API 36 the compat layer has no <code>ProgressStyle</code> at all, so it falls back to the classic determinate bar.</p>
</li>
</ul>
<p>Two runtime checks are your only debugging signal, and they answer different questions:</p>
<ul>
<li><p><code>hasPromotableCharacteristics()</code>, logged after <code>build()</code>, tells you whether <em>you</em> satisfied every promotion precondition. It says nothing about the device.</p>
</li>
<li><p><code>canPostPromotedNotifications()</code> tells you whether the <em>device</em> will honour the request. The two genuinely disagree on Samsung, which is why you need both.</p>
</li>
</ul>
<p>And <code>notificationIdFor()</code> derives a stable int from the activity id, so a re-post under the same id updates the card in place. A different id would post a second notification, the classic "why do I have seven delivery cards" bug.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/b815c40a-9c1f-42c6-a8da-1dc6647eb7d1.png" alt="DropTrack notification with a ProgressStyle segmented bar in the shade" style="display: block;" width="640" height="1386" loading="lazy">

<p><strong>Promotion eligibility is all-or-nothing, and silent.</strong> It flips true only when every condition holds: notification permission, <code>setRequestPromotedOngoing(true)</code>, ongoing, a content title, an allowed style, importance above <code>MIN</code>, and not colorized. Miss one and you get an ordinary notification with no explanation. Log <a href="https://developer.android.com/reference/android/app/Notification#hasPromotableCharacteristics()"><code>hasPromotableCharacteristics()</code></a> after every <code>build()</code>, and surface <code>canPostPromotedNotifications()</code> in your development interface. Those two booleans are the only debugging signal the platform gives you.</p>
<p>Note also that custom <code>RemoteViews</code> aren't allowed for promoted notifications. You get the <code>ProgressStyle</code> template, or you get no promotion. That's the biggest design constraint compared with iOS, where you write arbitrary SwiftUI.</p>
<p>When it does promote on a real API 36.1 build, you get the chip and the elevated lock-screen slot.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/d0c42fc5-3c8a-42c5-8398-cc4fc358a174.png" alt="DropTrack status-bar chip on Android 16 QPR" style="display: block;" width="636" height="59" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/1583af2a-bd36-40c1-9b6b-9fca3179669f.png" alt="DropTrack promoted lock-screen placement on Android 16 QPR" style="display: block;" width="640" height="1386" loading="lazy">

<p>The same APK behaves three different ways:</p>
<table>
<thead>
<tr>
<th>Runtime</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>API 36.1 (QPR)</td>
<td>Chip, promoted lock-screen slot, and segmented bar</td>
</tr>
<tr>
<td>API 36 (base)</td>
<td>Segmented bar only, no promotion</td>
</tr>
<tr>
<td>Below API 36</td>
<td>No bar at all, unless you keep the manual <code>setProgress</code> branch</td>
</tr>
</tbody></table>
<h2 id="heading-how-to-drive-android-from-a-server-with-fcm">How to Drive Android From a Server With FCM</h2>
<p>This is the deepest difference between the two platforms.</p>
<p>On iOS, APNs updates the widget through the system, and your app code never runs. On Android, there's no system-managed remote update. A data-only FCM message wakes a <a href="https://firebase.google.com/docs/reference/android/com/google/firebase/messaging/FirebaseMessagingService"><code>FirebaseMessagingService</code></a>, and your code re-posts the notification. The app is the updater, even from the background.</p>
<pre><code class="language-kotlin">// Registered in the manifest, so Android can instantiate it WITHOUT your
// Activity, your React context, or your module ever existing.
class DroptrackFcmService : FirebaseMessagingService() {

  // Called on install, and again whenever FCM rotates the token. Never assume
  // the token you saw at startup is still valid.
  override fun onNewToken(token: String) {
    Log.i(DeliveryNotifier.TAG, "[DropTrack] fcm token: $token")
  }

  override fun onMessageReceived(message: RemoteMessage) {
    // FCM `data` is always Map&lt;String, String&gt;. Every number and boolean was
    // stringified on the way out and has to be parsed back here.
    val d = message.data

    // No activityId means we cannot address a notification. Bail rather than
    // guess: posting under the wrong id creates a duplicate card.
    val activityId = d["activityId"] ?: return
    val event = d["event"] ?: "update"

    // This service is a SEPARATE entry point. It cannot see the module's
    // in-memory state, so every field must come from the payload, and every
    // parse must be defensive, because a crash here kills the update.
    DeliveryNotifier.ensureChannel(this)   // the process may be brand new
    DeliveryNotifier.post(
      ctx = this,
      activityId = activityId,
      // Every `?:` below is load-bearing. toDoubleOrNull returns null rather
      // than throwing on malformed input, so a bad payload degrades the card
      // instead of killing the service.
      orderId = d["orderId"] ?: "",
      status = d["status"] ?: "",
      progress = d["progress"]?.toDoubleOrNull() ?: 0.0,
      etaEpochMillis = d["etaEpochMillis"]?.toDoubleOrNull() ?: 0.0,
      stopsRemaining = d["stopsRemaining"]?.toIntOrNull() ?: 0,
      courierName = d["courierName"] ?: "",
      riderReassigned = d["riderReassigned"]?.toBoolean() ?: false,
      // "end" clears `ongoing`, which lets the user finally swipe the card away.
      ongoing = event != "end",
    )
  }
}
</code></pre>
<p>That "separate entry point" comment is the crux, so it's worth slowing down on. Android can start this service on its own to deliver a push, at a moment when the rest of your app isn't running. There's no React, no module instance, and none of the objects the app was holding in memory. So the push handler can't look up "what delivery is in progress" from app state, because there's no app state to look at. Everything it needs has to come from the push payload itself.</p>
<p>That forces a specific design. The code that builds a notification can't live inside the app and read app state. It has to be standalone code that takes plain values and nothing else.</p>
<p>So I pulled all of it into a shared <code>DeliveryNotifier</code> object, and both paths call it: the in-app path when JavaScript drives an update, and the push path when the service does. Because they run the same builder from the same inputs, they produce an identical notification, and that shared builder is the single most important structural change on the Android side.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/652317c7-291c-4480-a402-fe44c60fcb7f.png" alt="DropTrack notification updated by a remote FCM push while the app was backgrounded" style="display: block;" width="640" height="269" loading="lazy">

<h3 id="heading-the-four-rules-of-android-push">The Four Rules of Android Push</h3>
<p>Four things have to be right, or the push half quietly fails. In short: send the right kind of message, authenticate the harder way, read the error codes correctly, and wire the plugin through prebuild. Each one in full below.</p>
<h4 id="heading-rule-1-send-a-data-only-message-never-one-with-a-notification-block">Rule 1: send a data-only message, never one with a <code>notification</code> block</h4>
<p>This is the rule that catches everyone. A message with a <code>notification</code> block, while the app is backgrounded or killed, is handled by the system tray, and your <code>onMessageReceived</code> code never runs, so the card never updates. Only a data-only message (no <code>notification</code> block) reaches your code in the background.</p>
<p>Firebase documents this in <a href="https://firebase.google.com/docs/cloud-messaging/android/receive">Receive messages in an Android app</a>. Send data-only, and set <a href="https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidconfig"><code>android.priority</code></a> to <code>high</code> so the app wakes promptly.</p>
<h4 id="heading-rule-2-authenticate-with-a-service-account-token-not-a-static-key">Rule 2: authenticate with a service-account token, not a static key</h4>
<p>FCM v1 auth is heavier than APNs, with no one-shot <code>.p8</code>. You mint an RS256 JWT from a <a href="https://developers.google.com/identity/protocols/oauth2/service-account">service account</a>, exchange it for a short-lived OAuth access token, and send that access token as a bearer credential to <a href="https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send"><code>projects.messages.send</code></a>. The consolation is that FCM v1 is plain HTTPS, so <code>fetch()</code> works.</p>
<pre><code class="language-javascript">// `sa` is the parsed service-account JSON from Firebase. This JWT is not the
// credential you send to FCM; it is the one you trade for a short-lived token.
export function buildAuthJWT(sa) {
  const now = Math.floor(Date.now() / 1000);
  const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));  // RSA key, so no ieee-p1363 trap
  const claims = b64url(JSON.stringify({
    iss: sa.client_email,
    scope: "https://www.googleapis.com/auth/firebase.messaging",  // exact scope, or every send 403s
    aud: sa.token_uri,        // the JWT is FOR Google's token endpoint
    iat: now,
    exp: now + 3600,          // one hour, Google's maximum
  }));
  const signingInput = `${header}.${claims}`;
  const sig = createSign("RSA-SHA256").update(signingInput).sign(sa.private_key);
  return `${signingInput}.${b64url(sig)}`;
}

export async function sendDataMessage({ token, data, sa }) {
  const accessToken = await getAccessToken(sa);   // trades the JWT for a token; cache the result
  const res = await fetch(
    `https://fcm.googleapis.com/v1/projects/${sa.project_id}/messages:send`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        message: {
          token,                        // per-DEVICE token (APNs is per-activity)
          data,                         // note: NO `notification` key (see below)
          android: { priority: "high" },
        },
      }),
    }
  );
  const body = await res.json().catch(() =&gt; ({}));   // never throw on a bad error body
  return { status: res.status, reason: body?.error?.status ?? null };
}
</code></pre>
<p>The design turns on two absences and one indirection. The indirection is the token exchange. Unlike APNs, where the JWT <em>is</em> the credential, here you POST the JWT to Google's token endpoint, get back a short-lived access token, and send that, so cache it rather than re-minting per message.</p>
<p>The first absence is any <code>notification</code> key in the body, which is deliberate and covered next. The second is high priority over normal, because <code>normal</code> may be held until the device leaves Doze, by which time the delivery has finished.</p>
<p>On the response, <code>404 UNREGISTERED</code> means drop the token while <code>400 INVALID_ARGUMENT</code> means it was never a token. The caller needs to tell those apart, so the reason string is returned rather than swallowed.</p>
<p>FCM <code>data</code> values are a map of string to string, so everything is stringified on the way out and parsed on the way in. Unlike iOS, there's no silent shape mismatch, because you wrote the parser. But your parser must never throw.</p>
<h4 id="heading-rule-3-treat-400-and-404-as-different-failures">Rule 3: treat <code>400</code> and <code>404</code> as different failures</h4>
<p>FCM splits two cases that APNs collapses. A malformed token returns <code>400 INVALID_ARGUMENT</code>, while a well-formed but unregistered token returns <code>404 UNREGISTERED</code> (the full list is in the <a href="https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode">FCM ErrorCode reference</a>).</p>
<p>APNs reports both as <code>BadDeviceToken</code>. This matters for the fake-token auth probe from the iOS section: on FCM a fake token gives <code>INVALID_ARGUMENT</code>, not <code>UNREGISTERED</code>. Either one still proves your auth works, since a bad service account fails earlier with <code>401</code> or <code>403</code>. I wrote my probe expecting <code>UNREGISTERED</code>, and correcting the test is how I learned the distinction.</p>
<h4 id="heading-rule-4-wire-the-google-services-plugin-through-prebuild">Rule 4: wire the <code>google-services</code> plugin through prebuild</h4>
<p>The <a href="https://developers.google.com/android/guides/google-services-plugin"><code>google-services</code> plugin</a> is what reads your Firebase config into the build. Because Continuous Native Generation regenerates the <code>android</code> folder on every <code>prebuild</code>, a <a href="https://docs.expo.dev/config-plugins/introduction/">config plugin</a> has to re-place <code>google-services.json</code> and re-add the Gradle wiring each time, or the setting is lost on the next prebuild.</p>
<pre><code class="language-javascript">// plugins/withAndroidFcm.js
function withGoogleServicesJson(config) {
  // withDangerousMod runs arbitrary filesystem work during prebuild. It is
  // "dangerous" because nothing validates the result. It is also the only way
  // to place a file the Gradle plugin expects to already exist.
  return withDangerousMod(config, ["android", (cfg) =&gt; {
    // Kept at the repo root, gitignored, and copied in on every prebuild.
    const src = path.join(cfg.modRequest.projectRoot, "google-services.json");
    // Must land in android/app/, where the google-services plugin looks for it.
    const dest = path.join(cfg.modRequest.platformProjectRoot, "app", "google-services.json");

    // Fail loudly at prebuild. Without this the build succeeds, the app starts,
    // and FCM initialisation quietly no-ops at runtime.
    if (!fs.existsSync(src)) {
      throw new Error("[withAndroidFcm] google-services.json not found at project root");
    }

    fs.copyFileSync(src, dest);
    return cfg;
  }]);
}
// plus withProjectBuildGradle for the classpath,
// and withAppBuildGradle to apply the plugin.
</code></pre>
<p>Have it throw when the file is missing. Failing fast at prebuild beats a mystery at runtime.</p>
<h3 id="heading-the-honest-caveat">The Honest Caveat</h3>
<p>A data message can be dropped under <a href="https://developer.android.com/training/monitoring-device-state/doze-standby">Doze</a> or after a force-kill. An iOS Live Activity is system-owned and updates regardless. High priority helps. This is a platform limitation to document rather than engineer around. Test with the app backgrounded, not swiped away, and be honest with your product team about the difference.</p>
<h2 id="heading-three-ux-gaps-the-naive-implementation-leaves">Three UX Gaps the Naïve Implementation Leaves</h2>
<p>Because your code owns the Android side, you inherit three responsibilities that iOS handles quietly for you. I shipped all three only after hitting each one in testing.</p>
<h3 id="heading-tapping-the-notification-does-nothing">Tapping the Notification Does Nothing</h3>
<p><code>NotificationCompat</code> posts happily, but with no <code>setContentIntent(PendingIntent)</code> there is no tap target, so Android just expands and collapses the notification. Nothing in the documentation shouts this at you.</p>
<pre><code class="language-kotlin">private fun launchIntent(ctx: Context, activityId: String): PendingIntent? {
  // Ask the package manager for our own launcher intent, rather than naming
  // MainActivity. Under Expo's Continuous Native Generation that class name is
  // generated, so hardcoding it breaks on the next prebuild.
  val intent = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
    // SINGLE_TOP: reuse the existing task instead of stacking a second copy.
    // CLEAR_TOP: drop anything above it, so the user lands on the tracking screen.
    ?.apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP }
    ?: return null

  return PendingIntent.getActivity(
    ctx,
    // The request code. Keyed per activity so two concurrent deliveries get
    // two distinct PendingIntents rather than silently sharing one.
    notificationIdFor(activityId),
    intent,
    // FLAG_IMMUTABLE is mandatory on Android 12+; omit it and this throws.
    // FLAG_UPDATE_CURRENT refreshes the extras of the existing PendingIntent
    // rather than handing back a stale one from an earlier delivery.
    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
  )
}
</code></pre>
<p><a href="https://developer.android.com/reference/android/app/PendingIntent#FLAG_IMMUTABLE"><code>FLAG_IMMUTABLE</code></a> is required on Android 12 and later. Verify the result with <code>adb shell dumpsys notification</code>, which should show <code>contentIntent=PendingIntent{... startActivity}</code>.</p>
<h3 id="heading-a-push-updates-the-notification-but-not-your-interface">A Push Updates the Notification But Not Your Interface</h3>
<p>The service is a separate entry point from your JavaScript state, so a push arriving while the app is open leaves the app showing a stale step. The fix is a static bridge, set when a module instance exists and cleared when it does not.</p>
<pre><code class="language-kotlin">companion object {
  // Static, because the FCM service cannot reach a module INSTANCE. @Volatile
  // because the service runs on a different thread than the one that sets this.
  // Nullable because, most of the time, there is no live module to emit into.
  @Volatile private var pushEmitter: ((Map&lt;String, String&gt;) -&gt; Unit)? = null

  // Safe to call from anywhere. The `?.` is the entire "is the app alive?" check.
  fun emitPush(data: Map&lt;String, String&gt;) { pushEmitter?.invoke(data) }
}

override fun definition() = ModuleDefinition {
  // Declares the events JS may subscribe to. Emitting an undeclared name throws.
  Events("onFcmTokenReceived", "onDeliveryPush")

  // Register the bridge when a module instance exists...
  OnCreate  { pushEmitter = { data -&gt; sendEvent("onDeliveryPush", data) } }
  // ...and tear it down when it does not. Skip this and you leak a closure
  // holding a dead React context, then crash on the next push.
  OnDestroy { pushEmitter = null }
  // ...
}
</code></pre>
<p>The service calls <code>DroptrackLiveModule.emitPush(d)</code> after <code>notify()</code>. It does nothing when the app is dead, in which case the notification still updates and there's simply nothing to sync. This is the Android analogue of the iOS foreground resync.</p>
<h3 id="heading-a-cold-started-app-comes-up-empty">A Cold-started App Comes Up Empty</h3>
<p>iOS recovers from <code>Activity.activities</code>. Android has no such store, so a killed app reopened by tapping its own notification shows "Not tracking". Persist the delivery to <a href="https://developer.android.com/reference/android/content/SharedPreferences"><code>SharedPreferences</code></a> on every <code>notify()</code>, then read it back.</p>
<pre><code class="language-kotlin">// Same JS name as the iOS implementation, so App.tsx never branches on platform.
AsyncFunction("getRunningActivities") {
  val ctx = context ?: return@AsyncFunction emptyList&lt;Map&lt;String, Any?&gt;&gt;()

  // Our stand-in for iOS's Activity.activities: the record written on notify().
  val active = DeliveryNotifier.activeDelivery(ctx)
    ?: return@AsyncFunction emptyList&lt;Map&lt;String, Any?&gt;&gt;()
  val activityId = active["activityId"] as String

  // Phantom guard: trust the system's notification list over our own record.
  if (!DeliveryNotifier.isActive(ctx, activityId)) {
    DeliveryNotifier.clear(ctx)
    return@AsyncFunction emptyList&lt;Map&lt;String, Any?&gt;&gt;()
  }

  // Repopulate the per-process map, or a later update/end throws after cold start.
  deliveries[activityId] = DeliveryInfoRecord().apply { orderId = active["orderId"] as String }
  return@AsyncFunction listOf(active)
}
</code></pre>
<p>Two subtleties here I only found by breaking them. Guard the rehydration with <a href="https://developer.android.com/reference/android/app/NotificationManager#getActiveNotifications()"><code>getActiveNotifications()</code></a>. Without that check, a stale record produces an uncancellable phantom delivery whose Cancel button throws <code>ActivityNotFoundException</code>. And make <code>endDelivery</code> forgiving. If the activity isn't in the in-memory map because you cold-started, still cancel the notification and clear the state rather than throwing. Tearing something down should never fail because you've forgotten about it.</p>
<p>Because the resync effect in <code>App.tsx</code> is cross-platform, implementing Android's <code>getRunningActivities()</code> lights up the existing cold-start path with no JavaScript changes. That's the payoff of keeping one API across two backends.</p>
<h2 id="heading-how-to-script-the-simulators-and-devices">How to Script the Simulators and Devices</h2>
<p>Two small tools saved more time than any feature.</p>
<p>The iOS simulator has no scriptable tap. There's no <code>uiautomator</code> equivalent, and synthetic clicks need macOS accessibility grants. So I added a development-only deep-link driver, fifteen lines, stripped from release builds.</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
  // Dead code in release builds: the bundler strips the whole effect body.
  if (!__DEV__) return;

  // "droptrack://drive/next" -&gt; "next" -&gt; call actions.next().
  // actionsRef, not actions: the listener is registered once, so a plain
  // closure would capture the first render's handlers forever.
  const run = (url: string) =&gt; actionsRef.current[url.split("/").pop() ?? ""]?.();

  // Case 1: app already running. simctl openurl fires this event.
  const sub = Linking.addEventListener("url", ({ url }) =&gt; run(url));

  // Case 2: devicectl --payload-url COLD-STARTS the app: the URL arrives as the
  // initial URL and never fires the 'url' event. Handle both or device
  // automation silently does nothing while simulator automation works.
  void Linking.getInitialURL().then((url) =&gt; url &amp;&amp; run(url));

  return () =&gt; sub.remove();
}, []);
</code></pre>
<pre><code class="language-shell"># Drives the running app one step forward, with no tap and no accessibility grant.
xcrun simctl openurl booted "com.fasarticle.droptrack://drive/next"
</code></pre>
<p>For the push half I built a small web dispatcher. A browser cannot reach APNs or FCM, because of HTTP/2, missing cross-origin headers, and the fact that signing keys must never leave the server. So it talks to a zero-dependency local Node.js signing server that scrapes push tokens off the device console, streams them to the browser with server-sent events, and sends to whichever platform you select.</p>
<pre><code class="language-text">iPhone  --NSLog--&gt; devicectl --console --+
Android --Log.i--&gt; adb logcat -----------+
                                         | scrape
browser &lt;--SSE /events-- dispatch-server (127.0.0.1:8787)
   \--POST /push--------&gt; apns.mjs / fcm.mjs --&gt; APNs / FCM --&gt; device
</code></pre>
<p>Pick a step, pick a courier, and press one button. It turns a two-minute test into a ten-second one, and it made the Samsung investigation below possible at all.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/ac41eefe-7672-4668-a26d-2e375879621b.png" alt="The DropTrack web dispatcher, one control plane for iOS and Android pushes" style="display: block;" width="900" height="1546" loading="lazy">

<h2 id="heading-how-ios-and-android-compare">How iOS and Android Compare</h2>
<table>
<thead>
<tr>
<th></th>
<th>iOS Live Activity</th>
<th>Android Live Update</th>
</tr>
</thead>
<tbody><tr>
<td>Introduced</td>
<td>iOS 16.1 and 16.2</td>
<td>Android 16 (API 36), promotion in 36.1</td>
</tr>
<tr>
<td>Interface</td>
<td>Custom SwiftUI in a widget extension</td>
<td>System <code>ProgressStyle</code> template, custom <code>RemoteViews</code> not allowed when promoted</td>
</tr>
<tr>
<td>Surfaces</td>
<td>Lock screen and Dynamic Island, plus Mac via Continuity</td>
<td>Shade, status-bar chip, promoted lock-screen slot</td>
</tr>
<tr>
<td>Update while app closed</td>
<td>Per-activity APNs token. The system updates the widget</td>
<td>Re-post the same notification id. Remote means an FCM data message that wakes your service</td>
</tr>
<tr>
<td>Progress bar</td>
<td>Hand-built, an <code>HStack</code> of capsules</td>
<td><code>ProgressStyle</code> segments and points</td>
</tr>
<tr>
<td>Promotion rules</td>
<td>Automatic once started</td>
<td>Permission, request, ongoing, title, allowed style, importance above <code>MIN</code>, not colorized</td>
</tr>
<tr>
<td>Push auth</td>
<td>ES256 <code>.p8</code> key, HTTP/2</td>
<td>Service-account RS256 to OAuth token, plain HTTPS</td>
</tr>
<tr>
<td>Payload decoding</td>
<td>The system decodes into your <code>Codable</code>. A mismatch drops silently</td>
<td>You parse it yourself in your service</td>
</tr>
<tr>
<td>Cold-start recovery</td>
<td><code>Activity.activities</code>, owned by the system</td>
<td>You persist and read it back yourself</td>
</tr>
<tr>
<td>Tap to open</td>
<td>Free</td>
<td>You must attach a content <code>PendingIntent</code></td>
</tr>
<tr>
<td>Delivery guarantee</td>
<td>System-owned and reliable</td>
<td>Data message, droppable under Doze or force-kill</td>
</tr>
</tbody></table>
<h2 id="heading-the-samsung-reality-check">The Samsung Reality Check</h2>
<p>Version numbers lie, and Samsung is where they lie loudest. I tested by hand across three real Galaxy devices using <a href="https://developer.samsung.com/remote-test-lab">Samsung Remote Test Lab</a>, whose Remote Debug Bridge turns out to be a full local <code>adb</code> tunnel to a phone in Korea. The entire emulator automation playbook (granting permissions, tapping, taking screenshots, and reading <code>dumpsys</code>) works unchanged against remote hardware.</p>
<p>Here's what the same unmodified APK did on each:</p>
<table>
<thead>
<tr>
<th>Device</th>
<th>One UI / SDK</th>
<th><code>canPostPromotedNotifications()</code></th>
<th>What actually showed</th>
</tr>
</thead>
<tbody><tr>
<td>Galaxy S25 Ultra</td>
<td>8.0 / 36.0</td>
<td><code>false</code></td>
<td>Nothing. No chip, no promoted card</td>
</tr>
<tr>
<td>Galaxy S26 Ultra</td>
<td>8.5 / 36.1</td>
<td><code>true</code></td>
<td>Top-of-shade pinning and a status-bar icon</td>
</tr>
<tr>
<td>Galaxy A37 (mid-range)</td>
<td>8.5 / 36.1</td>
<td><code>true</code></td>
<td>Same as the S26</td>
</tr>
</tbody></table>
<p>Two findings come out of that table.</p>
<h4 id="heading-1-on-the-base-36-device-the-two-capability-checks-disagree">1. On the base-36 device, the two capability checks disagree</h4>
<p>The S25 Ultra runs base 36, which has no promotion pipeline, so nothing promotes. But <code>hasPromotableCharacteristics()</code> returns <code>true</code> there (Samsung backported some framework pieces) while <code>canPostPromotedNotifications()</code> returns <code>false</code>. A base-36 Pixel emulator returns <code>false</code> for both. So you can't infer one check from the other. Detect both at runtime, and trust <code>canPostPromotedNotifications()</code> for whether promotion will actually happen.</p>
<h4 id="heading-2-on-the-361-devices-promotion-works-but-only-partly">2. On the 36.1 devices, promotion works but only partly</h4>
<p>Both the S26 Ultra and the A37 genuinely grant <code>FLAG_PROMOTED_ONGOING</code> to the unmodified APK, which you can confirm in <code>dumpsys</code>. You get top-of-shade pinning and the status-bar icon. You do <em>not</em> get the chip pill, the lock-screen card, or a Now Bar entry, even though the promotion succeeded.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/71557971-c9d3-4936-ab7d-1ee9b2b779ef.png" alt="DropTrack promoted to the top of the shade on Samsung One UI 8.5" style="display: block;" width="640" height="1386" loading="lazy">

<p>That "only partly" was the mystery, until I found where the missing surfaces went. In Settings, under Lock screen and Always On Display, there's a page called Live notifications (searchable as "live notification", notably not as "Now bar"). It promises exactly the surfaces I was missing: the lock screen, the status bar, and the top of the notification panel, illustrated with the Now Bar pill. And directly below that promise sits a fixed, six-app allowlist: Audio broadcast, Emergency sharing, Google Finance, Maps, Media player, and Sports from Google.</p>
<p>DropTrack isn't on that list. Yet its promoted delivery was live on that very device at that very second, and the page's own "Not seeing Live notifications?" checklist (three notification permissions) was fully satisfied. The feature was working. Samsung simply doesn't offer its best surfaces to apps outside the six.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/5b2ad0b1-7c3d-4b96-ab37-8d82c72b09fb.png" alt="Samsung's Live notifications settings page showing a fixed six-app allowlist" style="display: block;" width="640" height="1386" loading="lazy">

<p>So the screenshot-backed conclusion for these units is this: One UI 8.5 accepts Google's promotion contract and ships the minor surfaces to any app, but reserves the headline ones for a hardcoded list. This reflects those specific devices at the time of testing, and Samsung may change it.</p>
<p>It leaves the three platforms in three different places. Apple offers a public API on every device. Google offers a public API on its own hardware. Samsung offers the framework to everyone but keeps the best stage by invitation.</p>
<h2 id="heading-the-demo-repository">The Demo Repository</h2>
<p>Everything in this handbook is one working project, <a href="https://github.com/FastheDeveloper/LiveActivity">DropTrack</a>, released under the MIT licence. It's not a snippet dump. It's a single React Native app whose one delivery-tracking feature reaches down through five layers. The point of reading it is to see how those layers connect rather than how any one of them looks in isolation.</p>
<p>Here's what lives in the repo, layer by layer:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Where</th>
<th>What is there</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Mobile app</strong></td>
<td><code>App.tsx</code>, <code>delivery.ts</code></td>
<td>The React Native UI and the one cross-platform TypeScript API (<code>startDelivery</code> / <code>updateDelivery</code> / <code>endDelivery</code>) that both native sides implement</td>
</tr>
<tr>
<td><strong>iOS native</strong></td>
<td><code>modules/droptrack-live/ios/</code>, <code>targets/widgets/</code></td>
<td>The Swift ActivityKit bridge and the SwiftUI widget with its four presentations, the segmented bar, and the reassignment treatment</td>
</tr>
<tr>
<td><strong>Android native</strong></td>
<td><code>modules/droptrack-live/android/</code></td>
<td>The Kotlin <code>NotificationCompat</code> builder, the promotion logic, and the <code>FirebaseMessagingService</code> that re-posts from a push</td>
</tr>
<tr>
<td><strong>Web</strong></td>
<td><code>DispatcherConsole.tsx</code>, <code>src/dispatchClient.ts</code></td>
<td>The Expo-web dispatcher console, a browser UI for composing and firing pushes at a chosen device</td>
</tr>
<tr>
<td><strong>Backend</strong></td>
<td><code>scripts/</code></td>
<td>The APNs client (about 100 lines) and the FCM client (about 80 lines) written from scratch with no push libraries, plus the local signing server that ties the console to real devices</td>
</tr>
</tbody></table>
<p>A few things make it worth cloning rather than skimming:</p>
<ul>
<li><p><strong>It genuinely runs on all three surfaces:</strong> The same <code>DeliveryState</code> object drives a SwiftUI Live Activity, an Android promoted notification, and a web console, so you can watch one API produce three very different results.</p>
</li>
<li><p><strong>The push clients have no dependencies:</strong> <code>scripts/apns.mjs</code> and <code>scripts/fcm.mjs</code> use only Node built-ins, so you can read the entire APNs and FCM path end to end without unpacking a library. Both have small test files next to them.</p>
</li>
<li><p><strong>Every gotcha in this article is written up in</strong> <a href="https://github.com/FastheDeveloper/LiveActivity/blob/main/GOTCHAS.md"><code>GOTCHAS.md</code></a><strong>:</strong> The long checklist that used to live in this section now lives there, next to the code it refers to, alongside <code>DEVLOG.md</code> (how the build unfolded) and <code>ARTICLE_NOTES.md</code>.</p>
</li>
<li><p><strong>It's safe to fork:</strong> The Firebase service account, the <code>google-services.json</code>, and the APNs <code>.p8</code> key are all gitignored, so nothing sensitive is in the history. The <code>README.md</code> lists exactly which of those you supply to run the push phase yourself.</p>
</li>
</ul>
<p>Clone it, run the app on a simulator, open the web console, and push an update to your own device. That loop is the fastest way to make everything above concrete.</p>
<h2 id="heading-what-to-know-before-you-start">What to Know Before You Start</h2>
<p><strong>The product feature is identical, and the platform contracts are opposites.</strong> iOS gives you a system-owned widget and updates it for you. Android gives you a notification and makes you the update engine, down to re-posting it from a background service on every message. Design your module's seam accordingly. The same three-function API can hide wildly different machinery, and that is exactly what a good native module is for.</p>
<p><strong>The silent failures are the tax.</strong> Almost every hard bug in this project, from the mismatched widget struct, to the missing entitlement, the wrong epoch, the un-promoted notification, and the <code>notification</code>-instead-of-<code>data</code> message, fails with zero errors. So instrument aggressively. Log promotable characteristics. Verify entitlements survived signing. Probe push auth with fake tokens before you involve a device. Always confirm on the real surface rather than trusting a <code>200</code>.</p>
<p><strong>Version numbers lie, so detect features instead.</strong> Android 16 is two releases. Samsung's Android 16 is a third. <code>canPostPromotedNotifications()</code> and <code>hasPromotableCharacteristics()</code> can disagree on the same build. Check both at runtime, and never infer one from the other.</p>
<p><strong>The system owns the activity, not your process.</strong> On iOS the activity outlives your variable, your React state, and your process. On Android the notification outlives your process too, but nothing recovers it for you. Both platforms punish you for assuming the id in your <code>useState</code> is the source of truth.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You now have a complete picture of both platforms. You built one TypeScript API over two native backends, a SwiftUI widget with four presentations, a Kotlin notification that satisfies Android's promotion contract, and two push clients written from scratch with no libraries. You also know the failure modes that produce no error message, which is most of them.</p>
<p>Three things are worth exploring next:</p>
<ol>
<li><p><strong>Push-to-start, on iOS 17.2 and later.</strong> You can start a Live Activity from a push, with no app launch at all. The token is per-app rather than per-activity, which rewrites the token plumbing described above.</p>
</li>
<li><p><strong>Broadcast channels, on iOS 18.</strong> One push updates an activity for many users, which suits live scores. Every push in this handbook targets a single token.</p>
</li>
<li><p><strong>An Android foreground service.</strong> It narrows the Doze and force-kill delivery gap. It won't close it, but it makes long-running deliveries more durable than a bare data message.</p>
</li>
</ol>
<p>The interesting part was never <code>Activity.request()</code>. It was everything the two platforms decline to tell you when you get it wrong.</p>
<h2 id="heading-sources-and-further-reading">Sources and Further Reading</h2>
<p>Apple, ActivityKit and APNs:</p>
<ul>
<li><p><a href="https://developer.apple.com/documentation/activitykit">ActivityKit</a> and <a href="https://developer.apple.com/documentation/activitykit/activityattributes"><code>ActivityAttributes</code></a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities">Displaying live data with Live Activities</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications">Starting and updating Live Activities with ActivityKit push notifications</a></p>
</li>
<li><p><a href="https://developer.apple.com/design/human-interface-guidelines/live-activities">Live Activities, Human Interface Guidelines</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment">The <code>aps-environment</code> entitlement</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns">Sending notification requests to APNs</a>, <a href="https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns">Establishing a token-based connection to APNs</a>, and <a href="https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns">Handling notification responses from APNs</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/foundation/date"><code>Date</code>, and the 2001 reference date</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/widgetkit">WidgetKit</a></p>
</li>
</ul>
<p>Android and Firebase:</p>
<ul>
<li><p><a href="https://developer.android.com/about/versions/16/features/progress-centric-notifications">Progress-centric notifications in Android 16</a> and <a href="https://developer.android.com/about/versions/16">Android 16 overview</a></p>
</li>
<li><p><a href="https://developer.android.com/reference/android/app/Notification.ProgressStyle"><code>Notification.ProgressStyle</code></a> and <a href="https://developer.android.com/reference/androidx/core/app/NotificationCompat.ProgressStyle"><code>NotificationCompat.ProgressStyle</code></a></p>
</li>
<li><p><a href="https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder"><code>NotificationCompat.Builder</code></a>, where <code>setRequestPromotedOngoing</code> and <code>setShortCriticalText</code> are backported</p>
</li>
<li><p><a href="https://developer.android.com/reference/android/os/Build.VERSION#SDK_INT_FULL"><code>Build.VERSION.SDK_INT_FULL</code></a> and <a href="https://developer.android.com/reference/android/os/Build.VERSION_CODES_FULL"><code>Build.VERSION_CODES_FULL</code></a>, the 36 versus 36.1 distinction</p>
</li>
<li><p><a href="https://developer.android.com/reference/android/app/NotificationManager#canPostPromotedNotifications()"><code>NotificationManager.canPostPromotedNotifications()</code></a>, <a href="https://developer.android.com/reference/android/app/Notification#hasPromotableCharacteristics()"><code>Notification.hasPromotableCharacteristics()</code></a>, and <a href="https://developer.android.com/reference/android/app/NotificationManager#getActiveNotifications()"><code>getActiveNotifications()</code></a></p>
</li>
<li><p><a href="https://developer.android.com/reference/android/app/PendingIntent#FLAG_IMMUTABLE"><code>PendingIntent.FLAG_IMMUTABLE</code></a> and <a href="https://developer.android.com/reference/android/content/SharedPreferences"><code>SharedPreferences</code></a></p>
</li>
<li><p><a href="https://developer.android.com/jetpack/androidx/releases/core"><code>androidx.core</code> release notes</a></p>
</li>
<li><p><a href="https://developer.android.com/training/monitoring-device-state/doze-standby">Doze and App Standby</a></p>
</li>
<li><p><a href="https://firebase.google.com/docs/cloud-messaging">Firebase Cloud Messaging</a>, <a href="https://firebase.google.com/docs/cloud-messaging/android/receive">Receive messages in an Android app</a>, <a href="https://firebase.google.com/docs/reference/android/com/google/firebase/messaging/FirebaseMessagingService"><code>FirebaseMessagingService</code></a>, <a href="https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send"><code>projects.messages.send</code></a>, and the <a href="https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode">FCM <code>ErrorCode</code> reference</a></p>
</li>
<li><p><a href="https://developers.google.com/identity/protocols/oauth2/service-account">Using OAuth 2.0 for server to server applications</a> and the <a href="https://developers.google.com/android/guides/google-services-plugin"><code>google-services</code> Gradle plugin</a></p>
</li>
</ul>
<p>Apps that document their own Live Activity, cited in "Where You Have Already Seen This Feature":</p>
<ul>
<li><p><a href="https://apps.apple.com/us/app/chowdeck-food-delivery/id1530676376">Chowdeck</a>, <a href="https://apps.apple.com/us/app/espn-live-sports-scores/id317469184">ESPN</a>, <a href="https://apps.apple.com/us/app/mlb/id493619333">MLB</a>, <a href="https://apps.apple.com/us/app/fotmob-soccer-live-scores/id488575683">FotMob</a>, <a href="https://apps.apple.com/us/app/carrot-weather-alerts-radar/id961390574">CARROT Weather</a>, and <a href="https://apps.apple.com/us/app/structured-daily-planner-todo/id1499198946">Structured</a>, all via their App Store listings</p>
</li>
<li><p><a href="https://flighty.com/help/live-activities-widgets">Flighty's Live Activities help page</a></p>
</li>
<li><p><a href="https://support.apple.com/guide/apple-sports-app/follow-games-in-real-time-apdc0cb7ad64/web">Apple Sports, following games in real time</a></p>
</li>
<li><p>MacRumors on <a href="https://www.macrumors.com/2023/05/02/uber-eats-live-activities/">Uber Eats, May 2023</a> and <a href="https://www.macrumors.com/2023/12/04/doordash-rolling-out-live-activities/">DoorDash, December 2023</a></p>
</li>
<li><p>MacStories, <a href="https://www.macstories.net/reviews/ios-16-1-and-apps-with-live-activities-the-macstories-roundup-part-1/">the iOS 16.1 Live Activities roundup</a></p>
</li>
</ul>
<p>React Native, Expo, and tooling:</p>
<ul>
<li><p><a href="https://docs.expo.dev/modules/overview/">Expo Modules API overview</a> and the <a href="https://docs.expo.dev/modules/module-api/">module API reference</a></p>
</li>
<li><p><a href="https://docs.expo.dev/config-plugins/introduction/">Expo config plugins</a> and <a href="https://docs.expo.dev/workflow/continuous-native-generation/">Continuous Native Generation</a></p>
</li>
<li><p><a href="https://github.com/EvanBacon/expo-apple-targets"><code>@bacons/apple-targets</code></a>, which generates the widget extension</p>
</li>
<li><p><a href="https://github.com/software-mansion-labs/expo-live-activity"><code>expo-live-activity</code></a>, the packaged alternative</p>
</li>
<li><p><a href="https://github.com/invertase/notifee">Notifee</a>, archived, and its last release <a href="https://www.npmjs.com/package/@notifee/react-native"><code>@notifee/react-native@9.1.8</code></a></p>
</li>
<li><p>React Native <a href="https://reactnative.dev/docs/appstate"><code>AppState</code></a> and <a href="https://reactnative.dev/docs/linking"><code>Linking</code></a></p>
</li>
<li><p>Node.js <a href="https://nodejs.org/api/crypto.html"><code>crypto</code></a> and <a href="https://nodejs.org/api/http2.html"><code>http2</code></a></p>
</li>
<li><p><a href="https://datatracker.ietf.org/doc/html/rfc7519">RFC 7519, JSON Web Token</a> and <a href="https://datatracker.ietf.org/doc/html/rfc7518">RFC 7518, JSON Web Algorithms</a>, which define the ES256 and RS256 signature formats</p>
</li>
<li><p><a href="https://developer.samsung.com/remote-test-lab">Samsung Remote Test Lab</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
