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
190 changes: 190 additions & 0 deletions __tests__/withAndroidPushNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import path from 'path';
import fs from 'fs';

jest.mock('@expo/config-plugins', () => ({
withDangerousMod: (config: any, [_platform, callback]: [string, Function]) =>
callback(config),
}));

import { withAndroidPushNotifications } from '../src/expo-plugins/withAndroidPushNotifications';

function createMockConfig(packageName?: string) {
return {
name: 'TestApp',
slug: 'test-app',
android: packageName ? { package: packageName } : undefined,
modRequest: {
projectRoot: '/mock/project',
},
};
}

describe('withAndroidPushNotifications', () => {
let mkdirSyncSpy: jest.SpyInstance;
let writeFileSyncSpy: jest.SpyInstance;
let readFileSyncSpy: jest.SpyInstance;

const fakeNativeBuildGradle = `
dependencies {
implementation "com.google.firebase:firebase-messaging:24.1.2"
implementation 'io.intercom.android:intercom-sdk:17.4.5'
}
`;

const fakeAppBuildGradle = `
android {
compileSdkVersion 34
}

dependencies {
implementation("com.facebook.react:react-native:+")
}
`;

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

afterEach(() => {
jest.restoreAllMocks();
});

describe('Kotlin service file generation', () => {
test('writes file with correct package name', () => {
const config = createMockConfig('com.example.myapp');
withAndroidPushNotifications(config as any, {} as any);

const content = writeFileSyncSpy.mock.calls[0][1] as string;
expect(content).toContain('package com.example.myapp');
});

test('generates valid FirebaseMessagingService subclass', () => {
const config = createMockConfig('com.example.myapp');
withAndroidPushNotifications(config as any, {} as any);

const content = writeFileSyncSpy.mock.calls[0][1] as string;

expect(content).toContain(
'class IntercomFirebaseMessagingService : FirebaseMessagingService()'
);
expect(content).toContain(
'override fun onNewToken(refreshedToken: String)'
);
expect(content).toContain(
'override fun onMessageReceived(remoteMessage: RemoteMessage)'
);
});

test('includes Intercom message routing logic', () => {
const config = createMockConfig('com.example.myapp');
withAndroidPushNotifications(config as any, {} as any);

const content = writeFileSyncSpy.mock.calls[0][1] as string;

expect(content).toContain(
'IntercomModule.sendTokenToIntercom(application, refreshedToken)'
);
expect(content).toContain('IntercomModule.isIntercomPush(remoteMessage)');
expect(content).toContain(
'IntercomModule.handleRemotePushMessage(application, remoteMessage)'
);
expect(content).toContain('super.onMessageReceived(remoteMessage)');
expect(content).toContain('super.onNewToken(refreshedToken)');
});

test('includes all required Kotlin imports', () => {
const config = createMockConfig('com.example.myapp');
withAndroidPushNotifications(config as any, {} as any);

const content = writeFileSyncSpy.mock.calls[0][1] as string;

expect(content).toContain(
'import com.google.firebase.messaging.FirebaseMessagingService'
);
expect(content).toContain(
'import com.google.firebase.messaging.RemoteMessage'
);
expect(content).toContain(
'import com.intercom.reactnative.IntercomModule'
);
});

test('writes file to correct directory based on package name', () => {
const config = createMockConfig('io.intercom.example');
withAndroidPushNotifications(config as any, {} as any);

const expectedDir = path.join(
'/mock/project',
'android',
'app',
'src',
'main',
'java',
'io',
'intercom',
'example'
);

expect(mkdirSyncSpy).toHaveBeenCalledWith(expectedDir, {
recursive: true,
});
expect(writeFileSyncSpy).toHaveBeenCalledWith(
path.join(expectedDir, 'IntercomFirebaseMessagingService.kt'),
expect.any(String),
'utf-8'
);
});
});

describe('Gradle dependency', () => {
test('adds firebase-messaging with version from native module', () => {
const config = createMockConfig('com.example.myapp');
withAndroidPushNotifications(config as any, {} as any);

const gradleWriteCall = writeFileSyncSpy.mock.calls.find((call: any[]) =>
(call[0] as string).includes('build.gradle')
);
expect(gradleWriteCall).toBeDefined();
expect(gradleWriteCall[1]).toContain('firebase-messaging:24.1.2');
});

test('skips adding firebase-messaging when already present', () => {
readFileSyncSpy.mockImplementation((filePath: any) => {
const p = String(filePath);
if (p.includes(path.join('app', 'build.gradle'))) {
return 'dependencies {\n implementation("com.google.firebase:firebase-messaging:23.0.0")\n}';
}
return fakeNativeBuildGradle;
});
const config = createMockConfig('com.example.myapp');
withAndroidPushNotifications(config as any, {} as any);

const gradleWriteCall = writeFileSyncSpy.mock.calls.find((call: any[]) =>
(call[0] as string).includes('build.gradle')
);
expect(gradleWriteCall).toBeUndefined();
});
});

describe('error handling', () => {
test('throws if android.package is not defined', () => {
const config = createMockConfig();

expect(() => {
withAndroidPushNotifications(config as any, {} as any);
}).toThrow('android.package must be defined');
});
});
});
112 changes: 112 additions & 0 deletions src/expo-plugins/withAndroidPushNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import path from 'path';
import fs from 'fs';

import { type ConfigPlugin, withDangerousMod } from '@expo/config-plugins';
import type { IntercomPluginProps } from './@types';

const SERVICE_CLASS_NAME = 'IntercomFirebaseMessagingService';

/**
* Generates the Kotlin source for the FirebaseMessagingService that
* forwards FCM tokens and Intercom push messages to the Intercom SDK.
*/
function generateFirebaseServiceKotlin(packageName: string): string {
return `package ${packageName}

import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.intercom.reactnative.IntercomModule

class ${SERVICE_CLASS_NAME} : FirebaseMessagingService() {

override fun onNewToken(refreshedToken: String) {
IntercomModule.sendTokenToIntercom(application, refreshedToken)
super.onNewToken(refreshedToken)
}

override fun onMessageReceived(remoteMessage: RemoteMessage) {
if (IntercomModule.isIntercomPush(remoteMessage)) {
IntercomModule.handleRemotePushMessage(application, remoteMessage)
} else {
super.onMessageReceived(remoteMessage)
}
}
}
`;
}

/**
* Uses withDangerousMod to write the Kotlin FirebaseMessagingService file
* into the app's Android source directory, and ensures firebase-messaging
* is on the app module's compile classpath.
*/
export const withAndroidPushNotifications: ConfigPlugin<IntercomPluginProps> = (
_config
) =>
withDangerousMod(_config, [
'android',
(config) => {
const packageName = config.android?.package;
if (!packageName) {
throw new Error(
'@intercom/intercom-react-native: android.package must be defined in your Expo config to use Android push notifications.'
);
}

const projectRoot = config.modRequest.projectRoot;
const packagePath = packageName.replace(/\./g, '/');
const serviceDir = path.join(
projectRoot,
'android',
'app',
'src',
'main',
'java',
packagePath
);

fs.mkdirSync(serviceDir, { recursive: true });
fs.writeFileSync(
path.join(serviceDir, `${SERVICE_CLASS_NAME}.kt`),
generateFirebaseServiceKotlin(packageName),
'utf-8'
);

// The native module declares firebase-messaging as an `implementation`
// dependency, which keeps it private to the library. Since our generated
// service lives in the app module, we need firebase-messaging on the
// app's compile classpath too. We read the version from the native
// module's build.gradle so it stays in sync automatically.
const packageRoot = path.resolve(
require.resolve('@intercom/intercom-react-native/package.json'),
'..'
);
const nativeBuildGradle = fs.readFileSync(
path.join(packageRoot, 'android', 'build.gradle'),
'utf-8'
);
const versionMatch = nativeBuildGradle.match(
/com\.google\.firebase:firebase-messaging:([\d.]+)/
);
const firebaseMessagingVersion = versionMatch
? versionMatch[1]
: '24.1.2';

const buildGradlePath = path.join(
projectRoot,
'android',
'app',
'build.gradle'
);
const buildGradle = fs.readFileSync(buildGradlePath, 'utf-8');
if (!buildGradle.includes('firebase-messaging')) {
const updatedBuildGradle = buildGradle.replace(
/dependencies\s*\{/,
`dependencies {\n implementation("com.google.firebase:firebase-messaging:${firebaseMessagingVersion}")`
);
fs.writeFileSync(buildGradlePath, updatedBuildGradle, 'utf-8');
}

return config;
},
]);
2 changes: 1 addition & 1 deletion tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"extends": "./tsconfig",
"exclude": ["examples/*"]
"exclude": ["examples/*", "__tests__"]
}
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@
"strict": true,
"target": "esnext"
},
"exclude": ["examples"]
"exclude": ["examples", "__tests__"]
}