diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ddc01..edd702e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Airship Expo Plugin Changelog +## Version 1.3.0 - December 03, 2024 +Minor version that adds support for Aiship Notification Service Extension and Android Custom Notification Channels. + ## Version 1.2.0 - June 04, 2024 Minor version that updates @expo/config-plugins dependency. diff --git a/README.md b/README.md index ae0536f..419d456 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,13 @@ Add the plugin to the app.json: "airship-expo-plugin", { "android":{ - "icon":"./assets/ic_notification.png" + "icon": "./assets/ic_notification.png", + "customNotificationChannels": "./assets/notification_channels.xml" }, "ios":{ - "mode": "development" + "mode": "development", + "notificationService": "./assets/NotificationService.swift", + "notificationServiceInfo": "./assets/NotificationServiceExtension-Info.plist" } } ] @@ -30,10 +33,13 @@ Add the plugin to the app.json: ``` Android Config: -- icon: Local path to an image to use as the icon for push notifications. 96x96 all-white png with transparency. The name of the icon will be the resource name. +- icon: Required. Local path to an image to use as the icon for push notifications. 96x96 all-white png with transparency. The name of the icon will be the resource name. +- customNotificationChannels: Optional. The local path to a Custom Notification Channels resource file. iOS Config: -- mode: The APNS entitlement. Either `development` or `production` +- mode: Required. The APNS entitlement. Either `development` or `production`. +- notificationService: Optional. The local path to a custom Notification Service Extension. +- notificationServiceInfo: Optional. Airship will use a default one if not provided. The local path to a Notification Service Extension Info.plist. ## Calling takeOff diff --git a/package.json b/package.json index 8a821b8..fba042c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "airship-expo-plugin", - "version": "1.2.0", + "version": "1.3.0", "description": "Airship Expo config plugin", "main": "./app.plugin.js", "scripts": { diff --git a/plugin/NotificationServiceExtension/AirshipNotificationServiceExtension-Info.plist b/plugin/NotificationServiceExtension/AirshipNotificationServiceExtension-Info.plist new file mode 100644 index 0000000..dead54c --- /dev/null +++ b/plugin/NotificationServiceExtension/AirshipNotificationServiceExtension-Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + AirshipNotificationServiceExtension + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).AirshipNotificationService + + + diff --git a/plugin/src/withAirship.ts b/plugin/src/withAirship.ts index 4d711a5..5fd6977 100644 --- a/plugin/src/withAirship.ts +++ b/plugin/src/withAirship.ts @@ -6,16 +6,36 @@ import { withAirshipIOS } from './withAirshipIOS'; const pkg = require('airship-expo-plugin/package.json'); export type AirshipAndroidPluginProps = { - icon: string; + /** + * Required. Local path to an image to use as the icon for push notifications. + * 96x96 all-white png with transparency. The name of the icon will be the resource name. + */ + icon: string; + /** + * Optional. The local path to a Custom Notification Channels resource file. + */ + customNotificationChannels?: string; }; export type AirshipIOSPluginProps = { - mode: 'development' | 'production'; + /** + * Required. The APNS entitlement. Either "development" or "production". + */ + mode: 'development' | 'production'; + /** + * Optional. The local path to a custom Notification Service Extension. + */ + notificationService?: string; + /** + * Optional. Airship will use a default one if not provided. + * The local path to a Notification Service Extension Info.plist. + */ + notificationServiceInfo?: string; } export type AirshipPluginProps = { - android?: AirshipAndroidPluginProps; - ios?: AirshipIOSPluginProps; + android?: AirshipAndroidPluginProps; + ios?: AirshipIOSPluginProps; }; const withAirship: ConfigPlugin = (config, props) => { diff --git a/plugin/src/withAirshipAndroid.ts b/plugin/src/withAirshipAndroid.ts index 87f2664..c78d160 100644 --- a/plugin/src/withAirshipAndroid.ts +++ b/plugin/src/withAirshipAndroid.ts @@ -5,8 +5,9 @@ import { } from '@expo/config-plugins'; import { generateImageAsync, ImageOptions } from '@expo/image-utils'; -import { writeFileSync, existsSync, mkdirSync } from 'fs'; -import { resolve, basename } from 'path'; +import { readFile, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { resolve, basename, join } from 'path'; + import { AirshipAndroidPluginProps } from './withAirship'; const iconSizeMap: Record = { @@ -17,6 +18,8 @@ const iconSizeMap: Record = { xxxhdpi: 96, }; +const NOTIFICATIONS_CHANNELS_FILE_NAME = "ua_custom_notification_channels.xml"; + async function writeNotificationIconImageFilesAsync(props: AirshipAndroidPluginProps, projectRoot: string) { const fileName = basename(props.icon) await Promise.all( @@ -71,8 +74,42 @@ const withCompileSDKVersionFix: ConfigPlugin = (confi }); }; +const withCustomNotificationChannels: ConfigPlugin = (config, props) => { + return withDangerousMod(config, [ + 'android', + async config => { + await writeNotificationChannelsFileAsync(props, config.modRequest.projectRoot); + return config; + }, + ]); +} + +// TODO copy the file from assets to xml res +async function writeNotificationChannelsFileAsync(props: AirshipAndroidPluginProps, projectRoot: string) { + if (!props.customNotificationChannels) { + return; + } + + const xmlResPath = join(projectRoot, "android/app/src/main/res/xml"); + + if (!existsSync(xmlResPath)) { + mkdirSync(xmlResPath, { recursive: true }); + } + + // Copy the custom notification channels file into the Android expo project as ua_custom_notification_channels.xml. + readFile(props.customNotificationChannels, 'utf8', (err, data) => { + if (err || !data) { + console.error("Airship couldn't read file " + props.customNotificationChannels); + console.error(err); + return; + } + writeFileSync(join(xmlResPath, NOTIFICATIONS_CHANNELS_FILE_NAME), data); + }); +}; + export const withAirshipAndroid: ConfigPlugin = (config, props) => { config = withCompileSDKVersionFix(config, props); config = withNotificationIcons(config, props); + config = withCustomNotificationChannels(config, props); return config; }; \ No newline at end of file diff --git a/plugin/src/withAirshipIOS.ts b/plugin/src/withAirshipIOS.ts index 8b8a6de..d217699 100644 --- a/plugin/src/withAirshipIOS.ts +++ b/plugin/src/withAirshipIOS.ts @@ -1,10 +1,21 @@ import { ConfigPlugin, withEntitlementsPlist, - withInfoPlist + withInfoPlist, + withDangerousMod, + withXcodeProject, + withPodfile } from '@expo/config-plugins'; -import { AirshipIOSPluginProps } from './withAirship'; +import { readFile, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { basename, join } from 'path'; + +import { AirshipIOSPluginProps } from './withAirship'; +import { mergeContents, MergeResults } from '@expo/config-plugins/build/utils/generateCode'; + +const NOTIFICATION_SERVICE_EXTENSION_TARGET_NAME = "AirshipNotificationServiceExtension"; +const NOTIFICATION_SERVICE_FILE_NAME = "AirshipNotificationService.swift"; +const NOTIFICATION_SERVICE_INFO_PLIST_FILE_NAME = "AirshipNotificationServiceExtension-Info.plist"; const withCapabilities: ConfigPlugin = (config, props) => { return withInfoPlist(config, (plist) => { @@ -21,13 +32,183 @@ const withCapabilities: ConfigPlugin = (config, props) => const withAPNSEnvironment: ConfigPlugin = (config, props) => { return withEntitlementsPlist(config, (plist) => { - plist.modResults['aps-environment'] = props.mode + plist.modResults['aps-environment'] = props.mode; return plist; }); }; +const withNotificationServiceExtension: ConfigPlugin = (config, props) => { + return withDangerousMod(config, [ + 'ios', + async config => { + await writeNotificationServiceFilesAsync(props, config.modRequest.projectRoot); + return config; + }, + ]); +}; + +async function writeNotificationServiceFilesAsync(props: AirshipIOSPluginProps, projectRoot: string) { + if (!props.notificationService) { + return; + } + + const pluginDir = require.resolve("airship-expo-plugin/package.json"); + const sourceDir = join(pluginDir, "../plugin/NotificationServiceExtension/"); + + const extensionPath = join(projectRoot, "ios", NOTIFICATION_SERVICE_EXTENSION_TARGET_NAME); + + if (!existsSync(extensionPath)) { + mkdirSync(extensionPath, { recursive: true }); + } + + // Copy the NotificationService.swift file into the iOS expo project as AirshipNotificationService.swift. + readFile(props.notificationService, 'utf8', (err, data) => { + if (err || !data) { + console.error("Airship couldn't read file " + props.notificationService); + console.error(err); + return; + } + + if (!props.notificationServiceInfo) { + const regexp = /class [A-Za-z]+:/; + const newSubStr = "class AirshipNotificationService:"; + data = data.replace(regexp, newSubStr); + } + + writeFileSync(join(extensionPath, NOTIFICATION_SERVICE_FILE_NAME), data); + }); + + // Copy the Info.plist (default to AirshipNotificationServiceExtension-Info.plist if null) file into the iOS expo project as AirshipNotificationServiceExtension-Info.plist. + readFile(props.notificationServiceInfo ?? join(sourceDir, NOTIFICATION_SERVICE_INFO_PLIST_FILE_NAME), 'utf8', (err, data) => { + if (err || !data) { + console.error("Airship couldn't read file " + (props.notificationServiceInfo ?? join(sourceDir, NOTIFICATION_SERVICE_INFO_PLIST_FILE_NAME))); + console.error(err); + return; + } + writeFileSync(join(extensionPath, NOTIFICATION_SERVICE_INFO_PLIST_FILE_NAME), data); + }); +}; + +const withExtensionTargetInXcodeProject: ConfigPlugin = (config, props) => { + return withXcodeProject(config, newConfig => { + const xcodeProject = newConfig.modResults; + + if (!!xcodeProject.pbxTargetByName(NOTIFICATION_SERVICE_EXTENSION_TARGET_NAME)) { + console.log(NOTIFICATION_SERVICE_EXTENSION_TARGET_NAME + " already exists in project. Skipping..."); + return newConfig; + } + + // Create new PBXGroup for the extension + const extGroup = xcodeProject.addPbxGroup( + [NOTIFICATION_SERVICE_FILE_NAME, NOTIFICATION_SERVICE_INFO_PLIST_FILE_NAME], + NOTIFICATION_SERVICE_EXTENSION_TARGET_NAME, + NOTIFICATION_SERVICE_EXTENSION_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"]; + Object.keys(groups).forEach(function(key) { + if (typeof groups[key] === "object" && groups[key].name === undefined && groups[key].path === undefined) { + xcodeProject.addToPbxGroup(extGroup.uuid, key); + } + }); + + // WORK AROUND for xcodeProject.addTarget BUG (making the pod install to fail somehow) + // Xcode projects don't contain these if there is only one target in the app + // 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 Notification Service Extension Target + // This adds PBXTargetDependency and PBXContainerItemProxy + const notificationServiceExtensionTarget = xcodeProject.addTarget( + NOTIFICATION_SERVICE_EXTENSION_TARGET_NAME, + "app_extension", + NOTIFICATION_SERVICE_EXTENSION_TARGET_NAME, + `${config.ios?.bundleIdentifier}.${NOTIFICATION_SERVICE_EXTENSION_TARGET_NAME}` + ); + + // Add build phases to the new Target + xcodeProject.addBuildPhase( + [NOTIFICATION_SERVICE_FILE_NAME], + "PBXSourcesBuildPhase", + "Sources", + notificationServiceExtensionTarget.uuid + ); + xcodeProject.addBuildPhase( + [], + "PBXResourcesBuildPhase", + "Resources", + notificationServiceExtensionTarget.uuid + ); + xcodeProject.addBuildPhase( + [], + "PBXFrameworksBuildPhase", + "Frameworks", + notificationServiceExtensionTarget.uuid + ); + + // Edit the new Target Build Settings and Deployment info + const configurations = xcodeProject.pbxXCBuildConfigurationSection(); + for (const key in configurations) { + if (typeof configurations[key].buildSettings !== "undefined" + && configurations[key].buildSettings.PRODUCT_NAME == `"${NOTIFICATION_SERVICE_EXTENSION_TARGET_NAME}"` + ) { + const buildSettingsObj = configurations[key].buildSettings; + buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = "14.0"; + buildSettingsObj.SWIFT_VERSION = "5.0"; + } + } + + return newConfig; + }); +}; + +const withAirshipServiceExtensionPod: ConfigPlugin = (config, props) => { + return withPodfile(config, async (config) => { + const airshipServiceExtensionPodfileSnippet = ` + target '${NOTIFICATION_SERVICE_EXTENSION_TARGET_NAME}' do + pod 'AirshipServiceExtension' + end + `; + + let results: MergeResults; + try { + results = mergeContents({ + tag: "AirshipServiceExtension", + src: config.modResults.contents, + newSrc: airshipServiceExtensionPodfileSnippet, + anchor: /target .* do/, + offset: 0, + comment: '#' + }); + } catch (error: any) { + if (error.code === 'ERR_NO_MATCH') { + throw new Error( + `Cannot add AirshipServiceExtension to the project's ios/Podfile because it's malformed. Please report this with a copy of your project Podfile.` + ); + } + throw error; + } + + if (results.didMerge || results.didClear) { + config.modResults.contents = results.contents; + } + + return config; + }); +}; + export const withAirshipIOS: ConfigPlugin = (config, props) => { - config = withCapabilities(config, props) - config = withAPNSEnvironment(config, props) + config = withCapabilities(config, props); + config = withAPNSEnvironment(config, props); + if (props.notificationService) { + config = withNotificationServiceExtension(config, props); + config = withExtensionTargetInXcodeProject(config, props); + config = withAirshipServiceExtensionPod(config, props); + } return config; }; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index cc413bd..928e374 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1226,7 +1226,28 @@ resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63" integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA== -"@expo/config-plugins@^7.2.5", "@expo/config-plugins@~7.8.2": +"@expo/config-plugins@^8.0.4": + version "8.0.11" + resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-8.0.11.tgz#b814395a910f4c8b7cc95d9719dccb6ca53ea4c5" + integrity sha512-oALE1HwnLFthrobAcC9ocnR9KXLzfWEjgIe4CPe+rDsfC6GDs8dGYCXfRFoCEzoLN4TGYs9RdZ8r0KoCcNrm2A== + dependencies: + "@expo/config-types" "^51.0.3" + "@expo/json-file" "~8.3.0" + "@expo/plist" "^0.1.0" + "@expo/sdk-runtime-versions" "^1.0.0" + chalk "^4.1.2" + debug "^4.3.1" + find-up "~5.0.0" + getenv "^1.0.0" + glob "7.1.6" + resolve-from "^5.0.0" + semver "^7.5.4" + slash "^3.0.0" + slugify "^1.6.6" + xcode "^3.0.1" + xml2js "0.6.0" + +"@expo/config-plugins@~7.8.2": version "7.8.4" resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.8.4.tgz#533b5d536c1dc8b5544d64878b51bda28f2e1a1f" integrity sha512-hv03HYxb/5kX8Gxv/BTI8TLc9L06WzqAfHRRXdbar4zkLcP2oTzvsLEF4/L/TIpD3rsnYa0KU42d0gWRxzPCJg== @@ -1254,6 +1275,11 @@ resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-50.0.0.tgz#b534d3ec997ec60f8af24f6ad56244c8afc71a0b" integrity sha512-0kkhIwXRT6EdFDwn+zTg9R2MZIAEYGn1MVkyRohAd+C9cXOb5RA8WLQi7vuxKF9m1SMtNAUrf0pO+ENK0+/KSw== +"@expo/config-types@^51.0.3": + version "51.0.3" + resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-51.0.3.tgz#520bdce5fd75f9d234fd81bd0347443086419450" + integrity sha512-hMfuq++b8VySb+m9uNNrlpbvGxYc8OcFCUX9yTmi9tlx6A4k8SDabWFBgmnr4ao3wEArvWrtUQIfQCVtPRdpKA== + "@expo/config@~8.5.0": version "8.5.4" resolved "https://registry.yarnpkg.com/@expo/config/-/config-8.5.4.tgz#bb5eb06caa36e4e35dc8c7647fae63e147b830ca"