From f5352e040248191afd762bf800e70b46fe454a78 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Thu, 15 Jan 2026 15:15:23 +0000 Subject: [PATCH 01/17] Support for in app in full compose apps --- .../iterableapi/InAppAnimationService.java | 147 ++++++++ .../iterableapi/InAppLayoutService.java | 141 +++++++ .../iterableapi/InAppOrientationService.java | 101 +++++ .../iterable/iterableapi/InAppServices.java | 41 ++ .../iterableapi/InAppTrackingService.java | 131 +++++++ .../iterableapi/InAppWebViewService.java | 141 +++++++ .../IterableInAppDialogNotification.kt | 356 ++++++++++++++++++ .../iterableapi/IterableInAppDisplayer.java | 85 ++++- .../iterable/iterableapi/IterableWebView.java | 2 +- 9 files changed, 1130 insertions(+), 15 deletions(-) create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppOrientationService.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppWebViewService.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.java b/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.java new file mode 100644 index 000000000..2ae9b1548 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.java @@ -0,0 +1,147 @@ +package com.iterable.iterableapi; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.view.View; +import android.view.Window; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.graphics.ColorUtils; + +/** + * Service class for in-app message animations. + * Centralizes animation logic shared between Fragment and Dialog implementations. + */ +class InAppAnimationService { + + private static final int ANIMATION_DURATION_MS = 300; + private static final String TAG = "InAppAnimService"; + + /** + * Creates a background drawable with the specified color and alpha + * @param hexColor The background color in hex format (e.g., "#000000") + * @param alpha The alpha value (0.0 to 1.0) + * @return ColorDrawable with the specified color and alpha, or null if parsing fails + */ + @Nullable + public ColorDrawable createInAppBackgroundDrawable(@Nullable String hexColor, double alpha) { + int backgroundColor; + + try { + if (hexColor != null && !hexColor.isEmpty()) { + backgroundColor = Color.parseColor(hexColor); + } else { + backgroundColor = Color.BLACK; + } + } catch (IllegalArgumentException e) { + IterableLogger.w(TAG, "Invalid background color: " + hexColor + ". Using BLACK.", e); + backgroundColor = Color.BLACK; + } + + int backgroundWithAlpha = ColorUtils.setAlphaComponent( + backgroundColor, + (int) (alpha * 255) + ); + + return new ColorDrawable(backgroundWithAlpha); + } + + /** + * Animates the window background from one drawable to another + * @param window The window to animate + * @param from The starting drawable + * @param to The ending drawable + * @param shouldAnimate If false, sets the background immediately without animation + */ + public void animateWindowBackground(@NonNull Window window, @NonNull Drawable from, @NonNull Drawable to, boolean shouldAnimate) { + if (shouldAnimate) { + Drawable[] layers = new Drawable[]{from, to}; + TransitionDrawable transition = new TransitionDrawable(layers); + window.setBackgroundDrawable(transition); + transition.startTransition(ANIMATION_DURATION_MS); + } else { + window.setBackgroundDrawable(to); + } + } + + /** + * Shows the in-app background with optional fade-in animation + * @param window The window to set the background on + * @param hexColor The background color in hex format + * @param alpha The background alpha (0.0 to 1.0) + * @param shouldAnimate Whether to animate the background fade-in + */ + public void showInAppBackground(@NonNull Window window, @Nullable String hexColor, double alpha, boolean shouldAnimate) { + ColorDrawable backgroundDrawable = createInAppBackgroundDrawable(hexColor, alpha); + + if (backgroundDrawable == null) { + IterableLogger.w(TAG, "Failed to create background drawable"); + return; + } + + if (shouldAnimate) { + // Animate from transparent to the target background + ColorDrawable transparentDrawable = new ColorDrawable(Color.TRANSPARENT); + animateWindowBackground(window, transparentDrawable, backgroundDrawable, true); + } else { + window.setBackgroundDrawable(backgroundDrawable); + } + } + + /** + * Shows and optionally animates a WebView + * @param webView The WebView to show + * @param shouldAnimate Whether to animate the appearance + * @param context Context for loading animation resources (only needed if shouldAnimate is true) + */ + public void showAndAnimateWebView(@NonNull View webView, boolean shouldAnimate, @Nullable Context context) { + if (shouldAnimate && context != null) { + // Animate with alpha fade-in + webView.setAlpha(0f); + webView.setVisibility(View.VISIBLE); + webView.animate() + .alpha(1.0f) + .setDuration(ANIMATION_DURATION_MS) + .start(); + } else { + // Show immediately + webView.setAlpha(1.0f); + webView.setVisibility(View.VISIBLE); + } + } + + /** + * Hides the in-app background with optional fade-out animation + * @param window The window to modify + * @param hexColor The current background color + * @param alpha The current background alpha + * @param shouldAnimate Whether to animate the background fade-out + */ + public void hideInAppBackground(@NonNull Window window, @Nullable String hexColor, double alpha, boolean shouldAnimate) { + if (shouldAnimate) { + ColorDrawable backgroundDrawable = createInAppBackgroundDrawable(hexColor, alpha); + ColorDrawable transparentDrawable = new ColorDrawable(Color.TRANSPARENT); + + if (backgroundDrawable != null) { + animateWindowBackground(window, backgroundDrawable, transparentDrawable, true); + } + } else { + window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + } + } + + /** + * Prepares a view to be shown by hiding it initially + * This is typically called before the resize operation + * @param view The view to hide + */ + public void prepareViewForDisplay(@NonNull View view) { + view.setAlpha(0f); + view.setVisibility(View.INVISIBLE); + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.java b/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.java new file mode 100644 index 000000000..0cbf22a89 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.java @@ -0,0 +1,141 @@ +package com.iterable.iterableapi; + +import android.graphics.Rect; +import android.view.Gravity; +import android.view.Window; +import android.view.WindowManager; + +import androidx.annotation.NonNull; + +/** + * Service class for in-app message layout calculations and window configuration. + * Centralizes layout detection logic shared between Fragment and Dialog implementations. + */ +class InAppLayoutService { + + /** + * Layout types for in-app messages based on padding configuration + */ + enum InAppLayout { + TOP, + BOTTOM, + CENTER, + FULLSCREEN + } + + /** + * Determines the layout type based on inset padding + * @param padding The inset padding (top/bottom) that defines the layout + * @return The corresponding InAppLayout type + */ + @NonNull + public InAppLayout getInAppLayout(@NonNull Rect padding) { + if (padding.top == 0 && padding.bottom == 0) { + return InAppLayout.FULLSCREEN; + } else if (padding.top > 0 && padding.bottom <= 0) { + return InAppLayout.TOP; + } else if (padding.top <= 0 && padding.bottom > 0) { + return InAppLayout.BOTTOM; + } else { + return InAppLayout.CENTER; + } + } + + /** + * Gets the vertical gravity for positioning based on padding + * @param padding The inset padding that defines positioning + * @return Gravity constant (TOP, BOTTOM, or CENTER_VERTICAL) + */ + public int getVerticalLocation(@NonNull Rect padding) { + InAppLayout layout = getInAppLayout(padding); + + switch (layout) { + case TOP: + return Gravity.TOP; + case BOTTOM: + return Gravity.BOTTOM; + case CENTER: + return Gravity.CENTER_VERTICAL; + case FULLSCREEN: + default: + return Gravity.CENTER_VERTICAL; + } + } + + /** + * Configures window flags based on layout type + * @param window The window to configure + * @param layout The layout type + */ + public void configureWindowFlags(Window window, @NonNull InAppLayout layout) { + if (window == null) { + return; + } + + if (layout == InAppLayout.FULLSCREEN) { + // Fullscreen: hide status bar + window.setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ); + } else if (layout != InAppLayout.TOP) { + // BOTTOM and CENTER: translucent status bar + // TOP layout keeps status bar opaque (no flags needed) + window.setFlags( + WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, + WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS + ); + } + } + + /** + * Sets window size to fill the screen + * This is necessary for both fullscreen and positioned layouts + * @param window The window to configure + */ + public void setWindowToFullScreen(Window window) { + if (window != null) { + window.setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT + ); + } + } + + /** + * Applies window gravity for positioned layouts (non-fullscreen) + * @param window The window to configure + * @param padding The inset padding + * @param source Debug string indicating where this is called from + */ + public void applyWindowGravity(Window window, @NonNull Rect padding, String source) { + if (window == null) { + return; + } + + int verticalGravity = getVerticalLocation(padding); + WindowManager.LayoutParams params = window.getAttributes(); + + switch (verticalGravity) { + case Gravity.CENTER_VERTICAL: + params.gravity = Gravity.CENTER; + break; + case Gravity.TOP: + params.gravity = Gravity.TOP | Gravity.CENTER_HORIZONTAL; + break; + case Gravity.BOTTOM: + params.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; + break; + default: + params.gravity = Gravity.CENTER; + break; + } + + window.setAttributes(params); + + if (source != null) { + IterableLogger.d("InAppLayoutService", "Applied window gravity from " + source + ": " + params.gravity); + } + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppOrientationService.java b/iterableapi/src/main/java/com/iterable/iterableapi/InAppOrientationService.java new file mode 100644 index 000000000..d32814129 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppOrientationService.java @@ -0,0 +1,101 @@ +package com.iterable.iterableapi; + +import android.content.Context; +import android.hardware.SensorManager; +import android.os.Handler; +import android.os.Looper; +import android.view.OrientationEventListener; + +import androidx.annotation.NonNull; + +/** + * Service class for handling device orientation changes in in-app messages. + * Centralizes orientation detection logic shared between Fragment and Dialog implementations. + */ +class InAppOrientationService { + + private static final String TAG = "InAppOrientService"; + private static final long ORIENTATION_CHANGE_DELAY_MS = 1500; + + /** + * Callback interface for orientation change events + */ + interface OrientationChangeCallback { + /** + * Called when the device orientation has changed + */ + void onOrientationChanged(); + } + + /** + * Creates an OrientationEventListener that detects 90-degree rotations + * @param context The context for sensor access + * @param callback The callback to invoke when orientation changes + * @return Configured OrientationEventListener (caller must enable it) + */ + @NonNull + public OrientationEventListener createOrientationListener( + @NonNull Context context, + @NonNull final OrientationChangeCallback callback) { + + return new OrientationEventListener(context, SensorManager.SENSOR_DELAY_NORMAL) { + private int lastOrientation = -1; + + @Override + public void onOrientationChanged(int orientation) { + int currentOrientation = roundToNearest90Degrees(orientation); + + // Only trigger callback if orientation actually changed + if (currentOrientation != lastOrientation && lastOrientation != -1) { + lastOrientation = currentOrientation; + + // Delay the callback to allow orientation change to stabilize + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + IterableLogger.d(TAG, "Orientation changed, triggering callback"); + callback.onOrientationChanged(); + } + }, ORIENTATION_CHANGE_DELAY_MS); + } else if (lastOrientation == -1) { + // Initialize last orientation + lastOrientation = currentOrientation; + } + } + }; + } + + /** + * Rounds an orientation value to the nearest 90-degree increment + * @param orientation The raw orientation value (0-359 degrees) + * @return The nearest 90-degree value (0, 90, 180, or 270) + */ + public int roundToNearest90Degrees(int orientation) { + return ((orientation + 45) / 90 * 90) % 360; + } + + /** + * Safely enables an OrientationEventListener + * @param listener The listener to enable (nullable) + */ + public void enableListener(OrientationEventListener listener) { + if (listener != null && listener.canDetectOrientation()) { + listener.enable(); + IterableLogger.d(TAG, "Orientation listener enabled"); + } else { + IterableLogger.w(TAG, "Cannot enable orientation listener"); + } + } + + /** + * Safely disables an OrientationEventListener + * @param listener The listener to disable (nullable) + */ + public void disableListener(OrientationEventListener listener) { + if (listener != null) { + listener.disable(); + IterableLogger.d(TAG, "Orientation listener disabled"); + } + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.java b/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.java new file mode 100644 index 000000000..602506421 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.java @@ -0,0 +1,41 @@ +package com.iterable.iterableapi; + +/** + * Central access point for all in-app message services. + * Provides singleton instances of each service for convenient access. + */ +final class InAppServices { + + /** + * Layout detection and window configuration service + */ + public static final InAppLayoutService layout = new InAppLayoutService(); + + /** + * Animation and visual effects service + */ + public static final InAppAnimationService animation = new InAppAnimationService(); + + /** + * Event tracking and analytics service + */ + public static final InAppTrackingService tracking = new InAppTrackingService(); + + /** + * WebView creation and management service + */ + public static final InAppWebViewService webView = new InAppWebViewService(); + + /** + * Orientation change detection service + */ + public static final InAppOrientationService orientation = new InAppOrientationService(); + + /** + * Private constructor to prevent instantiation + */ + private InAppServices() { + throw new UnsupportedOperationException("InAppServices is a static utility class and cannot be instantiated"); + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.java b/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.java new file mode 100644 index 000000000..4428e537b --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.java @@ -0,0 +1,131 @@ +package com.iterable.iterableapi; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Service class for in-app message event tracking. + * Centralizes tracking logic shared between Fragment and Dialog implementations. + * Provides null-safe wrappers around IterableApi tracking methods. + */ +class InAppTrackingService { + + private static final String TAG = "InAppTrackingService"; + + /** + * Tracks when an in-app message is opened + * @param messageId The message ID + * @param location The location where the message was triggered (nullable, defaults to IN_APP) + */ + public void trackInAppOpen(@NonNull String messageId, @Nullable IterableInAppLocation location) { + IterableInAppLocation loc = location != null ? location : IterableInAppLocation.IN_APP; + + IterableApi api = IterableApi.sharedInstance; + if (api != null) { + api.trackInAppOpen(messageId, loc); + IterableLogger.d(TAG, "Tracked in-app open: " + messageId + " at location: " + loc); + } else { + IterableLogger.w(TAG, "Cannot track in-app open: IterableApi not initialized"); + } + } + + /** + * Tracks when a user clicks on an in-app message + * @param messageId The message ID + * @param url The URL that was clicked (or special identifier like itbl://backButton) + * @param location The location where the click occurred (nullable, defaults to IN_APP) + */ + public void trackInAppClick(@NonNull String messageId, @NonNull String url, @Nullable IterableInAppLocation location) { + IterableInAppLocation loc = location != null ? location : IterableInAppLocation.IN_APP; + + IterableApi api = IterableApi.sharedInstance; + if (api != null) { + api.trackInAppClick(messageId, url, loc); + IterableLogger.d(TAG, "Tracked in-app click: " + messageId + " url: " + url + " at location: " + loc); + } else { + IterableLogger.w(TAG, "Cannot track in-app click: IterableApi not initialized"); + } + } + + /** + * Tracks when an in-app message is closed + * @param messageId The message ID + * @param url The URL associated with the close action (or special identifier) + * @param closeAction The type of close action (LINK, BACK, etc.) + * @param location The location where the close occurred (nullable, defaults to IN_APP) + */ + public void trackInAppClose(@NonNull String messageId, @NonNull String url, @NonNull IterableInAppCloseAction closeAction, @Nullable IterableInAppLocation location) { + IterableInAppLocation loc = location != null ? location : IterableInAppLocation.IN_APP; + + IterableApi api = IterableApi.sharedInstance; + if (api != null) { + api.trackInAppClose(messageId, url, closeAction, loc); + IterableLogger.d(TAG, "Tracked in-app close: " + messageId + " action: " + closeAction + " at location: " + loc); + } else { + IterableLogger.w(TAG, "Cannot track in-app close: IterableApi not initialized"); + } + } + + /** + * Removes a message from the in-app queue after it has been displayed or dismissed + * @param messageId The message ID to remove + * @param location The location where the removal occurred (nullable, defaults to IN_APP) + */ + public void removeMessage(@NonNull String messageId, @Nullable IterableInAppLocation location) { + IterableInAppLocation loc = location != null ? location : IterableInAppLocation.IN_APP; + + IterableApi api = IterableApi.sharedInstance; + if (api == null) { + IterableLogger.w(TAG, "Cannot remove message: IterableApi not initialized"); + return; + } + + IterableInAppManager inAppManager = api.getInAppManager(); + if (inAppManager == null) { + IterableLogger.w(TAG, "Cannot remove message: InAppManager not available"); + return; + } + + // Find the message by ID + IterableInAppMessage message = null; + if (inAppManager.getMessages() != null) { + for (IterableInAppMessage msg : inAppManager.getMessages()) { + if (msg != null && messageId.equals(msg.getMessageId())) { + message = msg; + break; + } + } + } + + if (message != null) { + // Remove with proper parameters (message, deleteType, location) + inAppManager.removeMessage( + message, + IterableInAppDeleteActionType.INBOX_SWIPE, + loc + ); + IterableLogger.d(TAG, "Removed message: " + messageId + " at location: " + loc); + } else { + IterableLogger.w(TAG, "Message not found for removal: " + messageId); + } + } + + /** + * Tracks a screen view event (useful for analytics) + * @param screenName The name of the screen being viewed + */ + public void trackScreenView(@NonNull String screenName) { + IterableApi api = IterableApi.sharedInstance; + if (api != null) { + try { + org.json.JSONObject data = new org.json.JSONObject(); + data.put("screenName", screenName); + api.track("Screen Viewed", data); + IterableLogger.d(TAG, "Tracked screen view: " + screenName); + } catch (org.json.JSONException e) { + IterableLogger.w(TAG, "Failed to track screen view", e); + } + } + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppWebViewService.java b/iterableapi/src/main/java/com/iterable/iterableapi/InAppWebViewService.java new file mode 100644 index 000000000..2c058e5ce --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppWebViewService.java @@ -0,0 +1,141 @@ +package com.iterable.iterableapi; + +import android.content.Context; +import android.widget.FrameLayout; +import android.widget.RelativeLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Service class for in-app message WebView management. + * Centralizes WebView creation and configuration logic shared between + * Fragment and Dialog implementations. + */ +class InAppWebViewService { + + private static final String TAG = "InAppWebViewService"; + + /** + * Creates and configures a WebView for in-app message display + * @param context The context for WebView creation + * @param callbacks The callback interface for WebView events + * @param htmlContent The HTML content to load + * @return Configured IterableWebView + */ + @NonNull + public IterableWebView createConfiguredWebView( + @NonNull Context context, + @NonNull IterableWebView.HTMLNotificationCallbacks callbacks, + @NonNull String htmlContent) { + + IterableWebView webView = new IterableWebView(context); + webView.setId(R.id.webView); + webView.createWithHtml(callbacks, htmlContent); + + IterableLogger.d(TAG, "Created and configured WebView with HTML content"); + return webView; + } + + /** + * Creates layout parameters for WebView based on layout type + * @param isFullScreen Whether this is a fullscreen in-app message + * @return Appropriate LayoutParams for the WebView + */ + @NonNull + public FrameLayout.LayoutParams createWebViewLayoutParams(boolean isFullScreen) { + if (isFullScreen) { + // Fullscreen: WebView fills entire container + return new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ); + } else { + // Non-fullscreen: WebView wraps content for proper sizing + return new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ); + } + } + + /** + * Creates layout parameters for WebView container (RelativeLayout) in positioned layouts + * @return RelativeLayout.LayoutParams for WebView centering + */ + @NonNull + public RelativeLayout.LayoutParams createCenteredWebViewParams() { + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT + ); + params.addRule(RelativeLayout.CENTER_IN_PARENT); + return params; + } + + /** + * Creates layout parameters for the WebView container based on layout type + * @param layout The layout type (TOP, BOTTOM, CENTER, FULLSCREEN) + * @return FrameLayout.LayoutParams with appropriate gravity + */ + @NonNull + public FrameLayout.LayoutParams createContainerLayoutParams(@NonNull InAppLayoutService.InAppLayout layout) { + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ); + + switch (layout) { + case TOP: + params.gravity = android.view.Gravity.TOP | android.view.Gravity.CENTER_HORIZONTAL; + break; + case BOTTOM: + params.gravity = android.view.Gravity.BOTTOM | android.view.Gravity.CENTER_HORIZONTAL; + break; + case CENTER: + params.gravity = android.view.Gravity.CENTER; + break; + case FULLSCREEN: + // Fullscreen doesn't use container positioning + params = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ); + break; + } + + return params; + } + + /** + * Properly cleans up and destroys a WebView + * @param webView The WebView to clean up (nullable) + */ + public void cleanupWebView(@Nullable IterableWebView webView) { + if (webView != null) { + try { + webView.destroy(); + IterableLogger.d(TAG, "WebView cleaned up and destroyed"); + } catch (Exception e) { + IterableLogger.w(TAG, "Error cleaning up WebView", e); + } + } + } + + /** + * Triggers the resize script on the WebView + * This is typically called after orientation changes or content updates + * @param webView The WebView to resize + */ + public void runResizeScript(@Nullable IterableWebView webView) { + if (webView != null) { + try { + webView.evaluateJavascript("window.resize()", null); + IterableLogger.d(TAG, "Triggered WebView resize script"); + } catch (Exception e) { + IterableLogger.w(TAG, "Error running resize script", e); + } + } + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt new file mode 100644 index 000000000..1bc401306 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -0,0 +1,356 @@ +package com.iterable.iterableapi + +import android.app.Activity +import android.app.Dialog +import android.graphics.Color +import android.graphics.Rect +import android.net.Uri +import android.os.Bundle +import android.view.KeyEvent +import android.view.OrientationEventListener +import android.view.View +import android.view.Window +import android.widget.FrameLayout +import android.widget.RelativeLayout +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.core.graphics.drawable.toDrawable +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +/** + * Dialog-based In-App notification for [androidx.activity.ComponentActivity] (Compose) support + * + * This class provides the same functionality as [IterableInAppFragmentHTMLNotification] + * but works with [androidx.activity.ComponentActivity] instead of requiring [androidx.fragment.app.FragmentActivity]. + */ +class IterableInAppDialogNotification private constructor( + activity: Activity, + private val htmlString: String?, + private val callbackOnCancel: Boolean, + private val messageId: String, + private val backgroundAlpha: Double, + private val insetPadding: Rect, + private val shouldAnimate: Boolean, + private val inAppBackgroundAlpha: Double, + private val inAppBackgroundColor: String?, + private val layoutService: InAppLayoutService = InAppServices.layout, + private val animationService: InAppAnimationService = InAppServices.animation, + private val trackingService: InAppTrackingService = InAppServices.tracking, + private val webViewService: InAppWebViewService = InAppServices.webView, + private val orientationService: InAppOrientationService = InAppServices.orientation +) : Dialog(activity), IterableWebView.HTMLNotificationCallbacks { + + private var webView: IterableWebView? = null + private var loaded: Boolean = false + private var orientationListener: OrientationEventListener? = null + private var backPressedCallback: OnBackPressedCallback? = null + + companion object { + private const val TAG = "IterableInAppDialog" + private const val BACK_BUTTON = "itbl://backButton" + private const val DELAY_THRESHOLD_MS = 500L + + @JvmStatic + private var notification: IterableInAppDialogNotification? = null + + @JvmStatic + private var clickCallback: IterableHelper.IterableUrlCallback? = null + + @JvmStatic + private var location: IterableInAppLocation? = null + + /** + * Factory method to create a new dialog instance + */ + @JvmStatic + @JvmOverloads + fun createInstance( + activity: Activity, + htmlString: String, + callbackOnCancel: Boolean, + urlCallback: IterableHelper.IterableUrlCallback, + inAppLocation: IterableInAppLocation, + messageId: String, + backgroundAlpha: Double, + padding: Rect, + animate: Boolean = false, + inAppBgColor: IterableInAppMessage.InAppBgColor = + IterableInAppMessage.InAppBgColor(null, 0.0) + ): IterableInAppDialogNotification { + clickCallback = urlCallback + location = inAppLocation + + notification = IterableInAppDialogNotification( + activity, + htmlString, + callbackOnCancel, + messageId, + backgroundAlpha, + padding, + animate, + inAppBgColor.bgAlpha, + inAppBgColor.bgHexColor + ) + + return notification!! + } + + /** + * Returns the notification instance currently being shown + * + * @return notification instance + */ + @JvmStatic + fun getInstance(): IterableInAppDialogNotification? = notification + } + + // Lifecycle and Setup + override fun onStart() { + super.onStart() + + // Set window to fullscreen using service + window?.let { layoutService.setWindowToFullScreen(it) } + + // Apply gravity for non-fullscreen layouts + val layout = layoutService.getInAppLayout(insetPadding) + if (layout != InAppLayoutService.InAppLayout.FULLSCREEN) { + window?.let { layoutService.applyWindowGravity(it, insetPadding, "onStart") } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Configure dialog window + requestWindowFeature(Window.FEATURE_NO_TITLE) + window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) + + // Configure based on layout type using service + val layout = layoutService.getInAppLayout(insetPadding) + window?.let { layoutService.configureWindowFlags(it, layout) } + + // Apply gravity for non-fullscreen layouts + if (layout != InAppLayoutService.InAppLayout.FULLSCREEN) { + window?.let { layoutService.applyWindowGravity(it, insetPadding, "onCreate") } + } + + // Setup cancel listener + setOnCancelListener { + if (callbackOnCancel && clickCallback != null) { + clickCallback?.execute(null) + } + } + + // Setup back press handling + setupBackPressHandling() + + // Create the view hierarchy + val contentView = createContentView() + setContentView(contentView) + + // Setup orientation listener + setupOrientationListener() + + // Track open event using service + trackingService.trackInAppOpen(messageId, location) + + // Prepare to show with animation + prepareToShowWebView() + } + + override fun dismiss() { + // Clean up back press callback + backPressedCallback?.remove() + backPressedCallback = null + + // Clean up orientation listener using service + orientationService.disableListener(orientationListener) + orientationListener = null + + // Clean up webview using service + webViewService.cleanupWebView(webView) + webView = null + + // Clear singleton + notification = null + + super.dismiss() + } + + private fun setupBackPressHandling() { + val activity = ownerActivity ?: context as? ComponentActivity + + if (activity is ComponentActivity) { + backPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + trackingService.trackInAppClick(messageId, BACK_BUTTON, location) + trackingService.trackInAppClose( + messageId, + BACK_BUTTON, + IterableInAppCloseAction.BACK, + location + ) + + // Process message removal + processMessageRemoval() + + // Dismiss the dialog + dismiss() + } + } + + activity.onBackPressedDispatcher.addCallback(activity, backPressedCallback!!) + IterableLogger.d(TAG, "dialog notification back press handler registered") + } else { + // Fallback to legacy key listener for non-ComponentActivity + IterableLogger.w(TAG, "Activity is not ComponentActivity, using legacy back press handling") + setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + // Track back button using service + trackingService.trackInAppClick(messageId, BACK_BUTTON, location) + trackingService.trackInAppClose( + messageId, + BACK_BUTTON, + IterableInAppCloseAction.BACK, + location + ) + + // Process message removal + processMessageRemoval() + + // Dismiss the dialog + dismiss() + true + } else { + false + } + } + } + } + + // View Creation + + private fun createContentView(): View { + val context = context + + // Create WebView using service + webView = webViewService.createConfiguredWebView( + context, + this@IterableInAppDialogNotification, + htmlString ?: "" + ) + + // Create container based on layout type using service + val frameLayout = FrameLayout(context) + val layout = layoutService.getInAppLayout(insetPadding) + val isFullScreen = layout == InAppLayoutService.InAppLayout.FULLSCREEN + + if (isFullScreen) { + // Fullscreen: WebView fills entire dialog + val params = webViewService.createWebViewLayoutParams(true) + frameLayout.addView(webView, params) + } else { + // Non-fullscreen: WebView in positioned container + val webViewContainer = RelativeLayout(context) + + // Container positioning using service + val containerParams = webViewService.createContainerLayoutParams(layout) + + // WebView centering using service + val webViewParams = webViewService.createCenteredWebViewParams() + + webViewContainer.addView(webView, webViewParams) + frameLayout.addView(webViewContainer, containerParams) + + // Apply window insets for system bars + ViewCompat.setOnApplyWindowInsetsListener(frameLayout) { v, insets -> + val sysBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(0, sysBars.top, 0, sysBars.bottom) + insets + } + } + + return frameLayout + } + + private fun setupOrientationListener() { + // Create orientation listener using service + orientationListener = orientationService.createOrientationListener(context) { + if (loaded && webView != null) { + webViewService.runResizeScript(webView) + } + } + orientationService.enableListener(orientationListener) + } + + private fun prepareToShowWebView() { + try { + webView?.let { animationService.prepareViewForDisplay(it) } + webView?.postDelayed({ + if (context != null && window != null) { + showInAppBackground() + showAndAnimateWebView() + } + }, DELAY_THRESHOLD_MS) + } catch (e: NullPointerException) { + IterableLogger.e(TAG, "View not present. Failed to hide before resizing inapp", e) + } + } + + private fun showInAppBackground() { + window?.let { w -> + animationService.showInAppBackground( + w, + inAppBackgroundColor, + inAppBackgroundAlpha, + shouldAnimate + ) + } + } + + private fun showAndAnimateWebView() { + webView?.let { wv -> + animationService.showAndAnimateWebView(wv, shouldAnimate, context) + } + } + + // WebView Callbacks + + override fun setLoaded(loaded: Boolean) { + this.loaded = loaded + } + + override fun runResizeScript() { + webViewService.runResizeScript(webView) + } + + override fun onUrlClicked(url: String?) { + url?.let { + // Track click and close using service + trackingService.trackInAppClick(messageId, it, location) + trackingService.trackInAppClose( + messageId, + it, + IterableInAppCloseAction.LINK, + location + ) + + clickCallback?.execute(Uri.parse(it)) + } + + processMessageRemoval() + hideWebView() + + } + + private fun hideWebView() { + dismiss() + } + + private fun processMessageRemoval() { + // Remove message using service + trackingService.removeMessage(messageId, location) + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java index 66dd34792..7a3833c39 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java @@ -16,7 +16,8 @@ class IterableInAppDisplayer { } boolean isShowingInApp() { - return IterableInAppFragmentHTMLNotification.getInstance() != null; + return IterableInAppFragmentHTMLNotification.getInstance() != null || + IterableInAppDialogNotification.getInstance() != null; } boolean showMessage(@NonNull IterableInAppMessage message, IterableInAppLocation location, @NonNull final IterableHelper.IterableUrlCallback clickCallback) { @@ -26,17 +27,31 @@ boolean showMessage(@NonNull IterableInAppMessage message, IterableInAppLocation } Activity currentActivity = activityMonitor.getCurrentActivity(); - // Prevent double display if (currentActivity != null) { - return IterableInAppDisplayer.showIterableFragmentNotificationHTML(currentActivity, - message.getContent().html, - message.getMessageId(), - clickCallback, - message.getContent().backgroundAlpha, - message.getContent().padding, - message.getContent().inAppDisplaySettings.shouldAnimate, - message.getContent().inAppDisplaySettings.inAppBgColor, - true, location); + // Try FragmentActivity path first (backward compatibility) + if (currentActivity instanceof FragmentActivity) { + return showIterableFragmentNotificationHTML(currentActivity, + message.getContent().html, + message.getMessageId(), + clickCallback, + message.getContent().backgroundAlpha, + message.getContent().padding, + message.getContent().inAppDisplaySettings.shouldAnimate, + message.getContent().inAppDisplaySettings.inAppBgColor, + true, location); + } + // Fall back to Dialog path for ComponentActivity (Compose support) + else { + return showIterableDialogNotificationHTML(currentActivity, + message.getContent().html, + message.getMessageId(), + clickCallback, + message.getContent().backgroundAlpha, + message.getContent().padding, + message.getContent().inAppDisplaySettings.shouldAnimate, + message.getContent().inAppDisplaySettings.inAppBgColor, + true, location); + } } return false; } @@ -51,8 +66,7 @@ boolean showMessage(@NonNull IterableInAppMessage message, IterableInAppLocation * @param padding */ static boolean showIterableFragmentNotificationHTML(@NonNull Context context, @NonNull String htmlString, @NonNull String messageId, @NonNull final IterableHelper.IterableUrlCallback clickCallback, double backgroundAlpha, @NonNull Rect padding, boolean shouldAnimate, IterableInAppMessage.InAppBgColor bgColor, boolean callbackOnCancel, @NonNull IterableInAppLocation location) { - if (context instanceof FragmentActivity) { - FragmentActivity currentActivity = (FragmentActivity) context; + if (context instanceof FragmentActivity currentActivity) { if (htmlString != null) { if (IterableInAppFragmentHTMLNotification.getInstance() != null) { IterableLogger.w(IterableInAppManager.TAG, "Skipping the in-app notification: another notification is already being displayed"); @@ -64,10 +78,53 @@ static boolean showIterableFragmentNotificationHTML(@NonNull Context context, @N return true; } } else { - IterableLogger.w(IterableInAppManager.TAG, "To display in-app notifications, the context must be of an instance of: FragmentActivity"); + IterableLogger.w(IterableInAppManager.TAG, "Received context that is not FragmentActivity. Attempting dialog-based display."); } return false; } + /** + * Displays an HTML rendered InApp Notification using Dialog (for ComponentActivity/Compose support) + * @param context + * @param htmlString + * @param messageId + * @param clickCallback + * @param backgroundAlpha + * @param padding + * @param shouldAnimate + * @param bgColor + * @param callbackOnCancel + * @param location + */ + static boolean showIterableDialogNotificationHTML(@NonNull Context context, @NonNull String htmlString, @NonNull String messageId, @NonNull final IterableHelper.IterableUrlCallback clickCallback, double backgroundAlpha, @NonNull Rect padding, boolean shouldAnimate, IterableInAppMessage.InAppBgColor bgColor, boolean callbackOnCancel, @NonNull IterableInAppLocation location) { + if (!(context instanceof Activity)) { + IterableLogger.w(IterableInAppManager.TAG, "To display in-app notifications, the context must be an Activity"); + return false; + } + + Activity activity = (Activity) context; + + if (htmlString == null) { + IterableLogger.w(IterableInAppManager.TAG, "HTML string is null"); + return false; + } + + // Check if already showing + if (IterableInAppDialogNotification.getInstance() != null) { + IterableLogger.w(IterableInAppManager.TAG, "Skipping the in-app notification: another notification is already being displayed"); + return false; + } + + // Create and show dialog (Kotlin interop) + IterableInAppDialogNotification dialog = IterableInAppDialogNotification.createInstance( + activity, htmlString, callbackOnCancel, clickCallback, location, + messageId, backgroundAlpha, padding, shouldAnimate, bgColor + ); + dialog.show(); + + IterableLogger.d(IterableInAppManager.TAG, "Displaying in-app notification via Dialog for ComponentActivity"); + + return true; + } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableWebView.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableWebView.java index 424f6bf1b..16eaac0ff 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableWebView.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableWebView.java @@ -47,7 +47,7 @@ void createWithHtml(IterableWebView.HTMLNotificationCallbacks notificationDialog loadDataWithBaseURL(IterableUtil.getWebViewBaseUrl(), html, MIME_TYPE, ENCODING, ""); } - interface HTMLNotificationCallbacks { + public interface HTMLNotificationCallbacks { void onUrlClicked(String url); void setLoaded(boolean loaded); void runResizeScript(); From ffb653cb03ba306b12e96198652a600e212c2fc0 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Fri, 16 Jan 2026 13:19:48 +0000 Subject: [PATCH 02/17] InAppServices for removing duplicated code --- .../iterableapi/InAppAnimationService.java | 147 ---------- .../iterableapi/InAppAnimationService.kt | 98 +++++++ .../iterableapi/InAppLayoutService.java | 141 --------- .../iterableapi/InAppLayoutService.kt | 97 +++++++ .../iterableapi/InAppOrientationService.java | 101 ------- .../iterableapi/InAppOrientationService.kt | 62 ++++ .../com/iterable/iterableapi/InAppPadding.kt | 27 ++ .../iterable/iterableapi/InAppServices.java | 41 --- .../com/iterable/iterableapi/InAppServices.kt | 10 + .../iterableapi/InAppTrackingService.java | 131 --------- .../iterableapi/InAppTrackingService.kt | 109 +++++++ .../iterableapi/InAppWebViewService.java | 141 --------- .../iterableapi/InAppWebViewService.kt | 109 +++++++ .../IterableInAppDialogNotification.kt | 50 +--- .../iterableapi/InAppLayoutServiceTest.java | 168 +++++++++++ .../InAppOrientationServiceTest.java | 208 +++++++++++++ .../iterableapi/InAppTrackingServiceTest.java | 273 ++++++++++++++++++ 17 files changed, 1169 insertions(+), 744 deletions(-) delete mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.kt delete mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.kt delete mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppOrientationService.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppOrientationService.kt create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppPadding.kt delete mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.kt delete mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt delete mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppWebViewService.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/InAppWebViewService.kt create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/InAppOrientationServiceTest.java create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.java b/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.java deleted file mode 100644 index 2ae9b1548..000000000 --- a/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.iterable.iterableapi; - -import android.content.Context; -import android.graphics.Color; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.TransitionDrawable; -import android.view.View; -import android.view.Window; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.graphics.ColorUtils; - -/** - * Service class for in-app message animations. - * Centralizes animation logic shared between Fragment and Dialog implementations. - */ -class InAppAnimationService { - - private static final int ANIMATION_DURATION_MS = 300; - private static final String TAG = "InAppAnimService"; - - /** - * Creates a background drawable with the specified color and alpha - * @param hexColor The background color in hex format (e.g., "#000000") - * @param alpha The alpha value (0.0 to 1.0) - * @return ColorDrawable with the specified color and alpha, or null if parsing fails - */ - @Nullable - public ColorDrawable createInAppBackgroundDrawable(@Nullable String hexColor, double alpha) { - int backgroundColor; - - try { - if (hexColor != null && !hexColor.isEmpty()) { - backgroundColor = Color.parseColor(hexColor); - } else { - backgroundColor = Color.BLACK; - } - } catch (IllegalArgumentException e) { - IterableLogger.w(TAG, "Invalid background color: " + hexColor + ". Using BLACK.", e); - backgroundColor = Color.BLACK; - } - - int backgroundWithAlpha = ColorUtils.setAlphaComponent( - backgroundColor, - (int) (alpha * 255) - ); - - return new ColorDrawable(backgroundWithAlpha); - } - - /** - * Animates the window background from one drawable to another - * @param window The window to animate - * @param from The starting drawable - * @param to The ending drawable - * @param shouldAnimate If false, sets the background immediately without animation - */ - public void animateWindowBackground(@NonNull Window window, @NonNull Drawable from, @NonNull Drawable to, boolean shouldAnimate) { - if (shouldAnimate) { - Drawable[] layers = new Drawable[]{from, to}; - TransitionDrawable transition = new TransitionDrawable(layers); - window.setBackgroundDrawable(transition); - transition.startTransition(ANIMATION_DURATION_MS); - } else { - window.setBackgroundDrawable(to); - } - } - - /** - * Shows the in-app background with optional fade-in animation - * @param window The window to set the background on - * @param hexColor The background color in hex format - * @param alpha The background alpha (0.0 to 1.0) - * @param shouldAnimate Whether to animate the background fade-in - */ - public void showInAppBackground(@NonNull Window window, @Nullable String hexColor, double alpha, boolean shouldAnimate) { - ColorDrawable backgroundDrawable = createInAppBackgroundDrawable(hexColor, alpha); - - if (backgroundDrawable == null) { - IterableLogger.w(TAG, "Failed to create background drawable"); - return; - } - - if (shouldAnimate) { - // Animate from transparent to the target background - ColorDrawable transparentDrawable = new ColorDrawable(Color.TRANSPARENT); - animateWindowBackground(window, transparentDrawable, backgroundDrawable, true); - } else { - window.setBackgroundDrawable(backgroundDrawable); - } - } - - /** - * Shows and optionally animates a WebView - * @param webView The WebView to show - * @param shouldAnimate Whether to animate the appearance - * @param context Context for loading animation resources (only needed if shouldAnimate is true) - */ - public void showAndAnimateWebView(@NonNull View webView, boolean shouldAnimate, @Nullable Context context) { - if (shouldAnimate && context != null) { - // Animate with alpha fade-in - webView.setAlpha(0f); - webView.setVisibility(View.VISIBLE); - webView.animate() - .alpha(1.0f) - .setDuration(ANIMATION_DURATION_MS) - .start(); - } else { - // Show immediately - webView.setAlpha(1.0f); - webView.setVisibility(View.VISIBLE); - } - } - - /** - * Hides the in-app background with optional fade-out animation - * @param window The window to modify - * @param hexColor The current background color - * @param alpha The current background alpha - * @param shouldAnimate Whether to animate the background fade-out - */ - public void hideInAppBackground(@NonNull Window window, @Nullable String hexColor, double alpha, boolean shouldAnimate) { - if (shouldAnimate) { - ColorDrawable backgroundDrawable = createInAppBackgroundDrawable(hexColor, alpha); - ColorDrawable transparentDrawable = new ColorDrawable(Color.TRANSPARENT); - - if (backgroundDrawable != null) { - animateWindowBackground(window, backgroundDrawable, transparentDrawable, true); - } - } else { - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - } - } - - /** - * Prepares a view to be shown by hiding it initially - * This is typically called before the resize operation - * @param view The view to hide - */ - public void prepareViewForDisplay(@NonNull View view) { - view.setAlpha(0f); - view.setVisibility(View.INVISIBLE); - } -} - diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.kt new file mode 100644 index 000000000..aab85e713 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.kt @@ -0,0 +1,98 @@ +package com.iterable.iterableapi + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.TransitionDrawable +import android.view.View +import android.view.Window +import androidx.core.graphics.ColorUtils + +internal class InAppAnimationService { + + fun createInAppBackgroundDrawable(hexColor: String?, alpha: Double): ColorDrawable? { + val backgroundColor = try { + if (!hexColor.isNullOrEmpty()) { + Color.parseColor(hexColor) + } else { + Color.BLACK + } + } catch (e: IllegalArgumentException) { + IterableLogger.w(TAG, "Invalid background color: $hexColor. Using BLACK.", e) + Color.BLACK + } + + val backgroundWithAlpha = ColorUtils.setAlphaComponent( + backgroundColor, + (alpha * 255).toInt() + ) + + return ColorDrawable(backgroundWithAlpha) + } + + fun animateWindowBackground(window: Window, from: Drawable, to: Drawable, shouldAnimate: Boolean) { + if (shouldAnimate) { + val layers = arrayOf(from, to) + val transition = TransitionDrawable(layers) + window.setBackgroundDrawable(transition) + transition.startTransition(ANIMATION_DURATION_MS) + } else { + window.setBackgroundDrawable(to) + } + } + + fun showInAppBackground(window: Window, hexColor: String?, alpha: Double, shouldAnimate: Boolean) { + val backgroundDrawable = createInAppBackgroundDrawable(hexColor, alpha) + + if (backgroundDrawable == null) { + IterableLogger.w(TAG, "Failed to create background drawable") + return + } + + if (shouldAnimate) { + val transparentDrawable = ColorDrawable(Color.TRANSPARENT) + animateWindowBackground(window, transparentDrawable, backgroundDrawable, true) + } else { + window.setBackgroundDrawable(backgroundDrawable) + } + } + + fun showAndAnimateWebView(webView: View, shouldAnimate: Boolean, context: Context?) { + if (shouldAnimate && context != null) { + webView.alpha = 0f + webView.visibility = View.VISIBLE + webView.animate() + .alpha(1.0f) + .setDuration(ANIMATION_DURATION_MS.toLong()) + .start() + } else { + webView.alpha = 1.0f + webView.visibility = View.VISIBLE + } + } + + fun hideInAppBackground(window: Window, hexColor: String?, alpha: Double, shouldAnimate: Boolean) { + if (shouldAnimate) { + val backgroundDrawable = createInAppBackgroundDrawable(hexColor, alpha) + val transparentDrawable = ColorDrawable(Color.TRANSPARENT) + + if (backgroundDrawable != null) { + animateWindowBackground(window, backgroundDrawable, transparentDrawable, true) + } + } else { + window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + } + + fun prepareViewForDisplay(view: View) { + view.alpha = 0f + view.visibility = View.INVISIBLE + } + + companion object { + private const val ANIMATION_DURATION_MS = 300 + private const val TAG = "InAppAnimService" + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.java b/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.java deleted file mode 100644 index 0cbf22a89..000000000 --- a/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.java +++ /dev/null @@ -1,141 +0,0 @@ -package com.iterable.iterableapi; - -import android.graphics.Rect; -import android.view.Gravity; -import android.view.Window; -import android.view.WindowManager; - -import androidx.annotation.NonNull; - -/** - * Service class for in-app message layout calculations and window configuration. - * Centralizes layout detection logic shared between Fragment and Dialog implementations. - */ -class InAppLayoutService { - - /** - * Layout types for in-app messages based on padding configuration - */ - enum InAppLayout { - TOP, - BOTTOM, - CENTER, - FULLSCREEN - } - - /** - * Determines the layout type based on inset padding - * @param padding The inset padding (top/bottom) that defines the layout - * @return The corresponding InAppLayout type - */ - @NonNull - public InAppLayout getInAppLayout(@NonNull Rect padding) { - if (padding.top == 0 && padding.bottom == 0) { - return InAppLayout.FULLSCREEN; - } else if (padding.top > 0 && padding.bottom <= 0) { - return InAppLayout.TOP; - } else if (padding.top <= 0 && padding.bottom > 0) { - return InAppLayout.BOTTOM; - } else { - return InAppLayout.CENTER; - } - } - - /** - * Gets the vertical gravity for positioning based on padding - * @param padding The inset padding that defines positioning - * @return Gravity constant (TOP, BOTTOM, or CENTER_VERTICAL) - */ - public int getVerticalLocation(@NonNull Rect padding) { - InAppLayout layout = getInAppLayout(padding); - - switch (layout) { - case TOP: - return Gravity.TOP; - case BOTTOM: - return Gravity.BOTTOM; - case CENTER: - return Gravity.CENTER_VERTICAL; - case FULLSCREEN: - default: - return Gravity.CENTER_VERTICAL; - } - } - - /** - * Configures window flags based on layout type - * @param window The window to configure - * @param layout The layout type - */ - public void configureWindowFlags(Window window, @NonNull InAppLayout layout) { - if (window == null) { - return; - } - - if (layout == InAppLayout.FULLSCREEN) { - // Fullscreen: hide status bar - window.setFlags( - WindowManager.LayoutParams.FLAG_FULLSCREEN, - WindowManager.LayoutParams.FLAG_FULLSCREEN - ); - } else if (layout != InAppLayout.TOP) { - // BOTTOM and CENTER: translucent status bar - // TOP layout keeps status bar opaque (no flags needed) - window.setFlags( - WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, - WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS - ); - } - } - - /** - * Sets window size to fill the screen - * This is necessary for both fullscreen and positioned layouts - * @param window The window to configure - */ - public void setWindowToFullScreen(Window window) { - if (window != null) { - window.setLayout( - WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.MATCH_PARENT - ); - } - } - - /** - * Applies window gravity for positioned layouts (non-fullscreen) - * @param window The window to configure - * @param padding The inset padding - * @param source Debug string indicating where this is called from - */ - public void applyWindowGravity(Window window, @NonNull Rect padding, String source) { - if (window == null) { - return; - } - - int verticalGravity = getVerticalLocation(padding); - WindowManager.LayoutParams params = window.getAttributes(); - - switch (verticalGravity) { - case Gravity.CENTER_VERTICAL: - params.gravity = Gravity.CENTER; - break; - case Gravity.TOP: - params.gravity = Gravity.TOP | Gravity.CENTER_HORIZONTAL; - break; - case Gravity.BOTTOM: - params.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; - break; - default: - params.gravity = Gravity.CENTER; - break; - } - - window.setAttributes(params); - - if (source != null) { - IterableLogger.d("InAppLayoutService", "Applied window gravity from " + source + ": " + params.gravity); - } - } -} - diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.kt new file mode 100644 index 000000000..e23061504 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.kt @@ -0,0 +1,97 @@ +package com.iterable.iterableapi + +import android.graphics.Rect +import android.view.Gravity +import android.view.Window +import android.view.WindowManager + +internal class InAppLayoutService { + internal enum class InAppLayout { + TOP, + BOTTOM, + CENTER, + FULLSCREEN + } + + fun getInAppLayout(padding: Rect): InAppLayout { + return getInAppLayout(InAppPadding.fromRect(padding)) + } + + fun getInAppLayout(padding: InAppPadding): InAppLayout { + if (padding.top == 0 && padding.bottom == 0) { + return InAppLayout.FULLSCREEN + } else if (padding.top > 0 && padding.bottom <= 0) { + return InAppLayout.TOP + } else if (padding.top <= 0 && padding.bottom > 0) { + return InAppLayout.BOTTOM + } else { + return InAppLayout.CENTER + } + } + + fun getVerticalLocation(padding: Rect): Int { + return getVerticalLocation(InAppPadding.fromRect(padding)) + } + + fun getVerticalLocation(padding: InAppPadding): Int { + val layout = getInAppLayout(padding) + + when (layout) { + InAppLayout.TOP -> return Gravity.TOP + InAppLayout.BOTTOM -> return Gravity.BOTTOM + InAppLayout.CENTER -> return Gravity.CENTER_VERTICAL + InAppLayout.FULLSCREEN -> return Gravity.CENTER_VERTICAL + } + } + + fun configureWindowFlags(window: Window?, layout: InAppLayout) { + if (window == null) { + return + } + + if (layout == InAppLayout.FULLSCREEN) { + window.setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ) + } else if (layout != InAppLayout.TOP) { + window.setFlags( + WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, + WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS + ) + } + } + + fun setWindowToFullScreen(window: Window?) { + window?.setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT + ) + } + + fun applyWindowGravity(window: Window?, padding: Rect, source: String?) { + if (window == null) { + return + } + + val verticalGravity = getVerticalLocation(padding) + val params = window.attributes + + when (verticalGravity) { + Gravity.CENTER_VERTICAL -> params.gravity = Gravity.CENTER + Gravity.TOP -> params.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + Gravity.BOTTOM -> params.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + else -> params.gravity = Gravity.CENTER + } + + window.attributes = params + + if (source != null) { + IterableLogger.d( + "InAppLayoutService", + "Applied window gravity from " + source + ": " + params.gravity + ) + } + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppOrientationService.java b/iterableapi/src/main/java/com/iterable/iterableapi/InAppOrientationService.java deleted file mode 100644 index d32814129..000000000 --- a/iterableapi/src/main/java/com/iterable/iterableapi/InAppOrientationService.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.iterable.iterableapi; - -import android.content.Context; -import android.hardware.SensorManager; -import android.os.Handler; -import android.os.Looper; -import android.view.OrientationEventListener; - -import androidx.annotation.NonNull; - -/** - * Service class for handling device orientation changes in in-app messages. - * Centralizes orientation detection logic shared between Fragment and Dialog implementations. - */ -class InAppOrientationService { - - private static final String TAG = "InAppOrientService"; - private static final long ORIENTATION_CHANGE_DELAY_MS = 1500; - - /** - * Callback interface for orientation change events - */ - interface OrientationChangeCallback { - /** - * Called when the device orientation has changed - */ - void onOrientationChanged(); - } - - /** - * Creates an OrientationEventListener that detects 90-degree rotations - * @param context The context for sensor access - * @param callback The callback to invoke when orientation changes - * @return Configured OrientationEventListener (caller must enable it) - */ - @NonNull - public OrientationEventListener createOrientationListener( - @NonNull Context context, - @NonNull final OrientationChangeCallback callback) { - - return new OrientationEventListener(context, SensorManager.SENSOR_DELAY_NORMAL) { - private int lastOrientation = -1; - - @Override - public void onOrientationChanged(int orientation) { - int currentOrientation = roundToNearest90Degrees(orientation); - - // Only trigger callback if orientation actually changed - if (currentOrientation != lastOrientation && lastOrientation != -1) { - lastOrientation = currentOrientation; - - // Delay the callback to allow orientation change to stabilize - new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { - @Override - public void run() { - IterableLogger.d(TAG, "Orientation changed, triggering callback"); - callback.onOrientationChanged(); - } - }, ORIENTATION_CHANGE_DELAY_MS); - } else if (lastOrientation == -1) { - // Initialize last orientation - lastOrientation = currentOrientation; - } - } - }; - } - - /** - * Rounds an orientation value to the nearest 90-degree increment - * @param orientation The raw orientation value (0-359 degrees) - * @return The nearest 90-degree value (0, 90, 180, or 270) - */ - public int roundToNearest90Degrees(int orientation) { - return ((orientation + 45) / 90 * 90) % 360; - } - - /** - * Safely enables an OrientationEventListener - * @param listener The listener to enable (nullable) - */ - public void enableListener(OrientationEventListener listener) { - if (listener != null && listener.canDetectOrientation()) { - listener.enable(); - IterableLogger.d(TAG, "Orientation listener enabled"); - } else { - IterableLogger.w(TAG, "Cannot enable orientation listener"); - } - } - - /** - * Safely disables an OrientationEventListener - * @param listener The listener to disable (nullable) - */ - public void disableListener(OrientationEventListener listener) { - if (listener != null) { - listener.disable(); - IterableLogger.d(TAG, "Orientation listener disabled"); - } - } -} - diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppOrientationService.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppOrientationService.kt new file mode 100644 index 000000000..27ecf35ef --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppOrientationService.kt @@ -0,0 +1,62 @@ +package com.iterable.iterableapi + +import android.content.Context +import android.hardware.SensorManager +import android.os.Handler +import android.os.Looper +import android.view.OrientationEventListener + +internal class InAppOrientationService { + + fun interface OrientationChangeCallback { + fun onOrientationChanged() + } + + fun createOrientationListener( + context: Context, + callback: OrientationChangeCallback + ): OrientationEventListener { + return object : OrientationEventListener(context, SensorManager.SENSOR_DELAY_NORMAL) { + private var lastOrientation = -1 + + override fun onOrientationChanged(orientation: Int) { + val currentOrientation = roundToNearest90Degrees(orientation) + + if (currentOrientation != lastOrientation && lastOrientation != -1) { + lastOrientation = currentOrientation + + Handler(Looper.getMainLooper()).postDelayed({ + IterableLogger.d(TAG, "Orientation changed, triggering callback") + callback.onOrientationChanged() + }, ORIENTATION_CHANGE_DELAY_MS) + } else if (lastOrientation == -1) { + lastOrientation = currentOrientation + } + } + } + } + + fun roundToNearest90Degrees(orientation: Int): Int { + return ((orientation + 45) / 90 * 90) % 360 + } + + fun enableListener(listener: OrientationEventListener?) { + if (listener != null && listener.canDetectOrientation()) { + listener.enable() + IterableLogger.d(TAG, "Orientation listener enabled") + } else { + IterableLogger.w(TAG, "Cannot enable orientation listener") + } + } + + fun disableListener(listener: OrientationEventListener?) { + listener?.disable() + IterableLogger.d(TAG, "Orientation listener disabled") + } + + companion object { + private const val TAG = "InAppOrientService" + private const val ORIENTATION_CHANGE_DELAY_MS = 1500L + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppPadding.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppPadding.kt new file mode 100644 index 000000000..2df19e017 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppPadding.kt @@ -0,0 +1,27 @@ +package com.iterable.iterableapi + +import android.graphics.Rect + +internal data class InAppPadding( + val left: Int = 0, + val top: Int = 0, + val right: Int = 0, + val bottom: Int = 0 +) { + companion object { + @JvmStatic + fun fromRect(rect: Rect): InAppPadding { + return InAppPadding( + left = rect.left, + top = rect.top, + right = rect.right, + bottom = rect.bottom + ) + } + } + + fun toRect(): Rect { + return Rect(left, top, right, bottom) + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.java b/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.java deleted file mode 100644 index 602506421..000000000 --- a/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.iterable.iterableapi; - -/** - * Central access point for all in-app message services. - * Provides singleton instances of each service for convenient access. - */ -final class InAppServices { - - /** - * Layout detection and window configuration service - */ - public static final InAppLayoutService layout = new InAppLayoutService(); - - /** - * Animation and visual effects service - */ - public static final InAppAnimationService animation = new InAppAnimationService(); - - /** - * Event tracking and analytics service - */ - public static final InAppTrackingService tracking = new InAppTrackingService(); - - /** - * WebView creation and management service - */ - public static final InAppWebViewService webView = new InAppWebViewService(); - - /** - * Orientation change detection service - */ - public static final InAppOrientationService orientation = new InAppOrientationService(); - - /** - * Private constructor to prevent instantiation - */ - private InAppServices() { - throw new UnsupportedOperationException("InAppServices is a static utility class and cannot be instantiated"); - } -} - diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.kt new file mode 100644 index 000000000..e62e01bac --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.kt @@ -0,0 +1,10 @@ +package com.iterable.iterableapi + +internal object InAppServices { + val layout: InAppLayoutService = InAppLayoutService() + val animation: InAppAnimationService = InAppAnimationService() + val tracking: InAppTrackingService = InAppTrackingService(IterableApi.sharedInstance) + val webView: InAppWebViewService = InAppWebViewService() + val orientation: InAppOrientationService = InAppOrientationService() +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.java b/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.java deleted file mode 100644 index 4428e537b..000000000 --- a/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.iterable.iterableapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * Service class for in-app message event tracking. - * Centralizes tracking logic shared between Fragment and Dialog implementations. - * Provides null-safe wrappers around IterableApi tracking methods. - */ -class InAppTrackingService { - - private static final String TAG = "InAppTrackingService"; - - /** - * Tracks when an in-app message is opened - * @param messageId The message ID - * @param location The location where the message was triggered (nullable, defaults to IN_APP) - */ - public void trackInAppOpen(@NonNull String messageId, @Nullable IterableInAppLocation location) { - IterableInAppLocation loc = location != null ? location : IterableInAppLocation.IN_APP; - - IterableApi api = IterableApi.sharedInstance; - if (api != null) { - api.trackInAppOpen(messageId, loc); - IterableLogger.d(TAG, "Tracked in-app open: " + messageId + " at location: " + loc); - } else { - IterableLogger.w(TAG, "Cannot track in-app open: IterableApi not initialized"); - } - } - - /** - * Tracks when a user clicks on an in-app message - * @param messageId The message ID - * @param url The URL that was clicked (or special identifier like itbl://backButton) - * @param location The location where the click occurred (nullable, defaults to IN_APP) - */ - public void trackInAppClick(@NonNull String messageId, @NonNull String url, @Nullable IterableInAppLocation location) { - IterableInAppLocation loc = location != null ? location : IterableInAppLocation.IN_APP; - - IterableApi api = IterableApi.sharedInstance; - if (api != null) { - api.trackInAppClick(messageId, url, loc); - IterableLogger.d(TAG, "Tracked in-app click: " + messageId + " url: " + url + " at location: " + loc); - } else { - IterableLogger.w(TAG, "Cannot track in-app click: IterableApi not initialized"); - } - } - - /** - * Tracks when an in-app message is closed - * @param messageId The message ID - * @param url The URL associated with the close action (or special identifier) - * @param closeAction The type of close action (LINK, BACK, etc.) - * @param location The location where the close occurred (nullable, defaults to IN_APP) - */ - public void trackInAppClose(@NonNull String messageId, @NonNull String url, @NonNull IterableInAppCloseAction closeAction, @Nullable IterableInAppLocation location) { - IterableInAppLocation loc = location != null ? location : IterableInAppLocation.IN_APP; - - IterableApi api = IterableApi.sharedInstance; - if (api != null) { - api.trackInAppClose(messageId, url, closeAction, loc); - IterableLogger.d(TAG, "Tracked in-app close: " + messageId + " action: " + closeAction + " at location: " + loc); - } else { - IterableLogger.w(TAG, "Cannot track in-app close: IterableApi not initialized"); - } - } - - /** - * Removes a message from the in-app queue after it has been displayed or dismissed - * @param messageId The message ID to remove - * @param location The location where the removal occurred (nullable, defaults to IN_APP) - */ - public void removeMessage(@NonNull String messageId, @Nullable IterableInAppLocation location) { - IterableInAppLocation loc = location != null ? location : IterableInAppLocation.IN_APP; - - IterableApi api = IterableApi.sharedInstance; - if (api == null) { - IterableLogger.w(TAG, "Cannot remove message: IterableApi not initialized"); - return; - } - - IterableInAppManager inAppManager = api.getInAppManager(); - if (inAppManager == null) { - IterableLogger.w(TAG, "Cannot remove message: InAppManager not available"); - return; - } - - // Find the message by ID - IterableInAppMessage message = null; - if (inAppManager.getMessages() != null) { - for (IterableInAppMessage msg : inAppManager.getMessages()) { - if (msg != null && messageId.equals(msg.getMessageId())) { - message = msg; - break; - } - } - } - - if (message != null) { - // Remove with proper parameters (message, deleteType, location) - inAppManager.removeMessage( - message, - IterableInAppDeleteActionType.INBOX_SWIPE, - loc - ); - IterableLogger.d(TAG, "Removed message: " + messageId + " at location: " + loc); - } else { - IterableLogger.w(TAG, "Message not found for removal: " + messageId); - } - } - - /** - * Tracks a screen view event (useful for analytics) - * @param screenName The name of the screen being viewed - */ - public void trackScreenView(@NonNull String screenName) { - IterableApi api = IterableApi.sharedInstance; - if (api != null) { - try { - org.json.JSONObject data = new org.json.JSONObject(); - data.put("screenName", screenName); - api.track("Screen Viewed", data); - IterableLogger.d(TAG, "Tracked screen view: " + screenName); - } catch (org.json.JSONException e) { - IterableLogger.w(TAG, "Failed to track screen view", e); - } - } - } -} - diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt new file mode 100644 index 000000000..dd06f7a8e --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt @@ -0,0 +1,109 @@ +package com.iterable.iterableapi + +import org.json.JSONException +import org.json.JSONObject + +internal class InAppTrackingService internal constructor( + private val iterableApi: IterableApi? +){ + fun trackInAppOpen(messageId: String, location: IterableInAppLocation?) { + val loc = location ?: IterableInAppLocation.IN_APP + + if (iterableApi != null) { + iterableApi.trackInAppOpen(messageId, loc) + IterableLogger.d(TAG, "Tracked in-app open: $messageId at location: $loc") + } else { + IterableLogger.w(TAG, "Cannot track in-app open: IterableApi not initialized") + } + } + + fun trackInAppClick(messageId: String, url: String, location: IterableInAppLocation?) { + val loc = location ?: IterableInAppLocation.IN_APP + + if (iterableApi != null) { + iterableApi.trackInAppClick(messageId, url, loc) + IterableLogger.d( + TAG, + "Tracked in-app click: $messageId url: $url at location: $loc" + ) + } else { + IterableLogger.w(TAG, "Cannot track in-app click: IterableApi not initialized") + } + } + + fun trackInAppClose( + messageId: String, + url: String, + closeAction: IterableInAppCloseAction, + location: IterableInAppLocation? + ) { + val loc = location ?: IterableInAppLocation.IN_APP + + if (iterableApi != null) { + iterableApi.trackInAppClose(messageId, url, closeAction, loc) + IterableLogger.d( + TAG, + "Tracked in-app close: $messageId action: $closeAction at location: $loc" + ) + } else { + IterableLogger.w(TAG, "Cannot track in-app close: IterableApi not initialized") + } + } + + fun removeMessage(messageId: String, location: IterableInAppLocation?) { + val loc = location ?: IterableInAppLocation.IN_APP + + if (iterableApi == null) { + IterableLogger.w(TAG, "Cannot remove message: IterableApi not initialized") + return + } + + val inAppManager = try { + iterableApi.inAppManager + } catch (e: Exception) { + null + } + + if (inAppManager == null) { + IterableLogger.w(TAG, "Cannot remove message: InAppManager not initialized") + return + } + + val message: IterableInAppMessage? = try { + inAppManager.messages.firstOrNull { msg -> + msg != null && messageId == msg.messageId + } + } catch (e: Exception) { + null + } + + if (message != null) { + inAppManager.removeMessage( + message, + IterableInAppDeleteActionType.INBOX_SWIPE, + loc + ) + IterableLogger.d(TAG, "Removed message: $messageId at location: $loc") + } else { + IterableLogger.w(TAG, "Message not found for removal: $messageId") + } + } + + fun trackScreenView(screenName: String) { + if (iterableApi != null) { + try { + val data = JSONObject() + data.put("screenName", screenName) + iterableApi.track("Screen Viewed", data) + IterableLogger.d(TAG, "Tracked screen view: $screenName") + } catch (e: JSONException) { + IterableLogger.w(TAG, "Failed to track screen view", e) + } + } + } + + companion object { + private const val TAG = "InAppTrackingService" + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppWebViewService.java b/iterableapi/src/main/java/com/iterable/iterableapi/InAppWebViewService.java deleted file mode 100644 index 2c058e5ce..000000000 --- a/iterableapi/src/main/java/com/iterable/iterableapi/InAppWebViewService.java +++ /dev/null @@ -1,141 +0,0 @@ -package com.iterable.iterableapi; - -import android.content.Context; -import android.widget.FrameLayout; -import android.widget.RelativeLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * Service class for in-app message WebView management. - * Centralizes WebView creation and configuration logic shared between - * Fragment and Dialog implementations. - */ -class InAppWebViewService { - - private static final String TAG = "InAppWebViewService"; - - /** - * Creates and configures a WebView for in-app message display - * @param context The context for WebView creation - * @param callbacks The callback interface for WebView events - * @param htmlContent The HTML content to load - * @return Configured IterableWebView - */ - @NonNull - public IterableWebView createConfiguredWebView( - @NonNull Context context, - @NonNull IterableWebView.HTMLNotificationCallbacks callbacks, - @NonNull String htmlContent) { - - IterableWebView webView = new IterableWebView(context); - webView.setId(R.id.webView); - webView.createWithHtml(callbacks, htmlContent); - - IterableLogger.d(TAG, "Created and configured WebView with HTML content"); - return webView; - } - - /** - * Creates layout parameters for WebView based on layout type - * @param isFullScreen Whether this is a fullscreen in-app message - * @return Appropriate LayoutParams for the WebView - */ - @NonNull - public FrameLayout.LayoutParams createWebViewLayoutParams(boolean isFullScreen) { - if (isFullScreen) { - // Fullscreen: WebView fills entire container - return new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ); - } else { - // Non-fullscreen: WebView wraps content for proper sizing - return new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, - FrameLayout.LayoutParams.WRAP_CONTENT - ); - } - } - - /** - * Creates layout parameters for WebView container (RelativeLayout) in positioned layouts - * @return RelativeLayout.LayoutParams for WebView centering - */ - @NonNull - public RelativeLayout.LayoutParams createCenteredWebViewParams() { - RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.WRAP_CONTENT, - RelativeLayout.LayoutParams.WRAP_CONTENT - ); - params.addRule(RelativeLayout.CENTER_IN_PARENT); - return params; - } - - /** - * Creates layout parameters for the WebView container based on layout type - * @param layout The layout type (TOP, BOTTOM, CENTER, FULLSCREEN) - * @return FrameLayout.LayoutParams with appropriate gravity - */ - @NonNull - public FrameLayout.LayoutParams createContainerLayoutParams(@NonNull InAppLayoutService.InAppLayout layout) { - FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.WRAP_CONTENT - ); - - switch (layout) { - case TOP: - params.gravity = android.view.Gravity.TOP | android.view.Gravity.CENTER_HORIZONTAL; - break; - case BOTTOM: - params.gravity = android.view.Gravity.BOTTOM | android.view.Gravity.CENTER_HORIZONTAL; - break; - case CENTER: - params.gravity = android.view.Gravity.CENTER; - break; - case FULLSCREEN: - // Fullscreen doesn't use container positioning - params = new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ); - break; - } - - return params; - } - - /** - * Properly cleans up and destroys a WebView - * @param webView The WebView to clean up (nullable) - */ - public void cleanupWebView(@Nullable IterableWebView webView) { - if (webView != null) { - try { - webView.destroy(); - IterableLogger.d(TAG, "WebView cleaned up and destroyed"); - } catch (Exception e) { - IterableLogger.w(TAG, "Error cleaning up WebView", e); - } - } - } - - /** - * Triggers the resize script on the WebView - * This is typically called after orientation changes or content updates - * @param webView The WebView to resize - */ - public void runResizeScript(@Nullable IterableWebView webView) { - if (webView != null) { - try { - webView.evaluateJavascript("window.resize()", null); - IterableLogger.d(TAG, "Triggered WebView resize script"); - } catch (Exception e) { - IterableLogger.w(TAG, "Error running resize script", e); - } - } - } -} - diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppWebViewService.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppWebViewService.kt new file mode 100644 index 000000000..b9fe9a3e0 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppWebViewService.kt @@ -0,0 +1,109 @@ +package com.iterable.iterableapi + +import android.content.Context +import android.view.Gravity +import android.widget.FrameLayout +import android.widget.RelativeLayout + +internal class InAppWebViewService { + + fun createConfiguredWebView( + context: Context, + callbacks: IterableWebView.HTMLNotificationCallbacks, + htmlContent: String + ): IterableWebView { + val webView = IterableWebView(context) + webView.id = R.id.webView + webView.createWithHtml(callbacks, htmlContent) + + IterableLogger.d(TAG, "Created and configured WebView with HTML content") + return webView + } + + fun createWebViewLayoutParams(isFullScreen: Boolean): FrameLayout.LayoutParams { + return if (isFullScreen) { + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + } else { + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) + } + } + + fun createCenteredWebViewParams(): RelativeLayout.LayoutParams { + val params = RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT + ) + params.addRule(RelativeLayout.CENTER_IN_PARENT) + return params + } + + fun createContainerLayoutParams(layout: InAppLayoutService.InAppLayout): FrameLayout.LayoutParams { + val params = when (layout) { + InAppLayoutService.InAppLayout.TOP -> { + val p = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) + p.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + p + } + InAppLayoutService.InAppLayout.BOTTOM -> { + val p = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) + p.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + p + } + InAppLayoutService.InAppLayout.CENTER -> { + val p = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) + p.gravity = Gravity.CENTER + p + } + InAppLayoutService.InAppLayout.FULLSCREEN -> { + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + } + } + + return params + } + + fun cleanupWebView(webView: IterableWebView?) { + if (webView != null) { + try { + webView.destroy() + IterableLogger.d(TAG, "WebView cleaned up and destroyed") + } catch (e: Exception) { + IterableLogger.w(TAG, "Error cleaning up WebView", e) + } + } + } + + fun runResizeScript(webView: IterableWebView?) { + if (webView != null) { + try { + webView.evaluateJavascript("window.resize()", null) + IterableLogger.d(TAG, "Triggered WebView resize script") + } catch (e: Exception) { + IterableLogger.w(TAG, "Error running resize script", e) + } + } + } + + companion object { + private const val TAG = "InAppWebViewService" + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt index 1bc401306..ddd3088e9 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -24,7 +24,7 @@ import androidx.core.view.WindowInsetsCompat * This class provides the same functionality as [IterableInAppFragmentHTMLNotification] * but works with [androidx.activity.ComponentActivity] instead of requiring [androidx.fragment.app.FragmentActivity]. */ -class IterableInAppDialogNotification private constructor( +class IterableInAppDialogNotification internal constructor( activity: Activity, private val htmlString: String?, private val callbackOnCancel: Boolean, @@ -60,9 +60,6 @@ class IterableInAppDialogNotification private constructor( @JvmStatic private var location: IterableInAppLocation? = null - /** - * Factory method to create a new dialog instance - */ @JvmStatic @JvmOverloads fun createInstance( @@ -76,7 +73,7 @@ class IterableInAppDialogNotification private constructor( padding: Rect, animate: Boolean = false, inAppBgColor: IterableInAppMessage.InAppBgColor = - IterableInAppMessage.InAppBgColor(null, 0.0) + IterableInAppMessage.InAppBgColor(null, 0.0), ): IterableInAppDialogNotification { clickCallback = urlCallback location = inAppLocation @@ -90,7 +87,12 @@ class IterableInAppDialogNotification private constructor( padding, animate, inAppBgColor.bgAlpha, - inAppBgColor.bgHexColor + inAppBgColor.bgHexColor, + InAppServices.layout, + InAppServices.animation, + InAppServices.tracking, + InAppServices.webView, + InAppServices.orientation ) return notification!! @@ -105,14 +107,11 @@ class IterableInAppDialogNotification private constructor( fun getInstance(): IterableInAppDialogNotification? = notification } - // Lifecycle and Setup override fun onStart() { super.onStart() - // Set window to fullscreen using service window?.let { layoutService.setWindowToFullScreen(it) } - // Apply gravity for non-fullscreen layouts val layout = layoutService.getInAppLayout(insetPadding) if (layout != InAppLayoutService.InAppLayout.FULLSCREEN) { window?.let { layoutService.applyWindowGravity(it, insetPadding, "onStart") } @@ -122,57 +121,44 @@ class IterableInAppDialogNotification private constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Configure dialog window requestWindowFeature(Window.FEATURE_NO_TITLE) window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) - // Configure based on layout type using service val layout = layoutService.getInAppLayout(insetPadding) window?.let { layoutService.configureWindowFlags(it, layout) } - // Apply gravity for non-fullscreen layouts if (layout != InAppLayoutService.InAppLayout.FULLSCREEN) { window?.let { layoutService.applyWindowGravity(it, insetPadding, "onCreate") } } - // Setup cancel listener setOnCancelListener { if (callbackOnCancel && clickCallback != null) { clickCallback?.execute(null) } } - // Setup back press handling setupBackPressHandling() - // Create the view hierarchy val contentView = createContentView() setContentView(contentView) - // Setup orientation listener setupOrientationListener() - // Track open event using service trackingService.trackInAppOpen(messageId, location) - // Prepare to show with animation prepareToShowWebView() } override fun dismiss() { - // Clean up back press callback backPressedCallback?.remove() backPressedCallback = null - // Clean up orientation listener using service orientationService.disableListener(orientationListener) orientationListener = null - // Clean up webview using service webViewService.cleanupWebView(webView) webView = null - // Clear singleton notification = null super.dismiss() @@ -192,10 +178,8 @@ class IterableInAppDialogNotification private constructor( location ) - // Process message removal processMessageRemoval() - // Dismiss the dialog dismiss() } } @@ -203,11 +187,9 @@ class IterableInAppDialogNotification private constructor( activity.onBackPressedDispatcher.addCallback(activity, backPressedCallback!!) IterableLogger.d(TAG, "dialog notification back press handler registered") } else { - // Fallback to legacy key listener for non-ComponentActivity IterableLogger.w(TAG, "Activity is not ComponentActivity, using legacy back press handling") setOnKeyListener { _, keyCode, event -> if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { - // Track back button using service trackingService.trackInAppClick(messageId, BACK_BUTTON, location) trackingService.trackInAppClose( messageId, @@ -216,10 +198,8 @@ class IterableInAppDialogNotification private constructor( location ) - // Process message removal processMessageRemoval() - // Dismiss the dialog dismiss() true } else { @@ -229,41 +209,32 @@ class IterableInAppDialogNotification private constructor( } } - // View Creation - private fun createContentView(): View { val context = context - // Create WebView using service webView = webViewService.createConfiguredWebView( context, this@IterableInAppDialogNotification, htmlString ?: "" ) - // Create container based on layout type using service val frameLayout = FrameLayout(context) val layout = layoutService.getInAppLayout(insetPadding) val isFullScreen = layout == InAppLayoutService.InAppLayout.FULLSCREEN if (isFullScreen) { - // Fullscreen: WebView fills entire dialog val params = webViewService.createWebViewLayoutParams(true) frameLayout.addView(webView, params) } else { - // Non-fullscreen: WebView in positioned container val webViewContainer = RelativeLayout(context) - // Container positioning using service val containerParams = webViewService.createContainerLayoutParams(layout) - // WebView centering using service val webViewParams = webViewService.createCenteredWebViewParams() webViewContainer.addView(webView, webViewParams) frameLayout.addView(webViewContainer, containerParams) - // Apply window insets for system bars ViewCompat.setOnApplyWindowInsetsListener(frameLayout) { v, insets -> val sysBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) v.setPadding(0, sysBars.top, 0, sysBars.bottom) @@ -275,7 +246,6 @@ class IterableInAppDialogNotification private constructor( } private fun setupOrientationListener() { - // Create orientation listener using service orientationListener = orientationService.createOrientationListener(context) { if (loaded && webView != null) { webViewService.runResizeScript(webView) @@ -315,8 +285,6 @@ class IterableInAppDialogNotification private constructor( } } - // WebView Callbacks - override fun setLoaded(loaded: Boolean) { this.loaded = loaded } @@ -327,7 +295,6 @@ class IterableInAppDialogNotification private constructor( override fun onUrlClicked(url: String?) { url?.let { - // Track click and close using service trackingService.trackInAppClick(messageId, it, location) trackingService.trackInAppClose( messageId, @@ -349,7 +316,6 @@ class IterableInAppDialogNotification private constructor( } private fun processMessageRemoval() { - // Remove message using service trackingService.removeMessage(messageId, location) } } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java new file mode 100644 index 000000000..a2148cf16 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java @@ -0,0 +1,168 @@ +package com.iterable.iterableapi; + +import android.view.Gravity; + +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class InAppLayoutServiceTest { + + private InAppLayoutService layoutService; + + @Before + public void setup() { + layoutService = new InAppLayoutService(); + } + + @Test + public void getInAppLayout_shouldReturnFullscreen_whenNoPadding() { + // Arrange + InAppPadding padding = new InAppPadding(0, 0, 0, 0); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.FULLSCREEN, result); + } + + @Test + public void getInAppLayout_shouldReturnTop_whenOnlyTopPadding() { + // Arrange + InAppPadding padding = new InAppPadding(0, 50, 0, 0); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.TOP, result); + } + + @Test + public void getInAppLayout_shouldReturnBottom_whenOnlyBottomPadding() { + // Arrange + InAppPadding padding = new InAppPadding(0, 0, 0, 50); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.BOTTOM, result); + } + + @Test + public void getInAppLayout_shouldReturnCenter_whenBothTopAndBottomPadding() { + // Arrange + InAppPadding padding = new InAppPadding(0, 50, 0, 50); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.CENTER, result); + } + + @Test + public void getInAppLayout_shouldReturnTop_whenTopPaddingAndBottomIsZero() { + // Arrange + InAppPadding padding = new InAppPadding(0, 100, 0, 0); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.TOP, result); + } + + @Test + public void getInAppLayout_shouldReturnBottom_whenBottomPaddingAndTopIsZero() { + // Arrange + InAppPadding padding = new InAppPadding(0, 0, 0, 100); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.BOTTOM, result); + } + + // Vertical Location Tests (Business Logic - derives from layout type) + + @Test + public void getVerticalLocation_shouldReturnTop_whenTopLayout() { + // Arrange + InAppPadding padding = new InAppPadding(0, 50, 0, 0); + + // Act + int result = layoutService.getVerticalLocation(padding); + + // Assert + assertEquals(Gravity.TOP, result); + } + + @Test + public void getVerticalLocation_shouldReturnBottom_whenBottomLayout() { + // Arrange + InAppPadding padding = new InAppPadding(0, 0, 0, 50); + + // Act + int result = layoutService.getVerticalLocation(padding); + + // Assert + assertEquals(Gravity.BOTTOM, result); + } + + @Test + public void getVerticalLocation_shouldReturnCenterVertical_whenCenterLayout() { + // Arrange + InAppPadding padding = new InAppPadding(0, 50, 0, 50); + + // Act + int result = layoutService.getVerticalLocation(padding); + + // Assert + assertEquals(Gravity.CENTER_VERTICAL, result); + } + + @Test + public void getVerticalLocation_shouldReturnCenterVertical_whenFullscreenLayout() { + // Arrange + InAppPadding padding = new InAppPadding(0, 0, 0, 0); + + // Act + int result = layoutService.getVerticalLocation(padding); + + // Assert + assertEquals(Gravity.CENTER_VERTICAL, result); + } + + // Edge Cases + + @Test + public void getInAppLayout_shouldHandleNegativePadding() { + // Arrange - negative padding for top with zero bottom + InAppPadding padding = new InAppPadding(0, -10, 0, 0); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + // top <= 0 but bottom not > 0, so it falls through to CENTER + assertEquals(InAppLayoutService.InAppLayout.CENTER, result); + } + + @Test + public void getInAppLayout_shouldHandleLargePaddingValues() { + // Arrange + InAppPadding padding = new InAppPadding(0, 1000, 0, 0); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.TOP, result); + } +} + diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/InAppOrientationServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/InAppOrientationServiceTest.java new file mode 100644 index 000000000..be37428aa --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppOrientationServiceTest.java @@ -0,0 +1,208 @@ +package com.iterable.iterableapi; + +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class InAppOrientationServiceTest { + + private InAppOrientationService orientationService; + + @Before + public void setup() { + orientationService = new InAppOrientationService(); + } + + @Test + public void roundToNearest90Degrees_shouldReturn0_when0Degrees() { + // Act + int result = orientationService.roundToNearest90Degrees(0); + + // Assert + assertEquals(0, result); + } + + @Test + public void roundToNearest90Degrees_shouldReturn0_when44Degrees() { + // Arrange - 44 is closer to 0 than 90 + int orientation = 44; + + // Act + int result = orientationService.roundToNearest90Degrees(orientation); + + // Assert + assertEquals(0, result); + } + + @Test + public void roundToNearest90Degrees_shouldReturn90_when45Degrees() { + // Arrange - 45 is the boundary, rounds up to 90 + int orientation = 45; + + // Act + int result = orientationService.roundToNearest90Degrees(orientation); + + // Assert + assertEquals(90, result); + } + + @Test + public void roundToNearest90Degrees_shouldReturn90_when90Degrees() { + // Act + int result = orientationService.roundToNearest90Degrees(90); + + // Assert + assertEquals(90, result); + } + + @Test + public void roundToNearest90Degrees_shouldReturn90_when89Degrees() { + // Arrange - 89 is closer to 90 than 0 + int orientation = 89; + + // Act + int result = orientationService.roundToNearest90Degrees(orientation); + + // Assert + assertEquals(90, result); + } + + @Test + public void roundToNearest90Degrees_shouldReturn90_when134Degrees() { + // Arrange - 134 is closer to 90 than 180 + int orientation = 134; + + // Act + int result = orientationService.roundToNearest90Degrees(orientation); + + // Assert + assertEquals(90, result); + } + + @Test + public void roundToNearest90Degrees_shouldReturn180_when135Degrees() { + // Arrange - 135 is the boundary, rounds up to 180 + int orientation = 135; + + // Act + int result = orientationService.roundToNearest90Degrees(orientation); + + // Assert + assertEquals(180, result); + } + + @Test + public void roundToNearest90Degrees_shouldReturn180_when180Degrees() { + // Act + int result = orientationService.roundToNearest90Degrees(180); + + // Assert + assertEquals(180, result); + } + + @Test + public void roundToNearest90Degrees_shouldReturn270_when225Degrees() { + // Arrange - 225 is the boundary, rounds up to 270 + int orientation = 225; + + // Act + int result = orientationService.roundToNearest90Degrees(orientation); + + // Assert + assertEquals(270, result); + } + + @Test + public void roundToNearest90Degrees_shouldReturn270_when270Degrees() { + // Act + int result = orientationService.roundToNearest90Degrees(270); + + // Assert + assertEquals(270, result); + } + + @Test + public void roundToNearest90Degrees_shouldReturn270_when314Degrees() { + // Arrange - 314 is closer to 270 than 360/0 + int orientation = 314; + + // Act + int result = orientationService.roundToNearest90Degrees(orientation); + + // Assert + assertEquals(270, result); + } + + @Test + public void roundToNearest90Degrees_shouldReturn0_when315Degrees() { + // Arrange - 315 is the boundary, rounds up to 360 which wraps to 0 + int orientation = 315; + + // Act + int result = orientationService.roundToNearest90Degrees(orientation); + + // Assert + assertEquals(0, result); + } + + @Test + public void roundToNearest90Degrees_shouldReturn0_when359Degrees() { + // Arrange - 359 is very close to 360, which wraps to 0 + int orientation = 359; + + // Act + int result = orientationService.roundToNearest90Degrees(orientation); + + // Assert + assertEquals(0, result); + } + + // Edge Cases + + @Test + public void roundToNearest90Degrees_shouldHandleNegativeValues() { + // Arrange - negative values (although unusual for orientation) + int orientation = -10; + + // Act + int result = orientationService.roundToNearest90Degrees(orientation); + + // Assert - The modulo will handle this, expect 350 rounded + // (-10 + 45) / 90 * 90 = 35 / 90 * 90 = 0 * 90 = 0, then % 360 = 0 + assertEquals(0, result); + } + + @Test + public void roundToNearest90Degrees_shouldHandleValuesOver360() { + // Arrange - values over 360 (sensor may provide these) + int orientation = 405; // 405 = 45 + 360 + + // Act + int result = orientationService.roundToNearest90Degrees(orientation); + + // Assert - (405 + 45) / 90 * 90 % 360 = 450 / 90 * 90 % 360 = 5 * 90 % 360 = 450 % 360 = 90 + assertEquals(90, result); + } + + // Comprehensive boundary tests + + @Test + public void roundToNearest90Degrees_allBoundaries() { + // Test all 4 boundaries systematically + assertEquals("Boundary at 45 degrees", 90, orientationService.roundToNearest90Degrees(45)); + assertEquals("Boundary at 135 degrees", 180, orientationService.roundToNearest90Degrees(135)); + assertEquals("Boundary at 225 degrees", 270, orientationService.roundToNearest90Degrees(225)); + assertEquals("Boundary at 315 degrees", 0, orientationService.roundToNearest90Degrees(315)); + } + + @Test + public void roundToNearest90Degrees_allCardinalDirections() { + // Test all 4 cardinal directions + assertEquals("Portrait (0°)", 0, orientationService.roundToNearest90Degrees(0)); + assertEquals("Landscape right (90°)", 90, orientationService.roundToNearest90Degrees(90)); + assertEquals("Portrait inverted (180°)", 180, orientationService.roundToNearest90Degrees(180)); + assertEquals("Landscape left (270°)", 270, orientationService.roundToNearest90Degrees(270)); + } +} + diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java new file mode 100644 index 000000000..2a7218df4 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java @@ -0,0 +1,273 @@ +package com.iterable.iterableapi; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.Arrays; +import java.util.Collections; + +import static org.mockito.Mockito.*; + +@RunWith(MockitoJUnitRunner.class) +public class InAppTrackingServiceTest { + + private InAppTrackingService trackingService; + + @Mock + private IterableApi mockIterableApi; + + @Mock + private IterableInAppManager mockInAppManager; + + @Mock + private IterableInAppMessage mockMessage; + + @Before + public void setup() { + trackingService = new InAppTrackingService(mockIterableApi); + } + + @Test + public void trackInAppOpen_shouldCallApi_whenLocationProvided() { + // Arrange + String messageId = "test-message-123"; + IterableInAppLocation location = IterableInAppLocation.IN_APP; + + // Act + trackingService.trackInAppOpen(messageId, location); + + // Assert + verify(mockIterableApi).trackInAppOpen(messageId, location); + } + + @Test + public void trackInAppOpen_shouldUseDefaultLocation_whenLocationIsNull() { + // Arrange + String messageId = "test-message-123"; + + // Act + trackingService.trackInAppOpen(messageId, null); + + // Assert + verify(mockIterableApi).trackInAppOpen(messageId, IterableInAppLocation.IN_APP); + } + + @Test + public void trackInAppOpen_shouldNotCrash_whenApiIsNull() { + // Arrange + String messageId = "test-message-123"; + InAppTrackingService nullApiService = new InAppTrackingService(null); + + // Act & Assert - should not throw exception + nullApiService.trackInAppOpen(messageId, IterableInAppLocation.IN_APP); + } + + // Track In-App Click Tests + + @Test + public void trackInAppClick_shouldCallApi_whenAllParametersProvided() { + // Arrange + String messageId = "test-message-123"; + String url = "https://example.com"; + IterableInAppLocation location = IterableInAppLocation.INBOX; + + // Act + trackingService.trackInAppClick(messageId, url, location); + + // Assert + verify(mockIterableApi).trackInAppClick(messageId, url, location); + } + + @Test + public void trackInAppClick_shouldUseDefaultLocation_whenLocationIsNull() { + // Arrange + String messageId = "test-message-123"; + String url = "https://example.com"; + + // Act + trackingService.trackInAppClick(messageId, url, null); + + // Assert + verify(mockIterableApi).trackInAppClick(messageId, url, IterableInAppLocation.IN_APP); + } + + @Test + public void trackInAppClick_shouldHandleBackButton() { + // Arrange + String messageId = "test-message-123"; + String backButton = "itbl://backButton"; + + // Act + trackingService.trackInAppClick(messageId, backButton, IterableInAppLocation.IN_APP); + + // Assert + verify(mockIterableApi).trackInAppClick(messageId, backButton, IterableInAppLocation.IN_APP); + } + + // Track In-App Close Tests + + @Test + public void trackInAppClose_shouldCallApi_whenAllParametersProvided() { + // Arrange + String messageId = "test-message-123"; + String url = "https://example.com"; + IterableInAppCloseAction action = IterableInAppCloseAction.LINK; + IterableInAppLocation location = IterableInAppLocation.IN_APP; + + // Act + trackingService.trackInAppClose(messageId, url, action, location); + + // Assert + verify(mockIterableApi).trackInAppClose(messageId, url, action, location); + } + + @Test + public void trackInAppClose_shouldUseDefaultLocation_whenLocationIsNull() { + // Arrange + String messageId = "test-message-123"; + String url = "https://example.com"; + IterableInAppCloseAction action = IterableInAppCloseAction.LINK; + + // Act + trackingService.trackInAppClose(messageId, url, action, null); + + // Assert + verify(mockIterableApi).trackInAppClose(messageId, url, action, IterableInAppLocation.IN_APP); + } + + @Test + public void trackInAppClose_shouldHandleBackAction() { + // Arrange + String messageId = "test-message-123"; + String backButton = "itbl://backButton"; + IterableInAppCloseAction action = IterableInAppCloseAction.BACK; + + // Act + trackingService.trackInAppClose(messageId, backButton, action, IterableInAppLocation.IN_APP); + + // Assert + verify(mockIterableApi).trackInAppClose(messageId, backButton, action, IterableInAppLocation.IN_APP); + } + + // Remove Message Tests + + @Test + public void removeMessage_shouldFindAndRemoveMessage_whenMessageExists() { + // Arrange + String messageId = "test-message-123"; + when(mockMessage.getMessageId()).thenReturn(messageId); + + when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); + when(mockInAppManager.getMessages()).thenReturn(Arrays.asList(mockMessage)); + + // Act + trackingService.removeMessage(messageId, IterableInAppLocation.INBOX); + + // Assert + verify(mockInAppManager).removeMessage( + mockMessage, + IterableInAppDeleteActionType.INBOX_SWIPE, + IterableInAppLocation.INBOX + ); + } + + @Test + public void removeMessage_shouldUseDefaultLocation_whenLocationIsNull() { + // Arrange + String messageId = "test-message-123"; + when(mockMessage.getMessageId()).thenReturn(messageId); + + when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); + when(mockInAppManager.getMessages()).thenReturn(Arrays.asList(mockMessage)); + + // Act + trackingService.removeMessage(messageId, null); + + // Assert + verify(mockInAppManager).removeMessage( + mockMessage, + IterableInAppDeleteActionType.INBOX_SWIPE, + IterableInAppLocation.IN_APP + ); + } + + @Test + public void removeMessage_shouldNotCrash_whenMessageNotFound() { + // Arrange + String messageId = "test-message-123"; + String differentId = "different-id-456"; + when(mockMessage.getMessageId()).thenReturn(differentId); + + when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); + when(mockInAppManager.getMessages()).thenReturn(Arrays.asList(mockMessage)); + + // Act & Assert - should not throw exception + trackingService.removeMessage(messageId, IterableInAppLocation.IN_APP); + + // Should not call removeMessage since message wasn't found + verify(mockInAppManager, never()).removeMessage(any(), any(), any()); + } + + @Test + public void removeMessage_shouldNotCrash_whenMessagesListIsEmpty() { + // Arrange + String messageId = "test-message-123"; + + when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); + when(mockInAppManager.getMessages()).thenReturn(Collections.emptyList()); + + // Act & Assert - should not throw exception + trackingService.removeMessage(messageId, IterableInAppLocation.IN_APP); + } + + @Test + public void removeMessage_shouldNotCrash_whenApiIsNull() { + // Arrange + String messageId = "test-message-123"; + InAppTrackingService nullApiService = new InAppTrackingService(null); + + // Act & Assert - should not throw exception + nullApiService.removeMessage(messageId, IterableInAppLocation.IN_APP); + } + + @Test + public void removeMessage_shouldNotCrash_whenInAppManagerIsNull() { + // Arrange + String messageId = "test-message-123"; + + when(mockIterableApi.getInAppManager()).thenReturn(null); + + // Act & Assert - should not throw exception + trackingService.removeMessage(messageId, IterableInAppLocation.IN_APP); + } + + // Track Screen View Tests + + @Test + public void trackScreenView_shouldCallTrackWithScreenNameData() { + // Arrange + String screenName = "Main Screen"; + + + // Act + trackingService.trackScreenView(screenName); + + // Assert + verify(mockIterableApi).track(eq("Screen Viewed"), any(org.json.JSONObject.class)); + } + + @Test + public void trackScreenView_shouldNotCrash_whenApiIsNull() { + // Arrange + String screenName = "Main Screen"; + InAppTrackingService nullApiService = new InAppTrackingService(null); + + // Act & Assert - should not throw exception + nullApiService.trackScreenView(screenName); + } +} + From 2a0eacdce1023231484d1ab57baa33815b97b9ed Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Fri, 13 Feb 2026 15:37:05 +0000 Subject: [PATCH 03/17] Fixing imports and styling --- .../iterableapi/IterableInAppDisplayer.java | 24 +++++++++---------- .../iterableapi/InAppLayoutServiceTest.java | 4 ++-- .../InAppOrientationServiceTest.java | 4 ++-- .../iterableapi/InAppTrackingServiceTest.java | 9 ++++--- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java index 7a3833c39..f076f4de4 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java @@ -30,7 +30,8 @@ boolean showMessage(@NonNull IterableInAppMessage message, IterableInAppLocation if (currentActivity != null) { // Try FragmentActivity path first (backward compatibility) if (currentActivity instanceof FragmentActivity) { - return showIterableFragmentNotificationHTML(currentActivity, + return showIterableFragmentNotificationHTML( + currentActivity, message.getContent().html, message.getMessageId(), clickCallback, @@ -38,10 +39,9 @@ boolean showMessage(@NonNull IterableInAppMessage message, IterableInAppLocation message.getContent().padding, message.getContent().inAppDisplaySettings.shouldAnimate, message.getContent().inAppDisplaySettings.inAppBgColor, - true, location); - } - // Fall back to Dialog path for ComponentActivity (Compose support) - else { + true, location + ); + } else { return showIterableDialogNotificationHTML(currentActivity, message.getContent().html, message.getMessageId(), @@ -101,29 +101,29 @@ static boolean showIterableDialogNotificationHTML(@NonNull Context context, @Non IterableLogger.w(IterableInAppManager.TAG, "To display in-app notifications, the context must be an Activity"); return false; } - + Activity activity = (Activity) context; - + if (htmlString == null) { IterableLogger.w(IterableInAppManager.TAG, "HTML string is null"); return false; } - + // Check if already showing if (IterableInAppDialogNotification.getInstance() != null) { IterableLogger.w(IterableInAppManager.TAG, "Skipping the in-app notification: another notification is already being displayed"); return false; } - + // Create and show dialog (Kotlin interop) IterableInAppDialogNotification dialog = IterableInAppDialogNotification.createInstance( - activity, htmlString, callbackOnCancel, clickCallback, location, + activity, htmlString, callbackOnCancel, clickCallback, location, messageId, backgroundAlpha, padding, shouldAnimate, bgColor ); dialog.show(); - + IterableLogger.d(IterableInAppManager.TAG, "Displaying in-app notification via Dialog for ComponentActivity"); - + return true; } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java index a2148cf16..95e3a869d 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java @@ -1,12 +1,12 @@ package com.iterable.iterableapi; +import static org.junit.Assert.assertEquals; + import android.view.Gravity; import org.junit.Before; import org.junit.Test; -import static org.junit.Assert.*; - public class InAppLayoutServiceTest { private InAppLayoutService layoutService; diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/InAppOrientationServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/InAppOrientationServiceTest.java index be37428aa..fd62aa663 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/InAppOrientationServiceTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppOrientationServiceTest.java @@ -1,10 +1,10 @@ package com.iterable.iterableapi; +import static org.junit.Assert.assertEquals; + import org.junit.Before; import org.junit.Test; -import static org.junit.Assert.*; - public class InAppOrientationServiceTest { private InAppOrientationService orientationService; diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java index 2a7218df4..b53a365f8 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java @@ -1,17 +1,20 @@ package com.iterable.iterableapi; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; -import org.mockito.MockedStatic; import org.mockito.junit.MockitoJUnitRunner; import java.util.Arrays; import java.util.Collections; -import static org.mockito.Mockito.*; - @RunWith(MockitoJUnitRunner.class) public class InAppTrackingServiceTest { From cae2eaf3d99e1ebf14d21ecaaed8999cce60e969 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Mon, 6 Apr 2026 10:43:05 +0100 Subject: [PATCH 04/17] Add activity recreation state handling for Dialog in-app Prevent duplicate trackInAppOpen calls when the activity is recreated (e.g. rotation) by saving/restoring the inAppOpenTracked flag via onSaveInstanceState. Also preserve static singleton state during configuration changes to match Fragment version behavior. Co-Authored-By: Claude Opus 4.6 --- .../IterableInAppDialogNotification.kt | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt index ddd3088e9..16cf568af 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -45,11 +45,13 @@ class IterableInAppDialogNotification internal constructor( private var loaded: Boolean = false private var orientationListener: OrientationEventListener? = null private var backPressedCallback: OnBackPressedCallback? = null + private var inAppOpenTracked: Boolean = false companion object { private const val TAG = "IterableInAppDialog" private const val BACK_BUTTON = "itbl://backButton" private const val DELAY_THRESHOLD_MS = 500L + private const val IN_APP_OPEN_TRACKED = "InAppOpenTracked" @JvmStatic private var notification: IterableInAppDialogNotification? = null @@ -120,47 +122,68 @@ class IterableInAppDialogNotification internal constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + + if (savedInstanceState != null) { + inAppOpenTracked = savedInstanceState.getBoolean(IN_APP_OPEN_TRACKED, false) + } + requestWindowFeature(Window.FEATURE_NO_TITLE) window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) - + val layout = layoutService.getInAppLayout(insetPadding) window?.let { layoutService.configureWindowFlags(it, layout) } - + if (layout != InAppLayoutService.InAppLayout.FULLSCREEN) { window?.let { layoutService.applyWindowGravity(it, insetPadding, "onCreate") } } - + setOnCancelListener { if (callbackOnCancel && clickCallback != null) { clickCallback?.execute(null) } } - + setupBackPressHandling() - + val contentView = createContentView() setContentView(contentView) - + setupOrientationListener() - - trackingService.trackInAppOpen(messageId, location) - + + if (!inAppOpenTracked) { + trackingService.trackInAppOpen(messageId, location) + inAppOpenTracked = true + } + prepareToShowWebView() } + override fun onSaveInstanceState(): Bundle { + val bundle = super.onSaveInstanceState() + bundle.putBoolean(IN_APP_OPEN_TRACKED, inAppOpenTracked) + return bundle + } + override fun dismiss() { backPressedCallback?.remove() backPressedCallback = null - + orientationService.disableListener(orientationListener) orientationListener = null - + webViewService.cleanupWebView(webView) webView = null - + + val activity = ownerActivity ?: context as? Activity + if (activity != null && activity.isChangingConfigurations) { + super.dismiss() + return + } + notification = null - + clickCallback = null + location = null + super.dismiss() } From b859d719717ac1012a9490a63c6f7f101c7d5de1 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Mon, 6 Apr 2026 10:45:43 +0100 Subject: [PATCH 05/17] Add unit tests for IterableInAppDialogNotification lifecycle Tests cover singleton lifecycle (create/getInstance/dismiss), dialog show/dismiss behavior, URL click handling, layout variants (fullscreen/top/bottom/center), duplicate display rejection, and resilience to resize after dismiss. Co-Authored-By: Claude Opus 4.6 --- .../IterableInAppDialogNotificationTest.java | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java new file mode 100644 index 000000000..22694613c --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java @@ -0,0 +1,175 @@ +package com.iterable.iterableapi; + +import android.graphics.Rect; + +import androidx.activity.ComponentActivity; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.robolectric.Robolectric; +import org.robolectric.android.controller.ActivityController; +import org.robolectric.shadows.ShadowDialog; + +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertTrue; + +public class IterableInAppDialogNotificationTest extends BaseTest { + + private ActivityController controller; + private ComponentActivity activity; + + @Before + public void setUp() { + IterableTestUtils.createIterableApiNew(); + controller = Robolectric.buildActivity(ComponentActivity.class).create().start().resume(); + activity = controller.get(); + } + + @After + public void tearDown() { + if (ShadowDialog.getLatestDialog() != null) { + ShadowDialog.getLatestDialog().dismiss(); + } + if (controller != null) { + controller.pause().stop().destroy(); + } + IterableTestUtils.resetIterableApi(); + } + + // ===== Singleton Lifecycle Tests ===== + + @Test + public void getInstance_shouldReturnNull_whenNoDialogCreated() { + assertNull(IterableInAppDialogNotification.getInstance()); + } + + @Test + public void getInstance_shouldReturnInstance_afterCreateInstance() { + createDialog(); + assertNotNull(IterableInAppDialogNotification.getInstance()); + } + + @Test + public void getInstance_shouldReturnNull_afterDismiss() { + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + dialog.dismiss(); + assertNull(IterableInAppDialogNotification.getInstance()); + } + + // ===== Show/Dismiss Tests ===== + + @Test + public void show_shouldDisplayDialog() { + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + assertTrue(dialog.isShowing()); + } + + @Test + public void dismiss_shouldCleanupSingletonState() { + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + assertNotNull(IterableInAppDialogNotification.getInstance()); + + dialog.dismiss(); + assertNull(IterableInAppDialogNotification.getInstance()); + } + + @Test + public void testDoNotCrashOnResizeAfterDismiss() { + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + dialog.dismiss(); + dialog.runResizeScript(); + } + + // ===== URL Click Tests ===== + + @Test + public void onUrlClicked_shouldNotCrash_whenUrlIsNull() { + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + dialog.onUrlClicked(null); + } + + @Test + public void onUrlClicked_shouldDismissDialog() { + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + assertTrue(dialog.isShowing()); + + dialog.onUrlClicked("https://example.com"); + assertNull(IterableInAppDialogNotification.getInstance()); + } + + // ===== Layout Variant Tests ===== + + @Test + public void showDialog_shouldDisplay_withFullscreenPadding() { + IterableInAppDialogNotification dialog = createDialogWithPadding(new Rect(0, 0, 0, 0)); + dialog.show(); + assertTrue(dialog.isShowing()); + } + + @Test + public void showDialog_shouldDisplay_withTopPadding() { + IterableInAppDialogNotification dialog = createDialogWithPadding(new Rect(0, 10, 0, 0)); + dialog.show(); + assertTrue(dialog.isShowing()); + } + + @Test + public void showDialog_shouldDisplay_withBottomPadding() { + IterableInAppDialogNotification dialog = createDialogWithPadding(new Rect(0, 0, 0, 10)); + dialog.show(); + assertTrue(dialog.isShowing()); + } + + @Test + public void showDialog_shouldDisplay_withCenterPadding() { + IterableInAppDialogNotification dialog = createDialogWithPadding(new Rect(0, 10, 0, 10)); + dialog.show(); + assertTrue(dialog.isShowing()); + } + + // ===== Displayer Integration Tests ===== + + @Test + public void displayer_shouldRejectDuplicate_whenDialogAlreadyShowing() { + createDialog().show(); + + boolean result = IterableInAppDisplayer.showIterableDialogNotificationHTML( + activity, "", "msg-2", uri -> { }, + 0.5, new Rect(), false, + new IterableInAppMessage.InAppBgColor(null, 0.0), + true, IterableInAppLocation.IN_APP + ); + + // Should reject since one is already showing + assertTrue(!result); + } + + // ===== Helper Methods ===== + + private IterableInAppDialogNotification createDialog() { + return createDialogWithPadding(new Rect(0, 0, 0, 0)); + } + + private IterableInAppDialogNotification createDialogWithPadding(Rect padding) { + return IterableInAppDialogNotification.createInstance( + activity, + "Test", + true, + uri -> { }, + IterableInAppLocation.IN_APP, + "test-message", + 0.5, + padding, + false, + new IterableInAppMessage.InAppBgColor(null, 0.0) + ); + } +} From 35156fb18e2e9fdba3b0ce2f4e5bbf4813f1b490 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Mon, 6 Apr 2026 10:47:23 +0100 Subject: [PATCH 06/17] Add volatile to static singleton fields for thread safety The static notification/clickCallback/location fields can be read from background threads (e.g. processMessages after network sync) while being written on the main thread during show/dismiss. Mark them volatile to ensure cross-thread visibility in both the Fragment and Dialog in-app notification classes. Co-Authored-By: Claude Opus 4.6 --- .../iterable/iterableapi/IterableInAppDialogNotification.kt | 3 +++ .../iterableapi/IterableInAppFragmentHTMLNotification.java | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt index 16cf568af..38d088d65 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -53,12 +53,15 @@ class IterableInAppDialogNotification internal constructor( private const val DELAY_THRESHOLD_MS = 500L private const val IN_APP_OPEN_TRACKED = "InAppOpenTracked" + @Volatile @JvmStatic private var notification: IterableInAppDialogNotification? = null + @Volatile @JvmStatic private var clickCallback: IterableHelper.IterableUrlCallback? = null + @Volatile @JvmStatic private var location: IterableInAppLocation? = null diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java index 51894424f..9fe00102c 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java @@ -55,11 +55,11 @@ public class IterableInAppFragmentHTMLNotification extends DialogFragment implem private static final int DELAY_THRESHOLD_MS = 500; @Nullable - static IterableInAppFragmentHTMLNotification notification; + static volatile IterableInAppFragmentHTMLNotification notification; @Nullable - static IterableHelper.IterableUrlCallback clickCallback; + static volatile IterableHelper.IterableUrlCallback clickCallback; @Nullable - static IterableInAppLocation location; + static volatile IterableInAppLocation location; private IterableWebView webView; private boolean loaded; From 86bd80f369448b307682c1f14f1870b2cece4d2f Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Fri, 10 Apr 2026 15:15:42 +0100 Subject: [PATCH 07/17] Fix Java 16 instanceof and lazy-init InAppServices tracking - Replace Java 16 pattern matching instanceof with traditional instanceof + cast for broader JDK compatibility - Make InAppTrackingService initialization lazy in InAppServices to avoid capturing IterableApi.sharedInstance at class load time Made-with: Cursor --- .../src/main/java/com/iterable/iterableapi/InAppServices.kt | 2 +- .../java/com/iterable/iterableapi/IterableInAppDisplayer.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.kt index e62e01bac..ab36c62cf 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.kt @@ -3,7 +3,7 @@ package com.iterable.iterableapi internal object InAppServices { val layout: InAppLayoutService = InAppLayoutService() val animation: InAppAnimationService = InAppAnimationService() - val tracking: InAppTrackingService = InAppTrackingService(IterableApi.sharedInstance) + val tracking: InAppTrackingService by lazy { InAppTrackingService(IterableApi.sharedInstance) } val webView: InAppWebViewService = InAppWebViewService() val orientation: InAppOrientationService = InAppOrientationService() } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java index f076f4de4..2f679bc98 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java @@ -66,7 +66,8 @@ boolean showMessage(@NonNull IterableInAppMessage message, IterableInAppLocation * @param padding */ static boolean showIterableFragmentNotificationHTML(@NonNull Context context, @NonNull String htmlString, @NonNull String messageId, @NonNull final IterableHelper.IterableUrlCallback clickCallback, double backgroundAlpha, @NonNull Rect padding, boolean shouldAnimate, IterableInAppMessage.InAppBgColor bgColor, boolean callbackOnCancel, @NonNull IterableInAppLocation location) { - if (context instanceof FragmentActivity currentActivity) { + if (context instanceof FragmentActivity) { + FragmentActivity currentActivity = (FragmentActivity) context; if (htmlString != null) { if (IterableInAppFragmentHTMLNotification.getInstance() != null) { IterableLogger.w(IterableInAppManager.TAG, "Skipping the in-app notification: another notification is already being displayed"); From f75a3ca00e37289fa5fefec85223f0c261545870 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Fri, 10 Apr 2026 15:21:25 +0100 Subject: [PATCH 08/17] Revert HTMLNotificationCallbacks to package-private visibility All implementors and consumers are within the same package, so public visibility is unnecessary and widens the public API surface. Made-with: Cursor --- .../src/main/java/com/iterable/iterableapi/IterableWebView.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableWebView.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableWebView.java index 16eaac0ff..424f6bf1b 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableWebView.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableWebView.java @@ -47,7 +47,7 @@ void createWithHtml(IterableWebView.HTMLNotificationCallbacks notificationDialog loadDataWithBaseURL(IterableUtil.getWebViewBaseUrl(), html, MIME_TYPE, ENCODING, ""); } - public interface HTMLNotificationCallbacks { + interface HTMLNotificationCallbacks { void onUrlClicked(String url); void setLoaded(boolean loaded); void runResizeScript(); From 601c5920f09b7d400252c4b75432baade6789443 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 14 Apr 2026 11:34:14 +0100 Subject: [PATCH 09/17] Fixes for dialog in compose --- .../iterableapi/InAppLayoutService.kt | 4 +- .../iterableapi/InAppTrackingService.kt | 27 ++++---- .../IterableInAppDialogNotification.kt | 11 ++-- .../iterableapi/InAppLayoutServiceTest.java | 55 ++++++++--------- .../iterableapi/InAppTrackingServiceTest.java | 61 ++++++++----------- 5 files changed, 71 insertions(+), 87 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.kt index e23061504..3c4c9cf5a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppLayoutService.kt @@ -20,9 +20,9 @@ internal class InAppLayoutService { fun getInAppLayout(padding: InAppPadding): InAppLayout { if (padding.top == 0 && padding.bottom == 0) { return InAppLayout.FULLSCREEN - } else if (padding.top > 0 && padding.bottom <= 0) { + } else if (padding.top == 0 && padding.bottom < 0) { return InAppLayout.TOP - } else if (padding.top <= 0 && padding.bottom > 0) { + } else if (padding.top < 0 && padding.bottom == 0) { return InAppLayout.BOTTOM } else { return InAppLayout.CENTER diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt index dd06f7a8e..ba6ec2b9a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt @@ -50,9 +50,7 @@ internal class InAppTrackingService internal constructor( } } - fun removeMessage(messageId: String, location: IterableInAppLocation?) { - val loc = location ?: IterableInAppLocation.IN_APP - + fun removeMessage(messageId: String) { if (iterableApi == null) { IterableLogger.w(TAG, "Cannot remove message: IterableApi not initialized") return @@ -63,29 +61,26 @@ internal class InAppTrackingService internal constructor( } catch (e: Exception) { null } - + if (inAppManager == null) { IterableLogger.w(TAG, "Cannot remove message: InAppManager not initialized") return } val message: IterableInAppMessage? = try { - inAppManager.messages.firstOrNull { msg -> - msg != null && messageId == msg.messageId - } + inAppManager.getMessageById(messageId) } catch (e: Exception) { null } - if (message != null) { - inAppManager.removeMessage( - message, - IterableInAppDeleteActionType.INBOX_SWIPE, - loc - ) - IterableLogger.d(TAG, "Removed message: $messageId at location: $loc") - } else { - IterableLogger.w(TAG, "Message not found for removal: $messageId") + if (message == null) { + IterableLogger.w(TAG, "Message with id $messageId does not exist") + return + } + + if (message.isMarkedForDeletion && !message.isConsumed) { + inAppManager.removeMessage(message) + IterableLogger.d(TAG, "Removed message: $messageId") } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt index 38d088d65..fa92ee7e6 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -177,12 +177,9 @@ class IterableInAppDialogNotification internal constructor( webViewService.cleanupWebView(webView) webView = null - val activity = ownerActivity ?: context as? Activity - if (activity != null && activity.isChangingConfigurations) { - super.dismiss() - return - } - + // Always clear statics. Unlike DialogFragment, Dialog is not recreated + // after configuration changes, so a stale reference would permanently + // block isShowingInApp() and prevent future in-app messages. notification = null clickCallback = null location = null @@ -342,7 +339,7 @@ class IterableInAppDialogNotification internal constructor( } private fun processMessageRemoval() { - trackingService.removeMessage(messageId, location) + trackingService.removeMessage(messageId) } } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java index 95e3a869d..121bd7d0b 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java @@ -29,9 +29,9 @@ public void getInAppLayout_shouldReturnFullscreen_whenNoPadding() { } @Test - public void getInAppLayout_shouldReturnTop_whenOnlyTopPadding() { - // Arrange - InAppPadding padding = new InAppPadding(0, 50, 0, 0); + public void getInAppLayout_shouldReturnTop_whenBottomIsAutoExpand() { + // Arrange - top=0, bottom=-1 (AutoExpand sentinel from decodePadding) + InAppPadding padding = new InAppPadding(0, 0, 0, -1); // Act InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); @@ -41,9 +41,9 @@ public void getInAppLayout_shouldReturnTop_whenOnlyTopPadding() { } @Test - public void getInAppLayout_shouldReturnBottom_whenOnlyBottomPadding() { - // Arrange - InAppPadding padding = new InAppPadding(0, 0, 0, 50); + public void getInAppLayout_shouldReturnBottom_whenTopIsAutoExpand() { + // Arrange - top=-1 (AutoExpand), bottom=0 + InAppPadding padding = new InAppPadding(0, -1, 0, 0); // Act InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); @@ -53,8 +53,8 @@ public void getInAppLayout_shouldReturnBottom_whenOnlyBottomPadding() { } @Test - public void getInAppLayout_shouldReturnCenter_whenBothTopAndBottomPadding() { - // Arrange + public void getInAppLayout_shouldReturnCenter_whenBothTopAndBottomHavePadding() { + // Arrange - both have positive percentage padding InAppPadding padding = new InAppPadding(0, 50, 0, 50); // Act @@ -65,35 +65,35 @@ public void getInAppLayout_shouldReturnCenter_whenBothTopAndBottomPadding() { } @Test - public void getInAppLayout_shouldReturnTop_whenTopPaddingAndBottomIsZero() { - // Arrange - InAppPadding padding = new InAppPadding(0, 100, 0, 0); + public void getInAppLayout_shouldReturnCenter_whenBothAutoExpand() { + // Arrange - both are AutoExpand (-1) + InAppPadding padding = new InAppPadding(0, -1, 0, -1); // Act InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); // Assert - assertEquals(InAppLayoutService.InAppLayout.TOP, result); + assertEquals(InAppLayoutService.InAppLayout.CENTER, result); } @Test - public void getInAppLayout_shouldReturnBottom_whenBottomPaddingAndTopIsZero() { - // Arrange - InAppPadding padding = new InAppPadding(0, 0, 0, 100); + public void getInAppLayout_shouldReturnCenter_whenPositivePaddingOnBothEdges() { + // Arrange - both edges have positive percentage values + InAppPadding padding = new InAppPadding(0, 100, 0, 100); // Act InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); // Assert - assertEquals(InAppLayoutService.InAppLayout.BOTTOM, result); + assertEquals(InAppLayoutService.InAppLayout.CENTER, result); } // Vertical Location Tests (Business Logic - derives from layout type) @Test public void getVerticalLocation_shouldReturnTop_whenTopLayout() { - // Arrange - InAppPadding padding = new InAppPadding(0, 50, 0, 0); + // Arrange - top=0, bottom=-1 (AutoExpand) → TOP layout + InAppPadding padding = new InAppPadding(0, 0, 0, -1); // Act int result = layoutService.getVerticalLocation(padding); @@ -104,8 +104,8 @@ public void getVerticalLocation_shouldReturnTop_whenTopLayout() { @Test public void getVerticalLocation_shouldReturnBottom_whenBottomLayout() { - // Arrange - InAppPadding padding = new InAppPadding(0, 0, 0, 50); + // Arrange - top=-1 (AutoExpand), bottom=0 → BOTTOM layout + InAppPadding padding = new InAppPadding(0, -1, 0, 0); // Act int result = layoutService.getVerticalLocation(padding); @@ -116,7 +116,7 @@ public void getVerticalLocation_shouldReturnBottom_whenBottomLayout() { @Test public void getVerticalLocation_shouldReturnCenterVertical_whenCenterLayout() { - // Arrange + // Arrange - both have positive padding → CENTER layout InAppPadding padding = new InAppPadding(0, 50, 0, 50); // Act @@ -141,28 +141,27 @@ public void getVerticalLocation_shouldReturnCenterVertical_whenFullscreenLayout( // Edge Cases @Test - public void getInAppLayout_shouldHandleNegativePadding() { - // Arrange - negative padding for top with zero bottom + public void getInAppLayout_shouldReturnBottom_whenTopIsNegativeAndBottomIsZero() { + // Arrange - any negative top value with zero bottom → BOTTOM InAppPadding padding = new InAppPadding(0, -10, 0, 0); // Act InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); // Assert - // top <= 0 but bottom not > 0, so it falls through to CENTER - assertEquals(InAppLayoutService.InAppLayout.CENTER, result); + assertEquals(InAppLayoutService.InAppLayout.BOTTOM, result); } @Test - public void getInAppLayout_shouldHandleLargePaddingValues() { - // Arrange + public void getInAppLayout_shouldReturnCenter_whenTopIsPositiveAndBottomIsZero() { + // Arrange - positive top with zero bottom: neither edge is auto-expand InAppPadding padding = new InAppPadding(0, 1000, 0, 0); // Act InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); // Assert - assertEquals(InAppLayoutService.InAppLayout.TOP, result); + assertEquals(InAppLayoutService.InAppLayout.CENTER, result); } } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java index b53a365f8..e8b613be3 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java @@ -12,9 +12,6 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; -import java.util.Arrays; -import java.util.Collections; - @RunWith(MockitoJUnitRunner.class) public class InAppTrackingServiceTest { @@ -159,72 +156,68 @@ public void trackInAppClose_shouldHandleBackAction() { // Remove Message Tests @Test - public void removeMessage_shouldFindAndRemoveMessage_whenMessageExists() { + public void removeMessage_shouldRemoveMessage_whenMarkedForDeletionAndNotConsumed() { // Arrange String messageId = "test-message-123"; - when(mockMessage.getMessageId()).thenReturn(messageId); + when(mockMessage.isMarkedForDeletion()).thenReturn(true); + when(mockMessage.isConsumed()).thenReturn(false); when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); - when(mockInAppManager.getMessages()).thenReturn(Arrays.asList(mockMessage)); + when(mockInAppManager.getMessageById(messageId)).thenReturn(mockMessage); // Act - trackingService.removeMessage(messageId, IterableInAppLocation.INBOX); + trackingService.removeMessage(messageId); // Assert - verify(mockInAppManager).removeMessage( - mockMessage, - IterableInAppDeleteActionType.INBOX_SWIPE, - IterableInAppLocation.INBOX - ); + verify(mockInAppManager).removeMessage(mockMessage); } @Test - public void removeMessage_shouldUseDefaultLocation_whenLocationIsNull() { + public void removeMessage_shouldNotRemove_whenNotMarkedForDeletion() { // Arrange String messageId = "test-message-123"; - when(mockMessage.getMessageId()).thenReturn(messageId); + when(mockMessage.isMarkedForDeletion()).thenReturn(false); when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); - when(mockInAppManager.getMessages()).thenReturn(Arrays.asList(mockMessage)); + when(mockInAppManager.getMessageById(messageId)).thenReturn(mockMessage); // Act - trackingService.removeMessage(messageId, null); + trackingService.removeMessage(messageId); // Assert - verify(mockInAppManager).removeMessage( - mockMessage, - IterableInAppDeleteActionType.INBOX_SWIPE, - IterableInAppLocation.IN_APP - ); + verify(mockInAppManager, never()).removeMessage(any(IterableInAppMessage.class)); } @Test - public void removeMessage_shouldNotCrash_whenMessageNotFound() { + public void removeMessage_shouldNotRemove_whenAlreadyConsumed() { // Arrange String messageId = "test-message-123"; - String differentId = "different-id-456"; - when(mockMessage.getMessageId()).thenReturn(differentId); + when(mockMessage.isMarkedForDeletion()).thenReturn(true); + when(mockMessage.isConsumed()).thenReturn(true); when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); - when(mockInAppManager.getMessages()).thenReturn(Arrays.asList(mockMessage)); + when(mockInAppManager.getMessageById(messageId)).thenReturn(mockMessage); - // Act & Assert - should not throw exception - trackingService.removeMessage(messageId, IterableInAppLocation.IN_APP); + // Act + trackingService.removeMessage(messageId); - // Should not call removeMessage since message wasn't found - verify(mockInAppManager, never()).removeMessage(any(), any(), any()); + // Assert + verify(mockInAppManager, never()).removeMessage(any(IterableInAppMessage.class)); } @Test - public void removeMessage_shouldNotCrash_whenMessagesListIsEmpty() { + public void removeMessage_shouldNotCrash_whenMessageNotFound() { // Arrange String messageId = "test-message-123"; when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); - when(mockInAppManager.getMessages()).thenReturn(Collections.emptyList()); + when(mockInAppManager.getMessageById(messageId)).thenReturn(null); // Act & Assert - should not throw exception - trackingService.removeMessage(messageId, IterableInAppLocation.IN_APP); + trackingService.removeMessage(messageId); + + // Should not call removeMessage since message wasn't found + verify(mockInAppManager, never()).removeMessage(any(IterableInAppMessage.class)); } @Test @@ -234,7 +227,7 @@ public void removeMessage_shouldNotCrash_whenApiIsNull() { InAppTrackingService nullApiService = new InAppTrackingService(null); // Act & Assert - should not throw exception - nullApiService.removeMessage(messageId, IterableInAppLocation.IN_APP); + nullApiService.removeMessage(messageId); } @Test @@ -245,7 +238,7 @@ public void removeMessage_shouldNotCrash_whenInAppManagerIsNull() { when(mockIterableApi.getInAppManager()).thenReturn(null); // Act & Assert - should not throw exception - trackingService.removeMessage(messageId, IterableInAppLocation.IN_APP); + trackingService.removeMessage(messageId); } // Track Screen View Tests From 5b4e29459f9797cdcef87b2b1dd3b6c8bebff6de Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Thu, 16 Apr 2026 17:42:26 +0100 Subject: [PATCH 10/17] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6ef8ba9..5d35f3ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fixed possible `NoSuchMethodException` crash on Android 5-10 caused by using `Map.of()` which is unavailable on those versions ### Added +- Added support for in-app messages in fully Jetpack Compose apps using a Dialog-based renderer (`IterableInAppDialogNotification`), removing the requirement for a `FragmentActivity`. - Added `imageScaleType` to `IterableEmbeddedViewConfig` to allow configuring how the image is scaled within the 16:9 container for embedded message views. - Added default values to all `IterableEmbeddedViewConfig` constructor parameters for easier configuration. From e98af86968380317a6a162db6a5d58c56bda08da Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 21 Apr 2026 18:05:53 +0100 Subject: [PATCH 11/17] Revert volatile on IterableInAppFragmentHTMLNotification statics The volatile keyword was added to the notification/clickCallback/location static fields on the Fragment path, but these are only mutated from the main thread (Fragment lifecycle callbacks). The modifier added a memory barrier with no observable race to guard against, and touched shipping code unnecessarily. Restoring the fields to their pre-PR (master) state. Made-with: Cursor --- .../iterableapi/IterableInAppFragmentHTMLNotification.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java index 5e590ee29..e88e47f1a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java @@ -54,11 +54,11 @@ public class IterableInAppFragmentHTMLNotification extends DialogFragment implem private static final int DELAY_THRESHOLD_MS = 500; @Nullable - static volatile IterableInAppFragmentHTMLNotification notification; + static IterableInAppFragmentHTMLNotification notification; @Nullable - static volatile IterableHelper.IterableUrlCallback clickCallback; + static IterableHelper.IterableUrlCallback clickCallback; @Nullable - static volatile IterableInAppLocation location; + static IterableInAppLocation location; private IterableWebView webView; private boolean loaded; From f47cba038acfed4374bf1b14d8a1c9d5ee14fe30 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 21 Apr 2026 18:06:12 +0100 Subject: [PATCH 12/17] Remove silent exception swallowing in InAppTrackingService.removeMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two try/catch blocks were silently swallowing any exception from IterableApi.getInAppManager() and InAppManager.getMessageById(), dropping the error without logging. The Fragment path's processMessageRemoval() does neither and is the reference behavior, so InAppTrackingService now matches: direct calls, no try/catch. The defensive "inAppManager == null" guard was also dead code because IterableApi.getInAppManager() is annotated @NonNull and throws RuntimeException on null — a contract the Fragment path already relies on. The companion test removeMessage_shouldNotCrash_whenInAppManagerIsNull was verifying an impossible scenario (Mockito stubbed @NonNull to return null, which Kotlin's inserted null-check then caught via the old try/catch); it's removed here. Made-with: Cursor --- .../iterableapi/InAppTrackingService.kt | 19 ++----------------- .../iterableapi/InAppTrackingServiceTest.java | 11 ----------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt index ba6ec2b9a..a0b35acb2 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt @@ -56,23 +56,8 @@ internal class InAppTrackingService internal constructor( return } - val inAppManager = try { - iterableApi.inAppManager - } catch (e: Exception) { - null - } - - if (inAppManager == null) { - IterableLogger.w(TAG, "Cannot remove message: InAppManager not initialized") - return - } - - val message: IterableInAppMessage? = try { - inAppManager.getMessageById(messageId) - } catch (e: Exception) { - null - } - + val inAppManager = iterableApi.inAppManager + val message = inAppManager.getMessageById(messageId) if (message == null) { IterableLogger.w(TAG, "Message with id $messageId does not exist") return diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java index e8b613be3..1af52075e 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java @@ -230,17 +230,6 @@ public void removeMessage_shouldNotCrash_whenApiIsNull() { nullApiService.removeMessage(messageId); } - @Test - public void removeMessage_shouldNotCrash_whenInAppManagerIsNull() { - // Arrange - String messageId = "test-message-123"; - - when(mockIterableApi.getInAppManager()).thenReturn(null); - - // Act & Assert - should not throw exception - trackingService.removeMessage(messageId); - } - // Track Screen View Tests @Test From b7594a13d5eefac0a4beb13b39a125097e020864 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 21 Apr 2026 18:06:42 +0100 Subject: [PATCH 13/17] Drop ineffective onSaveInstanceState dance in Dialog notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain android.app.Dialog does not participate in the host Activity's save-state pipeline the way DialogFragment does. Dialog.show() always invokes onCreate(null) via dispatchOnCreate(null), and our code path never calls Dialog.onRestoreInstanceState, so the savedInstanceState read in onCreate was unreachable. The onSaveInstanceState override was also never invoked (nothing wires it into the Activity state save). On config change the Dialog is auto-dismissed, dismiss() clears the static singleton, and any subsequent createInstance() produces a fresh instance with inAppOpenTracked=false — same as when the bundle dance was present. The in-memory inAppOpenTracked flag is kept as a defensive re-entry guard inside onCreate. Made-with: Cursor --- .../iterableapi/IterableInAppDialogNotification.kt | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt index fa92ee7e6..b3a3656d9 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -51,7 +51,6 @@ class IterableInAppDialogNotification internal constructor( private const val TAG = "IterableInAppDialog" private const val BACK_BUTTON = "itbl://backButton" private const val DELAY_THRESHOLD_MS = 500L - private const val IN_APP_OPEN_TRACKED = "InAppOpenTracked" @Volatile @JvmStatic @@ -126,10 +125,6 @@ class IterableInAppDialogNotification internal constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (savedInstanceState != null) { - inAppOpenTracked = savedInstanceState.getBoolean(IN_APP_OPEN_TRACKED, false) - } - requestWindowFeature(Window.FEATURE_NO_TITLE) window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) @@ -161,12 +156,6 @@ class IterableInAppDialogNotification internal constructor( prepareToShowWebView() } - override fun onSaveInstanceState(): Bundle { - val bundle = super.onSaveInstanceState() - bundle.putBoolean(IN_APP_OPEN_TRACKED, inAppOpenTracked) - return bundle - } - override fun dismiss() { backPressedCallback?.remove() backPressedCallback = null From e90f917113fe4ab75a836a1108890469dc2ef665 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 21 Apr 2026 18:07:05 +0100 Subject: [PATCH 14/17] Tighten IterableInAppDialogNotification.createInstance guard createInstance previously mutated the static clickCallback/location fields and overwrote the notification singleton before any duplicate check. The "already showing" check lived only in IterableInAppDisplayer.showIterableDialogNotificationHTML, so any direct caller of createInstance (which is @JvmStatic public) could clobber an in-flight dialog's callbacks. The guard is now inside createInstance: if a notification instance already exists it is returned unchanged and statics are untouched. Statics are also only assigned after successful construction, so a constructor throw cannot leave them in a partial state. The outer Displayer guard remains as defense-in-depth. Made-with: Cursor --- .../IterableInAppDialogNotification.kt | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt index b3a3656d9..1b9db3346 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -79,10 +79,17 @@ class IterableInAppDialogNotification internal constructor( inAppBgColor: IterableInAppMessage.InAppBgColor = IterableInAppMessage.InAppBgColor(null, 0.0), ): IterableInAppDialogNotification { - clickCallback = urlCallback - location = inAppLocation - - notification = IterableInAppDialogNotification( + val existing = notification + if (existing != null) { + IterableLogger.w( + TAG, + "createInstance called while another dialog is showing; " + + "returning existing instance without overwriting callbacks" + ) + return existing + } + + val newInstance = IterableInAppDialogNotification( activity, htmlString, callbackOnCancel, @@ -98,8 +105,12 @@ class IterableInAppDialogNotification internal constructor( InAppServices.webView, InAppServices.orientation ) - - return notification!! + + clickCallback = urlCallback + location = inAppLocation + notification = newInstance + + return newInstance } /** From f5ea99625a072945a0215cffe82a5da4aa357ed3 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 21 Apr 2026 18:07:43 +0100 Subject: [PATCH 15/17] Simplify Dialog back-press handling to a single setOnKeyListener path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous setupBackPressHandling branched on whether the host was a ComponentActivity and either registered an OnBackPressedCallback on the Activity's dispatcher or fell back to setOnKeyListener. While a Dialog is showing, back-key events go to the Dialog's window first, not the Activity — so the OnBackPressedDispatcher callback wouldn't fire for the interesting case anyway, and registering on the Activity's dispatcher can reorder against the Compose host's own BackHandlers. Unifying on setOnKeyListener handles back for every Activity subtype, including ComponentActivity, and avoids that dispatcher ordering risk. The backPressedCallback field and its dismiss() cleanup are removed, and the ComponentActivity/OnBackPressedCallback imports are dropped. Made-with: Cursor --- .../IterableInAppDialogNotification.kt | 62 +++++-------------- 1 file changed, 16 insertions(+), 46 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt index 1b9db3346..e0589f709 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -12,8 +12,6 @@ import android.view.View import android.view.Window import android.widget.FrameLayout import android.widget.RelativeLayout -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback import androidx.core.graphics.drawable.toDrawable import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -44,7 +42,6 @@ class IterableInAppDialogNotification internal constructor( private var webView: IterableWebView? = null private var loaded: Boolean = false private var orientationListener: OrientationEventListener? = null - private var backPressedCallback: OnBackPressedCallback? = null private var inAppOpenTracked: Boolean = false companion object { @@ -168,9 +165,6 @@ class IterableInAppDialogNotification internal constructor( } override fun dismiss() { - backPressedCallback?.remove() - backPressedCallback = null - orientationService.disableListener(orientationListener) orientationListener = null @@ -188,46 +182,22 @@ class IterableInAppDialogNotification internal constructor( } private fun setupBackPressHandling() { - val activity = ownerActivity ?: context as? ComponentActivity - - if (activity is ComponentActivity) { - backPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - trackingService.trackInAppClick(messageId, BACK_BUTTON, location) - trackingService.trackInAppClose( - messageId, - BACK_BUTTON, - IterableInAppCloseAction.BACK, - location - ) - - processMessageRemoval() - - dismiss() - } - } - - activity.onBackPressedDispatcher.addCallback(activity, backPressedCallback!!) - IterableLogger.d(TAG, "dialog notification back press handler registered") - } else { - IterableLogger.w(TAG, "Activity is not ComponentActivity, using legacy back press handling") - setOnKeyListener { _, keyCode, event -> - if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { - trackingService.trackInAppClick(messageId, BACK_BUTTON, location) - trackingService.trackInAppClose( - messageId, - BACK_BUTTON, - IterableInAppCloseAction.BACK, - location - ) - - processMessageRemoval() - - dismiss() - true - } else { - false - } + setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + trackingService.trackInAppClick(messageId, BACK_BUTTON, location) + trackingService.trackInAppClose( + messageId, + BACK_BUTTON, + IterableInAppCloseAction.BACK, + location + ) + + processMessageRemoval() + + dismiss() + true + } else { + false } } } From 158b0e049613d7f9e73a71aa53c1fd6035a4e644 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 21 Apr 2026 18:08:43 +0100 Subject: [PATCH 16/17] Port layout-aware enter/exit animations to Dialog for Fragment parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dialog path was doing a plain alpha fade (300ms) regardless of in-app layout, while the Fragment path uses layout-specific animations at 500ms: slide_down_custom / fade_in_custom / slide_up_custom for entry and top_exit / fade_out_custom / bottom_exit for exit. This commit closes that gap for Compose hosts without touching the shipping Fragment class. Changes: - InAppAnimationService now exposes getEnterAnimationResource(layout) and getExitAnimationResource(layout), plus a hideAndAnimateWebView method symmetric with showAndAnimateWebView. - showAndAnimateWebView now takes the layout and loads the correct animation with ITERABLE_IN_APP_ANIMATION_DURATION (500ms). - The hardcoded ANIMATION_DURATION_MS=300 was conflating two distinct timings: background transitions (300ms) and view animations (500ms). It's now replaced by the matching IterableConstants values. - IterableInAppDialogNotification.hideWebView plays the exit animation, hides the background, and dismisses after 400ms — mirroring the Fragment's hideWebView timing. When shouldAnimate=false it still dismisses synchronously so existing Dialog tests remain valid. - runResizeScript carries a TODO(future PR) pointing at the native window resize logic that still needs porting from the Fragment's resize(float). Until then, Dialog hosts rely on the HTML's window.resize() self-sizing hook, which covers fixed-height in-apps but not dynamically-resizing content. Made-with: Cursor --- .../iterableapi/InAppAnimationService.kt | 86 ++++++++++++++++--- .../IterableInAppDialogNotification.kt | 34 +++++++- 2 files changed, 106 insertions(+), 14 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.kt index aab85e713..c1085c229 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.kt @@ -7,6 +7,8 @@ import android.graphics.drawable.Drawable import android.graphics.drawable.TransitionDrawable import android.view.View import android.view.Window +import android.view.animation.AnimationUtils +import androidx.annotation.AnimRes import androidx.core.graphics.ColorUtils internal class InAppAnimationService { @@ -36,7 +38,7 @@ internal class InAppAnimationService { val layers = arrayOf(from, to) val transition = TransitionDrawable(layers) window.setBackgroundDrawable(transition) - transition.startTransition(ANIMATION_DURATION_MS) + transition.startTransition(IterableConstants.ITERABLE_IN_APP_BACKGROUND_ANIMATION_DURATION) } else { window.setBackgroundDrawable(to) } @@ -58,17 +60,78 @@ internal class InAppAnimationService { } } - fun showAndAnimateWebView(webView: View, shouldAnimate: Boolean, context: Context?) { + /** + * Returns the enter animation resource for the given in-app layout, mirroring the + * behavior of [IterableInAppFragmentHTMLNotification] so Compose/Dialog hosts get the + * same slide/fade animations as Fragment hosts. + */ + @AnimRes + fun getEnterAnimationResource(layout: InAppLayoutService.InAppLayout): Int { + return when (layout) { + InAppLayoutService.InAppLayout.TOP -> R.anim.slide_down_custom + InAppLayoutService.InAppLayout.BOTTOM -> R.anim.slide_up_custom + InAppLayoutService.InAppLayout.CENTER, + InAppLayoutService.InAppLayout.FULLSCREEN -> R.anim.fade_in_custom + } + } + + /** + * Returns the exit animation resource for the given in-app layout, mirroring the + * behavior of [IterableInAppFragmentHTMLNotification]. + */ + @AnimRes + fun getExitAnimationResource(layout: InAppLayoutService.InAppLayout): Int { + return when (layout) { + InAppLayoutService.InAppLayout.TOP -> R.anim.top_exit + InAppLayoutService.InAppLayout.BOTTOM -> R.anim.bottom_exit + InAppLayoutService.InAppLayout.CENTER, + InAppLayoutService.InAppLayout.FULLSCREEN -> R.anim.fade_out_custom + } + } + + fun showAndAnimateWebView( + webView: View, + shouldAnimate: Boolean, + context: Context?, + layout: InAppLayoutService.InAppLayout + ) { + webView.alpha = 1.0f + webView.visibility = View.VISIBLE + if (shouldAnimate && context != null) { - webView.alpha = 0f - webView.visibility = View.VISIBLE - webView.animate() - .alpha(1.0f) - .setDuration(ANIMATION_DURATION_MS.toLong()) - .start() - } else { - webView.alpha = 1.0f - webView.visibility = View.VISIBLE + try { + val anim = AnimationUtils.loadAnimation(context, getEnterAnimationResource(layout)) + anim.duration = IterableConstants.ITERABLE_IN_APP_ANIMATION_DURATION.toLong() + webView.startAnimation(anim) + } catch (e: Exception) { + IterableLogger.w(TAG, "Failed to start enter animation", e) + } + } + } + + /** + * Plays the layout-appropriate exit animation on the given view. Returns `true` when + * an animation was started, `false` otherwise (either because [shouldAnimate] was + * false or loading the animation failed). Callers should schedule dismissal + * accordingly. + */ + fun hideAndAnimateWebView( + webView: View, + shouldAnimate: Boolean, + context: Context?, + layout: InAppLayoutService.InAppLayout + ): Boolean { + if (!shouldAnimate || context == null) { + return false + } + return try { + val anim = AnimationUtils.loadAnimation(context, getExitAnimationResource(layout)) + anim.duration = IterableConstants.ITERABLE_IN_APP_ANIMATION_DURATION.toLong() + webView.startAnimation(anim) + true + } catch (e: Exception) { + IterableLogger.w(TAG, "Failed to start exit animation", e) + false } } @@ -91,7 +154,6 @@ internal class InAppAnimationService { } companion object { - private const val ANIMATION_DURATION_MS = 300 private const val TAG = "InAppAnimService" } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt index e0589f709..b8ef950a3 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -48,6 +48,7 @@ class IterableInAppDialogNotification internal constructor( private const val TAG = "IterableInAppDialog" private const val BACK_BUTTON = "itbl://backButton" private const val DELAY_THRESHOLD_MS = 500L + private const val DISMISS_DELAY_MS = 400L @Volatile @JvmStatic @@ -274,7 +275,8 @@ class IterableInAppDialogNotification internal constructor( private fun showAndAnimateWebView() { webView?.let { wv -> - animationService.showAndAnimateWebView(wv, shouldAnimate, context) + val layout = layoutService.getInAppLayout(insetPadding) + animationService.showAndAnimateWebView(wv, shouldAnimate, context, layout) } } @@ -283,6 +285,12 @@ class IterableInAppDialogNotification internal constructor( } override fun runResizeScript() { + // TODO(future PR): port IterableInAppFragmentHTMLNotification.resize(float) so the + // dialog window resizes natively to fit WebView content height (incl. debounced + // resize, gravity-aware RelativeLayout params, and full-screen fallback). + // Until then, Dialog hosts only invoke the JS `window.resize()` hook and rely on + // the HTML to self-size; content-sized in-apps that dynamically grow/shrink will + // not have the Dialog window follow. webViewService.runResizeScript(webView) } @@ -305,7 +313,29 @@ class IterableInAppDialogNotification internal constructor( } private fun hideWebView() { - dismiss() + val wv = webView + val win = window + val layout = layoutService.getInAppLayout(insetPadding) + + if (shouldAnimate && wv != null) { + animationService.hideAndAnimateWebView(wv, true, context, layout) + + if (win != null) { + animationService.hideInAppBackground( + win, + inAppBackgroundColor, + inAppBackgroundAlpha, + true + ) + } + + // Mirrors the 400ms post-animation dismiss delay used by + // IterableInAppFragmentHTMLNotification.hideWebView() so the exit animation + // has time to play before the dialog window is torn down. + wv.postDelayed({ dismiss() }, DISMISS_DELAY_MS) + } else { + dismiss() + } } private fun processMessageRemoval() { From a3f44ceb191bace50fb196efb55f0a3ec667d8b0 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Thu, 23 Apr 2026 13:06:08 +0100 Subject: [PATCH 17/17] Fixing deprecation warnings --- .../iterableapi/InAppTrackingService.kt | 31 +++---- .../IterableInAppDialogNotification.kt | 18 ++-- .../iterableapi/IterableInAppDisplayer.java | 8 +- .../iterableapi/InAppTrackingServiceTest.java | 82 +++++-------------- .../IterableInAppDialogNotificationTest.java | 11 ++- 5 files changed, 55 insertions(+), 95 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt index a0b35acb2..c4687c7ff 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt @@ -6,25 +6,25 @@ import org.json.JSONObject internal class InAppTrackingService internal constructor( private val iterableApi: IterableApi? ){ - fun trackInAppOpen(messageId: String, location: IterableInAppLocation?) { + fun trackInAppOpen(message: IterableInAppMessage, location: IterableInAppLocation?) { val loc = location ?: IterableInAppLocation.IN_APP if (iterableApi != null) { - iterableApi.trackInAppOpen(messageId, loc) - IterableLogger.d(TAG, "Tracked in-app open: $messageId at location: $loc") + iterableApi.trackInAppOpen(message, loc) + IterableLogger.d(TAG, "Tracked in-app open: ${message.messageId} at location: $loc") } else { IterableLogger.w(TAG, "Cannot track in-app open: IterableApi not initialized") } } - fun trackInAppClick(messageId: String, url: String, location: IterableInAppLocation?) { + fun trackInAppClick(message: IterableInAppMessage, url: String, location: IterableInAppLocation?) { val loc = location ?: IterableInAppLocation.IN_APP if (iterableApi != null) { - iterableApi.trackInAppClick(messageId, url, loc) + iterableApi.trackInAppClick(message, url, loc) IterableLogger.d( TAG, - "Tracked in-app click: $messageId url: $url at location: $loc" + "Tracked in-app click: ${message.messageId} url: $url at location: $loc" ) } else { IterableLogger.w(TAG, "Cannot track in-app click: IterableApi not initialized") @@ -32,7 +32,7 @@ internal class InAppTrackingService internal constructor( } fun trackInAppClose( - messageId: String, + message: IterableInAppMessage, url: String, closeAction: IterableInAppCloseAction, location: IterableInAppLocation? @@ -40,32 +40,25 @@ internal class InAppTrackingService internal constructor( val loc = location ?: IterableInAppLocation.IN_APP if (iterableApi != null) { - iterableApi.trackInAppClose(messageId, url, closeAction, loc) + iterableApi.trackInAppClose(message, url, closeAction, loc) IterableLogger.d( TAG, - "Tracked in-app close: $messageId action: $closeAction at location: $loc" + "Tracked in-app close: ${message.messageId} action: $closeAction at location: $loc" ) } else { IterableLogger.w(TAG, "Cannot track in-app close: IterableApi not initialized") } } - fun removeMessage(messageId: String) { + fun removeMessage(message: IterableInAppMessage) { if (iterableApi == null) { IterableLogger.w(TAG, "Cannot remove message: IterableApi not initialized") return } - val inAppManager = iterableApi.inAppManager - val message = inAppManager.getMessageById(messageId) - if (message == null) { - IterableLogger.w(TAG, "Message with id $messageId does not exist") - return - } - if (message.isMarkedForDeletion && !message.isConsumed) { - inAppManager.removeMessage(message) - IterableLogger.d(TAG, "Removed message: $messageId") + iterableApi.inAppManager.removeMessage(message) + IterableLogger.d(TAG, "Removed message: ${message.messageId}") } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt index b8ef950a3..18dd0a3cf 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -26,7 +26,7 @@ class IterableInAppDialogNotification internal constructor( activity: Activity, private val htmlString: String?, private val callbackOnCancel: Boolean, - private val messageId: String, + private val message: IterableInAppMessage, private val backgroundAlpha: Double, private val insetPadding: Rect, private val shouldAnimate: Boolean, @@ -70,7 +70,7 @@ class IterableInAppDialogNotification internal constructor( callbackOnCancel: Boolean, urlCallback: IterableHelper.IterableUrlCallback, inAppLocation: IterableInAppLocation, - messageId: String, + message: IterableInAppMessage, backgroundAlpha: Double, padding: Rect, animate: Boolean = false, @@ -91,7 +91,7 @@ class IterableInAppDialogNotification internal constructor( activity, htmlString, callbackOnCancel, - messageId, + message, backgroundAlpha, padding, animate, @@ -158,7 +158,7 @@ class IterableInAppDialogNotification internal constructor( setupOrientationListener() if (!inAppOpenTracked) { - trackingService.trackInAppOpen(messageId, location) + trackingService.trackInAppOpen(message, location) inAppOpenTracked = true } @@ -185,9 +185,9 @@ class IterableInAppDialogNotification internal constructor( private fun setupBackPressHandling() { setOnKeyListener { _, keyCode, event -> if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { - trackingService.trackInAppClick(messageId, BACK_BUTTON, location) + trackingService.trackInAppClick(message, BACK_BUTTON, location) trackingService.trackInAppClose( - messageId, + message, BACK_BUTTON, IterableInAppCloseAction.BACK, location @@ -296,9 +296,9 @@ class IterableInAppDialogNotification internal constructor( override fun onUrlClicked(url: String?) { url?.let { - trackingService.trackInAppClick(messageId, it, location) + trackingService.trackInAppClick(message, it, location) trackingService.trackInAppClose( - messageId, + message, it, IterableInAppCloseAction.LINK, location @@ -339,7 +339,7 @@ class IterableInAppDialogNotification internal constructor( } private fun processMessageRemoval() { - trackingService.removeMessage(messageId) + trackingService.removeMessage(message) } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java index 2f679bc98..d0e5f25a6 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java @@ -44,7 +44,7 @@ boolean showMessage(@NonNull IterableInAppMessage message, IterableInAppLocation } else { return showIterableDialogNotificationHTML(currentActivity, message.getContent().html, - message.getMessageId(), + message, clickCallback, message.getContent().backgroundAlpha, message.getContent().padding, @@ -88,7 +88,7 @@ static boolean showIterableFragmentNotificationHTML(@NonNull Context context, @N * Displays an HTML rendered InApp Notification using Dialog (for ComponentActivity/Compose support) * @param context * @param htmlString - * @param messageId + * @param message * @param clickCallback * @param backgroundAlpha * @param padding @@ -97,7 +97,7 @@ static boolean showIterableFragmentNotificationHTML(@NonNull Context context, @N * @param callbackOnCancel * @param location */ - static boolean showIterableDialogNotificationHTML(@NonNull Context context, @NonNull String htmlString, @NonNull String messageId, @NonNull final IterableHelper.IterableUrlCallback clickCallback, double backgroundAlpha, @NonNull Rect padding, boolean shouldAnimate, IterableInAppMessage.InAppBgColor bgColor, boolean callbackOnCancel, @NonNull IterableInAppLocation location) { + static boolean showIterableDialogNotificationHTML(@NonNull Context context, @NonNull String htmlString, @NonNull IterableInAppMessage message, @NonNull final IterableHelper.IterableUrlCallback clickCallback, double backgroundAlpha, @NonNull Rect padding, boolean shouldAnimate, IterableInAppMessage.InAppBgColor bgColor, boolean callbackOnCancel, @NonNull IterableInAppLocation location) { if (!(context instanceof Activity)) { IterableLogger.w(IterableInAppManager.TAG, "To display in-app notifications, the context must be an Activity"); return false; @@ -119,7 +119,7 @@ static boolean showIterableDialogNotificationHTML(@NonNull Context context, @Non // Create and show dialog (Kotlin interop) IterableInAppDialogNotification dialog = IterableInAppDialogNotification.createInstance( activity, htmlString, callbackOnCancel, clickCallback, location, - messageId, backgroundAlpha, padding, shouldAnimate, bgColor + message, backgroundAlpha, padding, shouldAnimate, bgColor ); dialog.show(); diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java index 1af52075e..29ee67dca 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java @@ -34,36 +34,31 @@ public void setup() { @Test public void trackInAppOpen_shouldCallApi_whenLocationProvided() { // Arrange - String messageId = "test-message-123"; IterableInAppLocation location = IterableInAppLocation.IN_APP; // Act - trackingService.trackInAppOpen(messageId, location); + trackingService.trackInAppOpen(mockMessage, location); // Assert - verify(mockIterableApi).trackInAppOpen(messageId, location); + verify(mockIterableApi).trackInAppOpen(mockMessage, location); } @Test public void trackInAppOpen_shouldUseDefaultLocation_whenLocationIsNull() { - // Arrange - String messageId = "test-message-123"; - // Act - trackingService.trackInAppOpen(messageId, null); + trackingService.trackInAppOpen(mockMessage, null); // Assert - verify(mockIterableApi).trackInAppOpen(messageId, IterableInAppLocation.IN_APP); + verify(mockIterableApi).trackInAppOpen(mockMessage, IterableInAppLocation.IN_APP); } @Test public void trackInAppOpen_shouldNotCrash_whenApiIsNull() { // Arrange - String messageId = "test-message-123"; InAppTrackingService nullApiService = new InAppTrackingService(null); // Act & Assert - should not throw exception - nullApiService.trackInAppOpen(messageId, IterableInAppLocation.IN_APP); + nullApiService.trackInAppOpen(mockMessage, IterableInAppLocation.IN_APP); } // Track In-App Click Tests @@ -71,41 +66,38 @@ public void trackInAppOpen_shouldNotCrash_whenApiIsNull() { @Test public void trackInAppClick_shouldCallApi_whenAllParametersProvided() { // Arrange - String messageId = "test-message-123"; String url = "https://example.com"; IterableInAppLocation location = IterableInAppLocation.INBOX; // Act - trackingService.trackInAppClick(messageId, url, location); + trackingService.trackInAppClick(mockMessage, url, location); // Assert - verify(mockIterableApi).trackInAppClick(messageId, url, location); + verify(mockIterableApi).trackInAppClick(mockMessage, url, location); } @Test public void trackInAppClick_shouldUseDefaultLocation_whenLocationIsNull() { // Arrange - String messageId = "test-message-123"; String url = "https://example.com"; // Act - trackingService.trackInAppClick(messageId, url, null); + trackingService.trackInAppClick(mockMessage, url, null); // Assert - verify(mockIterableApi).trackInAppClick(messageId, url, IterableInAppLocation.IN_APP); + verify(mockIterableApi).trackInAppClick(mockMessage, url, IterableInAppLocation.IN_APP); } @Test public void trackInAppClick_shouldHandleBackButton() { // Arrange - String messageId = "test-message-123"; String backButton = "itbl://backButton"; // Act - trackingService.trackInAppClick(messageId, backButton, IterableInAppLocation.IN_APP); + trackingService.trackInAppClick(mockMessage, backButton, IterableInAppLocation.IN_APP); // Assert - verify(mockIterableApi).trackInAppClick(messageId, backButton, IterableInAppLocation.IN_APP); + verify(mockIterableApi).trackInAppClick(mockMessage, backButton, IterableInAppLocation.IN_APP); } // Track In-App Close Tests @@ -113,44 +105,41 @@ public void trackInAppClick_shouldHandleBackButton() { @Test public void trackInAppClose_shouldCallApi_whenAllParametersProvided() { // Arrange - String messageId = "test-message-123"; String url = "https://example.com"; IterableInAppCloseAction action = IterableInAppCloseAction.LINK; IterableInAppLocation location = IterableInAppLocation.IN_APP; // Act - trackingService.trackInAppClose(messageId, url, action, location); + trackingService.trackInAppClose(mockMessage, url, action, location); // Assert - verify(mockIterableApi).trackInAppClose(messageId, url, action, location); + verify(mockIterableApi).trackInAppClose(mockMessage, url, action, location); } @Test public void trackInAppClose_shouldUseDefaultLocation_whenLocationIsNull() { // Arrange - String messageId = "test-message-123"; String url = "https://example.com"; IterableInAppCloseAction action = IterableInAppCloseAction.LINK; // Act - trackingService.trackInAppClose(messageId, url, action, null); + trackingService.trackInAppClose(mockMessage, url, action, null); // Assert - verify(mockIterableApi).trackInAppClose(messageId, url, action, IterableInAppLocation.IN_APP); + verify(mockIterableApi).trackInAppClose(mockMessage, url, action, IterableInAppLocation.IN_APP); } @Test public void trackInAppClose_shouldHandleBackAction() { // Arrange - String messageId = "test-message-123"; String backButton = "itbl://backButton"; IterableInAppCloseAction action = IterableInAppCloseAction.BACK; // Act - trackingService.trackInAppClose(messageId, backButton, action, IterableInAppLocation.IN_APP); + trackingService.trackInAppClose(mockMessage, backButton, action, IterableInAppLocation.IN_APP); // Assert - verify(mockIterableApi).trackInAppClose(messageId, backButton, action, IterableInAppLocation.IN_APP); + verify(mockIterableApi).trackInAppClose(mockMessage, backButton, action, IterableInAppLocation.IN_APP); } // Remove Message Tests @@ -158,15 +147,12 @@ public void trackInAppClose_shouldHandleBackAction() { @Test public void removeMessage_shouldRemoveMessage_whenMarkedForDeletionAndNotConsumed() { // Arrange - String messageId = "test-message-123"; when(mockMessage.isMarkedForDeletion()).thenReturn(true); when(mockMessage.isConsumed()).thenReturn(false); - when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); - when(mockInAppManager.getMessageById(messageId)).thenReturn(mockMessage); // Act - trackingService.removeMessage(messageId); + trackingService.removeMessage(mockMessage); // Assert verify(mockInAppManager).removeMessage(mockMessage); @@ -175,14 +161,10 @@ public void removeMessage_shouldRemoveMessage_whenMarkedForDeletionAndNotConsume @Test public void removeMessage_shouldNotRemove_whenNotMarkedForDeletion() { // Arrange - String messageId = "test-message-123"; when(mockMessage.isMarkedForDeletion()).thenReturn(false); - when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); - when(mockInAppManager.getMessageById(messageId)).thenReturn(mockMessage); - // Act - trackingService.removeMessage(messageId); + trackingService.removeMessage(mockMessage); // Assert verify(mockInAppManager, never()).removeMessage(any(IterableInAppMessage.class)); @@ -191,43 +173,23 @@ public void removeMessage_shouldNotRemove_whenNotMarkedForDeletion() { @Test public void removeMessage_shouldNotRemove_whenAlreadyConsumed() { // Arrange - String messageId = "test-message-123"; when(mockMessage.isMarkedForDeletion()).thenReturn(true); when(mockMessage.isConsumed()).thenReturn(true); - when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); - when(mockInAppManager.getMessageById(messageId)).thenReturn(mockMessage); - // Act - trackingService.removeMessage(messageId); + trackingService.removeMessage(mockMessage); // Assert verify(mockInAppManager, never()).removeMessage(any(IterableInAppMessage.class)); } - @Test - public void removeMessage_shouldNotCrash_whenMessageNotFound() { - // Arrange - String messageId = "test-message-123"; - - when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); - when(mockInAppManager.getMessageById(messageId)).thenReturn(null); - - // Act & Assert - should not throw exception - trackingService.removeMessage(messageId); - - // Should not call removeMessage since message wasn't found - verify(mockInAppManager, never()).removeMessage(any(IterableInAppMessage.class)); - } - @Test public void removeMessage_shouldNotCrash_whenApiIsNull() { // Arrange - String messageId = "test-message-123"; InAppTrackingService nullApiService = new InAppTrackingService(null); // Act & Assert - should not throw exception - nullApiService.removeMessage(messageId); + nullApiService.removeMessage(mockMessage); } // Track Screen View Tests @@ -237,7 +199,6 @@ public void trackScreenView_shouldCallTrackWithScreenNameData() { // Arrange String screenName = "Main Screen"; - // Act trackingService.trackScreenView(screenName); @@ -255,4 +216,3 @@ public void trackScreenView_shouldNotCrash_whenApiIsNull() { nullApiService.trackScreenView(screenName); } } - diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java index 22694613c..732765cc3 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java @@ -7,6 +7,7 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.Mockito; import org.robolectric.Robolectric; import org.robolectric.android.controller.ActivityController; import org.robolectric.shadows.ShadowDialog; @@ -142,7 +143,7 @@ public void displayer_shouldRejectDuplicate_whenDialogAlreadyShowing() { createDialog().show(); boolean result = IterableInAppDisplayer.showIterableDialogNotificationHTML( - activity, "", "msg-2", uri -> { }, + activity, "", mockMessage("msg-2"), uri -> { }, 0.5, new Rect(), false, new IterableInAppMessage.InAppBgColor(null, 0.0), true, IterableInAppLocation.IN_APP @@ -165,11 +166,17 @@ private IterableInAppDialogNotification createDialogWithPadding(Rect padding) { true, uri -> { }, IterableInAppLocation.IN_APP, - "test-message", + mockMessage("test-message"), 0.5, padding, false, new IterableInAppMessage.InAppBgColor(null, 0.0) ); } + + private IterableInAppMessage mockMessage(String messageId) { + IterableInAppMessage message = Mockito.mock(IterableInAppMessage.class); + Mockito.when(message.getMessageId()).thenReturn(messageId); + return message; + } }