@amrshbib/react-native-pushbib
v0.0.2
Published
Self-hosted push notification SDK for React Native. Pushbib-style API (login, tags, click events) with zero Firebase setup in the app — no google-services.json, no GoogleService-Info.plist.
Maintainers
Readme
@amrshbib/react-native-pushbib
Self-hosted push notifications for React Native — three lines of setup and zero Firebase configuration inside your app:
- ❌ no
google-services.json - ❌ no
GoogleService-Info.plist - ❌ no google-services gradle plugin
- ❌ no Firebase pods on iOS (pure APNs)
- ❌ no AppDelegate / MainApplication changes
import Pushbib from '@amrshbib/react-native-pushbib';
Pushbib.initialize({
baseUrl: 'https://push.your-server.com',
appId: 'PROJECT_ID', // Dashboard → App Settings
appKey: 'nk_xxxxxxxx', // Dashboard → App Settings → Secure Keys (kind: mobile)
});
Pushbib.Notifications.requestPermission();
Pushbib.login('user-42');
Pushbib.User.addTags({ plan: 'pro', city: 'DXB' });
Pushbib.Notifications.addEventListener('click', (e) => {
console.log('clicked', e.notification, e.result.actionId, e.result.url);
});How it works
- Android — the SDK bundles
firebase-messaginginternally and callsFirebaseApp.initializeApp(context, options)at runtime with client options fetched from your server (GET /api/mobile/v1/config). Those options are read straight out of Firebase by your server using the service-account JSON — nogoogle-services.jsonanywhere, not in the app and not in the dashboard. The backend sends data-only messages to SDK devices and the SDK renders every notification natively — which is what makes action buttons, images, foreground control and consistent click handling work. - iOS — no Firebase at all. The SDK registers directly with APNs, sends
the raw hex device token to your server, and the server delivers over
HTTP/2 APNs with the
.p8key you uploaded in the dashboard. AppDelegate callbacks and theUNUserNotificationCenterdelegate are hooked by runtime swizzling (installed beforedidFinishLaunching, forwarding to any handler your app installs) — which is also why the iOS bridge is Objective-C: swizzling needs+loadand the ObjC runtime.
Install
npm install @amrshbib/react-native-pushbib
# or from the monorepo:
npm install file:../RNPackages/@amrshbib/react-native-pushbib
cd ios && pod installFull guide with diagrams:
Docs/MOBILE-SDK.md
Autolinking does the rest. Requirements: React Native ≥ 0.71 (old + new architecture via interop), Android minSdk 24, iOS 13+.
One-time dashboard setup (per app)
- FCM service account (sending, Android): App Settings → upload the service-account JSON.
- Android client config: App Settings → "Detect from service account". The server reads the client identifiers from Firebase itself.
- APNs key (sending, iOS): App Settings → upload the
.p8key with Key ID / Team ID / Bundle ID. - Create a mobile Secure Key (optionally locked to your bundle ids) —
that's the
appKey.
The only platform config you can't avoid
- iOS: enable the Push Notifications capability (Xcode → Signing & Capabilities). That's an entitlement Apple requires per app; no SDK can inject it. Recommended: also enable Background Modes → Remote notifications.
- Android: nothing.
API
| Call | What it does |
| --- | --- |
| Pushbib.initialize(opts) | Boot the SDK: fetch config, mint the push token, register the device. All other calls queue behind it. |
| Pushbib.login(externalId) | Bind this install to your user id (/auth/link). Survives token rotation. |
| Pushbib.logout() | Unbind (/auth/unlink). |
| Pushbib.getMe() | Server-side view of the logged-in user (all subscriptions + tags). |
| Pushbib.User.addTag(k, v) / addTags({...}) | Merge tags (user-level when logged in, else this install). sendTag / sendTags are aliases. |
| Pushbib.User.addTag(k, [a, b]) | A tag value may be a list. |
| Pushbib.User.removeTag(k) / removeTags([k]) | Delete tag keys. |
| Pushbib.User.removeTag(k, [a]) | Drop items from a list, keeping the rest. |
| Pushbib.User.getTags() | Local cache of tags set through this install. |
| Pushbib.User.getExternalId() | Current external id (or null). |
| Pushbib.User.pushSubscription.getId() | Stable subscription id (treat as a secret). |
| Pushbib.User.pushSubscription.getToken() | Raw push token (FCM registration token / APNs hex). |
| Pushbib.User.pushSubscription.optOut() / optIn() | Server-side unsubscribe / resubscribe. |
| Pushbib.Location.setLocation(lat, long) | Report coordinates for radius targeting. Shown as "Location point" on the user record. |
| Pushbib.Location.getLocation() | Last coordinates reported from this install (or null). |
| Pushbib.Location.clearLocation() | Forget them — the device stops matching radius segments. |
| Pushbib.Notifications.requestPermission() | OS prompt (iOS, Android 13+). Resolves granted. |
| Pushbib.Notifications.hasPermission() | Current permission state. |
| Pushbib.Notifications.clearAll() | Clear delivered notifications (+ badge on iOS). |
| Pushbib.Notifications.setForegroundDisplayEnabled(bool) / getForegroundDisplayEnabled() | Device-local toggle: show pushes while the app is open. Default: true. |
| Pushbib.Notifications.setBackgroundDisplayEnabled(bool) / getBackgroundDisplayEnabled() | Device-local toggle: show pushes while the app is backgrounded (Android only). Default: true. |
| Pushbib.Channels.sync() | Re-read the app's channels from /config and apply them. Runs on start already. |
| Pushbib.Channels.list() | Channels on this phone with their live importance and a blocked flag (Android only). |
| Pushbib.Channels.definitions() | What the server last sent, from the local cache. |
| Pushbib.Channels.openSettings(key) | Open the OS settings screen for one channel. |
| Pushbib.setLogLevel('verbose') | none / error / warn / info / verbose. |
Location & radius targeting
Report where the device is and the dashboard can target it by radius — "everyone within 5 km of the store". The coordinates land on the user record as Location point (Audience → Users), and the segment builder gets a Location → is within radius of filter taking latitude, longitude and a radius in metres.
The SDK deliberately never reads GPS and never asks for the location permission: your app already owns that decision (and the App Store / Play justification for it), so it hands us a fix whenever it has one.
import Geolocation from '@react-native-community/geolocation'; // or expo-location
Geolocation.getCurrentPosition(({ coords }) => {
Pushbib.Location.setLocation(coords.latitude, coords.longitude);
});
await Pushbib.Location.getLocation(); // { lat, long } | null
await Pushbib.Location.clearLocation(); // user turned location sharing offThe value is cached on-device and replayed on every re-registration, so it
survives push-token rotation and reinstalls. Call setLocation again whenever
you have a newer fix — only the latest one is kept, and there is no background
tracking: a device that never reports coordinates simply never matches a radius
segment.
Events
// Tap on a notification or an action button — fires in every app state,
// including cold start (buffered until your listener registers).
Pushbib.Notifications.addEventListener('click', (e) => {
e.notification; // { title, body, url, buttons, data, ... }
e.result.actionId; // button id, or null for a body tap
e.result.url; // data.url if the campaign set one
});
// Every incoming push, whether it was displayed or not — for logging,
// analytics, badge math. Buffered until your listener registers.
Pushbib.Notifications.addEventListener('received', (e) => {
console.log('push received', e.notification.title, e.notification.data);
});
// Decide whether a push shows while the app is foregrounded.
// Default (no listener / no preventDefault): it shows.
Pushbib.Notifications.addEventListener('foregroundWillDisplay', (e) => {
if (e.notification.data.silent === '1') e.preventDefault(); // synchronous!
});
Pushbib.Notifications.addEventListener('permissionChange', (granted) => {});
Pushbib.User.pushSubscription.addEventListener('change', ({ id, token, optedIn }) => {});data.url from a campaign is opened automatically on click via Linking
(deep links included). Disable with initialize({ autoOpenLaunchUrls: false }).
Navigate to a screen on click
The click event is the navigation hook — it fires when the app is in the
foreground, background, and on cold start (the tap that launched the app
is buffered until your listener registers, so register it early):
// e.g. with React Navigation and a navigationRef
Pushbib.Notifications.addEventListener('click', (e) => {
const { screen, ...params } = e.notification.data; // set in the campaign's data
if (screen) navigationRef.current?.navigate(screen, params);
});Send the campaign with data { "screen": "OrderDetails", "orderId": "42" }
and set autoOpenLaunchUrls: false if you route everything yourself.
Per-notification display control (showForeground / showBackground)
Add these keys to the notification's data payload when sending; the SDK
enforces them natively (default: both true):
| Data | Effect |
| --- | --- |
| "showForeground": false | Not displayed while the app is open (foregroundWillDisplay is skipped entirely) — the received listener still fires. |
| "showBackground": false | Android: not displayed in background/killed — delivery is silent, received is buffered for the next app session. iOS: not enforceable client-side (Apple displays background alerts; needs a Notification Service Extension — planned). |
| both false | A silent data push: nothing shows anywhere (Android), your received listener gets the payload. |
The received event always fires regardless of these flags — that's the
"catch and log it" hook. On iOS it fires in the foreground only (background
alert pushes never wake the app without an NSE).
Device-local display toggles (foreground / background)
showForeground / showBackground above are set per message by the sender.
For a setting the user controls on their own device — "show me banners while
I'm using the app" vs. "while it's closed" — use the display toggles. They're
persisted natively and honored on every incoming push, even in a cold process:
await Pushbib.Notifications.setForegroundDisplayEnabled(false); // silent while open
await Pushbib.Notifications.setBackgroundDisplayEnabled(false); // silent while closed (Android)
await Pushbib.Notifications.getForegroundDisplayEnabled(); // → booleanThe device stays subscribed either way (unlike optOut()): the server keeps
targeting it and the received event keeps firing — only the visible banner is
suppressed. setBackgroundDisplayEnabled is Android-only: on iOS the OS
renders background alerts before the app runs, so it can't be honored
client-side without a Notification Service Extension.
Live Activities (iOS) & Live Updates (Android)
The lock-screen card that tracks something in progress — a delivery, a ride, a
match score. Your backend drives it (POST /api/server/v1/live-activity/…);
the SDK's job is the device half.
How much the SDK can do differs sharply by platform, and it's worth knowing why before you plan the work.
Android — the SDK does everything
Live updates arrive on the device's normal FCM token and the native service renders an ongoing notification, replacing it in place on every update. Nothing to set up.
// Optional: declare that an activity is live so the server can address it.
// You can skip this entirely and let your backend's /live-activity/start do it.
await Pushbib.LiveActivities.register({ activityId: 'order_12345' });
// Mirror the state in-app, or draw your own richer card from contentState.
// `displayed` / `outcome` tell you whether it actually reached the tray.
Pushbib.LiveActivities.addEventListener('update', ({ liveUpdate, displayed, outcome }) => {
console.log(liveUpdate.action, liveUpdate.contentState); // START | UPDATE | END
if (!displayed) console.warn('live update not shown:', outcome);
});
await Pushbib.LiveActivities.end('order_12345'); // user dismissed it locally⚠️ POST_NOTIFICATIONS must be granted, or nothing renders. From Android 13
it is a runtime permission, and until the user grants it every live update is
dropped on arrival while your server still reports it as delivered. Call
Pushbib.Notifications.requestPermission() at startup. The update event then
reports outcome: 'notifications-disabled' rather than failing silently.
The ongoing notification is drawn from a small set of conventional keys, because Android can't know how to render an app-defined struct:
| Shown | Read from |
|---|---|
| Title | alert.title → contentState.title → app name |
| Body | alert.body → contentState.body / .message / .status |
| Progress bar | contentState.progress (0-100), or .indeterminate: true |
Everything else is still available in full through the update event. Live
updates post to their own pushbib_live_updates channel (default importance, no
badge) so users can silence them separately from regular notifications — name it
with com.amrshbib.pushbib.live_update_channel_name.
Updates are silent unless the sender includes an alert, and an out-of-order
delivery is dropped natively as well as server-side — FCM makes no ordering
guarantee, so two updates sent moments apart can arrive reversed.
Out-of-order protection compares sequence numbers within one numbering stream
(stream_id: the server session, or direct for a session-less test send). It has
to: a session numbers its updates 1, 2, 3… and restarts at 1 for every new
session, while a direct test send numbers by clock (~1.78e9). Comparing across
those would make one test send black-hole every later real update for that activity
id. A change of stream resets the mark instead of comparing against it.
"The dashboard says delivered but I see nothing"
Every way an update can fail to reach the screen is reported as outcome on the
update event (and logged under the Pushbib tag):
| outcome | Meaning |
|---|---|
| shown / ended | It reached the tray |
| notifications-disabled | POST_NOTIFICATIONS not granted, or the user turned notifications off — by far the most common cause |
| stale-sequence | Older than the last update shown for this activity in the same stream |
| opted-out | The device opted out of push entirely |
| notify-blocked | The OS refused the notify() call |
| render-error | Building the notification threw |
adb logcat -s Pushbib:V # the same reasons, from a terminaliOS — you own the activity, the SDK owns the server contract
The SDK cannot start or render a Live Activity, and no library can.
ActivityAttributes and its SwiftUI ActivityConfiguration are types that must
be declared in your Widget Extension, and Activity<Attributes> is generic over
them. There is no API by which a package could do this for you.
What the SDK does own is the server contract. Your Swift observes the tokens ActivityKit hands you and reports them; registration, retry and the HTTP call are handled:
First make the bridge visible to Swift. With the default React Native setup (static libraries), add to your app's Objective-C bridging header:
#import "PushbibLiveActivities.h"With use_frameworks!, import react_native_pushbib in the Swift file instead.
import ActivityKit
// 1. Start the activity — your attributes, your widget
let activity = try Activity<DeliveryAttributes>.request(
attributes: DeliveryAttributes(orderId: "12345"),
content: .init(state: .init(status: "Preparing", eta: 25), staleDate: nil),
pushType: .token)
// 2. Report its token. Fires again on rotation, so keep observing.
Task {
for await tokenData in activity.pushTokenUpdates {
PushbibLiveActivities.reportActivityToken(
PushbibLiveActivities.hexString(from: tokenData),
forActivity: "order_12345",
attributesType: "DeliveryAttributes")
}
}
// 3. iOS 17.2+ — report ONCE at launch so your backend can start activities
// while the app isn't running (POST /live-activity/start).
Task {
for await tokenData in Activity<DeliveryAttributes>.pushToStartTokenUpdates {
PushbibLiveActivities.reportPushToStartToken(
PushbibLiveActivities.hexString(from: tokenData),
attributesType: "DeliveryAttributes")
}
}Reports are queued until JS is ready, so a token observed during app launch is
never lost. Add NSSupportsLiveActivities: true to your Info.plist.
You don't need to call register() on iOS — reported tokens register themselves.
It's there if you'd rather pass a token in from JS.
There is no update event on iOS: ActivityKit updates the widget directly and
your app process is never woken.
Reconciling after a cold start
iOS can restore activities your process knows nothing about. Ask the server what it still thinks is live, then re-report tokens or end what's stale:
const sessions = await Pushbib.LiveActivities.getSessions();
// [{ activity_id, status, activity_token_registered, last_sequence_number, … }]Android customization (optional)
AndroidManifest.xml meta-data, all optional:
<meta-data android:name="com.amrshbib.pushbib.default_notification_icon"
android:resource="@drawable/ic_stat_notify" />
<meta-data android:name="com.amrshbib.pushbib.default_notification_color"
android:value="#2E7DF6" />
<meta-data android:name="com.amrshbib.pushbib.default_channel_name"
android:value="Notifications" />Notifications post to a high-importance pushbib_default channel unless the
campaign names one — see below.
Notification channels
Channels are defined server-side (dashboard → App Settings → Channels, the third section beside Project and Team) and created on the phone by the SDK. One channel holds the Android side (importance, sound, vibration, lights, lock-screen visibility, badge, DND bypass, grouping) and the iOS side (sound, interruption level, relevance score, thread id, critical alerts). An app normally runs several at once.
initialize() fetches them from /config and creates them before the first push
can land. A channel created later still works on its first send: the definition
travels inside the push and the SDK creates the channel on the spot.
await Pushbib.Channels.sync();
const onDevice = await Pushbib.Channels.list(); // [{ id, name, importance, blocked, showBadge }]
const muted = onDevice.filter((c) => c.blocked);
await Pushbib.Channels.openSettings('orders'); // wire this to your own settings screenImportance has four rungs, and sound, vibration and lights are each off, default or custom:
| Field | Values | On the phone |
| --- | --- | --- |
| importance | low | Silent, no banner (IMPORTANCE_LOW) |
| | medium | Makes a sound (IMPORTANCE_DEFAULT) |
| | high | Sound + heads-up banner (IMPORTANCE_HIGH) |
| | urgent | IMPORTANCE_MAX — the same threshold as high |
| sound | { mode: 'off' } | No sound |
| | { mode: 'default' } | The phone's default notification tone |
| | { mode: 'custom', name: 'order_ding' } | res/raw/order_ding |
| vibration | { mode: 'off' } | No vibration |
| | { mode: 'default' } | Vibrates, OS pattern |
| | { mode: 'custom', pattern: [0, 250, 250, 250] } | That pattern, in ms |
| led | { mode: 'off' } | No light |
| | { mode: 'default' } | Blinks, OS colour |
| | { mode: 'custom', color: '#2E7DF7' } | Blinks in that colour |
urgent and high are indistinguishable on the device — Android clamps
IMPORTANCE_MAX down — so Channels.list() reports 4 for both.
Sounds ship inside the app — sound mode custom names a file, it never
downloads one:
| Platform | Where the file goes | What sound.name holds |
| --- | --- | --- |
| Android | android/app/src/main/res/raw/order_ding.mp3 | order_ding — no extension, [a-z0-9_] |
| iOS | the app bundle, order_ding.caf | order_ding.caf — with extension, ≤ 30 s |
A name that is not bundled falls back to the default tone and logs a warning, so a missing file never means a silent push.
Android channels are immutable after creation, so editing the sound or importance
server-side creates nf_<key>_v<n+1> and deletes nf_<key>_v<n> on the next
sync — a user who muted the old one starts hearing the new one. If your app
already creates its own channels, set App-owned channel id on the channel and
the SDK sends that id through without creating anything — but note that a pinned
id never changes, so a channel that carries one keeps whatever sound, importance
and vibration the phone created it with, no matter what the dashboard says.
Known limitations (v1)
- iOS action buttons & rich images need a Notification Service Extension (an Apple constraint, not ours). Not included yet; Android supports both natively.
getTags()returns the local cache; usegetMe()for the server truth.- Badges on Android are channel-driven (
setBadgeCountis iOS-only). Channels.list()andopenSettings(key)are Android-only; on iOS they return[]and open the app's own settings page, because iOS has no channels.- iOS
time-sensitiveandcriticalneed an Apple entitlement — without it APNs accepts the push and iOS ignores the level. - iOS Live Activities need app-side Swift — the
ActivityAttributesand the widget must live in your Widget Extension, so no library can supply them. The SDK provides the token bridge; see the section above. Android needs nothing. - Android live updates can't be scheduled for removal. An
ENDwith a futuredismissal_dateleaves a dismissible card rather than auto-clearing later — Android has no equivalent of ActivityKit's dismissal date.
Troubleshooting
pushbib/no-android-config— the server has no Android push identity for this app yet: open App Settings and run "Detect from service account".pushbib/token-timeouton iOS — simulators can't get APNs tokens; use a real device and check the Push Notifications capability.- No pushes on iOS — upload the APNs
.p8key in App Settings; make sure sandbox matches your build type (debug = sandbox). - Android background pushes delayed — aggressive OEM battery savers (Xiaomi/Huawei…) throttle high-priority data messages for apps the user force-stopped; this affects every push SDK equally.
