@chipmobilesdk/rn-notification
v0.2.0
Published
Shared notification library for bare React Native apps on iOS and Android.
Readme
@chipmobilesdk/rn-notification
Shared notification library for bare React Native apps on iOS and Android.
Scheduled local notifications with deterministic identity and idempotent reconciliation, provider-agnostic push, permission coordination, declarative channels and tones, action and tap routing across every app state, and a decision core you can test on a laptop.
Why this exists
Every notification feature re-solves the same problems, and most of them are discovered after shipping:
- Identifiers must be derived from data, not random, or you can never replace a notification you already scheduled.
- The OS will not hold an unbounded repeating series, and iOS silently discards past 64 pending notifications.
- Reconciling "what my data implies" against "what the device holds" must be idempotent, or it is untestable.
- Display permission and exact-alarm permission are different things, asked separately, and a refusal must not break the user's actual work.
- Android channels are immutable once created — changing one is a migration, not an assignment.
Installation
npm install @chipmobilesdk/rn-notification
npm install react-native-notify-kit react-native-mmkv react-native-localize
cd ios && pod installPeer dependencies
| Package | Required | Purpose |
|---|---|---|
| react >= 19, react-native >= 0.85 | yes | Host framework |
| react-native-notify-kit >= 10.5.0 | yes | Local notification engine |
| react-native-mmkv >= 4.0.0 | yes | Bounded persisted state |
| react-native-localize >= 3.7.0 | yes | Platform time zone for re-anchoring |
| @react-native-firebase/app >= 26.0.0 | optional | Push only — required by messaging |
| @react-native-firebase/messaging >= 26.0.0 | optional | Push only |
Enabling push (optional)
⚠️ Install both Firebase packages, and add the config files.
@react-native-firebase/messagingwill not build without@react-native-firebase/app, andappwill not build without a Firebase config file. Installing messaging alone fails the Android build at configure time withCould not find the react-native-firebase/app package.
npm install @react-native-firebase/app @react-native-firebase/messaging
cd ios && pod installThen add the config files before building — they are not optional:
- Android →
android/app/google-services.json, plus thecom.google.gms:google-servicesplugin - iOS →
ios/<App>/GoogleService-Info.plist
If you are not enabling push, install none of this. The package's push code lives behind the /push subpath and is never imported by the default entrypoint, so a local-only app ships zero provider code, zero push permissions, and zero entitlements — and takes on none of the provider's data-collection declarations.
On the engine. The original design named Notifee. Its repository was archived on 2026-04-07 with its last release in December 2024, so this package uses
react-native-notify-kit— the maintained, 100%-API-compatible fork Invertase itself points to. The engine sits behindNotificationEngineAdapter; nothing consumer-facing depends on which one is in use.
Native setup
iOS
Notification authorization needs no Info.plist usage string. Push additionally requires:
- the
aps-environmententitlement - the
remote-notificationbackground mode
Android
| Permission | When | Notes |
|---|---|---|
| POST_NOTIFICATIONS | API 33+ | Runtime; requested only when you call for it |
| RECEIVE_BOOT_COMPLETED | always | Restores scheduled notifications after a restart |
| SCHEDULE_EXACT_ALARM | opt-in | You add it; requires a Play Console declaration |
Never use USE_EXACT_ALARM unless your app is an alarm clock, timer, or calendar. Google Play restricts it and will disallow publishing for apps that do not qualify.
⚠️ You must actively remove SCHEDULE_EXACT_ALARM if you do not need it
This package declares no exact-alarm permission — but the notification engine does, unconditionally, in its own manifest. Android's manifest merger is transitive, so it lands in your merged manifest whether you asked for it or not. Verified on a real build: SCHEDULE_EXACT_ALARM appears in the installed package even when nothing opts in.
SCHEDULE_EXACT_ALARM requires a Play Console Restricted Permissions declaration. If your app does not need precise timing, strip it:
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"
tools:node="remove" />Without exact alarms, notifications are still scheduled — in approximate mode, with mayBeDelayed: true on the result. Nothing breaks.
Verify after building, because a merged manifest is the only thing that tells the truth:
adb shell dumpsys package <your.package.id> | grep -E "android\.permission\.(SCHEDULE|USE)_EXACT_ALARM"
# no output = correctly removedMatch on
android.permission.specifically. A loosegrep -i EXACT_ALARMalso hitsandroid.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED— a broadcast the engine legitimately listens for, not a permission — and reports a false failure.
The engine also contributes WAKE_LOCK, VIBRATE, FOREGROUND_SERVICE, INTERNET, and ACCESS_NETWORK_STATE. None is store-restricted, but review them against your own privacy declarations — and remove any you can prove unnecessary using the same tools:node="remove" mechanism.
Backup exclusion
State lives under Library/Caches on iOS (backup-excluded by platform design). On Android, add to your data_extraction_rules.xml and full_backup_content:
<exclude domain="file" path="mmkv/chipmobilesdk.notification" />Choosing identifiers
Identifiers are yours. The package never generates one, because only you can derive a reproducible identifier from your own data.
const id = `task:${task.id}:reminder:${index}`; // ✅ derived, reproducible
const id = crypto.randomUUID(); // ❌ you can never replace itCharset: A–Z a–z 0–9 and . _ - : /, at most 128 characters. Whitespace and control characters are rejected.
Nothing is normalized. Task:1 and task:1 are different identifiers. That is deliberate — silently folding them would hide a bug — but it is also how you accidentally create two notifications for one subject. Derive identifiers from a single canonical source.
Owner tags and group tags follow the same rule.
Choosing an anchoring mode
moment: { at } // wall-clock (default)
moment: { at, anchoring: 'absolute' } // fixed instant worldwideWall-clock means the local clock time you named. An 08:00 reminder stays 08:00 after your user flies to Tokyo. This is the default because reminders are wall-clock things.
Absolute means one instant everywhere — a match kickoff, a market open.
What re-anchoring cannot fix
The package re-anchors wall-clock notifications when it notices the device zone changed. It notices during maintenance, which runs while the app is running.
If a wall-clock notification's moment arrives while the app has not run since the zone changed, it fires at the previously anchored instant. This is stated rather than hidden: every pending entry carries an anchoringGeneration, so you can detect and explain the condition instead of being told nothing happened.
Maintenance versus reconciliation
| The package does automatically | You call explicitly |
|---|---|
| Lookahead window refresh | reconcile() — the desired set |
| Recurrence expansion | |
| Wall-clock re-anchoring | |
| Pending-set verification (reports only) | |
| Permission re-read | |
Reconciliation never runs on its own. Only you know what your data implies; a package that guessed would cancel notifications it does not understand.
Automatic maintenance is strictly additive. It may act only on recurrence series the package owns, and can never cancel a notification you scheduled directly. That rule is enforced in code (MaintenanceScopeViolation), not merely documented.
Call reconcile() after: mutating data that implies notifications, sign-in, sign-out, display permission becoming granted, a device restart, and app start.
Reconciliation
const result = await reconciler.reconcile({ domain: 'tasks', desired });
// result.operationCount === 0 on an unchanged second pass- Idempotent — a second consecutive pass with unchanged input performs zero platform operations.
- Domain-scoped — never cancels outside
domain, including notifications from other libraries. - Duplicates: every copy of a repeated identifier is rejected; anything already scheduled under it is left in place. A rejection is not a cancellation.
- Truncation: over the platform ceiling, the furthest-out entries are dropped and reported. They return as nearer ones deliver.
Recurrence
await recurrence.registerSeries({
id: 'series:medication',
pattern: { kind: 'daily', hour: 8, minute: 0 },
end: { kind: 'unbounded' },
template: { content: { title: 'Medication', body: 'Time for your dose' }, tone: 'reminder' },
});Default window: 60 days or 32 occurrences per series, whichever comes first. Bounded by both because either alone fails — a duration alone lets a frequent series exhaust the platform ceiling; a count alone leaves an annual series scheduled a decade out.
Months lacking the requested day are skipped, not clamped: day 31 means day 31.
Tones and channels
A tone is declared once and mapped to each platform, so your code contains no platform branches.
await channels.apply([
{
id: 'medication',
name: t('notifications.medication.name'), // shown in system settings
description: t('notifications.medication.hint'),
urgency: 'max',
sound: { name: 'chime' },
vibration: true,
bypassDoNotDisturb: true,
repeatAlert: { forMs: 15 * 60 * 1000 }, // repeats until handled, for 15 minutes
timeSensitive: true,
version: 1,
},
]);name and description are yours to localize — the package neither translates nor normalizes them. They are what a person reads when they open notification settings to turn something down, which makes them the only part of a tone a user ever sees.
Alarm-style tones
repeatAlert makes the alert sound repeat until the user handles the notification, rather than chiming once. A single chime is easy to miss from across the room, which is exactly the situation a medication or wake-up reminder exists for.
The duration is required, not optional. An alert with no bound means a user who left their phone at home comes back to a device still ringing, so the type makes that state impossible to write. Permitted range: 1 to 60 minutes (REPEAT_ALERT_BOUNDS).
When the window elapses:
- the sound stops, and
- the notification stays in the tray.
Both halves matter. A notification that silently disappeared is worse than one that never sounded — the user returns to a quiet phone and no idea what they missed. Handling it earlier stops the sound immediately.
One setup step, for repeating tones only
Expiry almost always falls while your app is not running, so the package needs a handler the platform can start on its own. Register it at module scope in index.js — not in a component, and not in an effect:
// index.js
import { registerBackgroundNotificationHandler } from '@chipmobilesdk/rn-notification';
registerBackgroundNotificationHandler({ engine });Without it, a repeating alert still starts and still stops when the user handles it — but nothing ends it on a phone lying face-down on a table. apply() reports REPEAT_STOP_HANDLER_MISSING rather than letting you find that out from a user.
An app that declares no repeating tone needs none of this.
Pair a repeating tone with
bypassDoNotDisturb: trueandurgency: 'max'. An alarm silenced by Do Not Disturb is not an alarm.
What each platform actually does
| Attribute | Android | iOS |
|---|---|---|
| repeatAlert — sound repeats | Honoured | Refused — reported in repeatUnavailable |
| repeatAlert.forMs — the bound | Honoured, with the handler registered | N/A — nothing to bound |
| Sound stops at expiry, notification stays | Honoured | N/A |
| timeSensitive | Honoured | Honoured only with your app's own Time Sensitive Notifications capability; without it iOS delivers at active |
| name / description in settings | Honoured, including updates on existing installs | Accepted; iOS has no per-tone settings entry, so they are not displayed |
| bypassDoNotDisturb | Honoured | Refused — reported in bypassUnavailable |
iOS does not repeat an alert sound at all. There is no looping API, and the one interruption level that overrides the mute switch requires an entitlement Apple grants by application — which this package does not hold and will not request. The notification still delivers and still sounds once.
Seven limits that remain true, stated rather than papered over:
- The declared window is a floor, not a precise stop. The entry that ends the alert is scheduled like any other notification, so it inherits the same delivery accuracy — within 60 s with exact alarms, up to 15 minutes without. Measured on an emulator without
SCHEDULE_EXACT_ALARM, a declared 2-minute window actually ran 2m35s and 3m00s. Declare the shortest window that is still useful, and expect it to overrun rather than undershoot. - Android alarm audio uses the notification volume, not the alarm volume. A device with notifications muted stays silent. Not fixable without native changes.
- Android stops an insistent sound when the user opens the notification shade. Platform behaviour — and arguably the right one, since they have now seen it.
- The stop marker is briefly visible in the tray before it is removed. It runs on a minimum-importance, silent channel; a scheduled notification is the only OS-driven execution available.
- Without the registered background handler, expiry does not happen while the app is dead.
- Aggressive vendor battery management can delay it, in the same way and for the same reasons the package already reports
mayBeDelayedandunknown. - Android restores a channel the user deleted with its original settings when it is re-declared. No reliable API distinguishes that from a channel that never existed.
When you must bump a tone version
Android freezes most of a channel's configuration once it exists, so changing one of those is a migration: a new channel, and the user loses every setting they had adjusted on the old one.
| Change | Version bump |
|---|---|
| name, description | No — updated in place |
| repeatAlert, timeSensitive | No — realized per notification |
| urgency, sound, vibration, badge, groupId, bypassDoNotDisturb | Yes |
Correcting a typo or shipping a new translation therefore costs nothing. apply() reports it as labelsRefreshed, distinct from created, migrated, and unchanged.
Upgrading from 0.1.x: the first
apply()refreshes labels once per existing tone, because every existing device currently holds the tone id as its channel name. Every apply after that is zero-operation again. Nothing is recreated or cancelled.
Permissions
const report = await permissions.getPermissions(); // never prompts
await permissions.requestDisplayPermission(); // independent
await permissions.requestExactAlarmPermission(); // independent
await permissions.openSettings('display'); // per-permissionSix states, never collapsed: notAsked, granted, denied, permanentlyDenied, provisional, notRequired.
Ask after intent, never on first launch. A prompt at the wrong moment is a permanent denial.
A refusal never fails your operation. The user's work completes; the lost capability is reported separately.
Interaction
notifications.onInteraction(async event => {
if (event.kind === 'tap') navigate(event.routing);
});One handler, identical shape in foreground, background, and cold start. Events that arrive before your handler registers are buffered durably — they survive process death, which is exactly what a cold-start tap is.
When the target is gone, call reportTargetMissing() and pick your own fallback. No platform error is ever shown to the user.
Push
import { createPushGate } from '@chipmobilesdk/rn-notification/push';Kept on its own subpath so an app that never imports it ships zero provider code, permissions, or entitlements.
Foreground pushes are never auto-displayed — presentation is your decision.
The payload contract (for backend teams)
{ "v": 1, "kind": "display", "id": "task:1234:due",
"content": { "title": "…", "body": "…" }, "tone": "reminder",
"routing": { "screen": "task" }, "dedupId": "evt-9f2c" }kind: display | data | schedule | cancel.
Append-only discipline — binding, not advisory. An unrecognized version is processed best-effort: known fields honoured, unknown fields ignored. That is safe only while a released field's meaning is never repurposed.
⚠️ Changing what a released field means will be silently misprocessed by every installed app running an older contract version. Express a semantic change as a new field, never a changed one. Adding a field is additive and never breaking.
Local schedules take precedence over push for the same identifier. A server that wants to override sends cancel then schedule.
What is persisted
Six collections, in app-private, backup-excluded storage:
| Collection | Cap | Retention | |---|---|---| | Deduplication ledger | 500 | 24 hours | | Interaction buffer | 50 | 7 days | | Migration ledger | 20 | — | | Series ledger | 32 | — | | Permission history | 64 | — | | Last reconcile summary | 1 | — |
No notification content, no push token, no desired set. The interaction buffer is the sole content-bearing exception, and it evicts on delivery.
Corrupt state is discarded and rebuilt, never blocking. What you lose: deduplication history and buffered interactions. What is unaffected: every notification already scheduled with the OS.
Testing without a device
import { createFakeEngine, createFakeProvider, runConformanceSuite } from '@chipmobilesdk/rn-notification/testing';The fakes simulate virtual time, time-zone travel, process death, boot-restoration gaps, redelivery, and platform truncation — so every decision path is provable with no device, no emulator, and no live provider.
Budgets
| Item | Limit | |---|---| | Identifier | 128 characters (hard) | | Routing payload | 4 KB (hard) | | Content | 512 / 3072 / 512 bytes, 3584 total (hard) | | Actions per notification | 10 | | iOS pending notifications | 64 (platform) | | Desired set per pass | 500 (soft) | | Recurrence series | 32 | | Repeating alert duration | 1–60 minutes (hard) |
Each repeating notification also holds one extra pending slot on Android, for the entry that ends its alert. That cost is subtracted from the ceiling and reported through the usual truncation path, rather than quietly shrinking how many of your notifications fit. Non-repeating notifications cost nothing extra, and iOS places none at all.
Delivery accuracy: within 60 s with exact alarms; within 15 min without, on a device under no manufacturer restriction. Under aggressive vendor battery management this bound does not hold, and the package reports the restriction where it can detect it.
Data behaviour (for your privacy declarations)
- Local-only adoption changes no privacy declaration. State is app-private, backup-excluded, capped, time-evicted, and never transmitted. The package opens no network connection.
- Enabling push does change it. The registration token is a device-scoped identifier your app sends to your server, and Firebase Cloud Messaging collects device data — both require declaration in Apple App Privacy and Google Play Data safety.
- Disable analytics collection so adoption cannot create an ATT-relevant posture:
firebase_analytics_collection_enabled=false,google_analytics_adid_collection_enabled=false. - Notification content appears on a locked screen. Keep anything sensitive out of the body and put it in the structured routing payload instead.
- User-generated content delivered through notifications remains your moderation obligation.
- Do not use notification content to route around store billing.
Compatibility
| Item | Supported |
|---|---|
| React | >= 19 |
| React Native | >= 0.85 |
| iOS | >= 15.1 |
| Android | minSdkVersion 24, targetSdkVersion 36 |
| Node | >= 22.11.0 |
Diagnostic identifiers
43 stable identifiers across validation, reconciliation, permissions, channels, interaction, push, state, and volume. Every one is reachable through the fault injector. See ALL_DIAGNOSTIC_CODES.
Some are reported outcomes, not failures — the operation succeeded and something you should know happened alongside it: TRUNCATED_OVER_CEILING, EXACT_ALARM_UNAVAILABLE, TARGET_MISSING, PENDING_SET_DISCREPANCY, BURST_THRESHOLD_EXCEEDED, PAYLOAD_VERSION_SKEW, PAYLOAD_DUPLICATE, STATE_CAP_EVICTED, STATE_CORRUPT_REBUILT, DND_BYPASS_UNAVAILABLE, TONE_LABELS_REFRESHED, REPEAT_ALERT_UNAVAILABLE, REPEAT_STOP_HANDLER_MISSING. Use isNonFailure(code).
License
UNLICENSED — internal ChipMobileSdk package.
