diff --git a/CarillonReactNative.podspec b/CarillonReactNative.podspec
index 8c2b14e..706afbf 100644
--- a/CarillonReactNative.podspec
+++ b/CarillonReactNative.podspec
@@ -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,
diff --git a/README.md b/README.md
index b17584e..25da821 100644
--- a/README.md
+++ b/README.md
@@ -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:
@@ -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.
diff --git a/android/build.gradle b/android/build.gradle
index 8a489e8..37b509f 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -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 ->
diff --git a/ios/CarillonBridge.swift b/ios/CarillonBridge.swift
index 814d57b..16c9c66 100644
--- a/ios/CarillonBridge.swift
+++ b/ios/CarillonBridge.swift
@@ -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()
diff --git a/package.json b/package.json
index c12bce5..50a6491 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/plugin/src/__tests__/plugin.test.ts b/plugin/src/__tests__/plugin.test.ts
index f53d4ba..a11c261 100644
--- a/plugin/src/__tests__/plugin.test.ts
+++ b/plugin/src/__tests__/plugin.test.ts
@@ -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 {
@@ -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',
@@ -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' });
@@ -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(`CarillonAppGroup${appGroup}`);
+ expect(readFileSync(join(folder, 'CarillonNotificationExtension/CarillonNotificationExtension.entitlements'), 'utf8')).toContain(`${appGroup}`);
+ } 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');
+ });
+});
diff --git a/plugin/src/extension.ts b/plugin/src/extension.ts
index a8ead96..ac4f666 100644
--- a/plugin/src/extension.ts
+++ b/plugin/src/extension.ts
@@ -4,7 +4,7 @@ import { dirname, join, relative, resolve } from 'node:path';
type Project = Parameters[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,
@@ -55,7 +55,8 @@ export const extensionPlist = `
export function addNotificationExtension(
project: Project,
bundleId: string,
- swiftPackage: SwiftPackageSource = swiftPackageSource()
+ swiftPackage: SwiftPackageSource = swiftPackageSource(),
+ appGroup?: string
): void {
const objects = project.hash.project.objects;
objects.PBXTargetDependency ??= {};
@@ -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',
@@ -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 ?? {};
@@ -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('', `CarillonAppGroup${appGroup}`) : extensionPlist);
+ if (appGroup) writeFileSync(join(folder, `${EXTENSION_NAME}.entitlements`), `com.apple.security.application-groups${appGroup}`);
+ addNotificationExtension(mod.modResults, bundleId, swiftPackageSource(), appGroup);
return mod;
});
};
diff --git a/plugin/src/index.ts b/plugin/src/index.ts
index c35d013..4620122 100644
--- a/plugin/src/index.ts
+++ b/plugin/src/index.ts
@@ -6,6 +6,7 @@ import {
withAppBuildGradle,
withAppDelegate,
withEntitlementsPlist,
+ withInfoPlist,
withMainActivity,
withProjectBuildGradle,
type ConfigPlugin,
@@ -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
@@ -479,9 +482,19 @@ export function addAndroidOpenForwarding(
const withCarillon: ConfigPlugin = (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;
});
@@ -542,7 +555,7 @@ const withCarillon: ConfigPlugin = (config, props) =
return gradle;
});
- config = withNotificationExtension(config);
+ config = withNotificationExtension(config, { appGroup });
return AndroidConfig.Permissions.withPermissions(config, [
'android.permission.POST_NOTIFICATIONS',