From 95e52ed42d5f8f13423c8f4c9fd9bba4559cfce1 Mon Sep 17 00:00:00 2001 From: Emir Muminoglu Date: Sun, 15 Dec 2024 21:29:14 +0300 Subject: [PATCH] feat(ios):) add notification service extension target for image support in notifications --- packages/messaging/plugin/src/index.ts | 25 +- .../messaging/plugin/src/ios/constants.ts | 27 ++ packages/messaging/plugin/src/ios/fsutils.ts | 38 +++ packages/messaging/plugin/src/ios/index.ts | 13 + .../NotificationService.h | 5 + .../NotificationService.m | 25 ++ ...aseNotificationServiceExtension-Info.plist | 31 ++ ...eNotificationServiceExtension.entitlements | 10 + .../ios/setupNotificationServiceExtension.ts | 266 ++++++++++++++++++ 9 files changed, 433 insertions(+), 7 deletions(-) create mode 100644 packages/messaging/plugin/src/ios/constants.ts create mode 100644 packages/messaging/plugin/src/ios/fsutils.ts create mode 100644 packages/messaging/plugin/src/ios/index.ts create mode 100644 packages/messaging/plugin/src/ios/serviceExtensionFiles/NotificationService.h create mode 100644 packages/messaging/plugin/src/ios/serviceExtensionFiles/NotificationService.m create mode 100644 packages/messaging/plugin/src/ios/serviceExtensionFiles/RNFirebaseNotificationServiceExtension-Info.plist create mode 100644 packages/messaging/plugin/src/ios/serviceExtensionFiles/RNFirebaseNotificationServiceExtension.entitlements create mode 100644 packages/messaging/plugin/src/ios/setupNotificationServiceExtension.ts diff --git a/packages/messaging/plugin/src/index.ts b/packages/messaging/plugin/src/index.ts index 7b46e739da..68ee4dce69 100644 --- a/packages/messaging/plugin/src/index.ts +++ b/packages/messaging/plugin/src/index.ts @@ -1,16 +1,27 @@ -import { ConfigPlugin, withPlugins, createRunOncePlugin } from '@expo/config-plugins'; +import { ConfigPlugin, createRunOncePlugin } from '@expo/config-plugins'; import { withExpoPluginFirebaseNotification } from './android'; +import { + withAppEnvironment, + withNotificationServiceExtension, + withRNFirebaseXcodeProject, +} from './ios'; +import { PluginProps, withEasManagedCredentials } from './ios/setupNotificationServiceExtension'; /** * A config plugin for configuring `@react-native-firebase/app` */ -const withRnFirebaseApp: ConfigPlugin = config => { - return withPlugins(config, [ - // iOS +const withRnFirebaseApp: ConfigPlugin = (config, props) => { + // iOS + if (props.installNSE) { + config = withAppEnvironment(config, props); + config = withNotificationServiceExtension(config, props); + config = withRNFirebaseXcodeProject(config, props); + config = withEasManagedCredentials(config, props); + } - // Android - withExpoPluginFirebaseNotification, - ]); + // Android + config = withExpoPluginFirebaseNotification(config); + return config; }; const pak = require('@react-native-firebase/messaging/package.json'); diff --git a/packages/messaging/plugin/src/ios/constants.ts b/packages/messaging/plugin/src/ios/constants.ts new file mode 100644 index 0000000000..10cb21bc1b --- /dev/null +++ b/packages/messaging/plugin/src/ios/constants.ts @@ -0,0 +1,27 @@ +export const IPHONEOS_DEPLOYMENT_TARGET = '11.0'; +export const TARGETED_DEVICE_FAMILY = `"1,2"`; + +export const NSE_PODFILE_SNIPPET = ` +target 'RNFirebaseNotificationServiceExtension' do + use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] + pod 'GoogleUtilities' + pod 'Firebase/Messaging' +end +`; + +export const NSE_PODFILE_REGEX = /target 'RNFirebaseNotificationServiceExtension'/; + +export const GROUP_IDENTIFIER_TEMPLATE_REGEX = /{{GROUP_IDENTIFIER}}/gm; +export const BUNDLE_SHORT_VERSION_TEMPLATE_REGEX = /{{BUNDLE_SHORT_VERSION}}/gm; +export const BUNDLE_VERSION_TEMPLATE_REGEX = /{{BUNDLE_VERSION}}/gm; + +export const DEFAULT_BUNDLE_VERSION = '1'; +export const DEFAULT_BUNDLE_SHORT_VERSION = '1.0'; + +export const NSE_TARGET_NAME = 'RNFirebaseNotificationServiceExtension'; +export const NSE_SOURCE_FILE = 'NotificationService.m'; +export const NSE_EXT_FILES = [ + 'NotificationService.h', + `${NSE_TARGET_NAME}.entitlements`, + `${NSE_TARGET_NAME}-Info.plist`, +]; diff --git a/packages/messaging/plugin/src/ios/fsutils.ts b/packages/messaging/plugin/src/ios/fsutils.ts new file mode 100644 index 0000000000..23fecb624b --- /dev/null +++ b/packages/messaging/plugin/src/ios/fsutils.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs'; + +export async function readFile(path: string): Promise { + return new Promise((resolve, reject) => { + fs.readFile(path, 'utf8', (err, data) => { + if (err || !data) { + // eslint-disable-next-line no-console + console.error("Couldn't read file:" + path); + reject(err); + return; + } + resolve(data); + }); + }); +} + +export async function writeFile(path: string, contents: string): Promise { + return new Promise((resolve, reject) => { + fs.writeFile(path, contents, 'utf8', err => { + if (err) { + // eslint-disable-next-line no-console + console.error("Couldn't write file:" + path); + reject(err); + return; + } + resolve(); + }); + }); +} + +export async function copyFile(path1: string, path2: string): Promise { + const fileContents = await readFile(path1); + await writeFile(path2, fileContents); +} + +export function dirExists(path: string): boolean { + return fs.existsSync(path); +} diff --git a/packages/messaging/plugin/src/ios/index.ts b/packages/messaging/plugin/src/ios/index.ts new file mode 100644 index 0000000000..eb10c50799 --- /dev/null +++ b/packages/messaging/plugin/src/ios/index.ts @@ -0,0 +1,13 @@ +import { + withNotificationServiceExtension, + withRNFirebaseXcodeProject, + withAppEnvironment, + withEasManagedCredentials, +} from './setupNotificationServiceExtension'; + +export { + withNotificationServiceExtension, + withRNFirebaseXcodeProject, + withAppEnvironment, + withEasManagedCredentials, +}; diff --git a/packages/messaging/plugin/src/ios/serviceExtensionFiles/NotificationService.h b/packages/messaging/plugin/src/ios/serviceExtensionFiles/NotificationService.h new file mode 100644 index 0000000000..32d6298754 --- /dev/null +++ b/packages/messaging/plugin/src/ios/serviceExtensionFiles/NotificationService.h @@ -0,0 +1,5 @@ +#import + +@interface NotificationService : UNNotificationServiceExtension + +@end diff --git a/packages/messaging/plugin/src/ios/serviceExtensionFiles/NotificationService.m b/packages/messaging/plugin/src/ios/serviceExtensionFiles/NotificationService.m new file mode 100644 index 0000000000..e008493fe9 --- /dev/null +++ b/packages/messaging/plugin/src/ios/serviceExtensionFiles/NotificationService.m @@ -0,0 +1,25 @@ +#import "NotificationService.h" +#import "FirebaseMessaging.h" + +@interface NotificationService () + +@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver); +@property (nonatomic, strong) UNNotificationRequest *receivedRequest; +@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent; + +@end + +@implementation NotificationService + +- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler { + self.contentHandler = contentHandler; + self.bestAttemptContent = [request.content mutableCopy]; + + [[FIRMessaging extensionHelper] populateNotificationContent:self.bestAttemptContent withContentHandler:contentHandler]; +} + +- (void)serviceExtensionTimeWillExpire { + self.contentHandler(self.bestAttemptContent); +} + +@end diff --git a/packages/messaging/plugin/src/ios/serviceExtensionFiles/RNFirebaseNotificationServiceExtension-Info.plist b/packages/messaging/plugin/src/ios/serviceExtensionFiles/RNFirebaseNotificationServiceExtension-Info.plist new file mode 100644 index 0000000000..98bd98511f --- /dev/null +++ b/packages/messaging/plugin/src/ios/serviceExtensionFiles/RNFirebaseNotificationServiceExtension-Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + RNFirebaseNotificationServiceExtension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + {{BUNDLE_SHORT_VERSION}} + CFBundleVersion + {{BUNDLE_VERSION}} + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + NotificationService + + + \ No newline at end of file diff --git a/packages/messaging/plugin/src/ios/serviceExtensionFiles/RNFirebaseNotificationServiceExtension.entitlements b/packages/messaging/plugin/src/ios/serviceExtensionFiles/RNFirebaseNotificationServiceExtension.entitlements new file mode 100644 index 0000000000..d15e8791c8 --- /dev/null +++ b/packages/messaging/plugin/src/ios/serviceExtensionFiles/RNFirebaseNotificationServiceExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + {{GROUP_IDENTIFIER}} + + + diff --git a/packages/messaging/plugin/src/ios/setupNotificationServiceExtension.ts b/packages/messaging/plugin/src/ios/setupNotificationServiceExtension.ts new file mode 100644 index 0000000000..452d9b7ec4 --- /dev/null +++ b/packages/messaging/plugin/src/ios/setupNotificationServiceExtension.ts @@ -0,0 +1,266 @@ +import * as path from 'path'; +import { + ConfigPlugin, + withDangerousMod, + withEntitlementsPlist, + withXcodeProject, +} from '@expo/config-plugins'; +import * as fs from 'fs'; +import { copyFile, readFile, writeFile } from './fsutils'; +import { + BUNDLE_SHORT_VERSION_TEMPLATE_REGEX, + BUNDLE_VERSION_TEMPLATE_REGEX, + DEFAULT_BUNDLE_SHORT_VERSION, + DEFAULT_BUNDLE_VERSION, + GROUP_IDENTIFIER_TEMPLATE_REGEX, + IPHONEOS_DEPLOYMENT_TARGET, + NSE_EXT_FILES, + NSE_PODFILE_REGEX, + NSE_PODFILE_SNIPPET, + NSE_SOURCE_FILE, + NSE_TARGET_NAME, + TARGETED_DEVICE_FAMILY, +} from './constants'; +import { ExpoConfig } from '@expo/config-types'; +import { mergeContents } from '@expo/config-plugins/build/utils/generateCode'; +export type PluginProps = { + installNSE?: boolean; + /** + * (required) Used to configure APNs environment entitlement. "development" or "production" + */ + mode: Mode; + + /** + * (optional) Used to configure Apple Team ID. You can find your Apple Team ID by running expo credentials:manager e.g: "91SW8A37CR" + */ + devTeam?: string; + + /** + * (optional) Target IPHONEOS_DEPLOYMENT_TARGET value to be used when adding the iOS NSE. A deployment target is nothing more than + * the minimum version of the operating system the application can run on. This value should match the value in your Podfile e.g: "12.0". + */ + iPhoneDeploymentTarget?: string; +}; + +export enum Mode { + Dev = 'development', + Prod = 'production', +} + +const entitlementsFileName = `RNFirebaseNotificationServiceExtension.entitlements`; +const plistFileName = `RNFirebaseNotificationServiceExtension-Info.plist`; + +async function updateNSEEntitlements(nsePath: string, groupIdentifier: string): Promise { + const entitlementsFilePath = `${nsePath}/${entitlementsFileName}`; + let entitlementsFile = await readFile(entitlementsFilePath); + entitlementsFile = entitlementsFile.replace(GROUP_IDENTIFIER_TEMPLATE_REGEX, groupIdentifier); + await writeFile(entitlementsFilePath, entitlementsFile); +} + +async function updateNSEBundleVersion(nsePath: string, version: string): Promise { + const plistFilePath = `${nsePath}/${plistFileName}`; + let plistFile = await readFile(plistFilePath); + plistFile = plistFile.replace(BUNDLE_VERSION_TEMPLATE_REGEX, version); + await writeFile(plistFilePath, plistFile); +} + +async function updateNSEBundleShortVersion(nsePath: string, version: string): Promise { + const plistFilePath = `${nsePath}/${plistFileName}`; + let plistFile = await readFile(plistFilePath); + plistFile = plistFile.replace(BUNDLE_SHORT_VERSION_TEMPLATE_REGEX, version); + await writeFile(plistFilePath, plistFile); +} + +async function updatePodfile(iosPath: string): Promise { + const podfile = await readFile(`${iosPath}/Podfile`); + const matches = podfile.match(NSE_PODFILE_REGEX); + + if (matches) { + // eslint-disable-next-line no-console + console.log( + 'RNFirebaseNotificationServiceExtension target already added to Podfile. Skipping...', + ); + } else { + fs.appendFile(`${iosPath}/Podfile`, NSE_PODFILE_SNIPPET, err => { + if (err) { + // eslint-disable-next-line no-console + console.error('Error writing to Podfile'); + } + }); + } + + const result = mergeContents({ + src: await readFile(`${iosPath}/Podfile`), + tag: '@react-native-firebase/messaging', + newSrc: ` pod 'GoogleUtilities'\n pod 'Firebase/Messaging'`, + comment: '#', + anchor: 'use_native_modules!', + offset: 0, + }) + writeFile(`${iosPath}/Podfile`, result.contents); +} + +function getEasManagedCredentialsConfigExtra(config: ExpoConfig): { [k: string]: any } { + return { + ...config.extra, + eas: { + ...config.extra?.eas, + build: { + ...config.extra?.eas?.build, + experimental: { + ...config.extra?.eas?.build?.experimental, + ios: { + ...config.extra?.eas?.build?.experimental?.ios, + appExtensions: [ + ...(config.extra?.eas?.build?.experimental?.ios?.appExtensions ?? []), + { + // keep in sync with native changes in NSE + targetName: NSE_TARGET_NAME, + bundleIdentifier: `${config?.ios?.bundleIdentifier}.${NSE_TARGET_NAME}`, + entitlements: { + 'com.apple.security.application-groups': [ + `group.${config?.ios?.bundleIdentifier}.rnfirebase`, + ], + }, + }, + ], + }, + }, + }, + }, + }; +} + +export const withEasManagedCredentials: ConfigPlugin = config => { + config.extra = getEasManagedCredentialsConfigExtra(config as ExpoConfig); + return config; +}; + +export const withAppEnvironment: ConfigPlugin = (config, props) => { + return withEntitlementsPlist(config, newConfig => { + if (props?.mode == null) { + throw new Error(` + Missing required "mode" key in your app.json or app.config.js file for "@react-native-firebase/messaging". + "mode" can be either "development" or "production". + `); + } + newConfig.modResults['aps-environment'] = props.mode; + return newConfig; + }); +}; + +export const withRNFirebaseXcodeProject: ConfigPlugin = (config, props) => { + return withXcodeProject(config, newConfig => { + const xcodeProject = newConfig.modResults; + + if (!!xcodeProject.pbxTargetByName(NSE_TARGET_NAME)) { + // eslint-disable-next-line no-console + console.log(`${NSE_TARGET_NAME} already exists in project. Skipping...`); + return newConfig; + } + + // Create new PBXGroup for the extension + const extGroup = xcodeProject.addPbxGroup( + [...NSE_EXT_FILES, NSE_SOURCE_FILE], + NSE_TARGET_NAME, + NSE_TARGET_NAME, + ); + + // Add the new PBXGroup to the top level group. This makes the + // files / folder appear in the file explorer in Xcode. + const groups = xcodeProject.hash.project.objects['PBXGroup']; + // biome-ignore lint/complexity/noForEach: + Object.keys(groups).forEach(key => { + if ( + typeof groups[key] === 'object' && + groups[key].name === undefined && + groups[key].path === undefined + ) { + xcodeProject.addToPbxGroup(extGroup.uuid, key); + } + }); + + // WORK AROUND for codeProject.addTarget BUG + // Xcode projects don't contain these if there is only one target + // An upstream fix should be made to the code referenced in this link: + // - https://github.com/apache/cordova-node-xcode/blob/8b98cabc5978359db88dc9ff2d4c015cba40f150/lib/pbxProject.js#L860 + const projObjects = xcodeProject.hash.project.objects; + projObjects['PBXTargetDependency'] = projObjects['PBXTargetDependency'] || {}; + projObjects['PBXContainerItemProxy'] = projObjects['PBXTargetDependency'] || {}; + + // Add the NSE target + // This adds PBXTargetDependency and PBXContainerItemProxy for you + const nseTarget = xcodeProject.addTarget( + NSE_TARGET_NAME, + 'app_extension', + NSE_TARGET_NAME, + `${config.ios?.bundleIdentifier}.${NSE_TARGET_NAME}`, + ); + + // Add build phases to the new target + xcodeProject.addBuildPhase( + ['NotificationService.m'], + 'PBXSourcesBuildPhase', + 'Sources', + nseTarget.uuid, + ); + xcodeProject.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', nseTarget.uuid); + + xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', nseTarget.uuid); + + // Edit the Deployment info of the new Target, only IphoneOS and Targeted Device Family + // However, can be more + const configurations = xcodeProject.pbxXCBuildConfigurationSection(); + for (const key in configurations) { + if ( + typeof configurations[key].buildSettings !== 'undefined' && + configurations[key].buildSettings.PRODUCT_NAME === `"${NSE_TARGET_NAME}"` + ) { + const buildSettingsObj = configurations[key].buildSettings; + buildSettingsObj.DEVELOPMENT_TEAM = props?.devTeam; + buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = + props?.iPhoneDeploymentTarget ?? IPHONEOS_DEPLOYMENT_TARGET; + buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = IPHONEOS_DEPLOYMENT_TARGET; + buildSettingsObj.TARGETED_DEVICE_FAMILY = TARGETED_DEVICE_FAMILY; + buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `${NSE_TARGET_NAME}/${NSE_TARGET_NAME}.entitlements`; + buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'; + } + } + + xcodeProject.addTargetAttribute('DevelopmentTeam', props?.devTeam, nseTarget); + xcodeProject.addTargetAttribute('DevelopmentTeam', props?.devTeam); + + return newConfig; + }); +}; + +export const withNotificationServiceExtension: ConfigPlugin = config => { + const pluginDir = require.resolve('@react-native-firebase/messaging/package.json'); + const sourceDir = path.join(pluginDir, '../plugin/src/ios/serviceExtensionFiles/'); + + return withDangerousMod(config, [ + 'ios', + async config => { + const iosPath = path.join(config.modRequest.projectRoot, 'ios'); + await updatePodfile(iosPath); + fs.mkdirSync(`${iosPath}/${NSE_TARGET_NAME}`, { recursive: true }); + + for (let i = 0; i < NSE_EXT_FILES.length; i++) { + const extFile = NSE_EXT_FILES[i]; + const targetFile = `${iosPath}/${NSE_TARGET_NAME}/${extFile}`; + await copyFile(`${sourceDir}${extFile}`, targetFile); + } + + const sourcePath = `${sourceDir}${NSE_SOURCE_FILE}`; + const targetFile = `${iosPath}/${NSE_TARGET_NAME}/${NSE_SOURCE_FILE}`; + await copyFile(`${sourcePath}`, targetFile); + + const nsePath = `${iosPath}/${NSE_TARGET_NAME}`; + await updateNSEEntitlements(nsePath, `group.${config.ios?.bundleIdentifier}.rnfirebase`); + await updateNSEBundleVersion(nsePath, config.ios?.buildNumber ?? DEFAULT_BUNDLE_VERSION); + await updateNSEBundleShortVersion(nsePath, config?.version ?? DEFAULT_BUNDLE_SHORT_VERSION); + + return config; + }, + ]); +};