From 27c60c90a27c18c3cbb5dba02210b5104c8d850f Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Fri, 6 Mar 2026 11:22:38 +0000 Subject: [PATCH 1/3] feat: register FirebaseMessagingService in AndroidManifest and wire into plugin pipeline Adds manifest registration for the generated service with priority 10 and android:exported=false. Detects existing FCM services and warns instead of conflicting. Wires the Android plugin into the push notification pipeline alongside the existing iOS plugins. Co-Authored-By: Claude Opus 4.6 --- .../withAndroidPushNotifications.test.ts | 128 ++++++++++++++++++ .../withAndroidPushNotifications.ts | 97 ++++++++++++- src/expo-plugins/withPushNotifications.ts | 2 + 3 files changed, 223 insertions(+), 4 deletions(-) diff --git a/__tests__/withAndroidPushNotifications.test.ts b/__tests__/withAndroidPushNotifications.test.ts index a5d113ff..f57b808a 100644 --- a/__tests__/withAndroidPushNotifications.test.ts +++ b/__tests__/withAndroidPushNotifications.test.ts @@ -4,6 +4,13 @@ import fs from 'fs'; jest.mock('@expo/config-plugins', () => ({ withDangerousMod: (config: any, [_platform, callback]: [string, Function]) => callback(config), + withAndroidManifest: (config: any, callback: Function) => callback(config), + AndroidConfig: { + Manifest: { + getMainApplicationOrThrow: (modResults: any) => + modResults.manifest.application[0], + }, + }, })); import { withAndroidPushNotifications } from '../src/expo-plugins/withAndroidPushNotifications'; @@ -16,6 +23,17 @@ function createMockConfig(packageName?: string) { modRequest: { projectRoot: '/mock/project', }, + modResults: { + manifest: { + application: [ + { + $: { 'android:name': '.MainApplication' }, + activity: [], + service: [] as any[], + }, + ], + }, + }, }; } @@ -153,6 +171,14 @@ dependencies { config: any, [_platform, callback]: [string, Function] ) => callback(config), + withAndroidManifest: (config: any, callback: Function) => + callback(config), + AndroidConfig: { + Manifest: { + getMainApplicationOrThrow: (modResults: any) => + modResults.manifest.application[0], + }, + }, })); jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); @@ -194,6 +220,14 @@ dependencies { config: any, [_platform, callback]: [string, Function] ) => callback(config), + withAndroidManifest: (config: any, callback: Function) => + callback(config), + AndroidConfig: { + Manifest: { + getMainApplicationOrThrow: (modResults: any) => + modResults.manifest.application[0], + }, + }, })); jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); @@ -225,6 +259,100 @@ dependencies { }); }); + describe('AndroidManifest service registration', () => { + test('adds service entry with correct attributes', () => { + const config = createMockConfig('com.example.myapp'); + withAndroidPushNotifications(config as any, {} as any); + + const services = config.modResults.manifest.application[0].service; + expect(services).toHaveLength(1); + + const service = services[0]; + expect(service.$['android:name']).toBe( + '.IntercomFirebaseMessagingService' + ); + expect(service.$['android:exported']).toBe('false'); + }); + + test('registers MESSAGING_EVENT intent filter with priority', () => { + const config = createMockConfig('com.example.myapp'); + withAndroidPushNotifications(config as any, {} as any); + + const service = config.modResults.manifest.application[0].service[0]; + const intentFilter = service['intent-filter'][0]; + const action = intentFilter.action[0]; + + expect(action.$['android:name']).toBe( + 'com.google.firebase.MESSAGING_EVENT' + ); + expect(intentFilter.$['android:priority']).toBe('10'); + }); + + test('preserves existing services when adding Intercom service', () => { + const config = createMockConfig('com.example.myapp'); + + config.modResults.manifest.application[0].service.push({ + $: { + 'android:name': '.SomeOtherService', + 'android:exported': 'false', + }, + } as any); + + withAndroidPushNotifications(config as any, {} as any); + + const services = config.modResults.manifest.application[0].service; + expect(services).toHaveLength(2); + expect(services[0].$['android:name']).toBe('.SomeOtherService'); + expect(services[1].$['android:name']).toBe( + '.IntercomFirebaseMessagingService' + ); + }); + + test('does not duplicate service on repeated runs (idempotency)', () => { + const config = createMockConfig('com.example.myapp'); + + withAndroidPushNotifications(config as any, {} as any); + withAndroidPushNotifications(config as any, {} as any); + + const services = config.modResults.manifest.application[0].service; + expect(services).toHaveLength(1); + }); + + test('skips registration and warns when another FCM service exists', () => { + const config = createMockConfig('com.example.myapp'); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + config.modResults.manifest.application[0].service.push({ + '$': { + 'android:name': '.ExistingFcmService', + 'android:exported': 'true', + }, + 'intent-filter': [ + { + action: [ + { + $: { + 'android:name': 'com.google.firebase.MESSAGING_EVENT', + }, + }, + ], + }, + ], + } as any); + + withAndroidPushNotifications(config as any, {} as any); + + const services = config.modResults.manifest.application[0].service; + expect(services).toHaveLength(1); + expect(services[0].$['android:name']).toBe('.ExistingFcmService'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('existing FirebaseMessagingService') + ); + + warnSpy.mockRestore(); + }); + }); + describe('error handling', () => { test('throws if android.package is not defined', () => { const config = createMockConfig(); diff --git a/src/expo-plugins/withAndroidPushNotifications.ts b/src/expo-plugins/withAndroidPushNotifications.ts index fa89d5a0..1ff3bce3 100644 --- a/src/expo-plugins/withAndroidPushNotifications.ts +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -1,7 +1,12 @@ import path from 'path'; import fs from 'fs'; -import { type ConfigPlugin, withDangerousMod } from '@expo/config-plugins'; +import { + type ConfigPlugin, + withDangerousMod, + withAndroidManifest, + AndroidConfig, +} from '@expo/config-plugins'; import type { IntercomPluginProps } from './@types'; const SERVICE_CLASS_NAME = 'IntercomFirebaseMessagingService'; @@ -57,9 +62,7 @@ class ${SERVICE_CLASS_NAME} : ${baseClass}() { * into the app's Android source directory, and ensures firebase-messaging * is on the app module's compile classpath. */ -export const withAndroidPushNotifications: ConfigPlugin = ( - _config -) => +const writeFirebaseService: ConfigPlugin = (_config) => withDangerousMod(_config, [ 'android', (config) => { @@ -124,3 +127,89 @@ export const withAndroidPushNotifications: ConfigPlugin = ( return config; }, ]); + +/** + * Adds the FirebaseMessagingService entry to the AndroidManifest.xml + * so Android knows to route FCM events to our service. + */ +const registerServiceInManifest: ConfigPlugin = ( + _config +) => + withAndroidManifest(_config, (config) => { + const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow( + config.modResults + ); + + const packageName = config.android?.package; + if (!packageName) { + throw new Error( + '@intercom/intercom-react-native: android.package must be defined in your Expo config to use Android push notifications.' + ); + } + + const serviceName = `.${SERVICE_CLASS_NAME}`; + + const existingService = mainApplication.service?.find( + (s) => s.$?.['android:name'] === serviceName + ); + + const hasExistingFcmService = mainApplication.service?.some( + (s) => + s.$?.['android:name'] !== serviceName && + s['intent-filter']?.some( + (f: any) => + f.action?.some( + (a: any) => + a.$?.['android:name'] === 'com.google.firebase.MESSAGING_EVENT' + ) + ) + ); + + if (hasExistingFcmService) { + console.warn( + '@intercom/intercom-react-native: An existing FirebaseMessagingService was found in AndroidManifest.xml. ' + + 'Skipping automatic Intercom service registration to avoid conflicts. ' + + 'You will need to route Intercom pushes manually using IntercomModule.isIntercomPush() and IntercomModule.handleRemotePushMessage().' + ); + return config; + } + + if (!existingService) { + if (!mainApplication.service) { + mainApplication.service = []; + } + + mainApplication.service.push({ + '$': { + 'android:name': serviceName, + 'android:exported': 'false' as any, + }, + 'intent-filter': [ + { + $: { + 'android:priority': '10', + } as any, + action: [ + { + $: { + 'android:name': 'com.google.firebase.MESSAGING_EVENT', + }, + }, + ], + }, + ], + } as any); + } + + return config; + }); + +export const withAndroidPushNotifications: ConfigPlugin = ( + config, + props +) => { + let newConfig = config; + newConfig = writeFirebaseService(newConfig, props); + newConfig = registerServiceInManifest(newConfig, props); + return newConfig; +}; diff --git a/src/expo-plugins/withPushNotifications.ts b/src/expo-plugins/withPushNotifications.ts index 8de9b286..6a0e912c 100644 --- a/src/expo-plugins/withPushNotifications.ts +++ b/src/expo-plugins/withPushNotifications.ts @@ -8,6 +8,7 @@ import { findObjcFunctionCodeBlock, insertContentsInsideObjcFunctionBlock, } from '@expo/config-plugins/build/ios/codeMod'; +import { withAndroidPushNotifications } from './withAndroidPushNotifications'; const appDelegate: ConfigPlugin = (_config) => withAppDelegate(_config, (config) => { @@ -61,5 +62,6 @@ export const withIntercomPushNotification: ConfigPlugin = ( let newConfig = config; newConfig = appDelegate(newConfig, props); newConfig = infoPlist(newConfig, props); + newConfig = withAndroidPushNotifications(newConfig, props); return newConfig; }; From 27077e59df25b339ff5cfa6c49332b7dc0fdcd75 Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Mon, 9 Mar 2026 13:18:26 +0000 Subject: [PATCH 2/3] docs: document FCM conflict detection plugin-ordering limitation The conflict detection in registerServiceInManifest only works when the competing plugin is listed after Intercom in the Expo plugins array, due to Expo's LIFO mod execution order. Added a JSDoc comment explaining this constraint and advising users to list Intercom first. Co-Authored-By: Claude Opus 4.6 --- src/expo-plugins/withAndroidPushNotifications.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/expo-plugins/withAndroidPushNotifications.ts b/src/expo-plugins/withAndroidPushNotifications.ts index 1ff3bce3..be4c03f3 100644 --- a/src/expo-plugins/withAndroidPushNotifications.ts +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -131,6 +131,13 @@ const writeFirebaseService: ConfigPlugin = (_config) => /** * Adds the FirebaseMessagingService entry to the AndroidManifest.xml * so Android knows to route FCM events to our service. + * + * If another FirebaseMessagingService is already registered (from another + * SDK or manual setup), skips registration and warns. Note: due to Expo's + * LIFO mod execution order, this detection only works when the conflicting + * plugin is listed AFTER @intercom/intercom-react-native in the plugins + * array. If your app has another FCM-handling SDK, list Intercom first + * in the plugins array to ensure conflict detection works correctly. */ const registerServiceInManifest: ConfigPlugin = ( _config From 487d4d0f0f899c71b1d270a14cc10f4974fcb259 Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Mon, 9 Mar 2026 13:27:42 +0000 Subject: [PATCH 3/3] docs: add Android push notification guidance to Expo README section Co-Authored-By: Claude Opus 4.6 --- README.md | 4 ++++ src/expo-plugins/withAndroidPushNotifications.ts | 11 ----------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 178fe7ed..c2844190 100644 --- a/README.md +++ b/README.md @@ -660,6 +660,10 @@ Add the necessary permission descriptions to infoPlist key. Next, rebuild your app as described in the ["Adding custom native code"](https://docs.expo.io/workflow/customizing/) guide. +The Expo plugin automatically generates a `FirebaseMessagingService` for Android that routes Intercom pushes to the SDK and passes non-Intercom messages through to other handlers (e.g. `expo-notifications`). + +> **Note**: If your app uses another SDK that registers its own `FirebaseMessagingService` (e.g. OneSignal, Braze), list `@intercom/intercom-react-native` **before** that SDK in your `plugins` array. This allows the plugin to detect the other service and skip its own registration, avoiding conflicts. + #### Expo: Push notification deep links support > **Note**: You can read more on Expo [documentation](https://docs.expo.dev/guides/deep-linking) diff --git a/src/expo-plugins/withAndroidPushNotifications.ts b/src/expo-plugins/withAndroidPushNotifications.ts index be4c03f3..118a371c 100644 --- a/src/expo-plugins/withAndroidPushNotifications.ts +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -128,17 +128,6 @@ const writeFirebaseService: ConfigPlugin = (_config) => }, ]); -/** - * Adds the FirebaseMessagingService entry to the AndroidManifest.xml - * so Android knows to route FCM events to our service. - * - * If another FirebaseMessagingService is already registered (from another - * SDK or manual setup), skips registration and warns. Note: due to Expo's - * LIFO mod execution order, this detection only works when the conflicting - * plugin is listed AFTER @intercom/intercom-react-native in the plugins - * array. If your app has another FCM-handling SDK, list Intercom first - * in the plugins array to ensure conflict detection works correctly. - */ const registerServiceInManifest: ConfigPlugin = ( _config ) =>