Skip to content

Commit 33151dd

Browse files
committed
feat: add androidPushFallback option to the Expo plugin
When expo-notifications is installed, the generated IntercomFirebaseMessagingService forwards every non-Intercom FCM message to expo-notifications for display. Apps that use another push provider (e.g. OneSignal) get each push displayed twice, because such providers receive pushes through a broadcast receiver, so the existing FCM-service detection never triggers for them. androidPushFallback lets apps choose what the generated service does with non-Intercom messages: 'expo-notifications' (forward, current behavior) or 'none' (ignore). Absent, behavior is unchanged.
1 parent be8e60d commit 33151dd

4 files changed

Lines changed: 193 additions & 13 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,7 @@ The plugin provides props for extra customization. Every time you change the pro
537537
- `iosApiKey` (_string_): iOS API Key from Intercom.
538538
- `intercomRegion` (_string_): Region for Intercom `US`, `EU`, `AU`. Optional. Defaults to `US`.
539539
- `useManualInit` (_boolean_): Set to `true` to manually initialize Intercom from JavaScript instead of at app startup. Optional. Defaults to `false`.
540+
- `androidPushFallback` (_string_): What the generated Android messaging service does with push messages that are not from Intercom. `"expo-notifications"` forwards them to `expo-notifications` for handling and display (requires `expo-notifications` to be installed). `"none"` ignores them and also stops forwarding new FCM tokens to `expo-notifications` push token listeners; use it when another push provider (e.g. OneSignal, Braze) displays its own notifications, to avoid duplicates. Optional. Defaults to `"expo-notifications"` when `expo-notifications` is installed, `"none"` otherwise.
540541

541542
```json
542543
{
@@ -655,6 +656,8 @@ The Expo plugin automatically generates a `FirebaseMessagingService` for Android
655656

656657
> **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.
657658

659+
> **Note**: Some push providers (e.g. OneSignal) receive FCM messages through their own broadcast receiver instead of a `FirebaseMessagingService`, so the detection above never triggers for them. If `expo-notifications` is also installed, the generated service then forwards the provider's pushes to `expo-notifications`, and each push is displayed twice. Set `"androidPushFallback": "none"` in the plugin props so the generated service handles Intercom pushes only and ignores everything else.
660+
658661
#### Expo: Push notification deep links support
659662

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

__tests__/withAndroidPushNotifications.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,104 @@ dependencies {
257257
'import com.google.firebase.messaging.FirebaseMessagingService'
258258
);
259259
});
260+
261+
test("extends FirebaseMessagingService when androidPushFallback is 'none', even with expo-notifications installed", () => {
262+
jest.resetModules();
263+
jest.mock('expo-notifications', () => ({}), { virtual: true });
264+
jest.mock('@expo/config-plugins', () => ({
265+
withDangerousMod: (
266+
config: any,
267+
[_platform, callback]: [string, Function]
268+
) => callback(config),
269+
withAndroidManifest: (config: any, callback: Function) =>
270+
callback(config),
271+
AndroidConfig: {
272+
Manifest: {
273+
getMainApplicationOrThrow: (modResults: any) =>
274+
modResults.manifest.application[0],
275+
},
276+
},
277+
}));
278+
279+
jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
280+
const localWriteSpy = jest
281+
.spyOn(fs, 'writeFileSync')
282+
.mockReturnValue(undefined);
283+
jest.spyOn(fs, 'readFileSync').mockImplementation((filePath: any) => {
284+
const p = String(filePath);
285+
if (p.includes(path.join('app', 'build.gradle'))) {
286+
return fakeAppBuildGradle;
287+
}
288+
return fakeNativeBuildGradle;
289+
});
290+
291+
const {
292+
withAndroidPushNotifications: freshPlugin,
293+
} = require('../src/expo-plugins/withAndroidPushNotifications');
294+
295+
const config = createMockConfig('com.example.myapp');
296+
freshPlugin(config as any, { androidPushFallback: 'none' } as any);
297+
298+
const content = localWriteSpy.mock.calls[0]?.[1] as string;
299+
expect(content).toContain(
300+
'class IntercomFirebaseMessagingService : FirebaseMessagingService()'
301+
);
302+
expect(content).toContain(
303+
'import com.google.firebase.messaging.FirebaseMessagingService'
304+
);
305+
expect(content).not.toContain('ExpoFirebaseMessagingService');
306+
});
307+
308+
test("extends ExpoFirebaseMessagingService when androidPushFallback is 'expo-notifications', even without expo-notifications installed", () => {
309+
jest.unmock('expo-notifications');
310+
jest.resetModules();
311+
jest.mock('@expo/config-plugins', () => ({
312+
withDangerousMod: (
313+
config: any,
314+
[_platform, callback]: [string, Function]
315+
) => callback(config),
316+
withAndroidManifest: (config: any, callback: Function) =>
317+
callback(config),
318+
AndroidConfig: {
319+
Manifest: {
320+
getMainApplicationOrThrow: (modResults: any) =>
321+
modResults.manifest.application[0],
322+
},
323+
},
324+
}));
325+
326+
jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
327+
const localWriteSpy = jest
328+
.spyOn(fs, 'writeFileSync')
329+
.mockReturnValue(undefined);
330+
jest.spyOn(fs, 'readFileSync').mockImplementation((filePath: any) => {
331+
const p = String(filePath);
332+
if (p.includes(path.join('app', 'build.gradle'))) {
333+
return fakeAppBuildGradle;
334+
}
335+
return fakeNativeBuildGradle;
336+
});
337+
338+
const {
339+
withAndroidPushNotifications: freshPlugin,
340+
} = require('../src/expo-plugins/withAndroidPushNotifications');
341+
342+
const config = createMockConfig('com.example.myapp');
343+
freshPlugin(
344+
config as any,
345+
{
346+
androidPushFallback: 'expo-notifications',
347+
} as any
348+
);
349+
350+
const content = localWriteSpy.mock.calls[0]?.[1] as string;
351+
expect(content).toContain(
352+
'class IntercomFirebaseMessagingService : ExpoFirebaseMessagingService()'
353+
);
354+
expect(content).toContain(
355+
'import expo.modules.notifications.service.ExpoFirebaseMessagingService'
356+
);
357+
});
260358
});
261359

262360
describe('AndroidManifest service registration', () => {
@@ -425,5 +523,27 @@ dependencies {
425523
withAndroidPushNotifications(config as any, {} as any);
426524
}).toThrow('android.package must be defined');
427525
});
526+
527+
test('throws on an unknown androidPushFallback value', () => {
528+
const config = createMockConfig('com.example.myapp');
529+
530+
expect(() => {
531+
withAndroidPushNotifications(
532+
config as any,
533+
{
534+
androidPushFallback: 'onesignal',
535+
} as any
536+
);
537+
}).toThrow('invalid androidPushFallback "onesignal"');
538+
539+
expect(() => {
540+
withAndroidPushNotifications(
541+
config as any,
542+
{
543+
androidPushFallback: 'toString',
544+
} as any
545+
);
546+
}).toThrow('invalid androidPushFallback "toString"');
547+
});
428548
});
429549
});

src/expo-plugins/@types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,26 @@
11
export type IntercomRegion = 'US' | 'EU' | 'AU';
2+
export type AndroidPushFallback = 'expo-notifications' | 'none';
23

34
type BasePluginProps = {
45
/** Data hosting region for your Intercom workspace. Defaults to 'US' */
56
intercomRegion?: IntercomRegion;
7+
/**
8+
* What the generated Android messaging service does with push messages that
9+
* are not from Intercom.
10+
*
11+
* - 'expo-notifications': forwards them to expo-notifications for handling
12+
* and display. Requires expo-notifications to be installed.
13+
* - 'none': ignores them. Use this when another push provider (e.g.
14+
* OneSignal, Braze) displays its own notifications; otherwise each of its
15+
* pushes may be displayed twice (once by the provider and once by
16+
* expo-notifications). Note that 'none' also stops forwarding new FCM
17+
* tokens to expo-notifications, so Notifications.addPushTokenListener
18+
* will no longer fire on token rotation.
19+
*
20+
* Defaults to 'expo-notifications' when expo-notifications is installed,
21+
* 'none' otherwise.
22+
*/
23+
androidPushFallback?: AndroidPushFallback;
624
};
725

826
type AutoInitPluginProps = BasePluginProps & {

src/expo-plugins/withAndroidPushNotifications.ts

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,31 @@ import {
77
withAndroidManifest,
88
AndroidConfig,
99
} from '@expo/config-plugins';
10-
import type { IntercomPluginProps } from './@types';
10+
import type { AndroidPushFallback, IntercomPluginProps } from './@types';
1111

1212
const SERVICE_CLASS_NAME = 'IntercomFirebaseMessagingService';
1313

14+
/**
15+
* The base class of the generated messaging service decides what happens to
16+
* push messages that are not from Intercom: ExpoFirebaseMessagingService
17+
* forwards them to expo-notifications, the plain FirebaseMessagingService
18+
* ignores them.
19+
*/
20+
const PUSH_FALLBACK_BASE_CLASSES: Record<
21+
AndroidPushFallback,
22+
{ className: string; import: string }
23+
> = {
24+
'expo-notifications': {
25+
className: 'ExpoFirebaseMessagingService',
26+
import:
27+
'import expo.modules.notifications.service.ExpoFirebaseMessagingService',
28+
},
29+
'none': {
30+
className: 'FirebaseMessagingService',
31+
import: 'import com.google.firebase.messaging.FirebaseMessagingService',
32+
},
33+
};
34+
1435
function hasExpoNotifications(): boolean {
1536
try {
1637
require('expo-notifications');
@@ -20,26 +41,39 @@ function hasExpoNotifications(): boolean {
2041
}
2142
}
2243

44+
function getPushNotificationsFallback(
45+
props: IntercomPluginProps | undefined
46+
): AndroidPushFallback {
47+
const fallback = props?.androidPushFallback;
48+
if (fallback === undefined) {
49+
return hasExpoNotifications() ? 'expo-notifications' : 'none';
50+
}
51+
if (!Object.hasOwn(PUSH_FALLBACK_BASE_CLASSES, fallback)) {
52+
throw new Error(
53+
`@intercom/intercom-react-native: invalid androidPushFallback "${fallback}". Expected 'expo-notifications' or 'none'.`
54+
);
55+
}
56+
return fallback;
57+
}
58+
2359
/**
2460
* Generates the Kotlin source for the FirebaseMessagingService that
2561
* forwards FCM tokens and Intercom push messages to the Intercom SDK.
62+
* Non-Intercom messages go to the pushFallback handler.
2663
*/
27-
function generateFirebaseServiceKotlin(packageName: string): string {
28-
const extendsExpo = hasExpoNotifications();
29-
const baseClass = extendsExpo
30-
? 'ExpoFirebaseMessagingService'
31-
: 'FirebaseMessagingService';
32-
const baseImport = extendsExpo
33-
? 'import expo.modules.notifications.service.ExpoFirebaseMessagingService'
34-
: 'import com.google.firebase.messaging.FirebaseMessagingService';
64+
function generateFirebaseServiceKotlin(
65+
packageName: string,
66+
pushFallback: AndroidPushFallback
67+
): string {
68+
const baseClass = PUSH_FALLBACK_BASE_CLASSES[pushFallback];
3569

3670
return `package ${packageName}
3771
38-
${baseImport}
72+
${baseClass.import}
3973
import com.google.firebase.messaging.RemoteMessage
4074
import com.intercom.reactnative.IntercomModule
4175
42-
class ${SERVICE_CLASS_NAME} : ${baseClass}() {
76+
class ${SERVICE_CLASS_NAME} : ${baseClass.className}() {
4377
4478
override fun onNewToken(refreshedToken: String) {
4579
IntercomModule.sendTokenToIntercom(application, refreshedToken)
@@ -62,7 +96,10 @@ class ${SERVICE_CLASS_NAME} : ${baseClass}() {
6296
* into the app's Android source directory, and ensures firebase-messaging
6397
* is on the app module's compile classpath.
6498
*/
65-
const writeFirebaseService: ConfigPlugin<IntercomPluginProps> = (_config) =>
99+
const writeFirebaseService: ConfigPlugin<IntercomPluginProps> = (
100+
_config,
101+
props
102+
) =>
66103
withDangerousMod(_config, [
67104
'android',
68105
(config) => {
@@ -73,6 +110,8 @@ const writeFirebaseService: ConfigPlugin<IntercomPluginProps> = (_config) =>
73110
);
74111
}
75112

113+
const pushFallback = getPushNotificationsFallback(props);
114+
76115
const projectRoot = config.modRequest.projectRoot;
77116
const packagePath = packageName.replace(/\./g, '/');
78117
const serviceDir = path.join(
@@ -88,7 +127,7 @@ const writeFirebaseService: ConfigPlugin<IntercomPluginProps> = (_config) =>
88127
fs.mkdirSync(serviceDir, { recursive: true });
89128
fs.writeFileSync(
90129
path.join(serviceDir, `${SERVICE_CLASS_NAME}.kt`),
91-
generateFirebaseServiceKotlin(packageName),
130+
generateFirebaseServiceKotlin(packageName, pushFallback),
92131
'utf-8'
93132
);
94133

0 commit comments

Comments
 (0)