diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c4c482dc..7d4618f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,20 @@ - Added `Iterable.registerDeviceToken(token)` to re-enable push for the current device. - Android accepts an FCM token string. - iOS accepts a continuous hex string representation of the APNS token. +- Replaced the two hardcoded 1000ms magic timeouts in the RN SDK with named, + documented, and configurable values (SDK-520). + - Added `IterableConfig.androidWakeDelayMs` (default `1000`) to tune the + Android deep-link wake delay before the SDK invokes `urlHandler`. Set to + `0` to dispatch synchronously. + - Added `IterableConfig.authCallbackTimeoutMs` (default `6000`) to tune the + safety-net timeout for the auth callback latch. The default is chosen to + comfortably exceed typical mobile auth round-trips while staying well + below the native 30s auth latch ceiling on both iOS and Android. + - The auth callback gate is now event-driven: the native + `handleAuthSuccessCalled` / `handleAuthFailureCalled` events resolve the + latch immediately. The timer survives only as a fallback when no native + event arrives within the configured window, instead of being the primary + resolution mechanism. ### Internal diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index ce5c42967..baccc3e41 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -24,6 +24,7 @@ import { IterableLogLevel, } from '../..'; import { TestHelper } from '../../__tests__/TestHelper'; +import { IterableLogger } from './IterableLogger'; describe('Iterable', () => { beforeEach(() => { @@ -334,6 +335,8 @@ describe('Iterable', () => { expect(config.pushIntegrationName).toBe(undefined); expect(config.urlHandler).toBe(undefined); expect(config.useInMemoryStorageForInApps).toBe(false); + expect(config.androidWakeDelayMs).toBe(1000); + expect(config.authCallbackTimeoutMs).toBe(6000); const configDict = config.toDict(); expect(configDict.allowedProtocols).toEqual([]); expect(configDict.androidSdkUseInMemoryStorageForInApps).toBe(false); @@ -350,6 +353,17 @@ describe('Iterable', () => { expect(configDict.pushIntegrationName).toBe(undefined); expect(configDict.urlHandlerPresent).toBe(false); expect(configDict.useInMemoryStorageForInApps).toBe(false); + expect(configDict.androidWakeDelayMs).toBe(1000); + expect(configDict.authCallbackTimeoutMs).toBe(6000); + }); + + it('should allow overriding androidWakeDelayMs and authCallbackTimeoutMs', () => { + const config = new IterableConfig(); + config.androidWakeDelayMs = 1500; + config.authCallbackTimeoutMs = 2500; + const configDict = config.toDict(); + expect(configDict.androidWakeDelayMs).toBe(1500); + expect(configDict.authCallbackTimeoutMs).toBe(2500); }); }); @@ -1607,6 +1621,88 @@ describe('Iterable', () => { expect(MockLinking.openURL).toBeCalledWith(expectedUrl); }); }); + + it('should honor a custom androidWakeDelayMs on Android', async () => { + // GIVEN Android platform + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, + }); + + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleUrlCalled); + + // sets up config with a custom wake delay + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.androidWakeDelayMs = 300; + config.urlHandler = jest.fn(() => false); + + // initialize Iterable object + Iterable.initialize('apiKey', config); + + // GIVEN the link can be opened + MockLinking.canOpenURL = jest.fn(async () => true); + MockLinking.openURL.mockReset(); + + const expectedUrl = 'https://somewhere.com'; + const dict = { + url: expectedUrl, + context: { + action: { type: 'openUrl' }, + source: IterableActionSource.inApp, + }, + }; + + // WHEN handleUrlCalled event is emitted + nativeEmitter.emit(IterableEventName.handleUrlCalled, dict); + + // THEN the handler is called after the custom delay, not the default + return await TestHelper.delayed(400, () => { + expect(config.urlHandler).toBeCalledWith(expectedUrl, dict.context); + expect(MockLinking.openURL).toBeCalledWith(expectedUrl); + }); + }); + + it('should dispatch synchronously on Android when androidWakeDelayMs is 0', async () => { + // GIVEN Android platform + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, + }); + + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleUrlCalled); + + // sets up config with wake delay disabled + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.androidWakeDelayMs = 0; + config.urlHandler = jest.fn(() => false); + + // initialize Iterable object + Iterable.initialize('apiKey', config); + + MockLinking.canOpenURL = jest.fn(async () => true); + MockLinking.openURL.mockReset(); + + const expectedUrl = 'https://somewhere.com'; + const dict = { + url: expectedUrl, + context: { + action: { type: 'openUrl' }, + source: IterableActionSource.inApp, + }, + }; + + // WHEN handleUrlCalled event is emitted + nativeEmitter.emit(IterableEventName.handleUrlCalled, dict); + + // THEN the handler is invoked without a setTimeout delay + expect(config.urlHandler).toBeCalledWith(expectedUrl, dict.context); + }); }); describe('re-initialization', () => { @@ -1706,9 +1802,12 @@ describe('Iterable', () => { IterableEventName.handleAuthFailureCalled ); - // sets up config with authHandler that returns an AuthResponse + // sets up config with authHandler that returns an AuthResponse and a + // short safety-net timeout (default is 6000ms; we use 200ms here so + // the safety-net fires within the test window). const config = new IterableConfig(); config.logReactNativeSdkCalls = false; + config.authCallbackTimeoutMs = 200; const successCallback = jest.fn(); const failureCallback = jest.fn(); const authResponse = new IterableAuthResponse(); @@ -1724,7 +1823,7 @@ describe('Iterable', () => { nativeEmitter.emit(IterableEventName.handleAuthCalled); // THEN the token is forwarded and neither callback fires - return await TestHelper.delayed(1100, () => { + return await TestHelper.delayed(300, () => { expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith( 'timeout-token' ); @@ -1732,5 +1831,253 @@ describe('Iterable', () => { expect(failureCallback).not.toBeCalled(); }); }); + + it('should honor a custom authCallbackTimeoutMs for the safety-net timeout', async () => { + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthSuccessCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthFailureCalled + ); + + // sets up config with a short custom auth callback timeout + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.authCallbackTimeoutMs = 200; + const successCallback = jest.fn(); + const failureCallback = jest.fn(); + const authResponse = new IterableAuthResponse(); + authResponse.authToken = 'short-timeout-token'; + authResponse.successCallback = successCallback; + authResponse.failureCallback = failureCallback; + config.authHandler = jest.fn(() => Promise.resolve(authResponse)); + + // initialize Iterable object + Iterable.initialize('apiKey', config); + + // WHEN handleAuthCalled event is emitted but no success/failure event follows + nativeEmitter.emit(IterableEventName.handleAuthCalled); + + // THEN the safety-net timer fires at the custom interval, not the default + return await TestHelper.delayed(300, () => { + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith( + 'short-timeout-token' + ); + expect(successCallback).not.toBeCalled(); + expect(failureCallback).not.toBeCalled(); + }); + }); + + it('should resolve the latch immediately when the native success event arrives before the safety-net timeout', async () => { + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthSuccessCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthFailureCalled + ); + + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.authCallbackTimeoutMs = 2000; + const successCallback = jest.fn(); + const failureCallback = jest.fn(); + const authResponse = new IterableAuthResponse(); + authResponse.authToken = 'fast-success-token'; + authResponse.successCallback = successCallback; + authResponse.failureCallback = failureCallback; + config.authHandler = jest.fn(() => Promise.resolve(authResponse)); + + Iterable.initialize('apiKey', config); + + // WHEN handleAuthCalled and handleAuthSuccessCalled both fire + nativeEmitter.emit(IterableEventName.handleAuthCalled); + nativeEmitter.emit(IterableEventName.handleAuthSuccessCalled); + + // THEN successCallback resolves on the microtask queue, well before the + // 2000ms safety-net timeout. + return await TestHelper.delayed(50, () => { + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith( + 'fast-success-token' + ); + expect(successCallback).toBeCalled(); + expect(failureCallback).not.toBeCalled(); + }); + }); + + it('should resolve both callbacks when two handleAuthCalled invocations overlap (SDK-520 regression)', async () => { + // Regression for the shared-latch-state bug: when a second + // handleAuthCalled event arrives while the first invocation's race is + // still pending, the original code wiped the first invocation's + // resolver, so the first successCallback was silently dropped. With + // per-invocation latch state, both invocations must resolve. + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthSuccessCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthFailureCalled + ); + + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.authCallbackTimeoutMs = 2000; + + const successCallback1 = jest.fn(); + const failureCallback1 = jest.fn(); + const authResponse1 = new IterableAuthResponse(); + authResponse1.authToken = 'overlap-token-1'; + authResponse1.successCallback = successCallback1; + authResponse1.failureCallback = failureCallback1; + + const successCallback2 = jest.fn(); + const failureCallback2 = jest.fn(); + const authResponse2 = new IterableAuthResponse(); + authResponse2.authToken = 'overlap-token-2'; + authResponse2.successCallback = successCallback2; + authResponse2.failureCallback = failureCallback2; + + // Use controllable promises so we can wire both latches before any + // native success event arrives. This is the realistic overlap shape: + // the SDK is awaiting native auth round-trips for two back-to-back + // handleAuthCalled events. + let resolveAuth1: (value: IterableAuthResponse) => void = () => {}; + let resolveAuth2: (value: IterableAuthResponse) => void = () => {}; + let callCount = 0; + config.authHandler = jest.fn(() => { + callCount += 1; + return new Promise((resolve) => { + if (callCount === 1) { + resolveAuth1 = resolve; + } else { + resolveAuth2 = resolve; + } + }); + }); + + const logSpy = jest.spyOn(IterableLogger, 'log'); + + Iterable.initialize('apiKey', config); + + // WHEN two handleAuthCalled events fire back-to-back (both invocations + // are now pending and awaiting their authHandler promises). + nativeEmitter.emit(IterableEventName.handleAuthCalled); + nativeEmitter.emit(IterableEventName.handleAuthCalled); + + // Resolve both authHandler promises. After the microtask queue + // flushes, both invocations' latches are wired and racing their + // safety-net timers. We defer the native success emissions to the + // next tick so the latch executors have run. + resolveAuth1(authResponse1); + resolveAuth2(authResponse2); + + // Then the corresponding native success events arrive in FIFO order. + await new Promise((resolve) => setTimeout(resolve, 0)); + nativeEmitter.emit(IterableEventName.handleAuthSuccessCalled); + nativeEmitter.emit(IterableEventName.handleAuthSuccessCalled); + + // THEN both successCallbacks fire and no "No callback received" + // warning is logged. + return await TestHelper.delayed(100, () => { + expect(successCallback1).toBeCalled(); + expect(successCallback2).toBeCalled(); + expect(failureCallback1).not.toBeCalled(); + expect(failureCallback2).not.toBeCalled(); + const noCallbackCalls = logSpy.mock.calls.filter( + (args) => + typeof args[0] === 'string' && + args[0].includes('No callback received from native layer') + ); + expect(noCallbackCalls).toHaveLength(0); + logSpy.mockRestore(); + }); + }); + + it('should not block the next callback when authHandler rejects (SDK-520 follow-up regression)', async () => { + // Regression for the zombie-invocation bug: when an authHandler + // rejects, the .catch path previously left the invocation at the head + // of pendingAuthInvocations. The late native event for the rejected + // invocation buffered into the zombie, so the next real invocation's + // native success event routed to the zombie and its successCallback + // was silently dropped. With removeInvocation() in the .catch path, + // the rejected invocation is dropped from the queue before native's + // late event arrives, so the next invocation's native event routes + // correctly. + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthSuccessCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthFailureCalled + ); + + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.authCallbackTimeoutMs = 2000; + + const successCallback2 = jest.fn(); + const failureCallback2 = jest.fn(); + const authResponse2 = new IterableAuthResponse(); + authResponse2.authToken = 'retry-success-token'; + authResponse2.successCallback = successCallback2; + authResponse2.failureCallback = failureCallback2; + + // inv1 rejects (authHandler throws); inv2 resolves with an + // IterableAuthResponse. Controllable promises let us sequence the + // second resolve after the first rejection has settled. + let resolveAuth2: (value: IterableAuthResponse) => void = () => {}; + let callCount = 0; + config.authHandler = jest.fn(() => { + callCount += 1; + if (callCount === 1) { + return Promise.reject(new Error('Auth failed (inv1)')); + } + return new Promise((resolve) => { + resolveAuth2 = resolve; + }); + }); + + const logSpy = jest.spyOn(IterableLogger, 'log'); + + Iterable.initialize('apiKey', config); + + // WHEN the first handleAuthCalled fires and authHandler rejects. + // The .catch path must remove inv1 from the queue. + nativeEmitter.emit(IterableEventName.handleAuthCalled); + // Let the rejection settle (microtask queue flush). + await new Promise((resolve) => setTimeout(resolve, 0)); + + // WHEN a second handleAuthCalled fires and authHandler resolves with + // an IterableAuthResponse, wiring inv2's latch. + nativeEmitter.emit(IterableEventName.handleAuthCalled); + resolveAuth2(authResponse2); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // THEN the native success event for inv2 routes to inv2 (not to a + // zombie at queue head) and inv2's successCallback fires. + nativeEmitter.emit(IterableEventName.handleAuthSuccessCalled); + + return await TestHelper.delayed(50, () => { + expect(successCallback2).toBeCalled(); + expect(failureCallback2).not.toBeCalled(); + // No "No callback received" warning: if inv1 had stayed at queue + // head, inv2's native event would have routed to the zombie and + // inv2's safety-net timer would have fired NO_CALLBACK. + const noCallbackCalls = logSpy.mock.calls.filter( + (args) => + typeof args[0] === 'string' && + args[0].includes('No callback received from native layer') + ); + expect(noCallbackCalls).toHaveLength(0); + logSpy.mockRestore(); + }); + }); }); }); diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index d29a90c7f..aaaa84073 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -1009,10 +1009,18 @@ export class Iterable { Iterable.wakeApp(); if (Platform.OS === 'android') { - //Give enough time for Activity to wake up. - setTimeout(() => { + // Give the host Activity time to wake from the background before + // dispatching the URL to the handler. Without this delay the + // handler can race the activity lifecycle and drop the link on + // cold start. Tunable via IterableConfig.androidWakeDelayMs. + const wakeDelayMs = Iterable.savedConfig.androidWakeDelayMs; + if (wakeDelayMs > 0) { + setTimeout(() => { + callUrlHandler(Iterable.savedConfig, url, context); + }, wakeDelayMs); + } else { callUrlHandler(Iterable.savedConfig, url, context); - }, 1000); + } } else { callUrlHandler(Iterable.savedConfig, url, context); } @@ -1043,66 +1051,197 @@ export class Iterable { } if (Iterable.savedConfig.authHandler) { - let authResponseCallback: IterableAuthResponseResult; + // Sentinel for the safety-net timeout path, distinct from the native + // SUCCESS / FAILURE results. + const AUTH_RESULT_NO_CALLBACK = 'NO_CALLBACK'; + type AuthLatchResult = + | IterableAuthResponseResult + | typeof AUTH_RESULT_NO_CALLBACK; + + // Per-invocation auth latch state. The native success/failure events + // resolve the latch when they arrive; the safety-net timer resolves it + // only if no native event arrives within the configured window. The + // timer is a fallback — the latch resolves immediately when the native + // event fires. + // + // `pendingResult` buffers a native result that arrives before the + // latch is created (e.g. the authHandler promise hasn't settled yet). + // It is consumed when the latch is created, so out-of-order events + // are not lost. + // + // State is scoped per `handleAuthCalled` invocation so overlapping + // invocations cannot wipe each other's resolver or buffered result + // (the bug with the original shared `authLatchResolver` / + // `pendingAuthResult` declarations). The bridge events carry no + // correlation id, so native success/failure events are routed to the + // oldest still-pending invocation in FIFO order — matching the + // original single-flight assumption that native processes auth + // requests in the order the SDK issues them. + type AuthInvocation = { + resolver: + | ((result: IterableAuthResponseResult) => void) + | null; + pendingResult: IterableAuthResponseResult | null; + }; + const pendingAuthInvocations: AuthInvocation[] = []; + RNEventEmitter.addListener(IterableEventName.handleAuthCalled, () => { + // Start a fresh per-invocation state object so a stale buffered + // result or resolver from a previous invocation cannot bleed into + // this one. + const invocation: AuthInvocation = { + resolver: null, + pendingResult: null, + }; + pendingAuthInvocations.push(invocation); + + // Drop this invocation from the FIFO queue. The bridge events carry + // no correlation id, so a late native event for this invocation may + // route to the new queue head — a pre-existing bridge limitation that + // is strictly better than leaving a zombie at head (which would + // deterministically block the next real callback). + const removeInvocation = () => { + const index = pendingAuthInvocations.indexOf(invocation); + if (index !== -1) { + pendingAuthInvocations.splice(index, 1); + } + }; + // MOB-10423: Check if we can use chain operator (?.) here instead // Asks frontend of the client/app to pass authToken Iterable.savedConfig.authHandler!() .then((promiseResult) => { // Promise result can be either just String OR of type AuthResponse. - // If type AuthReponse, authToken will be parsed looking for `authToken` within promised object. Two additional listeners will be registered for success and failure callbacks sent by native bridge layer. + // If type AuthResponse, authToken will be parsed looking for + // `authToken` within promised object. A latch is created and raced + // against a safety-net timeout: the native success/failure events + // resolve the latch immediately, while the timer only fires if no + // native event arrives within the configured window. // Else it will be looked for as a String. if (isIterableAuthResponse(promiseResult)) { Iterable.authManager.passAlongAuthToken(promiseResult.authToken); - setTimeout(() => { - if ( - authResponseCallback === IterableAuthResponseResult.SUCCESS - ) { - if (promiseResult.successCallback) { - promiseResult.successCallback?.(); + // If this invocation was already removed (e.g. a buffered + // native result already resolved it via another path), do not + // wire a latch for it. + if (!pendingAuthInvocations.includes(invocation)) { + return; + } + + const nativeLatch = new Promise( + (resolve) => { + if (invocation.pendingResult !== null) { + // A native event arrived before the latch was created; + // resolve immediately with the buffered result. + resolve(invocation.pendingResult); + invocation.pendingResult = null; + } else { + invocation.resolver = resolve; + } + } + ); + + const timeoutMs = Iterable.savedConfig.authCallbackTimeoutMs; + const timeoutLatch = new Promise((resolve) => { + setTimeout( + () => resolve(AUTH_RESULT_NO_CALLBACK), + timeoutMs + ); + }); + + Promise.race([nativeLatch, timeoutLatch]).then( + (result) => { + // Clear this invocation's resolver so a late native event + // after the timeout is a no-op. Only touch this + // invocation's state — other invocations own their own. + invocation.resolver = null; + invocation.pendingResult = null; + // Defensive cleanup: if the native event did not remove + // this invocation from the queue (e.g. the safety-net + // timer won the race), remove it now so future native + // events route to the next pending invocation. + const index = pendingAuthInvocations.indexOf(invocation); + if (index !== -1) { + pendingAuthInvocations.splice(index, 1); } - } else if ( - authResponseCallback === IterableAuthResponseResult.FAILURE - ) { - // We are currently only reporting JWT related errors. In - // the future, we should handle other types of errors as well. - if (promiseResult.failureCallback) { + if (result === IterableAuthResponseResult.SUCCESS) { + promiseResult.successCallback?.(); + } else if (result === IterableAuthResponseResult.FAILURE) { + // We are currently only reporting JWT related errors. In + // the future, we should handle other types of errors as + // well. promiseResult.failureCallback?.(); + } else { + IterableLogger?.log('No callback received from native layer'); } - } else { - IterableLogger?.log('No callback received from native layer'); } - }, 1000); + ); } else if (typeof promiseResult === 'string') { // If promise only returns string Iterable.authManager.passAlongAuthToken(promiseResult); + removeInvocation(); } else if (promiseResult === null || promiseResult === undefined) { // Even though this will cause authentication to fail, we want to // allow for this for JWT handling. Iterable.authManager.passAlongAuthToken(promiseResult); + removeInvocation(); } else { IterableLogger?.log( 'Unexpected promise returned. Auth token expects promise of String, null, undefined, or AuthResponse type.' ); + removeInvocation(); } }) - .catch((e) => IterableLogger?.log(e)); + .catch((e) => { + IterableLogger?.log(e); + removeInvocation(); + }); }); RNEventEmitter.addListener( IterableEventName.handleAuthSuccessCalled, () => { - authResponseCallback = IterableAuthResponseResult.SUCCESS; + // Route to the oldest still-pending invocation (FIFO). The bridge + // events carry no correlation id; this matches the assumption + // that native processes auth requests in issuance order. + const invocation = pendingAuthInvocations[0]; + if (!invocation) { + return; + } + // Resolve the pending auth latch immediately; the timer becomes a + // no-op for this invocation. + if (invocation.resolver) { + const resolve = invocation.resolver; + invocation.resolver = null; + invocation.pendingResult = null; + // Remove synchronously so the next native event routes to the + // following pending invocation, not back to this one. + pendingAuthInvocations.shift(); + resolve(IterableAuthResponseResult.SUCCESS); + } else { + // Latch not created yet — buffer the result for the latch. + invocation.pendingResult = IterableAuthResponseResult.SUCCESS; + } } ); RNEventEmitter.addListener( IterableEventName.handleAuthFailureCalled, (authFailureResponse: IterableAuthFailure) => { - // Mark the flag for above listener to indicate something failed. - // `catch(err)` will only indicate failure on high level. No actions - // should be taken inside `catch(err)`. - authResponseCallback = IterableAuthResponseResult.FAILURE; + const invocation = pendingAuthInvocations[0]; + if (invocation) { + // Resolve the pending auth latch immediately; the timer becomes a + // no-op for this invocation. + if (invocation.resolver) { + const resolve = invocation.resolver; + invocation.resolver = null; + invocation.pendingResult = null; + pendingAuthInvocations.shift(); + resolve(IterableAuthResponseResult.FAILURE); + } else { + // Latch not created yet — buffer the result for the latch. + invocation.pendingResult = IterableAuthResponseResult.FAILURE; + } + } // Call the actual JWT error with `authFailure` object. Iterable.savedConfig?.onJwtError?.(authFailureResponse); diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index 34befbbc8..8d0584ec6 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -329,6 +329,68 @@ export class IterableConfig { */ encryptionEnforced = false; + /** + * Delay (in milliseconds) the SDK waits on Android before invoking the URL + * handler after a deep-link event. + * + * On Android, the host `Activity` may still be waking from the background when + * a deep-link event is delivered. This delay gives the activity time to resume + * before the SDK dispatches the URL to `urlHandler`. Without it, the handler + * can race the activity lifecycle and drop or mishandle the link on cold + * start. + * + * The wake delay is applied only on Android; iOS dispatches immediately. + * + * @remarks + * Tune this value if your app observes dropped or duplicated deep links on + * Android. Increase it on slower devices or custom application classes; set + * it to `0` to dispatch synchronously (not recommended unless you have + * confirmed your activity lifecycle does not require the delay). + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.androidWakeDelayMs = 1500; // wait 1.5s on slow Android devices + * Iterable.initialize('', config); + * ``` + */ + androidWakeDelayMs = 1000; + + /** + * Maximum time (in milliseconds) the SDK waits for a native auth success or + * failure event before resolving the auth callback latch on its own. + * + * When `authHandler` returns an `IterableAuthResponse`, the SDK passes the + * `authToken` to the native layer and waits for either a + * `handleAuthSuccessCalled` or `handleAuthFailureCalled` event before + * invoking `successCallback` / `failureCallback`. This timeout is a + * **safety net**: if no native event arrives within the window, the SDK + * resolves the latch with a "no callback received" outcome and logs a + * warning rather than hanging the auth flow indefinitely. + * + * The timer is a fallback, not the primary resolution mechanism. The latch + * resolves immediately when the native event arrives. + * + * @remarks + * The default (`6000`) is chosen to comfortably exceed a typical mobile + * auth round-trip (the native layer performs `passAlongAuthToken` plus a + * full HTTP round-trip to the Iterable backend, which routinely exceeds + * 1 s on real networks) while staying well below the native auth latch + * ceiling of 30 s on both iOS and Android. Setting it below typical + * round-trip latency causes the safety net to win the race and drop the + * late native `successCallback` / `failureCallback`; setting it too high + * increases perceived auth latency only when the native layer fails to + * respond. + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.authCallbackTimeoutMs = 10000; // allow up to 10s for slow networks + * Iterable.initialize('', config); + * ``` + */ + authCallbackTimeoutMs = 6000; + /** * Should the SDK enable and use embedded messaging? * @@ -440,6 +502,8 @@ export class IterableConfig { encryptionEnforced: this.encryptionEnforced, retryPolicy: this.retryPolicy, enableEmbeddedMessaging: this.enableEmbeddedMessaging, + androidWakeDelayMs: this.androidWakeDelayMs, + authCallbackTimeoutMs: this.authCallbackTimeoutMs, }; } }