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 de8835520..2fa1d83b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ - Added `Iterable.disableDeviceForAllUsers()` to unregister this device's push token from every user associated with the device (SDK-550). - iOS: forwards to native `IterableAPI.disableDeviceForAllUsers()`. - Android: graceful no-op that logs a warning; use `disableDeviceForCurrentUser()` to disable push for the current user. There is no public native "all users" equivalent. +- 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 9c1fddd39..2e8c32b23 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -40,6 +40,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; @@ -54,7 +55,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCustomActionHandler, IterableInAppHandler, IterableAuthHandler, IterableDecryptionFailureHandler, IterableInAppManager.Listener, IterableEmbeddedUpdateHandler { +public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCustomActionHandler, IterableInAppHandler, IterableAuthHandler, IterableUnknownUserHandler, IterableDecryptionFailureHandler, IterableInAppManager.Listener, IterableEmbeddedUpdateHandler { public static final String NAME = "RNIterableAPI"; private static String TAG = "RNIterableAPIModule"; @@ -94,6 +95,10 @@ public void initializeWithApiKey(String apiKey, ReadableMap configReadableMap, S configBuilder.setAuthHandler(this); } + if (configReadableMap.hasKey("unknownUserHandlerPresent") && configReadableMap.getBoolean("unknownUserHandlerPresent") == true) { + configBuilder.setUnknownUserHandler(this); + } + // IterableKeychain is created once with config.decryptionFailureHandler; the JS // listener remains conditional on decryptionFailureHandlerPresent. configBuilder.setDecryptionFailureHandler(this); @@ -166,6 +171,10 @@ public void initialize2WithApiKey(String apiKey, ReadableMap configReadableMap, configBuilder.setAuthHandler(this); } + if (configReadableMap.hasKey("unknownUserHandlerPresent") && configReadableMap.getBoolean("unknownUserHandlerPresent") == true) { + configBuilder.setUnknownUserHandler(this); + } + // See initializeWithApiKey — keychain caches this handler at first construction. configBuilder.setDecryptionFailureHandler(this); @@ -257,6 +266,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 getAuthToken(Promise promise) { promise.resolve(RNIterableInternal.getAuthToken()); } @@ -779,6 +797,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"); @@ -894,6 +924,7 @@ enum EventName { handleEmbeddedMessagingDisabledCalled, handleEmbeddedMessagingSyncSucceededCalled, handleEmbeddedMessagingSyncFailedCalled, + 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 55c40e38f..d709db4ab 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; @@ -202,6 +203,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")); } @@ -247,10 +253,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")); + } + configBuilder.setKeychainEncryption( iterableContextJSON.optBoolean("keychainEncryption", true)); @@ -266,6 +281,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 ab07951ce..574d91c8f 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 getAuthToken(Promise promise) { moduleImpl.getAuthToken(promise); diff --git a/android/src/oldarch/java/com/RNIterableAPIModule.java b/android/src/oldarch/java/com/RNIterableAPIModule.java index 4ee68aeb2..d867ef301 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 getAuthToken(Promise promise) { moduleImpl.getAuthToken(promise); diff --git a/ios/RNIterableAPI/RNIterableAPI.mm b/ios/RNIterableAPI/RNIterableAPI.mm index ae7e47ba3..e5ee3ee9d 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]; } @@ -412,6 +422,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 05009ad37..958f15691 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -39,6 +39,7 @@ import React case handleEmbeddedMessagingSyncFailedCalled // Android-only native API; listed so JS addListener does not warn on iOS. case handleDecryptionFailureCalled + case handleUnknownUserCreatedCalled } @objc public static var supportedEvents: [String] { @@ -133,6 +134,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:) @@ -683,6 +699,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), @@ -889,6 +911,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 4fcfcdda8..aaab9a1ff 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 23ba55ba3..94b45412a 100644 --- a/src/__mocks__/MockRNIterableAPI.ts +++ b/src/__mocks__/MockRNIterableAPI.ts @@ -10,6 +10,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) => { @@ -33,6 +35,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 disableDeviceForAllUsers = jest.fn(); diff --git a/src/__mocks__/jest.setup.ts b/src/__mocks__/jest.setup.ts index e619f4735..eb8e37468 100644 --- a/src/__mocks__/jest.setup.ts +++ b/src/__mocks__/jest.setup.ts @@ -58,3 +58,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 8ef525f2a..6a00a427d 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 05216c1a9..2e2cf5f16 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'; @@ -57,6 +59,9 @@ describe('Iterable', () => { nativeEmitter.removeAllListeners( IterableEventName.handleDecryptionFailureCalled ); + nativeEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); // Clear any pending timers jest.clearAllTimers(); @@ -90,6 +95,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 @@ -344,10 +444,13 @@ 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.keychainEncryption).toBe(true); 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); @@ -357,6 +460,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); @@ -364,10 +471,13 @@ 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.keychainEncryption).toBe(true); 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); @@ -376,6 +486,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', () => { @@ -387,6 +501,80 @@ describe('Iterable', () => { 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, + }); + }); + it('should allow opting out of Android keychain encryption', () => { const config = new IterableConfig(); config.keychainEncryption = false; @@ -1309,6 +1497,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('decryptionFailureHandler', () => { it('should call decryptionFailureHandler when handleDecryptionFailureCalled event is emitted', () => { const nativeEmitter = new NativeEventEmitter(); diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index d279d939a..5d36fa597 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -28,6 +28,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 */ @@ -158,12 +165,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; + } + ); } /** @@ -177,6 +190,7 @@ export class Iterable { config: IterableConfig = new IterableConfig(), apiEndPoint: string ): Promise { + sdkInitialized = false; this.setupIterable(config); const version = this.getVersionFromPackageJson(); @@ -185,6 +199,9 @@ export class Iterable { config, version, apiEndPoint, + }).then((initialized) => { + sdkInitialized = true; + return initialized; }); } @@ -334,6 +351,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. * @@ -1028,6 +1084,9 @@ export class Iterable { RNEventEmitter.removeAllListeners( IterableEventName.handleDecryptionFailureCalled ); + RNEventEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); } /** @@ -1301,6 +1360,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 d77083712..1177a0357 100644 --- a/src/core/classes/IterableApi.test.ts +++ b/src/core/classes/IterableApi.test.ts @@ -272,6 +272,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 58066eb5d..ce8f59cd2 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 b4d50197c..ef3f5502c 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -5,6 +5,7 @@ import { IterableLogLevel } from '../enums/IterableLogLevel'; import { IterablePushPlatform } from '../enums/IterablePushPlatform'; import type { IterableAuthFailure } from '../types/IterableAuthFailure'; import type { IterableDecryptionFailure } from '../types/IterableDecryptionFailure'; +import type { IterableIdentityResolution } from '../types/IterableIdentityResolution'; import type { IterableRetryPolicy } from '../types/IterableRetryPolicy'; import { IterableAction } from './IterableAction'; import type { IterableActionContext } from './IterableActionContext'; @@ -286,6 +287,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 @@ -421,6 +433,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? * @@ -533,6 +607,10 @@ export class IterableConfig { authHandlerPresent: this.authHandler !== undefined, decryptionFailureHandlerPresent: this.decryptionFailureHandler !== 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. */ @@ -556,6 +634,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, @@ -565,6 +644,14 @@ export class IterableConfig { keychainEncryption: this.keychainEncryption, 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 a50b6ca20..ebccb0571 100644 --- a/src/core/enums/IterableEventName.ts +++ b/src/core/enums/IterableEventName.ts @@ -33,4 +33,6 @@ export enum IterableEventName { * Event that fires when Android keychain decryption fails (Android only). */ handleDecryptionFailureCalled = 'handleDecryptionFailureCalled', + /** 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 a8f7f2daa..235d5a8b7 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -1,4 +1,5 @@ export * from './IterableAuthFailure'; export * from './IterableDecryptionFailure'; export * from './IterableEdgeInsetDetails'; +export * from './IterableIdentityResolution'; export * from './IterableRetryPolicy'; diff --git a/src/index.tsx b/src/index.tsx index 9d88a05aa..bc9756bf5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -31,6 +31,7 @@ export type { IterableAuthFailure, IterableDecryptionFailure, IterableEdgeInsetDetails, + IterableIdentityResolution, IterableRetryPolicy, } from './core/types'; export {