diff --git a/CHANGELOG.md b/CHANGELOG.md index 56547ffb5..7abbd0bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - 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 diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index b998b468a..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. @@ -750,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"); @@ -850,6 +871,7 @@ enum EventName { handleCustomActionCalled, handleEmbeddedMessageUpdateCalled, handleEmbeddedMessagingDisabledCalled, + handleUnknownUserCreatedCalled, handleInAppCalled, handleUrlCalled, receivedIterableEmbeddedMessagesChanged, diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index 475edca3e..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] { @@ -677,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), @@ -875,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/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/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index c98cf065c..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, @@ -49,6 +50,9 @@ describe('Iterable', () => { nativeEmitter.removeAllListeners( IterableEventName.handleEmbeddedMessagingDisabledCalled ); + nativeEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); // Clear any pending timers jest.clearAllTimers(); @@ -1445,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 89ae3c811..4c5b65c53 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -1032,6 +1032,9 @@ export class Iterable { RNEventEmitter.removeAllListeners( IterableEventName.handleEmbeddedMessagingDisabledCalled ); + RNEventEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); } /** @@ -1305,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/IterableConfig.ts b/src/core/classes/IterableConfig.ts index 3c6e9d4cc..8f1d283e8 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -432,6 +432,39 @@ export class IterableConfig { 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? * @@ -504,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. */ 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', }