From c21ff8428d0333d9667e6388386acf9dc89d6f94 Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Thu, 5 Mar 2026 14:15:49 +0000 Subject: [PATCH 1/8] feat: add Android push notification support to Expo config plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Expo config plugin already automated iOS push notification setup (PR #191) but Android was left out, requiring developers to manually create a FirebaseMessagingService and edit native files — defeating the purpose of using Expo. This adds the Android counterpart: a config plugin that generates a Kotlin FirebaseMessagingService at prebuild time, registers it in the AndroidManifest, and routes Intercom pushes to the SDK while passing non-Intercom messages through to other handlers (e.g. expo-notifications). Co-Authored-By: Claude Opus 4.6 --- .../withAndroidPushNotifications.test.ts | 204 ++++++++++++++++++ .../withAndroidPushNotifications.ts | 140 ++++++++++++ src/expo-plugins/withPushNotifications.ts | 8 +- 3 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 __tests__/withAndroidPushNotifications.test.ts create mode 100644 src/expo-plugins/withAndroidPushNotifications.ts diff --git a/__tests__/withAndroidPushNotifications.test.ts b/__tests__/withAndroidPushNotifications.test.ts new file mode 100644 index 00000000..0067b012 --- /dev/null +++ b/__tests__/withAndroidPushNotifications.test.ts @@ -0,0 +1,204 @@ +import path from 'path'; +import fs from 'fs'; + +// Mock @expo/config-plugins so we don't need the full Expo runtime. +// withDangerousMod and withAndroidManifest just invoke their callbacks +// immediately with the config object, simulating what Expo does at prebuild. +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'; + +/** + * Helper to create a minimal Expo config object that the plugins expect. + * Mirrors the shape that Expo passes during prebuild. + */ +function createMockConfig(packageName?: string) { + return { + name: 'TestApp', + slug: 'test-app', + android: packageName ? { package: packageName } : undefined, + modRequest: { + projectRoot: '/mock/project', + }, + modResults: { + manifest: { + application: [ + { + $: { 'android:name': '.MainApplication' }, + activity: [], + service: [] as any[], + }, + ], + }, + }, + }; +} + +describe('withAndroidPushNotifications', () => { + let mkdirSyncSpy: jest.SpyInstance; + let writeFileSyncSpy: jest.SpyInstance; + + beforeEach(() => { + mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); + writeFileSyncSpy = jest + .spyOn(fs, 'writeFileSync') + .mockReturnValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Kotlin service file generation', () => { + test('writes file with correct package name', () => { + const config = createMockConfig('com.example.myapp'); + withAndroidPushNotifications(config as any, {} as any); + + const content = writeFileSyncSpy.mock.calls[0][1] as string; + expect(content).toContain('package com.example.myapp'); + }); + + test('generates valid FirebaseMessagingService subclass', () => { + const config = createMockConfig('com.example.myapp'); + withAndroidPushNotifications(config as any, {} as any); + + const content = writeFileSyncSpy.mock.calls[0][1] as string; + + expect(content).toContain( + 'class IntercomFirebaseMessagingService : FirebaseMessagingService()' + ); + expect(content).toContain( + 'override fun onNewToken(refreshedToken: String)' + ); + expect(content).toContain( + 'override fun onMessageReceived(remoteMessage: RemoteMessage)' + ); + }); + + test('includes Intercom message routing logic', () => { + const config = createMockConfig('com.example.myapp'); + withAndroidPushNotifications(config as any, {} as any); + + const content = writeFileSyncSpy.mock.calls[0][1] as string; + + // Token forwarding + expect(content).toContain( + 'IntercomModule.sendTokenToIntercom(application, refreshedToken)' + ); + // Message filtering + expect(content).toContain( + 'IntercomModule.isIntercomPush(remoteMessage)' + ); + // Intercom message handling + expect(content).toContain( + 'IntercomModule.handleRemotePushMessage(application, remoteMessage)' + ); + // Non-Intercom passthrough + expect(content).toContain('super.onMessageReceived(remoteMessage)'); + // Token passthrough + expect(content).toContain('super.onNewToken(refreshedToken)'); + }); + + test('includes all required Kotlin imports', () => { + const config = createMockConfig('com.example.myapp'); + withAndroidPushNotifications(config as any, {} as any); + + const content = writeFileSyncSpy.mock.calls[0][1] as string; + + expect(content).toContain( + 'import com.google.firebase.messaging.FirebaseMessagingService' + ); + expect(content).toContain( + 'import com.google.firebase.messaging.RemoteMessage' + ); + expect(content).toContain( + 'import com.intercom.reactnative.IntercomModule' + ); + }); + + test('writes file to correct directory based on package name', () => { + const config = createMockConfig('io.intercom.example'); + withAndroidPushNotifications(config as any, {} as any); + + const expectedDir = path.join( + '/mock/project', + 'android', + 'app', + 'src', + 'main', + 'java', + 'io', + 'intercom', + 'example' + ); + + expect(mkdirSyncSpy).toHaveBeenCalledWith(expectedDir, { + recursive: true, + }); + expect(writeFileSyncSpy).toHaveBeenCalledWith( + path.join(expectedDir, 'IntercomFirebaseMessagingService.kt'), + expect.any(String) + ); + }); + }); + + 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', () => { + 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' + ); + }); + + test('does not duplicate service on repeated runs (idempotency)', () => { + const config = createMockConfig('com.example.myapp'); + + // Run plugin twice on the same config + withAndroidPushNotifications(config as any, {} as any); + withAndroidPushNotifications(config as any, {} as any); + + const services = config.modResults.manifest.application[0].service; + expect(services).toHaveLength(1); + }); + }); + + describe('error handling', () => { + test('throws if android.package is not defined', () => { + const config = createMockConfig(); // no package name + + expect(() => { + withAndroidPushNotifications(config as any, {} as any); + }).toThrow('android.package must be defined'); + }); + }); +}); diff --git a/src/expo-plugins/withAndroidPushNotifications.ts b/src/expo-plugins/withAndroidPushNotifications.ts new file mode 100644 index 00000000..75a88b40 --- /dev/null +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -0,0 +1,140 @@ +import path from 'path'; +import fs from 'fs'; + +import { + type ConfigPlugin, + withDangerousMod, + withAndroidManifest, + AndroidConfig, +} from '@expo/config-plugins'; +import type { IntercomPluginProps } from './@types'; + +const SERVICE_CLASS_NAME = 'IntercomFirebaseMessagingService'; + +/** + * Generates the Kotlin source for the FirebaseMessagingService that + * forwards FCM tokens and Intercom push messages to the Intercom SDK. + */ +function generateFirebaseServiceKotlin(packageName: string): string { + return `package ${packageName} + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.intercom.reactnative.IntercomModule + +class ${SERVICE_CLASS_NAME} : FirebaseMessagingService() { + + override fun onNewToken(refreshedToken: String) { + IntercomModule.sendTokenToIntercom(application, refreshedToken) + super.onNewToken(refreshedToken) + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + if (IntercomModule.isIntercomPush(remoteMessage)) { + IntercomModule.handleRemotePushMessage(application, remoteMessage) + } else { + super.onMessageReceived(remoteMessage) + } + } +} +`; +} + +/** + * Uses withDangerousMod to write the Kotlin FirebaseMessagingService file + * into the app's Android source directory. + */ +const writeFirebaseService: ConfigPlugin = (_config) => + withDangerousMod(_config, [ + 'android', + (config) => { + 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 projectRoot = config.modRequest.projectRoot; + const packagePath = packageName.replace(/\./g, '/'); + const serviceDir = path.join( + projectRoot, + 'android', + 'app', + 'src', + 'main', + 'java', + packagePath + ); + + fs.mkdirSync(serviceDir, { recursive: true }); + fs.writeFileSync( + path.join(serviceDir, `${SERVICE_CLASS_NAME}.kt`), + generateFirebaseServiceKotlin(packageName) + ); + + 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}`; + + // Check if the service is already registered (idempotency) + const existingService = mainApplication.service?.find( + (s) => s.$?.['android:name'] === serviceName + ); + + if (!existingService) { + if (!mainApplication.service) { + mainApplication.service = []; + } + + mainApplication.service.push({ + $: { + 'android:name': serviceName, + 'android:exported': 'false' as any, + }, + 'intent-filter': [ + { + 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..229aeb49 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,10 @@ export const withIntercomPushNotification: ConfigPlugin = ( props ) => { let newConfig = config; - newConfig = appDelegate(config, props); - newConfig = infoPlist(config, props); + // iOS push notification setup + newConfig = appDelegate(newConfig, props); + newConfig = infoPlist(newConfig, props); + // Android push notification setup + newConfig = withAndroidPushNotifications(newConfig, props); return newConfig; }; From b317da420c74a732e7e3cd3a68089dd8935af8ae Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Thu, 5 Mar 2026 14:29:52 +0000 Subject: [PATCH 2/8] fix: address PR review feedback - Add explicit android:priority="10" on intent filter - Add utf-8 encoding to writeFileSync for generated Kotlin source - Add test for preserving pre-existing services in AndroidManifest - Exclude __tests__ from tsconfig to match existing JS test convention - Remove unnecessary comments Co-Authored-By: Claude Opus 4.6 --- .../withAndroidPushNotifications.test.ts | 41 +++++++++++-------- .../withAndroidPushNotifications.ts | 7 +++- src/expo-plugins/withPushNotifications.ts | 2 - tsconfig.json | 2 +- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/__tests__/withAndroidPushNotifications.test.ts b/__tests__/withAndroidPushNotifications.test.ts index 0067b012..0b01e83d 100644 --- a/__tests__/withAndroidPushNotifications.test.ts +++ b/__tests__/withAndroidPushNotifications.test.ts @@ -1,9 +1,6 @@ import path from 'path'; import fs from 'fs'; -// Mock @expo/config-plugins so we don't need the full Expo runtime. -// withDangerousMod and withAndroidManifest just invoke their callbacks -// immediately with the config object, simulating what Expo does at prebuild. jest.mock('@expo/config-plugins', () => ({ withDangerousMod: (config: any, [_platform, callback]: [string, Function]) => callback(config), @@ -18,10 +15,6 @@ jest.mock('@expo/config-plugins', () => ({ import { withAndroidPushNotifications } from '../src/expo-plugins/withAndroidPushNotifications'; -/** - * Helper to create a minimal Expo config object that the plugins expect. - * Mirrors the shape that Expo passes during prebuild. - */ function createMockConfig(packageName?: string) { return { name: 'TestApp', @@ -91,21 +84,16 @@ describe('withAndroidPushNotifications', () => { const content = writeFileSyncSpy.mock.calls[0][1] as string; - // Token forwarding expect(content).toContain( 'IntercomModule.sendTokenToIntercom(application, refreshedToken)' ); - // Message filtering expect(content).toContain( 'IntercomModule.isIntercomPush(remoteMessage)' ); - // Intercom message handling expect(content).toContain( 'IntercomModule.handleRemotePushMessage(application, remoteMessage)' ); - // Non-Intercom passthrough expect(content).toContain('super.onMessageReceived(remoteMessage)'); - // Token passthrough expect(content).toContain('super.onNewToken(refreshedToken)'); }); @@ -147,7 +135,8 @@ describe('withAndroidPushNotifications', () => { }); expect(writeFileSyncSpy).toHaveBeenCalledWith( path.join(expectedDir, 'IntercomFirebaseMessagingService.kt'), - expect.any(String) + expect.any(String), + 'utf-8' ); }); }); @@ -167,7 +156,7 @@ describe('withAndroidPushNotifications', () => { expect(service.$['android:exported']).toBe('false'); }); - test('registers MESSAGING_EVENT intent filter', () => { + test('registers MESSAGING_EVENT intent filter with priority', () => { const config = createMockConfig('com.example.myapp'); withAndroidPushNotifications(config as any, {} as any); @@ -178,12 +167,32 @@ describe('withAndroidPushNotifications', () => { 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'); - // Run plugin twice on the same config withAndroidPushNotifications(config as any, {} as any); withAndroidPushNotifications(config as any, {} as any); @@ -194,7 +203,7 @@ describe('withAndroidPushNotifications', () => { describe('error handling', () => { test('throws if android.package is not defined', () => { - const config = createMockConfig(); // no package name + const config = createMockConfig(); expect(() => { withAndroidPushNotifications(config as any, {} as any); diff --git a/src/expo-plugins/withAndroidPushNotifications.ts b/src/expo-plugins/withAndroidPushNotifications.ts index 75a88b40..7a2cf7bd 100644 --- a/src/expo-plugins/withAndroidPushNotifications.ts +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -70,7 +70,8 @@ const writeFirebaseService: ConfigPlugin = (_config) => fs.mkdirSync(serviceDir, { recursive: true }); fs.writeFileSync( path.join(serviceDir, `${SERVICE_CLASS_NAME}.kt`), - generateFirebaseServiceKotlin(packageName) + generateFirebaseServiceKotlin(packageName), + 'utf-8' ); return config; @@ -97,7 +98,6 @@ const registerServiceInManifest: ConfigPlugin = ( const serviceName = `.${SERVICE_CLASS_NAME}`; - // Check if the service is already registered (idempotency) const existingService = mainApplication.service?.find( (s) => s.$?.['android:name'] === serviceName ); @@ -114,6 +114,9 @@ const registerServiceInManifest: ConfigPlugin = ( }, 'intent-filter': [ { + $: { + 'android:priority': '10', + } as any, action: [ { $: { diff --git a/src/expo-plugins/withPushNotifications.ts b/src/expo-plugins/withPushNotifications.ts index 229aeb49..6a0e912c 100644 --- a/src/expo-plugins/withPushNotifications.ts +++ b/src/expo-plugins/withPushNotifications.ts @@ -60,10 +60,8 @@ export const withIntercomPushNotification: ConfigPlugin = ( props ) => { let newConfig = config; - // iOS push notification setup newConfig = appDelegate(newConfig, props); newConfig = infoPlist(newConfig, props); - // Android push notification setup newConfig = withAndroidPushNotifications(newConfig, props); return newConfig; }; diff --git a/tsconfig.json b/tsconfig.json index 360afee7..6f8787df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,5 +25,5 @@ "strict": true, "target": "esnext" }, - "exclude": ["examples"] + "exclude": ["examples", "__tests__"] } From f534f55c117f7a46b60d8e61fc47b3056a770dfa Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Thu, 5 Mar 2026 14:42:34 +0000 Subject: [PATCH 3/8] feat: skip service registration when existing FCM service detected If a developer already has a FirebaseMessagingService registered (from manual setup or another SDK), adding a second one would cause unpredictable FCM routing. The plugin now detects this and skips registration with a warning explaining how to route Intercom pushes manually. Co-Authored-By: Claude Opus 4.6 --- .../withAndroidPushNotifications.test.ts | 34 +++++++++++++++++++ .../withAndroidPushNotifications.ts | 20 +++++++++++ 2 files changed, 54 insertions(+) diff --git a/__tests__/withAndroidPushNotifications.test.ts b/__tests__/withAndroidPushNotifications.test.ts index 0b01e83d..86aa3b03 100644 --- a/__tests__/withAndroidPushNotifications.test.ts +++ b/__tests__/withAndroidPushNotifications.test.ts @@ -199,6 +199,40 @@ describe('withAndroidPushNotifications', () => { 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', () => { diff --git a/src/expo-plugins/withAndroidPushNotifications.ts b/src/expo-plugins/withAndroidPushNotifications.ts index 7a2cf7bd..4efd62b8 100644 --- a/src/expo-plugins/withAndroidPushNotifications.ts +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -102,6 +102,26 @@ const registerServiceInManifest: ConfigPlugin = ( (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 = []; From d797b38f57dcfb41e4be5a0e02bc81ee99b8b8cd Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Thu, 5 Mar 2026 14:55:43 +0000 Subject: [PATCH 4/8] fix: resolve lint and build-package CI failures - Apply prettier formatting fixes - Exclude __tests__ from tsconfig.build.json so bob build doesn't try to generate type definitions for test files Co-Authored-By: Claude Opus 4.6 --- __tests__/withAndroidPushNotifications.test.ts | 6 ++---- .../withAndroidPushNotifications.ts | 18 ++++++++++-------- tsconfig.build.json | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/__tests__/withAndroidPushNotifications.test.ts b/__tests__/withAndroidPushNotifications.test.ts index 86aa3b03..0d7bbf36 100644 --- a/__tests__/withAndroidPushNotifications.test.ts +++ b/__tests__/withAndroidPushNotifications.test.ts @@ -87,9 +87,7 @@ describe('withAndroidPushNotifications', () => { expect(content).toContain( 'IntercomModule.sendTokenToIntercom(application, refreshedToken)' ); - expect(content).toContain( - 'IntercomModule.isIntercomPush(remoteMessage)' - ); + expect(content).toContain('IntercomModule.isIntercomPush(remoteMessage)'); expect(content).toContain( 'IntercomModule.handleRemotePushMessage(application, remoteMessage)' ); @@ -205,7 +203,7 @@ describe('withAndroidPushNotifications', () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); config.modResults.manifest.application[0].service.push({ - $: { + '$': { 'android:name': '.ExistingFcmService', 'android:exported': 'true', }, diff --git a/src/expo-plugins/withAndroidPushNotifications.ts b/src/expo-plugins/withAndroidPushNotifications.ts index 4efd62b8..4a187590 100644 --- a/src/expo-plugins/withAndroidPushNotifications.ts +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -86,8 +86,9 @@ const registerServiceInManifest: ConfigPlugin = ( _config ) => withAndroidManifest(_config, (config) => { - const mainApplication = - AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults); + const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow( + config.modResults + ); const packageName = config.android?.package; if (!packageName) { @@ -105,11 +106,12 @@ const registerServiceInManifest: ConfigPlugin = ( 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' - ) + s['intent-filter']?.some( + (f: any) => + f.action?.some( + (a: any) => + a.$?.['android:name'] === 'com.google.firebase.MESSAGING_EVENT' + ) ) ); @@ -128,7 +130,7 @@ const registerServiceInManifest: ConfigPlugin = ( } mainApplication.service.push({ - $: { + '$': { 'android:name': serviceName, 'android:exported': 'false' as any, }, diff --git a/tsconfig.build.json b/tsconfig.build.json index d13086d5..143b90ad 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig", - "exclude": ["examples/*"] + "exclude": ["examples/*", "__tests__"] } From 22b9d2d7fbdc3175673f4772f5c40ed082632c48 Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Fri, 6 Mar 2026 10:45:44 +0000 Subject: [PATCH 5/8] fix: add firebase-messaging gradle dependency to app module The native module declares firebase-messaging as an `implementation` dependency, which is private to the library module. Since the generated FirebaseMessagingService lives in the app module, it needs firebase-messaging on its own compile classpath. Conditionally adds the dependency to app/build.gradle if not already present. Co-Authored-By: Claude Opus 4.6 --- .../withAndroidPushNotifications.test.ts | 40 +++++++++++++++++++ .../withAndroidPushNotifications.ts | 19 +++++++++ 2 files changed, 59 insertions(+) diff --git a/__tests__/withAndroidPushNotifications.test.ts b/__tests__/withAndroidPushNotifications.test.ts index 0d7bbf36..f4bfd081 100644 --- a/__tests__/withAndroidPushNotifications.test.ts +++ b/__tests__/withAndroidPushNotifications.test.ts @@ -40,12 +40,26 @@ function createMockConfig(packageName?: string) { describe('withAndroidPushNotifications', () => { let mkdirSyncSpy: jest.SpyInstance; let writeFileSyncSpy: jest.SpyInstance; + let readFileSyncSpy: jest.SpyInstance; + + const fakeBuildGradle = ` +android { + compileSdkVersion 34 +} + +dependencies { + implementation("com.facebook.react:react-native:+") +} +`; beforeEach(() => { mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); writeFileSyncSpy = jest .spyOn(fs, 'writeFileSync') .mockReturnValue(undefined); + readFileSyncSpy = jest + .spyOn(fs, 'readFileSync') + .mockReturnValue(fakeBuildGradle); }); afterEach(() => { @@ -139,6 +153,32 @@ describe('withAndroidPushNotifications', () => { }); }); + describe('Gradle dependency', () => { + test('adds firebase-messaging when not present', () => { + const config = createMockConfig('com.example.myapp'); + withAndroidPushNotifications(config as any, {} as any); + + const gradleWriteCall = writeFileSyncSpy.mock.calls.find( + (call: any[]) => (call[0] as string).includes('build.gradle') + ); + expect(gradleWriteCall).toBeDefined(); + expect(gradleWriteCall[1]).toContain('firebase-messaging'); + }); + + test('skips adding firebase-messaging when already present', () => { + readFileSyncSpy.mockReturnValue( + 'dependencies {\n implementation("com.google.firebase:firebase-messaging:23.0.0")\n}' + ); + const config = createMockConfig('com.example.myapp'); + withAndroidPushNotifications(config as any, {} as any); + + const gradleWriteCall = writeFileSyncSpy.mock.calls.find( + (call: any[]) => (call[0] as string).includes('build.gradle') + ); + expect(gradleWriteCall).toBeUndefined(); + }); + }); + describe('AndroidManifest service registration', () => { test('adds service entry with correct attributes', () => { const config = createMockConfig('com.example.myapp'); diff --git a/src/expo-plugins/withAndroidPushNotifications.ts b/src/expo-plugins/withAndroidPushNotifications.ts index 4a187590..6e9909da 100644 --- a/src/expo-plugins/withAndroidPushNotifications.ts +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -74,6 +74,25 @@ const writeFirebaseService: ConfigPlugin = (_config) => 'utf-8' ); + // The native module declares firebase-messaging as an `implementation` + // dependency, which keeps it private to the library. Since our generated + // service lives in the app module, we need firebase-messaging on the + // app's compile classpath too. + const buildGradlePath = path.join( + projectRoot, + 'android', + 'app', + 'build.gradle' + ); + const buildGradle = fs.readFileSync(buildGradlePath, 'utf-8'); + if (!buildGradle.includes('firebase-messaging')) { + const updatedBuildGradle = buildGradle.replace( + /dependencies\s*\{/, + `dependencies {\n implementation("com.google.firebase:firebase-messaging:24.1.2")` + ); + fs.writeFileSync(buildGradlePath, updatedBuildGradle, 'utf-8'); + } + return config; }, ]); From 38dcf13b2b45388700a4918970828aa7523b459c Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Fri, 6 Mar 2026 11:01:30 +0000 Subject: [PATCH 6/8] fix: resolve prettier formatting in gradle dependency tests Co-Authored-By: Claude Opus 4.6 --- __tests__/withAndroidPushNotifications.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/__tests__/withAndroidPushNotifications.test.ts b/__tests__/withAndroidPushNotifications.test.ts index f4bfd081..603333e8 100644 --- a/__tests__/withAndroidPushNotifications.test.ts +++ b/__tests__/withAndroidPushNotifications.test.ts @@ -158,8 +158,8 @@ dependencies { const config = createMockConfig('com.example.myapp'); withAndroidPushNotifications(config as any, {} as any); - const gradleWriteCall = writeFileSyncSpy.mock.calls.find( - (call: any[]) => (call[0] as string).includes('build.gradle') + const gradleWriteCall = writeFileSyncSpy.mock.calls.find((call: any[]) => + (call[0] as string).includes('build.gradle') ); expect(gradleWriteCall).toBeDefined(); expect(gradleWriteCall[1]).toContain('firebase-messaging'); @@ -172,8 +172,8 @@ dependencies { const config = createMockConfig('com.example.myapp'); withAndroidPushNotifications(config as any, {} as any); - const gradleWriteCall = writeFileSyncSpy.mock.calls.find( - (call: any[]) => (call[0] as string).includes('build.gradle') + const gradleWriteCall = writeFileSyncSpy.mock.calls.find((call: any[]) => + (call[0] as string).includes('build.gradle') ); expect(gradleWriteCall).toBeUndefined(); }); From 5eb1409d025eff955b558f63bf84b19d062bbed0 Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Fri, 6 Mar 2026 11:10:49 +0000 Subject: [PATCH 7/8] refactor: read firebase-messaging version from native module at prebuild time Instead of hardcoding the firebase-messaging version, read it from the native module's android/build.gradle so the app dependency stays in sync automatically when the SDK is updated. Co-Authored-By: Claude Opus 4.6 --- .../withAndroidPushNotifications.test.ts | 31 ++++++++++++++----- .../withAndroidPushNotifications.ts | 16 ++++++++-- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/__tests__/withAndroidPushNotifications.test.ts b/__tests__/withAndroidPushNotifications.test.ts index 603333e8..a5ffbb3f 100644 --- a/__tests__/withAndroidPushNotifications.test.ts +++ b/__tests__/withAndroidPushNotifications.test.ts @@ -42,7 +42,14 @@ describe('withAndroidPushNotifications', () => { let writeFileSyncSpy: jest.SpyInstance; let readFileSyncSpy: jest.SpyInstance; - const fakeBuildGradle = ` + const fakeNativeBuildGradle = ` +dependencies { + implementation "com.google.firebase:firebase-messaging:24.1.2" + implementation 'io.intercom.android:intercom-sdk:17.4.5' +} +`; + + const fakeAppBuildGradle = ` android { compileSdkVersion 34 } @@ -59,7 +66,13 @@ dependencies { .mockReturnValue(undefined); readFileSyncSpy = jest .spyOn(fs, 'readFileSync') - .mockReturnValue(fakeBuildGradle); + .mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes(path.join('app', 'build.gradle'))) { + return fakeAppBuildGradle; + } + return fakeNativeBuildGradle; + }); }); afterEach(() => { @@ -154,7 +167,7 @@ dependencies { }); describe('Gradle dependency', () => { - test('adds firebase-messaging when not present', () => { + test('adds firebase-messaging with version from native module', () => { const config = createMockConfig('com.example.myapp'); withAndroidPushNotifications(config as any, {} as any); @@ -162,13 +175,17 @@ dependencies { (call[0] as string).includes('build.gradle') ); expect(gradleWriteCall).toBeDefined(); - expect(gradleWriteCall[1]).toContain('firebase-messaging'); + expect(gradleWriteCall[1]).toContain('firebase-messaging:24.1.2'); }); test('skips adding firebase-messaging when already present', () => { - readFileSyncSpy.mockReturnValue( - 'dependencies {\n implementation("com.google.firebase:firebase-messaging:23.0.0")\n}' - ); + readFileSyncSpy.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes(path.join('app', 'build.gradle'))) { + return 'dependencies {\n implementation("com.google.firebase:firebase-messaging:23.0.0")\n}'; + } + return fakeNativeBuildGradle; + }); const config = createMockConfig('com.example.myapp'); withAndroidPushNotifications(config as any, {} as any); diff --git a/src/expo-plugins/withAndroidPushNotifications.ts b/src/expo-plugins/withAndroidPushNotifications.ts index 6e9909da..595c8ae1 100644 --- a/src/expo-plugins/withAndroidPushNotifications.ts +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -77,7 +77,19 @@ const writeFirebaseService: ConfigPlugin = (_config) => // The native module declares firebase-messaging as an `implementation` // dependency, which keeps it private to the library. Since our generated // service lives in the app module, we need firebase-messaging on the - // app's compile classpath too. + // app's compile classpath too. We read the version from the native + // module's build.gradle so it stays in sync automatically. + const nativeBuildGradle = fs.readFileSync( + path.join(__dirname, '..', '..', 'android', 'build.gradle'), + 'utf-8' + ); + const versionMatch = nativeBuildGradle.match( + /com\.google\.firebase:firebase-messaging:([\d.]+)/ + ); + const firebaseMessagingVersion = versionMatch + ? versionMatch[1] + : '24.1.2'; + const buildGradlePath = path.join( projectRoot, 'android', @@ -88,7 +100,7 @@ const writeFirebaseService: ConfigPlugin = (_config) => if (!buildGradle.includes('firebase-messaging')) { const updatedBuildGradle = buildGradle.replace( /dependencies\s*\{/, - `dependencies {\n implementation("com.google.firebase:firebase-messaging:24.1.2")` + `dependencies {\n implementation("com.google.firebase:firebase-messaging:${firebaseMessagingVersion}")` ); fs.writeFileSync(buildGradlePath, updatedBuildGradle, 'utf-8'); } From 32ffacc478048406ec7c350c7452951115f85ab4 Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Fri, 6 Mar 2026 11:13:19 +0000 Subject: [PATCH 8/8] fix: use require.resolve for package root instead of __dirname __dirname resolves differently from src/ vs lib/commonjs/ after compilation. Using require.resolve to find the package.json ensures the native module's android/build.gradle is found correctly regardless of the build output directory structure. Co-Authored-By: Claude Opus 4.6 --- src/expo-plugins/withAndroidPushNotifications.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/expo-plugins/withAndroidPushNotifications.ts b/src/expo-plugins/withAndroidPushNotifications.ts index 595c8ae1..b781a593 100644 --- a/src/expo-plugins/withAndroidPushNotifications.ts +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -79,8 +79,12 @@ const writeFirebaseService: ConfigPlugin = (_config) => // service lives in the app module, we need firebase-messaging on the // app's compile classpath too. We read the version from the native // module's build.gradle so it stays in sync automatically. + const packageRoot = path.resolve( + require.resolve('@intercom/intercom-react-native/package.json'), + '..' + ); const nativeBuildGradle = fs.readFileSync( - path.join(__dirname, '..', '..', 'android', 'build.gradle'), + path.join(packageRoot, 'android', 'build.gradle'), 'utf-8' ); const versionMatch = nativeBuildGradle.match(