diff --git a/.github/workflows/react-native-scene-e2e.yml b/.github/workflows/react-native-scene-e2e.yml index ce488017..6bf4db17 100644 --- a/.github/workflows/react-native-scene-e2e.yml +++ b/.github/workflows/react-native-scene-e2e.yml @@ -5,12 +5,15 @@ on: paths: - '.github/workflows/react-native-scene-e2e.yml' - '.maestro/fixtures/customerio_scene_cold.apns' + - '.maestro/fixtures/customerio_scene_declined.apns' - '.maestro/fixtures/customerio_scene_warm.apns' - '.maestro/fixtures/react-native-scene/**' - '.maestro/run_scene_push.sh' - '.maestro/scene_push_open.yaml' + - '.maestro/scene_push_declined.yaml' - '.maestro/scene_push_prepare.yaml' - '.maestro/scene_push_warm.yaml' + - '.maestro/scene_url_open.yaml' - 'src/customerio-cdp.ts' - 'src/customerio-push.ts' - 'src/index.ts' @@ -24,6 +27,7 @@ on: - 'src/utils/native-bridge.ts' - 'src/utils/param-validation.ts' - 'ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift' + - 'ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift' - 'ios/wrappers/NativeCustomerIO.swift' - 'ios/wrappers/NativeCustomerIO.mm' - 'ios/wrappers/liveactivities/NativeLiveActivities.swift' @@ -33,6 +37,7 @@ on: - 'ios/wrappers/CustomerioReactnative-Bridging-Header.h' - 'ios/cocoapods_deployment_target.rb' - 'customerio-reactnative.podspec' + - 'scripts/test_ios_deep_link_request_store.swift' - 'package.json' - 'package-lock.json' schedule: @@ -88,6 +93,15 @@ jobs: unzip -q "$maestro_zip" -d "$maestro_dir" echo "$maestro_dir/maestro/bin" >> "$GITHUB_PATH" + - name: Test deep-link acknowledgement state + shell: bash + run: | + xcrun swiftc \ + ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift \ + scripts/test_ios_deep_link_request_store.swift \ + -o "$RUNNER_TEMP/test-ios-deep-link-request-store" + "$RUNNER_TEMP/test-ios-deep-link-request-store" + - name: Test warm and terminated notification routing shell: bash env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 314f180c..5a1df8c0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,9 @@ jobs: - name: Compile run: npm run typescript + - name: Unit tests + run: npm test -- --runInBand --no-watchman + # `npm pack`, not `npm publish --dry-run`. From npm 11 (which node 24 bundles) a dry-run # publish also runs the registry-side preflight, so it fails with "cannot publish over the # previously published versions" on any branch whose version semantic-release has not bumped diff --git a/.maestro/fixtures/customerio_scene_declined.apns b/.maestro/fixtures/customerio_scene_declined.apns new file mode 100644 index 00000000..375c2bfe --- /dev/null +++ b/.maestro/fixtures/customerio_scene_declined.apns @@ -0,0 +1,16 @@ +{ + "aps": { + "alert": { + "title": "Customer.io scene routing", + "body": "Decline a React Native app-owned destination" + }, + "sound": "default" + }, + "CIO-Delivery-ID": "maestro-scene-route-declined", + "CIO-Delivery-Token": "maestro-scene-token-declined", + "CIO": { + "push": { + "link": "cio-rn-scene-e2e://declined" + } + } +} diff --git a/.maestro/fixtures/react-native-scene/App.tsx b/.maestro/fixtures/react-native-scene/App.tsx index bcd2746f..97361b95 100644 --- a/.maestro/fixtures/react-native-scene/App.tsx +++ b/.maestro/fixtures/react-native-scene/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Linking, Text, View } from 'react-native'; +import { Linking, Settings, Text, View } from 'react-native'; import { CioPushPermissionStatus, CioRegion, @@ -25,37 +25,80 @@ async function validateLiveActivityBridge(): Promise { } export default function App(): React.JSX.Element { + const routingMode = + Settings.get('CioSceneE2EPersistedMode') === 'linking' + ? 'linking' + : 'acknowledged'; const [receivedUrl, setReceivedUrl] = useState(null); + const [declineCount, setDeclineCount] = useState(0); const [initialized, setInitialized] = useState(false); const [failure, setFailure] = useState(null); useEffect(() => { - const subscription = Linking.addEventListener('url', ({ url }) => { - setReceivedUrl(url); - }); + let active = true; + const subscriptions: Array<{ remove(): void }> = []; - CustomerIO.initialize({ - cdpApiKey: 'scene-e2e-key', - region: CioRegion.US, - }) - .then(() => - CustomerIO.pushMessaging.showPromptForPushNotifications({ - ios: { sound: true, badge: true }, + const initialize = async () => { + subscriptions.push( + Linking.addEventListener('url', ({ url }) => { + setReceivedUrl(url); }) - ) - .then(async (status) => { - if (status !== CioPushPermissionStatus.Granted) { - throw new Error(`Push permission is ${status}`); + ); + + const initialUrl = await Linking.getInitialURL(); + if (initialUrl) { + setReceivedUrl(initialUrl); + } + + if (routingMode === 'linking') { + CustomerIO.setDeepLinkRoutingReady(); + } + + await CustomerIO.initialize({ + cdpApiKey: 'scene-e2e-key', + region: CioRegion.US, + }); + + // Register after initialization in acknowledged mode. This intentionally exercises the cold + // replay boundary where initialization must not drain the URL into legacy Linking. + if (routingMode === 'acknowledged') { + const handlerSubscription = CustomerIO.setDeepLinkHandler((url) => { + if (url === 'cio-rn-scene-e2e://declined') { + setDeclineCount((count) => count + 1); + return false; + } + setReceivedUrl(url); + return true; + }); + if (!active) { + handlerSubscription.remove(); + return; } - await validateLiveActivityBridge(); - setInitialized(true); - }) - .catch((error: unknown) => { + subscriptions.push(handlerSubscription); + } + + const status = + await CustomerIO.pushMessaging.showPromptForPushNotifications({ + ios: { sound: true, badge: true }, + }); + if (status !== CioPushPermissionStatus.Granted) { + throw new Error(`Push permission is ${status}`); + } + await validateLiveActivityBridge(); + setInitialized(true); + }; + + initialize().catch((error: unknown) => { + if (active) { setFailure(error instanceof Error ? error.message : String(error)); - }); + } + }); - return () => subscription.remove(); - }, []); + return () => { + active = false; + subscriptions.forEach((subscription) => subscription.remove()); + }; + }, [routingMode]); return ( @@ -63,8 +106,10 @@ export default function App(): React.JSX.Element { Customer.io React Native scene E2E{' '} {initialized ? 'ready' : 'initializing'} + Routing: {routingMode} {failure && Initialization failed: {failure}} {receivedUrl && Received: {receivedUrl}} + {declineCount > 0 && Declined count: {declineCount}} ); } diff --git a/.maestro/fixtures/react-native-scene/SceneDelegate.swift b/.maestro/fixtures/react-native-scene/SceneDelegate.swift index d6a42bdc..ee90ac31 100644 --- a/.maestro/fixtures/react-native-scene/SceneDelegate.swift +++ b/.maestro/fixtures/react-native-scene/SceneDelegate.swift @@ -5,6 +5,9 @@ import UIKit import customerio_reactnative class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate { + private static let launchModeKey = "CioSceneE2EMode" + private static let persistedModeKey = "CioSceneE2EPersistedMode" + var window: UIWindow? var reactNativeFactory: RCTReactNativeFactory? @@ -15,7 +18,11 @@ class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate ) { guard let windowScene = scene as? UIWindowScene else { return } - NativeCustomerIO.configureSceneDeepLinkRouting() + if usesAcknowledgedHandler { + NativeCustomerIO.configureAcknowledgedSceneDeepLinkRouting() + } else { + NativeCustomerIO.configureSceneDeepLinkRouting() + } dependencyProvider = RCTAppDependencyProvider() reactNativeFactory = RCTReactNativeFactory(delegate: self) window = UIWindow(windowScene: windowScene) @@ -41,4 +48,15 @@ class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate override func bundleURL() -> URL? { Bundle.main.url(forResource: "main", withExtension: "jsbundle") } + + private var usesAcknowledgedHandler: Bool { + let arguments = ProcessInfo.processInfo.arguments + if arguments.contains(Self.launchModeKey) || arguments.contains("-\(Self.launchModeKey)"), + let mode = UserDefaults.standard.string(forKey: Self.launchModeKey) + { + UserDefaults.standard.set(mode, forKey: Self.persistedModeKey) + } + + return UserDefaults.standard.string(forKey: Self.persistedModeKey) != "linking" + } } diff --git a/.maestro/run_scene_push.sh b/.maestro/run_scene_push.sh index 831e6678..6074a7ed 100755 --- a/.maestro/run_scene_push.sh +++ b/.maestro/run_scene_push.sh @@ -110,7 +110,7 @@ cleanup() { else echo "**Classification:** ${current_phase}-failed" fi - echo '**Scope:** simulator notification presentation, tap, and exact React Native Linking destination; no backend delivery or attribution claim' + echo '**Scope:** simulator notification presentation, tap, ordinary warm and cold URLs, and exact acknowledged-handler and legacy-Linking destinations; no backend delivery or attribution claim' } >> "$GITHUB_STEP_SUMMARY" fi return "$exit_code" @@ -201,6 +201,30 @@ run_notification_flow() { flow_log="" } +run_ordinary_url_flow() { + local url="$1" + local state="$2" + local ordinary_args=( + --device "$device_id" + test + -e "EXPECTED_URL=$url" + .maestro/scene_url_open.yaml + ) + + xcrun simctl openurl "$device_id" "$url" + if [[ -n "${RUNNER_TEMP:-}" ]]; then + ordinary_args=( + --device "$device_id" + test + --debug-output "$RUNNER_TEMP/react-native-scene-maestro-scene_url_open-$state" + --flatten-debug-output + -e "EXPECTED_URL=$url" + .maestro/scene_url_open.yaml + ) + fi + maestro "${ordinary_args[@]}" +} + cd "$REPO_ROOT" current_phase="package" npm ci @@ -239,6 +263,10 @@ plist="ios/$APP_NAME/Info.plist" /usr/libexec/PlistBuddy -c 'Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleApplication:0:UISceneConfigurationName string Default Configuration' "$plist" # shellcheck disable=SC2016 /usr/libexec/PlistBuddy -c 'Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleApplication:0:UISceneDelegateClassName string $(PRODUCT_MODULE_NAME).SceneDelegate' "$plist" +/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes array' "$plist" +/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0 dict' "$plist" +/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0:CFBundleURLSchemes array' "$plist" +/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string cio-rn-scene-e2e' "$plist" current_phase="pods" bundle install @@ -263,19 +291,35 @@ xcrun simctl install "$device_id" "$app_path" installed_app=true cd "$REPO_ROOT" -current_phase="prepare" -prepare_args=(--device "$device_id" test .maestro/scene_push_prepare.yaml) -if [[ -n "${RUNNER_TEMP:-}" ]]; then - prepare_args=( - --device "$device_id" - test - --debug-output "$RUNNER_TEMP/react-native-scene-maestro-scene_push_prepare" - --flatten-debug-output - .maestro/scene_push_prepare.yaml - ) -fi -maestro "${prepare_args[@]}" -xcrun simctl terminate "$device_id" "$APP_ID" -current_phase="routing" -run_notification_flow .maestro/scene_push_open.yaml .maestro/fixtures/customerio_scene_cold.apns -run_notification_flow .maestro/scene_push_warm.yaml .maestro/fixtures/customerio_scene_warm.apns +for routing_mode in acknowledged linking; do + current_phase="prepare-$routing_mode" + prepare_args=(--device "$device_id" test -e "ROUTING_MODE=$routing_mode" .maestro/scene_push_prepare.yaml) + if [[ -n "${RUNNER_TEMP:-}" ]]; then + prepare_args=( + --device "$device_id" + test + --debug-output "$RUNNER_TEMP/react-native-scene-maestro-scene_push_prepare-$routing_mode" + --flatten-debug-output + -e "ROUTING_MODE=$routing_mode" + .maestro/scene_push_prepare.yaml + ) + fi + maestro "${prepare_args[@]}" + + if [[ "$routing_mode" == acknowledged ]]; then + current_phase="ordinary-link-warm-$routing_mode" + run_ordinary_url_flow 'cio-rn-scene-e2e://ordinary-warm' warm + xcrun simctl terminate "$device_id" "$APP_ID" + + current_phase="ordinary-link-cold-$routing_mode" + run_ordinary_url_flow 'cio-rn-scene-e2e://ordinary-cold' cold + fi + xcrun simctl terminate "$device_id" "$APP_ID" + + current_phase="routing-$routing_mode" + run_notification_flow .maestro/scene_push_open.yaml .maestro/fixtures/customerio_scene_cold.apns + run_notification_flow .maestro/scene_push_warm.yaml .maestro/fixtures/customerio_scene_warm.apns + if [[ "$routing_mode" == acknowledged ]]; then + run_notification_flow .maestro/scene_push_declined.yaml .maestro/fixtures/customerio_scene_declined.apns + fi +done diff --git a/.maestro/scene_push_declined.yaml b/.maestro/scene_push_declined.yaml new file mode 100644 index 00000000..14009005 --- /dev/null +++ b/.maestro/scene_push_declined.yaml @@ -0,0 +1,16 @@ +appId: org.reactjs.native.example.CioRnSceneHost +name: React Native UIScene declined app-owned URL +--- +- pressKey: Home +- extendedWaitUntil: + visible: + id: 'NotificationShortLookView' + timeout: 60000 +- tapOn: + id: 'NotificationShortLookView' + retryTapIfNoChange: false +- extendedWaitUntil: + visible: 'Declined count: 1' + timeout: 15000 +- assertVisible: 'Received: cio-rn-scene-e2e://declined' +- assertNotVisible: 'Declined count: 2' diff --git a/.maestro/scene_push_prepare.yaml b/.maestro/scene_push_prepare.yaml index 4c8180f8..76c06bec 100644 --- a/.maestro/scene_push_prepare.yaml +++ b/.maestro/scene_push_prepare.yaml @@ -3,8 +3,11 @@ name: React Native UIScene push routing, prepare --- - launchApp: clearState: false + arguments: + CioSceneE2EMode: ${ROUTING_MODE} permissions: notifications: allow - extendedWaitUntil: - visible: "Customer.io React Native scene E2E ready" + visible: 'Customer.io React Native scene E2E ready' timeout: 15000 +- assertVisible: 'Routing: ${ROUTING_MODE}' diff --git a/.maestro/scene_url_open.yaml b/.maestro/scene_url_open.yaml new file mode 100644 index 00000000..00e32a64 --- /dev/null +++ b/.maestro/scene_url_open.yaml @@ -0,0 +1,11 @@ +appId: org.reactjs.native.example.CioRnSceneHost +name: React Native UIScene ordinary URL +--- +- tapOn: + text: Open + optional: true +- extendedWaitUntil: + visible: 'Customer.io React Native scene E2E ready' + timeout: 15000 +- assertVisible: 'Routing: acknowledged' +- assertVisible: 'Received: ${EXPECTED_URL}' diff --git a/README.md b/README.md index c11686b0..43986d5c 100644 --- a/README.md +++ b/README.md @@ -72,15 +72,41 @@ useEffect(() => { This SDK supports [rich push notifications](https://customer.io/docs/sdk/react-native/rich-push/) using Firebase (for Android) and either Firebase or APNs (for iOS). Follow our [push setup guide](https://customer.io/docs/sdk/react-native/push/) to configure your project for push. -On iOS, a `UIScene` host must install the wrapper's deep-link bridge before React Native starts. Call this first in `scene(_:willConnectTo:options:)`: +On iOS, a React Native 0.88+ `UIScene` host using the acknowledged handler must declare that ownership before React Native starts. This API is not used on Android or by AppDelegate-only hosts. Call this first in `scene(_:willConnectTo:options:)` so cold destinations wait for the JavaScript handler instead of entering the legacy `Linking` path: ```swift -NativeCustomerIO.configureSceneDeepLinkRouting() +NativeCustomerIO.configureAcknowledgedSceneDeepLinkRouting() ``` -Then register the app's JavaScript `Linking` URL listener before calling `CustomerIO.initialize`. This ordering is required. The Expo plugin detects its scene lifecycle during initialization, so Expo app code does not call the native installation method itself. Customer.io push, in-app, and inbox destinations are buffered during cold launch, then delivered through React Native's standard `Linking` API after initialization. If React Native does not initialize within ten seconds, the SDK opens the destination through the system instead of retaining it indefinitely. The listener owns the routing decision: navigate destinations your app handles, and use the app's normal external-browser path for other HTTP(S) destinations. React Native's native URL event has no handled result that the SDK can use for this decision, so a destination published after initialization without a listener is not opened externally, which would risk duplicate navigation. Older React Native versions and AppDelegate-only hosts keep their existing deep-link integration. +Register the app's Customer.io deep-link handler before calling `CustomerIO.initialize`. Return +`true` after routing a URL. Return `false` to let the native SDK try the host AppDelegate, then pass +a host-owned custom scheme to React Native `Linking` or open other URLs through the system. If your +handler might decline an app-owned custom scheme, keep a `Linking` URL listener registered for that +fallback. A thrown error, rejected promise, missing handler, or handler timeout follows the same +native fallback. Cold URLs wait up to ten seconds for registration and are replayed once the handler +is ready. After delivery, the handler has ten seconds to settle. If it takes longer, native fallback +runs; a handler that later routes the URL can cause a second navigation because its acknowledgement +cannot cancel the fallback. -An Expo app using config-plugin auto-initialization does not call `CustomerIO.initialize`. Register its `Linking` listener, then mark that listener ready instead: +```typescript +const subscription = CustomerIO.setDeepLinkHandler(async (url) => { + if (!canRouteInApp(url)) { + return false; + } + + await routeInApp(url); + return true; +}); + +CustomerIO.initialize(config); +``` + +Remove the returned subscription when the routing owner is torn down. If no replacement handler +registers, later destinations wait for the readiness timeout and then use the native fallback. + +The existing `Linking` path remains available for backward compatibility. Keep +`NativeCustomerIO.configureSceneDeepLinkRouting()` in your SceneDelegate, then signal readiness +after registering the listener: ```typescript const subscription = Linking.addEventListener('url', ({ url }) => { @@ -89,6 +115,18 @@ const subscription = Linking.addEventListener('url', ({ url }) => { CustomerIO.setDeepLinkRoutingReady(); ``` +Cold destinations wait up to ten seconds for this readiness signal. If it does not arrive, the SDK +tries the host AppDelegate, passes a host-owned custom scheme to React Native `Linking`, or opens +other URLs through the system. + +This older path cannot acknowledge whether JavaScript handled the URL, so the SDK cannot safely fall back after it publishes a `Linking` event. Prefer `setDeepLinkHandler` for new UIScene integrations. Older React Native versions and AppDelegate-only hosts keep their existing deep-link integration. + +Expo apps keep the `Linking` path. With config-plugin auto-initialization, register the app's +`Linking` listener, then call `CustomerIO.setDeepLinkRoutingReady()` after the router is ready. The +Expo plugin configures its native scene lifecycle automatically, so Expo app code does not call a +native configuration method. Apps using JavaScript initialization register the router before +`CustomerIO.initialize`; initialization marks Linking ready automatically. + This integration applies after the host has adopted React Native's UIScene lifecycle; the plugin does not replace React Native's root application lifecycle. This release's compatibility scope is one simultaneous window scene. Multiple simultaneous React Native window scenes are not supported. React Native's URL notification is process-wide, so SDK-published destinations may reach every connected React Native instance rather than one selected window. @@ -124,7 +162,7 @@ extension AppDelegate { A React Native `AppDelegate` conforms to `UIApplicationDelegate` directly rather than subclassing, so this method is not an `override`, and the URL is passed on to `RCTLinkingManager` instead of `super`. See [the sample app's `AppDelegate.swift`](/example/ios/SampleApp/AppDelegate.swift) for this in context. -For a `UIScene` host, handle both lifecycle paths. At scene connection, pass launch options through the wrapper so a cold Live Activity tap is attributed and React Native receives the customer's destination instead of Customer.io's internal tracking URL: +For a `UIScene` host, handle both lifecycle paths. At scene connection, pass the connection options through the wrapper so a cold Live Activity tap is attributed and its destination enters the acknowledged Customer.io router. Ordinary app links remain in React Native launch options for `Linking`: ```swift import customerio_reactnative @@ -136,7 +174,7 @@ reactNativeFactory?.startReactNative( ) ``` -Then replace the ordinary React Native URL-forwarding body in the existing `SceneDelegate` for warm opens. This reports Live Activity taps and routes each remaining URL through React Native Linking: +Then replace the ordinary React Native URL-forwarding body in the existing `SceneDelegate` for warm opens. This reports Live Activity taps, sends Customer.io destinations through the configured Customer.io router, and forwards ordinary app links to React Native `Linking`: ```swift import customerio_reactnative @@ -148,8 +186,8 @@ func scene(_ scene: UIScene, openURLContexts URLContexts: Set) } ``` -Do not also pass those URLs to `RCTLinkingManager`. The helper already publishes them through -React Native Linking, and forwarding them again can deliver the same URL twice. +Do not also pass those URLs to `RCTLinkingManager`. The helper already routes them through the +Customer.io bridge, and forwarding them again can deliver the same URL twice. Android needs no equivalent native step. diff --git a/__tests__/deep-link-routing-readiness.test.ts b/__tests__/deep-link-routing-readiness.test.ts index 98b24d92..515fb2e7 100644 --- a/__tests__/deep-link-routing-readiness.test.ts +++ b/__tests__/deep-link-routing-readiness.test.ts @@ -23,28 +23,139 @@ jest.mock('../src/customerio-push', () => ({ })); jest.mock('../src/native-logger-listener', () => ({ NativeLoggerListener: { - initNativeLogger: jest.fn(), initialize: jest.fn(), + initNativeLogger: jest.fn(), + warn: jest.fn(), }, })); -jest.mock('../src/specs/modules/NativeCustomerIO', () => ({ - __esModule: true, - default: { +jest.mock('../src/specs/modules/NativeCustomerIO', () => { + const nativeModule = { setDeepLinkRoutingReady: jest.fn(), - }, -})); + registerDeepLinkHandler: jest.fn(), + unregisterDeepLinkHandler: jest.fn(), + acknowledgeDeepLink: jest.fn(), + onDeepLinkReceived: jest.fn((emitter) => { + nativeModule.__emit = emitter; + return { remove: nativeModule.__nativeRemove }; + }), + __emit: undefined, + __nativeRemove: jest.fn(), + }; + return { __esModule: true, default: nativeModule }; +}); -import { CustomerIO } from '../src/customerio-cdp'; +import { + CustomerIO, + type DeepLinkHandlerSubscription, +} from '../src/customerio-cdp'; +import { NativeLoggerListener } from '../src/native-logger-listener'; import NativeCustomerIO from '../src/specs/modules/NativeCustomerIO'; -describe('CustomerIO scene deep-link readiness', () => { +const native = NativeCustomerIO as unknown as { + setDeepLinkRoutingReady: jest.Mock; + registerDeepLinkHandler: jest.Mock; + unregisterDeepLinkHandler: jest.Mock; + acknowledgeDeepLink: jest.Mock; + onDeepLinkReceived: jest.Mock; + __nativeRemove: jest.Mock; + __emit?: (data: unknown) => Promise; +}; + +describe('CustomerIO scene deep-link routing', () => { + let subscription: DeepLinkHandlerSubscription | undefined; + beforeEach(() => { jest.clearAllMocks(); + native.__emit = undefined; + subscription = undefined; }); - it('notifies the native router after the host registers its Linking listener', () => { + afterEach(() => { + subscription?.remove(); + }); + + it('keeps the existing Linking readiness API', () => { CustomerIO.setDeepLinkRoutingReady(); - expect(NativeCustomerIO.setDeepLinkRoutingReady).toHaveBeenCalledTimes(1); + expect(native.setDeepLinkRoutingReady).toHaveBeenCalledTimes(1); + }); + + it('subscribes before telling native that the handler is ready', () => { + subscription = CustomerIO.setDeepLinkHandler(() => true); + + expect(native.onDeepLinkReceived).toHaveBeenCalledTimes(1); + expect(native.registerDeepLinkHandler).toHaveBeenCalledTimes(1); + expect(native.onDeepLinkReceived.mock.invocationCallOrder[0]).toBeLessThan( + native.registerDeepLinkHandler.mock.invocationCallOrder[0] + ); + }); + + it('acknowledges a handled URL', async () => { + const handler = jest.fn().mockResolvedValue(true); + subscription = CustomerIO.setDeepLinkHandler(handler); + + await native.__emit?.({ id: 'request-1', url: 'myapp://inbox' }); + + expect(handler).toHaveBeenCalledWith('myapp://inbox'); + expect(native.acknowledgeDeepLink).toHaveBeenCalledWith('request-1', true); + }); + + it('declines a URL when the handler returns false', async () => { + subscription = CustomerIO.setDeepLinkHandler(() => false); + + await native.__emit?.({ id: 'request-2', url: 'https://example.com' }); + + expect(native.acknowledgeDeepLink).toHaveBeenCalledWith('request-2', false); + }); + + it('declines a URL when the handler fails', async () => { + const error = new Error('route failed'); + subscription = CustomerIO.setDeepLinkHandler(() => Promise.reject(error)); + + await native.__emit?.({ id: 'request-3', url: 'myapp://settings' }); + + expect(NativeLoggerListener.warn).toHaveBeenCalledWith( + 'Deep-link handler failed:', + error + ); + expect(native.acknowledgeDeepLink).toHaveBeenCalledWith('request-3', false); + }); + + it('leaves malformed native events for the native timeout fallback', async () => { + subscription = CustomerIO.setDeepLinkHandler(() => true); + + await native.__emit?.({ id: 'request-4' }); + + expect(NativeLoggerListener.warn).toHaveBeenCalledWith( + 'Received an invalid native deep-link event.' + ); + expect(native.acknowledgeDeepLink).not.toHaveBeenCalled(); + }); + + it('unregisters native routing when the subscription is removed', () => { + subscription = CustomerIO.setDeepLinkHandler(() => true); + + subscription.remove(); + subscription.remove(); + + expect(native.unregisterDeepLinkHandler).toHaveBeenCalledTimes(1); + expect(native.__nativeRemove).toHaveBeenCalledTimes(1); + subscription = undefined; + }); + + it('unregisters the previous handler before replacing it', () => { + CustomerIO.setDeepLinkHandler(() => true); + + subscription = CustomerIO.setDeepLinkHandler(() => false); + + expect(native.unregisterDeepLinkHandler).toHaveBeenCalledTimes(1); + expect(native.__nativeRemove).toHaveBeenCalledTimes(1); + expect(native.registerDeepLinkHandler).toHaveBeenCalledTimes(2); + }); + + it('rejects a non-function handler', () => { + expect(() => CustomerIO.setDeepLinkHandler(null as never)).toThrow( + '[CustomerIO] "handler" must be a function.' + ); }); }); diff --git a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt index 2fc0dc51..dabfa9bc 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt @@ -147,6 +147,18 @@ class NativeCustomerIOModule( // UIScene and React Native Linking readiness are iOS-only concerns. } + override fun registerDeepLinkHandler() { + // UIScene deep-link acknowledgement is an iOS-only concern. + } + + override fun unregisterDeepLinkHandler() { + // UIScene deep-link acknowledgement is an iOS-only concern. + } + + override fun acknowledgeDeepLink(id: String?, handled: Boolean) { + // UIScene deep-link acknowledgement is an iOS-only concern. + } + override fun identify(params: ReadableMap?) { val userId = params?.getString("userId") val traits = params?.getMap("traits") diff --git a/api-extractor-output/customerio-reactnative.api.md b/api-extractor-output/customerio-reactnative.api.md index b74c5e19..5268bc39 100644 --- a/api-extractor-output/customerio-reactnative.api.md +++ b/api-extractor-output/customerio-reactnative.api.md @@ -115,6 +115,7 @@ export class CustomerIO { static readonly pushMessaging: CustomerIOPushMessaging; static readonly registerDeviceToken: (token: string) => Promise; static readonly screen: (title: string, properties?: Record) => Promise; + static readonly setDeepLinkHandler: (handler: DeepLinkHandler) => DeepLinkHandlerSubscription; static readonly setDeepLinkRoutingReady: () => void; static readonly setDeviceAttributes: (attributes: Record) => Promise; static readonly setProfileAttributes: (attributes: Record) => Promise; @@ -179,6 +180,14 @@ export class CustomerIOPushMessaging implements NativePushSpec { trackNotificationResponseReceived(payload: Object): void; } +// @public +export type DeepLinkHandler = (url: string) => boolean | Promise; + +// @public +export interface DeepLinkHandlerSubscription { + remove(): void; +} + // @public export interface IdentifyParams { // (undocumented) diff --git a/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift b/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift new file mode 100644 index 00000000..3169df31 --- /dev/null +++ b/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift @@ -0,0 +1,123 @@ +import Foundation + +struct CustomerIOReactNativeDeepLinkRequestStore { + enum Acceptance: Equatable { + case buffered(UUID) + case linking(URL) + case handler(UUID, URL) + } + + enum Resolution: Equatable { + case handled + case fallback(URL) + } + + private enum DeliveryMode: Equatable { + case unavailable + case linking + case handler + } + + private enum RequestState: Equatable { + case buffered + case awaitingAcknowledgement + } + + private struct Request { + let id: UUID + let url: URL + var state: RequestState + } + + private var isLinkingReady = false + private var requiresHandler = false + private var hasHandler = false + private var requests: [Request] = [] + + private var deliveryMode: DeliveryMode { + if hasHandler { + return .handler + } + if !requiresHandler, isLinkingReady { + return .linking + } + return .unavailable + } + + var requiresAcknowledgedHandler: Bool { + requiresHandler + } + + var canDeliverWithLinking: Bool { + isLinkingReady + } + + mutating func requireAcknowledgedHandler() { + requiresHandler = true + } + + mutating func accept(_ url: URL) -> Acceptance { + switch deliveryMode { + case .unavailable: + let request = Request(id: UUID(), url: url, state: .buffered) + requests.append(request) + return .buffered(request.id) + case .linking: + return .linking(url) + case .handler: + let request = Request(id: UUID(), url: url, state: .awaitingAcknowledgement) + requests.append(request) + return .handler(request.id, request.url) + } + } + + mutating func useLinking() -> [URL] { + isLinkingReady = true + guard deliveryMode == .linking else { return [] } + + let urls = requests.compactMap { request in + request.state == .buffered ? request.url : nil + } + requests.removeAll { $0.state == .buffered } + return urls + } + + mutating func useHandler() -> [(UUID, URL)] { + hasHandler = true + var deliveries: [(UUID, URL)] = [] + for index in requests.indices where requests[index].state == .buffered { + requests[index].state = .awaitingAcknowledgement + deliveries.append((requests[index].id, requests[index].url)) + } + return deliveries + } + + mutating func removeHandler() { + hasHandler = false + } + + mutating func acknowledge(_ id: UUID, handled: Bool) -> Resolution? { + guard let index = requests.firstIndex(where: { + $0.id == id && $0.state == .awaitingAcknowledgement + }) else { return nil } + + let url = requests.remove(at: index).url + return handled ? .handled : .fallback(url) + } + + mutating func expireReadiness(_ id: UUID) -> URL? { + expire(id, expectedState: .buffered) + } + + mutating func expireAcknowledgement(_ id: UUID) -> URL? { + expire(id, expectedState: .awaitingAcknowledgement) + } + + private mutating func expire(_ id: UUID, expectedState: RequestState) -> URL? { + guard let index = requests.firstIndex(where: { + $0.id == id && $0.state == expectedState + }) else { return nil } + + return requests.remove(at: index).url + } +} diff --git a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift index 39c11eb3..38689700 100644 --- a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift +++ b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift @@ -3,12 +3,8 @@ import Foundation import UIKit enum CustomerIOReactNativeDeepLinkRouter { - private struct PendingUrl { - let id: UUID - let url: URL - } - private static let readinessTimeout: TimeInterval = 10 + private static let acknowledgementTimeout: TimeInterval = 10 private static let sceneManifestKey = "UIApplicationSceneManifest" private static let sceneConfigurationsKey = "UISceneConfigurations" private static let sceneOpenURLContextsSelector = NSSelectorFromString("scene:openURLContexts:") @@ -16,10 +12,15 @@ enum CustomerIOReactNativeDeepLinkRouter { // notification name and payload for Linking URL events. private static let openURLNotification = Notification.Name("RCTOpenURLNotification") private static let stateLock = NSLock() - // React Native Linking is process-wide. Multi-window and independent bridge lifecycles are - // outside the single-window scene contract, so readiness intentionally lasts for the process. - private static var isReactNativeReady = false - private static var pendingUrls: [PendingUrl] = [] + private static var requestStore = CustomerIOReactNativeDeepLinkRequestStore() + private static var handlerEmitter: ((_ id: String, _ url: String) -> Void)? + private static var handlerToken: UUID? + + static var requiresAcknowledgedHandler: Bool { + stateLock.lock() + defer { stateLock.unlock() } + return requestStore.requiresAcknowledgedHandler + } private static var hasSceneManifest: Bool { guard let manifest = Bundle.main.object(forInfoDictionaryKey: sceneManifestKey) as? [String: Any], @@ -48,7 +49,18 @@ enum CustomerIOReactNativeDeepLinkRouter { static func install() { guard isSceneLifecycleEnabled else { return } - installCallback() + installCallback(requiringAcknowledgedHandler: false) + } + + static func installAcknowledgedHandler() { + guard isSceneLifecycleEnabled else { + DIGraphShared.shared.logger.error( + "Customer.io could not install acknowledged deep-link routing because the host " + + "does not expose a supported React Native scene lifecycle" + ) + return + } + installCallback(requiringAcknowledgedHandler: true) } /// Expo owns scene-to-Linking forwarding even on React Native versions that do not expose the @@ -62,10 +74,15 @@ enum CustomerIOReactNativeDeepLinkRouter { ) return } - installCallback() + installCallback(requiringAcknowledgedHandler: false) } - private static func installCallback() { + private static func installCallback(requiringAcknowledgedHandler: Bool) { + stateLock.lock() + if requiringAcknowledgedHandler { + requestStore.requireAcknowledgedHandler() + } + stateLock.unlock() DIGraphShared.shared.deepLinkUtil.setDeepLinkCallback { url in accept(url) return true @@ -74,38 +91,28 @@ enum CustomerIOReactNativeDeepLinkRouter { static func accept(_ url: URL) { stateLock.lock() - if !isReactNativeReady { - let isFirstPendingUrl = pendingUrls.isEmpty - let pendingUrl = PendingUrl(id: UUID(), url: url) - pendingUrls.append(pendingUrl) - stateLock.unlock() - if isFirstPendingUrl { - DIGraphShared.shared.logger.info( - "Customer.io buffered an SDK deep link until React Native Linking is ready. " + - "Native-auto-initialized apps must call CustomerIO.setDeepLinkRoutingReady() " + - "after registering their Linking listener" - ) - } - DispatchQueue.main.asyncAfter(deadline: .now() + readinessTimeout) { - expire(pendingUrl.id) - } - return - } + let acceptance = requestStore.accept(url) stateLock.unlock() - route(url) + switch acceptance { + case let .buffered(id): + DIGraphShared.shared.logger.info( + "Customer.io buffered an SDK deep link until a React Native route is ready" + ) + scheduleReadinessExpiration(for: id) + case let .linking(url): + route(url) + case let .handler(id, url): + publishToHandler(id: id, url: url) + } } static func markReactNativeReady() { let markReady = { stateLock.lock() - isReactNativeReady = true - let urls = pendingUrls.map(\.url) - pendingUrls.removeAll() + let urls = requestStore.useLinking() stateLock.unlock() - // This runs on the main thread, so buffered URLs are published before a new - // main-thread URL can interleave with them. urls.forEach(route) } if Thread.isMainThread { @@ -115,6 +122,71 @@ enum CustomerIOReactNativeDeepLinkRouter { } } + static func registerHandler( + _ emitter: @escaping (_ id: String, _ url: String) -> Void + ) -> UUID { + let token = UUID() + stateLock.lock() + let hasAcknowledgedHandlerConfiguration = requestStore.requiresAcknowledgedHandler + handlerToken = token + handlerEmitter = emitter + let deliveries = requestStore.useHandler() + stateLock.unlock() + + if !hasAcknowledgedHandlerConfiguration { + DIGraphShared.shared.logger.error( + "Customer.io registered an acknowledged deep-link handler without configuring " + + "acknowledged scene routing before React Native started" + ) + } + for (id, url) in deliveries { + publishToHandler(id: id, url: url) + } + return token + } + + static func unregisterHandler(_ token: UUID) { + stateLock.lock() + guard handlerToken == token else { + stateLock.unlock() + return + } + handlerToken = nil + handlerEmitter = nil + requestStore.removeHandler() + stateLock.unlock() + } + + static func acknowledge(_ id: String, handled: Bool) { + guard let requestId = UUID(uuidString: id) else { + DIGraphShared.shared.logger.error( + "Customer.io ignored an invalid React Native deep-link acknowledgement" + ) + return + } + + stateLock.lock() + let resolution = requestStore.acknowledge(requestId, handled: handled) + stateLock.unlock() + + switch resolution { + case .handled: + DIGraphShared.shared.logger.info( + "React Native acknowledged the Customer.io deep link" + ) + case let .fallback(url): + DIGraphShared.shared.logger.info( + "React Native declined the Customer.io deep link; using the native fallback" + ) + fallback(url) + case nil: + DIGraphShared.shared.logger.info( + "Customer.io ignored a late or unknown React Native deep-link acknowledgement; " + + "the native fallback may have already routed the destination" + ) + } + } + static func route(_ url: URL) { let publish = { // React Native's AppDelegate URL entry point rejects scene-based hosts. Linking listens @@ -135,27 +207,106 @@ enum CustomerIOReactNativeDeepLinkRouter { } } - private static func expire(_ id: UUID) { - stateLock.lock() - guard !isReactNativeReady, - let index = pendingUrls.firstIndex(where: { $0.id == id }) - else { + private static func publishToHandler(id: UUID, url: URL) { + let publish = { + stateLock.lock() + let emitter = handlerEmitter stateLock.unlock() - return + + emitter?(id.uuidString, url.absoluteString) + DispatchQueue.main.asyncAfter(deadline: .now() + acknowledgementTimeout) { + expireAcknowledgement(id) + } } - let url = pendingUrls.remove(at: index).url + if Thread.isMainThread { + publish() + } else { + DispatchQueue.main.async(execute: publish) + } + } + + private static func scheduleReadinessExpiration(for id: UUID) { + DispatchQueue.main.asyncAfter(deadline: .now() + readinessTimeout) { + expireReadiness(id) + } + } + + private static func expireReadiness(_ id: UUID) { + stateLock.lock() + let url = requestStore.expireReadiness(id) + stateLock.unlock() + + guard let url else { return } + + DIGraphShared.shared.logger.error( + "Customer.io is using the native fallback because React Native deep-link routing " + + "did not become ready in time" + ) + fallback(url) + } + + private static func expireAcknowledgement(_ id: UUID) { + stateLock.lock() + let url = requestStore.expireAcknowledgement(id) stateLock.unlock() + guard let url else { return } + DIGraphShared.shared.logger.error( - "Customer.io is opening an SDK deep link externally because React Native did not " + - "initialize in time. Native-auto-initialized apps must call " + - "CustomerIO.setDeepLinkRoutingReady() after registering their Linking listener" + "Customer.io is using the native fallback because the React Native deep-link " + + "handler did not acknowledge the URL in time" ) - UIApplication.shared.open(url) { opened in - guard !opened else { return } - DIGraphShared.shared.logger.error( - "Customer.io could not open the SDK deep link externally" - ) + fallback(url) + } + + private static func fallback(_ url: URL) { + let open = { + let uiKit = DIGraphShared.shared.uIKitWrapper + if uiKit.continueNSUserActivity(webpageURL: url) { + DIGraphShared.shared.logger.info( + "Customer.io deep link was handled by the host AppDelegate fallback" + ) + } else if isAppOwnedCustomScheme(url) { + stateLock.lock() + let linkingIsReady = requestStore.canDeliverWithLinking + stateLock.unlock() + route(url) + if linkingIsReady { + DIGraphShared.shared.logger.info( + "Customer.io forwarded the host app's custom-scheme deep link to React " + + "Native Linking" + ) + } else { + DIGraphShared.shared.logger.error( + "Customer.io published the host app's custom-scheme deep link before " + + "React Native Linking was marked ready; the destination may not be handled" + ) + } + } else { + uiKit.open(url: url) + DIGraphShared.shared.logger.info( + "Customer.io opened the deep link through the system fallback" + ) + } + } + if Thread.isMainThread { + open() + } else { + DispatchQueue.main.async(execute: open) + } + } + + private static func isAppOwnedCustomScheme(_ url: URL) -> Bool { + guard let scheme = url.scheme, + scheme.caseInsensitiveCompare("http") != .orderedSame, + scheme.caseInsensitiveCompare("https") != .orderedSame, + let urlTypes = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") + as? [[String: Any]] + else { return false } + + return urlTypes.contains { urlType in + guard let schemes = urlType["CFBundleURLSchemes"] as? [String] else { return false } + return schemes.contains { $0.caseInsensitiveCompare(scheme) == .orderedSame } } } } diff --git a/ios/wrappers/NativeCustomerIO.mm b/ios/wrappers/NativeCustomerIO.mm index 14cb690b..925376c0 100644 --- a/ios/wrappers/NativeCustomerIO.mm +++ b/ios/wrappers/NativeCustomerIO.mm @@ -1,11 +1,16 @@ #import "utils/RCTCustomerIOUtils.h" #import +#import #import +@protocol NativeCustomerIOBridge +- (void)setEventEmitter:(id)emitter; +@end + // Objective-C wrapper for new architecture TurboModule implementation -@interface RCTNativeCustomerIO : NSObject +@interface RCTNativeCustomerIO : NativeCustomerIOSpecBase // Bridge to Swift implementation for cross-language compatibility -@property(nonatomic, strong) id swiftBridge; +@property(nonatomic, strong) id swiftBridge; @end @implementation RCTNativeCustomerIO @@ -30,6 +35,7 @@ - (instancetype)init { RCT_ASSERT_NOT_NIL(swiftClass, @"NativeCustomerIO Swift class", @"during runtime lookup"); _swiftBridge = [[swiftClass alloc] init]; [self assertBridgeAvailable:@"creating NativeCustomerIO Swift instance"]; + [_swiftBridge setEventEmitter:self]; } return self; } @@ -52,6 +58,32 @@ - (void)setDeepLinkRoutingReady { [_swiftBridge setDeepLinkRoutingReady]; } +- (void)registerDeepLinkHandler { + [self assertBridgeAvailable:@"during registerDeepLinkHandler"]; + [_swiftBridge registerDeepLinkHandler]; +} + +- (void)unregisterDeepLinkHandler { + [self assertBridgeAvailable:@"during unregisterDeepLinkHandler"]; + [_swiftBridge unregisterDeepLinkHandler]; +} + +- (void)acknowledgeDeepLink:(NSString *)id handled:(BOOL)handled { + [self assertBridgeAvailable:@"during acknowledgeDeepLink"]; + [_swiftBridge acknowledgeDeepLink:id handled:handled]; +} + +- (void)invalidate { + [self assertBridgeAvailable:@"during invalidate"]; + [_swiftBridge invalidate]; +} + +// Pins the Codegen event name at compile time. Keep this in sync with the TypeScript spec and the +// runtime selector in NativeCustomerIO.swift. +- (void)emitOnDeepLinkReceived:(NSDictionary *)value { + [super emitOnDeepLinkReceived:value]; +} + - (void)identify:(NSDictionary *)params { [self assertBridgeAvailable:@"during identify"]; [_swiftBridge identify:params]; diff --git a/ios/wrappers/NativeCustomerIO.swift b/ios/wrappers/NativeCustomerIO.swift index 0234b3f7..cffee6c3 100644 --- a/ios/wrappers/NativeCustomerIO.swift +++ b/ios/wrappers/NativeCustomerIO.swift @@ -7,6 +7,8 @@ import Foundation @objc(NativeCustomerIO) public class NativeCustomerIO: NSObject { private let logger: CioInternalCommon.Logger = DIGraphShared.shared.logger + private weak var objcEventEmitter: AnyObject? + private var deepLinkHandlerToken: UUID? /// Checks whether the CustomerIO SDK has been initialized. /// Returns `true` if the SDK has been successfully initialized, `false` otherwise. private var isInitialized: Bool { CustomerIO.shared.implementation != nil } @@ -21,6 +23,17 @@ public class NativeCustomerIO: NSObject { CustomerIOReactNativeDeepLinkRouter.install() } + /// Installs scene deep-link routing for the acknowledged JavaScript handler before the React + /// Native bridge starts. Call this at the start of `scene(_:willConnectTo:options:)` when the + /// host uses `CustomerIO.setDeepLinkHandler`. + @objc + public static func configureAcknowledgedSceneDeepLinkRouting() { + // The cold scene connection happens before JavaScript can register its handler. Record the + // host's ownership choice now so initialization cannot drain a buffered URL into Linking. + guard CustomerIO.shared.implementation == nil else { return } + CustomerIOReactNativeDeepLinkRouter.installAcknowledgedHandler() + } + /// Installs deep-link routing for an Expo-owned scene lifecycle. /// /// Expo forwards scene URLs into React Native Linking itself, including on React Native @@ -38,6 +51,53 @@ public class NativeCustomerIO: NSObject { CustomerIOReactNativeDeepLinkRouter.markReactNativeReady() } + @objc + public func setEventEmitter(_ emitter: AnyObject) { + objcEventEmitter = emitter + } + + @objc + func registerDeepLinkHandler() { + guard objcEventEmitter != nil else { + logger.error("Customer.io could not register the React Native deep-link handler") + return + } + + deepLinkHandlerToken = CustomerIOReactNativeDeepLinkRouter.registerHandler { [weak self] id, url in + self?.sendDeepLink(id: id, url: url) + } + } + + @objc + func unregisterDeepLinkHandler() { + guard let token = deepLinkHandlerToken else { return } + CustomerIOReactNativeDeepLinkRouter.unregisterHandler(token) + deepLinkHandlerToken = nil + } + + @objc + func acknowledgeDeepLink(_ id: String, handled: Bool) { + CustomerIOReactNativeDeepLinkRouter.acknowledge(id, handled: handled) + } + + @objc + func invalidate() { + unregisterDeepLinkHandler() + } + + private func sendDeepLink(id: String, url: String) { + guard let emitter = objcEventEmitter else { return } + + // Codegen derives this selector from `onDeepLinkReceived` in NativeCustomerIO.ts. Keep it + // in sync with that spec property and the forwarding override in NativeCustomerIO.mm. + let selector = Selector(("emitOnDeepLinkReceived:")) + guard emitter.responds(to: selector) else { + logger.error("Customer.io could not emit a React Native deep-link event") + return + } + _ = emitter.perform(selector, with: ["id": id, "url": url] as NSDictionary) + } + /// Ensures that the CustomerIO SDK is initialized before performing operations. /// Logs an error and returns false if the SDK is not initialized. private func ensureInitialized() -> Bool { diff --git a/ios/wrappers/liveactivities/NativeLiveActivities.swift b/ios/wrappers/liveactivities/NativeLiveActivities.swift index c6487f45..475d650f 100644 --- a/ios/wrappers/liveactivities/NativeLiveActivities.swift +++ b/ios/wrappers/liveactivities/NativeLiveActivities.swift @@ -237,17 +237,22 @@ public class NativeLiveActivities: NSObject { CustomerIO.liveActivities.handleWidgetUrl(url) } - /// Report an `opened` metric for a tapped Live Activity and route its destination through - /// React Native Linking. Call this from a scene-based host's URL lifecycle method. + /// Report an `opened` metric for a tapped Live Activity and route its destination through the + /// configured Customer.io router. Ordinary URLs continue through React Native Linking. @objc(handleAndRouteWidgetUrl:) public static func handleAndRouteWidgetUrl(_ url: URL) { guard let routableUrl = handleWidgetUrl(url) else { return } - CustomerIOReactNativeDeepLinkRouter.accept(routableUrl) + if routableUrl == url { + CustomerIOReactNativeDeepLinkRouter.route(routableUrl) + } else { + CustomerIOReactNativeDeepLinkRouter.accept(routableUrl) + } } /// Build React Native launch options from a scene connection, reporting a cold Live Activity - /// tap and replacing Customer.io's internal tracking URL with its destination. This mirrors the - /// `RCTConvertConnectionOptionsToLaunchOptions` conversion verified in React Native + /// tap and routing its destination through the installed Customer.io scene router. Without the + /// acknowledged-handler opt-in, the destination remains in React Native's launch options. This + /// mirrors the `RCTConvertConnectionOptionsToLaunchOptions` conversion verified in React Native /// 0.88.0-nightly-20260823-0c7f63a4e. Before `CustomerIO.initialize`, the native Live Activities /// stub parses the redirect and buffers the opened metric for the module to flush at initialization. @objc(reactNativeLaunchOptionsFromConnectionOptions:) @@ -259,7 +264,13 @@ public class NativeLiveActivities: NSObject { if let url = connectionOptions.urlContexts.first?.url, let routableUrl = handleWidgetUrl(url) { - launchOptions[.url] = routableUrl + if CustomerIOReactNativeDeepLinkRouter.requiresAcknowledgedHandler, + routableUrl != url + { + CustomerIOReactNativeDeepLinkRouter.accept(routableUrl) + } else { + launchOptions[.url] = routableUrl + } } if let userActivity = connectionOptions.userActivities.first { diff --git a/scripts/test_ios_deep_link_request_store.swift b/scripts/test_ios_deep_link_request_store.swift new file mode 100644 index 00000000..06c8a64b --- /dev/null +++ b/scripts/test_ios_deep_link_request_store.swift @@ -0,0 +1,159 @@ +import Foundation + +private func require(_ condition: @autoclosure () -> Bool, _ message: String) { + guard condition() else { + fputs("error: \(message)\n", stderr) + exit(1) + } +} + +private func bufferedRequest( + _ acceptance: CustomerIOReactNativeDeepLinkRequestStore.Acceptance +) -> UUID { + guard case let .buffered(id) = acceptance else { + fputs("error: expected a buffered request\n", stderr) + exit(1) + } + return id +} + +private func handlerRequest( + _ acceptance: CustomerIOReactNativeDeepLinkRequestStore.Acceptance +) -> UUID { + guard case let .handler(id, _) = acceptance else { + fputs("error: expected a handler request\n", stderr) + exit(1) + } + return id +} + +@main +private enum DeepLinkRequestStoreTests { + static func main() { + let coldUrl = URL(string: "myapp://cold")! + var lateStore = CustomerIOReactNativeDeepLinkRequestStore() + let coldId = bufferedRequest(lateStore.accept(coldUrl)) + let replay = lateStore.useHandler() + require(replay.count == 1, "late handler must receive one buffered URL") + require(replay[0].0 == coldId, "replay must preserve the request ID") + require(replay[0].1 == coldUrl, "replay must preserve the URL") + require( + lateStore.acknowledge(coldId, handled: true) == .handled, + "handled acknowledgement must resolve the request" + ) + require( + lateStore.expireAcknowledgement(coldId) == nil, + "resolved request must not fall back after its timeout" + ) + + let declinedUrl = URL(string: "https://example.com/declined")! + var declinedStore = CustomerIOReactNativeDeepLinkRequestStore() + _ = declinedStore.useHandler() + let declinedId = handlerRequest(declinedStore.accept(declinedUrl)) + require( + declinedStore.acknowledge(declinedId, handled: false) == .fallback(declinedUrl), + "declined request must select fallback" + ) + require( + declinedStore.acknowledge(declinedId, handled: true) == nil, + "request must resolve exactly once" + ) + + let absentUrl = URL(string: "myapp://absent")! + var absentStore = CustomerIOReactNativeDeepLinkRequestStore() + let absentId = bufferedRequest(absentStore.accept(absentUrl)) + require( + absentStore.expireReadiness(absentId) == absentUrl, + "missing handler must fall back after the readiness timeout" + ) + require( + absentStore.useHandler().isEmpty, + "an expired request must not replay to a late handler" + ) + + let timeoutUrl = URL(string: "myapp://timeout")! + var timeoutStore = CustomerIOReactNativeDeepLinkRequestStore() + _ = timeoutStore.useHandler() + let timeoutId = handlerRequest(timeoutStore.accept(timeoutUrl)) + require( + timeoutStore.expireAcknowledgement(timeoutId) == timeoutUrl, + "unacknowledged handler request must fall back" + ) + require( + timeoutStore.acknowledge(timeoutId, handled: true) == nil, + "late acknowledgement must not resolve an expired request" + ) + + let linkingUrl = URL(string: "myapp://legacy-linking")! + var linkingStore = CustomerIOReactNativeDeepLinkRequestStore() + let linkingId = bufferedRequest(linkingStore.accept(linkingUrl)) + require( + linkingStore.useLinking() == [linkingUrl], + "legacy Linking readiness must replay buffered URLs" + ) + require( + linkingStore.expireReadiness(linkingId) == nil, + "a URL replayed to Linking must not later fall back" + ) + + let handlerUrl = URL(string: "myapp://handler-wins")! + var handlerStore = CustomerIOReactNativeDeepLinkRequestStore() + _ = handlerStore.useHandler() + let handlerId = handlerRequest(handlerStore.accept(handlerUrl)) + require( + handlerStore.useLinking().isEmpty, + "initialization must not replace an explicit handler with Linking" + ) + require( + handlerStore.acknowledge(handlerId, handled: true) == .handled, + "explicit handler must remain able to acknowledge the URL" + ) + + let requiredUrl = URL(string: "myapp://required-handler")! + var requiredStore = CustomerIOReactNativeDeepLinkRequestStore() + requiredStore.requireAcknowledgedHandler() + let requiredId = bufferedRequest(requiredStore.accept(requiredUrl)) + require( + requiredStore.useLinking().isEmpty, + "native initialization must not drain a URL reserved for the acknowledged handler" + ) + let requiredReplay = requiredStore.useHandler() + require( + requiredReplay.count == 1 && requiredReplay[0].0 == requiredId, + "a handler registered after Linking readiness must receive the reserved URL" + ) + + let reloadUrl = URL(string: "myapp://bridge-reload")! + var reloadStore = CustomerIOReactNativeDeepLinkRequestStore() + _ = reloadStore.useLinking() + _ = reloadStore.useHandler() + let oldReloadId = handlerRequest(reloadStore.accept(reloadUrl)) + reloadStore.removeHandler() + require( + reloadStore.acknowledge(oldReloadId, handled: true) == .handled, + "handler removal must preserve an in-flight acknowledgement" + ) + require( + reloadStore.accept(linkingUrl) == .linking(linkingUrl), + "handler removal must restore previously signalled Linking readiness for new URLs" + ) + + let replacementUrl = URL(string: "myapp://handler-replacement")! + var replacementStore = CustomerIOReactNativeDeepLinkRequestStore() + replacementStore.requireAcknowledgedHandler() + _ = replacementStore.useHandler() + let oldReplacementId = handlerRequest(replacementStore.accept(replacementUrl)) + replacementStore.removeHandler() + let replacementReplay = replacementStore.useHandler() + require( + replacementReplay.isEmpty, + "a replacement handler must not receive a URL still awaiting its original acknowledgement" + ) + require( + replacementStore.acknowledge(oldReplacementId, handled: true) == .handled, + "the original handler must be able to resolve its in-flight URL after replacement" + ) + + print("React Native deep-link request store tests passed") + } +} diff --git a/src/customerio-cdp.ts b/src/customerio-cdp.ts index ee1e002d..56051166 100644 --- a/src/customerio-cdp.ts +++ b/src/customerio-cdp.ts @@ -23,6 +23,22 @@ const packageJson = require('customerio-reactnative/package.json'); // Track whether CustomerIO SDK has been initialized to prevent usage before setup let _initialized = false; +let deepLinkHandlerSubscription: DeepLinkHandlerSubscription | undefined; + +/** + * Handles a Customer.io deep-link destination delivered from iOS. + * @public + */ +export type DeepLinkHandler = (url: string) => boolean | Promise; + +/** + * Removes a Customer.io deep-link handler. + * @public + */ +export interface DeepLinkHandlerSubscription { + /** Stop routing Customer.io deep links through this handler. */ + remove(): void; +} // Reference to the native CustomerIO Data Pipelines module for SDK operations const nativeModule = ensureNativeModule(NativeModule); @@ -66,6 +82,92 @@ export class CustomerIO { return withNativeModule((native) => native.setDeepLinkRoutingReady()); }; + /** + * Register an acknowledged deep-link handler for an iOS UIScene host. + * + * This API only handles Customer.io destinations delivered through an iOS UIScene lifecycle. + * In a native React Native scene host, pair this with + * `NativeCustomerIO.configureAcknowledgedSceneDeepLinkRouting()` in the SceneDelegate. Register + * this before `initialize` when possible. Return `true` after routing a URL. Returning `false`, + * throwing, or rejecting lets the native SDK try the host AppDelegate, then React Native Linking + * for a host-owned custom scheme or the system for other URLs. Cold URLs are buffered until this + * handler is registered, subject to the native timeout. After delivery, the handler has ten + * seconds to settle. Once that timeout runs the native fallback, a late handler cannot cancel it + * and may cause a second navigation if it also routes the URL. + */ + static readonly setDeepLinkHandler = ( + handler: DeepLinkHandler + ): DeepLinkHandlerSubscription => { + if (typeof handler !== 'function') { + throw new Error('[CustomerIO] "handler" must be a function.'); + } + + // Native unregister is tokenless and removes the current native handler. Remove the old + // subscription first so its cleanup cannot unregister the replacement. + deepLinkHandlerSubscription?.remove(); + + return withNativeModule((native) => { + const nativeSubscription = native.onDeepLinkReceived(async (data) => { + const event = data as { id?: unknown; url?: unknown }; + if (typeof event.id !== 'string' || typeof event.url !== 'string') { + NativeLoggerListener.warn( + 'Received an invalid native deep-link event.' + ); + return; + } + + let handled = false; + try { + handled = (await handler(event.url)) === true; + } catch (error) { + NativeLoggerListener.warn('Deep-link handler failed:', error); + } + + try { + native.acknowledgeDeepLink(event.id, handled); + } catch (error) { + NativeLoggerListener.warn( + 'Failed to acknowledge a deep-link result:', + error + ); + } + }); + + let removed = false; + const subscription: DeepLinkHandlerSubscription = { + remove: () => { + if (removed) { + return; + } + removed = true; + try { + native.unregisterDeepLinkHandler(); + } finally { + nativeSubscription.remove(); + if (deepLinkHandlerSubscription === subscription) { + deepLinkHandlerSubscription = undefined; + } + } + }, + }; + + try { + native.registerDeepLinkHandler(); + } catch (error) { + try { + native.unregisterDeepLinkHandler(); + } catch { + // Preserve the registration error; the native timeout still owns pending URLs. + } + nativeSubscription.remove(); + throw error; + } + + deepLinkHandlerSubscription = subscription; + return subscription; + }); + }; + /** Identify a user to start tracking their activity. Requires userId, traits, or both. */ static readonly identify = async ({ userId, diff --git a/src/specs/modules/NativeCustomerIO.ts b/src/specs/modules/NativeCustomerIO.ts index 114f7570..c14d1e80 100644 --- a/src/specs/modules/NativeCustomerIO.ts +++ b/src/specs/modules/NativeCustomerIO.ts @@ -1,5 +1,8 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native'; -import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes'; +import type { + EventEmitter, + UnsafeObject, +} from 'react-native/Libraries/Types/CodegenTypes'; /** * Native module specification for CustomerIO React Native SDK @@ -42,6 +45,10 @@ export interface Spec extends TurboModule { args: NativeBridgeObject ): Promise; setDeepLinkRoutingReady(): void; + readonly onDeepLinkReceived: EventEmitter; + registerDeepLinkHandler(): void; + unregisterDeepLinkHandler(): void; + acknowledgeDeepLink(id: string, handled: boolean): void; identify(params?: NativeBridgeObject): void; clearIdentify(): void; track(name: string, properties?: NativeBridgeObject): void;