Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ The plugin provides props for extra customization. Every time you change the pro
- `iosApiKey` (_string_): iOS API Key from Intercom.
- `intercomRegion` (_string_): Region for Intercom `US`, `EU`, `AU`. Optional. Defaults to `US`.
- `useManualInit` (_boolean_): Set to `true` to manually initialize Intercom from JavaScript instead of at app startup. Optional. Defaults to `false`.
- `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.

```json
{
Expand Down Expand Up @@ -655,6 +656,8 @@ The Expo plugin automatically generates a `FirebaseMessagingService` for Android

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

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

#### Expo: Push notification deep links support

> **Note**: You can read more on Expo [documentation](https://docs.expo.dev/guides/deep-linking)
Expand Down
120 changes: 120 additions & 0 deletions __tests__/withAndroidPushNotifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,104 @@ dependencies {
'import com.google.firebase.messaging.FirebaseMessagingService'
);
});

test("extends FirebaseMessagingService when androidPushFallback is 'none', even with expo-notifications installed", () => {
jest.resetModules();
jest.mock('expo-notifications', () => ({}), { virtual: true });
jest.mock('@expo/config-plugins', () => ({
withDangerousMod: (
config: any,
[_platform, callback]: [string, Function]
) => callback(config),
withAndroidManifest: (config: any, callback: Function) =>
callback(config),
AndroidConfig: {
Manifest: {
getMainApplicationOrThrow: (modResults: any) =>
modResults.manifest.application[0],
},
},
}));

jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const localWriteSpy = jest
.spyOn(fs, 'writeFileSync')
.mockReturnValue(undefined);
jest.spyOn(fs, 'readFileSync').mockImplementation((filePath: any) => {
const p = String(filePath);
if (p.includes(path.join('app', 'build.gradle'))) {
return fakeAppBuildGradle;
}
return fakeNativeBuildGradle;
});

const {
withAndroidPushNotifications: freshPlugin,
} = require('../src/expo-plugins/withAndroidPushNotifications');

const config = createMockConfig('com.example.myapp');
freshPlugin(config as any, { androidPushFallback: 'none' } as any);

const content = localWriteSpy.mock.calls[0]?.[1] as string;
expect(content).toContain(
'class IntercomFirebaseMessagingService : FirebaseMessagingService()'
);
expect(content).toContain(
'import com.google.firebase.messaging.FirebaseMessagingService'
);
expect(content).not.toContain('ExpoFirebaseMessagingService');
});

test("extends ExpoFirebaseMessagingService when androidPushFallback is 'expo-notifications', even without expo-notifications installed", () => {
jest.unmock('expo-notifications');
jest.resetModules();
jest.mock('@expo/config-plugins', () => ({
withDangerousMod: (
config: any,
[_platform, callback]: [string, Function]
) => callback(config),
withAndroidManifest: (config: any, callback: Function) =>
callback(config),
AndroidConfig: {
Manifest: {
getMainApplicationOrThrow: (modResults: any) =>
modResults.manifest.application[0],
},
},
}));

jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const localWriteSpy = jest
.spyOn(fs, 'writeFileSync')
.mockReturnValue(undefined);
jest.spyOn(fs, 'readFileSync').mockImplementation((filePath: any) => {
const p = String(filePath);
if (p.includes(path.join('app', 'build.gradle'))) {
return fakeAppBuildGradle;
}
return fakeNativeBuildGradle;
});

const {
withAndroidPushNotifications: freshPlugin,
} = require('../src/expo-plugins/withAndroidPushNotifications');

const config = createMockConfig('com.example.myapp');
freshPlugin(
config as any,
{
androidPushFallback: 'expo-notifications',
} as any
);

const content = localWriteSpy.mock.calls[0]?.[1] as string;
expect(content).toContain(
'class IntercomFirebaseMessagingService : ExpoFirebaseMessagingService()'
);
expect(content).toContain(
'import expo.modules.notifications.service.ExpoFirebaseMessagingService'
);
});
});

describe('AndroidManifest service registration', () => {
Expand Down Expand Up @@ -425,5 +523,27 @@ dependencies {
withAndroidPushNotifications(config as any, {} as any);
}).toThrow('android.package must be defined');
});

test('throws on an unknown androidPushFallback value', () => {
const config = createMockConfig('com.example.myapp');

expect(() => {
withAndroidPushNotifications(
config as any,
{
androidPushFallback: 'onesignal',
} as any
);
}).toThrow('invalid androidPushFallback "onesignal"');

expect(() => {
withAndroidPushNotifications(
config as any,
{
androidPushFallback: 'toString',
} as any
);
}).toThrow('invalid androidPushFallback "toString"');
});
});
});
18 changes: 18 additions & 0 deletions src/expo-plugins/@types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
export type IntercomRegion = 'US' | 'EU' | 'AU';
export type AndroidPushFallback = 'expo-notifications' | 'none';

type BasePluginProps = {
/** Data hosting region for your Intercom workspace. Defaults to 'US' */
intercomRegion?: IntercomRegion;
/**
* 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. Use this when another push provider (e.g.
* OneSignal, Braze) displays its own notifications; otherwise each of its
* pushes may be displayed twice (once by the provider and once by
* expo-notifications). Note that 'none' also stops forwarding new FCM
* tokens to expo-notifications, so Notifications.addPushTokenListener
* will no longer fire on token rotation.
*
* Defaults to 'expo-notifications' when expo-notifications is installed,
* 'none' otherwise.
*/
androidPushFallback?: AndroidPushFallback;
};

type AutoInitPluginProps = BasePluginProps & {
Expand Down
65 changes: 52 additions & 13 deletions src/expo-plugins/withAndroidPushNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,31 @@ import {
withAndroidManifest,
AndroidConfig,
} from '@expo/config-plugins';
import type { IntercomPluginProps } from './@types';
import type { AndroidPushFallback, IntercomPluginProps } from './@types';

const SERVICE_CLASS_NAME = 'IntercomFirebaseMessagingService';

/**
* The base class of the generated messaging service decides what happens to
* push messages that are not from Intercom: ExpoFirebaseMessagingService
* forwards them to expo-notifications, the plain FirebaseMessagingService
* ignores them.
*/
const PUSH_FALLBACK_BASE_CLASSES: Record<
AndroidPushFallback,
{ className: string; import: string }
> = {
'expo-notifications': {
className: 'ExpoFirebaseMessagingService',
import:
'import expo.modules.notifications.service.ExpoFirebaseMessagingService',
},
'none': {
className: 'FirebaseMessagingService',
import: 'import com.google.firebase.messaging.FirebaseMessagingService',
},
};

function hasExpoNotifications(): boolean {
try {
require('expo-notifications');
Expand All @@ -20,26 +41,39 @@ function hasExpoNotifications(): boolean {
}
}

function getPushNotificationsFallback(
props: IntercomPluginProps | undefined
): AndroidPushFallback {
const fallback = props?.androidPushFallback;
if (fallback === undefined) {
return hasExpoNotifications() ? 'expo-notifications' : 'none';
}
if (!Object.hasOwn(PUSH_FALLBACK_BASE_CLASSES, fallback)) {
throw new Error(
`@intercom/intercom-react-native: invalid androidPushFallback "${fallback}". Expected 'expo-notifications' or 'none'.`
);
}
return fallback;
}

/**
* Generates the Kotlin source for the FirebaseMessagingService that
* forwards FCM tokens and Intercom push messages to the Intercom SDK.
* Non-Intercom messages go to the pushFallback handler.
*/
function generateFirebaseServiceKotlin(packageName: string): string {
const extendsExpo = hasExpoNotifications();
const baseClass = extendsExpo
? 'ExpoFirebaseMessagingService'
: 'FirebaseMessagingService';
const baseImport = extendsExpo
? 'import expo.modules.notifications.service.ExpoFirebaseMessagingService'
: 'import com.google.firebase.messaging.FirebaseMessagingService';
function generateFirebaseServiceKotlin(
packageName: string,
pushFallback: AndroidPushFallback
): string {
const baseClass = PUSH_FALLBACK_BASE_CLASSES[pushFallback];

return `package ${packageName}

${baseImport}
${baseClass.import}
import com.google.firebase.messaging.RemoteMessage
import com.intercom.reactnative.IntercomModule

class ${SERVICE_CLASS_NAME} : ${baseClass}() {
class ${SERVICE_CLASS_NAME} : ${baseClass.className}() {

override fun onNewToken(refreshedToken: String) {
IntercomModule.sendTokenToIntercom(application, refreshedToken)
Expand All @@ -62,7 +96,10 @@ class ${SERVICE_CLASS_NAME} : ${baseClass}() {
* into the app's Android source directory, and ensures firebase-messaging
* is on the app module's compile classpath.
*/
const writeFirebaseService: ConfigPlugin<IntercomPluginProps> = (_config) =>
const writeFirebaseService: ConfigPlugin<IntercomPluginProps> = (
_config,
props
) =>
withDangerousMod(_config, [
'android',
(config) => {
Expand All @@ -73,6 +110,8 @@ const writeFirebaseService: ConfigPlugin<IntercomPluginProps> = (_config) =>
);
}

const pushFallback = getPushNotificationsFallback(props);

const projectRoot = config.modRequest.projectRoot;
const packagePath = packageName.replace(/\./g, '/');
const serviceDir = path.join(
Expand All @@ -88,7 +127,7 @@ const writeFirebaseService: ConfigPlugin<IntercomPluginProps> = (_config) =>
fs.mkdirSync(serviceDir, { recursive: true });
fs.writeFileSync(
path.join(serviceDir, `${SERVICE_CLASS_NAME}.kt`),
generateFirebaseServiceKotlin(packageName),
generateFirebaseServiceKotlin(packageName, pushFallback),
'utf-8'
);

Expand Down