diff --git a/CHANGELOG.md b/CHANGELOG.md index 06c62a943..5d94910f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added - Notification small-icon resolution now falls back through standard conventions — the Firebase `com.google.firebase.messaging.default_notification_icon` meta-data, `@drawable/notification_icon` (Expo / React Native), and `@drawable/ic_notification` — before defaulting to the app launcher icon. This fixes white-square notification icons on Android 5.0+ for apps that configure their icon through these conventions but don't set `iterable_notification_icon`. +- 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. 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. + +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. 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`. ## [3.9.0] ### Added 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 288a66fc5..16991561c 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java @@ -145,10 +145,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. @@ -160,6 +156,27 @@ 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 — 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. + *
+ * 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();
+ }
+
/**
* 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.
@@ -398,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;
}
@@ -412,6 +442,12 @@ private void processMessages() {
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()) {
@@ -462,6 +498,10 @@ private boolean canShowInAppAfterPrevious() {
return getSecondsSinceLastInApp() >= inAppDisplayInterval;
}
+ boolean isAutoDisplayPaused() {
+ return 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/IterableInAppManagerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java
index 2a5902978..8733087cf 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")
@@ -398,6 +402,118 @@ public void testInAppAutoDisplayPause() throws Exception {
verify(inAppHandler, times(1)).onNewInApp(inAppMessageCaptor.capture());
}
+ private IterableInAppManager createManagerWithHandler(IterableInAppDisplayer displayer, IterableInAppHandler handler) {
+ IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext());
+ IterableActivityMonitor.instance = new IterableActivityMonitor();
+ 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
+ public IterableConfig.Builder run(IterableConfig.Builder builder) {
+ return builder.setCustomActionHandler(customActionHandler).setUrlHandler(urlHandler);
+ }
+ });
+ shadowOf(getMainLooper()).idle();
+ return inAppManager;
+ }
+
+ @Test
+ public void testDeferLeavesMessagePendingWithoutConsuming() throws Exception {
+ dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json")));
+
+ IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class);
+ 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(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());
+ assertFalse(inAppManager.getMessages().get(0).isProcessed());
+ assertFalse(inAppManager.getMessages().get(0).isConsumed());
+ }
+
+ @Test
+ public void testDeferThenShowDisplaysOnLaterPass() throws Exception {
+ dispatcher.enqueueResponse("/inApp/getMessages", new MockResponse().setBody(IterableTestUtils.getResourceString("inapp_payload_single.json")));
+
+ IterableInAppDisplayer displayerMock = mock(IterableInAppDisplayer.class);
+ 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);
+
+ // 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));
+
+ // 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 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