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 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")); + 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); + 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 IterableInAppHandler.InAppResponse answer(InvocationOnMock invocation) { + IterableInAppMessage message = invocation.getArgument(0); + return "deferredMessage".equals(message.getMessageId()) ? IterableInAppHandler.InAppResponse.DEFER : IterableInAppHandler.InAppResponse.SHOW; + } + }).when(handler).onNewInApp(any(IterableInAppMessage.class)); + IterableInAppManager inAppManager = createManagerWithHandler(displayerMock, handler); + + Robolectric.buildActivity(Activity.class).create().start().resume(); + shadowOf(getMainLooper()).idle(); + + 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 public void testMessagePersistentReadStateFromServer() throws Exception {