Skip to content

Commit b4c8fac

Browse files
feat: generate Android FirebaseMessagingService for Expo push notifications (#385)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * style: fix Prettier formatting in push notification tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c0acede commit b4c8fac

4 files changed

Lines changed: 365 additions & 2 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import path from 'path';
2+
import fs from 'fs';
3+
4+
jest.mock('@expo/config-plugins', () => ({
5+
withDangerousMod: (config: any, [_platform, callback]: [string, Function]) =>
6+
callback(config),
7+
}));
8+
9+
import { withAndroidPushNotifications } from '../src/expo-plugins/withAndroidPushNotifications';
10+
11+
function createMockConfig(packageName?: string) {
12+
return {
13+
name: 'TestApp',
14+
slug: 'test-app',
15+
android: packageName ? { package: packageName } : undefined,
16+
modRequest: {
17+
projectRoot: '/mock/project',
18+
},
19+
};
20+
}
21+
22+
describe('withAndroidPushNotifications', () => {
23+
let mkdirSyncSpy: jest.SpyInstance;
24+
let writeFileSyncSpy: jest.SpyInstance;
25+
let readFileSyncSpy: jest.SpyInstance;
26+
27+
const fakeNativeBuildGradle = `
28+
dependencies {
29+
implementation "com.google.firebase:firebase-messaging:24.1.2"
30+
implementation 'io.intercom.android:intercom-sdk:17.4.5'
31+
}
32+
`;
33+
34+
const fakeAppBuildGradle = `
35+
android {
36+
compileSdkVersion 34
37+
}
38+
39+
dependencies {
40+
implementation("com.facebook.react:react-native:+")
41+
}
42+
`;
43+
44+
beforeEach(() => {
45+
mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
46+
writeFileSyncSpy = jest
47+
.spyOn(fs, 'writeFileSync')
48+
.mockReturnValue(undefined);
49+
readFileSyncSpy = jest
50+
.spyOn(fs, 'readFileSync')
51+
.mockImplementation((filePath: any) => {
52+
const p = String(filePath);
53+
if (p.includes(path.join('app', 'build.gradle'))) {
54+
return fakeAppBuildGradle;
55+
}
56+
return fakeNativeBuildGradle;
57+
});
58+
});
59+
60+
afterEach(() => {
61+
jest.restoreAllMocks();
62+
});
63+
64+
describe('Kotlin service file generation', () => {
65+
test('writes file with correct package name', () => {
66+
const config = createMockConfig('com.example.myapp');
67+
withAndroidPushNotifications(config as any, {} as any);
68+
69+
const content = writeFileSyncSpy.mock.calls[0][1] as string;
70+
expect(content).toContain('package com.example.myapp');
71+
});
72+
73+
test('includes Intercom message routing logic', () => {
74+
const config = createMockConfig('com.example.myapp');
75+
withAndroidPushNotifications(config as any, {} as any);
76+
77+
const content = writeFileSyncSpy.mock.calls[0][1] as string;
78+
79+
expect(content).toContain(
80+
'IntercomModule.sendTokenToIntercom(application, refreshedToken)'
81+
);
82+
expect(content).toContain('IntercomModule.isIntercomPush(remoteMessage)');
83+
expect(content).toContain(
84+
'IntercomModule.handleRemotePushMessage(application, remoteMessage)'
85+
);
86+
expect(content).toContain('super.onMessageReceived(remoteMessage)');
87+
expect(content).toContain('super.onNewToken(refreshedToken)');
88+
});
89+
90+
test('writes file to correct directory based on package name', () => {
91+
const config = createMockConfig('io.intercom.example');
92+
withAndroidPushNotifications(config as any, {} as any);
93+
94+
const expectedDir = path.join(
95+
'/mock/project',
96+
'android',
97+
'app',
98+
'src',
99+
'main',
100+
'java',
101+
'io',
102+
'intercom',
103+
'example'
104+
);
105+
106+
expect(mkdirSyncSpy).toHaveBeenCalledWith(expectedDir, {
107+
recursive: true,
108+
});
109+
expect(writeFileSyncSpy).toHaveBeenCalledWith(
110+
path.join(expectedDir, 'IntercomFirebaseMessagingService.kt'),
111+
expect.any(String),
112+
'utf-8'
113+
);
114+
});
115+
});
116+
117+
describe('Gradle dependency', () => {
118+
test('adds firebase-messaging with version from native module', () => {
119+
const config = createMockConfig('com.example.myapp');
120+
withAndroidPushNotifications(config as any, {} as any);
121+
122+
const gradleWriteCall = writeFileSyncSpy.mock.calls.find((call: any[]) =>
123+
(call[0] as string).includes('build.gradle')
124+
);
125+
expect(gradleWriteCall).toBeDefined();
126+
expect(gradleWriteCall[1]).toContain('firebase-messaging:24.1.2');
127+
});
128+
129+
test('skips adding firebase-messaging when already present', () => {
130+
readFileSyncSpy.mockImplementation((filePath: any) => {
131+
const p = String(filePath);
132+
if (p.includes(path.join('app', 'build.gradle'))) {
133+
return 'dependencies {\n implementation("com.google.firebase:firebase-messaging:23.0.0")\n}';
134+
}
135+
return fakeNativeBuildGradle;
136+
});
137+
const config = createMockConfig('com.example.myapp');
138+
withAndroidPushNotifications(config as any, {} as any);
139+
140+
const gradleWriteCall = writeFileSyncSpy.mock.calls.find((call: any[]) =>
141+
(call[0] as string).includes('build.gradle')
142+
);
143+
expect(gradleWriteCall).toBeUndefined();
144+
});
145+
});
146+
147+
describe('expo-notifications compatibility', () => {
148+
test('extends ExpoFirebaseMessagingService when expo-notifications is installed', () => {
149+
jest.resetModules();
150+
jest.mock('expo-notifications', () => ({}), { virtual: true });
151+
jest.mock('@expo/config-plugins', () => ({
152+
withDangerousMod: (
153+
config: any,
154+
[_platform, callback]: [string, Function]
155+
) => callback(config),
156+
}));
157+
158+
jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
159+
const localWriteSpy = jest
160+
.spyOn(fs, 'writeFileSync')
161+
.mockReturnValue(undefined);
162+
jest.spyOn(fs, 'readFileSync').mockImplementation((filePath: any) => {
163+
const p = String(filePath);
164+
if (p.includes(path.join('app', 'build.gradle'))) {
165+
return fakeAppBuildGradle;
166+
}
167+
return fakeNativeBuildGradle;
168+
});
169+
170+
const {
171+
withAndroidPushNotifications: freshPlugin,
172+
} = require('../src/expo-plugins/withAndroidPushNotifications');
173+
174+
const config = createMockConfig('com.example.myapp');
175+
freshPlugin(config as any, {} as any);
176+
177+
const content = localWriteSpy.mock.calls[0]?.[1] as string;
178+
expect(content).toContain(
179+
'class IntercomFirebaseMessagingService : ExpoFirebaseMessagingService()'
180+
);
181+
expect(content).toContain(
182+
'import expo.modules.notifications.service.ExpoFirebaseMessagingService'
183+
);
184+
expect(content).not.toContain(
185+
'import com.google.firebase.messaging.FirebaseMessagingService'
186+
);
187+
});
188+
189+
test('extends FirebaseMessagingService when expo-notifications is not installed', () => {
190+
jest.unmock('expo-notifications');
191+
jest.resetModules();
192+
jest.mock('@expo/config-plugins', () => ({
193+
withDangerousMod: (
194+
config: any,
195+
[_platform, callback]: [string, Function]
196+
) => callback(config),
197+
}));
198+
199+
jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
200+
const localWriteSpy = jest
201+
.spyOn(fs, 'writeFileSync')
202+
.mockReturnValue(undefined);
203+
jest.spyOn(fs, 'readFileSync').mockImplementation((filePath: any) => {
204+
const p = String(filePath);
205+
if (p.includes(path.join('app', 'build.gradle'))) {
206+
return fakeAppBuildGradle;
207+
}
208+
return fakeNativeBuildGradle;
209+
});
210+
211+
const {
212+
withAndroidPushNotifications: freshPlugin,
213+
} = require('../src/expo-plugins/withAndroidPushNotifications');
214+
215+
const config = createMockConfig('com.example.myapp');
216+
freshPlugin(config as any, {} as any);
217+
218+
const content = localWriteSpy.mock.calls[0][1] as string;
219+
expect(content).toContain(
220+
'class IntercomFirebaseMessagingService : FirebaseMessagingService()'
221+
);
222+
expect(content).toContain(
223+
'import com.google.firebase.messaging.FirebaseMessagingService'
224+
);
225+
});
226+
});
227+
228+
describe('error handling', () => {
229+
test('throws if android.package is not defined', () => {
230+
const config = createMockConfig();
231+
232+
expect(() => {
233+
withAndroidPushNotifications(config as any, {} as any);
234+
}).toThrow('android.package must be defined');
235+
});
236+
});
237+
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import path from 'path';
2+
import fs from 'fs';
3+
4+
import { type ConfigPlugin, withDangerousMod } from '@expo/config-plugins';
5+
import type { IntercomPluginProps } from './@types';
6+
7+
const SERVICE_CLASS_NAME = 'IntercomFirebaseMessagingService';
8+
9+
function hasExpoNotifications(): boolean {
10+
try {
11+
require('expo-notifications');
12+
return true;
13+
} catch (e: any) {
14+
return e?.code !== 'MODULE_NOT_FOUND';
15+
}
16+
}
17+
18+
/**
19+
* Generates the Kotlin source for the FirebaseMessagingService that
20+
* forwards FCM tokens and Intercom push messages to the Intercom SDK.
21+
*/
22+
function generateFirebaseServiceKotlin(packageName: string): string {
23+
const extendsExpo = hasExpoNotifications();
24+
const baseClass = extendsExpo
25+
? 'ExpoFirebaseMessagingService'
26+
: 'FirebaseMessagingService';
27+
const baseImport = extendsExpo
28+
? 'import expo.modules.notifications.service.ExpoFirebaseMessagingService'
29+
: 'import com.google.firebase.messaging.FirebaseMessagingService';
30+
31+
return `package ${packageName}
32+
33+
${baseImport}
34+
import com.google.firebase.messaging.RemoteMessage
35+
import com.intercom.reactnative.IntercomModule
36+
37+
class ${SERVICE_CLASS_NAME} : ${baseClass}() {
38+
39+
override fun onNewToken(refreshedToken: String) {
40+
IntercomModule.sendTokenToIntercom(application, refreshedToken)
41+
super.onNewToken(refreshedToken)
42+
}
43+
44+
override fun onMessageReceived(remoteMessage: RemoteMessage) {
45+
if (IntercomModule.isIntercomPush(remoteMessage)) {
46+
IntercomModule.handleRemotePushMessage(application, remoteMessage)
47+
} else {
48+
super.onMessageReceived(remoteMessage)
49+
}
50+
}
51+
}
52+
`;
53+
}
54+
55+
/**
56+
* Uses withDangerousMod to write the Kotlin FirebaseMessagingService file
57+
* into the app's Android source directory, and ensures firebase-messaging
58+
* is on the app module's compile classpath.
59+
*/
60+
export const withAndroidPushNotifications: ConfigPlugin<IntercomPluginProps> = (
61+
_config
62+
) =>
63+
withDangerousMod(_config, [
64+
'android',
65+
(config) => {
66+
const packageName = config.android?.package;
67+
if (!packageName) {
68+
throw new Error(
69+
'@intercom/intercom-react-native: android.package must be defined in your Expo config to use Android push notifications.'
70+
);
71+
}
72+
73+
const projectRoot = config.modRequest.projectRoot;
74+
const packagePath = packageName.replace(/\./g, '/');
75+
const serviceDir = path.join(
76+
projectRoot,
77+
'android',
78+
'app',
79+
'src',
80+
'main',
81+
'java',
82+
packagePath
83+
);
84+
85+
fs.mkdirSync(serviceDir, { recursive: true });
86+
fs.writeFileSync(
87+
path.join(serviceDir, `${SERVICE_CLASS_NAME}.kt`),
88+
generateFirebaseServiceKotlin(packageName),
89+
'utf-8'
90+
);
91+
92+
// The native module declares firebase-messaging as an `implementation`
93+
// dependency, which keeps it private to the library. Since our generated
94+
// service lives in the app module, we need firebase-messaging on the
95+
// app's compile classpath too. We read the version from the native
96+
// module's build.gradle so it stays in sync automatically.
97+
const packageRoot = path.resolve(__dirname, '..', '..', '..');
98+
const nativeBuildGradle = fs.readFileSync(
99+
path.join(packageRoot, 'android', 'build.gradle'),
100+
'utf-8'
101+
);
102+
const versionMatch = nativeBuildGradle.match(
103+
/com\.google\.firebase:firebase-messaging:([\d.]+)/
104+
);
105+
const firebaseMessagingVersion = versionMatch
106+
? versionMatch[1]
107+
: '24.1.2';
108+
109+
const buildGradlePath = path.join(
110+
projectRoot,
111+
'android',
112+
'app',
113+
'build.gradle'
114+
);
115+
const buildGradle = fs.readFileSync(buildGradlePath, 'utf-8');
116+
if (!buildGradle.includes('firebase-messaging')) {
117+
const updatedBuildGradle = buildGradle.replace(
118+
/dependencies\s*\{/,
119+
`dependencies {\n implementation("com.google.firebase:firebase-messaging:${firebaseMessagingVersion}")`
120+
);
121+
fs.writeFileSync(buildGradlePath, updatedBuildGradle, 'utf-8');
122+
}
123+
124+
return config;
125+
},
126+
]);

tsconfig.build.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"extends": "./tsconfig",
3-
"exclude": ["examples/*"]
3+
"exclude": ["examples/*", "__tests__"]
44
}

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@
2525
"strict": true,
2626
"target": "esnext"
2727
},
28-
"exclude": ["examples"]
28+
"exclude": ["examples", "__tests__"]
2929
}

0 commit comments

Comments
 (0)