From 8a018c04cab6f5302b3e9f7d927b9e3cbda20eb2 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Thu, 15 Jan 2026 15:15:23 +0000 Subject: [PATCH 1/2] 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 2d19960beb9b745ca52badd6cf8d60222e280583 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Fri, 16 Jan 2026 13:19:48 +0000 Subject: [PATCH 2/2] 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); + } +} +