@pierrecapo/expo-notification-service-extension
v0.1.0
Published
Expo module for iOS Communication Notifications with avatar support via Notification Service Extension
Maintainers
Readme
@pierrecapo/expo-notification-service-extension
An Expo module that adds iOS 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
npx expo install @pierrecapo/expo-notification-service-extensionSetup
1. Add the config plugin
In your app.json or app.config.js:
{
"expo": {
"plugins": [
["@pierrecapo/expo-notification-service-extension", {
"enableLogging": true
}]
]
}
}2. Rebuild native project
npx expo prebuild --platform ios --clean3. Configure key mapping
Call this once on app startup to tell the extension where to find fields in your push payload:
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.
{
"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:
{
"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.
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.
import { prefetchAvatar } from "@pierrecapo/expo-notification-service-extension";
await prefetchAvatar("https://example.com/alice.jpg", "user-123");clearAvatarCache()
Deletes all cached avatar images.
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.
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.
["@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 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:
- The extension reads the key mapping config from the shared App Group
- It extracts the sender ID, avatar URL, and optional group fields from the payload
- It resolves the avatar (cache hit, download, or fallback to bundled default)
- It builds an
INSendMessageIntentwith the sender'sINPersonand avatar - It updates the notification content with the intent
- 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
INSendMessageIntentin the main app'sNSUserActivityTypes- 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:
// 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
- Verify
"mutable-content": 1is in yourapspayload - The app must be backgrounded or closed — the NSE doesn't run when the app is in the foreground
- Run
validateSetup()to check that the config is written and the App Group is accessible - Enable logging (
enableLogging: truein plugin options) and check Console.app on your Mac, filtering by your extension's bundle ID
Extension doesn't seem to run at all
- Run
npx expo prebuild --platform ios --cleanto regenerate the native project - In Xcode, verify the
NotificationServiceExtensiontarget exists and is listed under the main app's target dependencies - Check that both targets have the same development team in Signing & Capabilities
- 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:
- Select the main app target > Signing & Capabilities
- Ensure Communication Notifications is listed (add it via + Capability if missing)
- Ensure the main app's Info.plist contains
NSUserActivityTypeswithINSendMessageIntent - 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
extensionHookFileoption - 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
