diff --git a/.artifacts/feedback/search-before-specifying-interface-method.md b/.artifacts/feedback/search-before-specifying-interface-method.md new file mode 100644 index 000000000..d8aa9d5d8 --- /dev/null +++ b/.artifacts/feedback/search-before-specifying-interface-method.md @@ -0,0 +1,10 @@ +--- +kind: decision +applies-to: rules/specifier.md +--- + +# Search for an existing owner before specifying a method body + +Before a spec is finalized, search the tracker and open pull requests for every interface method this change must implement. Include long-lived branches, not only the default branch. If a ticket already defines that method, the spec depends on it and keeps that behaviour. A no-op is allowed only when the search finds no owner. + +A compile stub is not "too small for a spec" when the class already implements the interface and the pinned SDK requires a new method body. That body is a bridge contract another ticket may already own. Specifying an empty override on the default branch will overwrite the real behaviour when the branches meet. diff --git a/.artifacts/feedback/search-before-stubbing-interface-method.md b/.artifacts/feedback/search-before-stubbing-interface-method.md new file mode 100644 index 000000000..0f2543e9b --- /dev/null +++ b/.artifacts/feedback/search-before-stubbing-interface-method.md @@ -0,0 +1,10 @@ +--- +kind: decision +applies-to: rules/developer.md +--- + +# Search for an existing owner before stubbing an interface method + +Before writing a body for an interface method the current ticket did not name, search the tracker and branches other than the one you are on, including long-lived feature branches and open pull requests. If a ticket already defines the method, implement that contract or stop and ask. Do not invent a no-op. + +A green build of the current branch only proves the stub compiles. It cannot see an implementation that already emits, forwards, or otherwise behaves, sitting on another branch. Record the search in the handoff: the ticket, the branch, and whether the method already has a behaviour or is still absent. diff --git a/CHANGELOG.md b/CHANGELOG.md index c0673d32c..7abbd0bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## Unreleased + +### Updates + +- Added `IterableConfig.enableUnknownUserActivation` (default `false`) to opt in to Unknown User Activation on the native SDK (SDK-794). Visitor consent via `Iterable.setVisitorUsageTracked` is also required before the native SDK stores unknown-user events. +- Added `IterableConfig.enableForegroundCriteriaFetch` (default `true`) to control whether the SDK fetches unknown-user targeting criteria when the app enters the foreground (SDK-554). +- Added `IterableConfig.eventThresholdLimit` (default `100`) to cap locally stored unknown-user events before identification (SDK-553). The value is forwarded to the native SDK and applies when unknown-user activation is enabled. +- 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. +- Added `IterableConfig.identityResolution` and the `IterableIdentityResolution` type so RN apps can configure default visitor replay and unknown-to-known merge behavior (SDK-556). +- Added `IterableConfig.unknownUserHandler` so JWT-enabled apps can receive the generated unknown user id before native requests a token (SDK-804). Store the id synchronously in the handler and mint in `authHandler` for that id. + ## 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..1e89933f6 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -39,6 +39,7 @@ import com.iterable.iterableapi.IterableInAppMessage; import com.iterable.iterableapi.IterableInboxSession; import com.iterable.iterableapi.IterableLogger; +import com.iterable.iterableapi.IterableUnknownUserHandler; import com.iterable.iterableapi.IterableUrlHandler; import com.iterable.iterableapi.RNIterableInternal; @@ -53,7 +54,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCustomActionHandler, IterableInAppHandler, IterableAuthHandler, IterableInAppManager.Listener, IterableEmbeddedUpdateHandler { +public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCustomActionHandler, IterableInAppHandler, IterableAuthHandler, IterableUnknownUserHandler, IterableInAppManager.Listener, IterableEmbeddedUpdateHandler { public static final String NAME = "RNIterableAPI"; private static String TAG = "RNIterableAPIModule"; @@ -93,6 +94,10 @@ public void initializeWithApiKey(String apiKey, ReadableMap configReadableMap, S configBuilder.setAuthHandler(this); } + if (configReadableMap.hasKey("unknownUserHandlerPresent") && configReadableMap.getBoolean("unknownUserHandlerPresent") == true) { + configBuilder.setUnknownUserHandler(this); + } + // Check if embedded messaging is enabled before building config boolean enableEmbeddedMessaging = configReadableMap.hasKey("enableEmbeddedMessaging") && configReadableMap.getBoolean("enableEmbeddedMessaging"); @@ -161,6 +166,10 @@ public void initialize2WithApiKey(String apiKey, ReadableMap configReadableMap, configBuilder.setAuthHandler(this); } + if (configReadableMap.hasKey("unknownUserHandlerPresent") && configReadableMap.getBoolean("unknownUserHandlerPresent") == true) { + configBuilder.setUnknownUserHandler(this); + } + // NOTE: There does not seem to be a way to set the API endpoint // override in the Android SDK. Check with @Ayyanchira and @evantk91 to // see what the best approach is. @@ -249,6 +258,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)); @@ -741,6 +759,18 @@ public void onEmbeddedMessagingDisabled() { sendEvent(EventName.handleEmbeddedMessagingDisabledCalled.name(), null); } + @Override + public void onUnknownUserCreated(@NonNull String userId) { + JSONObject messageJson = new JSONObject(); + try { + messageJson.put("userId", userId); + WritableMap eventData = Serialization.convertJsonToMap(messageJson); + sendEvent(EventName.handleUnknownUserCreatedCalled.name(), eventData); + } catch (JSONException e) { + IterableLogger.e(TAG, "Failed to emit unknown user created event"); + } + } + @Override public void onEmbeddedMessagingSyncSucceeded() { IterableLogger.d(TAG, "onEmbeddedMessagingSyncSucceeded"); @@ -841,6 +871,7 @@ enum EventName { handleCustomActionCalled, handleEmbeddedMessageUpdateCalled, handleEmbeddedMessagingDisabledCalled, + handleUnknownUserCreatedCalled, handleInAppCalled, handleUrlCalled, receivedIterableEmbeddedMessagesChanged, diff --git a/android/src/main/java/com/iterable/reactnative/Serialization.java b/android/src/main/java/com/iterable/reactnative/Serialization.java index 97aa52f49..57e8249b8 100644 --- a/android/src/main/java/com/iterable/reactnative/Serialization.java +++ b/android/src/main/java/com/iterable/reactnative/Serialization.java @@ -22,6 +22,7 @@ import com.iterable.iterableapi.IterableInAppHandler; import com.iterable.iterableapi.IterableInAppLocation; import com.iterable.iterableapi.IterableInAppMessage; +import com.iterable.iterableapi.IterableIdentityResolution; import com.iterable.iterableapi.IterableInboxSession; import com.iterable.iterableapi.IterableLogger; import com.iterable.iterableapi.RetryPolicy; @@ -201,6 +202,11 @@ static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableConte configBuilder.setExpiringAuthTokenRefreshPeriod(iterableContextJSON.optLong("expiringAuthTokenRefreshPeriod")); } + if (iterableContextJSON.has("eventThresholdLimit")) { + configBuilder.setEventThresholdLimit( + iterableContextJSON.optInt("eventThresholdLimit", 100)); + } + if (iterableContextJSON.has("useInMemoryStorageForInApps") || iterableContextJSON.has("androidSdkUseInMemoryStorageForInApps")) { configBuilder.setUseInMemoryStorageForInApps(iterableContextJSON.optBoolean("useInMemoryStorageForInApps")); } @@ -246,10 +252,19 @@ static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableConte configBuilder.setDataRegion(iterableDataRegion); } + if (iterableContextJSON.has("enableForegroundCriteriaFetch")) { + configBuilder.setEnableForegroundCriteriaFetch(iterableContextJSON.optBoolean("enableForegroundCriteriaFetch")); + } + if (iterableContextJSON.has("enableEmbeddedMessaging")) { configBuilder.setEnableEmbeddedMessaging(iterableContextJSON.optBoolean("enableEmbeddedMessaging")); } + if (iterableContextJSON.has("enableUnknownUserActivation")) { + configBuilder.setEnableUnknownUserActivation( + iterableContextJSON.optBoolean("enableUnknownUserActivation")); + } + if (iterableContextJSON.has("retryPolicy")) { JSONObject retryPolicyJson = iterableContextJSON.getJSONObject("retryPolicy"); int maxRetry = retryPolicyJson.getInt("maxRetry"); @@ -262,6 +277,17 @@ static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableConte configBuilder.setAuthRetryPolicy(new RetryPolicy(maxRetry, retryInterval, retryPolicyType)); } + if (iterableContextJSON.has("identityResolution")) { + JSONObject identityResolutionJson = + iterableContextJSON.getJSONObject("identityResolution"); + boolean replayOnVisitorToKnown = + identityResolutionJson.optBoolean("replayOnVisitorToKnown", true); + boolean mergeOnUnknownToKnown = + identityResolutionJson.optBoolean("mergeOnUnknownToKnown", true); + configBuilder.setIdentityResolution( + new IterableIdentityResolution(replayOnVisitorToKnown, mergeOnUnknownToKnown)); + } + return configBuilder; } catch (JSONException e) { e.printStackTrace(); 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..4dac36d7d 100644 --- a/ios/RNIterableAPI/RNIterableAPI.mm +++ b/ios/RNIterableAPI/RNIterableAPI.mm @@ -12,6 +12,7 @@ @protocol IterableInAppDelegate; @protocol IterableCustomActionDelegate; @protocol IterableAuthDelegate; +@protocol IterableUnknownUserHandler; @protocol IterableURLDelegate; @protocol IterableEmbeddedUpdateDelegate; typedef NS_ENUM(NSInteger, InAppShowResponse) { @@ -112,6 +113,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 +413,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..c0cbccd35 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -35,6 +35,7 @@ import React case handleAuthFailureCalled case handleEmbeddedMessageUpdateCalled case handleEmbeddedMessagingDisabledCalled + case handleUnknownUserCreatedCalled } @objc public static var supportedEvents: [String] { @@ -129,6 +130,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:) @@ -662,6 +678,12 @@ import React iterableConfig.authDelegate = self } + if let unknownUserHandlerPresent = configDict["unknownUserHandlerPresent"] as? Bool, + unknownUserHandlerPresent == true + { + iterableConfig.unknownUserHandler = self + } + // connect new inbox in-app payloads to the RN SDK NotificationCenter.default.addObserver( self, selector: #selector(receivedIterableInboxChanged), @@ -860,6 +882,18 @@ extension ReactIterableAPI: IterableAuthDelegate { } } +extension ReactIterableAPI: IterableUnknownUserHandler { + public func onUnknownUserCreated(userId: String) { + ITBInfo() + guard shouldEmit else { + return + } + delegate?.sendEvent( + withName: EventName.handleUnknownUserCreatedCalled.rawValue, + body: ["userId": userId]) + } +} + extension ReactIterableAPI: IterableEmbeddedUpdateDelegate { public func onMessagesUpdated() { ITBInfo() diff --git a/ios/RNIterableAPI/Serialization.swift b/ios/RNIterableAPI/Serialization.swift index fee4c49b6..07bbb616e 100644 --- a/ios/RNIterableAPI/Serialization.swift +++ b/ios/RNIterableAPI/Serialization.swift @@ -75,6 +75,10 @@ extension IterableConfig { config.expiringAuthTokenRefreshPeriod = expiringAuthTokenRefreshPeriod } + if let eventThresholdLimit = dict["eventThresholdLimit"] as? Int { + config.eventThresholdLimit = eventThresholdLimit + } + if let logLevelNumber = dict["logLevel"] as? NSNumber { config.logDelegate = createLogDelegate(logLevelNumber: logLevelNumber) } @@ -83,10 +87,18 @@ extension IterableConfig { config.useInMemoryStorageForInApps = useInMemoryStorageForInApp } + if let enableForegroundCriteriaFetch = dict["enableForegroundCriteriaFetch"] as? Bool { + config.enableForegroundCriteriaFetch = enableForegroundCriteriaFetch + } + if let enableEmbeddedMessaging = dict["enableEmbeddedMessaging"] as? Bool { config.enableEmbeddedMessaging = enableEmbeddedMessaging } + if let enableUnknownUserActivation = dict["enableUnknownUserActivation"] as? Bool { + config.enableUnknownUserActivation = enableUnknownUserActivation + } + if let dataRegion = dict["dataRegion"] as? NSNumber { switch dataRegion { case 0: @@ -110,6 +122,16 @@ extension IterableConfig { } } + if let identityResolutionDict = dict["identityResolution"] as? [AnyHashable: Any] { + let replayOnVisitorToKnown = + identityResolutionDict["replayOnVisitorToKnown"] as? Bool ?? true + let mergeOnUnknownToKnown = + identityResolutionDict["mergeOnUnknownToKnown"] as? Bool ?? true + config.identityResolution = IterableIdentityResolution( + replayOnVisitorToKnown: replayOnVisitorToKnown, + mergeOnUnknownToKnown: mergeOnUnknownToKnown) + } + return config } 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/__mocks__/jest.setup.ts b/src/__mocks__/jest.setup.ts index 96c17ca52..18a12e8fe 100644 --- a/src/__mocks__/jest.setup.ts +++ b/src/__mocks__/jest.setup.ts @@ -56,3 +56,6 @@ jest.doMock('react-native', () => { ReactNative ); }); + +/** Shared backing store for all NativeEventEmitter instances in tests. */ +export const testNativeEventEmitter = mockNativeEventEmitter; 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..a3444c413 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -2,6 +2,7 @@ import { NativeEventEmitter, Platform } from 'react-native'; import { MockLinking } from '../../__mocks__/MockLinking'; import { MockRNIterableAPI } from '../../__mocks__/MockRNIterableAPI'; +import { testNativeEventEmitter } from '../../__mocks__/jest.setup'; // import from the same location that consumers import from import { Iterable, @@ -23,6 +24,7 @@ import { IterableInAppTriggerType, IterableLogLevel, } from '../..'; +import { resetSdkInitializationStateForTests } from './Iterable'; import { TestHelper } from '../../__tests__/TestHelper'; import { IterableLogger } from './IterableLogger'; @@ -48,6 +50,9 @@ describe('Iterable', () => { nativeEmitter.removeAllListeners( IterableEventName.handleEmbeddedMessagingDisabledCalled ); + nativeEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); // Clear any pending timers jest.clearAllTimers(); @@ -81,6 +86,101 @@ describe('Iterable', () => { }); }); + describe('visitorUsageTracked', () => { + beforeEach(() => { + MockRNIterableAPI.visitorUsageTracked = false; + MockRNIterableAPI.unknownVisitorEvents = []; + 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); + + 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 @@ -325,9 +425,12 @@ describe('Iterable', () => { expect(config.checkForDeferredDeeplink).toBe(false); expect(config.customActionHandler).toBe(undefined); expect(config.dataRegion).toBe(IterableDataRegion.US); + expect(config.enableForegroundCriteriaFetch).toBe(true); expect(config.enableEmbeddedMessaging).toBe(false); + expect(config.enableUnknownUserActivation).toBe(false); expect(config.encryptionEnforced).toBe(false); expect(config.expiringAuthTokenRefreshPeriod).toBe(60.0); + expect(config.eventThresholdLimit).toBe(100); expect(config.inAppDisplayInterval).toBe(30.0); expect(config.inAppHandler).toBe(undefined); expect(config.logLevel).toBe(IterableLogLevel.debug); @@ -337,6 +440,10 @@ describe('Iterable', () => { expect(config.useInMemoryStorageForInApps).toBe(false); expect(config.androidWakeDelayMs).toBe(1000); expect(config.authCallbackTimeoutMs).toBe(6000); + expect(config.identityResolution).toEqual({ + replayOnVisitorToKnown: true, + mergeOnUnknownToKnown: true, + }); const configDict = config.toDict(); expect(configDict.allowedProtocols).toEqual([]); expect(configDict.androidSdkUseInMemoryStorageForInApps).toBe(false); @@ -344,9 +451,12 @@ describe('Iterable', () => { expect(configDict.autoPushRegistration).toBe(true); expect(configDict.customActionHandlerPresent).toBe(false); expect(configDict.dataRegion).toBe(IterableDataRegion.US); + expect(configDict.enableForegroundCriteriaFetch).toBe(true); expect(configDict.enableEmbeddedMessaging).toBe(false); + expect(configDict.enableUnknownUserActivation).toBe(false); expect(configDict.encryptionEnforced).toBe(false); expect(configDict.expiringAuthTokenRefreshPeriod).toBe(60.0); + expect(configDict.eventThresholdLimit).toBe(100); expect(configDict.inAppDisplayInterval).toBe(30.0); expect(configDict.inAppHandlerPresent).toBe(false); expect(configDict.logLevel).toBe(IterableLogLevel.debug); @@ -355,6 +465,10 @@ describe('Iterable', () => { expect(configDict.useInMemoryStorageForInApps).toBe(false); expect(configDict.androidWakeDelayMs).toBe(1000); expect(configDict.authCallbackTimeoutMs).toBe(6000); + expect(configDict.identityResolution).toEqual({ + replayOnVisitorToKnown: true, + mergeOnUnknownToKnown: true, + }); }); it('should allow overriding androidWakeDelayMs and authCallbackTimeoutMs', () => { @@ -365,6 +479,80 @@ describe('Iterable', () => { expect(configDict.androidWakeDelayMs).toBe(1500); expect(configDict.authCallbackTimeoutMs).toBe(2500); }); + + it('should include enableUnknownUserActivation in toDict() when set to true', () => { + const config = new IterableConfig(); + config.enableUnknownUserActivation = true; + const configDict = config.toDict(); + expect(configDict.enableUnknownUserActivation).toBe(true); + }); + + it('should include enableForegroundCriteriaFetch in toDict() when set to false', () => { + const config = new IterableConfig(); + config.enableForegroundCriteriaFetch = false; + const configDict = config.toDict(); + expect(configDict.enableForegroundCriteriaFetch).toBe(false); + }); + + it('should include eventThresholdLimit in toDict() when overridden', () => { + const config = new IterableConfig(); + config.eventThresholdLimit = 50; + const configDict = config.toDict(); + expect(configDict.eventThresholdLimit).toBe(50); + }); + + it('should include identityResolution in toDict() with overridden values', () => { + const config = new IterableConfig(); + config.identityResolution = { + replayOnVisitorToKnown: false, + mergeOnUnknownToKnown: false, + }; + const configDict = config.toDict(); + expect(configDict.identityResolution).toEqual({ + replayOnVisitorToKnown: false, + mergeOnUnknownToKnown: false, + }); + }); + + it('should default missing identityResolution keys to true in toDict()', () => { + const config = new IterableConfig(); + config.identityResolution = { mergeOnUnknownToKnown: false }; + const configDict = config.toDict(); + expect(configDict.identityResolution).toEqual({ + replayOnVisitorToKnown: true, + mergeOnUnknownToKnown: false, + }); + }); + + it('should default mergeOnUnknownToKnown to true when only replayOnVisitorToKnown is set', () => { + const config = new IterableConfig(); + config.identityResolution = { replayOnVisitorToKnown: false }; + const configDict = config.toDict(); + expect(configDict.identityResolution).toEqual({ + replayOnVisitorToKnown: false, + mergeOnUnknownToKnown: true, + }); + }); + + it('should default both identityResolution keys to true for an empty object', () => { + const config = new IterableConfig(); + config.identityResolution = {}; + const configDict = config.toDict(); + expect(configDict.identityResolution).toEqual({ + replayOnVisitorToKnown: true, + mergeOnUnknownToKnown: true, + }); + }); + + it('should default both identityResolution keys to true when identityResolution is undefined', () => { + const config = new IterableConfig(); + config.identityResolution = undefined as unknown as IterableConfig['identityResolution']; + const configDict = config.toDict(); + expect(configDict.identityResolution).toEqual({ + replayOnVisitorToKnown: true, + mergeOnUnknownToKnown: true, + }); + }); }); describe('urlHandler', () => { @@ -1261,6 +1449,78 @@ describe('Iterable', () => { }); }); + describe('unknownUserHandler', () => { + it('should call unknownUserHandler when handleUnknownUserCreatedCalled is emitted with userId', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.unknownUserHandler = jest.fn(); + Iterable.initialize('apiKey', config); + expect( + testNativeEventEmitter.listenerCount( + IterableEventName.handleUnknownUserCreatedCalled + ) + ).toBe(1); + nativeEmitter.emit(IterableEventName.handleUnknownUserCreatedCalled, { + userId: 'generated-unknown-user-id', + }); + expect(config.unknownUserHandler).toHaveBeenCalledWith( + 'generated-unknown-user-id' + ); + }); + + it('should not register a handleUnknownUserCreatedCalled listener when unknownUserHandler is omitted', () => { + testNativeEventEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + Iterable.initialize('apiKey', config); + expect( + testNativeEventEmitter.listenerCount( + IterableEventName.handleUnknownUserCreatedCalled + ) + ).toBe(0); + }); + + it('should include unknownUserHandlerPresent in toDict() when the handler is set', () => { + const config = new IterableConfig(); + config.unknownUserHandler = jest.fn(); + expect(config.toDict().unknownUserHandlerPresent).toBe(true); + }); + + it('should set unknownUserHandlerPresent to false when the handler is omitted', () => { + const config = new IterableConfig(); + expect(config.toDict().unknownUserHandlerPresent).toBe(false); + }); + + it('should log without the userId when unknownUserHandler throws', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); + const secretUserId = 'secret-unknown-user-id-for-log-test'; + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.unknownUserHandler = jest.fn(() => { + throw new Error(`handler failed for ${secretUserId}`); + }); + const logSpy = jest.spyOn(IterableLogger, 'log'); + Iterable.initialize('apiKey', config); + nativeEmitter.emit(IterableEventName.handleUnknownUserCreatedCalled, { + userId: secretUserId, + }); + const loggedText = logSpy.mock.calls + .map((args) => String(args[0])) + .join('\n'); + expect(loggedText).not.toContain(secretUserId); + logSpy.mockRestore(); + }); + }); + describe('embedded messaging callbacks', () => { describe('onEmbeddedMessageUpdate', () => { it('should call onEmbeddedMessageUpdate when handleEmbeddedMessageUpdateCalled event is emitted', () => { diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index aaaa84073..4c5b65c53 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -27,6 +27,13 @@ const RNEventEmitter = new NativeEventEmitter(RNIterableAPI); const defaultConfig = new IterableConfig(); +let sdkInitialized = false; + +/** Resets init gating for unit tests only; not part of the public package API. */ +export function resetSdkInitializationStateForTests(): void { + sdkInitialized = false; +} + /** * Checks if the response is an IterableAuthResponse */ @@ -157,12 +164,18 @@ export class Iterable { apiKey: string, config: IterableConfig = new IterableConfig() ): Promise { + 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) => { + sdkInitialized = true; + return initialized; + } + ); } /** @@ -176,6 +189,7 @@ export class Iterable { config: IterableConfig = new IterableConfig(), apiEndPoint: string ): Promise { + sdkInitialized = false; this.setupIterable(config); const version = this.getVersionFromPackageJson(); @@ -184,6 +198,9 @@ export class Iterable { config, version, apiEndPoint, + }).then((initialized) => { + sdkInitialized = true; + return initialized; }); } @@ -333,6 +350,45 @@ 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. + * + * This method only records consent; it does not opt the app into Unknown User + * Activation by itself. Unknown-user flows also require + * `IterableConfig.enableUnknownUserActivation` (defaults to `false`) at + * `Iterable.initialize`. Use {@link Iterable.getVisitorUsageTracked} to read + * the stored value. + * + * No-op before `Iterable.initialize` has resolved (does not forward to native). + */ + static setVisitorUsageTracked(tracked: boolean): void { + if (!sdkInitialized) { + return; + } + IterableApi.setVisitorUsageTracked(tracked); + } + + /** + * 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`. Visitor consent alone + * does not enable unknown-user flows; those also require + * `IterableConfig.enableUnknownUserActivation` (defaults to `false`) at init. + */ + static getVisitorUsageTracked(): Promise { + if (!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. * @@ -976,6 +1032,9 @@ export class Iterable { RNEventEmitter.removeAllListeners( IterableEventName.handleEmbeddedMessagingDisabledCalled ); + RNEventEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); } /** @@ -1249,6 +1308,25 @@ export class Iterable { ); } + if (Iterable.savedConfig.unknownUserHandler) { + RNEventEmitter.addListener( + IterableEventName.handleUnknownUserCreatedCalled, + (dict: { userId?: string }) => { + const userId = dict?.userId; + if (userId === undefined || userId === null) { + return; + } + try { + Iterable.savedConfig.unknownUserHandler!(userId); + } catch { + IterableLogger?.log( + 'IterableConfig.unknownUserHandler threw an error' + ); + } + } + ); + } + if (Iterable.savedConfig.enableEmbeddedMessaging) { if (Iterable.savedConfig.onEmbeddedMessageUpdate) { RNEventEmitter.addListener( 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. */ diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index b4de2fb41..8f1d283e8 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -4,6 +4,7 @@ import { IterableDataRegion } from '../enums/IterableDataRegion'; import { IterableLogLevel } from '../enums/IterableLogLevel'; import { IterablePushPlatform } from '../enums/IterablePushPlatform'; import type { IterableAuthFailure } from '../types/IterableAuthFailure'; +import type { IterableIdentityResolution } from '../types/IterableIdentityResolution'; import type { IterableRetryPolicy } from '../types/IterableRetryPolicy'; import { IterableAction } from './IterableAction'; import type { IterableActionContext } from './IterableActionContext'; @@ -262,6 +263,17 @@ export class IterableConfig { */ expiringAuthTokenRefreshPeriod = 60.0; + /** + * Maximum number of unknown-user (anonymous) events stored locally before + * identification. The native SDK drops the oldest events when the count + * exceeds this limit. + * + * The value is sent to the native SDK at init. The limit applies only while + * unknown-user activation is enabled (`enableUnknownUserActivation`) and + * visitor consent has been granted. + */ + eventThresholdLimit = 100; + /** * Use this array to declare the specific URL protocols that the SDK can * expect to see on incoming links from Iterable, so it knows that it can @@ -391,6 +403,68 @@ export class IterableConfig { */ authCallbackTimeoutMs = 6000; + /** + * When `true`, the native SDK may store and process events for unidentified + * (unknown) users. Defaults to `false`. + * + * Visitor consent via `Iterable.setVisitorUsageTracked` is also required + * before the native SDK stores unknown-user events or runs unknown-user flows. + */ + enableUnknownUserActivation = false; + + /** + * When `true` (the default), the SDK fetches unknown-user (anonymous) targeting + * criteria when the app enters the foreground. + * + * Set to `false` to disable that foreground fetch. + */ + enableForegroundCriteriaFetch = true; + + /** + * Default identity-resolution behavior when identifying a user with + * `Iterable.setEmail` or `Iterable.setUserId`. + * + * Applies when unknown-user activation and visitor tracking are enabled on the + * native SDK. Omitted keys in a partial object default to `true`. + */ + identityResolution: IterableIdentityResolution = { + replayOnVisitorToKnown: true, + mergeOnUnknownToKnown: true, + }; + + /** + * Called when the native SDK creates a generated user id after unknown-user + * activation criteria are met. + * + * JWT-enabled API keys: native logs in as that id immediately after this + * callback and invokes `authHandler` without passing the id. Store the + * `userId` argument **synchronously** here so `authHandler` can mint a token + * for that id in the same turn. Do not use `Iterable.getUserId()` in this + * callback; native has not switched identity yet. In `authHandler`, consume + * and clear that pending id on each call so later known-user logins and + * token refreshes use `Iterable.getUserId()` instead of a stale cached id. + * + * @param userId - The generated unknown user id from Iterable. + * + * @example + * ```typescript + * let pendingUnknownUserId: string | null = null; + * const config = new IterableConfig(); + * config.enableUnknownUserActivation = true; + * config.unknownUserHandler = (userId) => { + * pendingUnknownUserId = userId; + * }; + * config.authHandler = async () => { + * const unknownUserId = pendingUnknownUserId; + * pendingUnknownUserId = null; + * const id = unknownUserId ?? (await Iterable.getUserId()); + * return fetchJwtForUser(id); + * }; + * Iterable.initialize('', config); + * ``` + */ + unknownUserHandler?: (userId: string) => void; + /** * Should the SDK enable and use embedded messaging? * @@ -463,6 +537,10 @@ export class IterableConfig { * A boolean indicating if an authentication handler is present. */ authHandlerPresent: this.authHandler !== undefined, + /** + * A boolean indicating if an unknown-user handler is present. + */ + unknownUserHandlerPresent: this.unknownUserHandler !== undefined, /** * A boolean indicating if an embedded message update callback is present. */ @@ -476,6 +554,7 @@ export class IterableConfig { /** The log level for the SDK. */ logLevel: this.logLevel, expiringAuthTokenRefreshPeriod: this.expiringAuthTokenRefreshPeriod, + eventThresholdLimit: this.eventThresholdLimit, allowedProtocols: this.allowedProtocols, androidSdkUseInMemoryStorageForInApps: this.androidSdkUseInMemoryStorageForInApps, @@ -484,6 +563,14 @@ export class IterableConfig { pushPlatform: this.pushPlatform, encryptionEnforced: this.encryptionEnforced, retryPolicy: this.retryPolicy, + enableUnknownUserActivation: this.enableUnknownUserActivation, + enableForegroundCriteriaFetch: this.enableForegroundCriteriaFetch, + identityResolution: { + replayOnVisitorToKnown: + this.identityResolution?.replayOnVisitorToKnown ?? true, + mergeOnUnknownToKnown: + this.identityResolution?.mergeOnUnknownToKnown ?? true, + }, enableEmbeddedMessaging: this.enableEmbeddedMessaging, androidWakeDelayMs: this.androidWakeDelayMs, authCallbackTimeoutMs: this.authCallbackTimeoutMs, diff --git a/src/core/enums/IterableEventName.ts b/src/core/enums/IterableEventName.ts index 6ea79c754..a7332ad49 100644 --- a/src/core/enums/IterableEventName.ts +++ b/src/core/enums/IterableEventName.ts @@ -23,4 +23,6 @@ export enum IterableEventName { handleEmbeddedMessageUpdateCalled = 'handleEmbeddedMessageUpdateCalled', /** Event that fires when embedded messaging is disabled */ handleEmbeddedMessagingDisabledCalled = 'handleEmbeddedMessagingDisabledCalled', + /** Event that fires when the native SDK creates an unknown user id */ + handleUnknownUserCreatedCalled = 'handleUnknownUserCreatedCalled', } diff --git a/src/core/types/IterableIdentityResolution.ts b/src/core/types/IterableIdentityResolution.ts new file mode 100644 index 000000000..e6b45639c --- /dev/null +++ b/src/core/types/IterableIdentityResolution.ts @@ -0,0 +1,20 @@ +/** + * Controls how the SDK resolves anonymous (visitor / unknown-user) data when a + * user is identified with `setEmail` or `setUserId`. + * + * Values set on `IterableConfig.identityResolution` are defaults; native + * `setEmail` / `setUserId` can override them per call (not exposed in RN yet). + */ +export interface IterableIdentityResolution { + /** + * When `true`, locally stored visitor events are replayed to the known user + * profile on identification. + */ + replayOnVisitorToKnown?: boolean; + + /** + * When `true`, the generated unknown-user profile is merged into the known + * user profile on identification. + */ + mergeOnUnknownToKnown?: boolean; +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 7659a76e4..f133be55e 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -1,3 +1,4 @@ export * from './IterableAuthFailure'; export * from './IterableEdgeInsetDetails'; +export * from './IterableIdentityResolution'; export * from './IterableRetryPolicy'; diff --git a/src/index.tsx b/src/index.tsx index 13cb6915d..92a262a3a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,6 +30,7 @@ export { export type { IterableAuthFailure, IterableEdgeInsetDetails, + IterableIdentityResolution, IterableRetryPolicy, } from './core/types'; export {