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