Notifications
Remind the user at the right moment, reach them with push when the app is closed, and know which
notification they tapped. @symbiote-native/notifications wraps
expo-notifications
(permissions, device and Expo push tokens, scheduling, presentation, badges, Android channels,
iOS and Android categories, and the background notification task hook) for every SymbioteNative
adapter.
Every core export is a plain async function or a module-level listener registration, shared by all
adapters. One adapter-specific binding, useLastNotificationResponse, is ported to all five.
| OS platform | Support |
|---|---|
| iOS | ✅ live |
| Android | ✅ live |
| Framework adapter | Support |
|---|---|
| React | ✅ live |
| Vue | ✅ live |
| Angular | ✅ live |
| Svelte | ✅ live |
| Solid | ✅ live |
Installation
Section titled “Installation”npm install @symbiote-native/notificationsScaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --notifications (or
add --notifications in an existing app) installs and wires this for you — see
@symbiote-native/cli.
expo-notifications and expo-modules-core come along as regular, pinned dependencies — never
install either yourself, and never add the expo meta-package to your project.
Local notifications need nothing further
Section titled “Local notifications need nothing further”Permissions, scheduling, presentation, badges, channels, and categories all work with zero extra app-level native config beyond the linker step above.
Push notifications need real, app-specific native configuration
Section titled “Push notifications need real, app-specific native configuration”| Platform | What you must add yourself | Why |
|---|---|---|
| Android | A real Firebase project + google-services.json, plus the Google Services Gradle plugin |
Push delivery is FCM, not a generic APNs-style relay |
| Android | A 96×96 all-white PNG at res/drawable-*dpi/notification_icon.png, plus two <meta-data> entries on <application> (com.google.firebase.messaging.default_notification_icon, expo.modules.notifications.default_notification_icon), both @drawable/notification_icon |
The status-bar icon Android draws for a notification with no custom small icon |
| Android | Optionally, android:color via @color/notification_icon_color and the matching default_notification_color meta-data entries |
Tint applied to the small icon above |
| Android | Optionally, com.google.firebase.messaging.default_notification_channel_id meta-data |
Which channel an FCM-delivered notification lands in when the payload names none |
| Android | Copy any custom sound file into res/raw/ |
INotificationContentInput.sound on iOS; Android channels carry their own sound field — see setNotificationChannelAsync |
| iOS | The Push Notifications capability + aps-environment entitlement (development/production) in Xcode |
getDevicePushTokenAsync/getExpoPushTokenAsync need APNs registration, which needs this entitlement — omitting it fails registration silently |
| iOS | Add any custom sound file as a bundle resource in Xcode | Same as Android’s res/raw/ step |
| iOS | Optionally, UIBackgroundModes including remote-notification in Info.plist |
Lets a background/silent push wake the app to run a registered task |
| Both | @symbiote-native/task-manager installed, with a task defineTask’d at module scope before registerTaskAsync runs |
registerTaskAsync/unregisterTaskAsync here need it linked to work at all |
import * as Notifications from '@symbiote-native/notifications';
Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: false, }),});
const { granted } = await Notifications.requestPermissionsAsync();if (granted) { const identifier = await Notifications.scheduleNotificationAsync({ content: { title: "Time's up!", body: 'Change sides!' }, trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: 60 }, });
const subscription = Notifications.addNotificationReceivedListener(notification => { console.log(notification.request.content.title); }); // later: subscription.remove();
await Notifications.cancelScheduledNotificationAsync(identifier);}Identical import surface on every adapter: @symbiote-native/notifications/react, /vue,
/svelte, /solid, /angular all re-export the same functions.
React to the tapped notification
Section titled “React to the tapped notification”useLastNotificationResponse returns the notification the user last tapped, so a screen can open
the right content, including when the tap launched the app. It is undefined until the first
read, then the response, or null when there is none.
import { useLastNotificationResponse } from '@symbiote-native/notifications/react';
export default function Inbox() { const response = useLastNotificationResponse();
return <text>{response?.notification.request.content.title ?? 'no tap yet'}</text>;}<script setup lang="ts">import { useLastNotificationResponse } from '@symbiote-native/notifications/vue';
const response = useLastNotificationResponse();</script>
<template> <text>{{ response?.notification.request.content.title ?? 'no tap yet' }}</text></template>import { Component, inject } from '@angular/core';import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';import { LastNotificationResponseService } from '@symbiote-native/notifications/angular';
@Component({ standalone: true, imports: [SYMBIOTE_ELEMENTS], template: `<text>{{ response()?.notification.request.content.title ?? 'no tap yet' }}</text>`,})export class Inbox { readonly response = inject(LastNotificationResponseService).connect();}connect() returns a signal and registers its subscriptions against the component.
<script lang="ts"> import { useLastNotificationResponse } from '@symbiote-native/notifications/svelte';
const last = useLastNotificationResponse();</script>
<text>{last.current?.notification.request.content.title ?? 'no tap yet'}</text>import { createLastNotificationResponse } from '@symbiote-native/notifications/solid';
export function Inbox() { const response = createLastNotificationResponse();
return <text>{response()?.notification.request.content.title ?? 'no tap yet'}</text>;}Solid reserves use* for consuming existing state, so the primitive is create*.
Get a push token
Section titled “Get a push token”const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId });projectId is required. applicationId and the iOS push environment default from
@symbiote-native/application. Use getDevicePushTokenAsync() to
get the raw APNs or FCM token instead.
// permissionsgetPermissionsAsync(): Promise<INotificationPermissionsStatus>requestPermissionsAsync(permissions?: INotificationPermissionsRequest): Promise<INotificationPermissionsStatus>
// tokensaddPushTokenListener(listener: IPushTokenListener): EventSubscriptiongetDevicePushTokenAsync(): Promise<IDevicePushToken>getExpoPushTokenAsync(options?: IExpoPushTokenOptions): Promise<IExpoPushToken>setAutoServerRegistrationEnabledAsync(enabled: boolean): Promise<void>unregisterForNotificationsAsync(): Promise<void>subscribeToTopicAsync(topic: string): Promise<null> // androidunsubscribeFromTopicAsync(topic: string): Promise<null> // android
// presentationgetPresentedNotificationsAsync(): Promise<INotification[]>dismissNotificationAsync(notificationIdentifier: string): Promise<void>dismissAllNotificationsAsync(): Promise<void>
// badgegetBadgeCountAsync(): Promise<number>setBadgeCountAsync(badgeCount: number): Promise<boolean>
// schedulingscheduleNotificationAsync(request: INotificationRequestInput): Promise<string>getAllScheduledNotificationsAsync(): Promise<INotificationRequest[]>cancelScheduledNotificationAsync(identifier: string): Promise<void>cancelAllScheduledNotificationsAsync(): Promise<void>getNextTriggerDateAsync(trigger: ISchedulableNotificationTriggerInput): Promise<number | null>
// categoriesgetNotificationCategoriesAsync(): Promise<INotificationCategory[]>setNotificationCategoryAsync(identifier, actions, options?): Promise<INotificationCategory>deleteNotificationCategoryAsync(identifier: string): Promise<boolean>
// channels — android only (no-op returning []/null/void elsewhere)getNotificationChannelsAsync(): Promise<INotificationChannel[]>getNotificationChannelAsync(channelId: string): Promise<INotificationChannel | null>setNotificationChannelAsync(channelId, channel): Promise<INotificationChannel | null>deleteNotificationChannelAsync(channelId: string): Promise<void>getNotificationChannelGroupsAsync(): Promise<INotificationChannelGroup[]>getNotificationChannelGroupAsync(groupId: string): Promise<INotificationChannelGroup | null>setNotificationChannelGroupAsync(groupId, group): Promise<INotificationChannelGroup | null>deleteNotificationChannelGroupAsync(groupId: string): Promise<void>
// handler / emittersetNotificationHandler(handler: INotificationHandler | null): voidaddNotificationReceivedListener(listener): EventSubscriptionaddNotificationsDroppedListener(listener): EventSubscription // androidaddNotificationResponseReceivedListener(listener): EventSubscriptiongetLastNotificationResponse(): INotificationResponse | nullclearLastNotificationResponse(): voidaddNotificationResponseClearedListener(listener): EventSubscriptionDEFAULT_ACTION_IDENTIFIER: string
// background task — needs @symbiote-native/task-manager, see native setup aboveregisterTaskAsync(taskName: string): Promise<null>unregisterTaskAsync(taskName: string): Promise<null>Plus the enums IosAlertStyle, IosAllowsPreviews, IosAuthorizationStatus,
AndroidNotificationVisibility, AndroidAudioContentType, AndroidImportance,
AndroidAudioUsage, AndroidNotificationPriority, SchedulableTriggerInputTypes,
BackgroundNotificationTaskResult, PermissionStatus, and the full content/trigger/payload type
surface.
Per-adapter binding
Section titled “Per-adapter binding”| Adapter | Entry point | Returns |
|---|---|---|
| React | useLastNotificationResponse() |
The last response, null or undefined |
| Vue | useLastNotificationResponse() |
A ref of the same |
| Svelte | useLastNotificationResponse() |
An object with a reactive current |
| Solid | createLastNotificationResponse() |
An accessor of the same |
| Angular | inject(LastNotificationResponseService).connect() |
A signal of the same |
The response is deduplicated by notification identifier and kept in sync with
addNotificationResponseReceivedListener and addNotificationResponseClearedListener.
Push tokens and server registration
Section titled “Push tokens and server registration”- Token resync is explicit. Upstream re-sends a rolled device token to Expo’s push backend with
exponential backoff. Here that is
installPushTokenAutoRegistration(). It is idempotent and is installed bysetAutoServerRegistrationEnabledAsync(true)(so also bygetExpoPushTokenAsync). Call it once at app startup too, so a token that rolled while the app was closed is re-sent. It is explicit rather than a module-load side effect because Metro’sinlineRequiresskips a barrel re-export that nothing names as a value. projectIdmust be passed. Upstream reads it fromexpo-constants; this package has no access to it.- The deprecated
getLastNotificationResponseAsyncandclearLastNotificationResponseAsyncare ported as thin wrappers over the synchronous forms, exactly like upstream.
- Push does not work on emulators or simulators (see Common questions below). Test remote push on a physical device. Local notifications work everywhere.
- Android push is FCM. It needs your own Firebase project (see the table above).
setBadgeCountAsynctakes no options here. Upstream’s web-onlybadginoption bag is not ported; this project targets iOS and Android.
- Android channel/channel-group functions branch on platform at runtime rather than shipping
as separate iOS/Android builds — off Android they resolve to the documented no-op values
(
[]/null/void) without touching native.
Common questions
Section titled “Common questions”getExpoPushTokenAsync fails or I never get a token. Pass your projectId, since this package
cannot read it from the Expo config. On Android, create a notification channel first:
setNotificationChannelAsync must run before getDevicePushTokenAsync or getExpoPushTokenAsync,
because Android 13’s permission prompt does not appear until a channel exists. Android also needs
your FCM credentials and google-services.json (see the table above).
A notification arrives but nothing shows while the app is open. Foreground notifications need a
handler. Call setNotificationHandler with shouldShowBanner and shouldShowList set to true.
Pushes arrive silently or late on Android. Check the notification channel: one created with low
importance is silent. Create it with importance: AndroidImportance.HIGH, and send the push with
priority: 'high'. Battery optimization can still delay normal-priority messages.
Push never arrives when the app is closed. Confirm a token was registered on the server and, for Android, that the project has valid FCM V1 credentials. Test on a physical device: push does not work on emulators or simulators.
Which notification did the user tap? Use useLastNotificationResponse (see above), or
addNotificationResponseReceivedListener.
Do I need push to show a reminder? No. Scheduled local notifications work with no Firebase or APNs setup.
Sources: Expo docs: Notifications, Expo docs: push notifications setup, expo/expo#49638 response listener does not fire for a foreground tap on Android, expo/expo#9745 FCM notifications not received in the foreground, DEV: basics and caveats of expo-notifications.