Skip to content

Commit 2a4d229

Browse files
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>
1 parent a0b5005 commit 2a4d229

4 files changed

Lines changed: 304 additions & 2 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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('generates valid FirebaseMessagingService subclass', () => {
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+
'class IntercomFirebaseMessagingService : FirebaseMessagingService()'
81+
);
82+
expect(content).toContain(
83+
'override fun onNewToken(refreshedToken: String)'
84+
);
85+
expect(content).toContain(
86+
'override fun onMessageReceived(remoteMessage: RemoteMessage)'
87+
);
88+
});
89+
90+
test('includes Intercom message routing logic', () => {
91+
const config = createMockConfig('com.example.myapp');
92+
withAndroidPushNotifications(config as any, {} as any);
93+
94+
const content = writeFileSyncSpy.mock.calls[0][1] as string;
95+
96+
expect(content).toContain(
97+
'IntercomModule.sendTokenToIntercom(application, refreshedToken)'
98+
);
99+
expect(content).toContain('IntercomModule.isIntercomPush(remoteMessage)');
100+
expect(content).toContain(
101+
'IntercomModule.handleRemotePushMessage(application, remoteMessage)'
102+
);
103+
expect(content).toContain('super.onMessageReceived(remoteMessage)');
104+
expect(content).toContain('super.onNewToken(refreshedToken)');
105+
});
106+
107+
test('includes all required Kotlin imports', () => {
108+
const config = createMockConfig('com.example.myapp');
109+
withAndroidPushNotifications(config as any, {} as any);
110+
111+
const content = writeFileSyncSpy.mock.calls[0][1] as string;
112+
113+
expect(content).toContain(
114+
'import com.google.firebase.messaging.FirebaseMessagingService'
115+
);
116+
expect(content).toContain(
117+
'import com.google.firebase.messaging.RemoteMessage'
118+
);
119+
expect(content).toContain(
120+
'import com.intercom.reactnative.IntercomModule'
121+
);
122+
});
123+
124+
test('writes file to correct directory based on package name', () => {
125+
const config = createMockConfig('io.intercom.example');
126+
withAndroidPushNotifications(config as any, {} as any);
127+
128+
const expectedDir = path.join(
129+
'/mock/project',
130+
'android',
131+
'app',
132+
'src',
133+
'main',
134+
'java',
135+
'io',
136+
'intercom',
137+
'example'
138+
);
139+
140+
expect(mkdirSyncSpy).toHaveBeenCalledWith(expectedDir, {
141+
recursive: true,
142+
});
143+
expect(writeFileSyncSpy).toHaveBeenCalledWith(
144+
path.join(expectedDir, 'IntercomFirebaseMessagingService.kt'),
145+
expect.any(String),
146+
'utf-8'
147+
);
148+
});
149+
});
150+
151+
describe('Gradle dependency', () => {
152+
test('adds firebase-messaging with version from native module', () => {
153+
const config = createMockConfig('com.example.myapp');
154+
withAndroidPushNotifications(config as any, {} as any);
155+
156+
const gradleWriteCall = writeFileSyncSpy.mock.calls.find((call: any[]) =>
157+
(call[0] as string).includes('build.gradle')
158+
);
159+
expect(gradleWriteCall).toBeDefined();
160+
expect(gradleWriteCall[1]).toContain('firebase-messaging:24.1.2');
161+
});
162+
163+
test('skips adding firebase-messaging when already present', () => {
164+
readFileSyncSpy.mockImplementation((filePath: any) => {
165+
const p = String(filePath);
166+
if (p.includes(path.join('app', 'build.gradle'))) {
167+
return 'dependencies {\n implementation("com.google.firebase:firebase-messaging:23.0.0")\n}';
168+
}
169+
return fakeNativeBuildGradle;
170+
});
171+
const config = createMockConfig('com.example.myapp');
172+
withAndroidPushNotifications(config as any, {} as any);
173+
174+
const gradleWriteCall = writeFileSyncSpy.mock.calls.find((call: any[]) =>
175+
(call[0] as string).includes('build.gradle')
176+
);
177+
expect(gradleWriteCall).toBeUndefined();
178+
});
179+
});
180+
181+
describe('error handling', () => {
182+
test('throws if android.package is not defined', () => {
183+
const config = createMockConfig();
184+
185+
expect(() => {
186+
withAndroidPushNotifications(config as any, {} as any);
187+
}).toThrow('android.package must be defined');
188+
});
189+
});
190+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
/**
10+
* Generates the Kotlin source for the FirebaseMessagingService that
11+
* forwards FCM tokens and Intercom push messages to the Intercom SDK.
12+
*/
13+
function generateFirebaseServiceKotlin(packageName: string): string {
14+
return `package ${packageName}
15+
16+
import com.google.firebase.messaging.FirebaseMessagingService
17+
import com.google.firebase.messaging.RemoteMessage
18+
import com.intercom.reactnative.IntercomModule
19+
20+
class ${SERVICE_CLASS_NAME} : FirebaseMessagingService() {
21+
22+
override fun onNewToken(refreshedToken: String) {
23+
IntercomModule.sendTokenToIntercom(application, refreshedToken)
24+
super.onNewToken(refreshedToken)
25+
}
26+
27+
override fun onMessageReceived(remoteMessage: RemoteMessage) {
28+
if (IntercomModule.isIntercomPush(remoteMessage)) {
29+
IntercomModule.handleRemotePushMessage(application, remoteMessage)
30+
} else {
31+
super.onMessageReceived(remoteMessage)
32+
}
33+
}
34+
}
35+
`;
36+
}
37+
38+
/**
39+
* Uses withDangerousMod to write the Kotlin FirebaseMessagingService file
40+
* into the app's Android source directory, and ensures firebase-messaging
41+
* is on the app module's compile classpath.
42+
*/
43+
export const withAndroidPushNotifications: ConfigPlugin<IntercomPluginProps> = (
44+
_config
45+
) =>
46+
withDangerousMod(_config, [
47+
'android',
48+
(config) => {
49+
const packageName = config.android?.package;
50+
if (!packageName) {
51+
throw new Error(
52+
'@intercom/intercom-react-native: android.package must be defined in your Expo config to use Android push notifications.'
53+
);
54+
}
55+
56+
const projectRoot = config.modRequest.projectRoot;
57+
const packagePath = packageName.replace(/\./g, '/');
58+
const serviceDir = path.join(
59+
projectRoot,
60+
'android',
61+
'app',
62+
'src',
63+
'main',
64+
'java',
65+
packagePath
66+
);
67+
68+
fs.mkdirSync(serviceDir, { recursive: true });
69+
fs.writeFileSync(
70+
path.join(serviceDir, `${SERVICE_CLASS_NAME}.kt`),
71+
generateFirebaseServiceKotlin(packageName),
72+
'utf-8'
73+
);
74+
75+
// The native module declares firebase-messaging as an `implementation`
76+
// dependency, which keeps it private to the library. Since our generated
77+
// service lives in the app module, we need firebase-messaging on the
78+
// app's compile classpath too. We read the version from the native
79+
// module's build.gradle so it stays in sync automatically.
80+
const packageRoot = path.resolve(
81+
require.resolve('@intercom/intercom-react-native/package.json'),
82+
'..'
83+
);
84+
const nativeBuildGradle = fs.readFileSync(
85+
path.join(packageRoot, 'android', 'build.gradle'),
86+
'utf-8'
87+
);
88+
const versionMatch = nativeBuildGradle.match(
89+
/com\.google\.firebase:firebase-messaging:([\d.]+)/
90+
);
91+
const firebaseMessagingVersion = versionMatch
92+
? versionMatch[1]
93+
: '24.1.2';
94+
95+
const buildGradlePath = path.join(
96+
projectRoot,
97+
'android',
98+
'app',
99+
'build.gradle'
100+
);
101+
const buildGradle = fs.readFileSync(buildGradlePath, 'utf-8');
102+
if (!buildGradle.includes('firebase-messaging')) {
103+
const updatedBuildGradle = buildGradle.replace(
104+
/dependencies\s*\{/,
105+
`dependencies {\n implementation("com.google.firebase:firebase-messaging:${firebaseMessagingVersion}")`
106+
);
107+
fs.writeFileSync(buildGradlePath, updatedBuildGradle, 'utf-8');
108+
}
109+
110+
return config;
111+
},
112+
]);

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)