Skip to content

Commit

Permalink
Merge pull request #6 from urbanairship/MOBILE-4785
Browse files Browse the repository at this point in the history
[MOBILE-4785] Add support for Airship Notification Service Extension
  • Loading branch information
Ulrico972 authored Dec 5, 2024
2 parents 4cbccbb + fac34a5 commit a3217a9
Show file tree
Hide file tree
Showing 8 changed files with 317 additions and 17 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,27 @@ 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"
}
}
]
]
```

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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>AirshipNotificationServiceExtension</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).AirshipNotificationService</string>
</dict>
</dict>
</plist>
28 changes: 24 additions & 4 deletions plugin/src/withAirship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AirshipPluginProps> = (config, props) => {
Expand Down
41 changes: 39 additions & 2 deletions plugin/src/withAirshipAndroid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
Expand All @@ -17,6 +18,8 @@ const iconSizeMap: Record<string, number> = {
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(
Expand Down Expand Up @@ -71,8 +74,42 @@ const withCompileSDKVersionFix: ConfigPlugin<AirshipAndroidPluginProps> = (confi
});
};

const withCustomNotificationChannels: ConfigPlugin<AirshipAndroidPluginProps> = (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<AirshipAndroidPluginProps> = (config, props) => {
config = withCompileSDKVersionFix(config, props);
config = withNotificationIcons(config, props);
config = withCustomNotificationChannels(config, props);
return config;
};
191 changes: 186 additions & 5 deletions plugin/src/withAirshipIOS.ts
Original file line number Diff line number Diff line change
@@ -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<AirshipIOSPluginProps> = (config, props) => {
return withInfoPlist(config, (plist) => {
Expand All @@ -21,13 +32,183 @@ const withCapabilities: ConfigPlugin<AirshipIOSPluginProps> = (config, props) =>

const withAPNSEnvironment: ConfigPlugin<AirshipIOSPluginProps> = (config, props) => {
return withEntitlementsPlist(config, (plist) => {
plist.modResults['aps-environment'] = props.mode
plist.modResults['aps-environment'] = props.mode;
return plist;
});
};

const withNotificationServiceExtension: ConfigPlugin<AirshipIOSPluginProps> = (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<AirshipIOSPluginProps> = (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<AirshipIOSPluginProps> = (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<AirshipIOSPluginProps> = (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;
};
Loading

0 comments on commit a3217a9

Please sign in to comment.