diff --git a/.github/workflows/scripts/test-expo-ios-link.sh b/.github/workflows/scripts/test-expo-ios-link.sh index 4a9aaf5ff9..885faf3f33 100755 --- a/.github/workflows/scripts/test-expo-ios-link.sh +++ b/.github/workflows/scripts/test-expo-ios-link.sh @@ -436,3 +436,19 @@ if [[ "$nine322_failed" -ne 0 ]]; then fi log "PASS: no RNFB* framework privately duplicates a shared GoogleUtilities class (#9322)" + +# test-expo enables the messaging plugin's `ios.notificationServiceExtension`: the extension must be embedded +# in the app and link FirebaseMessaging from Swift Package Manager. +log "--- notification service extension ---" +EXTENSION_BINARY="${BUILT_PRODUCTS_DIR}/testexpo.app/PlugIns/NotificationServiceExtension.appex/NotificationServiceExtension" +if [[ ! -f "$EXTENSION_BINARY" ]]; then + log "ERROR: NotificationServiceExtension.appex is not embedded in testexpo.app (expected ${EXTENSION_BINARY})" + exit 1 +fi +# Captured rather than piped: under pipefail, `grep -q` exiting at the first match makes `nm` fail on SIGPIPE. +extension_syms="$(nm "$EXTENSION_BINARY" 2>/dev/null || true)" +if ! grep -qE '_OBJC_CLASS_\$_FIRMessaging$' <<<"$extension_syms"; then + log "ERROR: NotificationServiceExtension does not link FirebaseMessaging (no FIRMessaging class reference)" + exit 1 +fi +log "PASS: NotificationServiceExtension.appex is embedded and links FirebaseMessaging" diff --git a/docs/messaging/ios-notification-images.mdx b/docs/messaging/ios-notification-images.mdx index eb4fed3710..98b5db4518 100644 --- a/docs/messaging/ios-notification-images.mdx +++ b/docs/messaging/ios-notification-images.mdx @@ -12,6 +12,37 @@ This is a quick guide to display an image in an incoming notification. Android h **🚨 Before you start** Be sure you already have Cloud Messaging installed and set up. In case you don't [get started here](/messaging/usage). +## Expo + +Expo projects can let the `@react-native-firebase/messaging` config plugin add the extension instead of following the steps below: + +```json +{ + "expo": { + "plugins": [ + [ + "@react-native-firebase/messaging", + { + "ios": { + "notificationServiceExtension": true + } + } + ] + ] + } +} +``` + +On `npx expo prebuild`, the plugin adds a `NotificationServiceExtension` target embedded in the app, with its source and `Info.plist` in `ios/NotificationServiceExtension/`. The extension links `FirebaseMessaging` through Swift Package Manager at the exact Firebase Apple SDK version of `@react-native-firebase/app`, the same package reference `pod install` links `FirebaseCore` into the app with (see [iOS SPM Support](/ios-spm)). Its bundle identifier is `.NotificationServiceExtension`, and the plugin declares it to EAS Build (`extra.eas.build.experimental.ios.appExtensions`) so that EAS provisions and signs it. + +To name the target differently, pass `{ "targetName": "ImageNotification" }` instead of `true`. The name is also the last component of the bundle identifier, so it may only contain letters, digits and hyphens. + +iOS hands an app's notifications to a single notification service extension. If another config plugin already adds one (OneSignal's, for example), don't enable this option as well: only one of the two would handle your notifications. Use that extension, or this one, alone. + +After turning the option off, run `npx expo prebuild --clean`: like other config plugins' changes, prebuild without `--clean` only adds to the native project, and leaves the target in it. + +## React Native CLI + **🏁 Ready to start** The following steps will guide you through how to add a new target to your application to support payloads with an image. Open Xcode and let's get started. diff --git a/packages/messaging/plugin/__tests__/fixtures/notification-service-extension/ios/HelloWorld.xcodeproj/project.pbxproj b/packages/messaging/plugin/__tests__/fixtures/notification-service-extension/ios/HelloWorld.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..aabe3ada50 --- /dev/null +++ b/packages/messaging/plugin/__tests__/fixtures/notification-service-extension/ios/HelloWorld.xcodeproj/project.pbxproj @@ -0,0 +1,205 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 13B07F961A680F5B00A75B9A /* HelloWorld.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HelloWorld.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = HelloWorld/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = HelloWorld/Info.plist; sourceTree = ""; }; + F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = HelloWorld/AppDelegate.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* HelloWorld */ = { + isa = PBXGroup; + children = ( + F11748412D0307B40044C1D9 /* AppDelegate.swift */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + ); + name = HelloWorld; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* HelloWorld */, + 83CBBA001A601CBA00E9B192 /* Products */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* HelloWorld.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* HelloWorld */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "HelloWorld" */; + buildPhases = ( + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = HelloWorld; + productName = HelloWorld; + productReference = 13B07F961A680F5B00A75B9A /* HelloWorld.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1250; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "HelloWorld" */; + compatibilityVersion = "Xcode 12.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* HelloWorld */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = HelloWorld/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; + PRODUCT_BUNDLE_IDENTIFIER = com.example.helloworld; + PRODUCT_NAME = HelloWorld; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = HelloWorld/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; + PRODUCT_BUNDLE_IDENTIFIER = com.example.helloworld; + PRODUCT_NAME = HelloWorld; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + SDKROOT = iphoneos; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "HelloWorld" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "HelloWorld" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/packages/messaging/plugin/__tests__/notificationServiceExtension.test.ts b/packages/messaging/plugin/__tests__/notificationServiceExtension.test.ts new file mode 100644 index 0000000000..a89cf3db97 --- /dev/null +++ b/packages/messaging/plugin/__tests__/notificationServiceExtension.test.ts @@ -0,0 +1,408 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { IOSConfig, XcodeProject } from '@expo/config-plugins'; +import type { ExpoConfig } from '@expo/config-types'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import withRnFirebaseMessaging from '../src'; +import { + addNotificationServiceExtension, + DEFAULT_TARGET_NAME, + FIREBASE_MESSAGING_PRODUCT, + notificationServiceInfoPlist, + resolveNotificationServiceExtension, + SOURCE_FILE, + withEasAppExtension, + writeNotificationServiceExtensionFiles, +} from '../src/ios/notificationServiceExtension'; + +const appPackageJson = require('@react-native-firebase/app/package.json'); + +const FIXTURE_ROOT = path.join(__dirname, 'fixtures', 'notification-service-extension'); +const BUNDLE_IDENTIFIER = 'com.example.helloworld'; +const EXTENSION = { + targetName: DEFAULT_TARGET_NAME, + bundleIdentifier: `${BUNDLE_IDENTIFIER}.${DEFAULT_TARGET_NAME}`, +}; + +const unquote = (value: unknown) => + typeof value === 'string' ? value.replace(/^"(.*)"$/, '$1') : value; + +const readProject = (): XcodeProject => IOSConfig.XcodeUtils.getPbxproj(FIXTURE_ROOT); + +/** Entries of a pbxproj object section, without the comment keys. */ +const entries = (project: XcodeProject, section: string): [string, any][] => + Object.entries(project.hash.project.objects[section] ?? {}).filter( + ([key]) => !key.endsWith('_comment'), + ); + +const findTarget = (project: XcodeProject, name: string) => + entries(project, 'PBXNativeTarget').find(([, target]) => unquote(target.name) === name); + +const buildSettings = (project: XcodeProject, target: any) => { + const objects = project.hash.project.objects; + return objects.XCConfigurationList[target.buildConfigurationList].buildConfigurations.map( + ({ value }: { value: string }) => objects.XCBuildConfiguration[value].buildSettings, + ); +}; + +const phaseOf = (project: XcodeProject, target: any, section: string) => { + const phases = project.hash.project.objects[section] ?? {}; + const uuid = target.buildPhases + .map(({ value }: { value: string }) => value) + .find((value: string) => phases[value]); + return uuid ? phases[uuid] : undefined; +}; + +describe('Config Plugin iOS Tests - notificationServiceExtension', () => { + describe('resolveNotificationServiceExtension', () => { + const config = { ios: { bundleIdentifier: BUNDLE_IDENTIFIER } }; + + it('returns null when the option is omitted or false', () => { + expect(resolveNotificationServiceExtension(config, undefined)).toBeNull(); + expect(resolveNotificationServiceExtension(config, {})).toBeNull(); + expect( + resolveNotificationServiceExtension(config, { + ios: { notificationServiceExtension: false }, + }), + ).toBeNull(); + }); + + it('uses the default target name when true', () => { + expect( + resolveNotificationServiceExtension(config, { + ios: { notificationServiceExtension: true }, + }), + ).toEqual(EXTENSION); + }); + + it('derives the bundle identifier from a custom target name', () => { + expect( + resolveNotificationServiceExtension(config, { + ios: { notificationServiceExtension: { targetName: 'ImageNotification' } }, + }), + ).toEqual({ + targetName: 'ImageNotification', + bundleIdentifier: `${BUNDLE_IDENTIFIER}.ImageNotification`, + }); + }); + + it('rejects a target name that is not a valid bundle identifier component', () => { + expect(() => + resolveNotificationServiceExtension(config, { + ios: { notificationServiceExtension: { targetName: 'Image_Notification' } }, + }), + ).toThrow(/targetName "Image_Notification"/); + }); + + it('requires the app bundle identifier', () => { + expect(() => + resolveNotificationServiceExtension({}, { ios: { notificationServiceExtension: true } }), + ).toThrow(/expo\.ios\.bundleIdentifier/); + }); + }); + + describe('addNotificationServiceExtension', () => { + it('adds an app extension target embedded in the app target', () => { + const project = addNotificationServiceExtension(readProject(), EXTENSION); + + const [extensionUuid, extension] = findTarget(project, DEFAULT_TARGET_NAME)!; + expect(unquote(extension.productType)).toBe('com.apple.product-type.app-extension'); + + const [, app] = findTarget(project, 'HelloWorld')!; + const embed = phaseOf(project, app, 'PBXCopyFilesBuildPhase'); + expect(embed.dstSubfolderSpec).toBe(13); + const buildFiles = project.pbxBuildFileSection(); + expect( + embed.files.some( + ({ value }: { value: string }) => + buildFiles[value].fileRef === extension.productReference, + ), + ).toBe(true); + + const dependencies = project.hash.project.objects.PBXTargetDependency; + expect( + app.dependencies.some( + ({ value }: { value: string }) => dependencies[value].target === extensionUuid, + ), + ).toBe(true); + }); + + it('embeds the extension in PlugIns when the app target already copies other files', () => { + // `addTarget` puts the product in the project's first "Copy Files" phase, whatever its destination. + const project = readProject(); + const [appUuid] = findTarget(project, 'HelloWorld')!; + project.addBuildPhase([], 'PBXCopyFilesBuildPhase', 'Copy Files', appUuid, 'frameworks'); + addNotificationServiceExtension(project, EXTENSION); + + const [, extension] = findTarget(project, DEFAULT_TARGET_NAME)!; + const [, app] = findTarget(project, 'HelloWorld')!; + const buildFiles = project.pbxBuildFileSection(); + const copyPhases = project.hash.project.objects.PBXCopyFilesBuildPhase; + const embeddingPhases = entries(project, 'PBXCopyFilesBuildPhase').filter(([, phase]) => + phase.files.some( + ({ value }: { value: string }) => + buildFiles[value].fileRef === extension.productReference, + ), + ); + expect(embeddingPhases).toHaveLength(1); + const [[embedUuid, embed]] = embeddingPhases; + expect(embed.dstSubfolderSpec).toBe(13); + expect(embed.files).toHaveLength(1); + expect(app.buildPhases.some(({ value }: { value: string }) => value === embedUuid)).toBe( + true, + ); + expect( + app.buildPhases.filter( + ({ value }: { value: string }) => copyPhases[value]?.dstSubfolderSpec === 13, + ), + ).toHaveLength(1); + }); + + it('compiles NotificationService.swift in the extension', () => { + const project = addNotificationServiceExtension(readProject(), EXTENSION); + + const [, extension] = findTarget(project, DEFAULT_TARGET_NAME)!; + const sources = phaseOf(project, extension, 'PBXSourcesBuildPhase'); + const buildFiles = project.pbxBuildFileSection(); + const fileReferences = project.pbxFileReferenceSection(); + expect( + sources.files.map(({ value }: { value: string }) => + unquote(fileReferences[buildFiles[value].fileRef].path), + ), + ).toEqual([SOURCE_FILE]); + }); + + it('adds no build file for the extension Info.plist', () => { + const project = addNotificationServiceExtension(readProject(), EXTENSION); + + const fileReferences = project.pbxFileReferenceSection(); + const infoPlists = Object.keys(fileReferences).filter( + key => !key.endsWith('_comment') && unquote(fileReferences[key].path) === 'Info.plist', + ); + expect(infoPlists).toHaveLength(1); + expect( + entries(project, 'PBXBuildFile').some( + ([, buildFile]) => buildFile.fileRef === infoPlists[0], + ), + ).toBe(false); + }); + + it('sets the build settings, following the app target', () => { + const project = addNotificationServiceExtension(readProject(), { + ...EXTENSION, + developmentTeam: 'ABCDE12345', + }); + + const [, extension] = findTarget(project, DEFAULT_TARGET_NAME)!; + for (const settings of buildSettings(project, extension)) { + expect(unquote(settings.PRODUCT_BUNDLE_IDENTIFIER)).toBe(EXTENSION.bundleIdentifier); + expect(unquote(settings.INFOPLIST_FILE)).toBe(`${DEFAULT_TARGET_NAME}/Info.plist`); + expect(`${settings.IPHONEOS_DEPLOYMENT_TARGET}`).toBe('16.4'); + expect(`${settings.TARGETED_DEVICE_FAMILY}`).toBe('1'); + expect(settings.DEVELOPMENT_TEAM).toBe('ABCDE12345'); + } + }); + + it('links FirebaseMessaging through SPM at the Firebase Apple SDK version of the app package', () => { + const project = addNotificationServiceExtension(readProject(), EXTENSION); + + const references = entries(project, 'XCRemoteSwiftPackageReference'); + expect(references).toHaveLength(1); + const [packageUuid, reference] = references[0]!; + expect(unquote(reference.repositoryURL)).toBe(appPackageJson.sdkVersions.ios.firebaseSpmUrl); + expect(reference.requirement).toEqual({ + kind: 'exactVersion', + version: appPackageJson.sdkVersions.ios.firebase, + }); + expect( + project + .getFirstProject() + .firstProject.packageReferences.map(({ value }: { value: string }) => value), + ).toEqual([packageUuid]); + + const [, extension] = findTarget(project, DEFAULT_TARGET_NAME)!; + const dependencies = project.hash.project.objects.XCSwiftPackageProductDependency; + const [dependencyUuid] = extension.packageProductDependencies.map( + ({ value }: { value: string }) => value, + ); + expect(dependencies[dependencyUuid]).toMatchObject({ + package: packageUuid, + productName: FIREBASE_MESSAGING_PRODUCT, + }); + + const frameworks = phaseOf(project, extension, 'PBXFrameworksBuildPhase'); + const buildFiles = project.pbxBuildFileSection(); + expect( + frameworks.files.map(({ value }: { value: string }) => buildFiles[value].productRef), + ).toEqual([dependencyUuid]); + }); + + it('reuses the firebase-ios-sdk package reference the project already has', () => { + const project = readProject(); + const objects = project.hash.project.objects; + objects.XCRemoteSwiftPackageReference = { + EXISTINGPACKAGEREFERENCE0: { + isa: 'XCRemoteSwiftPackageReference', + repositoryURL: `"${appPackageJson.sdkVersions.ios.firebaseSpmUrl}"`, + requirement: { kind: 'exactVersion', version: appPackageJson.sdkVersions.ios.firebase }, + }, + }; + project.getFirstProject().firstProject.packageReferences = [ + { value: 'EXISTINGPACKAGEREFERENCE0' }, + ]; + + addNotificationServiceExtension(project, EXTENSION); + + expect(entries(project, 'XCRemoteSwiftPackageReference').map(([uuid]) => uuid)).toEqual([ + 'EXISTINGPACKAGEREFERENCE0', + ]); + expect(project.getFirstProject().firstProject.packageReferences).toHaveLength(1); + expect(entries(project, 'XCSwiftPackageProductDependency')[0]![1].package).toBe( + 'EXISTINGPACKAGEREFERENCE0', + ); + }); + + it('updates the target of a previous prebuild rather than adding another', () => { + const project = addNotificationServiceExtension( + addNotificationServiceExtension(readProject(), EXTENSION), + EXTENSION, + ); + + expect( + entries(project, 'PBXNativeTarget').filter( + ([, target]) => unquote(target.name) === DEFAULT_TARGET_NAME, + ), + ).toHaveLength(1); + expect(entries(project, 'XCRemoteSwiftPackageReference')).toHaveLength(1); + expect(entries(project, 'XCSwiftPackageProductDependency')).toHaveLength(1); + const [, extension] = findTarget(project, DEFAULT_TARGET_NAME)!; + expect(phaseOf(project, extension, 'PBXFrameworksBuildPhase').files).toHaveLength(1); + const [, app] = findTarget(project, 'HelloWorld')!; + expect(app.dependencies).toHaveLength(1); + expect(phaseOf(project, app, 'PBXCopyFilesBuildPhase').files).toHaveLength(1); + }); + + it('writes a project that parses back with the extension', () => { + const project = addNotificationServiceExtension(readProject(), EXTENSION); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'rnfb-messaging-nse-')); + const projectDirectory = path.join(directory, 'ios', 'HelloWorld.xcodeproj'); + fs.mkdirSync(projectDirectory, { recursive: true }); + fs.writeFileSync(path.join(projectDirectory, 'project.pbxproj'), project.writeSync()); + + const written = IOSConfig.XcodeUtils.getPbxproj(directory); + + expect(findTarget(written, DEFAULT_TARGET_NAME)).toBeDefined(); + expect(entries(written, 'XCRemoteSwiftPackageReference')[0]![1].requirement).toEqual({ + kind: 'exactVersion', + version: appPackageJson.sdkVersions.ios.firebase, + }); + fs.rmSync(directory, { recursive: true, force: true }); + }); + }); + + describe('writeNotificationServiceExtensionFiles', () => { + let directory: string; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'rnfb-messaging-nse-')); + }); + + afterEach(() => { + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it('writes the extension source and Info.plist in ios//', () => { + writeNotificationServiceExtensionFiles(directory, EXTENSION, { + version: '2.3.0', + ios: { buildNumber: '42' }, + }); + + const source = fs.readFileSync( + path.join(directory, DEFAULT_TARGET_NAME, SOURCE_FILE), + 'utf8', + ); + expect(source).toContain('import FirebaseMessaging'); + expect(source).toContain('Messaging.serviceExtension().populateNotificationContent'); + + const infoPlist = fs.readFileSync( + path.join(directory, DEFAULT_TARGET_NAME, 'Info.plist'), + 'utf8', + ); + expect(infoPlist).toContain('com.apple.usernotifications.service'); + expect(infoPlist).toContain('$(PRODUCT_MODULE_NAME).NotificationService'); + expect(infoPlist).toContain( + 'CFBundleShortVersionString\n 2.3.0', + ); + expect(infoPlist).toContain('CFBundleVersion\n 42'); + }); + + it('defaults the versions like Expo does for the app', () => { + const infoPlist = notificationServiceInfoPlist({}); + + expect(infoPlist).toContain( + 'CFBundleShortVersionString\n 1.0.0', + ); + expect(infoPlist).toContain('CFBundleVersion\n 1'); + }); + }); + + describe('withEasAppExtension', () => { + it('declares the extension to EAS Build once, keeping the other extras', () => { + const config = { + name: 'HelloWorld', + slug: 'hello-world', + extra: { eas: { projectId: 'project-id' }, other: true }, + } as ExpoConfig; + + const result = withEasAppExtension(withEasAppExtension(config, EXTENSION), EXTENSION); + + expect(result.extra).toEqual({ + eas: { + projectId: 'project-id', + build: { experimental: { ios: { appExtensions: [EXTENSION] } } }, + }, + other: true, + }); + }); + }); + + describe('withRnFirebaseMessaging', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + // The Android step warns when no notification icon is configured; keep test output quiet. + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + const baseConfig = (): ExpoConfig => + ({ + name: 'HelloWorld', + slug: 'hello-world', + ios: { bundleIdentifier: BUNDLE_IDENTIFIER }, + _internal: { projectRoot: '/tmp/project' }, + }) as ExpoConfig; + + it('adds the extension when ios.notificationServiceExtension is set', () => { + const config = withRnFirebaseMessaging(baseConfig(), { + ios: { notificationServiceExtension: true }, + }); + + expect((config as any).mods?.ios?.xcodeproj).toBeDefined(); + expect((config as any).mods?.ios?.dangerous).toBeDefined(); + expect(config.extra?.eas?.build?.experimental?.ios?.appExtensions).toEqual([EXTENSION]); + }); + + it('adds nothing on iOS when ios.notificationServiceExtension is omitted', () => { + const config = withRnFirebaseMessaging(baseConfig(), {}); + + expect((config as any).mods?.ios?.xcodeproj).toBeUndefined(); + expect((config as any).mods?.ios?.dangerous).toBeUndefined(); + expect(config.extra).toBeUndefined(); + }); + }); +}); diff --git a/packages/messaging/plugin/src/index.ts b/packages/messaging/plugin/src/index.ts index 9079e1ff22..dd835ebf07 100644 --- a/packages/messaging/plugin/src/index.ts +++ b/packages/messaging/plugin/src/index.ts @@ -1,5 +1,6 @@ import { ConfigPlugin, withPlugins, createRunOncePlugin } from '@expo/config-plugins'; import { withExpoPluginFirebaseNotification } from './android'; +import { withNotificationServiceExtensionIos } from './ios/notificationServiceExtension'; import { withExpoPluginInstallationIdIos } from './ios/setupInstallationId'; import { PluginConfigType } from './pluginConfig'; @@ -10,6 +11,7 @@ const withRnFirebaseMessaging: ConfigPlugin = (con return withPlugins(config, [ // iOS [withExpoPluginInstallationIdIos, props], + [withNotificationServiceExtensionIos, props], // Android [withExpoPluginFirebaseNotification, props], ]); diff --git a/packages/messaging/plugin/src/ios/notificationServiceExtension.ts b/packages/messaging/plugin/src/ios/notificationServiceExtension.ts new file mode 100644 index 0000000000..e9f55497b2 --- /dev/null +++ b/packages/messaging/plugin/src/ios/notificationServiceExtension.ts @@ -0,0 +1,442 @@ +import { + ConfigPlugin, + withDangerousMod, + withXcodeProject, + XcodeProject, +} from '@expo/config-plugins'; +import type { ExpoConfig } from '@expo/config-types'; +import fs from 'fs'; +import path from 'path'; +import { PluginConfigType } from '../pluginConfig'; + +const appPackageJson = require('@react-native-firebase/app/package.json'); + +export const DEFAULT_TARGET_NAME = 'NotificationServiceExtension'; +export const SOURCE_FILE = 'NotificationService.swift'; +export const INFO_PLIST_FILE = 'Info.plist'; +export const FIREBASE_MESSAGING_PRODUCT = 'FirebaseMessaging'; + +const firebaseSpmUrl: string = appPackageJson.sdkVersions.ios.firebaseSpmUrl; +const firebaseVersion: string = appPackageJson.sdkVersions.ios.firebase; +const iosTarget: string = appPackageJson.sdkVersions.ios.iosTarget; +const PACKAGE_REFERENCE_COMMENT = 'XCRemoteSwiftPackageReference "firebase-ios-sdk"'; + +export interface NotificationServiceExtension { + targetName: string; + bundleIdentifier: string; +} + +/** + * Resolves the `ios.notificationServiceExtension` prop. Returns `null` when the extension is not requested. + */ +export function resolveNotificationServiceExtension( + config: Pick, + props: PluginConfigType | undefined, +): NotificationServiceExtension | null { + const option = props?.ios?.notificationServiceExtension; + if (!option) { + return null; + } + + const targetName = (option === true ? undefined : option.targetName) ?? DEFAULT_TARGET_NAME; + // The target name is also a bundle identifier component, which only allows letters, digits and hyphens. + if (!/^[A-Za-z][A-Za-z0-9-]*$/.test(targetName)) { + throw new Error( + `[@react-native-firebase/messaging] ios.notificationServiceExtension.targetName "${targetName}" must start with a letter and contain only letters, digits and hyphens.`, + ); + } + + const appBundleIdentifier = config.ios?.bundleIdentifier; + if (!appBundleIdentifier) { + throw new Error( + '[@react-native-firebase/messaging] ios.notificationServiceExtension requires `expo.ios.bundleIdentifier`, the extension bundle identifier is derived from it.', + ); + } + + return { targetName, bundleIdentifier: `${appBundleIdentifier}.${targetName}` }; +} + +export const notificationServiceSource = `import FirebaseMessaging +import UserNotifications + +class NotificationService: UNNotificationServiceExtension { + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent + + guard let bestAttemptContent else { + contentHandler(request.content) + return + } + Messaging.serviceExtension().populateNotificationContent(bestAttemptContent, withContentHandler: contentHandler) + } + + override func serviceExtensionTimeWillExpire() { + if let contentHandler, let bestAttemptContent { + contentHandler(bestAttemptContent) + } + } +} +`; + +const escapeXml = (value: string) => + value.replace(/&/g, '&').replace(//g, '>'); + +/** + * The extension's Info.plist. Its versions match the app's, which App Store Connect requires of an embedded extension. + */ +export function notificationServiceInfoPlist(config: Pick): string { + const version = escapeXml(config.version ?? '1.0.0'); + const buildNumber = escapeXml(config.ios?.buildNumber ?? '1'); + + return ` + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(PRODUCT_NAME) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + ${version} + CFBundleVersion + ${buildNumber} + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + +`; +} + +const unquote = (value: unknown) => + typeof value === 'string' ? value.replace(/^"(.*)"$/, '$1') : value; + +const isUuid = (key: string) => !key.endsWith('_comment'); + +function findNativeTarget(project: XcodeProject, predicate: (target: any) => boolean) { + const section = project.pbxNativeTargetSection(); + const uuid = Object.keys(section).find(key => isUuid(key) && predicate(section[key])); + return uuid ? { uuid, target: section[uuid] } : null; +} + +function buildConfigurations(project: XcodeProject, target: any): any[] { + const objects = project.hash.project.objects; + const list = objects.XCConfigurationList[target.buildConfigurationList]; + return list.buildConfigurations.map( + ({ value }: { value: string }) => objects.XCBuildConfiguration[value], + ); +} + +/** + * The `firebase-ios-sdk` package reference of the project, created at the Firebase Apple SDK version of + * `@react-native-firebase/app` when missing. An existing reference is reused: `pod install` links + * `FirebaseCore` into the app target through the same reference and pins its version. + */ +function ensureFirebasePackageReference(project: XcodeProject): string { + const objects = project.hash.project.objects; + objects.XCRemoteSwiftPackageReference ??= {}; + const references = objects.XCRemoteSwiftPackageReference; + + const existing = Object.keys(references).find( + key => isUuid(key) && unquote(references[key].repositoryURL) === firebaseSpmUrl, + ); + if (existing) { + return existing; + } + + const uuid = project.generateUuid(); + references[uuid] = { + isa: 'XCRemoteSwiftPackageReference', + repositoryURL: `"${firebaseSpmUrl}"`, + requirement: { kind: 'exactVersion', version: firebaseVersion }, + }; + references[`${uuid}_comment`] = PACKAGE_REFERENCE_COMMENT; + + const rootProject = project.getFirstProject().firstProject; + rootProject.packageReferences ??= []; + rootProject.packageReferences.push({ value: uuid, comment: PACKAGE_REFERENCE_COMMENT }); + + return uuid; +} + +/** Links the `FirebaseMessaging` package product into the extension target, unless it already is. */ +function linkFirebaseMessaging(project: XcodeProject, targetUuid: string, target: any) { + const objects = project.hash.project.objects; + objects.XCSwiftPackageProductDependency ??= {}; + const dependencies = objects.XCSwiftPackageProductDependency; + + target.packageProductDependencies ??= []; + let dependencyUuid: string | undefined = target.packageProductDependencies + .map(({ value }: { value: string }) => value) + .find( + (uuid: string) => unquote(dependencies[uuid]?.productName) === FIREBASE_MESSAGING_PRODUCT, + ); + + if (!dependencyUuid) { + const packageUuid = ensureFirebasePackageReference(project); + dependencyUuid = project.generateUuid(); + dependencies[dependencyUuid] = { + isa: 'XCSwiftPackageProductDependency', + package: packageUuid, + package_comment: PACKAGE_REFERENCE_COMMENT, + productName: FIREBASE_MESSAGING_PRODUCT, + }; + dependencies[`${dependencyUuid}_comment`] = FIREBASE_MESSAGING_PRODUCT; + target.packageProductDependencies.push({ + value: dependencyUuid, + comment: FIREBASE_MESSAGING_PRODUCT, + }); + } + + const frameworksPhases = objects.PBXFrameworksBuildPhase ?? {}; + const frameworksPhaseUuid = target.buildPhases + .map(({ value }: { value: string }) => value) + .find((uuid: string) => frameworksPhases[uuid]); + const frameworksPhase = frameworksPhaseUuid + ? frameworksPhases[frameworksPhaseUuid] + : project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetUuid).buildPhase; + + const buildFiles = project.pbxBuildFileSection(); + if ( + frameworksPhase.files.some( + ({ value }: { value: string }) => buildFiles[value]?.productRef === dependencyUuid, + ) + ) { + return; + } + + const buildFileUuid = project.generateUuid(); + buildFiles[buildFileUuid] = { + isa: 'PBXBuildFile', + productRef: dependencyUuid, + productRef_comment: FIREBASE_MESSAGING_PRODUCT, + }; + buildFiles[`${buildFileUuid}_comment`] = `${FIREBASE_MESSAGING_PRODUCT} in Frameworks`; + frameworksPhase.files.push({ + value: buildFileUuid, + comment: `${FIREBASE_MESSAGING_PRODUCT} in Frameworks`, + }); +} + +/** `dstSubfolderSpec` of a Copy Files phase whose destination is PlugIns, where app extensions are embedded. */ +const PLUGINS_FOLDER = 13; + +/** + * Embeds the extension's product in the app target's PlugIns phase, and in no other Copy Files phase: `addTarget` + * puts it in the first phase named "Copy Files" of the project, whatever its target and destination. + */ +function embedInApp(project: XcodeProject, app: { uuid: string; target: any }, extension: any) { + const copyPhases = project.hash.project.objects.PBXCopyFilesBuildPhase ?? {}; + const buildFiles = project.pbxBuildFileSection(); + const isProduct = ({ value }: { value: string }) => + buildFiles[value]?.fileRef === extension.productReference; + + const embedUuid = + app.target.buildPhases + .map(({ value }: { value: string }) => value) + .find((uuid: string) => Number(copyPhases[uuid]?.dstSubfolderSpec) === PLUGINS_FOLDER) ?? + project.addBuildPhase( + [], + 'PBXCopyFilesBuildPhase', + 'Embed Foundation Extensions', + app.uuid, + 'app_extension', + ).uuid; + + // Moves the product's build files out of the other phases, keeping one in the PlugIns phase. + const moved: { value: string; comment: string }[] = []; + for (const uuid of Object.keys(copyPhases).filter(key => isUuid(key) && key !== embedUuid)) { + const phase = copyPhases[uuid]; + moved.push(...phase.files.filter(isProduct)); + phase.files = phase.files.filter((file: { value: string }) => !isProduct(file)); + } + // Read again: `addBuildPhase` creates the section when the project has none. + const embed = project.hash.project.objects.PBXCopyFilesBuildPhase[embedUuid]; + if (!embed.files.some(isProduct) && moved.length) { + embed.files.push(moved.shift()!); + } + for (const { value } of moved) { + delete buildFiles[value]; + delete buildFiles[`${value}_comment`]; + } +} + +/** + * Adds the Notification Service Extension target to the Xcode project, embedded in the app target, or updates + * the one a previous prebuild added. Expects the files written by {@link writeNotificationServiceExtensionFiles}. + */ +export function addNotificationServiceExtension( + project: XcodeProject, + { + targetName, + bundleIdentifier, + developmentTeam, + }: NotificationServiceExtension & { developmentTeam?: string }, +): XcodeProject { + const objects = project.hash.project.objects; + let extension = findNativeTarget(project, target => unquote(target.name) === targetName); + + if (!extension) { + // `addTarget` adds the target dependency of the app target, in sections a fresh project may not have yet. + objects.PBXTargetDependency ??= {}; + objects.PBXContainerItemProxy ??= {}; + + const group = project.addPbxGroup([SOURCE_FILE, INFO_PLIST_FILE], targetName, targetName); + project.addToPbxGroup(group.uuid, project.getFirstProject().firstProject.mainGroup); + // `addPbxGroup` also adds a build file for each file, which the Info.plist must not have. + const infoPlistReference = group.pbxGroup.children.find( + ({ comment }: { comment: string }) => comment === INFO_PLIST_FILE, + )?.value; + const buildFiles = project.pbxBuildFileSection(); + for (const key of Object.keys(buildFiles)) { + if (isUuid(key) && buildFiles[key].fileRef === infoPlistReference) { + delete buildFiles[key]; + delete buildFiles[`${key}_comment`]; + } + } + + // An `app_extension` target is embedded in the first target, the app target of an Expo project. + const target = project.addTarget(targetName, 'app_extension', targetName, bundleIdentifier); + project.addBuildPhase([SOURCE_FILE], 'PBXSourcesBuildPhase', 'Sources', target.uuid); + project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid); + project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid); + extension = { uuid: target.uuid, target: target.pbxNativeTarget }; + } + + const app = findNativeTarget( + project, + target => unquote(target.productType) === 'com.apple.product-type.application', + ); + const appBuildSettings = (name: string) => + app + ? buildConfigurations(project, app.target).find(config => config.name === name)?.buildSettings + : undefined; + + if (app) { + embedInApp(project, app, extension.target); + } + + for (const config of buildConfigurations(project, extension.target)) { + const settings = config.buildSettings; + const appSettings = appBuildSettings(config.name) ?? {}; + + settings.INFOPLIST_FILE = `"${targetName}/${INFO_PLIST_FILE}"`; + settings.PRODUCT_BUNDLE_IDENTIFIER = `"${bundleIdentifier}"`; + settings.PRODUCT_NAME = '"$(TARGET_NAME)"'; + settings.SWIFT_VERSION = '5.0'; + settings.CLANG_ENABLE_MODULES = 'YES'; + settings.CODE_SIGN_STYLE = 'Automatic'; + settings.IPHONEOS_DEPLOYMENT_TARGET = appSettings.IPHONEOS_DEPLOYMENT_TARGET ?? iosTarget; + settings.TARGETED_DEVICE_FAMILY = appSettings.TARGETED_DEVICE_FAMILY ?? '"1,2"'; + if (developmentTeam) { + settings.DEVELOPMENT_TEAM = developmentTeam; + } + } + + linkFirebaseMessaging(project, extension.uuid, extension.target); + + return project; +} + +/** Writes the extension's source and Info.plist to `ios//`, replacing those of a previous prebuild. */ +export function writeNotificationServiceExtensionFiles( + platformProjectRoot: string, + { targetName }: NotificationServiceExtension, + config: Pick, +) { + const directory = path.join(platformProjectRoot, targetName); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, SOURCE_FILE), notificationServiceSource); + fs.writeFileSync(path.join(directory, INFO_PLIST_FILE), notificationServiceInfoPlist(config)); +} + +/** + * Declares the extension to EAS Build, which provisions and signs the targets it is told about before prebuild. + * See https://docs.expo.dev/build-reference/app-extensions/ + */ +export function withEasAppExtension( + config: ExpoConfig, + extension: NotificationServiceExtension, +): ExpoConfig { + const eas = config.extra?.eas ?? {}; + const appExtensions: { targetName: string }[] = eas.build?.experimental?.ios?.appExtensions ?? []; + if (appExtensions.some(({ targetName }) => targetName === extension.targetName)) { + return config; + } + + config.extra = { + ...config.extra, + eas: { + ...eas, + build: { + ...eas.build, + experimental: { + ...eas.build?.experimental, + ios: { + ...eas.build?.experimental?.ios, + appExtensions: [...appExtensions, extension], + }, + }, + }, + }, + }; + return config; +} + +/** + * Adds a Notification Service Extension target when `ios.notificationServiceExtension` is set, so FCM + * notifications display their image. `FirebaseMessaging` is linked through Swift Package Manager, the only + * distribution of the Firebase Apple SDK since 13.0.0, at the version `@react-native-firebase/app` pins. + */ +export const withNotificationServiceExtensionIos: ConfigPlugin = ( + config, + props, +) => { + const extension = resolveNotificationServiceExtension(config, props); + if (!extension) { + return config; + } + + config = withEasAppExtension(config, extension); + + config = withDangerousMod(config, [ + 'ios', + async config => { + writeNotificationServiceExtensionFiles( + config.modRequest.platformProjectRoot, + extension, + config, + ); + return config; + }, + ]); + + return withXcodeProject(config, config => { + config.modResults = addNotificationServiceExtension(config.modResults, { + ...extension, + developmentTeam: config.ios?.appleTeamId, + }); + return config; + }); +}; diff --git a/packages/messaging/plugin/src/pluginConfig.ts b/packages/messaging/plugin/src/pluginConfig.ts index 88effdace1..d052eef0a4 100644 --- a/packages/messaging/plugin/src/pluginConfig.ts +++ b/packages/messaging/plugin/src/pluginConfig.ts @@ -8,9 +8,29 @@ export interface PluginConfigType { */ installationIdEnabled?: boolean; android?: PluginConfigTypeAndroid; + ios?: PluginConfigTypeIos; } export interface PluginConfigTypeAndroid { notificationIcon?: string; notificationColor?: string; } + +export interface PluginConfigTypeIos { + /** + * Adds a Notification Service Extension target that displays the image of FCM notifications + * (`Messaging.serviceExtension().populateNotificationContent`), linking `FirebaseMessaging` + * through Swift Package Manager at the Firebase Apple SDK version of `@react-native-firebase/app`. + * + * `true` uses the defaults of {@link NotificationServiceExtensionConfig}. Omitted or `false` adds nothing. + */ + notificationServiceExtension?: boolean | NotificationServiceExtensionConfig; +} + +export interface NotificationServiceExtensionConfig { + /** + * Name of the extension target, also its folder in `ios/` and the last component of its bundle + * identifier (`.`). Defaults to `NotificationServiceExtension`. + */ + targetName?: string; +} diff --git a/test-expo/app.json b/test-expo/app.json index 099fad6e34..e5d98a7f27 100644 --- a/test-expo/app.json +++ b/test-expo/app.json @@ -20,7 +20,14 @@ "expo-router", "expo-dev-client", "@react-native-firebase/app", - "@react-native-firebase/messaging", + [ + "@react-native-firebase/messaging", + { + "ios": { + "notificationServiceExtension": true + } + } + ], "@react-native-firebase/analytics", "@react-native-firebase/app-check", "@react-native-firebase/auth",