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"