From 2a4d22947707685b7ab660f404bc945d1c5412e7 Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Fri, 6 Mar 2026 11:18:09 +0000 Subject: [PATCH 1/4] feat: generate Android FirebaseMessagingService for Expo push notifications Adds an Expo config plugin that generates a Kotlin FirebaseMessagingService at prebuild time. The service forwards FCM tokens and Intercom push messages to the Intercom SDK, and passes non-Intercom messages through to other handlers. Also conditionally adds the firebase-messaging gradle dependency to the app module when not already present. Co-Authored-By: Claude Opus 4.6 --- .../withAndroidPushNotifications.test.ts | 190 ++++++++++++++++++ .../withAndroidPushNotifications.ts | 112 +++++++++++ tsconfig.build.json | 2 +- tsconfig.json | 2 +- 4 files changed, 304 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..8f68a4b2 --- /dev/null +++ b/__tests__/withAndroidPushNotifications.test.ts @@ -0,0 +1,190 @@ +import path from 'path'; +import fs from 'fs'; + +jest.mock('@expo/config-plugins', () => ({ + withDangerousMod: (config: any, [_platform, callback]: [string, Function]) => + callback(config), +})); + +import { withAndroidPushNotifications } from '../src/expo-plugins/withAndroidPushNotifications'; + +function createMockConfig(packageName?: string) { + return { + name: 'TestApp', + slug: 'test-app', + android: packageName ? { package: packageName } : undefined, + modRequest: { + projectRoot: '/mock/project', + }, + }; +} + +describe('withAndroidPushNotifications', () => { + let mkdirSyncSpy: jest.SpyInstance; + let writeFileSyncSpy: jest.SpyInstance; + let readFileSyncSpy: jest.SpyInstance; + + 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 +} + +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') + .mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes(path.join('app', 'build.gradle'))) { + return fakeAppBuildGradle; + } + return fakeNativeBuildGradle; + }); + }); + + 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; + + expect(content).toContain( + 'IntercomModule.sendTokenToIntercom(application, refreshedToken)' + ); + expect(content).toContain('IntercomModule.isIntercomPush(remoteMessage)'); + expect(content).toContain( + 'IntercomModule.handleRemotePushMessage(application, remoteMessage)' + ); + expect(content).toContain('super.onMessageReceived(remoteMessage)'); + 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), + 'utf-8' + ); + }); + }); + + describe('Gradle dependency', () => { + test('adds firebase-messaging with version from native module', () => { + 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:24.1.2'); + }); + + test('skips adding firebase-messaging when already present', () => { + 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); + + const gradleWriteCall = writeFileSyncSpy.mock.calls.find((call: any[]) => + (call[0] as string).includes('build.gradle') + ); + expect(gradleWriteCall).toBeUndefined(); + }); + }); + + describe('error handling', () => { + test('throws if android.package is not defined', () => { + const config = createMockConfig(); + + 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..25e38667 --- /dev/null +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -0,0 +1,112 @@ +import path from 'path'; +import fs from 'fs'; + +import { type ConfigPlugin, withDangerousMod } 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, and ensures firebase-messaging + * is on the app module's compile classpath. + */ +export const withAndroidPushNotifications: 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), + '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. 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(packageRoot, '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', + '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:${firebaseMessagingVersion}")` + ); + fs.writeFileSync(buildGradlePath, updatedBuildGradle, 'utf-8'); + } + + return config; + }, + ]); 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__"] } 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 c338c643a12616e3d5b6b33f9c32ef82b9069ea0 Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Mon, 9 Mar 2026 14:55:03 +0000 Subject: [PATCH 2/4] feat: extend ExpoFirebaseMessagingService when expo-notifications is installed Detects expo-notifications at prebuild time and generates a service that extends ExpoFirebaseMessagingService instead of the base class, ensuring super.onMessageReceived() chains through to Expo's handler so both Intercom and Expo notifications work at runtime. Co-Authored-By: Claude Opus 4.6 --- .../withAndroidPushNotifications.test.ts | 103 ++++++++++++------ .../withAndroidPushNotifications.ts | 21 +++- 2 files changed, 88 insertions(+), 36 deletions(-) diff --git a/__tests__/withAndroidPushNotifications.test.ts b/__tests__/withAndroidPushNotifications.test.ts index 8f68a4b2..4cb7b786 100644 --- a/__tests__/withAndroidPushNotifications.test.ts +++ b/__tests__/withAndroidPushNotifications.test.ts @@ -70,23 +70,6 @@ dependencies { 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); @@ -104,23 +87,6 @@ dependencies { 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); @@ -178,6 +144,75 @@ dependencies { }); }); + describe('expo-notifications compatibility', () => { + test('extends ExpoFirebaseMessagingService when expo-notifications is installed', () => { + jest.resetModules(); + jest.mock('expo-notifications', () => ({}), { virtual: true }); + jest.mock('@expo/config-plugins', () => ({ + withDangerousMod: (config: any, [_platform, callback]: [string, Function]) => + callback(config), + })); + + jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); + const localWriteSpy = jest.spyOn(fs, 'writeFileSync').mockReturnValue(undefined); + jest.spyOn(fs, 'readFileSync').mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes(path.join('app', 'build.gradle'))) { + return fakeAppBuildGradle; + } + return fakeNativeBuildGradle; + }); + + const { withAndroidPushNotifications: freshPlugin } = require('../src/expo-plugins/withAndroidPushNotifications'); + + const config = createMockConfig('com.example.myapp'); + freshPlugin(config as any, {} as any); + + const content = localWriteSpy.mock.calls[0]?.[1] as string; + expect(content).toContain( + 'class IntercomFirebaseMessagingService : ExpoFirebaseMessagingService()' + ); + expect(content).toContain( + 'import expo.modules.notifications.service.ExpoFirebaseMessagingService' + ); + expect(content).not.toContain( + 'import com.google.firebase.messaging.FirebaseMessagingService' + ); + }); + + test('extends FirebaseMessagingService when expo-notifications is not installed', () => { + jest.unmock('expo-notifications'); + jest.resetModules(); + jest.mock('@expo/config-plugins', () => ({ + withDangerousMod: (config: any, [_platform, callback]: [string, Function]) => + callback(config), + })); + + jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); + const localWriteSpy = jest.spyOn(fs, 'writeFileSync').mockReturnValue(undefined); + jest.spyOn(fs, 'readFileSync').mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes(path.join('app', 'build.gradle'))) { + return fakeAppBuildGradle; + } + return fakeNativeBuildGradle; + }); + + const { withAndroidPushNotifications: freshPlugin } = require('../src/expo-plugins/withAndroidPushNotifications'); + + const config = createMockConfig('com.example.myapp'); + freshPlugin(config as any, {} as any); + + const content = localWriteSpy.mock.calls[0][1] as string; + expect(content).toContain( + 'class IntercomFirebaseMessagingService : FirebaseMessagingService()' + ); + expect(content).toContain( + 'import com.google.firebase.messaging.FirebaseMessagingService' + ); + }); + }); + 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..2a8b4fda 100644 --- a/src/expo-plugins/withAndroidPushNotifications.ts +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -6,18 +6,35 @@ import type { IntercomPluginProps } from './@types'; const SERVICE_CLASS_NAME = 'IntercomFirebaseMessagingService'; +function hasExpoNotifications(): boolean { + try { + require('expo-notifications'); + return true; + } catch { + return false; + } +} + /** * Generates the Kotlin source for the FirebaseMessagingService that * forwards FCM tokens and Intercom push messages to the Intercom SDK. */ function generateFirebaseServiceKotlin(packageName: string): string { + const extendsExpo = hasExpoNotifications(); + const baseClass = extendsExpo + ? 'ExpoFirebaseMessagingService' + : 'FirebaseMessagingService'; + const baseImport = extendsExpo + ? 'import expo.modules.notifications.service.ExpoFirebaseMessagingService' + : 'import com.google.firebase.messaging.FirebaseMessagingService'; + return `package ${packageName} -import com.google.firebase.messaging.FirebaseMessagingService +${baseImport} import com.google.firebase.messaging.RemoteMessage import com.intercom.reactnative.IntercomModule -class ${SERVICE_CLASS_NAME} : FirebaseMessagingService() { +class ${SERVICE_CLASS_NAME} : ${baseClass}() { override fun onNewToken(refreshedToken: String) { IntercomModule.sendTokenToIntercom(application, refreshedToken) From 05a2574f9f2cd2227612c6e39b23e76ba79f648a Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Mon, 9 Mar 2026 15:29:40 +0000 Subject: [PATCH 3/4] fix: resolve package root via __dirname and handle non-resolution errors Two bugs found during manual testing: 1. `require.resolve('@intercom/intercom-react-native/package.json')` fails when node_modules is reshuffled (e.g. after installing expo-notifications). Use `path.resolve(__dirname, '..', '..', '..')` instead since compiled JS always runs from `lib/commonjs/expo-plugins/`. 2. `hasExpoNotifications()` catch-all returned false for non-MODULE_NOT_FOUND errors (e.g. TS stripping errors in local dev), incorrectly treating an installed module as absent. Now checks `e?.code !== 'MODULE_NOT_FOUND'`. Co-Authored-By: Claude Opus 4.6 --- src/expo-plugins/withAndroidPushNotifications.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/expo-plugins/withAndroidPushNotifications.ts b/src/expo-plugins/withAndroidPushNotifications.ts index 2a8b4fda..fa89d5a0 100644 --- a/src/expo-plugins/withAndroidPushNotifications.ts +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -10,8 +10,8 @@ function hasExpoNotifications(): boolean { try { require('expo-notifications'); return true; - } catch { - return false; + } catch (e: any) { + return e?.code !== 'MODULE_NOT_FOUND'; } } @@ -94,10 +94,7 @@ export const withAndroidPushNotifications: ConfigPlugin = ( // 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 packageRoot = path.resolve(__dirname, '..', '..', '..'); const nativeBuildGradle = fs.readFileSync( path.join(packageRoot, 'android', 'build.gradle'), 'utf-8' From a7a8403439444e2c98e1e205880ec21e725f4457 Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Mon, 9 Mar 2026 17:43:55 +0000 Subject: [PATCH 4/4] style: fix Prettier formatting in push notification tests Co-Authored-By: Claude Opus 4.6 --- .../withAndroidPushNotifications.test.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/__tests__/withAndroidPushNotifications.test.ts b/__tests__/withAndroidPushNotifications.test.ts index 4cb7b786..a5d113ff 100644 --- a/__tests__/withAndroidPushNotifications.test.ts +++ b/__tests__/withAndroidPushNotifications.test.ts @@ -149,12 +149,16 @@ dependencies { jest.resetModules(); jest.mock('expo-notifications', () => ({}), { virtual: true }); jest.mock('@expo/config-plugins', () => ({ - withDangerousMod: (config: any, [_platform, callback]: [string, Function]) => - callback(config), + withDangerousMod: ( + config: any, + [_platform, callback]: [string, Function] + ) => callback(config), })); jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); - const localWriteSpy = jest.spyOn(fs, 'writeFileSync').mockReturnValue(undefined); + const localWriteSpy = jest + .spyOn(fs, 'writeFileSync') + .mockReturnValue(undefined); jest.spyOn(fs, 'readFileSync').mockImplementation((filePath: any) => { const p = String(filePath); if (p.includes(path.join('app', 'build.gradle'))) { @@ -163,7 +167,9 @@ dependencies { return fakeNativeBuildGradle; }); - const { withAndroidPushNotifications: freshPlugin } = require('../src/expo-plugins/withAndroidPushNotifications'); + const { + withAndroidPushNotifications: freshPlugin, + } = require('../src/expo-plugins/withAndroidPushNotifications'); const config = createMockConfig('com.example.myapp'); freshPlugin(config as any, {} as any); @@ -184,12 +190,16 @@ dependencies { jest.unmock('expo-notifications'); jest.resetModules(); jest.mock('@expo/config-plugins', () => ({ - withDangerousMod: (config: any, [_platform, callback]: [string, Function]) => - callback(config), + withDangerousMod: ( + config: any, + [_platform, callback]: [string, Function] + ) => callback(config), })); jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); - const localWriteSpy = jest.spyOn(fs, 'writeFileSync').mockReturnValue(undefined); + const localWriteSpy = jest + .spyOn(fs, 'writeFileSync') + .mockReturnValue(undefined); jest.spyOn(fs, 'readFileSync').mockImplementation((filePath: any) => { const p = String(filePath); if (p.includes(path.join('app', 'build.gradle'))) { @@ -198,7 +208,9 @@ dependencies { return fakeNativeBuildGradle; }); - const { withAndroidPushNotifications: freshPlugin } = require('../src/expo-plugins/withAndroidPushNotifications'); + const { + withAndroidPushNotifications: freshPlugin, + } = require('../src/expo-plugins/withAndroidPushNotifications'); const config = createMockConfig('com.example.myapp'); freshPlugin(config as any, {} as any);