diff --git a/CHANGELOG.md b/CHANGELOG.md index 499c0eda5..22f26f78f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ - 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 `1000`) to tune the + safety-net timeout for the auth callback latch. + - 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. ## 3.0.1 diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index ce5c42967..2a1a6a36c 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -334,6 +334,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(1000); const configDict = config.toDict(); expect(configDict.allowedProtocols).toEqual([]); expect(configDict.androidSdkUseInMemoryStorageForInApps).toBe(false); @@ -350,6 +352,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(1000); + }); + + 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 +1620,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', () => { @@ -1732,5 +1827,83 @@ 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(); + }); + }); }); }); diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index d29a90c7f..194388df1 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -27,6 +27,20 @@ const RNEventEmitter = new NativeEventEmitter(RNIterableAPI); const defaultConfig = new IterableConfig(); +/** + * Fallback safety-net timeout for the auth callback latch when no native + * auth success/failure event arrives. Overridable via + * {@link IterableConfig.authCallbackTimeoutMs}. + */ +const AUTH_CALLBACK_TIMEOUT_DEFAULT_MS = 1000; + +/** + * Default delay (ms) the SDK waits on Android before invoking the URL handler + * so the host Activity can wake from the background. Overridable via + * {@link IterableConfig.androidWakeDelayMs}. + */ +const ANDROID_WAKE_DELAY_DEFAULT_MS = 1000; + /** * Checks if the response is an IterableAuthResponse */ @@ -1009,10 +1023,20 @@ 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 ?? + ANDROID_WAKE_DELAY_DEFAULT_MS; + if (wakeDelayMs > 0) { + setTimeout(() => { + callUrlHandler(Iterable.savedConfig, url, context); + }, wakeDelayMs); + } else { callUrlHandler(Iterable.savedConfig, url, context); - }, 1000); + } } else { callUrlHandler(Iterable.savedConfig, url, context); } @@ -1043,37 +1067,90 @@ 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; + + // Event-driven auth latch. The native success/failure listeners + // resolve the latch when their event arrives; 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. + // + // `pendingAuthResult` 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. + let authLatchResolver: + | ((result: IterableAuthResponseResult) => void) + | null = null; + let pendingAuthResult: IterableAuthResponseResult | null = null; + RNEventEmitter.addListener(IterableEventName.handleAuthCalled, () => { + // Reset per-invocation state so a stale buffered result from a + // previous invocation cannot bleed into this one. + authLatchResolver = null; + pendingAuthResult = null; + // 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?.(); + const nativeLatch = new Promise( + (resolve) => { + if (pendingAuthResult !== null) { + // A native event arrived before the latch was created; + // resolve immediately with the buffered result. + const buffered = pendingAuthResult; + pendingAuthResult = null; + resolve(buffered); + } else { + authLatchResolver = resolve; } - } 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) { + } + ); + + const timeoutMs = + Iterable.savedConfig.authCallbackTimeoutMs ?? + AUTH_CALLBACK_TIMEOUT_DEFAULT_MS; + const timeoutLatch = new Promise((resolve) => { + setTimeout( + () => resolve(AUTH_RESULT_NO_CALLBACK), + timeoutMs + ); + }); + + Promise.race([nativeLatch, timeoutLatch]).then( + (result) => { + // Clear the resolver so a late native event after the timeout + // is a no-op. + authLatchResolver = null; + pendingAuthResult = null; + 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); @@ -1093,16 +1170,33 @@ export class Iterable { RNEventEmitter.addListener( IterableEventName.handleAuthSuccessCalled, () => { - authResponseCallback = IterableAuthResponseResult.SUCCESS; + // Resolve the pending auth latch immediately; the timer becomes a + // no-op for this invocation. + if (authLatchResolver) { + const resolve = authLatchResolver; + authLatchResolver = null; + pendingAuthResult = null; + resolve(IterableAuthResponseResult.SUCCESS); + } else { + // Latch not created yet — buffer the result for the latch. + pendingAuthResult = 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; + // Resolve the pending auth latch immediately; the timer becomes a + // no-op for this invocation. + if (authLatchResolver) { + const resolve = authLatchResolver; + authLatchResolver = null; + pendingAuthResult = null; + resolve(IterableAuthResponseResult.FAILURE); + } else { + // Latch not created yet — buffer the result for the latch. + pendingAuthResult = 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..cf35e1f7e 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -329,6 +329,63 @@ 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 + * Increase this value on networks or devices where native auth round-trips + * are slow. Setting it too low can cause premature "no callback received" + * warnings. Setting it too high increases perceived auth latency only when + * the native layer fails to respond. + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.authCallbackTimeoutMs = 2000; // wait up to 2s for native auth events + * Iterable.initialize('', config); + * ``` + */ + authCallbackTimeoutMs = 1000; + /** * Should the SDK enable and use embedded messaging? * @@ -440,6 +497,8 @@ export class IterableConfig { encryptionEnforced: this.encryptionEnforced, retryPolicy: this.retryPolicy, enableEmbeddedMessaging: this.enableEmbeddedMessaging, + androidWakeDelayMs: this.androidWakeDelayMs, + authCallbackTimeoutMs: this.authCallbackTimeoutMs, }; } }