From 0290e8ad7c3d538392a4754171a97a0716a68b15 Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Fri, 6 Mar 2026 11:22:38 +0000 Subject: [PATCH] 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 | 112 ++++++++++++++++++ .../withAndroidPushNotifications.ts | 97 ++++++++++++++- src/expo-plugins/withPushNotifications.ts | 6 +- 3 files changed, 209 insertions(+), 6 deletions(-) diff --git a/__tests__/withAndroidPushNotifications.test.ts b/__tests__/withAndroidPushNotifications.test.ts index 8f68a4b2..a5ffbb3f 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[], + }, + ], + }, + }, }; } @@ -178,6 +196,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 25e38667..361ba1b7 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'; @@ -40,9 +45,7 @@ class ${SERVICE_CLASS_NAME} : FirebaseMessagingService() { * 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) => { @@ -110,3 +113,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 64f06e8a..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) => { @@ -59,7 +60,8 @@ export const withIntercomPushNotification: ConfigPlugin = ( props ) => { let newConfig = config; - newConfig = appDelegate(config, props); - newConfig = infoPlist(config, props); + newConfig = appDelegate(newConfig, props); + newConfig = infoPlist(newConfig, props); + newConfig = withAndroidPushNotifications(newConfig, props); return newConfig; };