<?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>Tue, 14 Jul 2026 20:04:48 +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 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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>
