From b2de2cc79079820d583ecbe37adcd926ab1c93e0 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Thu, 8 Oct 2026 18:29:30 +0100 Subject: [PATCH 1/3] [SDK-791] Display in-app messages on the main thread when showMessage is called off it Since 3.9.0 the Dialog renderer registers a LifecycleRegistry observer, which throws off the main thread. Post the display to the main looper, matching the iOS SDK. Main-thread callers are unchanged. --- CHANGELOG.md | 1 + .../iterableapi/IterableInAppManager.java | 16 +++- .../IterableInAppManagerThreadingTest.java | 87 +++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerThreadingTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c799eb848..005bf83e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Fixed +- `IterableInAppManager.showMessage` is now safe to call from any thread. Called off the main thread, it posts the display to the main thread instead of running it on the caller thread. Since 3.9.0, a host activity that is not a `FragmentActivity` (for example a Jetpack Compose `ComponentActivity`) uses the Dialog renderer, which crashed with `IllegalStateException: Method addObserver must be called on the main thread` when `showMessage` was called from a background thread, including the React Native bridge thread. Calls from the main thread still display synchronously. This matches the iOS SDK, which always displays in-app messages on the main thread. - Push token registration, disable operations, Iterable deep-link redirects, and API requests now use dedicated SDK-owned executors instead of Android's process-wide `AsyncTask` queues. Push and deep-link work retain isolated serial lanes. Ordinary online API requests remain concurrent, while their automatic retries and offline immediate requests retain serial ordering; stored offline requests retain their own FIFO flow. Slow work in one flow no longer delays unrelated SDK operations, and client callbacks and attribution updates continue on the main thread. ## [3.11.0] diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java index 496d892f3..335ce4ae5 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java @@ -241,7 +241,8 @@ void reset() { } /** - * Display the in-app message on the screen + * Display the in-app message on the screen. Safe to call from any thread; the message is always + * displayed on the main thread. * @param message In-App message object retrieved from {@link IterableInAppManager#getMessages()} */ public void showMessage(@NonNull IterableInAppMessage message) { @@ -255,6 +256,7 @@ public void showMessage(@NonNull IterableInAppMessage message, @NonNull Iterable /** * Display the in-app message on the screen. This method, by default, assumes the current location of activity as InApp. To pass * different inAppLocation as paramter, use showMessage method which takes in IterableAppLocation as a parameter. + * Safe to call from any thread; the message is always displayed on the main thread. * @param message In-App message object retrieved from {@link IterableInAppManager#getMessages()} * @param consume A boolean indicating whether to remove the message from the list after showing * @param clickCallback A callback that is called when the user clicks on a link in the in-app message @@ -263,7 +265,17 @@ public void showMessage(final @NonNull IterableInAppMessage message, boolean con showMessage(message, consume, clickCallback, IterableInAppLocation.IN_APP); } - public void showMessage(final @NonNull IterableInAppMessage message, boolean consume, final @Nullable IterableHelper.IterableUrlCallback clickCallback, @NonNull IterableInAppLocation inAppLocation) { + public void showMessage(final @NonNull IterableInAppMessage message, final boolean consume, final @Nullable IterableHelper.IterableUrlCallback clickCallback, final @NonNull IterableInAppLocation inAppLocation) { + // Displaying creates dialogs and registers lifecycle observers, which must happen on the main thread. + if (Looper.myLooper() != Looper.getMainLooper()) { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + showMessage(message, consume, clickCallback, inAppLocation); + } + }); + return; + } if (displayer.showMessage(message, inAppLocation, new IterableHelper.IterableUrlCallback() { @Override public void execute(Uri url) { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerThreadingTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerThreadingTest.java new file mode 100644 index 000000000..876149826 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerThreadingTest.java @@ -0,0 +1,87 @@ +package com.iterable.iterableapi; + +import static android.os.Looper.getMainLooper; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertTrue; +import static org.robolectric.Shadows.shadowOf; + +import android.os.Handler; +import android.os.HandlerThread; + +import androidx.activity.ComponentActivity; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.robolectric.Robolectric; +import org.robolectric.android.controller.ActivityController; +import org.robolectric.shadows.ShadowDialog; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +public class IterableInAppManagerThreadingTest extends BaseTest { + + private ActivityController controller; + private IterableInAppManager inAppManager; + private IterableInAppMessage message; + + @Before + public void setUp() throws Exception { + IterableTestUtils.createIterableApiNew(); + controller = Robolectric.buildActivity(ComponentActivity.class).create().start().resume(); + shadowOf(getMainLooper()).idle(); + inAppManager = IterableApi.getInstance().getInAppManager(); + message = InAppTestUtils.getTestInAppMessage(); + } + + @After + public void tearDown() { + if (ShadowDialog.getLatestDialog() != null) { + ShadowDialog.getLatestDialog().dismiss(); + } + if (controller != null) { + controller.pause().stop().destroy(); + } + IterableTestUtils.resetIterableApi(); + } + + @Test + public void showMessage_fromBackgroundLooperThread_displaysOnMainThreadWithoutCrashing() throws InterruptedException { + // A looper thread, like the React Native native-modules thread that crashed in SDK-789. + HandlerThread background = new HandlerThread("background"); + background.start(); + final AtomicReference thrown = new AtomicReference<>(); + final CountDownLatch done = new CountDownLatch(1); + new Handler(background.getLooper()).post(new Runnable() { + @Override + public void run() { + try { + inAppManager.showMessage(message, false, null); + } catch (Throwable t) { + thrown.set(t); + } finally { + done.countDown(); + } + } + }); + assertTrue(done.await(5, TimeUnit.SECONDS)); + background.quitSafely(); + + assertNull(thrown.get()); + assertNull(IterableInAppDialogNotification.getInstance()); + + shadowOf(getMainLooper()).idle(); + + assertNotNull(IterableInAppDialogNotification.getInstance()); + } + + @Test + public void showMessage_fromMainThread_displaysSynchronously() { + inAppManager.showMessage(message, false, null); + + assertNotNull(IterableInAppDialogNotification.getInstance()); + } +} From cc3f8277ca42349909f34faf1acbab60f94e4633 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 9 Oct 2026 10:28:32 +0100 Subject: [PATCH 2/3] [SDK-791] Document deferred read/consume state and assert it in tests --- CHANGELOG.md | 2 +- .../iterable/iterableapi/IterableInAppManager.java | 10 ++++++++++ .../IterableInAppManagerThreadingTest.java | 13 ++++++++++--- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 005bf83e6..62b95d96a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Fixed -- `IterableInAppManager.showMessage` is now safe to call from any thread. Called off the main thread, it posts the display to the main thread instead of running it on the caller thread. Since 3.9.0, a host activity that is not a `FragmentActivity` (for example a Jetpack Compose `ComponentActivity`) uses the Dialog renderer, which crashed with `IllegalStateException: Method addObserver must be called on the main thread` when `showMessage` was called from a background thread, including the React Native bridge thread. Calls from the main thread still display synchronously. This matches the iOS SDK, which always displays in-app messages on the main thread. +- `IterableInAppManager.showMessage` is now safe to call from any thread. Called off the main thread, it posts the display to the main thread instead of running it on the caller thread. Since 3.9.0, a host activity that is not a `FragmentActivity` (for example a Jetpack Compose `ComponentActivity`) uses the Dialog renderer, which crashed with `IllegalStateException: Method addObserver must be called on the main thread` when `showMessage` was called from a background thread, including the React Native bridge thread. Calls from the main thread still display synchronously. Called from another thread, the message's read and consumed state are updated after `showMessage` returns. Like iOS, display always happens on the main thread. - Push token registration, disable operations, Iterable deep-link redirects, and API requests now use dedicated SDK-owned executors instead of Android's process-wide `AsyncTask` queues. Push and deep-link work retain isolated serial lanes. Ordinary online API requests remain concurrent, while their automatic retries and offline immediate requests retain serial ordering; stored offline requests retain their own FIFO flow. Slow work in one flow no longer delays unrelated SDK operations, and client callbacks and attribution updates continue on the main thread. ## [3.11.0] diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java index 335ce4ae5..82c7fc972 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java @@ -265,6 +265,16 @@ public void showMessage(final @NonNull IterableInAppMessage message, boolean con showMessage(message, consume, clickCallback, IterableInAppLocation.IN_APP); } + /** + * Display the in-app message on the screen. Safe to call from any thread. Called from the main + * thread, the message is displayed before this method returns. Called from any other thread, the + * display is posted to the main thread, so the message's read and consumed state are updated after + * this method returns. {@code clickCallback} is always invoked on the main thread. + * @param message In-App message object retrieved from {@link IterableInAppManager#getMessages()} + * @param consume A boolean indicating whether to remove the message from the list after showing + * @param clickCallback A callback that is called when the user clicks on a link in the in-app message + * @param inAppLocation Where the message is being displayed from + */ public void showMessage(final @NonNull IterableInAppMessage message, final boolean consume, final @Nullable IterableHelper.IterableUrlCallback clickCallback, final @NonNull IterableInAppLocation inAppLocation) { // Displaying creates dialogs and registers lifecycle observers, which must happen on the main thread. if (Looper.myLooper() != Looper.getMainLooper()) { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerThreadingTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerThreadingTest.java index 876149826..753318ace 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerThreadingTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerThreadingTest.java @@ -1,6 +1,7 @@ package com.iterable.iterableapi; import static android.os.Looper.getMainLooper; +import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; @@ -50,7 +51,7 @@ public void tearDown() { @Test public void showMessage_fromBackgroundLooperThread_displaysOnMainThreadWithoutCrashing() throws InterruptedException { - // A looper thread, like the React Native native-modules thread that crashed in SDK-789. + // A looper thread, like the React Native native-modules thread. HandlerThread background = new HandlerThread("background"); background.start(); final AtomicReference thrown = new AtomicReference<>(); @@ -59,7 +60,7 @@ public void showMessage_fromBackgroundLooperThread_displaysOnMainThreadWithoutCr @Override public void run() { try { - inAppManager.showMessage(message, false, null); + inAppManager.showMessage(message, true, null); } catch (Throwable t) { thrown.set(t); } finally { @@ -72,16 +73,22 @@ public void run() { assertNull(thrown.get()); assertNull(IterableInAppDialogNotification.getInstance()); + assertFalse(message.isRead()); + assertFalse(message.isMarkedForDeletion()); shadowOf(getMainLooper()).idle(); assertNotNull(IterableInAppDialogNotification.getInstance()); + assertTrue(message.isRead()); + assertTrue(message.isMarkedForDeletion()); } @Test public void showMessage_fromMainThread_displaysSynchronously() { - inAppManager.showMessage(message, false, null); + inAppManager.showMessage(message, true, null); assertNotNull(IterableInAppDialogNotification.getInstance()); + assertTrue(message.isRead()); + assertTrue(message.isMarkedForDeletion()); } } From 6eedcb6eba52f940e7f6edada3290c5f79c275eb Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 9 Oct 2026 17:49:36 +0100 Subject: [PATCH 3/3] Update iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java Co-authored-by: Franco Zalamena --- .../iterableapi/IterableInAppManager.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java index 82c7fc972..cd1e47a6b 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java @@ -266,14 +266,15 @@ public void showMessage(final @NonNull IterableInAppMessage message, boolean con } /** - * Display the in-app message on the screen. Safe to call from any thread. Called from the main - * thread, the message is displayed before this method returns. Called from any other thread, the - * display is posted to the main thread, so the message's read and consumed state are updated after - * this method returns. {@code clickCallback} is always invoked on the main thread. - * @param message In-App message object retrieved from {@link IterableInAppManager#getMessages()} - * @param consume A boolean indicating whether to remove the message from the list after showing - * @param clickCallback A callback that is called when the user clicks on a link in the in-app message - * @param inAppLocation Where the message is being displayed from + * Displays an in-app message on the main thread. + * + *

Threading: Main-thread calls are synchronous. Other calls are queued, so the + * message's read/consumed state changes after this method returns. The callback runs on main. + * + * @param message message to display + * @param consume whether to consume the message after display + * @param clickCallback callback for clicked links, or {@code null} + * @param inAppLocation where the message is displayed from */ public void showMessage(final @NonNull IterableInAppMessage message, final boolean consume, final @Nullable IterableHelper.IterableUrlCallback clickCallback, final @NonNull IterableInAppLocation inAppLocation) { // Displaying creates dialogs and registers lifecycle observers, which must happen on the main thread.