@tiledev/sdk-tile-notification
v0.4.1
Published
OneSignal push, iOS Live Activities and Android Live Notifications for TilePacket apps — real on iOS/Android, no-op on web. Powers audience targeting for the Tile notification center.
Readme
@tiledev/sdk-tile-notification
OneSignal push notifications for TilePacket apps — real on iOS/Android, a no-op on web.
This package is the device half of Tile's notification center. The dashboard stores a per-app OneSignal App ID + REST API Key and sends through OneSignal's REST API; this package is what makes the app a subscriber of that OneSignal app, and what sets the user tags the dashboard's automations select on.
npm install @tiledev/sdk-tile-notificationreact-native-onesignal is an optional peer — install it (and run a prebuild so the
native module links) only for the platforms that need real push:
npm install react-native-onesignal@^5
npx expo install onesignal-expo-pluginWithout it the package still installs and imports cleanly; every call is simply a no-op.
Usage
import AsyncStorage from '@react-native-async-storage/async-storage';
import { onesignal } from '@tiledev/sdk-tile-notification';
// The App ID is the published app's OWN OneSignal app — not the tile platform's.
// Fetch it at boot: GET /api/runtime/onesignal-app-id?appId=<tileAppId>
await onesignal.init({
appId,
storage: AsyncStorage, // persists the re-prompt throttle
reOptInPeriodDays: 7, // don't re-ask a user who declined, for 7 days
onDeeplink: (url) => navigate(url),
logger: console,
logLevel: 'none',
});Then feed it commerce events — this is what makes audience targeting work at all, since OneSignal segments are built only from tags and outcomes:
onesignal.trackEvent('updateCartQuantity', { totalQuantity: 2 });
onesignal.trackEvent('purchase', { totalValue: 49.99 });
onesignal.trackEvent('pageView', { pageId: 'Home', storeName: 'acme' });
await onesignal.identify({ customerId, email, phone, firstName, lastName });
await onesignal.clearIdentity(); // on logoutWhat each event does
| Event | Effect | Dashboard automation it enables |
| --- | --- | --- |
| updateCartQuantity | sets cart_update to a unix timestamp, or removes it when the cart empties | Abandoned Cart |
| purchase | Purchase outcome with revenue, and clears cart_update | Order Success |
| login / signup | external_id + email/SMS subscriptions + first_name/last_name tags | New User Welcome |
| logout | detaches the device from that customer | — |
| pageView (Home only) | sets the store tag | audience segmentation |
| pageView (every page) | sets the screen In-App Message trigger to its pageId | "show on the cart screen" |
| updateCartQuantity | sets the cart_items trigger to the count ("0" when empty) | "show while the cart has items" |
| every event, named or not | a OneSignal custom event with its properties (User.trackEvent) | Journeys and segments on any event the app tracks |
purchase clearing cart_update is deliberate — without it a customer who converted would
stay in the abandoned-cart segment.
Notifications opened and received, and the unread badge
await onesignal.init({
appId, storage: AsyncStorage, onDeeplink: openUrl,
onNotification: (e) => analytics.track(e.type === 'opened' ? 'apptile_notification_open' : 'apptile_notification_foreground', e),
});
const unread = useUnreadNotificationCount({ historyUrl, storage: AsyncStorage }); // the bell badge
await markNotificationsSeen(AsyncStorage); // history screen openedonNotificationgets every notification opened and every one that arrives while the app is open (foreground):{ type, notificationId, title, body, launchUrl, data, at }. Notifications without a link included; a link still goes toonDeeplink.- Foreground notifications are shown by the SDK. Once anything listens, react-native-onesignal
holds every foreground notification back (
preventDefault()before JS sees it), so the SDK callsdisplay()first.showForegroundNotifications: falsekeeps them silent. The SDK's own Live Notification cards are never shown twice. With neither option set, no listener is added and the native SDK shows them as always. - The badge: rows of the store's history (below) sent after the stored seen time (key
notifications.seenAt.v1, the production apps' key). It counts the same rows the history screen shows. Refreshes on mount and when the app returns to the foreground;markNotificationsSeenresets every badge at once. A store with no history file (404) has 0. - A fresh install shows no badge (0.4.1). With no seen time stored, the first successful read
stores the newest row's
sentAt(or now, when the store has sent nothing) and counts 0, so only pushes sent after the first launch count. Offline on the first launch, nothing is stored until a read succeeds. Before 0.4.1 every row counted, so a store with a long history showed "99+" on a new phone (as the apps before the SDK did, on purpose; changed 2026-10-06 by the Head of Engineering).
The notification history screen
const { notifications, loading, error, refresh, markSeen } =
useNotificationHistory({ historyUrl, storage: AsyncStorage });
// historyUrl: https://cdn.apptile.io/assets/notification-history/<store>.myshopify.com.jsonEach row is a SentNotification:
| Field | From the history file's notificationBody |
| --- | --- |
| id, sentAt | the entry's own (sentAt is ISO 8601) |
| title: string \| null | headings.en, else the first language that has one, else name |
| body: string \| null | contents.en, else the first language that has one |
| imageUrl: string \| null | big_picture, else large_icon, else ios_attachments.id1 |
| link: string \| null | app_url exactly as sent (e.g. shop.68843864220.app://live-selling/uhlw2vrkxm); the app decides what it opens. An empty one is null |
- Rows are newest first. Entries with no title and no body are dropped (nothing to show), as
are entries without a
sentAt.fetchNotificationHistory(historyUrl)returns the same rows. loadingis true only until the first answer. A failedrefresh()keeps the last list and setserror; the next good one clears it. A store that has never sent a push has no file (403/404): an empty list, not an error.- It fetches once on mount (and again if
historyUrlchanges). Nothing refetches on its own, so re-renders, focus changes and new{ historyUrl, storage }objects never cause a loop. Callrefresh()for pull-to-refresh. markSeen()marks the list read throughmarkNotificationsSeen, so every bell badge drops to 0 at once. It stores the newest shown row'ssentAt, so a push sent after the list loaded stays unread.- The request asks for a fresh copy. The CDN sends no
Cache-Control, so iOS would otherwise answer from its URL cache and keep showing a deleted push. On device it sendsCache-Control: no-cacheandPragma: no-cache. On web it usescache: 'no-cache'instead: those headers make the browser send a CORS preflight, which the CDN answers with 403.
Push on and off: the Settings switch
const push = usePushNotifications();
<Switch value={push.enabled} disabled={push.permission === null} onValueChange={async (on) => {
if (!on) return push.turnOff();
if ((await push.turnOn()) === 'denied') offerSettings(); // e.g. an Alert with Linking.openSettings()
}} />The switch really turns pushes on and off. enabled is the OS permission AND this device's
OneSignal push subscription. turnOff() calls OneSignal's User.pushSubscription.optOut(), so
pushes stop arriving. turnOn() opts the device back in, asking the OS first if needed. OneSignal
remembers the choice across launches, so the app stores nothing. (Production amber's switch only
stored a local preference: pushes kept arriving when it was off, and turning it on never registered
the device.)
| | Returns |
| --- | --- |
| permission | 'granted' \| 'denied' \| 'undetermined' \| null. null until the first check, and wherever push isn't available (web, or before init): keep the switch disabled then |
| optedIn | boolean \| null: OneSignal's push subscription |
| enabled | permission === 'granted' && optedIn: what the switch shows |
| turnOn() | undetermined: asks the OS, then opts in if allowed → 'on'. Granted: opts in → 'on'. Denied, or refused just now: changes nothing → 'denied', and the app offers Settings. Never opens Settings itself |
| turnOff() | opts out. The permission is left alone |
| refresh() | checks both again |
- It re-checks on mount and whenever the app comes back to the foreground, because the shopper may have changed the permission in Settings.
'undetermined'means "the OS can still show its dialog", the same rule as production'scanAskAgain. Android has no native "not determined" state the way iOS does. On iOS it is undetermined only until the first answer. On Android it can stay undetermined after a "Don't allow", because OneSignal records a refusal as final only when it was asked to fall back to Settings.turnOn()takes its answer from the request itself, so it returns'denied'correctly on both.- On web it returns
permission: null,enabled: false, and functions that do nothing (turnOn()resolves'denied'). - The provider methods underneath are public too:
requestPermission(),isOptedIn(),optIn(),optOut()(see the API table).
All events to OneSignal: the analytics adapter
The app's whole event stream reaches OneSignal through one line, with no OneSignal code in the app:
import { createOneSignalAnalyticsAdapter } from '@tiledev/sdk-tile-notification';
createAnalytics([createApptileAnalyticsAdapter({ ... }), createOneSignalAnalyticsAdapter()]);track→onesignal.trackEvent: every event becomes a custom event, and the named events above also drive their tags, outcomes and triggers.identify→external_id= the email, else the customer id (the same rule as theloginevent, so a customer is never split). A bare user id, such as an install id, is ignored.reset→ logout. There is noscreen: page views arrive aspageView, and handling both would count each screen twice.- Properties are made JSON-safe (dates as ISO strings, no functions or
undefined), trimmed (strings 1,000 chars, 50 keys, 50 items, 4 levels), and keys that look like credentials are dropped (password,token,secret,authorization,cvv, card numbers). init({ customEvents: false })keeps only the mapping;{ exclude: ['scroll'] }skips events. Needs react-native-onesignal 5.2+ (User.trackEvent); on older builds it is skipped quietly.- Custom events have to be available on the OneSignal app's plan to show up in the dashboard.
API
| Method | Notes |
| --- | --- |
| init(config) | Idempotent; safe on every boot. Initializes the SDK, requests permission (subject to reOptInPeriodDays) and wires the tap handler. |
| trackEvent(name, data?) | Applies the tag/outcome map above. |
| identify(identity) | customerId becomes OneSignal's external_id — what per-user sends target. |
| clearIdentity() | OneSignal.logout(). |
| getPermissionStatus() | 'granted' \| 'denied' \| 'undetermined'. Does not prompt. 'undetermined' = the OS can still show its dialog (see "Push on and off"). |
| requestPermission() | Asks the OS only while undetermined and returns 'granted' or 'denied'; otherwise returns the current answer without asking. Never opens Settings (fallbackToSettings: false). Feeds the reOptInPeriodDays throttle. |
| isOptedIn() | OneSignal's push subscription is on (User.pushSubscription.getOptedInAsync()). |
| optIn() / optOut() | User.pushSubscription.optIn() / optOut(). Really starts or stops pushes to this device; OneSignal remembers it. Call optIn once permission is granted (OneSignal prompts on its own otherwise). |
| getSubscriptionId() | This device's OneSignal id, or null. |
| isReady() | True once init() completed against a real SDK. Always false on web. |
| liveActivitiesEnabled() | True once init() set up Live Activities on iOS. Always false on Android and web. See below. |
| prepareLiveActivityImage(url) | iOS: downloads a thumbnail into the App Group so the widget can show it (widgets can't use the network). Android: nothing to do, resolves true. See "Live thumbnails". |
| startLiveActivity(id, attributes, content) | Start a Live Activity from the app. false if not enabled, no id, or over 4 KB. true means OneSignal accepted it; iOS can still refuse (see below). |
| setInAppTriggers(triggers) | Set In-App Message triggers; null removes one. See "In-App Messages". |
| setInAppMessagesPaused(paused) | Pause or resume In-App Messages. |
| isNoop | True in the web build. |
The provider's methods are safe before init() has run and where push is unavailable: they never
throw, and answer 'undetermined' / false / nothing.
Hooks: usePushNotifications(), useNotificationHistory({ historyUrl, storage }) and
useUnreadNotificationCount({ historyUrl, storage }), described above.
./eventMap is also exported on its own — it's pure (event → tag/outcome mutations, no SDK
calls), so the mapping can be tested without a device.
In-App Messages
The popups, banners and full-screen cards a marketer designs in the OneSignal dashboard
(Messages → In-App), shown while the app is open. The native SDK already shows them with no
code, on both platforms, as soon as init runs. This package adds the control around them:
await onesignal.init({
appId,
inAppMessages: {
paused: true, // optional: nothing shows until you resume
onEvent: (e) => analytics.track(`in_app_message_${e.type}`, { id: e.messageId, ...('click' in e ? e.click : {}) }),
},
});
onesignal.setInAppMessagesPaused(false); // e.g. once onboarding is done
onesignal.setInAppTriggers({ viewed_collection: 'sale' });- Who sees a message is set in the dashboard: a segment (built from the tags
trackEventsets) and/or triggers.trackEventsets two triggers for free:screen(thepageIdof everypageView) andcart_items(the count,"0"when empty). So "screen is Cart" or "cart_items greater than 0" works with no app code. Triggers are strings; numbers are converted, and OneSignal compares a numeric rule numerically. - Buttons with a URL need nothing from the app: OneSignal opens the URL itself. A link in the
app's own scheme (e.g.
amorefashion://…, target "browser") comes back through the OS like any deep link. A button with only an action id is for the app to handle inonEvent. onEventgets{ type: 'display' | 'dismiss', messageId }or{ type: 'click', messageId, click: { actionId?, url?, urlTarget?, closesMessage } }.urlTargetis iOS only: react-native-onesignal's Android bridge drops it.- OneSignal fetches messages when a session starts (a cold start, or back from 30 s+ in the background). A message added in the dashboard shows from the next session, not the current one. Test on a device: a headless iOS simulator never starts a session.
- To test, give the dashboard message the trigger
tile_testisiamand set it from a test build. A message with no trigger goes to its whole audience, real customers included. - No native changes: it works on any build that already has react-native-onesignal.
Live Activities (iOS)
A lock-screen and Dynamic Island card that the backend keeps up to date, such as a live show
with its viewer count and current product. It uses OneSignal's built-in
DefaultLiveActivityAttributes, so the app needs no Swift bridge of its own.
1. The app's app.json (native, so a new binary):
["onesignal-expo-plugin", {
"mode": "production",
"liveActivities": { "widgetFilePath": "./node_modules/@tiledev/sdk-tile-notification/ios/TileLiveActivity.swift" }
}]ios/TileLiveActivity.swift is this package's widget. It has two layouts, upcoming and
live, driven by the data keys below, and covers the lock screen, the Dynamic Island (compact,
expanded, minimal) and the brand colours from attributes.primary / accent. Point
widgetFilePath at your own Swift file only if you need a different design.
The plugin adds the widget extension (OneSignalWidget, iOS 16.2+, bundle id
<bundleId>.OneSignalWidget) and NSSupportsLiveActivities. Without widgetFilePath it uses
OneSignal's placeholder widget, which isn't fit to ship. The widget's bundle id needs its own
provisioning profile.
2. Opt in at init:
await onesignal.init({ appId, storage: AsyncStorage, liveActivities: true });
// or { pushToStart: true, pushToUpdate: true }, which is what `true` meansThis registers the device's push-to-start token. It doesn't need notification permission.
3. Start one, from the backend (push-to-start, iOS 17.2+) or from the app (iOS 16.1+, app in the foreground):
onesignal.startLiveActivity(streamId, { title: 'Live now' }, { viewers: 120 });In the widget, attributes arrive as context.attributes.data (fixed for the activity's life)
and content as context.state.data (replaced by each update).
4. Update and end it from the backend. The REST API key stays server-side:
| | Endpoint | Body |
| --- | --- | --- |
| Start | POST /apps/{app_id}/activities/activity/DefaultLiveActivityAttributes | event: "start", activity_id, event_attributes, event_updates, name, contents, headings, a target (included_segments, include_aliases, …) |
| Update / end | POST /apps/{app_id}/live_activities/{activity_id}/notifications | event: "update" or "end", event_updates, name, contents |
Limits:
- The OneSignal app needs a .p8 APNs key. Apple doesn't allow p12 certificates for Live Activities.
- Attributes and content together must be 4 KB or less.
startLiveActivityrefuses anything larger rather than letting iOS drop it silently. - The widget can't use the network, so remote images won't load. Use text and bundled art.
- Give every
Textin the widget an explicit colour. The lock screen's default foreground follows the system appearance, so in dark mode it is white. On a whiteactivityBackgroundTint, any line left on the default colour disappears; the first Amore test card lost its message and product name this way. - The first activity asks the user. iOS shows "Allow Live Activities from ?" under it on the lock screen, and the card is dimmed until they answer.
- An activity stays live for up to 8 hours, then remains on the lock screen for up to 4 more.
- Priority-10 updates count against a budget Apple doesn't publish. Send frequent changes (viewer counts) at priority 5.
- iOS only. On Android and web nothing is set up,
liveActivitiesEnabled()isfalseandstartLiveActivityreturnsfalse. - The app needs the
aps-environmententitlement, even for an app-started activity, because it is push-updatable.onesignal-expo-pluginadds it, but a simulator build made withCODE_SIGNING_ALLOWED=NOdrops every entitlement. iOS then refuses withdoes not specify an APS environment name(in theliveactivitiesdlog) whilestartLiveActivityhas already returnedtrue. Sign simulator builds ad hoc (CODE_SIGN_IDENTITY=-). - The simulator can't test server updates. It gets no APNs push token ("Push token is not available"), and
simctl pushdoesn't accept Live Activity payloads. Test push-to-start and updates on a real iPhone.
Live thumbnails and the card design
Pass the show's image as attributes.image / event_attributes.image (an https URL).
| | iOS widget (ios/TileLiveActivity.swift) | Android renderer |
| --- | --- | --- |
| Lock screen / shade | Dark brand-gradient card; a 76 pt rounded thumbnail with a red ring and LIVE badge; host in small caps; bold title; viewers pill; frosted product chip with the price. Upcoming: UPCOMING pill and a big "4 hrs, 25 min · 7:30 PM" countdown | Thumbnail as the large icon. Expanded: the full picture (below Android 16 QPR1; promotion allows only text and progress styles), with Watch now and Hide buttons. Upcoming: "Starts in · 4:25:10" |
| Dynamic Island / status bar | Compact: a round thumbnail plus viewers or the countdown. Expanded: thumbnail, badge, title, chip | Promoted chip on Android 16 QPR1+ |
| Where the image comes from | The App Group only. A widget can't use the network: call prepareLiveActivityImage(url) before startLiveActivity, and whenever the app learns about upcoming shows, so an activity the server starts later finds its image. No file yet: a brand-gradient tile stands in | Downloaded by the renderer (off the main thread, 5 s timeouts, 4 MB cap), cached in the app's cache |
iOS setup: expo-file-system (SDK 54, 19.x) in the app, plus the plugin option:
["@tiledev/sdk-tile-notification", { "liveNotifications": true, "liveActivityImages": true }]List this plugin after onesignal-expo-plugin. liveActivityImages also adds the App Group to
the widget's entry in extra.eas.build.experimental.ios.appExtensions, which cloud builds (Tile,
EAS) use to provision each extension; onesignal-expo-plugin leaves it {}.
liveActivityImages gives OneSignal's widget target (OneSignalWidget) an entitlements file with
the App Group, because onesignal-expo-plugin gives it none. It also writes TileAppGroup into the
widget's Info.plist. The group is appGroup, defaulting to group.<bundleId>.onesignal, the one
OneSignal already gives the app. The widget's App ID needs that App Group in its provisioning
profile.
Register the widget's App ID by hand before the first cloud build. In the Apple Developer
portal (Certificates, Identifiers & Profiles → Identifiers → +), add <bundleId>.OneSignalWidget
with App Groups on and the app's group ticked. Cloud builds sign with an App Store Connect API key.
The key can make profiles for IDs that already exist, but it couldn't set up this new one with its
group. Amore's first TestFlight build failed on the widget target alone with Authentication
failed: Make sure a bearer token was provided… and No profiles for
'com.amorefashion.app.OneSignalWidget' were found. The app and its notification extension, whose
IDs already had the group, signed fine. One local Xcode build signed with an Apple ID on the team
(automatic signing) also registers it, group included.
Cached file names follow one rule in all three languages (JS, Swift, Kotlin): FNV-1a 32-bit
of the URL's UTF-8 bytes, as 8 hex digits, plus .img (liveActivityImageFileName, which is
exported). The gate checks the JS against the canonical vector ("a" → e40c292c); the Swift and
Kotlin were checked end to end on Amore, where the widget found the file the JS had written.
Upcoming shows: "Starting in 4 hrs, 25 min"
The same activity or notification can count down to a start time, and then flip to live. The
countdown is drawn by the OS: Text(date, style: .relative) on iOS and a count-down chronometer
on Android. It keeps ticking with no pushes.
| When | Send (iOS and Android) | Shows |
| --- | --- | --- |
| Any time before the show | start with event_updates: { status: "UPCOMING", startsAt: <unix seconds>, message } | UPCOMING, the title, "Starting in 4 hrs, 25 min · 7:30 PM" (iOS) / "Starts in · 4:25:10" and "Starts at 7:30 PM · Thu 24 Sep" (Android) |
| At start time | update with status: "LIVE" plus viewers, product, price | The live layout, the same card updated in place |
| After the show | end | Removed |
startsAt is unix seconds. Send the LIVE update at start time. Without it, iOS's countdown
stops at 0:00 and, once stale, shows "Starting now". Android's chronometer would keep running past
zero. On iOS, also set the push's stale_date to startsAt. On Android the notification's timeout
stretches to the wait plus 8 hours, so a show more than 8 hours away doesn't expire before it starts.
Live Notifications (Android)
Android's counterpart to Live Activities: one ongoing notification per key, updated in place.
On Android 16 QPR1+ it is promoted to a Live Update (a status-bar chip); older versions still
update in place. It is push-driven only: the app can't start one itself, and startLiveActivity
returns false on Android.
1. The app's app.json: add this package as a config plugin (native, so a new binary):
["@tiledev/sdk-tile-notification", { "liveNotifications": true }]At prebuild, the plugin writes Kotlin into the app's own package (<package>.tilelive) and
registers it in AndroidManifest:
TileLiveNotificationExtensionas OneSignal'scom.onesignal.NotificationServiceExtension. Pushes withoutlive_notificationgo to OneSignal untouched.TileLiveUpdateDismissReceiver, so a swipe-away or "Hide" sticks until the nextstart.POST_PROMOTED_NOTIFICATIONS(install-time; promotion only).- Debug builds only:
TileLiveDebugReceiver, which renders a payload sent over adb, with no server. Only its manifest entry is debug-only; the class sits in the main source set, because a debug source set holding just this file gets moved by Expo's package rename on the next prebuild and breaks the compile.
If the app already registers its own OneSignal extension, prebuild fails: OneSignal loads exactly
one. Call TileLiveNotifications.handle(context, payload) from yours instead. Needs compileSdk 36
(Expo SDK 54's default) and OneSignal Android 5.1.14+.
2. The backend sends normal pushes with the same collapse_id for every event of one
instance, and data.live_notification:
POST https://api.onesignal.com/notifications
{
"app_id": "…", "include_aliases": { "external_id": ["…"] }, "target_channel": "push",
"isAndroid": true, "collapse_id": "show-42", "ttl": 120,
"headings": { "en": "Amore Live" }, "contents": { "en": "We're live" },
"data": { "live_notification": {
"key": "show-42", "event": "start",
"event_attributes": { "title": "Amore Live: Summer Drop", "url": "amorefashion://", "accent": "#A94D62" },
"event_updates": { "status": "LIVE", "message": "30% off tonight", "viewers": 128, "product": "Linen Midi Dress", "price": "$58" }
} }
}event is start, update (an upsert) or end. The data keys are the iOS widget's, so one
backend event can drive both platforms. Resend event_attributes on every event: the extension
runs per push and keeps no state apart from dismissals. progress (0–100) switches to a progress
bar (ProgressStyle on Android 16).
3. Test without a server (debug build):
adb shell am broadcast -n <package>/.tilelive.TileLiveDebugReceiver \
--es payload '{"key":"show-42","event":"start","event_attributes":{"title":"Live"},"event_updates":{"viewers":12}}'
# → Broadcast completed: result=0, data="handled=true"Design notes
Platform split, not a mock build. provider.native.ts holds the real implementation and
provider.ts a no-op; Metro resolves ./provider per platform. react-native-onesignal
has no web build, so it must never enter a web bundle — the split is on a file, which is
unambiguous in Metro, rather than a directory index.
One build, all real. There is no mock/stub variant. A OneSignal App ID is public by design (it ships inside every client binary) and the REST API Key stays server-side, so there is nothing to withhold from a published artifact.
Zero dependencies. The SDK is required lazily inside init() and typed against a
narrow local interface, so tsc passes with nothing installed and importing the package
where the SDK is absent cannot throw. Only init() can fail, and it reports why.
No import-time side effects. sideEffects: false is honest — initialize, log level and
the tap listener all happen inside init().
Development
npm run build # clean + tsc (fails loudly; noEmitOnError)
npm test # publish gate — see below
npm run lint # tsc --noEmitnpm test enforces five invariants that a reviewer can't eyeball, and runs again from
prepublishOnly:
- The web closure never imports
react-native-onesignal. Checked at the level ofrequire/importspecifiers, not raw text — the doc comments legitimately name the package, so a plain grep gives false positives. - The tag map still produces what the dashboard selects on. A silent change here crashes nothing; it just stops campaigns matching anyone, which stays invisible until a send reaches zero devices.
- Live Activities are set up the way the RN bridge needs. It runs the native provider
against a fake SDK. The bridge reads a missing
setupDefaultoption asfalse, not the native defaulttrue, so both options must always be passed. It also checks the 4 KB refusal, the off-by-default opt-in, that Android reports them off, and that an SDK without Live Activities still initialises. - The Android config plugin writes what OneSignal expects. Every template renders into the app's package. The manifest edits (extension, dismiss receiver, permission) are idempotent. An existing OneSignal extension is refused, not overwritten. The debug receiver lands only in the debug manifest (Expo's self-closing
<application/>included). A disabled plugin never loads Expo. - The push switch and the history behave as the app relies on. The provider runs against a
fake SDK with a fake device (permission, the shopper's answer, opt-out): permission states,
asking only while undetermined and never with the Settings fallback, opt-in and opt-out, and
every call before
init. History rows are mapped from entries shaped like the real file (other languages, a missing title or body, image precedence), dropped and sorted; 403/404 is empty; the web request carries no headers. The hooks are rendered (React 18 + react-dom under jsdom, therenderHookhelper inscripts/verify.js):turnOn's three paths, the foreground re-check, no refetch loop, a failed refresh keeping the list, andmarkSeenresetting the badge. The hooks get the native provider the way Metro would give it (loadWithswaps./provider), so their public signatures carry no test-only options.
See docs/ARCHITECTURE.md for how this fits the notification center end to end.
