Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CarillonReactNative.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Pod::Spec.new do |s|
if carillon_swift.empty?
spm_dependency(s,
url: "https://github.com/exostack/carillon-swift.git",
requirement: { kind: "upToNextMajorVersion", minimumVersion: "0.4.1" },
requirement: { kind: "upToNextMajorVersion", minimumVersion: "0.5.0" },
products: ["Carillon"])
else
spm_dependency(s,
Expand Down
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ Options:
## Native dependencies

The podspec resolves `carillon-swift` through Swift Package Manager with minimum
version `0.2.1`. Gradle resolves `com.exostack:carillon:0.2.1`.
version `0.5.0`. Gradle resolves `com.exostack:carillon:0.5.0`.

For local development, clone the native SDKs beside this repository:

Expand Down Expand Up @@ -471,6 +471,41 @@ Expo configuration. A policy based only on the Expo SDK version does not track
adding a native module between SDK upgrades. See
[Expo runtime versions](https://docs.expo.dev/eas-update/runtime-versions/).

Release 0.4.3 requires native Carillon Swift and Kotlin 0.4.1 or later for
This release requires native Carillon Swift and Kotlin 0.5.0 or later for
re-registration after mobile-key changes. Rebuild the native app and update its
resolved native dependencies; an OTA update alone cannot apply this native fix.

## Receipt tracking

SDK 0.5.0 automatically reports `received` through its native callbacks and checks
notifications still present when the app starts or resumes. These events persist
offline. They do not trigger `onOpened`; opening the app directly does not count
as tapping a notification. Missing receipts do not prove a delivery failure.

On iOS, notifications removed before the app opens are recoverable only when the
notification service extension records them in an App Group. With Expo:

```json
{
"expo": {
"plugins": [["@exostack/carillon-react-native", {
"appGroup": "group.com.example.app.carillon"
}]]
}
}
```

Enable this App Group for both app and extension in Apple Developer, then rebuild
the native app with matching provisioning profiles. The plugin configures the
entitlements and `CarillonAppGroup` Info.plist value on both targets. For a bare
app, add those settings manually to both targets. Receipts saved by the extension
are reported the next time the host app launches or becomes active. iOS may not
run the extension for every notification, particularly silent notifications.

Android recovery uses Carillon's default notification tag. A custom
`android.notification.tag` disables recovery from the tray for that notification.
Already dismissed notifications cannot be recovered from the tray.

Check the delivery trace for `received_at` and `received_reported_at`: the first
is the observed receipt time, the second is when Carillon accepted the report.
A receipt is not proof that a person read the notification.
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ buildscript {
// The Android SDK this bridge binds. A published coordinate, resolved from
// Maven Central once carillon-kotlin is there; see the README for what
// resolves it today.
sdkVersion: "0.4.1"
sdkVersion: "0.5.0"
]

ext.getExtOrDefault = { prop ->
Expand Down
1 change: 1 addition & 0 deletions ios/CarillonBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ public final class CarillonBridge: NSObject {
_ notification: UNNotification,
completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
Carillon.didReceive(userInfo: notification.request.content.userInfo)
receivedLock.lock()
guard let handler = receivedHandler else {
receivedLock.unlock()
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": "@exostack/carillon-react-native",
"version": "0.4.3",
"version": "0.5.0",
"description": "React Native bridge over the Carillon native SDKs.",
"main": "./lib/module/index.js",
"types": "./lib/typescript/src/index.d.ts",
Expand Down
47 changes: 45 additions & 2 deletions plugin/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from '@jest/globals';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
Expand Down Expand Up @@ -534,7 +534,7 @@ describe('notification service extension', () => {
};

it('resolves the Swift package from the environment as the podspec does', () => {
expect(swiftPackageSource({})).toEqual({ kind: 'remote', minimumVersion: '0.2.1' });
expect(swiftPackageSource({})).toEqual({ kind: 'remote', minimumVersion: '0.5.0' });
expect(swiftPackageSource({ CARILLON_SWIFT_PATH: '/checkouts/carillon-swift' })).toEqual({
kind: 'local',
path: '/checkouts/carillon-swift',
Expand All @@ -551,6 +551,16 @@ describe('notification service extension', () => {
expect(written).not.toContain('carillon-swift.git');
});

it('signs the extension with the shared receipt App Group idempotently', () => {
const project = exampleProject();
const source = { kind: 'remote', minimumVersion: '0.5.0' };
addNotificationExtension(project, 'dev.carillon.example', source, 'group.dev.carillon.example');
const first = project.writeSync();
expect(first).toContain('CODE_SIGN_ENTITLEMENTS = "CarillonNotificationExtension/CarillonNotificationExtension.entitlements"');
addNotificationExtension(project, 'dev.carillon.example', source, 'group.dev.carillon.example');
expect(project.writeSync()).toBe(first);
});

it('embeds one extension and links its Swift product idempotently', () => {
const project = exampleProject();
addNotificationExtension(project, 'dev.carillon.example', { kind: 'remote', minimumVersion: '0.2.0' });
Expand All @@ -574,3 +584,36 @@ describe('notification service extension', () => {
expect(project.writeSync()).toBe(first);
});
});


describe('receipt App Group configuration', () => {
const withCarillon = require('../index').default;
const { withNotificationExtension } = require('../extension');
const xcode = require('xcode');
const path = require('node:path');

it('sets matching host and extension settings and EAS entitlements', async () => {
const appGroup = 'group.dev.carillon.example';
const config = withCarillon({ name: 'Example', slug: 'example', ios: { bundleIdentifier: 'dev.carillon.example' } }, { appGroup });
const request = { projectRoot: __dirname, platform: 'ios', modName: 'infoPlist', introspect: false };
const info = await config.mods.ios.infoPlist({ ...config, modResults: {}, modRequest: request });
expect(info.modResults.CarillonAppGroup).toBe(appGroup);
const entitlements = await config.mods.ios.entitlements({ ...config, modResults: { 'com.apple.security.application-groups': ['group.existing', appGroup] }, modRequest: { ...request, modName: 'entitlements' } });
expect(entitlements.modResults['com.apple.security.application-groups']).toEqual(['group.existing', appGroup]);
const extension = config.extra.eas.build.experimental.ios.appExtensions[0];
expect(extension.entitlements['com.apple.security.application-groups']).toEqual([appGroup]);
const folder = mkdtempSync(join(tmpdir(), 'carillon-receipt-plugin-'));
try {
const project = xcode.project(path.join(__dirname, '../../../example/ios/CarillonExample.xcodeproj/project.pbxproj'));
project.parseSync();
const extensionConfig = withNotificationExtension({ name: 'Example', slug: 'example', ios: { bundleIdentifier: 'dev.carillon.example' } }, { appGroup });
await extensionConfig.mods.ios.xcodeproj({ ...extensionConfig, modResults: project, modRequest: { ...request, modName: 'xcodeproj', platformProjectRoot: folder } });
expect(readFileSync(join(folder, 'CarillonNotificationExtension/CarillonNotificationExtension-Info.plist'), 'utf8')).toContain(`<key>CarillonAppGroup</key><string>${appGroup}</string>`);
expect(readFileSync(join(folder, 'CarillonNotificationExtension/CarillonNotificationExtension.entitlements'), 'utf8')).toContain(`<string>${appGroup}</string>`);
} finally { rmSync(folder, { recursive: true, force: true }); }
});

it('rejects an invalid group identifier before writing native files', () => {
expect(() => withCarillon({ name: 'Example', slug: 'example' }, { appGroup: 'invalid<&' })).toThrow('appGroup');
});
});
18 changes: 12 additions & 6 deletions plugin/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { dirname, join, relative, resolve } from 'node:path';

type Project = Parameters<Parameters<typeof withXcodeProject>[1]>[0]['modResults'];
export const EXTENSION_NAME = 'CarillonNotificationExtension';
export const SWIFT_PACKAGE_VERSION = '0.2.1';
export const SWIFT_PACKAGE_VERSION = '0.5.0';

/**
* Where the extension's Swift package comes from: the published repository,
Expand Down Expand Up @@ -55,7 +55,8 @@ export const extensionPlist = `<?xml version="1.0" encoding="UTF-8"?>
export function addNotificationExtension(
project: Project,
bundleId: string,
swiftPackage: SwiftPackageSource = swiftPackageSource()
swiftPackage: SwiftPackageSource = swiftPackageSource(),
appGroup?: string
): void {
const objects = project.hash.project.objects;
objects.PBXTargetDependency ??= {};
Expand All @@ -80,6 +81,7 @@ export function addNotificationExtension(
PRODUCT_MODULE_NAME: 'CarillonNotificationService',
PRODUCT_BUNDLE_IDENTIFIER: `"${bundleId}.${EXTENSION_NAME}"`,
APPLICATION_EXTENSION_API_ONLY: 'YES',
...(appGroup ? { CODE_SIGN_ENTITLEMENTS: `"${EXTENSION_NAME}/${EXTENSION_NAME}.entitlements"` } : {}),
TARGETED_DEVICE_FAMILY: settings.TARGETED_DEVICE_FAMILY ?? '"1,2"',
MARKETING_VERSION: settings.MARKETING_VERSION ?? '1.0',
CURRENT_PROJECT_VERSION: settings.CURRENT_PROJECT_VERSION ?? '1',
Expand Down Expand Up @@ -126,7 +128,10 @@ export function addNotificationExtension(
root.packageReferences.push({ value: packageId, comment: 'carillon-swift' });
}

export const withNotificationExtension: ConfigPlugin = (config) => {
export const withNotificationExtension: ConfigPlugin<{ appGroup?: string } | void> = (config, props) => {
const appGroup = props?.appGroup;
if (appGroup && !/^group\.[A-Za-z0-9.-]+$/.test(appGroup)) throw new Error('Carillon appGroup must be a valid group identifier.');
const entitlements = appGroup ? { 'com.apple.security.application-groups': [appGroup] } : {};
const bundleId = config.ios?.bundleIdentifier;
if (!bundleId) throw new Error('Set ios.bundleIdentifier before configuring Carillon notifications.');
const eas = config.extra?.eas ?? {};
Expand All @@ -137,15 +142,16 @@ export const withNotificationExtension: ConfigPlugin = (config) => {
config.extra = { ...config.extra, eas: { ...eas, build: { ...build, experimental: { ...experimental, ios: {
...ios, appExtensions: [
...extensions.filter((item: { targetName: string }) => item.targetName !== EXTENSION_NAME),
{ targetName: EXTENSION_NAME, bundleIdentifier: `${bundleId}.${EXTENSION_NAME}`, entitlements: {} },
{ targetName: EXTENSION_NAME, bundleIdentifier: `${bundleId}.${EXTENSION_NAME}`, entitlements },
],
} } } } };
return withXcodeProject(config, (mod) => {
const folder = join(mod.modRequest.platformProjectRoot, EXTENSION_NAME);
mkdirSync(folder, { recursive: true });
writeFileSync(join(folder, 'NotificationService.swift'), notificationService);
writeFileSync(join(folder, `${EXTENSION_NAME}-Info.plist`), extensionPlist);
addNotificationExtension(mod.modResults, bundleId);
writeFileSync(join(folder, `${EXTENSION_NAME}-Info.plist`), appGroup ? extensionPlist.replace('</dict></plist>', `<key>CarillonAppGroup</key><string>${appGroup}</string></dict></plist>`) : extensionPlist);
if (appGroup) writeFileSync(join(folder, `${EXTENSION_NAME}.entitlements`), `<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>com.apple.security.application-groups</key><array><string>${appGroup}</string></array></dict></plist>`);
addNotificationExtension(mod.modResults, bundleId, swiftPackageSource(), appGroup);
return mod;
});
};
15 changes: 14 additions & 1 deletion plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
withAppBuildGradle,
withAppDelegate,
withEntitlementsPlist,
withInfoPlist,
withMainActivity,
withProjectBuildGradle,
type ConfigPlugin,
Expand All @@ -28,6 +29,8 @@ import { dirname, join } from 'node:path';
export type MessagingServiceMode = 'auto' | 'carillon' | 'external';

export type CarillonPluginProps = {
/** Shared App Group for receipt recovery from the iOS notification extension. */
appGroup?: string;
/**
* Whether the plugin makes the AppDelegate the notification-center delegate
* and inserts the `userNotificationCenter` callbacks. Set to `false` when
Expand Down Expand Up @@ -479,9 +482,19 @@ export function addAndroidOpenForwarding(
const withCarillon: ConfigPlugin<CarillonPluginProps | void> = (config, props) => {
const installDelegate = props?.installNotificationCenterDelegate ?? true;
const messagingService = props?.messagingService ?? 'auto';
const appGroup = props?.appGroup;
if (appGroup && !/^group\.[A-Za-z0-9.-]+$/.test(appGroup)) throw new Error('Carillon appGroup must be a valid group identifier.');
if (appGroup) config = withInfoPlist(config, (mod) => {
mod.modResults.CarillonAppGroup = appGroup;
return mod;
});

config = withEntitlementsPlist(config, (entitlements) => {
entitlements.modResults = setApsEnvironment(entitlements.modResults);
if (appGroup) {
const groups = entitlements.modResults['com.apple.security.application-groups'];
entitlements.modResults['com.apple.security.application-groups'] = [...new Set([...(Array.isArray(groups) ? groups : []), appGroup])];
}

return entitlements;
});
Expand Down Expand Up @@ -542,7 +555,7 @@ const withCarillon: ConfigPlugin<CarillonPluginProps | void> = (config, props) =
return gradle;
});

config = withNotificationExtension(config);
config = withNotificationExtension(config, { appGroup });

return AndroidConfig.Permissions.withPermissions(config, [
'android.permission.POST_NOTIFICATIONS',
Expand Down
Loading