# Notifications

> Schedule and present local notifications, receive push, and react to taps, with badges, channels and background delivery, on every SymbioteNative adapter.

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`](https://github.com/expo/expo/tree/main/packages/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

```sh
npm install @symbiote-native/notifications
```

Scaffolding 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`](https://github.com/OneEyed1366/symbiote-native/tree/master/packages/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.

<Aside type="danger" title="Native setup is required before first use">
  Follow [How to: wire up an Expo native
  module](/docs/howtos/expo-native-module-setup/) once per app first — it
  wires this package's 13 native modules with zero further native changes.
  None of the following steps are covered by that linker; read them before
  shipping push.
</Aside>

### 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

| 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`](/docs/packages/task-manager/) installed, with a task `defineTask`'d at module scope before `registerTaskAsync` runs | `registerTaskAsync`/`unregisterTaskAsync` here need it linked to work at all |

## Usage

```ts
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

`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.

<Tabs syncKey="framework">
  <TabItem label="React">
    ```tsx
    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>;
    }
    ```

  </TabItem>
  <TabItem label="Vue">
    ```vue
    <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>
    ```

  </TabItem>
  <TabItem label="Angular">
    ```ts
    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.

  </TabItem>
  <TabItem label="Svelte">
    ```svelte
    <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>
    ```

  </TabItem>
  <TabItem label="Solid">
    ```tsx
    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*`.

  </TabItem>
</Tabs>

### Get a push token

```ts
const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId });
```

`projectId` is required. `applicationId` and the iOS push environment default from
[`@symbiote-native/application`](/docs/packages/application/). Use `getDevicePushTokenAsync()` to
get the raw APNs or FCM token instead.

## API

```ts
// permissions
getPermissionsAsync(): Promise<INotificationPermissionsStatus>
requestPermissionsAsync(permissions?: INotificationPermissionsRequest): Promise<INotificationPermissionsStatus>

// tokens
addPushTokenListener(listener: IPushTokenListener): EventSubscription
getDevicePushTokenAsync(): Promise<IDevicePushToken>
getExpoPushTokenAsync(options?: IExpoPushTokenOptions): Promise<IExpoPushToken>
setAutoServerRegistrationEnabledAsync(enabled: boolean): Promise<void>
unregisterForNotificationsAsync(): Promise<void>
subscribeToTopicAsync(topic: string): Promise<null>          // android
unsubscribeFromTopicAsync(topic: string): Promise<null>      // android

// presentation
getPresentedNotificationsAsync(): Promise<INotification[]>
dismissNotificationAsync(notificationIdentifier: string): Promise<void>
dismissAllNotificationsAsync(): Promise<void>

// badge
getBadgeCountAsync(): Promise<number>
setBadgeCountAsync(badgeCount: number): Promise<boolean>

// scheduling
scheduleNotificationAsync(request: INotificationRequestInput): Promise<string>
getAllScheduledNotificationsAsync(): Promise<INotificationRequest[]>
cancelScheduledNotificationAsync(identifier: string): Promise<void>
cancelAllScheduledNotificationsAsync(): Promise<void>
getNextTriggerDateAsync(trigger: ISchedulableNotificationTriggerInput): Promise<number | null>

// categories
getNotificationCategoriesAsync(): 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 / emitter
setNotificationHandler(handler: INotificationHandler | null): void
addNotificationReceivedListener(listener): EventSubscription
addNotificationsDroppedListener(listener): EventSubscription    // android
addNotificationResponseReceivedListener(listener): EventSubscription
getLastNotificationResponse(): INotificationResponse | null
clearLastNotificationResponse(): void
addNotificationResponseClearedListener(listener): EventSubscription
DEFAULT_ACTION_IDENTIFIER: string

// background task — needs @symbiote-native/task-manager, see native setup above
registerTaskAsync(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

| 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

- **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 by `setAutoServerRegistrationEnabledAsync(true)` (so also by `getExpoPushTokenAsync`).
  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's `inlineRequires` skips a barrel
  re-export that nothing names as a value.
- **`projectId` must be passed.** Upstream reads it from `expo-constants`; this package has no
  access to it.
- **The deprecated `getLastNotificationResponseAsync` and `clearLastNotificationResponseAsync`** are
  ported as thin wrappers over the synchronous forms, exactly like upstream.

## Notes

- **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).
- **`setBadgeCountAsync` takes no options here.** Upstream's web-only `badgin` option bag is not
  ported; this project targets iOS and Android.

<Aside type="caution" title="setNotificationHandler must respond within 3 seconds">
  A slow `handleNotification` implementation causes native to discard the
  notification and call `handleError` with a `NotificationTimeoutError`
  instead.
</Aside>

- **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

**`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](https://docs.expo.dev/versions/latest/sdk/notifications/),
[Expo docs: push notifications setup](https://docs.expo.dev/push-notifications/push-notifications-setup/),
[expo/expo#49638 response listener does not fire for a foreground tap on Android](https://github.com/expo/expo/issues/49638),
[expo/expo#9745 FCM notifications not received in the foreground](https://github.com/expo/expo/issues/9745),
[DEV: basics and caveats of expo-notifications](https://dev.to/marianapatcosta/basics-and-caveats-of-expo-notifications-23cd).
