Skip to content

Commit b2d196b

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 05a2574 commit b2d196b

3 files changed

Lines changed: 223 additions & 6 deletions

File tree

__tests__/withAndroidPushNotifications.test.ts

Lines changed: 126 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

@@ -151,6 +169,13 @@ dependencies {
151169
jest.mock('@expo/config-plugins', () => ({
152170
withDangerousMod: (config: any, [_platform, callback]: [string, Function]) =>
153171
callback(config),
172+
withAndroidManifest: (config: any, callback: Function) => callback(config),
173+
AndroidConfig: {
174+
Manifest: {
175+
getMainApplicationOrThrow: (modResults: any) =>
176+
modResults.manifest.application[0],
177+
},
178+
},
154179
}));
155180

156181
jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
@@ -186,6 +211,13 @@ dependencies {
186211
jest.mock('@expo/config-plugins', () => ({
187212
withDangerousMod: (config: any, [_platform, callback]: [string, Function]) =>
188213
callback(config),
214+
withAndroidManifest: (config: any, callback: Function) => callback(config),
215+
AndroidConfig: {
216+
Manifest: {
217+
getMainApplicationOrThrow: (modResults: any) =>
218+
modResults.manifest.application[0],
219+
},
220+
},
189221
}));
190222

191223
jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
@@ -213,6 +245,100 @@ dependencies {
213245
});
214246
});
215247

248+
describe('AndroidManifest service registration', () => {
249+
test('adds service entry with correct attributes', () => {
250+
const config = createMockConfig('com.example.myapp');
251+
withAndroidPushNotifications(config as any, {} as any);
252+
253+
const services = config.modResults.manifest.application[0].service;
254+
expect(services).toHaveLength(1);
255+
256+
const service = services[0];
257+
expect(service.$['android:name']).toBe(
258+
'.IntercomFirebaseMessagingService'
259+
);
260+
expect(service.$['android:exported']).toBe('false');
261+
});
262+
263+
test('registers MESSAGING_EVENT intent filter with priority', () => {
264+
const config = createMockConfig('com.example.myapp');
265+
withAndroidPushNotifications(config as any, {} as any);
266+
267+
const service = config.modResults.manifest.application[0].service[0];
268+
const intentFilter = service['intent-filter'][0];
269+
const action = intentFilter.action[0];
270+
271+
expect(action.$['android:name']).toBe(
272+
'com.google.firebase.MESSAGING_EVENT'
273+
);
274+
expect(intentFilter.$['android:priority']).toBe('10');
275+
});
276+
277+
test('preserves existing services when adding Intercom service', () => {
278+
const config = createMockConfig('com.example.myapp');
279+
280+
config.modResults.manifest.application[0].service.push({
281+
$: {
282+
'android:name': '.SomeOtherService',
283+
'android:exported': 'false',
284+
},
285+
} as any);
286+
287+
withAndroidPushNotifications(config as any, {} as any);
288+
289+
const services = config.modResults.manifest.application[0].service;
290+
expect(services).toHaveLength(2);
291+
expect(services[0].$['android:name']).toBe('.SomeOtherService');
292+
expect(services[1].$['android:name']).toBe(
293+
'.IntercomFirebaseMessagingService'
294+
);
295+
});
296+
297+
test('does not duplicate service on repeated runs (idempotency)', () => {
298+
const config = createMockConfig('com.example.myapp');
299+
300+
withAndroidPushNotifications(config as any, {} as any);
301+
withAndroidPushNotifications(config as any, {} as any);
302+
303+
const services = config.modResults.manifest.application[0].service;
304+
expect(services).toHaveLength(1);
305+
});
306+
307+
test('skips registration and warns when another FCM service exists', () => {
308+
const config = createMockConfig('com.example.myapp');
309+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
310+
311+
config.modResults.manifest.application[0].service.push({
312+
'$': {
313+
'android:name': '.ExistingFcmService',
314+
'android:exported': 'true',
315+
},
316+
'intent-filter': [
317+
{
318+
action: [
319+
{
320+
$: {
321+
'android:name': 'com.google.firebase.MESSAGING_EVENT',
322+
},
323+
},
324+
],
325+
},
326+
],
327+
} as any);
328+
329+
withAndroidPushNotifications(config as any, {} as any);
330+
331+
const services = config.modResults.manifest.application[0].service;
332+
expect(services).toHaveLength(1);
333+
expect(services[0].$['android:name']).toBe('.ExistingFcmService');
334+
expect(warnSpy).toHaveBeenCalledWith(
335+
expect.stringContaining('existing FirebaseMessagingService')
336+
);
337+
338+
warnSpy.mockRestore();
339+
});
340+
});
341+
216342
describe('error handling', () => {
217343
test('throws if android.package is not defined', () => {
218344
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: 4 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,8 @@ export const withIntercomPushNotification: ConfigPlugin<IntercomPluginProps> = (
5960
props
6061
) => {
6162
let newConfig = config;
62-
newConfig = appDelegate(config, props);
63-
newConfig = infoPlist(config, props);
63+
newConfig = appDelegate(newConfig, props);
64+
newConfig = infoPlist(newConfig, props);
65+
newConfig = withAndroidPushNotifications(newConfig, props);
6466
return newConfig;
6567
};

0 commit comments

Comments
 (0)