npm package: @pierrecapo/expo-notification-service-extension
Description: Expo module for iOS Communication Notifications with avatar support via Notification Service Extension
Author: PierreCapo
Latest version: 0.1.0

# @pierrecapo/expo-notification-service-extension

An Expo module that adds iOS [Communication Notifications](https://developer.apple.com/documentation/usernotifications/implementing-communication-notifications) to your app. When a push notification arrives, the Notification Service Extension downloads the sender's avatar and displays it as a circular image alongside the notification — just like iMessage or WhatsApp.

**iOS only** | iOS 15+ | Expo SDK 55+

## Features

- Circular sender avatar on push notifications via `INSendMessageIntent`
- Group notification support (group avatar + sender name in body)
- Automatic avatar caching with LRU eviction
- Configurable payload key mapping (works with any backend)
- Optional developer hook for custom notification processing (decryption, etc.)
- Diagnostics API to verify setup

## Installation

```bash
npx expo install @pierrecapo/expo-notification-service-extension
```

## Setup

### 1. Add the config plugin

In your `app.json` or `app.config.js`:

```json
{
  "expo": {
    "plugins": [
      ["@pierrecapo/expo-notification-service-extension", {
        "enableLogging": true
      }]
    ]
  }
}
```

### 2. Rebuild native project

```bash
npx expo prebuild --platform ios --clean
```

### 3. Configure key mapping

Call this once on app startup to tell the extension where to find fields in your push payload:

```ts
import { configureNotificationExtension } from "@pierrecapo/expo-notification-service-extension";

configureNotificationExtension({
  senderIdKey: "data.senderId",             // required — dot-path to sender ID
  avatarUrlKey: "data.senderAvatar",        // optional — dot-path to avatar URL
  conversationIdKey: "data.conversationId", // optional — groups notifications
  groupNameKey: "data.groupName",           // optional — enables group mode, shown as subtitle
  groupAvatarUrlKey: "data.groupAvatar",    // optional — group avatar (circular icon)
});
```

### 4. Send a push notification

Your push payload must include `"mutable-content": 1` in the `aps` dictionary for the extension to intercept it. The notification `title` is used as the sender display name.

**DM notification** — shows the sender's circular avatar, their name as the title, and the message body. No subtitle.

```json
{
  "aps": {
    "alert": {
      "title": "Alice",
      "body": "Hey! Are you coming tonight?"
    },
    "mutable-content": 1
  },
  "data": {
    "senderId": "user-123",
    "senderAvatar": "https://example.com/alice-avatar.jpg",
    "conversationId": "conv-456"
  }
}
```

**Group notification** — shows the group's circular avatar, the group name as subtitle, and sender attribution in the body text (like WhatsApp). The `groupName` value becomes the notification subtitle, and `groupAvatar` becomes the circular icon:

```json
{
  "aps": {
    "alert": {
      "title": "Fitness Squad",
      "body": "~ Alice: Who's coming to the gym?"
    },
    "mutable-content": 1
  },
  "data": {
    "senderId": "user-123",
    "conversationId": "group-789",
    "groupName": "Fitness Squad",
    "groupAvatar": "https://example.com/squad-avatar.jpg"
  }
}
```

## API

### `configureNotificationExtension(config)`

Writes the payload key mapping to the shared App Group so the extension can read it. **Must be called at least once** before notifications can be enhanced. If the config hasn't been written yet, the extension passes notifications through unchanged.

```ts
type NotificationExtensionConfig = {
  senderIdKey: string;        // required
  avatarUrlKey?: string;
  conversationIdKey?: string;
  groupNameKey?: string;      // shown as notification subtitle
  groupAvatarUrlKey?: string; // circular icon in group mode
};
```

All keys support **dot-path notation** (e.g., `"data.sender.id"`) for nested payloads.

### `prefetchAvatar(url, id)`

Pre-downloads an avatar image into the shared cache. The extension will use it instead of downloading at notification time. Useful for warming the cache on app launch or when opening a conversation.

```ts
import { prefetchAvatar } from "@pierrecapo/expo-notification-service-extension";

await prefetchAvatar("https://example.com/alice.jpg", "user-123");
```

### `clearAvatarCache()`

Deletes all cached avatar images.

```ts
import { clearAvatarCache } from "@pierrecapo/expo-notification-service-extension";

await clearAvatarCache();
```

### `validateSetup(samplePayload?)`

Runs diagnostics to verify the extension is correctly configured. Optionally pass a sample payload to dry-run key extraction.

```ts
import { validateSetup } from "@pierrecapo/expo-notification-service-extension";

const result = await validateSetup({
  data: { senderId: "user-123", senderAvatar: "https://example.com/avatar.jpg" },
});

// result:
// {
//   configValid: true,
//   appGroupAccessible: true,
//   cachedAvatarCount: 3,
//   payloadParseResult: {
//     senderId: "user-123",
//     avatarUrl: "https://example.com/avatar.jpg",
//     conversationId: null,
//     groupName: null,
//     groupAvatarUrl: null
//   }
// }
```

## Plugin Options

All options are optional — sensible defaults are provided.

```ts
["@pierrecapo/expo-notification-service-extension", {
  // Bundle ID for the NSE target
  // Default: "{appBundleId}.NotificationServiceExtension"
  extensionBundleId: "com.myapp.NotificationServiceExtension",

  // App Group identifier (shared between app and extension)
  // Default: "group.{appBundleId}"
  appGroupId: "group.com.myapp",

  // Path to a default avatar image used when download fails or no URL is provided
  defaultAvatarImage: "./assets/default-avatar.png",

  // Max avatar cache size in MB (LRU eviction when exceeded)
  // Default: 50
  cacheSizeLimitMB: 50,

  // Path to a Swift file with custom notification processing logic
  extensionHookFile: "./ios/MyNotificationHook.swift",

  // Enable NSLog output in the extension (visible in Console.app)
  // Default: false
  enableLogging: false,

  // Code signing configuration for the extension target
  signing: {
    style: "automatic",           // "automatic" | "manual"
    developmentTeam: "TEAM_ID",   // inherited from main app if not set
    provisioningProfile: "...",   // manual signing only
    codeSignIdentity: "...",      // manual signing only
  }
}]
```

## How It Works

The config plugin adds a [Notification Service Extension](https://developer.apple.com/documentation/usernotifications/modifying-content-in-newly-delivered-notifications) target to your Xcode project. This is a separate iOS process that intercepts push notifications before they are displayed.

When a push with `mutable-content: 1` arrives:

1. The extension reads the key mapping config from the shared App Group
2. It extracts the sender ID, avatar URL, and optional group fields from the payload
3. It resolves the avatar (cache hit, download, or fallback to bundled default)
4. It builds an `INSendMessageIntent` with the sender's `INPerson` and avatar
5. It updates the notification content with the intent
6. iOS renders the notification with the circular avatar and app icon badge

The plugin automatically configures:
- A Notification Service Extension Xcode target with all source files
- App Group entitlement on both the main app and extension targets
- Communication Notifications entitlement on the main app
- `INSendMessageIntent` in the main app's `NSUserActivityTypes`
- Intents and UserNotifications frameworks linked to the extension
- Target dependency so the extension is built and embedded with the app

## Avatar Caching

The extension caches downloaded avatars in the App Group container, keyed by `{id}_{urlHash}`. On subsequent notifications:

- **Same URL**: uses the cached file (no network request)
- **Different URL** (e.g., user changed their avatar): re-downloads and replaces the cache
- **Download fails**: falls back to stale cache if available, then to the bundled default avatar

When the cache exceeds the size limit (default 50MB), the oldest files by access date are evicted (LRU). You can also pre-warm the cache from JS with `prefetchAvatar()` or clear it entirely with `clearAvatarCache()`.

## Developer Hook

For advanced use cases (e.g., decrypting an end-to-end encrypted notification body), provide a Swift file via `extensionHookFile`. The extension calls your function before processing the notification:

```swift
// ios/MyNotificationHook.swift
import UserNotifications

extension NotificationHook {
  static func willProcessNotification(
    content: UNMutableNotificationContent
  ) -> UNMutableNotificationContent {
    // Decrypt, modify, or enrich the content here
    return content
  }
}
```

## Troubleshooting

### Notification shows without avatar

1. Verify `"mutable-content": 1` is in your `aps` payload
2. The app must be **backgrounded or closed** — the NSE doesn't run when the app is in the foreground
3. Run `validateSetup()` to check that the config is written and the App Group is accessible
4. Enable logging (`enableLogging: true` in plugin options) and check **Console.app** on your Mac, filtering by your extension's bundle ID

### Extension doesn't seem to run at all

1. Run `npx expo prebuild --platform ios --clean` to regenerate the native project
2. In Xcode, verify the `NotificationServiceExtension` target exists and is listed under the main app's target dependencies
3. Check that both targets have the same development team in Signing & Capabilities
4. The extension's bundle ID must be a child of the main app's (e.g., `com.myapp.NotificationServiceExtension`)

### "messaging notifications are not allowed" in Console.app

The main app is missing required capabilities. The plugin adds these automatically, but if you see this error after a manual Xcode change:
1. Select the main app target > Signing & Capabilities
2. Ensure **Communication Notifications** is listed (add it via + Capability if missing)
3. Ensure the main app's Info.plist contains `NSUserActivityTypes` with `INSendMessageIntent`
4. Clean build (Cmd+Shift+K) and re-run

## Limitations

- **iOS only** — Android uses a different notification system
- **One NSE per app** — iOS allows only one Notification Service Extension per app. If you need additional NSE functionality (rich media attachments, payload decryption), use the `extensionHookFile` option
- **30-second time limit** — iOS gives the extension ~30 seconds to process each notification. Avatar downloads are typically well under 1 second
- **Foreground notifications** — the NSE only runs when the app is backgrounded or closed. Foreground notifications bypass the extension entirely

## License

MIT