diff --git a/CHANGELOG.md b/CHANGELOG.md index c799eb848..62b95d96a 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. 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 496d892f3..cd1e47a6b 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,28 @@ 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) { + /** + * 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. + 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..753318ace --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerThreadingTest.java @@ -0,0 +1,94 @@ +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; +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. + 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, true, null); + } catch (Throwable t) { + thrown.set(t); + } finally { + done.countDown(); + } + } + }); + assertTrue(done.await(5, TimeUnit.SECONDS)); + background.quitSafely(); + + 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, true, null); + + assertNotNull(IterableInAppDialogNotification.getInstance()); + assertTrue(message.isRead()); + assertTrue(message.isMarkedForDeletion()); + } +}