From c77afd48a6d3d8d65676f4edaa6db93b11ff5328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sat, 12 Sep 2026 07:26:04 +0100 Subject: [PATCH 01/25] docs(SDK-549): document logout() as native logoutUser equivalent Co-authored-by: Cursor --- src/core/classes/Iterable.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index aaaa84073..3716f6277 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -941,7 +941,27 @@ export class Iterable { /** * Logs out the current user from the Iterable SDK. * - * This method will remove all event listeners for the Iterable SDK and set the email and user ID to null. + * This is the React Native equivalent of native `logoutUser`. There is no + * dedicated bridged `logoutUser` method. `logout()` removes JS event + * listeners and clears identity via `setEmail(null)` and `setUserId(null)`, + * which triggers the native logout cleanup on both platforms. + * + * Native cleanup includes: + * - resetting the auth manager + * - resetting the in-app (and embedded) manager + * - disabling the current push token when + * `IterableConfig.autoPushRegistration` is `true` + * + * Both identity-clears are required. On iOS, `setEmail(null)` is a no-op + * when the user is identified only by userId, and `setUserId(null)` is a + * no-op when the user is identified only by email. Android has no public + * `logoutUser`; private `logoutPreviousUser()` runs as part of identity + * clear. + * + * iOS `logoutUser(withOnSuccess:onFailure:)` is not exposed. + * + * Calling `logout()` when no user is signed in is safe: listeners are + * removed and native identity-clear is effectively a no-op. * * @example * ```typescript From 1ecb9397e8703f1fd1496db8567186934d3cc44a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Tue, 15 Sep 2026 09:45:52 +0100 Subject: [PATCH 02/25] feat(SDK-550): expose disableDeviceForAllUsers on the RN SDK Bridge Iterable.disableDeviceForAllUsers() so iOS can unregister this device's push token for every associated user. Android stays a logged no-op because the native SDK has no public equivalent. Co-authored-by: Cursor --- CHANGELOG.md | 8 ++++++++ .../reactnative/RNIterableAPIModuleImpl.java | 4 ++++ .../newarch/java/com/RNIterableAPIModule.java | 5 +++++ .../oldarch/java/com/RNIterableAPIModule.java | 5 +++++ ios/RNIterableAPI/RNIterableAPI.mm | 8 ++++++++ ios/RNIterableAPI/ReactIterableAPI.swift | 6 ++++++ src/__mocks__/MockRNIterableAPI.ts | 2 ++ src/api/NativeRNIterableAPI.ts | 1 + src/core/classes/Iterable.test.ts | 10 ++++++++++ src/core/classes/Iterable.ts | 20 +++++++++++++++++++ src/core/classes/IterableApi.test.ts | 11 ++++++++++ src/core/classes/IterableApi.ts | 11 ++++++++++ 12 files changed, 91 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b2f4c33..812560427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## Unreleased + +### Updates + +- 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. + ## 3.1.0 ### Fixes diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index b25432d4b..ea7bae18b 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -373,6 +373,10 @@ public void disableDeviceForCurrentUser() { IterableApi.getInstance().disablePush(); } + public void disableDeviceForAllUsers() { + IterableLogger.w(TAG, "disableDeviceForAllUsers is not supported on Android; use disableDeviceForCurrentUser. There is no public native equivalent."); + } + public void registerDeviceToken(String token) { IterableLogger.v(TAG, "registerDeviceToken"); IterableApi.getInstance().registerDeviceToken(token); diff --git a/android/src/newarch/java/com/RNIterableAPIModule.java b/android/src/newarch/java/com/RNIterableAPIModule.java index fb71142b8..b25c39362 100644 --- a/android/src/newarch/java/com/RNIterableAPIModule.java +++ b/android/src/newarch/java/com/RNIterableAPIModule.java @@ -137,6 +137,11 @@ public void disableDeviceForCurrentUser() { moduleImpl.disableDeviceForCurrentUser(); } + @Override + public void disableDeviceForAllUsers() { + moduleImpl.disableDeviceForAllUsers(); + } + @Override public void registerDeviceToken(String token) { moduleImpl.registerDeviceToken(token); diff --git a/android/src/oldarch/java/com/RNIterableAPIModule.java b/android/src/oldarch/java/com/RNIterableAPIModule.java index c2f9b3161..cb290f3e4 100644 --- a/android/src/oldarch/java/com/RNIterableAPIModule.java +++ b/android/src/oldarch/java/com/RNIterableAPIModule.java @@ -138,6 +138,11 @@ public void disableDeviceForCurrentUser() { moduleImpl.disableDeviceForCurrentUser(); } + @ReactMethod + public void disableDeviceForAllUsers() { + moduleImpl.disableDeviceForAllUsers(); + } + @ReactMethod public void registerDeviceToken(String token) { moduleImpl.registerDeviceToken(token); diff --git a/ios/RNIterableAPI/RNIterableAPI.mm b/ios/RNIterableAPI/RNIterableAPI.mm index 965b2d81e..a766a6814 100644 --- a/ios/RNIterableAPI/RNIterableAPI.mm +++ b/ios/RNIterableAPI/RNIterableAPI.mm @@ -230,6 +230,10 @@ - (void)disableDeviceForCurrentUser { [_swiftAPI disableDeviceForCurrentUser]; } +- (void)disableDeviceForAllUsers { + [_swiftAPI disableDeviceForAllUsers]; +} + - (void)registerDeviceToken:(NSString *)token { [_swiftAPI registerDeviceToken:token]; } @@ -516,6 +520,10 @@ - (void)wakeApp { [_swiftAPI disableDeviceForCurrentUser]; } +RCT_EXPORT_METHOD(disableDeviceForAllUsers) { + [_swiftAPI disableDeviceForAllUsers]; +} + RCT_EXPORT_METHOD(registerDeviceToken : (NSString *)token) { [_swiftAPI registerDeviceToken:token]; } diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index a216a0e69..26def081d 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -144,6 +144,12 @@ import React IterableAPI.disableDeviceForCurrentUser() } + @objc(disableDeviceForAllUsers) + public func disableDeviceForAllUsers() { + ITBInfo() + IterableAPI.disableDeviceForAllUsers() + } + @objc(registerDeviceToken:) public func registerDeviceToken(token: String) { ITBInfo() diff --git a/src/__mocks__/MockRNIterableAPI.ts b/src/__mocks__/MockRNIterableAPI.ts index 60013bdbb..68355d2a1 100644 --- a/src/__mocks__/MockRNIterableAPI.ts +++ b/src/__mocks__/MockRNIterableAPI.ts @@ -34,6 +34,8 @@ export class MockRNIterableAPI { static disableDeviceForCurrentUser = jest.fn(); + static disableDeviceForAllUsers = jest.fn(); + static registerDeviceToken = jest.fn((token: string): void => { MockRNIterableAPI.token = token; }); diff --git a/src/api/NativeRNIterableAPI.ts b/src/api/NativeRNIterableAPI.ts index 7e96bc649..c902dc598 100644 --- a/src/api/NativeRNIterableAPI.ts +++ b/src/api/NativeRNIterableAPI.ts @@ -147,6 +147,7 @@ export interface Spec extends TurboModule { // Device management disableDeviceForCurrentUser(): void; + disableDeviceForAllUsers(): void; registerDeviceToken(token: string): void; getLastPushPayload(): Promise<{ [key: string]: string | number | boolean; diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index baccc3e41..6da4b9ca5 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -143,6 +143,16 @@ describe('Iterable', () => { }); }); + describe('disableDeviceForAllUsers', () => { + it('should disable the device for all users', () => { + // GIVEN no parameters + // WHEN Iterable.disableDeviceForAllUsers is called + Iterable.disableDeviceForAllUsers(); + // THEN corresponding method is called on RNIterableAPI + expect(MockRNIterableAPI.disableDeviceForAllUsers).toBeCalled(); + }); + }); + describe('registerDeviceToken', () => { it('should register the device token for the current user', () => { // GIVEN a device token diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index aaaa84073..ffd93295d 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -345,6 +345,26 @@ export class Iterable { IterableApi.disableDeviceForCurrentUser(); } + /** + * Disable this device's push token for every user associated with the device, + * not only the currently signed-in user. + * + * On iOS this forwards to native `IterableAPI.disableDeviceForAllUsers()`. + * On Android this is a no-op that logs a warning: the native Android SDK has + * no public "all users" equivalent. Use {@link disableDeviceForCurrentUser} to + * disable push for the current user on both platforms. + * + * Fire-and-forget: native success or failure is not surfaced to JS. + * + * @example + * ```typescript + * Iterable.disableDeviceForAllUsers(); + * ``` + */ + static disableDeviceForAllUsers() { + IterableApi.disableDeviceForAllUsers(); + } + /** * Register the device's token for the current user, re-enabling push notifications. * diff --git a/src/core/classes/IterableApi.test.ts b/src/core/classes/IterableApi.test.ts index d639d79ed..93cf80f6c 100644 --- a/src/core/classes/IterableApi.test.ts +++ b/src/core/classes/IterableApi.test.ts @@ -282,6 +282,17 @@ describe('IterableApi', () => { }); }); + describe('disableDeviceForAllUsers', () => { + it('should call RNIterableAPI.disableDeviceForAllUsers', () => { + // GIVEN no parameters + // WHEN disableDeviceForAllUsers is called + IterableApi.disableDeviceForAllUsers(); + + // THEN RNIterableAPI.disableDeviceForAllUsers is called + expect(MockRNIterableAPI.disableDeviceForAllUsers).toBeCalled(); + }); + }); + describe('registerDeviceToken', () => { it('should call RNIterableAPI.registerDeviceToken with the token', () => { // GIVEN a device token diff --git a/src/core/classes/IterableApi.ts b/src/core/classes/IterableApi.ts index 2fad862a7..aeb7def6b 100644 --- a/src/core/classes/IterableApi.ts +++ b/src/core/classes/IterableApi.ts @@ -137,6 +137,17 @@ export class IterableApi { return RNIterableAPI.disableDeviceForCurrentUser(); } + /** + * Disable this device's push token for every user associated with the device. + * + * iOS: forwards to native `IterableAPI.disableDeviceForAllUsers()`. + * Android: logged no-op — there is no public native "all users" equivalent. + */ + static disableDeviceForAllUsers() { + IterableLogger.log('disableDeviceForAllUsers'); + return RNIterableAPI.disableDeviceForAllUsers(); + } + /** * Register the device token for the current user, re-enabling push notifications. * From 8c0841bfbe9b3e41264b239893b2a61866911693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Mon, 21 Sep 2026 10:33:14 +0100 Subject: [PATCH 03/25] fix(SDK-550): log Android disableDeviceForAllUsers no-op in Metro Keep the native bridge no-op, but emit the same unsupported warning from JS so the gap is visible without logcat when logReactNativeSdkCalls is on. Co-authored-by: Cursor --- src/core/classes/Iterable.ts | 7 +++-- src/core/classes/IterableApi.test.ts | 43 ++++++++++++++++++++++++++++ src/core/classes/IterableApi.ts | 10 ++++++- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index ffd93295d..e7119fcbd 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -350,9 +350,10 @@ export class Iterable { * not only the currently signed-in user. * * On iOS this forwards to native `IterableAPI.disableDeviceForAllUsers()`. - * On Android this is a no-op that logs a warning: the native Android SDK has - * no public "all users" equivalent. Use {@link disableDeviceForCurrentUser} to - * disable push for the current user on both platforms. + * On Android this is a no-op that logs a warning in Metro and logcat: the + * native Android SDK has no public "all users" equivalent. Use + * {@link disableDeviceForCurrentUser} to disable push for the current user + * on both platforms. The native Android method is still invoked. * * Fire-and-forget: native success or failure is not surfaced to JS. * diff --git a/src/core/classes/IterableApi.test.ts b/src/core/classes/IterableApi.test.ts index 93cf80f6c..b19954f73 100644 --- a/src/core/classes/IterableApi.test.ts +++ b/src/core/classes/IterableApi.test.ts @@ -3,6 +3,7 @@ import { Platform } from 'react-native'; import { MockRNIterableAPI } from '../../__mocks__/MockRNIterableAPI'; import { IterableApi } from './IterableApi'; import { IterableConfig } from './IterableConfig'; +import { IterableLogger } from './IterableLogger'; import { IterableAttributionInfo } from './IterableAttributionInfo'; import { IterableCommerceItem } from './IterableCommerceItem'; import { IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage'; @@ -291,6 +292,48 @@ describe('IterableApi', () => { // THEN RNIterableAPI.disableDeviceForAllUsers is called expect(MockRNIterableAPI.disableDeviceForAllUsers).toBeCalled(); }); + + it('should log an unsupported warning on Android and still call native', () => { + const originalPlatform = Platform.OS; + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, + }); + const logSpy = jest.spyOn(IterableLogger, 'log'); + + IterableApi.disableDeviceForAllUsers(); + + expect(logSpy).toHaveBeenCalledWith( + 'disableDeviceForAllUsers is not supported on Android; use disableDeviceForCurrentUser. There is no public native equivalent.' + ); + expect(MockRNIterableAPI.disableDeviceForAllUsers).toBeCalled(); + + logSpy.mockRestore(); + Object.defineProperty(Platform, 'OS', { + value: originalPlatform, + writable: true, + }); + }); + + it('should log the method name on iOS and still call native', () => { + const originalPlatform = Platform.OS; + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + const logSpy = jest.spyOn(IterableLogger, 'log'); + + IterableApi.disableDeviceForAllUsers(); + + expect(logSpy).toHaveBeenCalledWith('disableDeviceForAllUsers'); + expect(MockRNIterableAPI.disableDeviceForAllUsers).toBeCalled(); + + logSpy.mockRestore(); + Object.defineProperty(Platform, 'OS', { + value: originalPlatform, + writable: true, + }); + }); }); describe('registerDeviceToken', () => { diff --git a/src/core/classes/IterableApi.ts b/src/core/classes/IterableApi.ts index aeb7def6b..4d0650102 100644 --- a/src/core/classes/IterableApi.ts +++ b/src/core/classes/IterableApi.ts @@ -142,9 +142,17 @@ export class IterableApi { * * iOS: forwards to native `IterableAPI.disableDeviceForAllUsers()`. * Android: logged no-op — there is no public native "all users" equivalent. + * The warning is logged in JS (Metro) as well as native (logcat); the + * native method is still invoked. */ static disableDeviceForAllUsers() { - IterableLogger.log('disableDeviceForAllUsers'); + if (Platform.OS === 'android') { + IterableLogger.log( + 'disableDeviceForAllUsers is not supported on Android; use disableDeviceForCurrentUser. There is no public native equivalent.' + ); + } else { + IterableLogger.log('disableDeviceForAllUsers'); + } return RNIterableAPI.disableDeviceForAllUsers(); } From 09db5e421474986e28a852c93ce4c0bbbc7846dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Tue, 22 Sep 2026 10:06:48 +0100 Subject: [PATCH 04/25] docs(SDK-549): clarify iOS public logoutUser is not bridged Co-authored-by: Cursor --- src/core/classes/Iterable.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 3716f6277..03d6b74d6 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -954,11 +954,12 @@ export class Iterable { * * Both identity-clears are required. On iOS, `setEmail(null)` is a no-op * when the user is identified only by userId, and `setUserId(null)` is a - * no-op when the user is identified only by email. Android has no public - * `logoutUser`; private `logoutPreviousUser()` runs as part of identity - * clear. + * no-op when the user is identified only by email. * - * iOS `logoutUser(withOnSuccess:onFailure:)` is not exposed. + * iOS has a public `IterableAPI.logoutUser()`; it is not wired through the + * RN bridge. Android has no public `logoutUser` — private + * `logoutPreviousUser()` runs as part of identity-clear. The iOS + * `logoutUser(withOnSuccess:onFailure:)` overload is also not exposed. * * Calling `logout()` when no user is signed in is safe: listeners are * removed and native identity-clear is effectively a no-op. From 24cf9ad143e8f4c298533f60977e9f3cb1dd8c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 24 Sep 2026 21:11:17 +0100 Subject: [PATCH 05/25] feat(SDK-555): wire Android keychain encryption config Add IterableConfig.keychainEncryption (default true) and apply it on the Android bridge, deprecating encryptionEnforced which was never honored. Co-authored-by: Cursor --- CHANGELOG.md | 1 + .../iterable/reactnative/Serialization.java | 8 ++++++++ src/core/classes/Iterable.test.ts | 19 +++++++++++++++++++ src/core/classes/IterableConfig.ts | 11 +++++++++++ 4 files changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6cbd8519..3267a6d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Updates +- Added `IterableConfig.keychainEncryption` (default `true`, Android only) so React Native apps can control native `IterableConfig.Builder.setKeychainEncryption`. Deprecated `IterableConfig.encryptionEnforced`, which was serialized but never applied on Android (SDK-555). - 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. diff --git a/android/src/main/java/com/iterable/reactnative/Serialization.java b/android/src/main/java/com/iterable/reactnative/Serialization.java index 97aa52f49..11a0a2345 100644 --- a/android/src/main/java/com/iterable/reactnative/Serialization.java +++ b/android/src/main/java/com/iterable/reactnative/Serialization.java @@ -250,6 +250,14 @@ static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableConte configBuilder.setEnableEmbeddedMessaging(iterableContextJSON.optBoolean("enableEmbeddedMessaging")); } + boolean keychainEncryption = true; + if (iterableContextJSON.has("keychainEncryption")) { + keychainEncryption = iterableContextJSON.optBoolean("keychainEncryption"); + } else if (iterableContextJSON.optBoolean("encryptionEnforced")) { + keychainEncryption = true; + } + configBuilder.setKeychainEncryption(keychainEncryption); + if (iterableContextJSON.has("retryPolicy")) { JSONObject retryPolicyJson = iterableContextJSON.getJSONObject("retryPolicy"); int maxRetry = retryPolicyJson.getInt("maxRetry"); diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 6da4b9ca5..5ef5271ec 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -336,6 +336,7 @@ describe('Iterable', () => { expect(config.customActionHandler).toBe(undefined); expect(config.dataRegion).toBe(IterableDataRegion.US); expect(config.enableEmbeddedMessaging).toBe(false); + expect(config.keychainEncryption).toBe(true); expect(config.encryptionEnforced).toBe(false); expect(config.expiringAuthTokenRefreshPeriod).toBe(60.0); expect(config.inAppDisplayInterval).toBe(30.0); @@ -355,6 +356,7 @@ describe('Iterable', () => { expect(configDict.customActionHandlerPresent).toBe(false); expect(configDict.dataRegion).toBe(IterableDataRegion.US); expect(configDict.enableEmbeddedMessaging).toBe(false); + expect(configDict.keychainEncryption).toBe(true); expect(configDict.encryptionEnforced).toBe(false); expect(configDict.expiringAuthTokenRefreshPeriod).toBe(60.0); expect(configDict.inAppDisplayInterval).toBe(30.0); @@ -375,6 +377,23 @@ describe('Iterable', () => { expect(configDict.androidWakeDelayMs).toBe(1500); expect(configDict.authCallbackTimeoutMs).toBe(2500); }); + + it('should allow opting out of Android keychain encryption', () => { + const config = new IterableConfig(); + config.keychainEncryption = false; + const configDict = config.toDict(); + expect(config.keychainEncryption).toBe(false); + expect(configDict.keychainEncryption).toBe(false); + }); + + it('should keep keychainEncryption enabled when only deprecated encryptionEnforced is set', () => { + const config = new IterableConfig(); + config.encryptionEnforced = true; + const configDict = config.toDict(); + expect(config.keychainEncryption).toBe(true); + expect(configDict.keychainEncryption).toBe(true); + expect(configDict.encryptionEnforced).toBe(true); + }); }); describe('urlHandler', () => { diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index b4de2fb41..eb01f75a6 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -324,6 +324,16 @@ export class IterableConfig { pushPlatform: IterablePushPlatform = IterablePushPlatform.auto; /** + * Android only: whether Iterable encrypts PII (email, userId, auth token) in on-device keychain storage. + * When `true` (the default), data is encrypted; when `false`, it is stored in plaintext. + * iOS always uses the system Keychain and ignores this option. + */ + keychainEncryption = true; + + /** + * @deprecated Use {@link IterableConfig.keychainEncryption} instead. This field is still serialized for + * back-compat on Android when `keychainEncryption` is absent from the bridge payload. + * * Android only feature: This controls whether the SDK should enforce encryption for all PII stored on disk. * By default, the SDK will not enforce encryption and may fallback to unencrypted storage in case the encryption fails. */ @@ -482,6 +492,7 @@ export class IterableConfig { useInMemoryStorageForInApps: this.useInMemoryStorageForInApps, dataRegion: this.dataRegion, pushPlatform: this.pushPlatform, + keychainEncryption: this.keychainEncryption, encryptionEnforced: this.encryptionEnforced, retryPolicy: this.retryPolicy, enableEmbeddedMessaging: this.enableEmbeddedMessaging, From 910818e147341df19f96716ca12e43074f2c811c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 24 Sep 2026 21:30:01 +0100 Subject: [PATCH 06/25] docs(SDK-555): clarify deprecated encryption docs and precedence test Align encryptionEnforced JSDoc with keychainEncryption defaults and assert explicit keychainEncryption wins over encryptionEnforced in toDict(). Co-authored-by: Cursor --- src/core/classes/Iterable.test.ts | 11 ++++++++++- src/core/classes/IterableConfig.ts | 9 ++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 5ef5271ec..017fce2b2 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -386,7 +386,7 @@ describe('Iterable', () => { expect(configDict.keychainEncryption).toBe(false); }); - it('should keep keychainEncryption enabled when only deprecated encryptionEnforced is set', () => { + it('should still serialize encrypted keychain when only deprecated encryptionEnforced is set', () => { const config = new IterableConfig(); config.encryptionEnforced = true; const configDict = config.toDict(); @@ -394,6 +394,15 @@ describe('Iterable', () => { expect(configDict.keychainEncryption).toBe(true); expect(configDict.encryptionEnforced).toBe(true); }); + + it('should prefer keychainEncryption over deprecated encryptionEnforced in toDict()', () => { + const config = new IterableConfig(); + config.keychainEncryption = false; + config.encryptionEnforced = true; + const configDict = config.toDict(); + expect(configDict.keychainEncryption).toBe(false); + expect(configDict.encryptionEnforced).toBe(true); + }); }); describe('urlHandler', () => { diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index eb01f75a6..119fed4aa 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -331,11 +331,10 @@ export class IterableConfig { keychainEncryption = true; /** - * @deprecated Use {@link IterableConfig.keychainEncryption} instead. This field is still serialized for - * back-compat on Android when `keychainEncryption` is absent from the bridge payload. - * - * Android only feature: This controls whether the SDK should enforce encryption for all PII stored on disk. - * By default, the SDK will not enforce encryption and may fallback to unencrypted storage in case the encryption fails. + * @deprecated Use {@link IterableConfig.keychainEncryption} instead. Android honors + * `keychainEncryption` (default `true`, encrypted). This field is still serialized so + * older bridge payloads that omit `keychainEncryption` can fall back when + * `encryptionEnforced` is `true`; it does not disable encryption when left `false`. */ encryptionEnforced = false; From 785f73e504a1d2224045dca9a416febafd7d683d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 24 Sep 2026 21:58:07 +0100 Subject: [PATCH 07/25] feat(SDK-557): bridge Android decryption failure handler to JS Expose IterableConfig.decryptionFailureHandler so RN apps can react when Android keychain PII decryption fails, matching native IterableConfig parity. --- CHANGELOG.md | 1 + .../reactnative/RNIterableAPIModuleImpl.java | 30 +++++++- src/core/classes/Iterable.test.ts | 68 +++++++++++++++++++ src/core/classes/Iterable.ts | 18 +++++ src/core/classes/IterableConfig.ts | 26 +++++++ src/core/enums/IterableEventName.ts | 4 ++ src/core/types/IterableDecryptionFailure.ts | 7 ++ src/core/types/index.ts | 1 + src/index.tsx | 1 + 9 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 src/core/types/IterableDecryptionFailure.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6cbd8519..99f634c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Updates +- Added `IterableConfig.decryptionFailureHandler` (Android only) to notify the app when native keychain decryption fails (SDK-557). The SDK clears stored PII and disables encryption for the device before invoking the callback; iOS has no equivalent native API and ignores this option. - 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. diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index ea7bae18b..f0814aba9 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -29,6 +29,7 @@ import com.iterable.iterableapi.IterableAuthManager; import com.iterable.iterableapi.IterableConfig; import com.iterable.iterableapi.IterableCustomActionHandler; +import com.iterable.iterableapi.IterableDecryptionFailureHandler; import com.iterable.iterableapi.IterableEmbeddedMessage; import com.iterable.iterableapi.IterableEmbeddedUpdateHandler; import com.iterable.iterableapi.IterableHelper; @@ -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, IterableDecryptionFailureHandler, 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("decryptionFailureHandlerPresent") && configReadableMap.getBoolean("decryptionFailureHandlerPresent") == true) { + configBuilder.setDecryptionFailureHandler(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("decryptionFailureHandlerPresent") && configReadableMap.getBoolean("decryptionFailureHandlerPresent") == true) { + configBuilder.setDecryptionFailureHandler(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. @@ -663,6 +672,24 @@ public String onAuthTokenRequested() { } } + private static final String DECRYPTION_FAILURE_DEFAULT_MESSAGE = "Decryption failed"; + + @Override + public void onDecryptionFailed(Exception exception) { + JSONObject messageJson = new JSONObject(); + try { + String message = exception != null ? exception.getMessage() : null; + if (message == null || message.isEmpty()) { + message = DECRYPTION_FAILURE_DEFAULT_MESSAGE; + } + messageJson.put("message", message); + WritableMap eventData = Serialization.convertJsonToMap(messageJson); + sendEvent(EventName.handleDecryptionFailureCalled.name(), eventData); + } catch (JSONException e) { + IterableLogger.e(TAG, "Failed to send decryption failure event"); + } + } + @Override public void onAuthFailure(AuthFailure authFailure) { // Create a JSON object for the authFailure object @@ -815,6 +842,7 @@ enum EventName { handleAuthFailureCalled, handleAuthSuccessCalled, handleCustomActionCalled, + handleDecryptionFailureCalled, handleEmbeddedMessageUpdateCalled, handleEmbeddedMessagingDisabledCalled, handleInAppCalled, diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 6da4b9ca5..a7a5e818e 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -48,6 +48,9 @@ describe('Iterable', () => { nativeEmitter.removeAllListeners( IterableEventName.handleEmbeddedMessagingDisabledCalled ); + nativeEmitter.removeAllListeners( + IterableEventName.handleDecryptionFailureCalled + ); // Clear any pending timers jest.clearAllTimers(); @@ -1271,6 +1274,71 @@ describe('Iterable', () => { }); }); + describe('decryptionFailureHandler', () => { + it('should call decryptionFailureHandler when handleDecryptionFailureCalled event is emitted', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleDecryptionFailureCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.decryptionFailureHandler = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit(IterableEventName.handleDecryptionFailureCalled, { + message: 'Keychain decrypt error', + }); + expect(config.decryptionFailureHandler).toHaveBeenCalledWith({ + message: 'Keychain decrypt error', + }); + expect(config.decryptionFailureHandler).toHaveBeenCalledTimes(1); + }); + + it('should use a generic message when the event payload message is empty', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleDecryptionFailureCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.decryptionFailureHandler = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit(IterableEventName.handleDecryptionFailureCalled, { + message: ' ', + }); + expect(config.decryptionFailureHandler).toHaveBeenCalledWith({ + message: 'Decryption failed', + }); + }); + + it('should not set up listener if decryptionFailureHandler is not provided', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleDecryptionFailureCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + Iterable.initialize('apiKey', config); + expect(() => { + nativeEmitter.emit(IterableEventName.handleDecryptionFailureCalled, { + message: 'ignored', + }); + }).not.toThrow(); + }); + + it('should include decryptionFailureHandlerPresent flag in config dict when callback is provided', () => { + const config = new IterableConfig(); + config.decryptionFailureHandler = jest.fn(); + const configDict = config.toDict(); + expect(configDict.decryptionFailureHandlerPresent).toBe(true); + }); + + it('should set decryptionFailureHandlerPresent flag to false when callback is not provided', () => { + const config = new IterableConfig(); + const configDict = config.toDict(); + expect(configDict.decryptionFailureHandlerPresent).toBe(false); + }); + }); + 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 483b177b6..47920b5ca 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -12,6 +12,7 @@ import { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation'; import { IterableAuthResponseResult } from '../enums/IterableAuthResponseResult'; import { IterableEventName } from '../enums/IterableEventName'; import type { IterableAuthFailure } from '../types/IterableAuthFailure'; +import type { IterableDecryptionFailure } from '../types/IterableDecryptionFailure'; import { callUrlHandler } from '../utils/callUrlHandler'; import { IterableAction } from './IterableAction'; import { IterableActionContext } from './IterableActionContext'; @@ -1018,6 +1019,9 @@ export class Iterable { RNEventEmitter.removeAllListeners( IterableEventName.handleEmbeddedMessagingDisabledCalled ); + RNEventEmitter.removeAllListeners( + IterableEventName.handleDecryptionFailureCalled + ); } /** @@ -1310,6 +1314,20 @@ export class Iterable { ); } } + + if (Iterable.savedConfig.decryptionFailureHandler) { + RNEventEmitter.addListener( + IterableEventName.handleDecryptionFailureCalled, + (payload: IterableDecryptionFailure) => { + const rawMessage = payload?.message?.trim(); + const message = + rawMessage && rawMessage.length > 0 + ? rawMessage + : 'Decryption failed'; + Iterable.savedConfig.decryptionFailureHandler?.({ message }); + } + ); + } } /** diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index b4de2fb41..7d566eb2d 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -4,6 +4,7 @@ import { IterableDataRegion } from '../enums/IterableDataRegion'; import { IterableLogLevel } from '../enums/IterableLogLevel'; import { IterablePushPlatform } from '../enums/IterablePushPlatform'; import type { IterableAuthFailure } from '../types/IterableAuthFailure'; +import type { IterableDecryptionFailure } from '../types/IterableDecryptionFailure'; import type { IterableRetryPolicy } from '../types/IterableRetryPolicy'; import { IterableAction } from './IterableAction'; import type { IterableActionContext } from './IterableActionContext'; @@ -225,6 +226,29 @@ export class IterableConfig { */ onJwtError?: (authFailure: IterableAuthFailure) => void; + /** + * A callback invoked when the Android SDK fails to decrypt PII in keychain + * storage. Before calling this handler, the native SDK clears stored PII, + * disables encryption for the device, and requires the user to sign in again. + * + * **Android only.** iOS does not surface decryption failures this way; setting + * this callback on iOS has no effect. + * + * @param failure - Details about the decryption failure. + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.decryptionFailureHandler = (failure) => { + * console.error('Iterable decryption failed:', failure.message); + * // Prompt the user to log in again + * }; + * ``` + */ + decryptionFailureHandler?: ( + failure: IterableDecryptionFailure + ) => void; + /** * Set the verbosity of Android and iOS project's log system. * @@ -463,6 +487,8 @@ export class IterableConfig { * A boolean indicating if an authentication handler is present. */ authHandlerPresent: this.authHandler !== undefined, + decryptionFailureHandlerPresent: + this.decryptionFailureHandler !== 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..17013aa40 100644 --- a/src/core/enums/IterableEventName.ts +++ b/src/core/enums/IterableEventName.ts @@ -23,4 +23,8 @@ export enum IterableEventName { handleEmbeddedMessageUpdateCalled = 'handleEmbeddedMessageUpdateCalled', /** Event that fires when embedded messaging is disabled */ handleEmbeddedMessagingDisabledCalled = 'handleEmbeddedMessagingDisabledCalled', + /** + * Event that fires when Android keychain decryption fails (Android only). + */ + handleDecryptionFailureCalled = 'handleDecryptionFailureCalled', } diff --git a/src/core/types/IterableDecryptionFailure.ts b/src/core/types/IterableDecryptionFailure.ts new file mode 100644 index 000000000..01c9f3fc9 --- /dev/null +++ b/src/core/types/IterableDecryptionFailure.ts @@ -0,0 +1,7 @@ +/** + * Payload for Android keychain decryption failure notifications. + */ +export interface IterableDecryptionFailure { + /** Human-readable description of the decryption failure */ + message?: string; +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 7659a76e4..a8f7f2daa 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -1,3 +1,4 @@ export * from './IterableAuthFailure'; +export * from './IterableDecryptionFailure'; export * from './IterableEdgeInsetDetails'; export * from './IterableRetryPolicy'; diff --git a/src/index.tsx b/src/index.tsx index 13cb6915d..9d88a05aa 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -29,6 +29,7 @@ export { } from './core/hooks'; export type { IterableAuthFailure, + IterableDecryptionFailure, IterableEdgeInsetDetails, IterableRetryPolicy, } from './core/types'; From 828f41940568c08837923c3f3c8ab0fbda6801c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 24 Sep 2026 22:43:41 +0100 Subject: [PATCH 08/25] fix(SDK-557): declare decryption event on iOS supportedEvents Avoid RN debug RedBox when apps set decryptionFailureHandler on iOS. Assert listenerCount in the omitted-callback test. --- ios/RNIterableAPI/ReactIterableAPI.swift | 2 ++ src/__mocks__/jest.setup.ts | 2 ++ src/core/classes/Iterable.test.ts | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index 26def081d..3bd32a692 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -35,6 +35,8 @@ import React case handleAuthFailureCalled case handleEmbeddedMessageUpdateCalled case handleEmbeddedMessagingDisabledCalled + // Android-only native API; listed so JS addListener does not warn on iOS. + case handleDecryptionFailureCalled } @objc public static var supportedEvents: [String] { diff --git a/src/__mocks__/jest.setup.ts b/src/__mocks__/jest.setup.ts index 96c17ca52..e619f4735 100644 --- a/src/__mocks__/jest.setup.ts +++ b/src/__mocks__/jest.setup.ts @@ -27,6 +27,8 @@ const mockNativeEventEmitterConstructor = jest.fn().mockImplementation(() => ({ eventType: string, listener: (...args: unknown[]) => void ) => mockNativeEventEmitter.removeListener(eventType, listener), + listenerCount: (eventType: string) => + mockNativeEventEmitter.listenerCount(eventType), })); jest.mock( diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index a7a5e818e..27420f42d 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -1318,6 +1318,11 @@ describe('Iterable', () => { const config = new IterableConfig(); config.logReactNativeSdkCalls = false; Iterable.initialize('apiKey', config); + expect( + nativeEmitter.listenerCount( + IterableEventName.handleDecryptionFailureCalled + ) + ).toBe(0); expect(() => { nativeEmitter.emit(IterableEventName.handleDecryptionFailureCalled, { message: 'ignored', From 62b6a85a9bc9ce565e9d522b7f0612c3e585ee1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Fri, 25 Sep 2026 00:02:42 +0100 Subject: [PATCH 09/25] feat(SDK-559): expose Iterable.authManager.getAuthToken() Bridge native JWT reads for parity with Android getAuthToken() and iOS IterableAPI.authToken, alongside existing getEmail/getUserId. --- CHANGELOG.md | 3 +++ .../iterableapi/RNIterableInternal.java | 5 +++++ .../reactnative/RNIterableAPIModuleImpl.java | 4 ++++ .../newarch/java/com/RNIterableAPIModule.java | 5 +++++ .../oldarch/java/com/RNIterableAPIModule.java | 5 +++++ ios/RNIterableAPI/RNIterableAPI.mm | 10 ++++++++++ ios/RNIterableAPI/ReactIterableAPI.swift | 6 ++++++ src/__mocks__/MockRNIterableAPI.ts | 7 +++++++ src/api/NativeRNIterableAPI.ts | 1 + src/core/classes/Iterable.test.ts | 19 +++++++++++++++++++ src/core/classes/IterableApi.test.ts | 19 +++++++++++++++++++ src/core/classes/IterableApi.ts | 8 ++++++++ src/core/classes/IterableAuthManager.ts | 14 ++++++++++++++ 13 files changed, 106 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6cbd8519..880e8f512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ### Updates +- Added `Iterable.authManager.getAuthToken()` to read the JWT currently held by the native SDK (SDK-559). + - iOS: `IterableAPI.authToken`. + - Android: `IterableApi.getAuthToken()`. - 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. diff --git a/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java b/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java index db8fdbc98..f5ea29df6 100644 --- a/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java +++ b/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java @@ -16,6 +16,11 @@ public static String getUserId() { return IterableApi.getInstance().getUserId(); } + @Nullable + public static String getAuthToken() { + return IterableApi.getInstance().getAuthToken(); + } + public static JSONObject getInAppMessageJson(IterableInAppMessage message) { return message.toJSONObject(); } diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index ea7bae18b..f006f5b25 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -249,6 +249,10 @@ public void getUserId(Promise promise) { promise.resolve(RNIterableInternal.getUserId()); } + public void getAuthToken(Promise promise) { + promise.resolve(RNIterableInternal.getAuthToken()); + } + public void trackEvent(String name, @Nullable ReadableMap dataFields) { IterableLogger.v(TAG, "trackEvent"); IterableApi.getInstance().track(name, optSerializedDataFields(dataFields)); diff --git a/android/src/newarch/java/com/RNIterableAPIModule.java b/android/src/newarch/java/com/RNIterableAPIModule.java index b25c39362..6e851fb54 100644 --- a/android/src/newarch/java/com/RNIterableAPIModule.java +++ b/android/src/newarch/java/com/RNIterableAPIModule.java @@ -72,6 +72,11 @@ public void getUserId(Promise promise) { moduleImpl.getUserId(promise); } + @Override + public void getAuthToken(Promise promise) { + moduleImpl.getAuthToken(promise); + } + @Override public void trackEvent(String name, @Nullable ReadableMap dataFields) { moduleImpl.trackEvent(name, dataFields); diff --git a/android/src/oldarch/java/com/RNIterableAPIModule.java b/android/src/oldarch/java/com/RNIterableAPIModule.java index cb290f3e4..9142967d5 100644 --- a/android/src/oldarch/java/com/RNIterableAPIModule.java +++ b/android/src/oldarch/java/com/RNIterableAPIModule.java @@ -73,6 +73,11 @@ public void getUserId(Promise promise) { moduleImpl.getUserId(promise); } + @ReactMethod + public void getAuthToken(Promise promise) { + moduleImpl.getAuthToken(promise); + } + @ReactMethod public void trackEvent(String name, @Nullable ReadableMap dataFields) { moduleImpl.trackEvent(name, dataFields); diff --git a/ios/RNIterableAPI/RNIterableAPI.mm b/ios/RNIterableAPI/RNIterableAPI.mm index a766a6814..825091661 100644 --- a/ios/RNIterableAPI/RNIterableAPI.mm +++ b/ios/RNIterableAPI/RNIterableAPI.mm @@ -283,6 +283,11 @@ - (void)updateVisibleRows:(NSArray *)visibleRows { [_swiftAPI updateVisibleRows:visibleRows]; } +- (void)getAuthToken:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_swiftAPI getAuthToken:resolve rejecter:reject]; +} + - (void)passAlongAuthToken:(NSString *_Nullable)authToken { [_swiftAPI passAlongAuthToken:authToken]; } @@ -571,6 +576,11 @@ - (void)wakeApp { [_swiftAPI updateVisibleRows:visibleRows]; } +RCT_EXPORT_METHOD(getAuthToken : (RCTPromiseResolveBlock) + resolve rejecter : (RCTPromiseRejectBlock)reject) { + [_swiftAPI getAuthToken:resolve rejecter:reject]; +} + RCT_EXPORT_METHOD(passAlongAuthToken : (NSString *_Nullable)authToken) { [_swiftAPI passAlongAuthToken:authToken]; } diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index 26def081d..2ba96f028 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -500,6 +500,12 @@ import React // MARK: - SDK Auth Manager Functions + @objc(getAuthToken:rejecter:) + public func getAuthToken(resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock) { + ITBInfo() + resolver(IterableAPI.authToken) + } + @objc(passAlongAuthToken:) public func passAlongAuthToken(authToken: String?) { ITBInfo() diff --git a/src/__mocks__/MockRNIterableAPI.ts b/src/__mocks__/MockRNIterableAPI.ts index 68355d2a1..23ba55ba3 100644 --- a/src/__mocks__/MockRNIterableAPI.ts +++ b/src/__mocks__/MockRNIterableAPI.ts @@ -4,6 +4,7 @@ import { IterableInAppMessage } from '../inApp'; export class MockRNIterableAPI { static email?: string; static userId?: string; + static authToken?: string | null; static token?: string; static lastPushPayload?: unknown; static attributionInfo?: IterableAttributionInfo; @@ -82,6 +83,12 @@ export class MockRNIterableAPI { static setInAppShowResponse = jest.fn(); + static async getAuthToken(): Promise { + return await new Promise((resolve) => { + resolve(MockRNIterableAPI.authToken ?? null); + }); + } + static passAlongAuthToken = jest.fn(); static pauseAuthRetries = jest.fn(); diff --git a/src/api/NativeRNIterableAPI.ts b/src/api/NativeRNIterableAPI.ts index c902dc598..d3ab874b0 100644 --- a/src/api/NativeRNIterableAPI.ts +++ b/src/api/NativeRNIterableAPI.ts @@ -179,6 +179,7 @@ export interface Spec extends TurboModule { ): void; // Auth + getAuthToken(): Promise; passAlongAuthToken(authToken?: string | null): void; pauseAuthRetries(pauseRetry: boolean): void; diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 6da4b9ca5..b29d6fdaf 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -1038,6 +1038,25 @@ describe('Iterable', () => { }); }); + describe('getAuthToken', () => { + it('should return the auth token from RNIterableAPI', async () => { + const expectedToken = 'jwt-token'; + MockRNIterableAPI.authToken = expectedToken; + + const result = await Iterable.authManager.getAuthToken(); + + expect(result).toBe(expectedToken); + }); + + it('should return null when RNIterableAPI has no auth token', async () => { + MockRNIterableAPI.authToken = null; + + const result = await Iterable.authManager.getAuthToken(); + + expect(result).toBeNull(); + }); + }); + describe('passAlongAuthToken', () => { it('should call RNIterableAPI.passAlongAuthToken with a valid string token', async () => { // GIVEN a valid auth token diff --git a/src/core/classes/IterableApi.test.ts b/src/core/classes/IterableApi.test.ts index b19954f73..77b1b68b5 100644 --- a/src/core/classes/IterableApi.test.ts +++ b/src/core/classes/IterableApi.test.ts @@ -677,6 +677,25 @@ describe('IterableApi', () => { // ======================= AUTH ======================= // // ====================================================== // + describe('getAuthToken', () => { + it('should return the auth token from RNIterableAPI', async () => { + const expectedToken = 'jwt-token'; + MockRNIterableAPI.authToken = expectedToken; + + const result = await IterableApi.getAuthToken(); + + expect(result).toBe(expectedToken); + }); + + it('should return null when RNIterableAPI has no auth token', async () => { + MockRNIterableAPI.authToken = null; + + const result = await IterableApi.getAuthToken(); + + expect(result).toBeNull(); + }); + }); + describe('pauseAuthRetries', () => { it('should call RNIterableAPI.pauseAuthRetries with true', () => { // GIVEN pauseRetry is true diff --git a/src/core/classes/IterableApi.ts b/src/core/classes/IterableApi.ts index 4d0650102..9e01b6793 100644 --- a/src/core/classes/IterableApi.ts +++ b/src/core/classes/IterableApi.ts @@ -371,6 +371,14 @@ export class IterableApi { return RNIterableAPI.pauseAuthRetries(pauseRetry); } + /** + * Get the JWT currently held by the native SDK for the signed-in user. + */ + static getAuthToken() { + IterableLogger.log('getAuthToken'); + return RNIterableAPI.getAuthToken(); + } + /** * Pass along an auth token to the SDK. * diff --git a/src/core/classes/IterableAuthManager.ts b/src/core/classes/IterableAuthManager.ts index 44ece1af0..645459bb0 100644 --- a/src/core/classes/IterableAuthManager.ts +++ b/src/core/classes/IterableAuthManager.ts @@ -25,6 +25,20 @@ export class IterableAuthManager { return IterableApi.pauseAuthRetries(pauseRetry); } + /** + * Get the JWT currently held by the native SDK for the signed-in user. + * + * @returns The current auth token, or `null` when no token is stored + * + * @example + * ```typescript + * const token = await Iterable.authManager.getAuthToken(); + * ``` + */ + getAuthToken(): Promise { + return IterableApi.getAuthToken(); + } + /** * Pass along an auth token to the SDK. * From 3470f54a60691dffce6e33f902bace6f17019963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sun, 27 Sep 2026 15:31:58 +0100 Subject: [PATCH 10/25] feat(SDK-749): bridge embedded messaging sync callbacks Expose IterableConfig sync success/failure handlers via native events so apps can observe embedded sync outcomes. --- CHANGELOG.md | 8 + .../reactnative/RNIterableAPIModuleImpl.java | 14 ++ ios/RNIterableAPI/ReactIterableAPI.swift | 34 ++++- src/core/classes/Iterable.test.ts | 138 ++++++++++++++++++ src/core/classes/Iterable.ts | 26 ++++ src/core/classes/IterableConfig.ts | 48 ++++++ src/core/enums/IterableEventName.ts | 6 + 7 files changed, 272 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6cbd8519..6c26af466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ### Updates +- Added `IterableConfig.onEmbeddedMessagingSyncSucceeded` and + `IterableConfig.onEmbeddedMessagingSyncFailed` so apps can observe embedded + messaging sync outcomes (SDK-749). Failure callbacks receive a nullable reason + string forwarded from the native SDKs. On iOS, subscription-inactive or + invalid API key failures may invoke both `onEmbeddedMessagingDisabled` and + `onEmbeddedMessagingSyncFailed`; on Android only + `onEmbeddedMessagingDisabled` is invoked for those cases (native SDK + behavior). - 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. diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index ea7bae18b..f501c71bf 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -726,6 +726,18 @@ public void onEmbeddedMessagingDisabled() { IterableLogger.d(TAG, "onEmbeddedMessagingDisabled"); sendEvent(EventName.handleEmbeddedMessagingDisabledCalled.name(), null); } + + @Override + public void onEmbeddedMessagingSyncSucceeded() { + IterableLogger.d(TAG, "onEmbeddedMessagingSyncSucceeded"); + sendEvent(EventName.handleEmbeddedMessagingSyncSucceededCalled.name(), null); + } + + @Override + public void onEmbeddedMessagingSyncFailed(@Nullable String reason) { + IterableLogger.d(TAG, "onEmbeddedMessagingSyncFailed"); + sendEvent(EventName.handleEmbeddedMessagingSyncFailedCalled.name(), reason); + } // --------------------------------------------------------------------------------------- // endregion @@ -817,6 +829,8 @@ enum EventName { handleCustomActionCalled, handleEmbeddedMessageUpdateCalled, handleEmbeddedMessagingDisabledCalled, + handleEmbeddedMessagingSyncSucceededCalled, + handleEmbeddedMessagingSyncFailedCalled, handleInAppCalled, handleUrlCalled, receivedIterableEmbeddedMessagesChanged, diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index 26def081d..54b5341b3 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -35,6 +35,8 @@ import React case handleAuthFailureCalled case handleEmbeddedMessageUpdateCalled case handleEmbeddedMessagingDisabledCalled + case handleEmbeddedMessagingSyncSucceededCalled + case handleEmbeddedMessagingSyncFailedCalled } @objc public static var supportedEvents: [String] { @@ -702,8 +704,16 @@ import React // Add embedded update listener if any callback is present let onEmbeddedMessageUpdatePresent = configDict["onEmbeddedMessageUpdatePresent"] as? Bool ?? false let onEmbeddedMessagingDisabledPresent = configDict["onEmbeddedMessagingDisabledPresent"] as? Bool ?? false - - if onEmbeddedMessageUpdatePresent || onEmbeddedMessagingDisabledPresent { + let onEmbeddedMessagingSyncSucceededPresent = + configDict["onEmbeddedMessagingSyncSucceededPresent"] as? Bool ?? false + let onEmbeddedMessagingSyncFailedPresent = + configDict["onEmbeddedMessagingSyncFailedPresent"] as? Bool ?? false + + if onEmbeddedMessageUpdatePresent + || onEmbeddedMessagingDisabledPresent + || onEmbeddedMessagingSyncSucceededPresent + || onEmbeddedMessagingSyncFailedPresent + { IterableAPI.embeddedManager.addUpdateListener(self) } @@ -886,4 +896,24 @@ extension ReactIterableAPI: IterableEmbeddedUpdateDelegate { withName: EventName.handleEmbeddedMessagingDisabledCalled.rawValue, body: nil as Any?) } + + public func onEmbeddedMessagingSyncSucceeded() { + ITBInfo() + guard shouldEmit else { + return + } + delegate?.sendEvent( + withName: EventName.handleEmbeddedMessagingSyncSucceededCalled.rawValue, + body: nil as Any?) + } + + public func onEmbeddedMessagingSyncFailed(_ error: String?) { + ITBInfo() + guard shouldEmit else { + return + } + delegate?.sendEvent( + withName: EventName.handleEmbeddedMessagingSyncFailedCalled.rawValue, + body: error as Any?) + } } diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 6da4b9ca5..c298cfaa7 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -48,6 +48,12 @@ describe('Iterable', () => { nativeEmitter.removeAllListeners( IterableEventName.handleEmbeddedMessagingDisabledCalled ); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); // Clear any pending timers jest.clearAllTimers(); @@ -1494,6 +1500,138 @@ describe('Iterable', () => { expect(configDict.onEmbeddedMessagingDisabledPresent).toBe(true); }); }); + + describe('onEmbeddedMessagingSyncSucceeded', () => { + it('should call onEmbeddedMessagingSyncSucceeded when handleEmbeddedMessagingSyncSucceededCalled event is emitted', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; + config.onEmbeddedMessagingSyncSucceeded = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + expect(config.onEmbeddedMessagingSyncSucceeded).toHaveBeenCalled(); + expect(config.onEmbeddedMessagingSyncSucceeded).toHaveBeenCalledTimes( + 1 + ); + }); + + it('should not set up listener if onEmbeddedMessagingSyncSucceeded is not provided', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + Iterable.initialize('apiKey', config); + expect(() => { + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + }).not.toThrow(); + }); + + it('should include onEmbeddedMessagingSyncSucceededPresent flag in config dict when callback is provided', () => { + const config = new IterableConfig(); + config.onEmbeddedMessagingSyncSucceeded = jest.fn(); + const configDict = config.toDict(); + expect(configDict.onEmbeddedMessagingSyncSucceededPresent).toBe(true); + }); + + it('should set onEmbeddedMessagingSyncSucceededPresent flag to false when callback is not provided', () => { + const config = new IterableConfig(); + const configDict = config.toDict(); + expect(configDict.onEmbeddedMessagingSyncSucceededPresent).toBe(false); + }); + }); + + describe('onEmbeddedMessagingSyncFailed', () => { + it('should call onEmbeddedMessagingSyncFailed with reason when handleEmbeddedMessagingSyncFailedCalled event is emitted', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; + config.onEmbeddedMessagingSyncFailed = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled, + 'network error' + ); + expect(config.onEmbeddedMessagingSyncFailed).toHaveBeenCalledWith( + 'network error' + ); + }); + + it('should deliver null when failure event has no reason', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; + config.onEmbeddedMessagingSyncFailed = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled, + null + ); + expect(config.onEmbeddedMessagingSyncFailed).toHaveBeenCalledWith(null); + }); + + it('should deliver null when failure event omits the reason payload', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; + config.onEmbeddedMessagingSyncFailed = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + expect(config.onEmbeddedMessagingSyncFailed).toHaveBeenCalledWith(null); + }); + + it('should not set up listener if onEmbeddedMessagingSyncFailed is not provided', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + Iterable.initialize('apiKey', config); + expect(() => { + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled, + 'ignored' + ); + }).not.toThrow(); + }); + + it('should include onEmbeddedMessagingSyncFailedPresent flag in config dict when callback is provided', () => { + const config = new IterableConfig(); + config.onEmbeddedMessagingSyncFailed = jest.fn(); + const configDict = config.toDict(); + expect(configDict.onEmbeddedMessagingSyncFailedPresent).toBe(true); + }); + + it('should set onEmbeddedMessagingSyncFailedPresent flag to false when callback is not provided', () => { + const config = new IterableConfig(); + const configDict = config.toDict(); + expect(configDict.onEmbeddedMessagingSyncFailedPresent).toBe(false); + }); + }); }); describe('getAttributionInfo', () => { diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 483b177b6..7a276659f 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -1018,6 +1018,12 @@ export class Iterable { RNEventEmitter.removeAllListeners( IterableEventName.handleEmbeddedMessagingDisabledCalled ); + RNEventEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + RNEventEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); } /** @@ -1309,6 +1315,26 @@ export class Iterable { } ); } + + if (Iterable.savedConfig.onEmbeddedMessagingSyncSucceeded) { + RNEventEmitter.addListener( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled, + () => { + Iterable.savedConfig.onEmbeddedMessagingSyncSucceeded?.(); + } + ); + } + + if (Iterable.savedConfig.onEmbeddedMessagingSyncFailed) { + RNEventEmitter.addListener( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled, + (reason: string | null | undefined) => { + Iterable.savedConfig.onEmbeddedMessagingSyncFailed?.( + reason ?? null + ); + } + ); + } } } diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index b4de2fb41..38a6d32a1 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -437,6 +437,44 @@ export class IterableConfig { */ onEmbeddedMessagingDisabled?: () => void; + /** + * A callback function that is called when an embedded messaging sync completes + * successfully. + * + * This is distinct from {@link onEmbeddedMessageUpdate}, which fires when the + * local embedded message cache changes. Sync success indicates the network + * sync operation finished without error. + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.enableEmbeddedMessaging = true; + * config.onEmbeddedMessagingSyncSucceeded = () => { + * console.log('Embedded sync succeeded'); + * }; + * Iterable.initialize('', config); + * ``` + */ + onEmbeddedMessagingSyncSucceeded?: () => void; + + /** + * A callback function that is called when an embedded messaging sync fails. + * + * @param reason - Failure reason from the native SDK, or `null` when none is + * available. + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.enableEmbeddedMessaging = true; + * config.onEmbeddedMessagingSyncFailed = (reason) => { + * console.warn('Embedded sync failed', reason); + * }; + * Iterable.initialize('', config); + * ``` + */ + onEmbeddedMessagingSyncFailed?: (reason: string | null) => void; + /** * Converts the IterableConfig instance to a dictionary object. * @@ -473,6 +511,16 @@ export class IterableConfig { */ onEmbeddedMessagingDisabledPresent: this.onEmbeddedMessagingDisabled !== undefined, + /** + * A boolean indicating if an embedded messaging sync succeeded callback is present. + */ + onEmbeddedMessagingSyncSucceededPresent: + this.onEmbeddedMessagingSyncSucceeded !== undefined, + /** + * A boolean indicating if an embedded messaging sync failed callback is present. + */ + onEmbeddedMessagingSyncFailedPresent: + this.onEmbeddedMessagingSyncFailed !== undefined, /** The log level for the SDK. */ logLevel: this.logLevel, expiringAuthTokenRefreshPeriod: this.expiringAuthTokenRefreshPeriod, diff --git a/src/core/enums/IterableEventName.ts b/src/core/enums/IterableEventName.ts index 6ea79c754..8c86615d4 100644 --- a/src/core/enums/IterableEventName.ts +++ b/src/core/enums/IterableEventName.ts @@ -23,4 +23,10 @@ export enum IterableEventName { handleEmbeddedMessageUpdateCalled = 'handleEmbeddedMessageUpdateCalled', /** Event that fires when embedded messaging is disabled */ handleEmbeddedMessagingDisabledCalled = 'handleEmbeddedMessagingDisabledCalled', + /** Event that fires when an embedded messaging sync succeeds */ + handleEmbeddedMessagingSyncSucceededCalled = + 'handleEmbeddedMessagingSyncSucceededCalled', + /** Event that fires when an embedded messaging sync fails */ + handleEmbeddedMessagingSyncFailedCalled = + 'handleEmbeddedMessagingSyncFailedCalled', } From d0ffb1d76de1fdff4770836c3332a3f8c56c97aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sun, 27 Sep 2026 16:44:16 +0100 Subject: [PATCH 11/25] test(SDK-749): cover sync callback gating and independence Assert sync handlers stay separate from message-update events and respect enableEmbeddedMessaging when callbacks are omitted or set. --- src/core/classes/Iterable.test.ts | 83 +++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index c298cfaa7..b882cc28b 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -1528,6 +1528,7 @@ describe('Iterable', () => { ); const config = new IterableConfig(); config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; Iterable.initialize('apiKey', config); expect(() => { nativeEmitter.emit( @@ -1536,6 +1537,21 @@ describe('Iterable', () => { }).not.toThrow(); }); + it('should not set up listener when enableEmbeddedMessaging is false even if callback is provided', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.onEmbeddedMessagingSyncSucceeded = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + expect(config.onEmbeddedMessagingSyncSucceeded).not.toHaveBeenCalled(); + }); + it('should include onEmbeddedMessagingSyncSucceededPresent flag in config dict when callback is provided', () => { const config = new IterableConfig(); config.onEmbeddedMessagingSyncSucceeded = jest.fn(); @@ -1610,6 +1626,7 @@ describe('Iterable', () => { ); const config = new IterableConfig(); config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; Iterable.initialize('apiKey', config); expect(() => { nativeEmitter.emit( @@ -1619,6 +1636,22 @@ describe('Iterable', () => { }).not.toThrow(); }); + it('should not set up listener when enableEmbeddedMessaging is false even if callback is provided', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.onEmbeddedMessagingSyncFailed = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled, + 'ignored' + ); + expect(config.onEmbeddedMessagingSyncFailed).not.toHaveBeenCalled(); + }); + it('should include onEmbeddedMessagingSyncFailedPresent flag in config dict when callback is provided', () => { const config = new IterableConfig(); config.onEmbeddedMessagingSyncFailed = jest.fn(); @@ -1632,6 +1665,56 @@ describe('Iterable', () => { expect(configDict.onEmbeddedMessagingSyncFailedPresent).toBe(false); }); }); + + describe('sync callbacks vs onEmbeddedMessageUpdate', () => { + it('should invoke only the matching callback when update and sync handlers are configured', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessageUpdateCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; + config.onEmbeddedMessageUpdate = jest.fn(); + config.onEmbeddedMessagingSyncSucceeded = jest.fn(); + config.onEmbeddedMessagingSyncFailed = jest.fn(); + Iterable.initialize('apiKey', config); + + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessageUpdateCalled + ); + expect(config.onEmbeddedMessageUpdate).toHaveBeenCalled(); + expect(config.onEmbeddedMessagingSyncSucceeded).not.toHaveBeenCalled(); + expect(config.onEmbeddedMessagingSyncFailed).not.toHaveBeenCalled(); + + jest.clearAllMocks(); + + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + expect(config.onEmbeddedMessagingSyncSucceeded).toHaveBeenCalled(); + expect(config.onEmbeddedMessageUpdate).not.toHaveBeenCalled(); + expect(config.onEmbeddedMessagingSyncFailed).not.toHaveBeenCalled(); + + jest.clearAllMocks(); + + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled, + 'sync failed' + ); + expect(config.onEmbeddedMessagingSyncFailed).toHaveBeenCalledWith( + 'sync failed' + ); + expect(config.onEmbeddedMessageUpdate).not.toHaveBeenCalled(); + expect(config.onEmbeddedMessagingSyncSucceeded).not.toHaveBeenCalled(); + }); + }); }); describe('getAttributionInfo', () => { From c14cc7819ccc978a856929f69c925a90a65aa23a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Mon, 28 Sep 2026 18:43:28 +0100 Subject: [PATCH 12/25] refactor(SDK-555): simplify keychain encryption bridge wiring Drop dead encryptionEnforced fallback, tighten deprecated docs, and trim misleading tests per PR review. --- CHANGELOG.md | 3 ++- .../iterable/reactnative/Serialization.java | 9 ++------- src/core/classes/Iterable.test.ts | 18 ------------------ src/core/classes/IterableConfig.ts | 5 +---- 4 files changed, 5 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3267a6d55..9c4902c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ### Updates -- Added `IterableConfig.keychainEncryption` (default `true`, Android only) so React Native apps can control native `IterableConfig.Builder.setKeychainEncryption`. Deprecated `IterableConfig.encryptionEnforced`, which was serialized but never applied on Android (SDK-555). +- Added `IterableConfig.keychainEncryption` (default `true`, Android only) so React Native apps can control native `IterableConfig.Builder.setKeychainEncryption`. +- Deprecated `IterableConfig.encryptionEnforced`, which never had an effect. - 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. diff --git a/android/src/main/java/com/iterable/reactnative/Serialization.java b/android/src/main/java/com/iterable/reactnative/Serialization.java index 11a0a2345..994906d71 100644 --- a/android/src/main/java/com/iterable/reactnative/Serialization.java +++ b/android/src/main/java/com/iterable/reactnative/Serialization.java @@ -250,13 +250,8 @@ static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableConte configBuilder.setEnableEmbeddedMessaging(iterableContextJSON.optBoolean("enableEmbeddedMessaging")); } - boolean keychainEncryption = true; - if (iterableContextJSON.has("keychainEncryption")) { - keychainEncryption = iterableContextJSON.optBoolean("keychainEncryption"); - } else if (iterableContextJSON.optBoolean("encryptionEnforced")) { - keychainEncryption = true; - } - configBuilder.setKeychainEncryption(keychainEncryption); + configBuilder.setKeychainEncryption( + iterableContextJSON.optBoolean("keychainEncryption", true)); if (iterableContextJSON.has("retryPolicy")) { JSONObject retryPolicyJson = iterableContextJSON.getJSONObject("retryPolicy"); diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 017fce2b2..a12ad43dd 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -385,24 +385,6 @@ describe('Iterable', () => { expect(config.keychainEncryption).toBe(false); expect(configDict.keychainEncryption).toBe(false); }); - - it('should still serialize encrypted keychain when only deprecated encryptionEnforced is set', () => { - const config = new IterableConfig(); - config.encryptionEnforced = true; - const configDict = config.toDict(); - expect(config.keychainEncryption).toBe(true); - expect(configDict.keychainEncryption).toBe(true); - expect(configDict.encryptionEnforced).toBe(true); - }); - - it('should prefer keychainEncryption over deprecated encryptionEnforced in toDict()', () => { - const config = new IterableConfig(); - config.keychainEncryption = false; - config.encryptionEnforced = true; - const configDict = config.toDict(); - expect(configDict.keychainEncryption).toBe(false); - expect(configDict.encryptionEnforced).toBe(true); - }); }); describe('urlHandler', () => { diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index 119fed4aa..4f2af22b3 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -331,10 +331,7 @@ export class IterableConfig { keychainEncryption = true; /** - * @deprecated Use {@link IterableConfig.keychainEncryption} instead. Android honors - * `keychainEncryption` (default `true`, encrypted). This field is still serialized so - * older bridge payloads that omit `keychainEncryption` can fall back when - * `encryptionEnforced` is `true`; it does not disable encryption when left `false`. + * @deprecated Has no effect on any platform. Use {@link IterableConfig.keychainEncryption}. */ encryptionEnforced = false; From ffcd73f4bf5ec04713a590679f3a343ffe19384c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Wed, 30 Sep 2026 11:56:30 +0100 Subject: [PATCH 13/25] fix(SDK-557): always wire Android decryption bridge handler IterableKeychain caches decryptionFailureHandler at first construction. Register the RN bridge unconditionally; keep the JS listener conditional. --- .../reactnative/RNIterableAPIModuleImpl.java | 11 +++---- src/core/classes/Iterable.test.ts | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index f0814aba9..b9e01329d 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -94,9 +94,9 @@ public void initializeWithApiKey(String apiKey, ReadableMap configReadableMap, S configBuilder.setAuthHandler(this); } - if (configReadableMap.hasKey("decryptionFailureHandlerPresent") && configReadableMap.getBoolean("decryptionFailureHandlerPresent") == true) { - configBuilder.setDecryptionFailureHandler(this); - } + // IterableKeychain is created once with config.decryptionFailureHandler; the JS + // listener remains conditional on decryptionFailureHandlerPresent. + configBuilder.setDecryptionFailureHandler(this); // Check if embedded messaging is enabled before building config boolean enableEmbeddedMessaging = configReadableMap.hasKey("enableEmbeddedMessaging") && configReadableMap.getBoolean("enableEmbeddedMessaging"); @@ -166,9 +166,8 @@ public void initialize2WithApiKey(String apiKey, ReadableMap configReadableMap, configBuilder.setAuthHandler(this); } - if (configReadableMap.hasKey("decryptionFailureHandlerPresent") && configReadableMap.getBoolean("decryptionFailureHandlerPresent") == true) { - configBuilder.setDecryptionFailureHandler(this); - } + // See initializeWithApiKey — keychain caches this handler at first construction. + configBuilder.setDecryptionFailureHandler(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 diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index ecb715813..687a8db86 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -1352,6 +1352,38 @@ describe('Iterable', () => { const configDict = config.toDict(); expect(configDict.decryptionFailureHandlerPresent).toBe(false); }); + + it('should invoke decryptionFailureHandler after re-initialize enables the callback', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleDecryptionFailureCalled + ); + const configWithoutHandler = new IterableConfig(); + configWithoutHandler.logReactNativeSdkCalls = false; + Iterable.initialize('apiKey', configWithoutHandler); + expect( + nativeEmitter.listenerCount( + IterableEventName.handleDecryptionFailureCalled + ) + ).toBe(0); + + const configWithHandler = new IterableConfig(); + configWithHandler.logReactNativeSdkCalls = false; + configWithHandler.decryptionFailureHandler = jest.fn(); + Iterable.initialize('apiKey', configWithHandler); + expect( + nativeEmitter.listenerCount( + IterableEventName.handleDecryptionFailureCalled + ) + ).toBe(1); + + nativeEmitter.emit(IterableEventName.handleDecryptionFailureCalled, { + message: 'late handler', + }); + expect(configWithHandler.decryptionFailureHandler).toHaveBeenCalledWith({ + message: 'late handler', + }); + }); }); describe('embedded messaging callbacks', () => { From ccc3f0ad4fbf195bf7747504a8874265cbcaa13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 1 Oct 2026 20:52:33 +0100 Subject: [PATCH 14/25] fix(SDK-748): forward appAlreadyRunning on Android push open The bridge accepted the flag but dropped it before IterableApi. Call the five-argument trackPushOpen overload so campaigns see the real value. --- CHANGELOG.md | 4 ++++ .../com/iterable/iterableapi/RNIterableInternal.java | 10 ++++++++-- .../iterable/reactnative/RNIterableAPIModuleImpl.java | 7 ++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3132e52c..542e29983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +### Fixes + +- Fixed Android push-open tracking so `appAlreadyRunning` is forwarded to the native SDK (SDK-748). Previously every Android push open was reported as if the app was not already running. + ### Updates - Added `Iterable.authManager.getAuthToken()` to read the JWT currently held by the native SDK (SDK-559). diff --git a/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java b/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java index f5ea29df6..4516abd42 100644 --- a/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java +++ b/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java @@ -29,8 +29,14 @@ public static IterableInAppMessage getMessageById(String messageId) { return IterableApi.getInstance().getInAppManager().getMessageById(messageId); } - public static void trackPushOpenWithCampaignId(Integer campaignId, Integer templateId, String messageId, @Nullable JSONObject dataFields) { - IterableApi.getInstance().trackPushOpen(campaignId, templateId, messageId, dataFields); + public static void trackPushOpenWithCampaignId( + Integer campaignId, + Integer templateId, + String messageId, + boolean appAlreadyRunning, + @Nullable JSONObject dataFields) { + IterableApi.getInstance().trackPushOpen( + campaignId, templateId, messageId, appAlreadyRunning, dataFields); } public static void setAttributionInfo(IterableAttributionInfo attributionInfo) { diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index ae393863f..6f8793102 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -277,7 +277,12 @@ public void trackPurchase(double total, ReadableArray items, @Nullable ReadableM } public void trackPushOpenWithCampaignId(double campaignId, @Nullable Double templateId, String messageId, boolean appAlreadyRunning, @Nullable ReadableMap dataFields) { - RNIterableInternal.trackPushOpenWithCampaignId((int) campaignId, templateId != null ? templateId.intValue() : null, messageId, optSerializedDataFields(dataFields)); + RNIterableInternal.trackPushOpenWithCampaignId( + (int) campaignId, + templateId != null ? templateId.intValue() : null, + messageId, + appAlreadyRunning, + optSerializedDataFields(dataFields)); } public void updateSubscriptions(@Nullable ReadableArray emailListIds, @Nullable ReadableArray unsubscribedChannelIds, @Nullable ReadableArray unsubscribedMessageTypeIds, @Nullable ReadableArray subscribedMessageTypeIds, double campaignId, double templateId) { From 7e4ce354a9ad644dc6fe6c1e1a64386bcb343e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 1 Oct 2026 21:10:03 +0100 Subject: [PATCH 15/25] test(SDK-748): assert Android forwards appAlreadyRunning Add RNIterableInternal unit tests against the five-argument trackPushOpen overload, wire testDebugUnitTest in CI, and implement embedded sync handler stubs required to compile against iterableapi 3.10.1. --- .github/workflows/ci.yml | 5 +++ android/build.gradle | 12 ++++++ .../reactnative/RNIterableAPIModuleImpl.java | 14 +++++++ .../iterableapi/RNIterableInternalTest.java | 41 +++++++++++++++++++ package.json | 1 + 5 files changed, 73 insertions(+) create mode 100644 android/src/test/java/com/iterable/iterableapi/RNIterableInternalTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 273b19918..53378c24a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,11 @@ jobs: run: | yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" + - name: Run Android library unit tests + if: env.turbo_cache_hit != 1 + run: | + cd example/android && ./gradlew :iterable_react-native-sdk:testDebugUnitTest --no-daemon + build-ios: runs-on: macos-14 env: diff --git a/android/build.gradle b/android/build.gradle index 623d9e5fc..e1edab03d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -81,6 +81,14 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } + testOptions { + unitTests.all { + testLogging { + exceptionFormat "full" + } + } + } + sourceSets { main { if (isNewArchitectureEnabled()) { @@ -107,5 +115,9 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" api "com.iterable:iterableapi:3.10.1" // api project(":iterableapi") // links to local android SDK repo rather than by release + + testImplementation "junit:junit:4.13.2" + testImplementation "org.mockito:mockito-core:4.8.0" + testImplementation "org.mockito:mockito-inline:4.8.0" } diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index 6f8793102..63a251424 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -761,6 +761,18 @@ public void onEmbeddedMessagingDisabled() { IterableLogger.d(TAG, "onEmbeddedMessagingDisabled"); sendEvent(EventName.handleEmbeddedMessagingDisabledCalled.name(), null); } + + @Override + public void onEmbeddedMessagingSyncSucceeded() { + IterableLogger.d(TAG, "onEmbeddedMessagingSyncSucceeded"); + sendEvent(EventName.handleEmbeddedMessagingSyncSucceededCalled.name(), null); + } + + @Override + public void onEmbeddedMessagingSyncFailed(@Nullable String reason) { + IterableLogger.d(TAG, "onEmbeddedMessagingSyncFailed"); + sendEvent(EventName.handleEmbeddedMessagingSyncFailedCalled.name(), reason); + } // --------------------------------------------------------------------------------------- // endregion @@ -853,6 +865,8 @@ enum EventName { handleDecryptionFailureCalled, handleEmbeddedMessageUpdateCalled, handleEmbeddedMessagingDisabledCalled, + handleEmbeddedMessagingSyncSucceededCalled, + handleEmbeddedMessagingSyncFailedCalled, handleInAppCalled, handleUrlCalled, receivedIterableEmbeddedMessagesChanged, diff --git a/android/src/test/java/com/iterable/iterableapi/RNIterableInternalTest.java b/android/src/test/java/com/iterable/iterableapi/RNIterableInternalTest.java new file mode 100644 index 000000000..f2c2320b0 --- /dev/null +++ b/android/src/test/java/com/iterable/iterableapi/RNIterableInternalTest.java @@ -0,0 +1,41 @@ +package com.iterable.iterableapi; + +import org.junit.Test; +import org.mockito.MockedStatic; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; + +public class RNIterableInternalTest { + + @Test + public void trackPushOpenWithCampaignId_forwardsAppAlreadyRunningTrue() { + IterableApi api = mock(IterableApi.class); + try (MockedStatic iterableApi = mockStatic(IterableApi.class)) { + iterableApi.when(IterableApi::getInstance).thenReturn(api); + + RNIterableInternal.trackPushOpenWithCampaignId( + 10, 20, "message-id", true, null); + + verify(api).trackPushOpen( + eq(10), eq(20), eq("message-id"), eq(true), isNull()); + } + } + + @Test + public void trackPushOpenWithCampaignId_forwardsAppAlreadyRunningFalse() { + IterableApi api = mock(IterableApi.class); + try (MockedStatic iterableApi = mockStatic(IterableApi.class)) { + iterableApi.when(IterableApi::getInstance).thenReturn(api); + + RNIterableInternal.trackPushOpenWithCampaignId( + 10, 20, "message-id", false, null); + + verify(api).trackPushOpen( + eq(10), eq(20), eq("message-id"), eq(false), isNull()); + } + } +} diff --git a/package.json b/package.json index 3ffca9aa5..d0847072a 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:android:unit": "cd example/android && ./gradlew :iterable_react-native-sdk:testDebugUnitTest --no-daemon", "typecheck": "tsc", "lint": "eslint \"**/*.{js,ts,tsx}\"", "check:circular": "dpdm --no-warning --no-tree -T --circular --exit-code circular:1 src/index.tsx", From d4973d078307099b200845c6650258d3479cc0a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 1 Oct 2026 21:15:21 +0100 Subject: [PATCH 16/25] chore: gitignore local .artifacts directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index de804bf93..655bf6b15 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,7 @@ docs/ # Agents .agent/ +.artifacts/ .claude/ .cursor/ .opencode/ From 41df363c45debddcda2f6ab3ca2e0234bd14f284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 1 Oct 2026 21:32:50 +0100 Subject: [PATCH 17/25] fix(SDK-748): use empty embedded sync handler overrides Keep compile-only stubs for IterableEmbeddedUpdateHandler without emitting events outside SDK-748 scope; JS wiring remains SDK-749. --- .../reactnative/RNIterableAPIModuleImpl.java | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index 63a251424..4f3e5b687 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -763,16 +763,10 @@ public void onEmbeddedMessagingDisabled() { } @Override - public void onEmbeddedMessagingSyncSucceeded() { - IterableLogger.d(TAG, "onEmbeddedMessagingSyncSucceeded"); - sendEvent(EventName.handleEmbeddedMessagingSyncSucceededCalled.name(), null); - } + public void onEmbeddedMessagingSyncSucceeded() {} @Override - public void onEmbeddedMessagingSyncFailed(@Nullable String reason) { - IterableLogger.d(TAG, "onEmbeddedMessagingSyncFailed"); - sendEvent(EventName.handleEmbeddedMessagingSyncFailedCalled.name(), reason); - } + public void onEmbeddedMessagingSyncFailed(@Nullable String reason) {} // --------------------------------------------------------------------------------------- // endregion @@ -865,8 +859,6 @@ enum EventName { handleDecryptionFailureCalled, handleEmbeddedMessageUpdateCalled, handleEmbeddedMessagingDisabledCalled, - handleEmbeddedMessagingSyncSucceededCalled, - handleEmbeddedMessagingSyncFailedCalled, handleInAppCalled, handleUrlCalled, receivedIterableEmbeddedMessagesChanged, From 81fe6c8e73a3557e4d8dee34b7fe5cc4f1bdc111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 1 Oct 2026 22:07:31 +0100 Subject: [PATCH 18/25] feat(SDK-750): expose jsonOnly on in-app message payloads Serialize the native JSON-only flag onto bridge dictionaries so inAppHandler and message fetches can distinguish JSON-only in-apps from HTML messages. --- CHANGELOG.md | 1 + .../iterableapi/RNIterableInternal.java | 8 +++++- ios/RNIterableAPI/Serialization.swift | 1 + .../classes/IterableInAppMessage.test.ts | 26 +++++++++++++++++++ src/inApp/classes/IterableInAppMessage.ts | 22 +++++++++++++--- src/inApp/types/IterableInAppMessageRaw.ts | 2 ++ 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3132e52c..a5c32314c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Updates +- Added `IterableInAppMessage.jsonOnly` so `inAppHandler` and in-app message fetches can distinguish JSON-only messages from HTML in-apps (SDK-750). - Added `Iterable.authManager.getAuthToken()` to read the JWT currently held by the native SDK (SDK-559). - iOS: `IterableAPI.authToken`. - Android: `IterableApi.getAuthToken()`. diff --git a/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java b/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java index f5ea29df6..ac186f77c 100644 --- a/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java +++ b/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java @@ -22,7 +22,13 @@ public static String getAuthToken() { } public static JSONObject getInAppMessageJson(IterableInAppMessage message) { - return message.toJSONObject(); + JSONObject messageJson = message.toJSONObject(); + try { + messageJson.put("jsonOnly", message.isJsonOnly()); + } catch (org.json.JSONException e) { + IterableLogger.e(TAG, "Failed to add jsonOnly to in-app message JSON"); + } + return messageJson; } public static IterableInAppMessage getMessageById(String messageId) { diff --git a/ios/RNIterableAPI/Serialization.swift b/ios/RNIterableAPI/Serialization.swift index fee4c49b6..4fcfcdda8 100644 --- a/ios/RNIterableAPI/Serialization.swift +++ b/ios/RNIterableAPI/Serialization.swift @@ -199,6 +199,7 @@ extension IterableInAppMessage { dict["customPayload"] = customPayload dict["read"] = read dict["priorityLevel"] = priorityLevel + dict["jsonOnly"] = jsonOnly return dict } diff --git a/src/inApp/classes/IterableInAppMessage.test.ts b/src/inApp/classes/IterableInAppMessage.test.ts index 156bed1bc..9fed54d6b 100644 --- a/src/inApp/classes/IterableInAppMessage.test.ts +++ b/src/inApp/classes/IterableInAppMessage.test.ts @@ -108,4 +108,30 @@ describe('IterableInAppMessage.fromDict', () => { expect(message.inboxMetadata).toBeUndefined(); }); }); + + describe('jsonOnly', () => { + it('parses jsonOnly true when present', () => { + const message = IterableInAppMessage.fromDict({ + ...baseDict, + jsonOnly: true, + }); + + expect(message.jsonOnly).toBe(true); + }); + + it('parses jsonOnly false when present', () => { + const message = IterableInAppMessage.fromDict({ + ...baseDict, + jsonOnly: false, + }); + + expect(message.jsonOnly).toBe(false); + }); + + it('defaults jsonOnly to false when omitted', () => { + const message = IterableInAppMessage.fromDict({ ...baseDict }); + + expect(message.jsonOnly).toBe(false); + }); + }); }); \ No newline at end of file diff --git a/src/inApp/classes/IterableInAppMessage.ts b/src/inApp/classes/IterableInAppMessage.ts index c77b08e63..3dee68e04 100644 --- a/src/inApp/classes/IterableInAppMessage.ts +++ b/src/inApp/classes/IterableInAppMessage.ts @@ -90,6 +90,13 @@ export class IterableInAppMessage { */ readonly priorityLevel: number; + /** + * Whether this message is JSON-only (custom payload without Iterable HTML UI). + * For JSON-only messages, `inAppHandler` show/skip does not control display on native; + * use this flag to branch custom handling instead of inferring from `customPayload` alone. + */ + readonly jsonOnly: boolean; + /** * Constructs an instance of IterableInAppMessage. * @@ -103,6 +110,7 @@ export class IterableInAppMessage { * @param customPayload - A custom payload associated with the message. * @param read - A boolean indicating whether the message has been read. * @param priorityLevel - The priority level of the message. + * @param jsonOnly - Whether the message is JSON-only (defaults to `false`). */ constructor( messageId: string, @@ -114,7 +122,8 @@ export class IterableInAppMessage { inboxMetadata: IterableInboxMetadata | undefined, customPayload: unknown | undefined, read: boolean, - priorityLevel: number + priorityLevel: number, + jsonOnly: boolean = false ) { this.campaignId = campaignId; this.messageId = messageId; @@ -126,6 +135,7 @@ export class IterableInAppMessage { this.customPayload = customPayload; this.read = read; this.priorityLevel = priorityLevel; + this.jsonOnly = jsonOnly; } /** @@ -147,7 +157,8 @@ export class IterableInAppMessage { inAppMessage?.inboxMetadata, inAppMessage?.customPayload, inAppMessage?.read, - inAppMessage?.priorityLevel + inAppMessage?.priorityLevel, + inAppMessage?.jsonOnly ?? false ); } @@ -197,6 +208,10 @@ export class IterableInAppMessage { const read = IterableUtil.readBoolean(dict, 'read'); const priorityLevel = dict.priorityLevel; + const jsonOnly = IterableUtil.readBoolean( + dict as Record, + 'jsonOnly' + ); return new IterableInAppMessage( messageId, @@ -212,7 +227,8 @@ export class IterableInAppMessage { inboxMetadata, customPayload, read, - priorityLevel + priorityLevel, + jsonOnly ); } } diff --git a/src/inApp/types/IterableInAppMessageRaw.ts b/src/inApp/types/IterableInAppMessageRaw.ts index 78a1748a3..07048b4d3 100644 --- a/src/inApp/types/IterableInAppMessageRaw.ts +++ b/src/inApp/types/IterableInAppMessageRaw.ts @@ -29,4 +29,6 @@ export type IterableInAppMessageRaw = { read?: boolean; /** The priority level of the message. */ priorityLevel?: number; + /** Whether the message is JSON-only (no HTML UI); show/skip in `inAppHandler` does not apply. */ + jsonOnly?: boolean; }; From fe0fe08ede4848ed39ffb6c6b3c928d43ae08bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 1 Oct 2026 22:18:20 +0100 Subject: [PATCH 19/25] fix(SDK-750): drop no-op jsonOnly put catch on Android Always overwrite jsonOnly with a boolean after toJSONObject() so the bridge does not forward native integer 1 to JS. --- .../com/iterable/iterableapi/RNIterableInternal.java | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java b/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java index ac186f77c..315e91b7d 100644 --- a/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java +++ b/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java @@ -6,8 +6,6 @@ public class RNIterableInternal { - private static String TAG = "RNIterableInternal"; - public static String getEmail() { return IterableApi.getInstance().getEmail(); } @@ -23,11 +21,8 @@ public static String getAuthToken() { public static JSONObject getInAppMessageJson(IterableInAppMessage message) { JSONObject messageJson = message.toJSONObject(); - try { - messageJson.put("jsonOnly", message.isJsonOnly()); - } catch (org.json.JSONException e) { - IterableLogger.e(TAG, "Failed to add jsonOnly to in-app message JSON"); - } + // Overwrite native serialization (may use integer 1) so the bridge sends a boolean for JS. + messageJson.putOpt("jsonOnly", message.isJsonOnly()); return messageJson; } From 7602034943ff94ce11c97dacf0708455e0c277ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 1 Oct 2026 22:33:40 +0100 Subject: [PATCH 20/25] fix(SDK-750): declare JSONException for jsonOnly bridge put Use put(boolean) instead of putOpt so false is always written, and propagate JSONException to existing handlers in onNewInApp and getMessages paths. --- .../java/com/iterable/iterableapi/RNIterableInternal.java | 6 ++++-- .../com/iterable/reactnative/RNIterableAPIModuleImpl.java | 3 +-- .../main/java/com/iterable/reactnative/Serialization.java | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java b/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java index 315e91b7d..cbfbf9c75 100644 --- a/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java +++ b/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java @@ -2,6 +2,7 @@ import androidx.annotation.Nullable; +import org.json.JSONException; import org.json.JSONObject; public class RNIterableInternal { @@ -19,10 +20,11 @@ public static String getAuthToken() { return IterableApi.getInstance().getAuthToken(); } - public static JSONObject getInAppMessageJson(IterableInAppMessage message) { + public static JSONObject getInAppMessageJson(IterableInAppMessage message) + throws JSONException { JSONObject messageJson = message.toJSONObject(); // Overwrite native serialization (may use integer 1) so the bridge sends a boolean for JS. - messageJson.putOpt("jsonOnly", message.isJsonOnly()); + messageJson.put("jsonOnly", message.isJsonOnly()); return messageJson; } diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index ae393863f..7461f45e6 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -626,9 +626,8 @@ public boolean handleIterableCustomAction(@NonNull IterableAction action, @NonNu public IterableInAppHandler.InAppResponse onNewInApp(@NonNull IterableInAppMessage message) { IterableLogger.printInfo(); - JSONObject messageJson = RNIterableInternal.getInAppMessageJson(message); - try { + JSONObject messageJson = RNIterableInternal.getInAppMessageJson(message); WritableMap eventData = Serialization.convertJsonToMap(messageJson); jsCallBackLatch = new CountDownLatch(1); sendEvent(EventName.handleInAppCalled.name(), eventData); diff --git a/android/src/main/java/com/iterable/reactnative/Serialization.java b/android/src/main/java/com/iterable/reactnative/Serialization.java index 994906d71..55c40e38f 100644 --- a/android/src/main/java/com/iterable/reactnative/Serialization.java +++ b/android/src/main/java/com/iterable/reactnative/Serialization.java @@ -129,7 +129,8 @@ static JSONObject messageContentToJsonObject(IterableInAppMessage.Content conten return messageContent; } - static JSONArray serializeInAppMessages(List inAppMessages) { + static JSONArray serializeInAppMessages(List inAppMessages) + throws JSONException { JSONArray inAppMessagesJson = new JSONArray(); for (IterableInAppMessage message : inAppMessages) { JSONObject messageJson = RNIterableInternal.getInAppMessageJson(message); From 70024a9dc9f282db0f768288615f4c881021680f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 1 Oct 2026 22:48:31 +0100 Subject: [PATCH 21/25] feat(embedded): add placement IDs to syncMessages (SDK-751) Expose optional placement scoping on embedded sync so RN matches native Android and iOS APIs; omit/null/empty still syncs all placements. --- CHANGELOG.md | 1 + .../reactnative/RNIterableAPIModuleImpl.java | 15 ++++++++--- .../newarch/java/com/RNIterableAPIModule.java | 4 +-- .../oldarch/java/com/RNIterableAPIModule.java | 4 +-- ios/RNIterableAPI/RNIterableAPI.mm | 8 +++--- ios/RNIterableAPI/ReactIterableAPI.swift | 11 +++++--- src/api/NativeRNIterableAPI.ts | 2 +- src/core/classes/IterableApi.test.ts | 27 ++++++++++++++++--- src/core/classes/IterableApi.ts | 8 +++--- .../classes/IterableEmbeddedManager.test.ts | 19 +++++++++++-- .../classes/IterableEmbeddedManager.ts | 8 ++++-- 11 files changed, 82 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3132e52c..727089101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Updates +- Added optional placement IDs to `Iterable.embeddedManager.syncMessages()` so apps can sync embedded messages for specific placements only (SDK-751). When omitted, null, or empty, behavior matches the previous sync-all call. - Added `Iterable.authManager.getAuthToken()` to read the JWT currently held by the native SDK (SDK-559). - iOS: `IterableAPI.authToken`. - Android: `IterableApi.getAuthToken()`. diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index ae393863f..fc6c5cb15 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -762,9 +762,18 @@ public void onEmbeddedMessagingDisabled() { // --------------------------------------------------------------------------------------- // region Embedded messaging - public void syncEmbeddedMessages() { - IterableLogger.d(TAG, "syncEmbeddedMessages"); - IterableApi.getInstance().getEmbeddedManager().syncMessages(); + public void syncEmbeddedMessages(@Nullable ReadableArray placementIds) { + IterableLogger.d(TAG, "syncEmbeddedMessages for placements: " + placementIds); + + if (placementIds == null || placementIds.size() == 0) { + IterableApi.getInstance().getEmbeddedManager().syncMessages(); + } else { + Long[] ids = new Long[placementIds.size()]; + for (int i = 0; i < placementIds.size(); i++) { + ids[i] = (long) placementIds.getInt(i); + } + IterableApi.getInstance().getEmbeddedManager().syncMessages(ids); + } } public void startEmbeddedSession() { diff --git a/android/src/newarch/java/com/RNIterableAPIModule.java b/android/src/newarch/java/com/RNIterableAPIModule.java index 6e851fb54..ab07951ce 100644 --- a/android/src/newarch/java/com/RNIterableAPIModule.java +++ b/android/src/newarch/java/com/RNIterableAPIModule.java @@ -240,8 +240,8 @@ public void pauseAuthRetries(boolean pauseRetry) { } @Override - public void syncEmbeddedMessages() { - moduleImpl.syncEmbeddedMessages(); + public void syncEmbeddedMessages(@Nullable ReadableArray placementIds) { + moduleImpl.syncEmbeddedMessages(placementIds); } @Override diff --git a/android/src/oldarch/java/com/RNIterableAPIModule.java b/android/src/oldarch/java/com/RNIterableAPIModule.java index 9142967d5..4ee68aeb2 100644 --- a/android/src/oldarch/java/com/RNIterableAPIModule.java +++ b/android/src/oldarch/java/com/RNIterableAPIModule.java @@ -244,8 +244,8 @@ public void pauseAuthRetries(boolean pauseRetry) { } @ReactMethod - public void syncEmbeddedMessages() { - moduleImpl.syncEmbeddedMessages(); + public void syncEmbeddedMessages(@Nullable ReadableArray placementIds) { + moduleImpl.syncEmbeddedMessages(placementIds); } @ReactMethod diff --git a/ios/RNIterableAPI/RNIterableAPI.mm b/ios/RNIterableAPI/RNIterableAPI.mm index 825091661..ae7e47ba3 100644 --- a/ios/RNIterableAPI/RNIterableAPI.mm +++ b/ios/RNIterableAPI/RNIterableAPI.mm @@ -304,8 +304,8 @@ - (void)endEmbeddedSession { [_swiftAPI endEmbeddedSession]; } -- (void)syncEmbeddedMessages { - [_swiftAPI syncEmbeddedMessages]; +- (void)syncEmbeddedMessages:(NSArray *_Nullable)placementIds { + [_swiftAPI syncEmbeddedMessages:placementIds]; } - (void)getEmbeddedMessages:(NSArray *_Nullable)placementIds @@ -597,8 +597,8 @@ - (void)wakeApp { [_swiftAPI endEmbeddedSession]; } -RCT_EXPORT_METHOD(syncEmbeddedMessages) { - [_swiftAPI syncEmbeddedMessages]; +RCT_EXPORT_METHOD(syncEmbeddedMessages : (NSArray *_Nullable)placementIds) { + [_swiftAPI syncEmbeddedMessages:placementIds]; } RCT_EXPORT_METHOD(getEmbeddedMessages : (NSArray *_Nullable)placementIds resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) { diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index 1b78958e6..a72c7b894 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -535,10 +535,15 @@ import React EmbeddedSessionManager.shared.endSession() } - @objc(syncEmbeddedMessages) - public func syncEmbeddedMessages() { + @objc(syncEmbeddedMessages:) + public func syncEmbeddedMessages(placementIds: [NSNumber]?) { ITBInfo() - IterableAPI.embeddedManager.syncMessages { } + if let placementIds = placementIds, !placementIds.isEmpty { + let ids = placementIds.map { $0.intValue } + IterableAPI.embeddedManager.syncMessages(placementIds: ids) { } + } else { + IterableAPI.embeddedManager.syncMessages { } + } } @objc(getEmbeddedMessages:resolver:rejecter:) diff --git a/src/api/NativeRNIterableAPI.ts b/src/api/NativeRNIterableAPI.ts index d3ab874b0..8ef525f2a 100644 --- a/src/api/NativeRNIterableAPI.ts +++ b/src/api/NativeRNIterableAPI.ts @@ -184,7 +184,7 @@ export interface Spec extends TurboModule { pauseAuthRetries(pauseRetry: boolean): void; // Embedded Messaging - syncEmbeddedMessages(): void; + syncEmbeddedMessages(placementIds: Array | null): void; startEmbeddedSession(): void; endEmbeddedSession(): void; startEmbeddedImpression(messageId: string, placementId: number): void; diff --git a/src/core/classes/IterableApi.test.ts b/src/core/classes/IterableApi.test.ts index 77b1b68b5..64b7deeaa 100644 --- a/src/core/classes/IterableApi.test.ts +++ b/src/core/classes/IterableApi.test.ts @@ -1299,13 +1299,34 @@ describe('IterableApi', () => { }); describe('syncEmbeddedMessages', () => { - it('should call RNIterableAPI.syncEmbeddedMessages', () => { + it('should call RNIterableAPI.syncEmbeddedMessages with null when omitted', () => { // GIVEN no parameters // WHEN syncEmbeddedMessages is called IterableApi.syncEmbeddedMessages(); - // THEN RNIterableAPI.syncEmbeddedMessages is called - expect(MockRNIterableAPI.syncEmbeddedMessages).toBeCalled(); + // THEN RNIterableAPI.syncEmbeddedMessages is called with null (sync all) + expect(MockRNIterableAPI.syncEmbeddedMessages).toBeCalledWith(null); + }); + + it('should call RNIterableAPI.syncEmbeddedMessages with placement IDs', () => { + // GIVEN placement IDs + const placementIds = [1, 2]; + + // WHEN syncEmbeddedMessages is called + IterableApi.syncEmbeddedMessages(placementIds); + + // THEN RNIterableAPI.syncEmbeddedMessages is called with placement IDs + expect(MockRNIterableAPI.syncEmbeddedMessages).toBeCalledWith( + placementIds + ); + }); + + it('should call RNIterableAPI.syncEmbeddedMessages with empty array for sync all', () => { + // WHEN syncEmbeddedMessages is called with an empty array + IterableApi.syncEmbeddedMessages([]); + + // THEN the empty array is forwarded (native syncs all placements) + expect(MockRNIterableAPI.syncEmbeddedMessages).toBeCalledWith([]); }); }); diff --git a/src/core/classes/IterableApi.ts b/src/core/classes/IterableApi.ts index 9e01b6793..c0793b7d7 100644 --- a/src/core/classes/IterableApi.ts +++ b/src/core/classes/IterableApi.ts @@ -551,9 +551,11 @@ export class IterableApi { /** * Syncs embedded local cache with the server. */ - static syncEmbeddedMessages() { - IterableLogger.log('syncEmbeddedMessages'); - return RNIterableAPI.syncEmbeddedMessages(); + static syncEmbeddedMessages(placementIds?: number[] | null) { + IterableLogger.log('syncEmbeddedMessages: ', placementIds); + return RNIterableAPI.syncEmbeddedMessages( + placementIds === undefined ? null : placementIds + ); } /** diff --git a/src/embedded/classes/IterableEmbeddedManager.test.ts b/src/embedded/classes/IterableEmbeddedManager.test.ts index c460572f0..4da487a4a 100644 --- a/src/embedded/classes/IterableEmbeddedManager.test.ts +++ b/src/embedded/classes/IterableEmbeddedManager.test.ts @@ -97,16 +97,31 @@ describe('IterableEmbeddedManager', () => { }); describe('syncMessages', () => { - it('should call IterableApi.syncEmbeddedMessages', async () => { + it('should call IterableApi.syncEmbeddedMessages with null when omitted', async () => { // WHEN syncMessages is called const result = await embeddedManager.syncMessages(); - // THEN IterableApi.syncEmbeddedMessages is called + // THEN IterableApi.syncEmbeddedMessages is called with null expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledWith(null); // AND the result is returned expect(result).toBeUndefined(); }); + + it('should call IterableApi.syncEmbeddedMessages with placement IDs', () => { + // GIVEN placement IDs + const placementIds = [1, 2]; + + // WHEN syncMessages is called + embeddedManager.syncMessages(placementIds); + + // THEN IterableApi.syncEmbeddedMessages is called with placement IDs + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledWith( + placementIds + ); + }); }); describe('getMessages', () => { diff --git a/src/embedded/classes/IterableEmbeddedManager.ts b/src/embedded/classes/IterableEmbeddedManager.ts index 8d53af49c..1359f46b3 100644 --- a/src/embedded/classes/IterableEmbeddedManager.ts +++ b/src/embedded/classes/IterableEmbeddedManager.ts @@ -71,13 +71,17 @@ export class IterableEmbeddedManager { * * However, do not poll for new embedded messages at a regular interval. * + * @param placementIds - Optional placement IDs to sync. When omitted, null, or + * empty, syncs all placements. + * * @example * ```typescript * IterableEmbeddedManager.syncMessages(); + * IterableEmbeddedManager.syncMessages([1, 2, 3]); * ``` */ - syncMessages() { - return IterableApi.syncEmbeddedMessages(); + syncMessages(placementIds?: number[] | null) { + return IterableApi.syncEmbeddedMessages(placementIds); } /** From f840cb0cab8f2d72f3b9c7ff6ebfb758fc25eff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 1 Oct 2026 23:10:17 +0100 Subject: [PATCH 22/25] test(embedded): cover null and empty syncMessages paths (SDK-751) Lock AC2 sync-all inputs at IterableApi and IterableEmbeddedManager. --- src/core/classes/IterableApi.test.ts | 8 ++++++++ .../classes/IterableEmbeddedManager.test.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/core/classes/IterableApi.test.ts b/src/core/classes/IterableApi.test.ts index 64b7deeaa..d77083712 100644 --- a/src/core/classes/IterableApi.test.ts +++ b/src/core/classes/IterableApi.test.ts @@ -1308,6 +1308,14 @@ describe('IterableApi', () => { expect(MockRNIterableAPI.syncEmbeddedMessages).toBeCalledWith(null); }); + it('should call RNIterableAPI.syncEmbeddedMessages with null when passed null', () => { + // WHEN syncEmbeddedMessages is called with null + IterableApi.syncEmbeddedMessages(null); + + // THEN RNIterableAPI.syncEmbeddedMessages is called with null (sync all) + expect(MockRNIterableAPI.syncEmbeddedMessages).toBeCalledWith(null); + }); + it('should call RNIterableAPI.syncEmbeddedMessages with placement IDs', () => { // GIVEN placement IDs const placementIds = [1, 2]; diff --git a/src/embedded/classes/IterableEmbeddedManager.test.ts b/src/embedded/classes/IterableEmbeddedManager.test.ts index 4da487a4a..80a910237 100644 --- a/src/embedded/classes/IterableEmbeddedManager.test.ts +++ b/src/embedded/classes/IterableEmbeddedManager.test.ts @@ -122,6 +122,24 @@ describe('IterableEmbeddedManager', () => { placementIds ); }); + + it('should call IterableApi.syncEmbeddedMessages with null placement IDs', () => { + // WHEN syncMessages is called with null + embeddedManager.syncMessages(null); + + // THEN IterableApi.syncEmbeddedMessages is called with null + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledWith(null); + }); + + it('should call IterableApi.syncEmbeddedMessages with empty placement IDs', () => { + // WHEN syncMessages is called with an empty array + embeddedManager.syncMessages([]); + + // THEN the empty array is forwarded (native syncs all placements) + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledWith([]); + }); }); describe('getMessages', () => { From 87f9d09a4b5dc3c79588737c897049b6df67e798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Tue, 6 Oct 2026 20:16:09 +0100 Subject: [PATCH 23/25] docs(SDK-748): tighten changelog; reuse test:android:unit in CI Scope push-open fix to trackPushOpenWithCampaignId and call the package script from the Android CI job. --- .github/workflows/ci.yml | 3 +-- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53378c24a..edfcc8bb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,8 +111,7 @@ jobs: - name: Run Android library unit tests if: env.turbo_cache_hit != 1 - run: | - cd example/android && ./gradlew :iterable_react-native-sdk:testDebugUnitTest --no-daemon + run: yarn test:android:unit build-ios: runs-on: macos-14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 332626f90..ecd0fec8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Fixes -- Fixed Android push-open tracking so `appAlreadyRunning` is forwarded to the native SDK (SDK-748). Previously every Android push open was reported as if the app was not already running. +- Fixed `Iterable.trackPushOpenWithCampaignId` on Android so the `appAlreadyRunning` argument is forwarded to the native SDK instead of always being sent as `false`. ### Updates From 0530d83bed74bfec6c3b9ea803f5cdaca914c293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Mon, 12 Oct 2026 17:19:43 +0100 Subject: [PATCH 24/25] test(SDK-750): cover jsonOnly native bridge serialization Add Robolectric tests for Android getInAppMessageJson boolean overwrite and Jest coverage for inAppHandler and getMessages jsonOnly payloads. --- .../iterableapi/RNIterableInternalTest.java | 38 +++++++ src/__tests__/IterableInApp.test.ts | 100 ++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/android/src/test/java/com/iterable/iterableapi/RNIterableInternalTest.java b/android/src/test/java/com/iterable/iterableapi/RNIterableInternalTest.java index f2c2320b0..b0db1d823 100644 --- a/android/src/test/java/com/iterable/iterableapi/RNIterableInternalTest.java +++ b/android/src/test/java/com/iterable/iterableapi/RNIterableInternalTest.java @@ -1,14 +1,24 @@ package com.iterable.iterableapi; +import org.json.JSONException; +import org.json.JSONObject; import org.junit.Test; +import org.junit.runner.RunWith; import org.mockito.MockedStatic; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) public class RNIterableInternalTest { @Test @@ -38,4 +48,32 @@ public void trackPushOpenWithCampaignId_forwardsAppAlreadyRunningFalse() { eq(10), eq(20), eq("message-id"), eq(false), isNull()); } } + + @Test + public void getInAppMessageJson_overwritesNativeIntegerJsonOnlyWithBooleanTrue() + throws JSONException { + IterableInAppMessage message = mock(IterableInAppMessage.class); + JSONObject nativeJson = new JSONObject(); + nativeJson.put("jsonOnly", 1); + when(message.toJSONObject()).thenReturn(nativeJson); + when(message.isJsonOnly()).thenReturn(true); + + JSONObject bridgeJson = RNIterableInternal.getInAppMessageJson(message); + + assertTrue(bridgeJson.getBoolean("jsonOnly")); + } + + @Test + public void getInAppMessageJson_writesBooleanFalseForHtmlMessages() + throws JSONException { + IterableInAppMessage message = mock(IterableInAppMessage.class); + JSONObject nativeJson = new JSONObject(); + nativeJson.put("messageId", "msg-1"); + when(message.toJSONObject()).thenReturn(nativeJson); + when(message.isJsonOnly()).thenReturn(false); + + JSONObject bridgeJson = RNIterableInternal.getInAppMessageJson(message); + + assertFalse(bridgeJson.getBoolean("jsonOnly")); + } } diff --git a/src/__tests__/IterableInApp.test.ts b/src/__tests__/IterableInApp.test.ts index bddb3f3f9..ca48934da 100644 --- a/src/__tests__/IterableInApp.test.ts +++ b/src/__tests__/IterableInApp.test.ts @@ -178,6 +178,106 @@ describe('Iterable In App', () => { ); }); + test('inAppHandler_jsonOnlyTrue_flagSetOnMessage', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleInAppCalled); + + const config = new IterableConfig(); + config.inAppHandler = jest.fn((_message: IterableInAppMessage) => { + return IterableInAppShowResponse.show; + }); + + Iterable.initialize('apiKey', config); + + const messageDict = { + messageId: 'json-only-msg', + campaignId: 1234, + trigger: { type: IterableInAppTriggerType.immediate }, + priorityLevel: 300.5, + jsonOnly: true, + }; + const expectedMessage = new IterableInAppMessage( + 'json-only-msg', + 1234, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + undefined, + undefined, + false, + undefined, + undefined, + false, + 300.5, + true + ); + + nativeEmitter.emit(IterableEventName.handleInAppCalled, messageDict); + + expect(config.inAppHandler).toBeCalledWith(expectedMessage); + }); + + test('inAppHandler_jsonOnlyFalse_flagSetOnHtmlMessage', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleInAppCalled); + + const config = new IterableConfig(); + config.inAppHandler = jest.fn((_message: IterableInAppMessage) => { + return IterableInAppShowResponse.show; + }); + + Iterable.initialize('apiKey', config); + + const messageDict = { + messageId: 'html-msg', + campaignId: 1234, + trigger: { type: IterableInAppTriggerType.immediate }, + priorityLevel: 300.5, + jsonOnly: false, + }; + const expectedMessage = new IterableInAppMessage( + 'html-msg', + 1234, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + undefined, + undefined, + false, + undefined, + undefined, + false, + 300.5, + false + ); + + nativeEmitter.emit(IterableEventName.handleInAppCalled, messageDict); + + expect(config.inAppHandler).toBeCalledWith(expectedMessage); + }); + + test('getMessages_jsonOnlyFlag_roundTripsFromBridgePayload', async () => { + const messageDicts = [ + { + messageId: 'json-only', + campaignId: 1, + trigger: { type: IterableInAppTriggerType.immediate }, + jsonOnly: true, + }, + { + messageId: 'html', + campaignId: 2, + trigger: { type: IterableInAppTriggerType.immediate }, + jsonOnly: false, + }, + ]; + const messages = messageDicts.map((message) => + IterableInAppMessage.fromDict(message) + ); + + MockRNIterableAPI.setMessages(messages); + + const messagesObtained = await Iterable.inAppManager?.getMessages(); + expect(messagesObtained?.[0]?.jsonOnly).toBe(true); + expect(messagesObtained?.[1]?.jsonOnly).toBe(false); + }); + test('getMessages_noParams_returnsMessages', async () => { // GIVEN a list of in-app messages representing the local queue const messageDicts = [ From 86228fcd0dc1e7a9201b132aabd0c14bd3f6e31c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Mon, 12 Oct 2026 17:24:12 +0100 Subject: [PATCH 25/25] docs(embedded): document Android scoped sync caveat (SDK-798) Warn when scoped sync is used on Android and document iOS/Android cache behavior in CHANGELOG and public TSDoc for SDK-751. --- CHANGELOG.md | 1 + .../com/iterable/reactnative/RNIterableAPIModuleImpl.java | 1 + src/core/classes/IterableApi.ts | 4 ++++ src/embedded/classes/IterableEmbeddedManager.ts | 6 ++++++ 4 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f85ec6b01..9cffe47ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Updates - Added optional placement IDs to `Iterable.embeddedManager.syncMessages()` so apps can sync embedded messages for specific placements only (SDK-751). When omitted, null, or empty, behavior matches the previous sync-all call. + - **Android:** Scoped sync (`syncMessages([id, …])`) matches the pinned iterableapi behavior and may remove cached messages for placements not included in the sync response (SDK-798). iOS preserves unrequested placements. Prefer `syncMessages()` without placement IDs on Android until the native fix ships and the Android SDK pin is bumped. - Added `IterableConfig.onEmbeddedMessagingSyncSucceeded` and `IterableConfig.onEmbeddedMessagingSyncFailed` so apps can observe embedded messaging sync outcomes (SDK-749). Failure callbacks receive a nullable reason diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index b319109ac..c6c1297c8 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -803,6 +803,7 @@ public void syncEmbeddedMessages(@Nullable ReadableArray placementIds) { if (placementIds == null || placementIds.size() == 0) { IterableApi.getInstance().getEmbeddedManager().syncMessages(); } else { + IterableLogger.w(TAG, "Scoped syncEmbeddedMessages may remove cached messages for placements not in the sync response (SDK-798). Prefer syncMessages() without placement IDs on Android until iterableapi is fixed."); Long[] ids = new Long[placementIds.size()]; for (int i = 0; i < placementIds.size(); i++) { ids[i] = (long) placementIds.getInt(i); diff --git a/src/core/classes/IterableApi.ts b/src/core/classes/IterableApi.ts index c0793b7d7..58066eb5d 100644 --- a/src/core/classes/IterableApi.ts +++ b/src/core/classes/IterableApi.ts @@ -550,6 +550,10 @@ export class IterableApi { /** * Syncs embedded local cache with the server. + * + * @param placementIds - When provided, limits the sync to those placements. + * See {@link IterableEmbeddedManager.syncMessages} for Android scoped-sync + * caveats (SDK-798). */ static syncEmbeddedMessages(placementIds?: number[] | null) { IterableLogger.log('syncEmbeddedMessages: ', placementIds); diff --git a/src/embedded/classes/IterableEmbeddedManager.ts b/src/embedded/classes/IterableEmbeddedManager.ts index 1359f46b3..0575c6311 100644 --- a/src/embedded/classes/IterableEmbeddedManager.ts +++ b/src/embedded/classes/IterableEmbeddedManager.ts @@ -74,6 +74,12 @@ export class IterableEmbeddedManager { * @param placementIds - Optional placement IDs to sync. When omitted, null, or * empty, syncs all placements. * + * @remarks On Android, scoped sync may clear cached messages for placements + * that are not in the sync response (iterableapi SDK-798). iOS merges + * unrequested placements from the existing cache. Until Android iterableapi + * is fixed and this SDK bumps its Android pin, prefer calling `syncMessages()` + * without placement IDs on Android when multiple placements are in use. + * * @example * ```typescript * IterableEmbeddedManager.syncMessages();