Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
* <p>
* 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.
* <p>
* 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.
Expand Down Expand Up @@ -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;
}

Expand All @@ -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()) {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<IterableInAppMessage> 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<IterableInAppHandler.InAppResponse>() {
@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<IterableInAppMessage> 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 {

Expand Down
Loading