diff --git a/CHANGELOG.md b/CHANGELOG.md index feeaefe65..144db50c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This project adheres to [Semantic Versioning](http://semver.org/). - 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)`. +- Added `IterableApi.getInAppManagerOrNull()` and `IterableApi.getEmbeddedManagerOrNull()`, which return `null` when the SDK has not been initialized instead of a no-op manager. Use these when you need to detect the uninitialized state explicitly. + +### Changed +- `IterableApi.getInAppManager()` and `IterableApi.getEmbeddedManager()` no longer throw a `RuntimeException` when called before `IterableApi.initialize()`. They now log an error and return a no-op manager (empty lists, ignored commands). To detect the uninitialized state explicitly, use the new `getInAppManagerOrNull()` / `getEmbeddedManagerOrNull()`. ### Migration guide **No action required.** Existing `IterableInAppHandler` implementations returning `SHOW`/`SKIP` are unaffected. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/EmptyEmbeddedManager.kt b/iterableapi/src/main/java/com/iterable/iterableapi/EmptyEmbeddedManager.kt new file mode 100644 index 000000000..9e52aa3a8 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/EmptyEmbeddedManager.kt @@ -0,0 +1,66 @@ +package com.iterable.iterableapi + +/** + * No-op [IterableEmbeddedManager] returned by [IterableApi.getEmbeddedManager] when the SDK has not + * been initialized. Every method is a benign no-op so callers never crash for a pre-initialization + * usage error. Use [IterableApi.getEmbeddedManagerOrNull] to detect this state explicitly. + */ +internal class EmptyEmbeddedManager : IterableEmbeddedManager() { + + private val emptySessionManager = EmbeddedSessionManager() + + private fun logNotInitialized(method: String) { + IterableLogger.e(LOG_TAG, "$method called before IterableApi was initialized; no-op. " + + "Call IterableApi.initialize() in Application#onCreate.") + } + + override fun addUpdateListener(updateHandler: IterableEmbeddedUpdateHandler) { + logNotInitialized("addUpdateListener()") + } + + override fun removeUpdateListener(updateHandler: IterableEmbeddedUpdateHandler) { + logNotInitialized("removeUpdateListener()") + } + + override fun getUpdateHandlers(): List { + logNotInitialized("getUpdateHandlers()") + return emptyList() + } + + override fun getEmbeddedSessionManager(): EmbeddedSessionManager { + logNotInitialized("getEmbeddedSessionManager()") + return emptySessionManager + } + + override fun getMessages(placementId: Long): List? { + logNotInitialized("getMessages()") + return null + } + + override fun reset() { + logNotInitialized("reset()") + } + + override fun getPlacementIds(): List { + logNotInitialized("getPlacementIds()") + return emptyList() + } + + override fun syncMessages(placementIds: Array) { + logNotInitialized("syncMessages()") + } + + override fun handleEmbeddedClick(message: IterableEmbeddedMessage, buttonIdentifier: String?, clickedUrl: String?) { + logNotInitialized("handleEmbeddedClick()") + } + + override fun onSwitchToForeground() { + } + + override fun onSwitchToBackground() { + } + + private companion object { + private const val LOG_TAG = "EmptyEmbeddedManager" + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/EmptyInAppManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/EmptyInAppManager.java new file mode 100644 index 000000000..2e65550d8 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/EmptyInAppManager.java @@ -0,0 +1,157 @@ +package com.iterable.iterableapi; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collections; +import java.util.List; + +/** + * No-op {@link IterableInAppManager} returned by {@link IterableApi#getInAppManager()} when the SDK + * has not been initialized. Every method is a benign no-op so callers never crash for a + * pre-initialization usage error. Use {@link IterableApi#getInAppManagerOrNull()} to detect this + * state explicitly. + */ +class EmptyInAppManager extends IterableInAppManager { + private static final String TAG = "EmptyInAppManager"; + + EmptyInAppManager() { + super(); + } + + private void logNotInitialized(String method) { + IterableLogger.e(TAG, method + " called before IterableApi was initialized; no-op. " + + "Call IterableApi.initialize() in Application#onCreate."); + } + + @NonNull + @Override + public synchronized List getMessages() { + logNotInitialized("getMessages()"); + return Collections.emptyList(); + } + + @Override + synchronized IterableInAppMessage getMessageById(String messageId) { + logNotInitialized("getMessageById()"); + return null; + } + + @NonNull + @Override + public synchronized List getInboxMessages() { + logNotInitialized("getInboxMessages()"); + return Collections.emptyList(); + } + + @Override + public synchronized int getUnreadInboxMessagesCount() { + logNotInitialized("getUnreadInboxMessagesCount()"); + return 0; + } + + @Override + public synchronized void setRead(@NonNull IterableInAppMessage message, boolean read) { + logNotInitialized("setRead()"); + } + + @Override + public synchronized void setRead(@NonNull IterableInAppMessage message, boolean read, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { + logNotInitialized("setRead()"); + if (failureHandler != null) { + failureHandler.onFailure("Iterable SDK is not initialized", null); + } + } + + @Override + public void setAutoDisplayPaused(boolean paused) { + logNotInitialized("setAutoDisplayPaused()"); + } + + @Override + public void resumeInAppDisplay() { + logNotInitialized("resumeInAppDisplay()"); + } + + @Override + void syncInApp() { + logNotInitialized("syncInApp()"); + } + + @Override + void reset() { + logNotInitialized("reset()"); + } + + @Override + public void showMessage(@NonNull IterableInAppMessage message) { + logNotInitialized("showMessage()"); + } + + @Override + public void showMessage(@NonNull IterableInAppMessage message, @NonNull IterableInAppLocation location) { + logNotInitialized("showMessage()"); + } + + @Override + public void showMessage(final @NonNull IterableInAppMessage message, boolean consume, final @Nullable IterableHelper.IterableUrlCallback clickCallback) { + logNotInitialized("showMessage()"); + } + + @Override + public void showMessage(final @NonNull IterableInAppMessage message, boolean consume, final @Nullable IterableHelper.IterableUrlCallback clickCallback, @NonNull IterableInAppLocation inAppLocation) { + logNotInitialized("showMessage()"); + } + + @Override + public synchronized void removeMessage(@NonNull IterableInAppMessage message) { + logNotInitialized("removeMessage()"); + } + + @Override + public synchronized void removeMessage(@NonNull IterableInAppMessage message, @NonNull IterableInAppDeleteActionType source, @NonNull IterableInAppLocation clickLocation) { + logNotInitialized("removeMessage()"); + } + + @Override + public synchronized void removeMessage(@NonNull IterableInAppMessage message, @Nullable IterableInAppDeleteActionType source, @Nullable IterableInAppLocation clickLocation, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { + logNotInitialized("removeMessage()"); + if (failureHandler != null) { + failureHandler.onFailure("Iterable SDK is not initialized", null); + } + } + + @Override + synchronized void removeMessage(String messageId) { + logNotInitialized("removeMessage()"); + } + + @Override + public void handleInAppClick(@NonNull IterableInAppMessage message, @Nullable Uri url) { + logNotInitialized("handleInAppClick()"); + } + + @Override + public void onSwitchToForeground() { + } + + @Override + public void onSwitchToBackground() { + } + + @Override + public void addListener(@NonNull Listener listener) { + logNotInitialized("addListener()"); + } + + @Override + public void removeListener(@NonNull Listener listener) { + logNotInitialized("removeListener()"); + } + + @Override + public void notifyOnChange() { + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index a49ab8286..95eb354ba 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -54,6 +54,8 @@ public class IterableApi { private @Nullable UnknownUserManager unknownUserManager; private @Nullable IterableInAppManager inAppManager; private @Nullable IterableEmbeddedManager embeddedManager; + private final IterableInAppManager emptyInAppManager = new EmptyInAppManager(); + private final IterableEmbeddedManager emptyEmbeddedManager = new EmptyEmbeddedManager(); private String inboxSessionId; private IterableAuthManager authManager; private ConcurrentHashMap deviceAttributes = new ConcurrentHashMap<>(); @@ -994,28 +996,45 @@ static void initializeForPush(@Nullable Context context) { //region SDK public functions /** - * Returns an {@link IterableInAppManager} that can be used to manage in-app messages. - * Make sure the Iterable API is initialized before calling this method. - * @return {@link IterableInAppManager} instance + * Returns an {@link IterableInAppManager} for managing in-app messages. If the SDK is not initialized, + * logs an error and returns a no-op manager instead of throwing; use {@link #getInAppManagerOrNull()} to detect that. */ @NonNull public IterableInAppManager getInAppManager() { if (inAppManager == null) { - throw new RuntimeException("IterableApi must be initialized before calling getInAppManager(). " + - "Make sure you call IterableApi#initialize() in Application#onCreate"); + IterableLogger.e(TAG, "getInAppManager() called before IterableApi was initialized; " + + "returning a no-op manager. Call IterableApi.initialize() in Application#onCreate."); + return emptyInAppManager; } return inAppManager; } + /** Like {@link #getInAppManager()} but returns {@code null} (never a no-op instance) if not initialized. */ + @Nullable + public IterableInAppManager getInAppManagerOrNull() { + return inAppManager; + } + + /** + * Returns an {@link IterableEmbeddedManager} for managing embedded messages. If the SDK is not initialized, + * logs an error and returns a no-op manager instead of throwing; use {@link #getEmbeddedManagerOrNull()} to detect that. + */ @NonNull public IterableEmbeddedManager getEmbeddedManager() { if (embeddedManager == null) { - throw new RuntimeException("IterableApi must be initialized before calling getEmbeddedManager(). " + - "Make sure you call IterableApi#initialize() in Application#onCreate"); + IterableLogger.e(TAG, "getEmbeddedManager() called before IterableApi was initialized; " + + "returning a no-op manager. Call IterableApi.initialize() in Application#onCreate."); + return emptyEmbeddedManager; } return embeddedManager; } + /** Like {@link #getEmbeddedManager()} but returns {@code null} (never a no-op instance) if not initialized. */ + @Nullable + public IterableEmbeddedManager getEmbeddedManagerOrNull() { + return embeddedManager; + } + /** * Returns the attribution information ({@link IterableAttributionInfo}) for last push open * or app link click from an email. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableEmbeddedManager.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableEmbeddedManager.kt index 1991cc6cb..60a00a987 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableEmbeddedManager.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableEmbeddedManager.kt @@ -4,7 +4,7 @@ import android.content.Context import org.json.JSONException import org.json.JSONObject -public class IterableEmbeddedManager : IterableActivityMonitor.AppStateCallback { +public open class IterableEmbeddedManager : IterableActivityMonitor.AppStateCallback { // region constants val TAG = "IterableEmbeddedManager" @@ -15,8 +15,8 @@ public class IterableEmbeddedManager : IterableActivityMonitor.AppStateCallback private var localPlacementIds = mutableListOf() private var updateHandleListeners = mutableListOf() - private var iterableApi: IterableApi - private var context: Context + private lateinit var iterableApi: IterableApi + private lateinit var context: Context private var embeddedSessionManager = EmbeddedSessionManager() @@ -38,27 +38,32 @@ public class IterableEmbeddedManager : IterableActivityMonitor.AppStateCallback } } + // No-op constructor for the pre-initialization stub (see EmptyEmbeddedManager). Leaves + // iterableApi/context uninitialized; EmptyEmbeddedManager overrides every method that + // would otherwise touch them. + internal constructor() + // endregion // region getters and setters //Add updateHandler to the list - public fun addUpdateListener(updateHandler: IterableEmbeddedUpdateHandler) { + public open fun addUpdateListener(updateHandler: IterableEmbeddedUpdateHandler) { updateHandleListeners.add(updateHandler) } //Remove updateHandler from the list - public fun removeUpdateListener(updateHandler: IterableEmbeddedUpdateHandler) { + public open fun removeUpdateListener(updateHandler: IterableEmbeddedUpdateHandler) { updateHandleListeners.remove(updateHandler) embeddedSessionManager.endSession() } //Get the list of updateHandlers - public fun getUpdateHandlers(): List { + public open fun getUpdateHandlers(): List { return updateHandleListeners } - public fun getEmbeddedSessionManager(): EmbeddedSessionManager { + public open fun getEmbeddedSessionManager(): EmbeddedSessionManager { return embeddedSessionManager } @@ -67,20 +72,20 @@ public class IterableEmbeddedManager : IterableActivityMonitor.AppStateCallback // region public methods //Gets the list of embedded messages in memory without syncing - fun getMessages(placementId: Long): List? { + open fun getMessages(placementId: Long): List? { return localPlacementMessagesMap[placementId] } - fun reset() { + open fun reset() { localPlacementMessagesMap = mutableMapOf() } - fun getPlacementIds(): List { + open fun getPlacementIds(): List { return localPlacementIds } @JvmOverloads - fun syncMessages(placementIds: Array = emptyArray()) { + open fun syncMessages(placementIds: Array = emptyArray()) { if (iterableApi.config.enableEmbeddedMessaging) { IterableLogger.v(TAG, "Syncing messages...") @@ -159,7 +164,7 @@ public class IterableEmbeddedManager : IterableActivityMonitor.AppStateCallback } } - fun handleEmbeddedClick(message: IterableEmbeddedMessage, buttonIdentifier: String?, clickedUrl: String?) { + open fun handleEmbeddedClick(message: IterableEmbeddedMessage, buttonIdentifier: String?, clickedUrl: String?) { if ((clickedUrl != null) && clickedUrl.toString().isNotEmpty()) { if (clickedUrl.startsWith(IterableConstants.URL_SCHEME_ACTION)) { // This is an action:// URL, pass that to the custom action handler diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java index 16991561c..496d892f3 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppManager.java @@ -79,6 +79,19 @@ public interface Listener { syncInApp(); } + // No-op constructor for the pre-initialization stub (see EmptyInAppManager). Leaves + // collaborators null and does no wiring; EmptyInAppManager overrides every method that + // would otherwise touch them. + IterableInAppManager() { + this.api = null; + this.context = null; + this.storage = null; + this.handler = null; + this.displayer = null; + this.activityMonitor = null; + this.inAppDisplayInterval = 0; + } + /** * Get the list of available in-app messages * This list is synchronized with the server by the SDK diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiPreInitTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiPreInitTest.java new file mode 100644 index 000000000..b5667332b --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiPreInitTest.java @@ -0,0 +1,66 @@ +package com.iterable.iterableapi; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * Verifies that the manager getters fail gracefully (no-op stub instead of RuntimeException) when + * the SDK has not been initialized. See SDK-417. + */ +public class IterableApiPreInitTest extends BaseTest { + + @Test + public void getInAppManager_beforeInit_returnsNoOpInsteadOfThrowing() { + IterableInAppManager manager = IterableApi.getInstance().getInAppManager(); + + assertNotNull(manager); + assertTrue(manager instanceof EmptyInAppManager); + } + + @Test + public void getInAppManager_noOp_returnsEmptyDefaults() { + IterableInAppManager manager = IterableApi.getInstance().getInAppManager(); + + assertTrue(manager.getMessages().isEmpty()); + assertTrue(manager.getInboxMessages().isEmpty()); + assertNull(manager.getMessageById("anyId")); + } + + @Test + public void getInAppManagerOrNull_beforeInit_returnsNull() { + assertNull(IterableApi.getInstance().getInAppManagerOrNull()); + } + + @Test + public void getInAppManager_beforeInit_returnsStableInstance() { + IterableApi api = IterableApi.getInstance(); + assertSame(api.getInAppManager(), api.getInAppManager()); + } + + @Test + public void getEmbeddedManager_beforeInit_returnsNoOpInsteadOfThrowing() { + IterableEmbeddedManager manager = IterableApi.getInstance().getEmbeddedManager(); + + assertNotNull(manager); + assertTrue(manager instanceof EmptyEmbeddedManager); + } + + @Test + public void getEmbeddedManager_noOp_returnsEmptyDefaults() { + IterableEmbeddedManager manager = IterableApi.getInstance().getEmbeddedManager(); + + assertNull(manager.getMessages(0L)); + assertTrue(manager.getPlacementIds().isEmpty()); + assertTrue(manager.getUpdateHandlers().isEmpty()); + assertNotNull(manager.getEmbeddedSessionManager()); + } + + @Test + public void getEmbeddedManagerOrNull_beforeInit_returnsNull() { + assertNull(IterableApi.getInstance().getEmbeddedManagerOrNull()); + } +}