Skip to content

Commit c21ff84

Browse files
feat: add Android push notification support to Expo config plugin
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 <noreply@anthropic.com>
1 parent a0b5005 commit c21ff84

3 files changed

Lines changed: 350 additions & 2 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import path from 'path';
2+
import fs from 'fs';
3+
4+
// Mock @expo/config-plugins so we don't need the full Expo runtime.
5+
// withDangerousMod and withAndroidManifest just invoke their callbacks
6+
// immediately with the config object, simulating what Expo does at prebuild.
7+
jest.mock('@expo/config-plugins', () => ({
8+
withDangerousMod: (config: any, [_platform, callback]: [string, Function]) =>
9+
callback(config),
10+
withAndroidManifest: (config: any, callback: Function) => callback(config),
11+
AndroidConfig: {
12+
Manifest: {
13+
getMainApplicationOrThrow: (modResults: any) =>
14+
modResults.manifest.application[0],
15+
},
16+
},
17+
}));
18+
19+
import { withAndroidPushNotifications } from '../src/expo-plugins/withAndroidPushNotifications';
20+
21+
/**
22+
* Helper to create a minimal Expo config object that the plugins expect.
23+
* Mirrors the shape that Expo passes during prebuild.
24+
*/
25+
function createMockConfig(packageName?: string) {
26+
return {
27+
name: 'TestApp',
28+
slug: 'test-app',
29+
android: packageName ? { package: packageName } : undefined,
30+
modRequest: {
31+
projectRoot: '/mock/project',
32+
},
33+
modResults: {
34+
manifest: {
35+
application: [
36+
{
37+
$: { 'android:name': '.MainApplication' },
38+
activity: [],
39+
service: [] as any[],
40+
},
41+
],
42+
},
43+
},
44+
};
45+
}
46+
47+
describe('withAndroidPushNotifications', () => {
48+
let mkdirSyncSpy: jest.SpyInstance;
49+
let writeFileSyncSpy: jest.SpyInstance;
50+
51+
beforeEach(() => {
52+
mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
53+
writeFileSyncSpy = jest
54+
.spyOn(fs, 'writeFileSync')
55+
.mockReturnValue(undefined);
56+
});
57+
58+
afterEach(() => {
59+
jest.restoreAllMocks();
60+
});
61+
62+
describe('Kotlin service file generation', () => {
63+
test('writes file with correct package name', () => {
64+
const config = createMockConfig('com.example.myapp');
65+
withAndroidPushNotifications(config as any, {} as any);
66+
67+
const content = writeFileSyncSpy.mock.calls[0][1] as string;
68+
expect(content).toContain('package com.example.myapp');
69+
});
70+
71+
test('generates valid FirebaseMessagingService subclass', () => {
72+
const config = createMockConfig('com.example.myapp');
73+
withAndroidPushNotifications(config as any, {} as any);
74+
75+
const content = writeFileSyncSpy.mock.calls[0][1] as string;
76+
77+
expect(content).toContain(
78+
'class IntercomFirebaseMessagingService : FirebaseMessagingService()'
79+
);
80+
expect(content).toContain(
81+
'override fun onNewToken(refreshedToken: String)'
82+
);
83+
expect(content).toContain(
84+
'override fun onMessageReceived(remoteMessage: RemoteMessage)'
85+
);
86+
});
87+
88+
test('includes Intercom message routing logic', () => {
89+
const config = createMockConfig('com.example.myapp');
90+
withAndroidPushNotifications(config as any, {} as any);
91+
92+
const content = writeFileSyncSpy.mock.calls[0][1] as string;
93+
94+
// Token forwarding
95+
expect(content).toContain(
96+
'IntercomModule.sendTokenToIntercom(application, refreshedToken)'
97+
);
98+
// Message filtering
99+
expect(content).toContain(
100+
'IntercomModule.isIntercomPush(remoteMessage)'
101+
);
102+
// Intercom message handling
103+
expect(content).toContain(
104+
'IntercomModule.handleRemotePushMessage(application, remoteMessage)'
105+
);
106+
// Non-Intercom passthrough
107+
expect(content).toContain('super.onMessageReceived(remoteMessage)');
108+
// Token passthrough
109+
expect(content).toContain('super.onNewToken(refreshedToken)');
110+
});
111+
112+
test('includes all required Kotlin imports', () => {
113+
const config = createMockConfig('com.example.myapp');
114+
withAndroidPushNotifications(config as any, {} as any);
115+
116+
const content = writeFileSyncSpy.mock.calls[0][1] as string;
117+
118+
expect(content).toContain(
119+
'import com.google.firebase.messaging.FirebaseMessagingService'
120+
);
121+
expect(content).toContain(
122+
'import com.google.firebase.messaging.RemoteMessage'
123+
);
124+
expect(content).toContain(
125+
'import com.intercom.reactnative.IntercomModule'
126+
);
127+
});
128+
129+
test('writes file to correct directory based on package name', () => {
130+
const config = createMockConfig('io.intercom.example');
131+
withAndroidPushNotifications(config as any, {} as any);
132+
133+
const expectedDir = path.join(
134+
'/mock/project',
135+
'android',
136+
'app',
137+
'src',
138+
'main',
139+
'java',
140+
'io',
141+
'intercom',
142+
'example'
143+
);
144+
145+
expect(mkdirSyncSpy).toHaveBeenCalledWith(expectedDir, {
146+
recursive: true,
147+
});
148+
expect(writeFileSyncSpy).toHaveBeenCalledWith(
149+
path.join(expectedDir, 'IntercomFirebaseMessagingService.kt'),
150+
expect.any(String)
151+
);
152+
});
153+
});
154+
155+
describe('AndroidManifest service registration', () => {
156+
test('adds service entry with correct attributes', () => {
157+
const config = createMockConfig('com.example.myapp');
158+
withAndroidPushNotifications(config as any, {} as any);
159+
160+
const services = config.modResults.manifest.application[0].service;
161+
expect(services).toHaveLength(1);
162+
163+
const service = services[0];
164+
expect(service.$['android:name']).toBe(
165+
'.IntercomFirebaseMessagingService'
166+
);
167+
expect(service.$['android:exported']).toBe('false');
168+
});
169+
170+
test('registers MESSAGING_EVENT intent filter', () => {
171+
const config = createMockConfig('com.example.myapp');
172+
withAndroidPushNotifications(config as any, {} as any);
173+
174+
const service = config.modResults.manifest.application[0].service[0];
175+
const intentFilter = service['intent-filter'][0];
176+
const action = intentFilter.action[0];
177+
178+
expect(action.$['android:name']).toBe(
179+
'com.google.firebase.MESSAGING_EVENT'
180+
);
181+
});
182+
183+
test('does not duplicate service on repeated runs (idempotency)', () => {
184+
const config = createMockConfig('com.example.myapp');
185+
186+
// Run plugin twice on the same config
187+
withAndroidPushNotifications(config as any, {} as any);
188+
withAndroidPushNotifications(config as any, {} as any);
189+
190+
const services = config.modResults.manifest.application[0].service;
191+
expect(services).toHaveLength(1);
192+
});
193+
});
194+
195+
describe('error handling', () => {
196+
test('throws if android.package is not defined', () => {
197+
const config = createMockConfig(); // no package name
198+
199+
expect(() => {
200+
withAndroidPushNotifications(config as any, {} as any);
201+
}).toThrow('android.package must be defined');
202+
});
203+
});
204+
});
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import path from 'path';
2+
import fs from 'fs';
3+
4+
import {
5+
type ConfigPlugin,
6+
withDangerousMod,
7+
withAndroidManifest,
8+
AndroidConfig,
9+
} from '@expo/config-plugins';
10+
import type { IntercomPluginProps } from './@types';
11+
12+
const SERVICE_CLASS_NAME = 'IntercomFirebaseMessagingService';
13+
14+
/**
15+
* Generates the Kotlin source for the FirebaseMessagingService that
16+
* forwards FCM tokens and Intercom push messages to the Intercom SDK.
17+
*/
18+
function generateFirebaseServiceKotlin(packageName: string): string {
19+
return `package ${packageName}
20+
21+
import com.google.firebase.messaging.FirebaseMessagingService
22+
import com.google.firebase.messaging.RemoteMessage
23+
import com.intercom.reactnative.IntercomModule
24+
25+
class ${SERVICE_CLASS_NAME} : FirebaseMessagingService() {
26+
27+
override fun onNewToken(refreshedToken: String) {
28+
IntercomModule.sendTokenToIntercom(application, refreshedToken)
29+
super.onNewToken(refreshedToken)
30+
}
31+
32+
override fun onMessageReceived(remoteMessage: RemoteMessage) {
33+
if (IntercomModule.isIntercomPush(remoteMessage)) {
34+
IntercomModule.handleRemotePushMessage(application, remoteMessage)
35+
} else {
36+
super.onMessageReceived(remoteMessage)
37+
}
38+
}
39+
}
40+
`;
41+
}
42+
43+
/**
44+
* Uses withDangerousMod to write the Kotlin FirebaseMessagingService file
45+
* into the app's Android source directory.
46+
*/
47+
const writeFirebaseService: ConfigPlugin<IntercomPluginProps> = (_config) =>
48+
withDangerousMod(_config, [
49+
'android',
50+
(config) => {
51+
const packageName = config.android?.package;
52+
if (!packageName) {
53+
throw new Error(
54+
'@intercom/intercom-react-native: android.package must be defined in your Expo config to use Android push notifications.'
55+
);
56+
}
57+
58+
const projectRoot = config.modRequest.projectRoot;
59+
const packagePath = packageName.replace(/\./g, '/');
60+
const serviceDir = path.join(
61+
projectRoot,
62+
'android',
63+
'app',
64+
'src',
65+
'main',
66+
'java',
67+
packagePath
68+
);
69+
70+
fs.mkdirSync(serviceDir, { recursive: true });
71+
fs.writeFileSync(
72+
path.join(serviceDir, `${SERVICE_CLASS_NAME}.kt`),
73+
generateFirebaseServiceKotlin(packageName)
74+
);
75+
76+
return config;
77+
},
78+
]);
79+
80+
/**
81+
* Adds the FirebaseMessagingService entry to the AndroidManifest.xml
82+
* so Android knows to route FCM events to our service.
83+
*/
84+
const registerServiceInManifest: ConfigPlugin<IntercomPluginProps> = (
85+
_config
86+
) =>
87+
withAndroidManifest(_config, (config) => {
88+
const mainApplication =
89+
AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
90+
91+
const packageName = config.android?.package;
92+
if (!packageName) {
93+
throw new Error(
94+
'@intercom/intercom-react-native: android.package must be defined in your Expo config to use Android push notifications.'
95+
);
96+
}
97+
98+
const serviceName = `.${SERVICE_CLASS_NAME}`;
99+
100+
// Check if the service is already registered (idempotency)
101+
const existingService = mainApplication.service?.find(
102+
(s) => s.$?.['android:name'] === serviceName
103+
);
104+
105+
if (!existingService) {
106+
if (!mainApplication.service) {
107+
mainApplication.service = [];
108+
}
109+
110+
mainApplication.service.push({
111+
$: {
112+
'android:name': serviceName,
113+
'android:exported': 'false' as any,
114+
},
115+
'intent-filter': [
116+
{
117+
action: [
118+
{
119+
$: {
120+
'android:name': 'com.google.firebase.MESSAGING_EVENT',
121+
},
122+
},
123+
],
124+
},
125+
],
126+
} as any);
127+
}
128+
129+
return config;
130+
});
131+
132+
export const withAndroidPushNotifications: ConfigPlugin<IntercomPluginProps> = (
133+
config,
134+
props
135+
) => {
136+
let newConfig = config;
137+
newConfig = writeFirebaseService(newConfig, props);
138+
newConfig = registerServiceInManifest(newConfig, props);
139+
return newConfig;
140+
};

src/expo-plugins/withPushNotifications.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
findObjcFunctionCodeBlock,
99
insertContentsInsideObjcFunctionBlock,
1010
} from '@expo/config-plugins/build/ios/codeMod';
11+
import { withAndroidPushNotifications } from './withAndroidPushNotifications';
1112

1213
const appDelegate: ConfigPlugin<IntercomPluginProps> = (_config) =>
1314
withAppDelegate(_config, (config) => {
@@ -59,7 +60,10 @@ export const withIntercomPushNotification: ConfigPlugin<IntercomPluginProps> = (
5960
props
6061
) => {
6162
let newConfig = config;
62-
newConfig = appDelegate(config, props);
63-
newConfig = infoPlist(config, props);
63+
// iOS push notification setup
64+
newConfig = appDelegate(newConfig, props);
65+
newConfig = infoPlist(newConfig, props);
66+
// Android push notification setup
67+
newConfig = withAndroidPushNotifications(newConfig, props);
6468
return newConfig;
6569
};

0 commit comments

Comments
 (0)