Skip to content

Commit 79bec30

Browse files
feat: register Android push service in manifest and wire into plugin (#384)
* 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> * docs: document FCM conflict detection plugin-ordering limitation The conflict detection in registerServiceInManifest only works when the competing plugin is listed after Intercom in the Expo plugins array, due to Expo's LIFO mod execution order. Added a JSDoc comment explaining this constraint and advising users to list Intercom first. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add Android push notification guidance to Expo README section Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b4c8fac commit 79bec30

File tree

4 files changed

+223
-4
lines changed

4 files changed

+223
-4
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,10 @@ Add the necessary permission descriptions to infoPlist key.
660660

661661
Next, rebuild your app as described in the ["Adding custom native code"](https://docs.expo.io/workflow/customizing/) guide.
662662

663+
The Expo plugin automatically generates a `FirebaseMessagingService` for Android that routes Intercom pushes to the SDK and passes non-Intercom messages through to other handlers (e.g. `expo-notifications`).
664+
665+
> **Note**: If your app uses another SDK that registers its own `FirebaseMessagingService` (e.g. OneSignal, Braze), list `@intercom/intercom-react-native` **before** that SDK in your `plugins` array. This allows the plugin to detect the other service and skip its own registration, avoiding conflicts.
666+
663667
#### Expo: Push notification deep links support
664668

665669
> **Note**: You can read more on Expo [documentation](https://docs.expo.dev/guides/deep-linking)

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

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)