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 1/4] 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 2/4] 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 3/4] 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 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 4/4] 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 = [