Skip to content

Commit 27c60c9

Browse files
feat: register FirebaseMessagingService in AndroidManifest and wire into plugin pipeline
Adds manifest registration for the generated service with priority 10 and android:exported=false. Detects existing FCM services and warns instead of conflicting. Wires the Android plugin into the push notification pipeline alongside the existing iOS plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b4c8fac commit 27c60c9

3 files changed

Lines changed: 223 additions & 4 deletions

File tree

__tests__/withAndroidPushNotifications.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import fs from 'fs';
44
jest.mock('@expo/config-plugins', () => ({
55
withDangerousMod: (config: any, [_platform, callback]: [string, Function]) =>
66
callback(config),
7+
withAndroidManifest: (config: any, callback: Function) => callback(config),
8+
AndroidConfig: {
9+
Manifest: {
10+
getMainApplicationOrThrow: (modResults: any) =>
11+
modResults.manifest.application[0],
12+
},
13+
},
714
}));
815

916
import { withAndroidPushNotifications } from '../src/expo-plugins/withAndroidPushNotifications';
@@ -16,6 +23,17 @@ function createMockConfig(packageName?: string) {
1623
modRequest: {
1724
projectRoot: '/mock/project',
1825
},
26+
modResults: {
27+
manifest: {
28+
application: [
29+
{
30+
$: { 'android:name': '.MainApplication' },
31+
activity: [],
32+
service: [] as any[],
33+
},
34+
],
35+
},
36+
},
1937
};
2038
}
2139

@@ -153,6 +171,14 @@ dependencies {
153171
config: any,
154172
[_platform, callback]: [string, Function]
155173
) => callback(config),
174+
withAndroidManifest: (config: any, callback: Function) =>
175+
callback(config),
176+
AndroidConfig: {
177+
Manifest: {
178+
getMainApplicationOrThrow: (modResults: any) =>
179+
modResults.manifest.application[0],
180+
},
181+
},
156182
}));
157183

158184
jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
@@ -194,6 +220,14 @@ dependencies {
194220
config: any,
195221
[_platform, callback]: [string, Function]
196222
) => callback(config),
223+
withAndroidManifest: (config: any, callback: Function) =>
224+
callback(config),
225+
AndroidConfig: {
226+
Manifest: {
227+
getMainApplicationOrThrow: (modResults: any) =>
228+
modResults.manifest.application[0],
229+
},
230+
},
197231
}));
198232

199233
jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
@@ -225,6 +259,100 @@ dependencies {
225259
});
226260
});
227261

262+
describe('AndroidManifest service registration', () => {
263+
test('adds service entry with correct attributes', () => {
264+
const config = createMockConfig('com.example.myapp');
265+
withAndroidPushNotifications(config as any, {} as any);
266+
267+
const services = config.modResults.manifest.application[0].service;
268+
expect(services).toHaveLength(1);
269+
270+
const service = services[0];
271+
expect(service.$['android:name']).toBe(
272+
'.IntercomFirebaseMessagingService'
273+
);
274+
expect(service.$['android:exported']).toBe('false');
275+
});
276+
277+
test('registers MESSAGING_EVENT intent filter with priority', () => {
278+
const config = createMockConfig('com.example.myapp');
279+
withAndroidPushNotifications(config as any, {} as any);
280+
281+
const service = config.modResults.manifest.application[0].service[0];
282+
const intentFilter = service['intent-filter'][0];
283+
const action = intentFilter.action[0];
284+
285+
expect(action.$['android:name']).toBe(
286+
'com.google.firebase.MESSAGING_EVENT'
287+
);
288+
expect(intentFilter.$['android:priority']).toBe('10');
289+
});
290+
291+
test('preserves existing services when adding Intercom service', () => {
292+
const config = createMockConfig('com.example.myapp');
293+
294+
config.modResults.manifest.application[0].service.push({
295+
$: {
296+
'android:name': '.SomeOtherService',
297+
'android:exported': 'false',
298+
},
299+
} as any);
300+
301+
withAndroidPushNotifications(config as any, {} as any);
302+
303+
const services = config.modResults.manifest.application[0].service;
304+
expect(services).toHaveLength(2);
305+
expect(services[0].$['android:name']).toBe('.SomeOtherService');
306+
expect(services[1].$['android:name']).toBe(
307+
'.IntercomFirebaseMessagingService'
308+
);
309+
});
310+
311+
test('does not duplicate service on repeated runs (idempotency)', () => {
312+
const config = createMockConfig('com.example.myapp');
313+
314+
withAndroidPushNotifications(config as any, {} as any);
315+
withAndroidPushNotifications(config as any, {} as any);
316+
317+
const services = config.modResults.manifest.application[0].service;
318+
expect(services).toHaveLength(1);
319+
});
320+
321+
test('skips registration and warns when another FCM service exists', () => {
322+
const config = createMockConfig('com.example.myapp');
323+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
324+
325+
config.modResults.manifest.application[0].service.push({
326+
'$': {
327+
'android:name': '.ExistingFcmService',
328+
'android:exported': 'true',
329+
},
330+
'intent-filter': [
331+
{
332+
action: [
333+
{
334+
$: {
335+
'android:name': 'com.google.firebase.MESSAGING_EVENT',
336+
},
337+
},
338+
],
339+
},
340+
],
341+
} as any);
342+
343+
withAndroidPushNotifications(config as any, {} as any);
344+
345+
const services = config.modResults.manifest.application[0].service;
346+
expect(services).toHaveLength(1);
347+
expect(services[0].$['android:name']).toBe('.ExistingFcmService');
348+
expect(warnSpy).toHaveBeenCalledWith(
349+
expect.stringContaining('existing FirebaseMessagingService')
350+
);
351+
352+
warnSpy.mockRestore();
353+
});
354+
});
355+
228356
describe('error handling', () => {
229357
test('throws if android.package is not defined', () => {
230358
const config = createMockConfig();

src/expo-plugins/withAndroidPushNotifications.ts

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import path from 'path';
22
import fs from 'fs';
33

4-
import { type ConfigPlugin, withDangerousMod } from '@expo/config-plugins';
4+
import {
5+
type ConfigPlugin,
6+
withDangerousMod,
7+
withAndroidManifest,
8+
AndroidConfig,
9+
} from '@expo/config-plugins';
510
import type { IntercomPluginProps } from './@types';
611

712
const SERVICE_CLASS_NAME = 'IntercomFirebaseMessagingService';
@@ -57,9 +62,7 @@ class ${SERVICE_CLASS_NAME} : ${baseClass}() {
5762
* into the app's Android source directory, and ensures firebase-messaging
5863
* is on the app module's compile classpath.
5964
*/
60-
export const withAndroidPushNotifications: ConfigPlugin<IntercomPluginProps> = (
61-
_config
62-
) =>
65+
const writeFirebaseService: ConfigPlugin<IntercomPluginProps> = (_config) =>
6366
withDangerousMod(_config, [
6467
'android',
6568
(config) => {
@@ -124,3 +127,89 @@ export const withAndroidPushNotifications: ConfigPlugin<IntercomPluginProps> = (
124127
return config;
125128
},
126129
]);
130+
131+
/**
132+
* Adds the FirebaseMessagingService entry to the AndroidManifest.xml
133+
* so Android knows to route FCM events to our service.
134+
*/
135+
const registerServiceInManifest: ConfigPlugin<IntercomPluginProps> = (
136+
_config
137+
) =>
138+
withAndroidManifest(_config, (config) => {
139+
const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(
140+
config.modResults
141+
);
142+
143+
const packageName = config.android?.package;
144+
if (!packageName) {
145+
throw new Error(
146+
'@intercom/intercom-react-native: android.package must be defined in your Expo config to use Android push notifications.'
147+
);
148+
}
149+
150+
const serviceName = `.${SERVICE_CLASS_NAME}`;
151+
152+
const existingService = mainApplication.service?.find(
153+
(s) => s.$?.['android:name'] === serviceName
154+
);
155+
156+
const hasExistingFcmService = mainApplication.service?.some(
157+
(s) =>
158+
s.$?.['android:name'] !== serviceName &&
159+
s['intent-filter']?.some(
160+
(f: any) =>
161+
f.action?.some(
162+
(a: any) =>
163+
a.$?.['android:name'] === 'com.google.firebase.MESSAGING_EVENT'
164+
)
165+
)
166+
);
167+
168+
if (hasExistingFcmService) {
169+
console.warn(
170+
'@intercom/intercom-react-native: An existing FirebaseMessagingService was found in AndroidManifest.xml. ' +
171+
'Skipping automatic Intercom service registration to avoid conflicts. ' +
172+
'You will need to route Intercom pushes manually using IntercomModule.isIntercomPush() and IntercomModule.handleRemotePushMessage().'
173+
);
174+
return config;
175+
}
176+
177+
if (!existingService) {
178+
if (!mainApplication.service) {
179+
mainApplication.service = [];
180+
}
181+
182+
mainApplication.service.push({
183+
'$': {
184+
'android:name': serviceName,
185+
'android:exported': 'false' as any,
186+
},
187+
'intent-filter': [
188+
{
189+
$: {
190+
'android:priority': '10',
191+
} as any,
192+
action: [
193+
{
194+
$: {
195+
'android:name': 'com.google.firebase.MESSAGING_EVENT',
196+
},
197+
},
198+
],
199+
},
200+
],
201+
} as any);
202+
}
203+
204+
return config;
205+
});
206+
207+
export const withAndroidPushNotifications: ConfigPlugin<IntercomPluginProps> = (
208+
config,
209+
props
210+
) => {
211+
let newConfig = config;
212+
newConfig = writeFirebaseService(newConfig, props);
213+
newConfig = registerServiceInManifest(newConfig, props);
214+
return newConfig;
215+
};

src/expo-plugins/withPushNotifications.ts

Lines changed: 2 additions & 0 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) => {
@@ -61,5 +62,6 @@ export const withIntercomPushNotification: ConfigPlugin<IntercomPluginProps> = (
6162
let newConfig = config;
6263
newConfig = appDelegate(newConfig, props);
6364
newConfig = infoPlist(newConfig, props);
65+
newConfig = withAndroidPushNotifications(newConfig, props);
6466
return newConfig;
6567
};

0 commit comments

Comments
 (0)