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',