From 241dbfa8dedddf44e869aeb86ea53b2af39c3a9d Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Thu, 8 Oct 2026 17:59:06 +0100 Subject: [PATCH 1/4] fix(SDK-789): implement embedded sync callbacks so the Android bridge compiles against native 3.10.1 The published iterableapi 3.10.1 AAR is compiled with Kotlin 1.9 without jvm-default, so onEmbeddedMessagingSyncSucceeded and onEmbeddedMessagingSyncFailed are abstract to Java implementers. RNIterableAPIModuleImpl did not override them, so the module failed javac. Add no-op overrides that match the native defaults. --- .../iterable/reactnative/RNIterableAPIModuleImpl.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index 3e266b224..c35c0dc56 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -729,6 +729,16 @@ public void onEmbeddedMessagingDisabled() { IterableLogger.d(TAG, "onEmbeddedMessagingDisabled"); sendEvent(EventName.handleEmbeddedMessagingDisabledCalled.name(), null); } + + @Override + public void onEmbeddedMessagingSyncSucceeded() { + IterableLogger.d(TAG, "onEmbeddedMessagingSyncSucceeded"); + } + + @Override + public void onEmbeddedMessagingSyncFailed(@Nullable String reason) { + IterableLogger.d(TAG, "onEmbeddedMessagingSyncFailed: " + reason); + } // --------------------------------------------------------------------------------------- // endregion From 3fc0fffc68f4fb666efe2a277baadff0960eb0fa Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Thu, 8 Oct 2026 17:59:08 +0100 Subject: [PATCH 2/4] fix(SDK-789): show in-app messages on the main thread on Android Iterable.inAppManager.showMessage called IterableInAppManager.showMessage on the React Native native-modules thread. Since native Android 3.9.0, a host activity that is not a FragmentActivity takes the Dialog path, whose LifecycleRegistry.addObserver call throws IllegalStateException off the main thread and kills the process. - Post the native call with UiThreadUtil.runOnUiThread, matching setAutoDisplayPaused and the native iOS SDK. - Reject when the message id is missing instead of passing null into native (NPE). - Resolve null when the message is dismissed without a URL instead of NPE on toString. - Fix the empty messageId check, which used reference equality. Adds the first JVM unit tests for the Android bridge (Robolectric + Mockito, versions mirrored from iterable-android-sdk) and excludes them from the npm package. --- CHANGELOG.md | 5 + android/build.gradle | 5 + .../reactnative/RNIterableAPIModuleImpl.java | 21 ++- ...NIterableAPIModuleImplShowMessageTest.java | 126 ++++++++++++++++++ package.json | 1 + 5 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 android/src/test/java/com/iterable/iterableapi/RNIterableAPIModuleImplShowMessageTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f244d4cc1..771a9fca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ - Fixed an Android crash when marking an in-app message as read after that message was no longer in the native queue. - `setReadForMessage` logs and returns when the message id is missing, matching the iOS bridge. The Android bridge was passing that missing message into `IterableInAppManager.setRead`, which threw a `NullPointerException` and killed the app process. +- Fixed the Android module failing to compile against its pinned native Android SDK 3.10.1 in 3.2.0. The bridge now implements `onEmbeddedMessagingSyncSucceeded` and `onEmbeddedMessagingSyncFailed` from `IterableEmbeddedUpdateHandler` as no-ops, matching the native defaults. +- Fixed an Android crash in `Iterable.inAppManager.showMessage` (`IllegalStateException: Method addObserver must be called on the main thread`), present since 3.2.0. + - The bridge now displays the message on the main thread, matching `setAutoDisplayPaused` and the iOS SDK. It was calling the native in-app displayer on the React Native native-modules thread, which crashes when the current activity is not a `FragmentActivity` (for example a Jetpack Compose `ComponentActivity`). + - The promise now rejects when the message id is no longer in the native queue instead of crashing with a `NullPointerException`. + - Dismissing a message by tapping outside it now resolves the promise with `null` instead of crashing on `url.toString()`. ## 3.2.0 diff --git a/android/build.gradle b/android/build.gradle index 623d9e5fc..663f80532 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -107,5 +107,10 @@ 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" + testImplementation "org.robolectric:robolectric:4.14.1" } diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index c35c0dc56..c3396295d 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -286,15 +286,26 @@ public void updateSubscriptions(@Nullable ReadableArray emailListIds, @Nullable ); } - public void showMessage(String messageId, boolean consume, final Promise promise) { - if (messageId == null || messageId == "") { + public void showMessage(String messageId, final boolean consume, final Promise promise) { + if (messageId == null || messageId.isEmpty()) { promise.reject("", "messageId is null or empty"); return; } - IterableApi.getInstance().getInAppManager().showMessage(RNIterableInternal.getMessageById(messageId), consume, new IterableHelper.IterableUrlCallback() { + final IterableInAppMessage message = RNIterableInternal.getMessageById(messageId); + if (message == null) { + promise.reject("", "Could not find message with id: " + messageId); + return; + } + // In-app display attaches lifecycle observers and fragments, which AndroidX requires on the main thread. + UiThreadUtil.runOnUiThread(new Runnable() { @Override - public void execute(@Nullable Uri url) { - promise.resolve(url.toString()); + public void run() { + IterableApi.getInstance().getInAppManager().showMessage(message, consume, new IterableHelper.IterableUrlCallback() { + @Override + public void execute(@Nullable Uri url) { + promise.resolve(url != null ? url.toString() : null); + } + }); } }); } diff --git a/android/src/test/java/com/iterable/iterableapi/RNIterableAPIModuleImplShowMessageTest.java b/android/src/test/java/com/iterable/iterableapi/RNIterableAPIModuleImplShowMessageTest.java new file mode 100644 index 000000000..5f0fd1708 --- /dev/null +++ b/android/src/test/java/com/iterable/iterableapi/RNIterableAPIModuleImplShowMessageTest.java @@ -0,0 +1,126 @@ +package com.iterable.iterableapi; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.net.Uri; +import android.os.Looper; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.iterable.reactnative.RNIterableAPIModuleImpl; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.concurrent.atomic.AtomicReference; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class RNIterableAPIModuleImplShowMessageTest { + + private static final String MESSAGE_ID = "message-id"; + + private IterableApi originalApi; + private IterableInAppManager inAppManager; + private IterableInAppMessage message; + private Promise promise; + private RNIterableAPIModuleImpl module; + + @Before + public void setUp() { + originalApi = IterableApi.sharedInstance; + IterableApi api = mock(IterableApi.class); + inAppManager = mock(IterableInAppManager.class); + message = mock(IterableInAppMessage.class); + promise = mock(Promise.class); + when(api.getInAppManager()).thenReturn(inAppManager); + when(inAppManager.getMessageById(MESSAGE_ID)).thenReturn(message); + IterableApi.sharedInstance = api; + module = new RNIterableAPIModuleImpl(mock(ReactApplicationContext.class)); + } + + @After + public void tearDown() { + IterableApi.sharedInstance = originalApi; + } + + @Test + public void showMessageFromBridgeThreadDisplaysOnMainThread() throws InterruptedException { + final AtomicReference displayThread = new AtomicReference<>(); + doAnswer(invocation -> { + displayThread.set(Thread.currentThread()); + return null; + }).when(inAppManager).showMessage(eq(message), eq(true), any(IterableHelper.IterableUrlCallback.class)); + + Thread bridgeThread = new Thread(() -> module.showMessage(MESSAGE_ID, true, promise)); + bridgeThread.start(); + bridgeThread.join(); + + assertNull(displayThread.get()); + + shadowOf(Looper.getMainLooper()).idle(); + + assertSame(Looper.getMainLooper().getThread(), displayThread.get()); + } + + @Test + public void showMessageRejectsWhenMessageIsMissing() { + module.showMessage("missing", true, promise); + shadowOf(Looper.getMainLooper()).idle(); + + verify(promise).reject("", "Could not find message with id: missing"); + verify(inAppManager, never()).showMessage(any(IterableInAppMessage.class), anyBoolean(), any(IterableHelper.IterableUrlCallback.class)); + } + + @Test + public void showMessageRejectsWhenMessageIdIsEmpty() { + module.showMessage(new String(""), true, promise); + shadowOf(Looper.getMainLooper()).idle(); + + verify(promise).reject("", "messageId is null or empty"); + verify(inAppManager, never()).showMessage(any(IterableInAppMessage.class), anyBoolean(), any(IterableHelper.IterableUrlCallback.class)); + } + + @Test + public void showMessageResolvesClickedUrl() { + stubClickWith(Uri.parse("https://iterable.com")); + + module.showMessage(MESSAGE_ID, false, promise); + shadowOf(Looper.getMainLooper()).idle(); + + verify(promise).resolve("https://iterable.com"); + } + + @Test + public void showMessageResolvesNullWhenClosedWithoutUrl() { + stubClickWith(null); + + module.showMessage(MESSAGE_ID, false, promise); + shadowOf(Looper.getMainLooper()).idle(); + + verify(promise).resolve(isNull()); + } + + private void stubClickWith(final Uri url) { + doAnswer(invocation -> { + IterableHelper.IterableUrlCallback callback = invocation.getArgument(2); + callback.execute(url); + return null; + }).when(inAppManager).showMessage(eq(message), eq(false), any(IterableHelper.IterableUrlCallback.class)); + } +} diff --git a/package.json b/package.json index 3ffca9aa5..a4067b684 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "!android/gradlew", "!android/gradlew.bat", "!android/local.properties", + "!android/src/test", "!**/*.test.{js,ts,tsx}", "!**/__tests__", "!**/__fixtures__", From 295bcc5956b322cec7423807dc40b1a8f4f74f07 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Thu, 8 Oct 2026 18:20:58 +0100 Subject: [PATCH 3/4] fix(SDK-792): reject showMessage on iOS for an empty or unknown message id The iOS bridge logged and returned without settling the promise. Reject with the same messages as the Android bridge. --- CHANGELOG.md | 1 + ios/RNIterableAPI/ReactIterableAPI.swift | 8 ++++++++ src/__tests__/IterableInApp.test.ts | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 771a9fca4..611b403ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - The bridge now displays the message on the main thread, matching `setAutoDisplayPaused` and the iOS SDK. It was calling the native in-app displayer on the React Native native-modules thread, which crashes when the current activity is not a `FragmentActivity` (for example a Jetpack Compose `ComponentActivity`). - The promise now rejects when the message id is no longer in the native queue instead of crashing with a `NullPointerException`. - Dismissing a message by tapping outside it now resolves the promise with `null` instead of crashing on `url.toString()`. +- `Iterable.inAppManager.showMessage` now rejects on iOS when the message id is empty or no longer in the native queue, matching Android. Previously the promise never settled. ## 3.2.0 diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index a216a0e69..639d09c73 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -384,8 +384,16 @@ import React rejecter: RCTPromiseRejectBlock ) { ITBInfo() + guard !messageId.isEmpty else { + ITBError("messageId is null or empty") + rejecter("", "messageId is null or empty", NSError(domain: "", code: 0, userInfo: nil)) + return + } guard let message = IterableAPI.inAppManager.getMessage(withId: messageId) else { ITBError("Could not find message with id: \(messageId)") + rejecter( + "", "Could not find message with id: \(messageId)", + NSError(domain: "", code: 0, userInfo: nil)) return } IterableAPI.inAppManager.show(message: message, consume: consume) { (url) in diff --git a/src/__tests__/IterableInApp.test.ts b/src/__tests__/IterableInApp.test.ts index bddb3f3f9..f0a964045 100644 --- a/src/__tests__/IterableInApp.test.ts +++ b/src/__tests__/IterableInApp.test.ts @@ -228,6 +228,24 @@ describe('Iterable In App', () => { }); }); + test('showMessage_messageNotInNativeQueue_rejects', async () => { + // GIVEN an in-app message that is no longer in the native queue + const message: IterableInAppMessage = IterableInAppMessage.fromDict({ + messageId: 'message1', + campaignId: 1234, + trigger: { type: IterableInAppTriggerType.immediate }, + }); + const error = new Error('Could not find message with id: message1'); + + // WHEN the native module rejects the call + MockRNIterableAPI.showMessage.mockRejectedValueOnce(error); + + // THEN Iterable.inAppManager.showMessage rejects with the native error + await expect( + Iterable.inAppManager?.showMessage(message, true) + ).rejects.toBe(error); + }); + test('removeMessage_params_methodCalledWithParams', () => { // GIVEN an in-app message const messageDict = { From 06a7ccc9e8d177076374412af32a8f2dafb0d0de Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 9 Oct 2026 10:30:38 +0100 Subject: [PATCH 4/4] test(SDK-792): name the showMessage rejection test as a JS contract test --- src/__tests__/IterableInApp.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/__tests__/IterableInApp.test.ts b/src/__tests__/IterableInApp.test.ts index f0a964045..a1e9472c3 100644 --- a/src/__tests__/IterableInApp.test.ts +++ b/src/__tests__/IterableInApp.test.ts @@ -228,7 +228,8 @@ describe('Iterable In App', () => { }); }); - test('showMessage_messageNotInNativeQueue_rejects', async () => { + test('showMessage_nativeRejects_propagatesRejectionToJs', async () => { + // JS contract only: the native rejection itself is implemented in the iOS and Android bridges // GIVEN an in-app message that is no longer in the native queue const message: IterableInAppMessage = IterableInAppMessage.fromDict({ messageId: 'message1',