diff --git a/README.md b/README.md index 4ff49a49..1b1d69a0 100644 --- a/README.md +++ b/README.md @@ -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 { @@ -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) diff --git a/__tests__/withAndroidPushNotifications.test.ts b/__tests__/withAndroidPushNotifications.test.ts index 4f3cdd83..58fd2a06 100644 --- a/__tests__/withAndroidPushNotifications.test.ts +++ b/__tests__/withAndroidPushNotifications.test.ts @@ -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', () => { @@ -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"'); + }); }); }); diff --git a/src/expo-plugins/@types.ts b/src/expo-plugins/@types.ts index db1c377c..0f5a4835 100644 --- a/src/expo-plugins/@types.ts +++ b/src/expo-plugins/@types.ts @@ -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 & { diff --git a/src/expo-plugins/withAndroidPushNotifications.ts b/src/expo-plugins/withAndroidPushNotifications.ts index 8bf765bb..eda22edb 100644 --- a/src/expo-plugins/withAndroidPushNotifications.ts +++ b/src/expo-plugins/withAndroidPushNotifications.ts @@ -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'); @@ -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) @@ -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 = (_config) => +const writeFirebaseService: ConfigPlugin = ( + _config, + props +) => withDangerousMod(_config, [ 'android', (config) => { @@ -73,6 +110,8 @@ const writeFirebaseService: ConfigPlugin = (_config) => ); } + const pushFallback = getPushNotificationsFallback(props); + const projectRoot = config.modRequest.projectRoot; const packagePath = packageName.replace(/\./g, '/'); const serviceDir = path.join( @@ -88,7 +127,7 @@ const writeFirebaseService: ConfigPlugin = (_config) => fs.mkdirSync(serviceDir, { recursive: true }); fs.writeFileSync( path.join(serviceDir, `${SERVICE_CLASS_NAME}.kt`), - generateFirebaseServiceKotlin(packageName), + generateFirebaseServiceKotlin(packageName, pushFallback), 'utf-8' );