From a62987ff2cfcdbd168ac6b2913f8dc79a60eee53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sat, 10 Oct 2026 12:28:13 +0100 Subject: [PATCH 1/2] feat(SDK-561): expose visitor usage consent on Iterable Add setVisitorUsageTracked and getVisitorUsageTracked across the TurboModule bridge. Guard calls until initialize resolves to avoid Android pre-init crashes. --- CHANGELOG.md | 6 ++ .../reactnative/RNIterableAPIModuleImpl.java | 9 ++ .../newarch/java/com/RNIterableAPIModule.java | 10 ++ .../oldarch/java/com/RNIterableAPIModule.java | 10 ++ ios/RNIterableAPI/RNIterableAPI.mm | 18 ++++ ios/RNIterableAPI/ReactIterableAPI.swift | 15 +++ src/__mocks__/MockRNIterableAPI.ts | 15 +++ src/api/NativeRNIterableAPI.ts | 2 + src/core/classes/Iterable.test.ts | 95 +++++++++++++++++++ src/core/classes/Iterable.ts | 53 ++++++++++- src/core/classes/IterableApi.test.ts | 19 ++++ src/core/classes/IterableApi.ts | 10 ++ 12 files changed, 261 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 771a9fca4..729eb38e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Updates + +- Added `Iterable.setVisitorUsageTracked` and `Iterable.getVisitorUsageTracked` to record and read visitor consent for Unknown User Activation (SDK-561). Consent is persisted by the native SDK; unknown-user activation is not enabled from React Native config yet. + ## 3.2.1 ### Fixes diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index c3396295d..b998b468a 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -249,6 +249,15 @@ public void getUserId(Promise promise) { promise.resolve(RNIterableInternal.getUserId()); } + public void setVisitorUsageTracked(boolean tracked) { + IterableLogger.d(TAG, "setVisitorUsageTracked: " + tracked); + IterableApi.getInstance().setVisitorUsageTracked(tracked); + } + + public void getVisitorUsageTracked(Promise promise) { + promise.resolve(IterableApi.getInstance().getVisitorUsageTracked()); + } + public void trackEvent(String name, @Nullable ReadableMap dataFields) { IterableLogger.v(TAG, "trackEvent"); IterableApi.getInstance().track(name, optSerializedDataFields(dataFields)); diff --git a/android/src/newarch/java/com/RNIterableAPIModule.java b/android/src/newarch/java/com/RNIterableAPIModule.java index fb71142b8..0cd0d9533 100644 --- a/android/src/newarch/java/com/RNIterableAPIModule.java +++ b/android/src/newarch/java/com/RNIterableAPIModule.java @@ -72,6 +72,16 @@ public void getUserId(Promise promise) { moduleImpl.getUserId(promise); } + @Override + public void setVisitorUsageTracked(boolean tracked) { + moduleImpl.setVisitorUsageTracked(tracked); + } + + @Override + public void getVisitorUsageTracked(Promise promise) { + moduleImpl.getVisitorUsageTracked(promise); + } + @Override public void trackEvent(String name, @Nullable ReadableMap dataFields) { moduleImpl.trackEvent(name, dataFields); diff --git a/android/src/oldarch/java/com/RNIterableAPIModule.java b/android/src/oldarch/java/com/RNIterableAPIModule.java index c2f9b3161..9efe250ce 100644 --- a/android/src/oldarch/java/com/RNIterableAPIModule.java +++ b/android/src/oldarch/java/com/RNIterableAPIModule.java @@ -73,6 +73,16 @@ public void getUserId(Promise promise) { moduleImpl.getUserId(promise); } + @ReactMethod + public void setVisitorUsageTracked(boolean tracked) { + moduleImpl.setVisitorUsageTracked(tracked); + } + + @ReactMethod + public void getVisitorUsageTracked(Promise promise) { + moduleImpl.getVisitorUsageTracked(promise); + } + @ReactMethod public void trackEvent(String name, @Nullable ReadableMap dataFields) { moduleImpl.trackEvent(name, dataFields); diff --git a/ios/RNIterableAPI/RNIterableAPI.mm b/ios/RNIterableAPI/RNIterableAPI.mm index 965b2d81e..745307dfd 100644 --- a/ios/RNIterableAPI/RNIterableAPI.mm +++ b/ios/RNIterableAPI/RNIterableAPI.mm @@ -112,6 +112,15 @@ - (void)getUserId:(RCTPromiseResolveBlock)resolve [_swiftAPI getUserId:resolve rejecter:reject]; } +- (void)setVisitorUsageTracked:(BOOL)tracked { + [_swiftAPI setVisitorUsageTracked:tracked]; +} + +- (void)getVisitorUsageTracked:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_swiftAPI getVisitorUsageTracked:resolve rejecter:reject]; +} + - (void)setInAppShowResponse:(double)inAppShowResponse { [_swiftAPI setInAppShowResponse:inAppShowResponse]; } @@ -403,6 +412,15 @@ - (void)wakeApp { [_swiftAPI getUserId:resolve rejecter:reject]; } +RCT_EXPORT_METHOD(setVisitorUsageTracked : (BOOL)tracked) { + [_swiftAPI setVisitorUsageTracked:tracked]; +} + +RCT_EXPORT_METHOD(getVisitorUsageTracked : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject) { + [_swiftAPI getVisitorUsageTracked:resolve rejecter:reject]; +} + RCT_EXPORT_METHOD(setInAppShowResponse : (double)inAppShowResponse) { [_swiftAPI setInAppShowResponse:inAppShowResponse]; } diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index a216a0e69..475edca3e 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -129,6 +129,21 @@ import React resolver(IterableAPI.userId) } + @objc(setVisitorUsageTracked:) + public func setVisitorUsageTracked(tracked: Bool) { + ITBInfo() + IterableAPI.setVisitorUsageTracked(isVisitorUsageTracked: tracked) + } + + @objc(getVisitorUsageTracked:rejecter:) + public func getVisitorUsageTracked( + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + ITBInfo() + resolver(IterableAPI.getVisitorUsageTracked()) + } + // MARK: - Iterable API Request Functions @objc(setInAppShowResponse:) diff --git a/src/__mocks__/MockRNIterableAPI.ts b/src/__mocks__/MockRNIterableAPI.ts index 60013bdbb..407e093a1 100644 --- a/src/__mocks__/MockRNIterableAPI.ts +++ b/src/__mocks__/MockRNIterableAPI.ts @@ -9,6 +9,8 @@ export class MockRNIterableAPI { static attributionInfo?: IterableAttributionInfo; static messages?: IterableInAppMessage[]; static clickedUrl?: string; + static visitorUsageTracked = false; + static unknownVisitorEvents: string[] = []; static async getEmail(): Promise { return await new Promise((resolve) => { @@ -32,6 +34,19 @@ export class MockRNIterableAPI { MockRNIterableAPI.token = authToken; }); + static setVisitorUsageTracked = jest.fn((tracked: boolean): void => { + MockRNIterableAPI.visitorUsageTracked = tracked; + MockRNIterableAPI.unknownVisitorEvents = []; + }); + + static getVisitorUsageTracked = jest.fn(async (): Promise => { + return await Promise.resolve(MockRNIterableAPI.visitorUsageTracked); + }); + + static pushUnknownVisitorEventForTests(eventId: string): void { + MockRNIterableAPI.unknownVisitorEvents.push(eventId); + } + static disableDeviceForCurrentUser = jest.fn(); static registerDeviceToken = jest.fn((token: string): void => { diff --git a/src/api/NativeRNIterableAPI.ts b/src/api/NativeRNIterableAPI.ts index 7e96bc649..6c27f8d6d 100644 --- a/src/api/NativeRNIterableAPI.ts +++ b/src/api/NativeRNIterableAPI.ts @@ -85,6 +85,8 @@ export interface Spec extends TurboModule { getEmail(): Promise; setUserId(userId?: string | null, authToken?: string | null): void; getUserId(): Promise; + setVisitorUsageTracked(tracked: boolean): void; + getVisitorUsageTracked(): Promise; // In-app messaging setInAppShowResponse(number: number): void; diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index baccc3e41..44806ae88 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -81,6 +81,101 @@ describe('Iterable', () => { }); }); + describe('visitorUsageTracked', () => { + beforeEach(() => { + MockRNIterableAPI.visitorUsageTracked = false; + MockRNIterableAPI.unknownVisitorEvents = []; + Iterable.resetSdkInitializationStateForTests(); + }); + + it('should expose setVisitorUsageTracked and getVisitorUsageTracked on Iterable', () => { + expect(typeof Iterable.setVisitorUsageTracked).toBe('function'); + expect(typeof Iterable.getVisitorUsageTracked).toBe('function'); + }); + + it('should forward true and false to RNIterableAPI after initialize', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + + Iterable.setVisitorUsageTracked(true); + expect(MockRNIterableAPI.setVisitorUsageTracked).toHaveBeenCalledWith(true); + + Iterable.setVisitorUsageTracked(false); + expect(MockRNIterableAPI.setVisitorUsageTracked).toHaveBeenCalledWith(false); + }); + + it('should resolve getVisitorUsageTracked to the value stored in the mock', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + + MockRNIterableAPI.visitorUsageTracked = true; + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(true); + + MockRNIterableAPI.visitorUsageTracked = false; + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(false); + }); + + it('should resolve to false when the integrator never calls the setter', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(false); + }); + + it('should return the persisted mock value after initialize runs again', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + Iterable.setVisitorUsageTracked(true); + + Iterable.resetSdkInitializationStateForTests(); + await Iterable.initialize('test-key', config); + + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(true); + }); + + it('should clear unknown-visitor events in the mock on each setVisitorUsageTracked call', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + MockRNIterableAPI.pushUnknownVisitorEventForTests('event-1'); + await Iterable.initialize('test-key', config); + + Iterable.setVisitorUsageTracked(false); + expect(MockRNIterableAPI.unknownVisitorEvents).toEqual([]); + + MockRNIterableAPI.pushUnknownVisitorEventForTests('event-2'); + Iterable.setVisitorUsageTracked(true); + expect(MockRNIterableAPI.unknownVisitorEvents).toEqual([]); + }); + + it('should not forward setVisitorUsageTracked before initialize resolves', async () => { + Iterable.setVisitorUsageTracked(true); + expect(MockRNIterableAPI.setVisitorUsageTracked).not.toHaveBeenCalled(); + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(false); + + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(false); + }); + + it('should not call trackEvent, setEmail, or setUserId from visitor usage methods', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + + Iterable.setVisitorUsageTracked(true); + await Iterable.getVisitorUsageTracked(); + + expect(MockRNIterableAPI.trackEvent).not.toHaveBeenCalled(); + expect(MockRNIterableAPI.setEmail).not.toHaveBeenCalled(); + expect(MockRNIterableAPI.setUserId).not.toHaveBeenCalled(); + }); + }); + describe('logout', () => { it('should call setEmail with null', () => { // GIVEN no parameters diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index aaaa84073..3bdd1e56c 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -68,6 +68,15 @@ export class Iterable { */ static savedConfig: IterableConfig = defaultConfig; + private static sdkInitialized = false; + + /** + * @internal + */ + static resetSdkInitializationStateForTests(): void { + Iterable.sdkInitialized = false; + } + /** * In-app message manager for the current user. * @@ -157,12 +166,18 @@ export class Iterable { apiKey: string, config: IterableConfig = new IterableConfig() ): Promise { + Iterable.sdkInitialized = false; Iterable.savedConfig = config; this.setupIterable(config); const version = this.getVersionFromPackageJson(); - return IterableApi.initializeWithApiKey(apiKey, { config, version }); + return IterableApi.initializeWithApiKey(apiKey, { config, version }).then( + (initialized) => { + Iterable.sdkInitialized = true; + return initialized; + } + ); } /** @@ -176,6 +191,7 @@ export class Iterable { config: IterableConfig = new IterableConfig(), apiEndPoint: string ): Promise { + Iterable.sdkInitialized = false; this.setupIterable(config); const version = this.getVersionFromPackageJson(); @@ -184,6 +200,9 @@ export class Iterable { config, version, apiEndPoint, + }).then((initialized) => { + Iterable.sdkInitialized = true; + return initialized; }); } @@ -333,6 +352,38 @@ export class Iterable { return IterableApi.getUserId(); } + /** + * Record whether the end user has consented to visitor / unknown-user tracking. + * + * The value is persisted by the native SDK across app restarts. Each call + * clears locally stored unknown-visitor events on the native side. + * + * Unknown User Activation is not enabled from React Native config yet, so + * this only stores and returns the consent flag until + * `enableUnknownUserActivation` is exposed. + * + * No-op before `Iterable.initialize` has resolved (does not forward to native). + */ + static setVisitorUsageTracked(tracked: boolean): void { + if (!Iterable.sdkInitialized) { + return; + } + IterableApi.setVisitorUsageTracked(tracked); + } + + /** + * Returns whether visitor / unknown-user tracking consent has been recorded. + * + * Resolves to `false` before `Iterable.initialize` has resolved or if the + * integrator has never called `setVisitorUsageTracked`. + */ + static getVisitorUsageTracked(): Promise { + if (!Iterable.sdkInitialized) { + return Promise.resolve(false); + } + return IterableApi.getVisitorUsageTracked(); + } + /** * Disable the device's token for the current user. This will disable push notifications for the current user. * diff --git a/src/core/classes/IterableApi.test.ts b/src/core/classes/IterableApi.test.ts index d639d79ed..0e99e9a49 100644 --- a/src/core/classes/IterableApi.test.ts +++ b/src/core/classes/IterableApi.test.ts @@ -271,6 +271,25 @@ describe('IterableApi', () => { }); }); + describe('visitorUsageTracked', () => { + beforeEach(() => { + MockRNIterableAPI.visitorUsageTracked = false; + }); + + it('should call RNIterableAPI.setVisitorUsageTracked with the tracked flag', () => { + IterableApi.setVisitorUsageTracked(true); + expect(MockRNIterableAPI.setVisitorUsageTracked).toHaveBeenCalledWith(true); + + IterableApi.setVisitorUsageTracked(false); + expect(MockRNIterableAPI.setVisitorUsageTracked).toHaveBeenCalledWith(false); + }); + + it('should return visitor usage tracked from RNIterableAPI', async () => { + MockRNIterableAPI.visitorUsageTracked = true; + await expect(IterableApi.getVisitorUsageTracked()).resolves.toBe(true); + }); + }); + describe('disableDeviceForCurrentUser', () => { it('should call RNIterableAPI.disableDeviceForCurrentUser', () => { // GIVEN no parameters diff --git a/src/core/classes/IterableApi.ts b/src/core/classes/IterableApi.ts index 2fad862a7..65377914f 100644 --- a/src/core/classes/IterableApi.ts +++ b/src/core/classes/IterableApi.ts @@ -129,6 +129,16 @@ export class IterableApi { return RNIterableAPI.getUserId(); } + static setVisitorUsageTracked(tracked: boolean) { + IterableLogger.log('setVisitorUsageTracked: ', tracked); + return RNIterableAPI.setVisitorUsageTracked(tracked); + } + + static getVisitorUsageTracked() { + IterableLogger.log('getVisitorUsageTracked'); + return RNIterableAPI.getVisitorUsageTracked(); + } + /** * Disable the device for the current user. */ From 37863f023651e572a700ee65a064525f51d2aee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sat, 10 Oct 2026 13:57:02 +0100 Subject: [PATCH 2/2] docs(SDK-561): fix visitor consent setter TSDoc Describe storage on setVisitorUsageTracked and reads on getVisitorUsageTracked. --- src/core/classes/Iterable.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 3bdd1e56c..d7ab9944d 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -359,8 +359,8 @@ export class Iterable { * clears locally stored unknown-visitor events on the native side. * * Unknown User Activation is not enabled from React Native config yet, so - * this only stores and returns the consent flag until - * `enableUnknownUserActivation` is exposed. + * this only stores the consent flag until `enableUnknownUserActivation` is + * exposed. Use {@link Iterable.getVisitorUsageTracked} to read the stored value. * * No-op before `Iterable.initialize` has resolved (does not forward to native). */ @@ -374,8 +374,13 @@ export class Iterable { /** * Returns whether visitor / unknown-user tracking consent has been recorded. * + * Reads the value persisted by the native SDK (including across app restarts + * after a prior `setVisitorUsageTracked` call). + * * Resolves to `false` before `Iterable.initialize` has resolved or if the - * integrator has never called `setVisitorUsageTracked`. + * integrator has never called `setVisitorUsageTracked`. Until + * `enableUnknownUserActivation` is exposed on `IterableConfig`, the flag is + * stored only and does not enable unknown-user flows by itself. */ static getVisitorUsageTracked(): Promise { if (!Iterable.sdkInitialized) {