Every trap worth knowing when building Live Activities (iOS) and Live Updates (Android) in a React Native / Expo project, with the symptom and the fix. Most of these fail silently, which is what makes them expensive.
[CXX1101] NDK at .../ndk/27.1.12297006 did not have a source.properties file— the NDK directory exists but is an empty husk from an aborted install, which is enough to stop Gradle from auto-provisioning it. React Native 0.86 pins NDK 27.1.12297006 exactly. Delete the broken directory and reinstall:sdkmanager --sdk_root=$ANDROID_HOME "ndk;27.1.12297006".- A piped
./gradlew assembleDebug | tail -5reports exit code 0 even when the build fails — the pipe swallows Gradle's exit status. Grep the log forBUILD SUCCESSFULrather than trusting the pipeline's exit code. - Emulator system-image install fails with
Not in GZIP format— the same disease as the NDK husk, wider blast radius. A disk-full episode can leave the API 36 system-image directories as empty husks (so the AVDs look configured but never boot) and a truncated.asdownloadfile in$ANDROID_HOME/.downloadIntermediatesthat the installer keeps trying to resume. Delete the husk dirs and the stale intermediates (plus$ANDROID_HOME/.temp/PackageOperation*), then reinstall viasdkmanager "system-images;android-36;google_apis;arm64-v8a". An AVD'sconfig.inipointing at a system image proves nothing; check the image directory actually has files in it. sdkmanager's downloader can hang forever on a dead socket — zero bytes, no timeout, no error. Download the samedl.google.com/android/repository/...zip withcurl -fL --retry 10 -C -, unzip it intosystem-images/<api>/<tag>/, and hand-write a minimalpackage.xmlso Android Studio stops offering to install it.No space left on deviceat:app:mergeDebugNativeLibs— a React Native Android debug build is large and the pinned NDK adds a couple more GB. Budget roughly 25 GB free before the first build; Xcode DerivedData, the CocoaPods cache, and the npm cache are safe to reclaim from.expo prebuild --cleancan die withENOTEMPTY: directory not empty, rmdir— a race while deleting the large Pods or APK tree (file watchers touching files mid-delete). Harmless in a CNG project:rm -rf ios && npx expo prebuild -p ios(orrm -rf android && npx expo prebuild -p android).create-expo-module --local modules/droptrack-livecreatesmodules/modules/…— the CLI already prefixesmodules/, so passing a path that includes it nests twice. Pass the bare name.
- The widget's
DeliveryAttributes.swiftmust be an EXACT copy of the module's. The app and the extension compile separate copies, and ActivityKit pairs them by type name and Codable shape at runtime. A drift does not error; the activity UI just silently never renders. A shared-folder trick does not help here, because the module compiles as its own pod rather than into the app target. - The widget extension's bundle id must be
<app-bundle-id>.<suffix>and must not equal the app's bundle id. - The App Group must be added to BOTH targets (app and extension), or shared data silently fails.
- "Copy only when installing" on the extension's embed build phase means debug builds ship without the extension.
- Widget extensions have no network access. Pre-fetch images in the app and pass them through the App Group container.
- Island or lock-screen blank even though the activity is running — the activity was started by a build that did not yet contain the widget extension. Activities bind to the build that started them, and adding the extension later does not retro-fix live ones. End the stale activity and start a fresh one after every extension change.
- The compact island never shows while your own app is in the foreground. Switch to another app or lock the device before concluding it is broken.
- Reinstalling can wedge
chronod, the widget-platter daemon. The first activity started right aftersimctl installcan hitwidgetDescriptorNotFound, and ending and restarting the activity does not clear it.pluginkit -mshowing the extension proves nothing, because the daemon itself is stuck. Fix:simctl shutdown && boot, then start a fresh activity. - A full JS reload orphans the activity — the React state holding
activityIddies while the activity lives for hours. Log the id so it is recoverable, and add anendAll()development helper. - Tapping the Live Activity cold-starts your app, and your app has amnesia. Live Activities are
owned by the system and survive force-quit, but
activityIdnormally lives in React state, so the user-triggered launch lands on a UI insisting nothing is being tracked. The activity is fine (Activity.activitiesstill lists it; an APNs push returns 200, a dead one 410). EnumerateActivity<T>.activitieson launch and onAppStateactive, re-attach, and recover the step fromactivity.content.state. Re-subscribe topushTokenUpdateswhile you are there, because the previous process'sfor awaitloop died with it. - The iOS simulator has no scriptable tap (no uiautomator equivalent; synthetic clicks need
macOS accessibility grants). A
__DEV__-only deep-link driver works:simctl openurl booted <bundle-id>://drive/<action>plus aLinkinglistener. Note thatopenurlforegrounds the app, and the compact island hides while the app is foreground. - Simulator screenshots can serve a frozen framebuffer (clock stuck,
openurl"works" but nothing changes).simctl shutdownandbootfixes it.
pushType: .tokendoes nothing without theaps-environmententitlement.Activity.requeststill succeeds and local updates still work, butactivity.pushTokenUpdatesnever yields, and nothing errors. Addaps-environment: developmenttoios.entitlementsinapp.json, and confirm it survived signing:codesign -d --entitlements - --xml <App>.app.- The push token is per-ACTIVITY, not per-device. It arrives asynchronously after
Activity.requestreturns and can be rotated mid-flight. Consumeactivity.pushTokenUpdates(anAsyncSequence) rather than readingactivity.pushTokenonce; a single read right afterrequest()is usually nil. apns-topicis<bundle-id>.push-type.liveactivity, not the bare bundle id, andapns-push-type: liveactivityis required. A plain bundle-id topic is rejected.- Dev-signed builds must talk to
api.sandbox.push.apple.com. Hitting the production host with a sandbox token returns400 BadDeviceToken, which reads like a malformed token and sends you debugging the wrong layer. - ES256 JWTs need raw r‖s signatures. Node's
crypto.signdefaults to DER, which APNs rejects; passdsaEncoding: 'ieee-p1363'. APNs is HTTP/2-only, sofetch()cannot talk to it; usenode:http2. - Diagnose auth versus token errors without a device: push to the sandbox with a fake device
token.
400 BadDeviceTokenproves the JWT,.p8, team, and Key ID are all correct (APNs got far enough to look the token up).403 InvalidProviderTokenmeans the key itself is wrong. aps.timestampis an ordering guard. A push whose timestamp is older than the last one applied is silently discarded.- Two different "eta" shapes: the native-bridge shape is not the APNs content-state shape. A JS
DeliveryStatebuilt for the native module carriesetaEpochMillis(Unix ms), which Swift converts to aDate. But an APNs push'scontent-stateis decoded directly by the widget's CodableContentState, which expects a field namedetaholding seconds since 2001 and has noetaEpochMillis. Reuse the native-bridge object as a push payload and it is the classic silent drop: APNs returns 200, iOS discards it, nothing logs. Translate at the boundary. - Without
NSSupportsLiveActivitiesFrequentUpdates, rapid pushes get budgeted and dropped. - A 200 from APNs proves only that Apple accepted the bytes, not that the card changed. Confirm on the lock screen.
- A Debug build on a wired-only iPhone cannot reach Metro.
react-native-xcode.shbakes the Mac's LAN IP intoip.txtinside the app, so off that network the app red-boxes withNo script URL provided. There is noadb reverseequivalent on iOS. Build--configuration Releaseso the JS bundle is embedded; that is the more honest push harness anyway, because the app can be fully force-quit. xcrun devicectl device process launch --consolestreams the app's stdout/stderr, soNSLogis the reliable way to read a value like a push token off a device with no Metro connection.--payload-urlcold-starts the app, so a deep link arrives viaLinking.getInitialURL()and never fires the'url'event that the simulator'ssimctl openurlfires. A deep-link driver must handle both.- One phone has two identifiers.
expo run:ios --devicewants the hardware UDID (fromxcrun xctrace list devices);devicectlwants the CoreDevice identifier (fromxcrun devicectl list devices). Passing one where the other belongs reads like the phone is unplugged.
- "Android 16" is two releases. Base API 36 has
ProgressStylebut notsetRequestPromotedOngoing,setShortCriticalText, or thePOST_PROMOTED_NOTIFICATIONSpermission; those are API 36.1 (QPR). Both reportSDK_INT == 36(readSDK_INT_FULL/ro.build.version.sdk_fullto tell them apart). React Native 0.86 compiles against base 36, so the platform promotion APIs do not compile. Fix:androidx.core:core-ktx:1.17.0, whoseNotificationCompatbackports all three. Corollaries:NotificationManager.canPostPromotedNotifications()can be unavailable and throw on some pre-QPR builds. Wrap it inrunCatching.- A pre-QPR device shows the
ProgressStylebar but never promotes:hasPromotableCharacteristics=false, no chip, no lock-screen slot. - Below API 36,
NotificationCompat.ProgressStyledegrades to no progress bar at all. Keep a manualsetProgress(100, pct, false)branch for pre-16.
- Promotion is all-or-nothing and silent. It flips true only with permission, request, ongoing,
a content title, an allowed style, importance above
MIN, and not colorized. Lognotification.hasPromotableCharacteristics()after everybuild(), and surfacecanPostPromotedNotifications()in a dev UI. - Custom
RemoteViewsare not allowed for promoted notifications. You get theProgressStyletemplate or no promotion. - Re-post under the same notification id, or you get a second notification instead of an in-place update.
setWhen(...)must point to a future time or the update can be skipped. One UI renders a futurewhenas an absolute clock time, Pixel as a relative countdown.- Tapping the notification does nothing without a content intent.
NotificationCompatposts fine, but with nosetContentIntent(PendingIntent)there is no tap target. Add a launchPendingIntent(getLaunchIntentForPackage+FLAG_IMMUTABLE | FLAG_UPDATE_CURRENT), keyed per activity. Verify withdumpsys notification. - Notifee is archived (last release Dec 2024,
compileSdk 34), with no Live Updates support. A custom module is the only React Native route. - Emulator AVDs can boot with the keyguard disabled — the lock-screen screenshot never shows.
adb shell locksettings set-disabled false. - Piping emulator stdout through
headkills the emulator (SIGPIPE once it logs past your line budget). Redirect to a file instead. - An AVD can point at a system image that is not installed (empty husk), and the emulator FATALs
with "Cannot find AVD system path". Use an installed
google_apisimage, which also has Play Services, all FCM needs. - FCM needs Google Play services — use a
google_apis(or_playstore) emulator image, not a bare AOSP one. Verify withadb shell pm list packages | grep com.google.android.gms.
- A
notificationFCM message never reaches your code when backgrounded. Only a data-only message routes toFirebaseMessagingService.onMessageReceivedwhile the app is backgrounded or killed; a message with anotificationblock is handled by the system tray and your re-post code never runs. Send data-only withandroid.priority: "high". - The messaging service cannot see the module's in-memory state. It is a separate entry point and may run with the module instance dead, so the data payload must carry every field needed to rebuild the notification. Extract a shared notification builder that both the module and the service call from plain parameters.
- A push updates the notification but not the app's UI. The service is a separate entry point
from the app's JS state. To keep an in-foreground app in sync, bridge the service to JS with a
@Volatilestatic emitter on the module (set inOnCreate, cleared inOnDestroy) thatsendEvents to JS afternotify(). It no-ops when the app is dead, which is fine, because the notification still updates. - A cold-started app comes up empty. Android has no
Activity.activitiesequivalent, so persist the delivery toSharedPreferenceson everynotify()and read it back on launch. Guard the rehydration withgetActiveNotifications(), or a stale record produces an uncancellable phantom delivery. Make teardown forgiving so a cold-started Cancel does not throw. - FCM error codes differ from APNs. A malformed token returns
400 INVALID_ARGUMENT; a well-formed but unregistered token returns404 UNREGISTERED. APNs collapses both intoBadDeviceToken. A fabricated token in an auth probe returnsINVALID_ARGUMENT, notUNREGISTERED; either proves auth is fine, since a bad service account gives401/403. - FCM v1 auth is a service-account RS256 JWT exchanged for an OAuth token, not a one-shot
.p8. Mint the JWT (scopefirebase.messaging), exchange it atoauth2.googleapis.com/token, then bearer the access token to/v1/projects/<id>/messages:send. FCM v1 is plain HTTPS, sofetchworks. google-servicesmust be wired through CNG.android/is generated by prebuild, so a config plugin has to re-placegoogle-services.jsonintoandroid/app/and re-add the Gradle wiring on every prebuild. Have it throw at prebuild when the file is missing.- A data message can be dropped under Doze or after a force-kill, unlike a system-owned iOS Live Activity. High priority helps. Test backgrounded (Home), not swiped away. This is a platform limitation to document, not a bug to engineer around.
- Pre-14 Pro iPhones have no Dynamic Island. Lock-screen Live Activities work, but the compact/minimal/expanded island presentations need a 14 Pro or newer, or an iOS 18+ simulator.
- Samsung ships base 36, and the OEM behavior is worse than the version number suggests. On a
base-36 One UI device, the capability checks disagree:
hasPromotableCharacteristics()returns true (backported framework bits) whilecanPostPromotedNotifications()returns false, whereas a base-36 Pixel emulator returns false for both. Never infer one check from the other. - One UI 8.5 (SDK 36.1) grants the promotion but withholds the surfaces.
canPostPromotedNotifications()returns true andFLAG_PROMOTED_ONGOINGlands on the notification (verify indumpsys), giving top-of-shade pinning and a status-bar icon, but no chip pill, no lock-screen card, and no Now Bar entry. The headline surfaces are gated behind a fixed per-app allowlist in Samsung's Live notifications settings. Do not equate "promotion granted" with "promoted UI shown" on Samsung. - Samsung Remote Test Lab is scriptable. Its Remote Debug Bridge is a local adb tunnel
(
adb devicesshowslocalhost:<port>), so granting permissions, tapping, screencap, anddumpsysall work on the remote phone. Traps: it is an x86_64 binary (slow first start under Rosetta), it ignores CLI flags and just starts its server, and two instances fight over the web client, so run exactly one. Build an arm64-only release APK (-PreactNativeArchitectures=arm64-v8a), since the remote device cannot reach Metro. - Remote Test Lab devices can boot in landscape, so taps computed from a portrait screencap land
on the wrong UI. Check rotation first and force it with
settings put system user_rotation 0. Lock screens doze in seconds; runsvc power stayon truebefore lock-screen captures.