From 65b1272a3e5574b6d850d417722ffad3a249644a Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Mon, 22 Jun 2026 14:37:52 +0100 Subject: [PATCH 1/4] isOkToShow parity with iOS --- .../com/iterable/iterableapi/IterableApi.java | 1 + .../iterable/iterableapi/IterableConfig.java | 23 ++++ .../IterableInAppDisplayDelegate.java | 20 ++++ .../iterableapi/IterableInAppManager.java | 16 ++- .../IterableInAppManagerSyncTest.java | 2 +- .../iterableapi/IterableInAppManagerTest.java | 110 +++++++++++++++++- .../iterableapi/IterableInboxTest.java | 2 +- 7 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayDelegate.java diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index a49ab8286..c3a48de56 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -828,6 +828,7 @@ public static void initialize(@NonNull Context context, @NonNull String apiKey, sharedInstance.inAppManager = new IterableInAppManager( sharedInstance, sharedInstance.config.inAppHandler, + sharedInstance.config.inAppDisplayDelegate, sharedInstance.config.inAppDisplayInterval, sharedInstance.config.useInMemoryStorageForInApps); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index d9e6b2542..6a9515138 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -50,6 +50,13 @@ public class IterableConfig { */ final IterableInAppHandler inAppHandler; + /** + * Optional delegate that gives the app real-time, per-message control over whether an in-app + * message should be displayed automatically. Takes precedence over the global auto-display pause. + */ + @Nullable + final IterableInAppDisplayDelegate inAppDisplayDelegate; + /** * The number of seconds to wait before showing the next in-app message, if there are multiple * messages in the queue @@ -171,6 +178,7 @@ private IterableConfig(Builder builder) { checkForDeferredDeeplink = builder.checkForDeferredDeeplink; logLevel = builder.logLevel; inAppHandler = builder.inAppHandler; + inAppDisplayDelegate = builder.inAppDisplayDelegate; inAppDisplayInterval = builder.inAppDisplayInterval; authHandler = builder.authHandler; expiringAuthTokenRefreshPeriod = builder.expiringAuthTokenRefreshPeriod; @@ -200,6 +208,7 @@ public static class Builder { private boolean checkForDeferredDeeplink; private int logLevel = Log.ERROR; private IterableInAppHandler inAppHandler = new IterableDefaultInAppHandler(); + private IterableInAppDisplayDelegate inAppDisplayDelegate = null; private double inAppDisplayInterval = 30.0; private IterableAuthHandler authHandler; private long expiringAuthTokenRefreshPeriod = 60000L; @@ -309,6 +318,20 @@ public Builder setInAppHandler(@NonNull IterableInAppHandler inAppHandler) { return this; } + /** + * Set an in-app display delegate for real-time, per-message control over whether an in-app + * message should be displayed automatically. Returning {@code true} from + * {@link IterableInAppDisplayDelegate#isAutoDisplayPaused(IterableInAppMessage)} defers the + * message so it is reconsidered on a later display pass. Takes precedence over + * {@link IterableInAppManager#setAutoDisplayPaused(boolean)}. + * @param inAppDisplayDelegate In-app display delegate provided by the app + */ + @NonNull + public Builder setInAppDisplayDelegate(@NonNull IterableInAppDisplayDelegate inAppDisplayDelegate) { + this.inAppDisplayDelegate = inAppDisplayDelegate; + return this; + } + /** * Set the in-app message display interval: the number of seconds to wait before showing * the next in-app message, if there are multiple messages in the queue diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayDelegate.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayDelegate.java new file mode 100644 index 000000000..cdb298551 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayDelegate.java @@ -0,0 +1,20 @@ +package com.iterable.iterableapi; + +import androidx.annotation.NonNull; + +/** + * Delegate that gives the app real-time, per-message control over whether the SDK is allowed to + * automatically display a given in-app message at the moment it is about to be shown. + * + * When set via {@link IterableConfig.Builder#setInAppDisplayDelegate(IterableInAppDisplayDelegate)}, + * this takes precedence over the global {@link IterableInAppManager#setAutoDisplayPaused(boolean)} flag. + */ +public interface IterableInAppDisplayDelegate { + /** + * Called right before the SDK attempts to automatically display an in-app message. + * @param message The in-app message about to be displayed. + * @return {@code true} to pause/skip displaying this message for now (it will be reconsidered + * on a later display pass); {@code false} to allow it to be shown. + */ + boolean isAutoDisplayPaused(@NonNull IterableInAppMessage message); +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java index 288a66fc5..7ad52a3f2 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java @@ -43,6 +43,8 @@ public interface Listener { private final Context context; private final IterableInAppStorage storage; private final IterableInAppHandler handler; + @Nullable + private final IterableInAppDisplayDelegate displayDelegate; private final IterableInAppDisplayer displayer; private final IterableActivityMonitor activityMonitor; private final double inAppDisplayInterval; @@ -51,9 +53,10 @@ public interface Listener { private long lastInAppShown = 0; private boolean autoDisplayPaused = false; - IterableInAppManager(IterableApi iterableApi, IterableInAppHandler handler, double inAppDisplayInterval, boolean useInMemoryStorageForInApps) { + IterableInAppManager(IterableApi iterableApi, IterableInAppHandler handler, @Nullable IterableInAppDisplayDelegate displayDelegate, double inAppDisplayInterval, boolean useInMemoryStorageForInApps) { this(iterableApi, handler, + displayDelegate, inAppDisplayInterval, IterableInAppManager.getInAppStorageModel(iterableApi, useInMemoryStorageForInApps), IterableActivityMonitor.getInstance(), @@ -63,6 +66,7 @@ public interface Listener { @VisibleForTesting IterableInAppManager(IterableApi iterableApi, IterableInAppHandler handler, + @Nullable IterableInAppDisplayDelegate displayDelegate, double inAppDisplayInterval, IterableInAppStorage storage, IterableActivityMonitor activityMonitor, @@ -70,6 +74,7 @@ public interface Listener { this.api = iterableApi; this.context = iterableApi.getMainActivityContext(); this.handler = handler; + this.displayDelegate = displayDelegate; this.inAppDisplayInterval = inAppDisplayInterval; this.storage = storage; this.displayer = displayer; @@ -398,7 +403,7 @@ public int compare(IterableInAppMessage message1, IterableInAppMessage message2) } private void processMessages() { - if (!activityMonitor.isInForeground() || isShowingInApp() || !canShowInAppAfterPrevious() || isAutoDisplayPaused()) { + if (!activityMonitor.isInForeground() || isShowingInApp() || !canShowInAppAfterPrevious()) { return; } @@ -409,6 +414,9 @@ private void processMessages() { for (IterableInAppMessage message : messagesByPriorityLevel) { if (!message.isProcessed() && !message.isConsumed() && message.getTriggerType() == TriggerType.IMMEDIATE && !message.isRead()) { + if (isAutoDisplayPaused(message)) { + return; + } IterableLogger.d(TAG, "Calling onNewInApp on " + message.getMessageId()); InAppResponse response = handler.onNewInApp(message); IterableLogger.d(TAG, "Response: " + response); @@ -462,6 +470,10 @@ private boolean canShowInAppAfterPrevious() { return getSecondsSinceLastInApp() >= inAppDisplayInterval; } + private boolean isAutoDisplayPaused(IterableInAppMessage message) { + return displayDelegate != null ? displayDelegate.isAutoDisplayPaused(message) : autoDisplayPaused; + } + private void handleIterableCustomAction(String actionName, IterableInAppMessage message) { if (IterableConstants.ITERABLE_IN_APP_ACTION_DELETE.equals(actionName)) { removeMessage(message, IterableInAppDeleteActionType.DELETE_BUTTON, IterableInAppLocation.IN_APP, null, null); diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerSyncTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerSyncTest.java index 6d03f73cf..d6b6dbd82 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerSyncTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerSyncTest.java @@ -38,7 +38,7 @@ public class IterableInAppManagerSyncTest extends BaseTest { @Before public void setUp() throws Exception { MockitoAnnotations.initMocks(this); - inAppManager = spy(new IterableInAppManager(iterableApiMock, handlerMock, 30.0, storageMock, activityMonitorMock, inAppDisplayerMock)); + inAppManager = spy(new IterableInAppManager(iterableApiMock, handlerMock, null, 30.0, storageMock, activityMonitorMock, inAppDisplayerMock)); doAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) throws Throwable { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java index 2a5902978..fcb3ddbb5 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java @@ -92,6 +92,10 @@ public IterableConfig.Builder run(IterableConfig.Builder builder) { public void tearDown() throws IOException { server.shutdown(); server = null; + // Tests that swap in a spy in-app manager register it on the activity monitor; reset the + // monitor so a leaked manager doesn't consume the next test's enqueued response on foreground. + IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); + IterableActivityMonitor.instance = new IterableActivityMonitor(); } @Ignore("Stalls under Robolectric: showIterableFragmentNotificationHTML requires real Activity lifecycle - candidate for androidTest with Espresso") @@ -273,7 +277,7 @@ public void testHandleActionLink() throws Exception { IterableActivityMonitor.instance = new IterableActivityMonitor(); IterableInAppDisplayer inAppDisplayerMock = mock(IterableInAppDisplayer.class); - IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableDefaultInAppHandler(), 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), inAppDisplayerMock)); + IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableDefaultInAppHandler(), null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), inAppDisplayerMock)); IterableApi.sharedInstance = new IterableApi(inAppManager); IterableTestUtils.createIterableApiNew(new IterableTestUtils.ConfigBuilderExtender() { @Override @@ -340,7 +344,7 @@ public void testHandleCustomActionDelete() throws Exception { IterableActivityMonitor.instance = new IterableActivityMonitor(); IterableInAppDisplayer inAppDisplayerMock = mock(IterableInAppDisplayer.class); - IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableSkipInAppHandler(), 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), inAppDisplayerMock)); + IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableSkipInAppHandler(), null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), inAppDisplayerMock)); IterableApi.sharedInstance = new IterableApi(inAppManager); IterableTestUtils.createIterableApiNew(new IterableTestUtils.ConfigBuilderExtender() { @Override @@ -398,6 +402,104 @@ public void testInAppAutoDisplayPause() throws Exception { verify(inAppHandler, times(1)).onNewInApp(inAppMessageCaptor.capture()); } + private IterableInAppManager createManagerWithDisplayDelegate(IterableInAppDisplayer displayer, IterableInAppDisplayDelegate displayDelegate) { + IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); + IterableActivityMonitor.instance = new IterableActivityMonitor(); + IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableDefaultInAppHandler(), displayDelegate, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), displayer)); + IterableApi.sharedInstance = new IterableApi(inAppManager); + IterableTestUtils.createIterableApiNew(new IterableTestUtils.ConfigBuilderExtender() { + @Override + public IterableConfig.Builder run(IterableConfig.Builder builder) { + return builder.setCustomActionHandler(customActionHandler).setUrlHandler(urlHandler); + } + }); + shadowOf(getMainLooper()).idle(); + return inAppManager; + } + + @Test + public void testDisplayDelegatePausesMessageWithoutConsuming() throws Exception { + dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); + + IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class); + IterableInAppDisplayDelegate displayDelegate = mock(IterableInAppDisplayDelegate.class); + doReturn(true).when(displayDelegate).isAutoDisplayPaused(any(IterableInAppMessage.class)); + IterableInAppManager inAppManager = createManagerWithDisplayDelegate(displayerMock, displayDelegate); + + Robolectric.buildActivity(Activity.class).create().start().resume(); + shadowOf(getMainLooper()).idle(); + + verify(displayDelegate).isAutoDisplayPaused(any(IterableInAppMessage.class)); + verify(displayerMock, never()).showMessage(any(IterableInAppMessage.class), any(IterableInAppLocation.class), any(IterableHelper.IterableUrlCallback.class)); + // Deferred, not consumed: message remains available and unprocessed for a later pass + assertEquals(1, inAppManager.getMessages().size()); + assertFalse(inAppManager.getMessages().get(0).isProcessed()); + assertFalse(inAppManager.getMessages().get(0).isConsumed()); + } + + @Test + public void testDisplayDelegateOverridesGlobalPause() throws Exception { + dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); + + IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class); + IterableInAppDisplayDelegate displayDelegate = mock(IterableInAppDisplayDelegate.class); + doReturn(false).when(displayDelegate).isAutoDisplayPaused(any(IterableInAppMessage.class)); + IterableInAppManager inAppManager = createManagerWithDisplayDelegate(displayerMock, displayDelegate); + + // Global pause is on, but the delegate (returning false) takes precedence and allows display + inAppManager.setAutoDisplayPaused(true); + + Robolectric.buildActivity(Activity.class).create().start().resume(); + shadowOf(getMainLooper()).idle(); + + verify(displayerMock).showMessage(any(IterableInAppMessage.class), eq(IterableInAppLocation.IN_APP), any(IterableHelper.IterableUrlCallback.class)); + } + + @Test + public void testDisplayDelegateDefersThenDisplaysOnLaterPass() throws Exception { + dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); + + IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class); + IterableInAppDisplayDelegate displayDelegate = mock(IterableInAppDisplayDelegate.class); + doReturn(true, false).when(displayDelegate).isAutoDisplayPaused(any(IterableInAppMessage.class)); + IterableInAppManager inAppManager = createManagerWithDisplayDelegate(displayerMock, displayDelegate); + + // First pass: delegate pauses, nothing shown + Robolectric.buildActivity(Activity.class).create().start().resume(); + shadowOf(getMainLooper()).idle(); + verify(displayerMock, never()).showMessage(any(IterableInAppMessage.class), any(IterableInAppLocation.class), any(IterableHelper.IterableUrlCallback.class)); + + // Second pass: delegate now allows it, message is displayed + inAppManager.scheduleProcessing(); + shadowOf(getMainLooper()).idle(); + verify(displayerMock).showMessage(any(IterableInAppMessage.class), eq(IterableInAppLocation.IN_APP), any(IterableHelper.IterableUrlCallback.class)); + } + + @Test + public void testDisplayDelegateReceivesMessageForPerMessageDecision() throws Exception { + dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); + + IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class); + IterableInAppDisplayDelegate displayDelegate = mock(IterableInAppDisplayDelegate.class); + // Allow display only for a specific message id, proving the delegate can decide per-message + doAnswer(new Answer() { + @Override + public Boolean answer(InvocationOnMock invocation) { + IterableInAppMessage message = invocation.getArgument(0); + return !"7kx2MmoGdCpuZao9fDueuQoXVAZuDaVV".equals(message.getMessageId()); + } + }).when(displayDelegate).isAutoDisplayPaused(any(IterableInAppMessage.class)); + IterableInAppManager inAppManager = createManagerWithDisplayDelegate(displayerMock, displayDelegate); + + Robolectric.buildActivity(Activity.class).create().start().resume(); + shadowOf(getMainLooper()).idle(); + + ArgumentCaptor delegateCaptor = ArgumentCaptor.forClass(IterableInAppMessage.class); + verify(displayDelegate).isAutoDisplayPaused(delegateCaptor.capture()); + assertEquals("7kx2MmoGdCpuZao9fDueuQoXVAZuDaVV", delegateCaptor.getValue().getMessageId()); + verify(displayerMock).showMessage(any(IterableInAppMessage.class), eq(IterableInAppLocation.IN_APP), any(IterableHelper.IterableUrlCallback.class)); + } + @Test public void testMessagePersistentReadStateFromServer() throws Exception { @@ -462,6 +564,7 @@ public void testJsonOnlyMessageDisplay() throws Exception { IterableInAppManager inAppManager = spy(new IterableInAppManager( IterableApi.sharedInstance, new IterableDefaultInAppHandler(), + null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), @@ -597,6 +700,7 @@ public void testJsonOnlyInAppMessageDelegateCallbacks() throws Exception { IterableInAppManager inAppManager = spy(new IterableInAppManager( IterableApi.sharedInstance, inAppHandler, + null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), @@ -751,6 +855,7 @@ public void testJsonOnlyInAppMessageProcessingAndDisplay() throws Exception { IterableInAppManager inAppManager = spy(new IterableInAppManager( IterableApi.sharedInstance, inAppHandler, + null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), @@ -825,6 +930,7 @@ public void testJsonOnlyMessageConsume() throws Exception { IterableInAppManager inAppManager = new IterableInAppManager( spyApi, new IterableDefaultInAppHandler(), + null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInboxTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInboxTest.java index 7b2b8de57..1a3fe176d 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInboxTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInboxTest.java @@ -213,7 +213,7 @@ public void testShowInboxMessageImmediate() throws Exception { IterableInAppDisplayer inAppDisplayerMock = mock(IterableInAppDisplayer.class); when(inAppDisplayerMock.showMessage(any(IterableInAppMessage.class), eq(IterableInAppLocation.IN_APP), any(IterableHelper.IterableUrlCallback.class))).thenReturn(true); - IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableDefaultInAppHandler(), 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), inAppDisplayerMock)); + IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableDefaultInAppHandler(), null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), inAppDisplayerMock)); IterableApi.sharedInstance = new IterableApi(inAppManager); IterableTestUtils.createIterableApiNew(new IterableTestUtils.ConfigBuilderExtender() { @Override From 73b9b6105bfa2db94b9bd6528c13f770cc20cd59 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 23 Jun 2026 12:08:56 +0100 Subject: [PATCH 2/4] Renaming and adding a method to resume in-app display --- CHANGELOG.md | 2 + .../com/iterable/iterableapi/IterableApi.java | 2 +- .../iterable/iterableapi/IterableConfig.java | 18 +++--- ....java => IterableInAppDisplayHandler.java} | 6 +- .../iterableapi/IterableInAppManager.java | 30 ++++++---- .../iterableapi/IterableInAppManagerTest.java | 55 ++++++++++--------- 6 files changed, 63 insertions(+), 50 deletions(-) rename iterableapi/src/main/java/com/iterable/iterableapi/{IterableInAppDisplayDelegate.java => IterableInAppDisplayHandler.java} (73%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f52b5b5..8ceb309da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [3.9.0] ### Added +- Added `IterableInAppDisplayHandler` for real-time, per-message control over whether the SDK automatically displays an in-app message. Set it via `IterableConfig.Builder.setInAppDisplayHandler()`. Returning `true` from `isAutoDisplayPaused(message)` defers that message so it is reconsidered on a later display pass (rather than being permanently skipped). The handler takes precedence over the global `IterableInAppManager.setAutoDisplayPaused(boolean)` flag. +- Added `IterableInAppManager.resumeInAppDisplay()` so apps can prompt the SDK to re-evaluate pending in-app messages once they become ready to display (e.g. after a splash screen is dismissed), without waiting for the next foreground/sync trigger. - Added support for in-app messages in fully Jetpack Compose apps using a Dialog-based renderer (`IterableInAppDialogNotification`), removing the requirement for a `FragmentActivity`. - New `IterableInboxToolbarView` — an opt-in, reusable toolbar component for the inbox UI. Configurable via the new Kotlin sealed interface `InboxToolbarOption`: - `None` (default) — no toolbar; behavior is unchanged from prior SDK versions. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index c3a48de56..54f777e31 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -828,7 +828,7 @@ public static void initialize(@NonNull Context context, @NonNull String apiKey, sharedInstance.inAppManager = new IterableInAppManager( sharedInstance, sharedInstance.config.inAppHandler, - sharedInstance.config.inAppDisplayDelegate, + sharedInstance.config.inAppDisplayHandler, sharedInstance.config.inAppDisplayInterval, sharedInstance.config.useInMemoryStorageForInApps); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index 6a9515138..2b53d4658 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -51,11 +51,11 @@ public class IterableConfig { final IterableInAppHandler inAppHandler; /** - * Optional delegate that gives the app real-time, per-message control over whether an in-app + * Optional handler that gives the app real-time, per-message control over whether an in-app * message should be displayed automatically. Takes precedence over the global auto-display pause. */ @Nullable - final IterableInAppDisplayDelegate inAppDisplayDelegate; + final IterableInAppDisplayHandler inAppDisplayHandler; /** * The number of seconds to wait before showing the next in-app message, if there are multiple @@ -178,7 +178,7 @@ private IterableConfig(Builder builder) { checkForDeferredDeeplink = builder.checkForDeferredDeeplink; logLevel = builder.logLevel; inAppHandler = builder.inAppHandler; - inAppDisplayDelegate = builder.inAppDisplayDelegate; + inAppDisplayHandler = builder.inAppDisplayHandler; inAppDisplayInterval = builder.inAppDisplayInterval; authHandler = builder.authHandler; expiringAuthTokenRefreshPeriod = builder.expiringAuthTokenRefreshPeriod; @@ -208,7 +208,7 @@ public static class Builder { private boolean checkForDeferredDeeplink; private int logLevel = Log.ERROR; private IterableInAppHandler inAppHandler = new IterableDefaultInAppHandler(); - private IterableInAppDisplayDelegate inAppDisplayDelegate = null; + private IterableInAppDisplayHandler inAppDisplayHandler = null; private double inAppDisplayInterval = 30.0; private IterableAuthHandler authHandler; private long expiringAuthTokenRefreshPeriod = 60000L; @@ -319,16 +319,16 @@ public Builder setInAppHandler(@NonNull IterableInAppHandler inAppHandler) { } /** - * Set an in-app display delegate for real-time, per-message control over whether an in-app + * Set an in-app display handler for real-time, per-message control over whether an in-app * message should be displayed automatically. Returning {@code true} from - * {@link IterableInAppDisplayDelegate#isAutoDisplayPaused(IterableInAppMessage)} defers the + * {@link IterableInAppDisplayHandler#isAutoDisplayPaused(IterableInAppMessage)} defers the * message so it is reconsidered on a later display pass. Takes precedence over * {@link IterableInAppManager#setAutoDisplayPaused(boolean)}. - * @param inAppDisplayDelegate In-app display delegate provided by the app + * @param inAppDisplayHandler In-app display handler provided by the app */ @NonNull - public Builder setInAppDisplayDelegate(@NonNull IterableInAppDisplayDelegate inAppDisplayDelegate) { - this.inAppDisplayDelegate = inAppDisplayDelegate; + public Builder setInAppDisplayHandler(@NonNull IterableInAppDisplayHandler inAppDisplayHandler) { + this.inAppDisplayHandler = inAppDisplayHandler; return this; } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayDelegate.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayHandler.java similarity index 73% rename from iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayDelegate.java rename to iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayHandler.java index cdb298551..3a6b9daaa 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayDelegate.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayHandler.java @@ -3,13 +3,13 @@ import androidx.annotation.NonNull; /** - * Delegate that gives the app real-time, per-message control over whether the SDK is allowed to + * Handler that gives the app real-time, per-message control over whether the SDK is allowed to * automatically display a given in-app message at the moment it is about to be shown. * - * When set via {@link IterableConfig.Builder#setInAppDisplayDelegate(IterableInAppDisplayDelegate)}, + * When set via {@link IterableConfig.Builder#setInAppDisplayHandler(IterableInAppDisplayHandler)}, * this takes precedence over the global {@link IterableInAppManager#setAutoDisplayPaused(boolean)} flag. */ -public interface IterableInAppDisplayDelegate { +public interface IterableInAppDisplayHandler { /** * Called right before the SDK attempts to automatically display an in-app message. * @param message The in-app message about to be displayed. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java index 7ad52a3f2..1ee18262a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java @@ -44,7 +44,7 @@ public interface Listener { private final IterableInAppStorage storage; private final IterableInAppHandler handler; @Nullable - private final IterableInAppDisplayDelegate displayDelegate; + private final IterableInAppDisplayHandler displayHandler; private final IterableInAppDisplayer displayer; private final IterableActivityMonitor activityMonitor; private final double inAppDisplayInterval; @@ -53,10 +53,10 @@ public interface Listener { private long lastInAppShown = 0; private boolean autoDisplayPaused = false; - IterableInAppManager(IterableApi iterableApi, IterableInAppHandler handler, @Nullable IterableInAppDisplayDelegate displayDelegate, double inAppDisplayInterval, boolean useInMemoryStorageForInApps) { + IterableInAppManager(IterableApi iterableApi, IterableInAppHandler handler, @Nullable IterableInAppDisplayHandler displayHandler, double inAppDisplayInterval, boolean useInMemoryStorageForInApps) { this(iterableApi, handler, - displayDelegate, + displayHandler, inAppDisplayInterval, IterableInAppManager.getInAppStorageModel(iterableApi, useInMemoryStorageForInApps), IterableActivityMonitor.getInstance(), @@ -66,7 +66,7 @@ public interface Listener { @VisibleForTesting IterableInAppManager(IterableApi iterableApi, IterableInAppHandler handler, - @Nullable IterableInAppDisplayDelegate displayDelegate, + @Nullable IterableInAppDisplayHandler displayHandler, double inAppDisplayInterval, IterableInAppStorage storage, IterableActivityMonitor activityMonitor, @@ -74,7 +74,7 @@ public interface Listener { this.api = iterableApi; this.context = iterableApi.getMainActivityContext(); this.handler = handler; - this.displayDelegate = displayDelegate; + this.displayHandler = displayHandler; this.inAppDisplayInterval = inAppDisplayInterval; this.storage = storage; this.displayer = displayer; @@ -150,10 +150,6 @@ public synchronized void setRead(@NonNull IterableInAppMessage message, boolean notifyOnChange(); } - boolean isAutoDisplayPaused() { - return autoDisplayPaused; - } - /** * Set a pause to prevent showing in-app messages automatically. By default the value is set to false. * @param paused Whether to pause showing in-app messages. @@ -165,6 +161,20 @@ public void setAutoDisplayPaused(boolean paused) { } } + /** + * Ask the SDK to re-evaluate whether a pending in-app message can be displayed now. + *

+ * Use this when display was deferred via {@link IterableInAppDisplayHandler} (or + * {@link #setAutoDisplayPaused(boolean)}) and your app has since become ready to show in-apps — + * for example once a splash screen is dismissed and the main UI is visible. The SDK only + * re-checks pending messages on its own triggers (foreground, sync, new message); calling this + * lets the app prompt a re-check without one of those occurring. This does not change any stored + * state; it triggers a single display attempt. + */ + public void resumeInAppDisplay() { + scheduleProcessing(); + } + /** * Trigger a manual sync. This method is called automatically by the SDK, so there should be no * need to call this method from your app. @@ -471,7 +481,7 @@ private boolean canShowInAppAfterPrevious() { } private boolean isAutoDisplayPaused(IterableInAppMessage message) { - return displayDelegate != null ? displayDelegate.isAutoDisplayPaused(message) : autoDisplayPaused; + return displayHandler != null ? displayHandler.isAutoDisplayPaused(message) : autoDisplayPaused; } private void handleIterableCustomAction(String actionName, IterableInAppMessage message) { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java index fcb3ddbb5..b8e80508c 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java @@ -402,10 +402,10 @@ public void testInAppAutoDisplayPause() throws Exception { verify(inAppHandler, times(1)).onNewInApp(inAppMessageCaptor.capture()); } - private IterableInAppManager createManagerWithDisplayDelegate(IterableInAppDisplayer displayer, IterableInAppDisplayDelegate displayDelegate) { + private IterableInAppManager createManagerWithDisplayHandler(IterableInAppDisplayer displayer, IterableInAppDisplayHandler displayHandler) { IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); IterableActivityMonitor.instance = new IterableActivityMonitor(); - IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableDefaultInAppHandler(), displayDelegate, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), displayer)); + IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableDefaultInAppHandler(), displayHandler, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), displayer)); IterableApi.sharedInstance = new IterableApi(inAppManager); IterableTestUtils.createIterableApiNew(new IterableTestUtils.ConfigBuilderExtender() { @Override @@ -418,18 +418,18 @@ public IterableConfig.Builder run(IterableConfig.Builder builder) { } @Test - public void testDisplayDelegatePausesMessageWithoutConsuming() throws Exception { + public void testDisplayHandlerPausesMessageWithoutConsuming() throws Exception { dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class); - IterableInAppDisplayDelegate displayDelegate = mock(IterableInAppDisplayDelegate.class); - doReturn(true).when(displayDelegate).isAutoDisplayPaused(any(IterableInAppMessage.class)); - IterableInAppManager inAppManager = createManagerWithDisplayDelegate(displayerMock, displayDelegate); + IterableInAppDisplayHandler displayHandler = mock(IterableInAppDisplayHandler.class); + doReturn(true).when(displayHandler).isAutoDisplayPaused(any(IterableInAppMessage.class)); + IterableInAppManager inAppManager = createManagerWithDisplayHandler(displayerMock, displayHandler); Robolectric.buildActivity(Activity.class).create().start().resume(); shadowOf(getMainLooper()).idle(); - verify(displayDelegate).isAutoDisplayPaused(any(IterableInAppMessage.class)); + verify(displayHandler).isAutoDisplayPaused(any(IterableInAppMessage.class)); verify(displayerMock, never()).showMessage(any(IterableInAppMessage.class), any(IterableInAppLocation.class), any(IterableHelper.IterableUrlCallback.class)); // Deferred, not consumed: message remains available and unprocessed for a later pass assertEquals(1, inAppManager.getMessages().size()); @@ -438,15 +438,15 @@ public void testDisplayDelegatePausesMessageWithoutConsuming() throws Exception } @Test - public void testDisplayDelegateOverridesGlobalPause() throws Exception { + public void testDisplayHandlerOverridesGlobalPause() throws Exception { dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class); - IterableInAppDisplayDelegate displayDelegate = mock(IterableInAppDisplayDelegate.class); - doReturn(false).when(displayDelegate).isAutoDisplayPaused(any(IterableInAppMessage.class)); - IterableInAppManager inAppManager = createManagerWithDisplayDelegate(displayerMock, displayDelegate); + IterableInAppDisplayHandler displayHandler = mock(IterableInAppDisplayHandler.class); + doReturn(false).when(displayHandler).isAutoDisplayPaused(any(IterableInAppMessage.class)); + IterableInAppManager inAppManager = createManagerWithDisplayHandler(displayerMock, displayHandler); - // Global pause is on, but the delegate (returning false) takes precedence and allows display + // Global pause is on, but the handler (returning false) takes precedence and allows display inAppManager.setAutoDisplayPaused(true); Robolectric.buildActivity(Activity.class).create().start().resume(); @@ -456,47 +456,48 @@ public void testDisplayDelegateOverridesGlobalPause() throws Exception { } @Test - public void testDisplayDelegateDefersThenDisplaysOnLaterPass() throws Exception { + public void testDisplayHandlerDefersThenDisplaysOnLaterPass() throws Exception { dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class); - IterableInAppDisplayDelegate displayDelegate = mock(IterableInAppDisplayDelegate.class); - doReturn(true, false).when(displayDelegate).isAutoDisplayPaused(any(IterableInAppMessage.class)); - IterableInAppManager inAppManager = createManagerWithDisplayDelegate(displayerMock, displayDelegate); + IterableInAppDisplayHandler displayHandler = mock(IterableInAppDisplayHandler.class); + doReturn(true, false).when(displayHandler).isAutoDisplayPaused(any(IterableInAppMessage.class)); + IterableInAppManager inAppManager = createManagerWithDisplayHandler(displayerMock, displayHandler); - // First pass: delegate pauses, nothing shown + // First pass: handler pauses, nothing shown Robolectric.buildActivity(Activity.class).create().start().resume(); shadowOf(getMainLooper()).idle(); verify(displayerMock, never()).showMessage(any(IterableInAppMessage.class), any(IterableInAppLocation.class), any(IterableHelper.IterableUrlCallback.class)); - // Second pass: delegate now allows it, message is displayed - inAppManager.scheduleProcessing(); + // Second pass: app prompts a re-check via the public API (no background->foreground transition); + // handler now allows it, message is displayed + inAppManager.resumeInAppDisplay(); shadowOf(getMainLooper()).idle(); verify(displayerMock).showMessage(any(IterableInAppMessage.class), eq(IterableInAppLocation.IN_APP), any(IterableHelper.IterableUrlCallback.class)); } @Test - public void testDisplayDelegateReceivesMessageForPerMessageDecision() throws Exception { + public void testDisplayHandlerReceivesMessageForPerMessageDecision() throws Exception { dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class); - IterableInAppDisplayDelegate displayDelegate = mock(IterableInAppDisplayDelegate.class); - // Allow display only for a specific message id, proving the delegate can decide per-message + IterableInAppDisplayHandler displayHandler = mock(IterableInAppDisplayHandler.class); + // Allow display only for a specific message id, proving the handler can decide per-message doAnswer(new Answer() { @Override public Boolean answer(InvocationOnMock invocation) { IterableInAppMessage message = invocation.getArgument(0); return !"7kx2MmoGdCpuZao9fDueuQoXVAZuDaVV".equals(message.getMessageId()); } - }).when(displayDelegate).isAutoDisplayPaused(any(IterableInAppMessage.class)); - IterableInAppManager inAppManager = createManagerWithDisplayDelegate(displayerMock, displayDelegate); + }).when(displayHandler).isAutoDisplayPaused(any(IterableInAppMessage.class)); + IterableInAppManager inAppManager = createManagerWithDisplayHandler(displayerMock, displayHandler); Robolectric.buildActivity(Activity.class).create().start().resume(); shadowOf(getMainLooper()).idle(); - ArgumentCaptor delegateCaptor = ArgumentCaptor.forClass(IterableInAppMessage.class); - verify(displayDelegate).isAutoDisplayPaused(delegateCaptor.capture()); - assertEquals("7kx2MmoGdCpuZao9fDueuQoXVAZuDaVV", delegateCaptor.getValue().getMessageId()); + ArgumentCaptor handlerCaptor = ArgumentCaptor.forClass(IterableInAppMessage.class); + verify(displayHandler).isAutoDisplayPaused(handlerCaptor.capture()); + assertEquals("7kx2MmoGdCpuZao9fDueuQoXVAZuDaVV", handlerCaptor.getValue().getMessageId()); verify(displayerMock).showMessage(any(IterableInAppMessage.class), eq(IterableInAppLocation.IN_APP), any(IterableHelper.IterableUrlCallback.class)); } From 4def62452db8b06d6c9a3d7f34ca33b93d3d4c0d Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 23 Jun 2026 15:11:02 +0100 Subject: [PATCH 3/4] Changed for new ENUM type instead of a new handler with similar name to avoid confusion --- CHANGELOG.md | 22 ++++- .../com/iterable/iterableapi/IterableApi.java | 1 - .../iterable/iterableapi/IterableConfig.java | 23 ----- .../IterableInAppDisplayHandler.java | 20 ----- .../iterableapi/IterableInAppHandler.java | 10 ++- .../iterableapi/IterableInAppManager.java | 35 ++++---- .../IterableInAppManagerSyncTest.java | 2 +- .../iterableapi/IterableInAppManagerTest.java | 85 ++++++++----------- .../iterableapi/IterableInboxTest.java | 2 +- 9 files changed, 82 insertions(+), 118 deletions(-) delete mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayHandler.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ceb309da..c7263814b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,29 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- Added a `DEFER` response to `IterableInAppHandler.InAppResponse`, returned from `onNewInApp(message)`. Unlike `SKIP` (which permanently drops the message), `DEFER` leaves the message pending so the SDK reconsiders it on a later display pass (next foreground, sync, or newly arrived message). Use it for temporary, per-message suppression — for example while a splash screen is showing. Existing handlers returning `SHOW`/`SKIP` are unaffected. +- Added `IterableInAppManager.resumeInAppDisplay()` so apps can prompt the SDK to re-evaluate pending in-app messages once they become ready to display (e.g. after a splash screen is dismissed), without waiting for the next foreground/sync trigger. + +### Migration guide +**No action required.** Existing `IterableInAppHandler` implementations returning `SHOW`/`SKIP` are unaffected. + +To suppress an in-app temporarily (e.g. during a splash screen), return the new `DEFER` instead of `SKIP` — the message stays pending and is re-offered on a later display pass: + +```java +new IterableConfig.Builder().setInAppHandler(message -> + appIsShowingSplashScreen() + ? IterableInAppHandler.InAppResponse.DEFER + : IterableInAppHandler.InAppResponse.SHOW +).build(); +``` + +Once ready, call `IterableApi.getInstance().getInAppManager().resumeInAppDisplay()` to re-check pending messages immediately instead of waiting for the next foreground/sync. + +> **Kotlin:** add a `DEFER` branch to any exhaustive `when` over `InAppResponse`. ## [3.9.0] ### Added -- Added `IterableInAppDisplayHandler` for real-time, per-message control over whether the SDK automatically displays an in-app message. Set it via `IterableConfig.Builder.setInAppDisplayHandler()`. Returning `true` from `isAutoDisplayPaused(message)` defers that message so it is reconsidered on a later display pass (rather than being permanently skipped). The handler takes precedence over the global `IterableInAppManager.setAutoDisplayPaused(boolean)` flag. -- Added `IterableInAppManager.resumeInAppDisplay()` so apps can prompt the SDK to re-evaluate pending in-app messages once they become ready to display (e.g. after a splash screen is dismissed), without waiting for the next foreground/sync trigger. - Added support for in-app messages in fully Jetpack Compose apps using a Dialog-based renderer (`IterableInAppDialogNotification`), removing the requirement for a `FragmentActivity`. - New `IterableInboxToolbarView` — an opt-in, reusable toolbar component for the inbox UI. Configurable via the new Kotlin sealed interface `InboxToolbarOption`: - `None` (default) — no toolbar; behavior is unchanged from prior SDK versions. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 54f777e31..a49ab8286 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -828,7 +828,6 @@ public static void initialize(@NonNull Context context, @NonNull String apiKey, sharedInstance.inAppManager = new IterableInAppManager( sharedInstance, sharedInstance.config.inAppHandler, - sharedInstance.config.inAppDisplayHandler, sharedInstance.config.inAppDisplayInterval, sharedInstance.config.useInMemoryStorageForInApps); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index 2b53d4658..d9e6b2542 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -50,13 +50,6 @@ public class IterableConfig { */ final IterableInAppHandler inAppHandler; - /** - * Optional handler that gives the app real-time, per-message control over whether an in-app - * message should be displayed automatically. Takes precedence over the global auto-display pause. - */ - @Nullable - final IterableInAppDisplayHandler inAppDisplayHandler; - /** * The number of seconds to wait before showing the next in-app message, if there are multiple * messages in the queue @@ -178,7 +171,6 @@ private IterableConfig(Builder builder) { checkForDeferredDeeplink = builder.checkForDeferredDeeplink; logLevel = builder.logLevel; inAppHandler = builder.inAppHandler; - inAppDisplayHandler = builder.inAppDisplayHandler; inAppDisplayInterval = builder.inAppDisplayInterval; authHandler = builder.authHandler; expiringAuthTokenRefreshPeriod = builder.expiringAuthTokenRefreshPeriod; @@ -208,7 +200,6 @@ public static class Builder { private boolean checkForDeferredDeeplink; private int logLevel = Log.ERROR; private IterableInAppHandler inAppHandler = new IterableDefaultInAppHandler(); - private IterableInAppDisplayHandler inAppDisplayHandler = null; private double inAppDisplayInterval = 30.0; private IterableAuthHandler authHandler; private long expiringAuthTokenRefreshPeriod = 60000L; @@ -318,20 +309,6 @@ public Builder setInAppHandler(@NonNull IterableInAppHandler inAppHandler) { return this; } - /** - * Set an in-app display handler for real-time, per-message control over whether an in-app - * message should be displayed automatically. Returning {@code true} from - * {@link IterableInAppDisplayHandler#isAutoDisplayPaused(IterableInAppMessage)} defers the - * message so it is reconsidered on a later display pass. Takes precedence over - * {@link IterableInAppManager#setAutoDisplayPaused(boolean)}. - * @param inAppDisplayHandler In-app display handler provided by the app - */ - @NonNull - public Builder setInAppDisplayHandler(@NonNull IterableInAppDisplayHandler inAppDisplayHandler) { - this.inAppDisplayHandler = inAppDisplayHandler; - return this; - } - /** * Set the in-app message display interval: the number of seconds to wait before showing * the next in-app message, if there are multiple messages in the queue diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayHandler.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayHandler.java deleted file mode 100644 index 3a6b9daaa..000000000 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayHandler.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.iterable.iterableapi; - -import androidx.annotation.NonNull; - -/** - * Handler that gives the app real-time, per-message control over whether the SDK is allowed to - * automatically display a given in-app message at the moment it is about to be shown. - * - * When set via {@link IterableConfig.Builder#setInAppDisplayHandler(IterableInAppDisplayHandler)}, - * this takes precedence over the global {@link IterableInAppManager#setAutoDisplayPaused(boolean)} flag. - */ -public interface IterableInAppDisplayHandler { - /** - * Called right before the SDK attempts to automatically display an in-app message. - * @param message The in-app message about to be displayed. - * @return {@code true} to pause/skip displaying this message for now (it will be reconsidered - * on a later display pass); {@code false} to allow it to be shown. - */ - boolean isAutoDisplayPaused(@NonNull IterableInAppMessage message); -} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppHandler.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppHandler.java index e4ebceb53..29ecaebdf 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppHandler.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppHandler.java @@ -4,8 +4,16 @@ public interface IterableInAppHandler { enum InAppResponse { + /** Display the in-app message now. */ SHOW, - SKIP + /** Do not display the in-app message; it is marked processed and will not be reconsidered. */ + SKIP, + /** + * Do not display the in-app message right now, but leave it pending so the SDK asks again on + * a later display pass (e.g. the next foreground, sync, or newly arrived message). Use this + * for temporary, per-message suppression — for example while a splash screen is showing. + */ + DEFER } @NonNull diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java index 1ee18262a..97df65ccf 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java @@ -43,8 +43,6 @@ public interface Listener { private final Context context; private final IterableInAppStorage storage; private final IterableInAppHandler handler; - @Nullable - private final IterableInAppDisplayHandler displayHandler; private final IterableInAppDisplayer displayer; private final IterableActivityMonitor activityMonitor; private final double inAppDisplayInterval; @@ -53,10 +51,9 @@ public interface Listener { private long lastInAppShown = 0; private boolean autoDisplayPaused = false; - IterableInAppManager(IterableApi iterableApi, IterableInAppHandler handler, @Nullable IterableInAppDisplayHandler displayHandler, double inAppDisplayInterval, boolean useInMemoryStorageForInApps) { + IterableInAppManager(IterableApi iterableApi, IterableInAppHandler handler, double inAppDisplayInterval, boolean useInMemoryStorageForInApps) { this(iterableApi, handler, - displayHandler, inAppDisplayInterval, IterableInAppManager.getInAppStorageModel(iterableApi, useInMemoryStorageForInApps), IterableActivityMonitor.getInstance(), @@ -66,7 +63,6 @@ public interface Listener { @VisibleForTesting IterableInAppManager(IterableApi iterableApi, IterableInAppHandler handler, - @Nullable IterableInAppDisplayHandler displayHandler, double inAppDisplayInterval, IterableInAppStorage storage, IterableActivityMonitor activityMonitor, @@ -74,7 +70,6 @@ public interface Listener { this.api = iterableApi; this.context = iterableApi.getMainActivityContext(); this.handler = handler; - this.displayHandler = displayHandler; this.inAppDisplayInterval = inAppDisplayInterval; this.storage = storage; this.displayer = displayer; @@ -164,12 +159,13 @@ public void setAutoDisplayPaused(boolean paused) { /** * Ask the SDK to re-evaluate whether a pending in-app message can be displayed now. *

- * Use this when display was deferred via {@link IterableInAppDisplayHandler} (or - * {@link #setAutoDisplayPaused(boolean)}) and your app has since become ready to show in-apps — - * for example once a splash screen is dismissed and the main UI is visible. The SDK only - * re-checks pending messages on its own triggers (foreground, sync, new message); calling this - * lets the app prompt a re-check without one of those occurring. This does not change any stored - * state; it triggers a single display attempt. + * Use this when display was deferred — by returning {@link IterableInAppHandler.InAppResponse#DEFER} + * from {@link IterableInAppHandler#onNewInApp(IterableInAppMessage)}, or via + * {@link #setAutoDisplayPaused(boolean)} — and your app has since become ready to show in-apps, + * for example once a splash screen is dismissed and the main UI is visible. The SDK otherwise + * only re-checks pending messages on its own triggers (foreground, sync, new message); calling + * this prompts a re-check without one of those occurring. This does not change any stored state; + * it triggers a single display attempt. */ public void resumeInAppDisplay() { scheduleProcessing(); @@ -413,7 +409,7 @@ public int compare(IterableInAppMessage message1, IterableInAppMessage message2) } private void processMessages() { - if (!activityMonitor.isInForeground() || isShowingInApp() || !canShowInAppAfterPrevious()) { + if (!activityMonitor.isInForeground() || isShowingInApp() || !canShowInAppAfterPrevious() || isAutoDisplayPaused()) { return; } @@ -424,12 +420,15 @@ private void processMessages() { for (IterableInAppMessage message : messagesByPriorityLevel) { if (!message.isProcessed() && !message.isConsumed() && message.getTriggerType() == TriggerType.IMMEDIATE && !message.isRead()) { - if (isAutoDisplayPaused(message)) { - return; - } IterableLogger.d(TAG, "Calling onNewInApp on " + message.getMessageId()); InAppResponse response = handler.onNewInApp(message); IterableLogger.d(TAG, "Response: " + response); + + if (response == InAppResponse.DEFER) { + // Leave the message unprocessed so it is reconsidered on a later display pass. + continue; + } + message.setProcessed(true); if (message.isJsonOnly()) { @@ -480,8 +479,8 @@ private boolean canShowInAppAfterPrevious() { return getSecondsSinceLastInApp() >= inAppDisplayInterval; } - private boolean isAutoDisplayPaused(IterableInAppMessage message) { - return displayHandler != null ? displayHandler.isAutoDisplayPaused(message) : autoDisplayPaused; + boolean isAutoDisplayPaused() { + return autoDisplayPaused; } private void handleIterableCustomAction(String actionName, IterableInAppMessage message) { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerSyncTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerSyncTest.java index d6b6dbd82..6d03f73cf 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerSyncTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerSyncTest.java @@ -38,7 +38,7 @@ public class IterableInAppManagerSyncTest extends BaseTest { @Before public void setUp() throws Exception { MockitoAnnotations.initMocks(this); - inAppManager = spy(new IterableInAppManager(iterableApiMock, handlerMock, null, 30.0, storageMock, activityMonitorMock, inAppDisplayerMock)); + inAppManager = spy(new IterableInAppManager(iterableApiMock, handlerMock, 30.0, storageMock, activityMonitorMock, inAppDisplayerMock)); doAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) throws Throwable { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java index b8e80508c..04d8d1e26 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java @@ -277,7 +277,7 @@ public void testHandleActionLink() throws Exception { IterableActivityMonitor.instance = new IterableActivityMonitor(); IterableInAppDisplayer inAppDisplayerMock = mock(IterableInAppDisplayer.class); - IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableDefaultInAppHandler(), null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), inAppDisplayerMock)); + IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableDefaultInAppHandler(), 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), inAppDisplayerMock)); IterableApi.sharedInstance = new IterableApi(inAppManager); IterableTestUtils.createIterableApiNew(new IterableTestUtils.ConfigBuilderExtender() { @Override @@ -344,7 +344,7 @@ public void testHandleCustomActionDelete() throws Exception { IterableActivityMonitor.instance = new IterableActivityMonitor(); IterableInAppDisplayer inAppDisplayerMock = mock(IterableInAppDisplayer.class); - IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableSkipInAppHandler(), null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), inAppDisplayerMock)); + IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableSkipInAppHandler(), 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), inAppDisplayerMock)); IterableApi.sharedInstance = new IterableApi(inAppManager); IterableTestUtils.createIterableApiNew(new IterableTestUtils.ConfigBuilderExtender() { @Override @@ -402,10 +402,10 @@ public void testInAppAutoDisplayPause() throws Exception { verify(inAppHandler, times(1)).onNewInApp(inAppMessageCaptor.capture()); } - private IterableInAppManager createManagerWithDisplayHandler(IterableInAppDisplayer displayer, IterableInAppDisplayHandler displayHandler) { + private IterableInAppManager createManagerWithHandler(IterableInAppDisplayer displayer, IterableInAppHandler handler) { IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); IterableActivityMonitor.instance = new IterableActivityMonitor(); - IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableDefaultInAppHandler(), displayHandler, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), displayer)); + IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, handler, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), displayer)); IterableApi.sharedInstance = new IterableApi(inAppManager); IterableTestUtils.createIterableApiNew(new IterableTestUtils.ConfigBuilderExtender() { @Override @@ -418,18 +418,18 @@ public IterableConfig.Builder run(IterableConfig.Builder builder) { } @Test - public void testDisplayHandlerPausesMessageWithoutConsuming() throws Exception { + public void testDeferLeavesMessagePendingWithoutConsuming() throws Exception { dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class); - IterableInAppDisplayHandler displayHandler = mock(IterableInAppDisplayHandler.class); - doReturn(true).when(displayHandler).isAutoDisplayPaused(any(IterableInAppMessage.class)); - IterableInAppManager inAppManager = createManagerWithDisplayHandler(displayerMock, displayHandler); + IterableInAppHandler handler = mock(IterableInAppHandler.class); + doReturn(IterableInAppHandler.InAppResponse.DEFER).when(handler).onNewInApp(any(IterableInAppMessage.class)); + IterableInAppManager inAppManager = createManagerWithHandler(displayerMock, handler); Robolectric.buildActivity(Activity.class).create().start().resume(); shadowOf(getMainLooper()).idle(); - verify(displayHandler).isAutoDisplayPaused(any(IterableInAppMessage.class)); + verify(handler).onNewInApp(any(IterableInAppMessage.class)); verify(displayerMock, never()).showMessage(any(IterableInAppMessage.class), any(IterableInAppLocation.class), any(IterableHelper.IterableUrlCallback.class)); // Deferred, not consumed: message remains available and unprocessed for a later pass assertEquals(1, inAppManager.getMessages().size()); @@ -438,67 +438,54 @@ public void testDisplayHandlerPausesMessageWithoutConsuming() throws Exception { } @Test - public void testDisplayHandlerOverridesGlobalPause() throws Exception { + public void testDeferThenShowDisplaysOnLaterPass() throws Exception { dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class); - IterableInAppDisplayHandler displayHandler = mock(IterableInAppDisplayHandler.class); - doReturn(false).when(displayHandler).isAutoDisplayPaused(any(IterableInAppMessage.class)); - IterableInAppManager inAppManager = createManagerWithDisplayHandler(displayerMock, displayHandler); + IterableInAppHandler handler = mock(IterableInAppHandler.class); + // Defer on the first pass, then allow display on the next + doReturn(IterableInAppHandler.InAppResponse.DEFER, IterableInAppHandler.InAppResponse.SHOW).when(handler).onNewInApp(any(IterableInAppMessage.class)); + IterableInAppManager inAppManager = createManagerWithHandler(displayerMock, handler); - // Global pause is on, but the handler (returning false) takes precedence and allows display - inAppManager.setAutoDisplayPaused(true); - - Robolectric.buildActivity(Activity.class).create().start().resume(); - shadowOf(getMainLooper()).idle(); - - verify(displayerMock).showMessage(any(IterableInAppMessage.class), eq(IterableInAppLocation.IN_APP), any(IterableHelper.IterableUrlCallback.class)); - } - - @Test - public void testDisplayHandlerDefersThenDisplaysOnLaterPass() throws Exception { - dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); - - IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class); - IterableInAppDisplayHandler displayHandler = mock(IterableInAppDisplayHandler.class); - doReturn(true, false).when(displayHandler).isAutoDisplayPaused(any(IterableInAppMessage.class)); - IterableInAppManager inAppManager = createManagerWithDisplayHandler(displayerMock, displayHandler); - - // First pass: handler pauses, nothing shown + // First pass: deferred, nothing shown Robolectric.buildActivity(Activity.class).create().start().resume(); shadowOf(getMainLooper()).idle(); verify(displayerMock, never()).showMessage(any(IterableInAppMessage.class), any(IterableInAppLocation.class), any(IterableHelper.IterableUrlCallback.class)); - // Second pass: app prompts a re-check via the public API (no background->foreground transition); - // handler now allows it, message is displayed + // App prompts a re-check via the public API (no background->foreground transition); + // handler now returns SHOW and the message is displayed inAppManager.resumeInAppDisplay(); shadowOf(getMainLooper()).idle(); verify(displayerMock).showMessage(any(IterableInAppMessage.class), eq(IterableInAppLocation.IN_APP), any(IterableHelper.IterableUrlCallback.class)); } @Test - public void testDisplayHandlerReceivesMessageForPerMessageDecision() throws Exception { - dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); + public void testDeferDoesNotBlockLowerPriorityMessage() throws Exception { + JSONObject payload = new JSONObject(IterableTestUtils.getResourceString("inapp_payload_single.json")); + JSONArray jsonArray = payload.optJSONArray(IterableConstants.ITERABLE_IN_APP_MESSAGE); + jsonArray.getJSONObject(0).put(IterableConstants.KEY_MESSAGE_ID, "deferredMessage"); + JSONObject secondMessage = new JSONObject(jsonArray.getJSONObject(0).toString()).put(IterableConstants.KEY_MESSAGE_ID, "shownMessage"); + jsonArray.put(secondMessage); + dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(payload.toString())); IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class); - IterableInAppDisplayHandler displayHandler = mock(IterableInAppDisplayHandler.class); - // Allow display only for a specific message id, proving the handler can decide per-message - doAnswer(new Answer() { + IterableInAppHandler handler = mock(IterableInAppHandler.class); + // Defer one message, show the other, proving DEFER is per-message and does not halt the pass + doAnswer(new Answer() { @Override - public Boolean answer(InvocationOnMock invocation) { + public IterableInAppHandler.InAppResponse answer(InvocationOnMock invocation) { IterableInAppMessage message = invocation.getArgument(0); - return !"7kx2MmoGdCpuZao9fDueuQoXVAZuDaVV".equals(message.getMessageId()); + return "deferredMessage".equals(message.getMessageId()) ? IterableInAppHandler.InAppResponse.DEFER : IterableInAppHandler.InAppResponse.SHOW; } - }).when(displayHandler).isAutoDisplayPaused(any(IterableInAppMessage.class)); - IterableInAppManager inAppManager = createManagerWithDisplayHandler(displayerMock, displayHandler); + }).when(handler).onNewInApp(any(IterableInAppMessage.class)); + IterableInAppManager inAppManager = createManagerWithHandler(displayerMock, handler); Robolectric.buildActivity(Activity.class).create().start().resume(); shadowOf(getMainLooper()).idle(); - ArgumentCaptor handlerCaptor = ArgumentCaptor.forClass(IterableInAppMessage.class); - verify(displayHandler).isAutoDisplayPaused(handlerCaptor.capture()); - assertEquals("7kx2MmoGdCpuZao9fDueuQoXVAZuDaVV", handlerCaptor.getValue().getMessageId()); - verify(displayerMock).showMessage(any(IterableInAppMessage.class), eq(IterableInAppLocation.IN_APP), any(IterableHelper.IterableUrlCallback.class)); + ArgumentCaptor shownCaptor = ArgumentCaptor.forClass(IterableInAppMessage.class); + verify(displayerMock).showMessage(shownCaptor.capture(), eq(IterableInAppLocation.IN_APP), any(IterableHelper.IterableUrlCallback.class)); + assertEquals("shownMessage", shownCaptor.getValue().getMessageId()); } @Test @@ -565,7 +552,6 @@ public void testJsonOnlyMessageDisplay() throws Exception { IterableInAppManager inAppManager = spy(new IterableInAppManager( IterableApi.sharedInstance, new IterableDefaultInAppHandler(), - null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), @@ -701,7 +687,6 @@ public void testJsonOnlyInAppMessageDelegateCallbacks() throws Exception { IterableInAppManager inAppManager = spy(new IterableInAppManager( IterableApi.sharedInstance, inAppHandler, - null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), @@ -856,7 +841,6 @@ public void testJsonOnlyInAppMessageProcessingAndDisplay() throws Exception { IterableInAppManager inAppManager = spy(new IterableInAppManager( IterableApi.sharedInstance, inAppHandler, - null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), @@ -931,7 +915,6 @@ public void testJsonOnlyMessageConsume() throws Exception { IterableInAppManager inAppManager = new IterableInAppManager( spyApi, new IterableDefaultInAppHandler(), - null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInboxTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInboxTest.java index 1a3fe176d..7b2b8de57 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInboxTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInboxTest.java @@ -213,7 +213,7 @@ public void testShowInboxMessageImmediate() throws Exception { IterableInAppDisplayer inAppDisplayerMock = mock(IterableInAppDisplayer.class); when(inAppDisplayerMock.showMessage(any(IterableInAppMessage.class), eq(IterableInAppLocation.IN_APP), any(IterableHelper.IterableUrlCallback.class))).thenReturn(true); - IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableDefaultInAppHandler(), null, 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), inAppDisplayerMock)); + IterableInAppManager inAppManager = spy(new IterableInAppManager(IterableApi.sharedInstance, new IterableDefaultInAppHandler(), 30.0, new IterableInAppMemoryStorage(), IterableActivityMonitor.getInstance(), inAppDisplayerMock)); IterableApi.sharedInstance = new IterableApi(inAppManager); IterableTestUtils.createIterableApiNew(new IterableTestUtils.ConfigBuilderExtender() { @Override From 183821c2f51567c3e312f38b0354fdc2fa18fc24 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Fri, 26 Jun 2026 09:50:33 +0100 Subject: [PATCH 4/4] SDK-487 Make resumeInAppDisplay no-op explainable Split processMessages guard into per-reason debug logs so any skipped display pass is explainable from logcat. Warn when resumeInAppDisplay() is called while auto display is paused, and document that the two controls are independent. Add a test covering the paused no-op. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 +-- .../iterableapi/IterableInAppManager.java | 21 ++++++++++++++- .../iterableapi/IterableInAppManagerTest.java | 26 +++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7263814b..ac0acdc43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added - Added a `DEFER` response to `IterableInAppHandler.InAppResponse`, returned from `onNewInApp(message)`. Unlike `SKIP` (which permanently drops the message), `DEFER` leaves the message pending so the SDK reconsiders it on a later display pass (next foreground, sync, or newly arrived message). Use it for temporary, per-message suppression — for example while a splash screen is showing. Existing handlers returning `SHOW`/`SKIP` are unaffected. -- Added `IterableInAppManager.resumeInAppDisplay()` so apps can prompt the SDK to re-evaluate pending in-app messages once they become ready to display (e.g. after a splash screen is dismissed), without waiting for the next foreground/sync trigger. +- Added `IterableInAppManager.resumeInAppDisplay()` so apps can prompt the SDK to re-evaluate pending in-app messages once they become ready to display (e.g. after a splash screen is dismissed), without waiting for the next foreground/sync trigger. This is independent of `setAutoDisplayPaused(boolean)`: if auto display is paused, `resumeInAppDisplay()` will not show anything (and logs a warning) until you also call `setAutoDisplayPaused(false)`. ### Migration guide **No action required.** Existing `IterableInAppHandler` implementations returning `SHOW`/`SKIP` are unaffected. @@ -20,7 +20,7 @@ new IterableConfig.Builder().setInAppHandler(message -> ).build(); ``` -Once ready, call `IterableApi.getInstance().getInAppManager().resumeInAppDisplay()` to re-check pending messages immediately instead of waiting for the next foreground/sync. +Once ready, call `IterableApi.getInstance().getInAppManager().resumeInAppDisplay()` to re-check pending messages immediately instead of waiting for the next foreground/sync. Note that `resumeInAppDisplay()` does not unpause auto display — if you previously called `setAutoDisplayPaused(true)`, call `setAutoDisplayPaused(false)` to resume. > **Kotlin:** add a `DEFER` branch to any exhaustive `when` over `InAppResponse`. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java index 97df65ccf..16991561c 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java @@ -166,8 +166,14 @@ public void setAutoDisplayPaused(boolean paused) { * only re-checks pending messages on its own triggers (foreground, sync, new message); calling * this prompts a re-check without one of those occurring. This does not change any stored state; * it triggers a single display attempt. + *

+ * This is independent of {@link #setAutoDisplayPaused(boolean)}: if auto display is paused, this + * call will not show anything until you also call {@code setAutoDisplayPaused(false)}. */ public void resumeInAppDisplay() { + if (isAutoDisplayPaused()) { + IterableLogger.w(TAG, "resumeInAppDisplay() ignored: auto display is paused. Call setAutoDisplayPaused(false) to resume."); + } scheduleProcessing(); } @@ -409,7 +415,20 @@ public int compare(IterableInAppMessage message1, IterableInAppMessage message2) } private void processMessages() { - if (!activityMonitor.isInForeground() || isShowingInApp() || !canShowInAppAfterPrevious() || isAutoDisplayPaused()) { + if (!activityMonitor.isInForeground()) { + IterableLogger.d(TAG, "processMessages skipped: app is not in foreground"); + return; + } + if (isShowingInApp()) { + IterableLogger.d(TAG, "processMessages skipped: an in-app is already showing"); + return; + } + if (!canShowInAppAfterPrevious()) { + IterableLogger.d(TAG, "processMessages skipped: within the in-app display interval"); + return; + } + if (isAutoDisplayPaused()) { + IterableLogger.d(TAG, "processMessages skipped: auto display is paused"); return; } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java index 04d8d1e26..8733087cf 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java @@ -459,6 +459,32 @@ public void testDeferThenShowDisplaysOnLaterPass() throws Exception { verify(displayerMock).showMessage(any(IterableInAppMessage.class), eq(IterableInAppLocation.IN_APP), any(IterableHelper.IterableUrlCallback.class)); } + @Test + public void testResumeInAppDisplayIsNoOpWhilePaused() throws Exception { + dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json"))); + IterableInAppManager inAppManager = IterableApi.getInstance().getInAppManager(); + + inAppManager.syncInApp(); + shadowOf(getMainLooper()).idle(); + assertEquals(1, inAppManager.getMessages().size()); + + inAppManager.setAutoDisplayPaused(true); + Robolectric.buildActivity(Activity.class).create().start().resume(); + shadowOf(getMainLooper()).idle(); + ArgumentCaptor inAppMessageCaptor = ArgumentCaptor.forClass(IterableInAppMessage.class); + verify(inAppHandler, times(0)).onNewInApp(inAppMessageCaptor.capture()); + + // resumeInAppDisplay() does not unpause: the message is still not offered while paused + inAppManager.resumeInAppDisplay(); + shadowOf(getMainLooper()).idle(); + verify(inAppHandler, times(0)).onNewInApp(inAppMessageCaptor.capture()); + + // Unpausing resumes display + inAppManager.setAutoDisplayPaused(false); + shadowOf(getMainLooper()).idle(); + verify(inAppHandler, times(1)).onNewInApp(inAppMessageCaptor.capture()); + } + @Test public void testDeferDoesNotBlockLowerPriorityMessage() throws Exception { JSONObject payload = new JSONObject(IterableTestUtils.getResourceString("inapp_payload_single.json"));