From 2a4d22947707685b7ab660f404bc945d1c5412e7 Mon Sep 17 00:00:00 2001 From: Rosie-Kennelly-1 Date: Fri, 6 Mar 2026 11:18:09 +0000 Subject: [PATCH] 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__"] }