diff --git a/CHANGELOG.md b/CHANGELOG.md index e1224e4aa..37f0231ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - `FORCE_RESPECT_BOUNDS` — ensures in-app content never overlaps system bars, keeping UI elements like the close button always accessible. - Added `imageScaleType` to `IterableEmbeddedViewConfig` to allow configuring how the image is scaled within the 16:9 container for embedded message views. - Added default values to all `IterableEmbeddedViewConfig` constructor parameters for easier configuration. +- Added support for in-app messages in fully Jetpack Compose apps using a Dialog-based renderer (`IterableInAppDialogNotification`), removing the requirement for a `FragmentActivity`. ### Fixed - Fixed `IterableEmbeddedView` card layout rendering issues: image now displays at a 16:9 aspect ratio instead of collapsing to zero height, card container no longer expands to fill the parent, missing end margin on the card is now applied, bottom spacing on buttons is no longer cut off, and the image properly clips to the card's rounded corners. 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..c1085c229 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppAnimationService.kt @@ -0,0 +1,160 @@ +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 android.view.animation.AnimationUtils +import androidx.annotation.AnimRes +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(IterableConstants.ITERABLE_IN_APP_BACKGROUND_ANIMATION_DURATION) + } 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) + } + } + + /** + * Returns the enter animation resource for the given in-app layout, mirroring the + * behavior of [IterableInAppFragmentHTMLNotification] so Compose/Dialog hosts get the + * same slide/fade animations as Fragment hosts. + */ + @AnimRes + fun getEnterAnimationResource(layout: InAppLayoutService.InAppLayout): Int { + return when (layout) { + InAppLayoutService.InAppLayout.TOP -> R.anim.slide_down_custom + InAppLayoutService.InAppLayout.BOTTOM -> R.anim.slide_up_custom + InAppLayoutService.InAppLayout.CENTER, + InAppLayoutService.InAppLayout.FULLSCREEN -> R.anim.fade_in_custom + } + } + + /** + * Returns the exit animation resource for the given in-app layout, mirroring the + * behavior of [IterableInAppFragmentHTMLNotification]. + */ + @AnimRes + fun getExitAnimationResource(layout: InAppLayoutService.InAppLayout): Int { + return when (layout) { + InAppLayoutService.InAppLayout.TOP -> R.anim.top_exit + InAppLayoutService.InAppLayout.BOTTOM -> R.anim.bottom_exit + InAppLayoutService.InAppLayout.CENTER, + InAppLayoutService.InAppLayout.FULLSCREEN -> R.anim.fade_out_custom + } + } + + fun showAndAnimateWebView( + webView: View, + shouldAnimate: Boolean, + context: Context?, + layout: InAppLayoutService.InAppLayout + ) { + webView.alpha = 1.0f + webView.visibility = View.VISIBLE + + if (shouldAnimate && context != null) { + try { + val anim = AnimationUtils.loadAnimation(context, getEnterAnimationResource(layout)) + anim.duration = IterableConstants.ITERABLE_IN_APP_ANIMATION_DURATION.toLong() + webView.startAnimation(anim) + } catch (e: Exception) { + IterableLogger.w(TAG, "Failed to start enter animation", e) + } + } + } + + /** + * Plays the layout-appropriate exit animation on the given view. Returns `true` when + * an animation was started, `false` otherwise (either because [shouldAnimate] was + * false or loading the animation failed). Callers should schedule dismissal + * accordingly. + */ + fun hideAndAnimateWebView( + webView: View, + shouldAnimate: Boolean, + context: Context?, + layout: InAppLayoutService.InAppLayout + ): Boolean { + if (!shouldAnimate || context == null) { + return false + } + return try { + val anim = AnimationUtils.loadAnimation(context, getExitAnimationResource(layout)) + anim.duration = IterableConstants.ITERABLE_IN_APP_ANIMATION_DURATION.toLong() + webView.startAnimation(anim) + true + } catch (e: Exception) { + IterableLogger.w(TAG, "Failed to start exit animation", e) + false + } + } + + 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 TAG = "InAppAnimService" + } +} + 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..3c4c9cf5a --- /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.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.kt b/iterableapi/src/main/java/com/iterable/iterableapi/InAppServices.kt new file mode 100644 index 000000000..ab36c62cf --- /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 by lazy { InAppTrackingService(IterableApi.sharedInstance) } + val webView: InAppWebViewService = InAppWebViewService() + val orientation: InAppOrientationService = InAppOrientationService() +} + 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..c4687c7ff --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/InAppTrackingService.kt @@ -0,0 +1,82 @@ +package com.iterable.iterableapi + +import org.json.JSONException +import org.json.JSONObject + +internal class InAppTrackingService internal constructor( + private val iterableApi: IterableApi? +){ + fun trackInAppOpen(message: IterableInAppMessage, location: IterableInAppLocation?) { + val loc = location ?: IterableInAppLocation.IN_APP + + if (iterableApi != null) { + iterableApi.trackInAppOpen(message, loc) + IterableLogger.d(TAG, "Tracked in-app open: ${message.messageId} at location: $loc") + } else { + IterableLogger.w(TAG, "Cannot track in-app open: IterableApi not initialized") + } + } + + fun trackInAppClick(message: IterableInAppMessage, url: String, location: IterableInAppLocation?) { + val loc = location ?: IterableInAppLocation.IN_APP + + if (iterableApi != null) { + iterableApi.trackInAppClick(message, url, loc) + IterableLogger.d( + TAG, + "Tracked in-app click: ${message.messageId} url: $url at location: $loc" + ) + } else { + IterableLogger.w(TAG, "Cannot track in-app click: IterableApi not initialized") + } + } + + fun trackInAppClose( + message: IterableInAppMessage, + url: String, + closeAction: IterableInAppCloseAction, + location: IterableInAppLocation? + ) { + val loc = location ?: IterableInAppLocation.IN_APP + + if (iterableApi != null) { + iterableApi.trackInAppClose(message, url, closeAction, loc) + IterableLogger.d( + TAG, + "Tracked in-app close: ${message.messageId} action: $closeAction at location: $loc" + ) + } else { + IterableLogger.w(TAG, "Cannot track in-app close: IterableApi not initialized") + } + } + + fun removeMessage(message: IterableInAppMessage) { + if (iterableApi == null) { + IterableLogger.w(TAG, "Cannot remove message: IterableApi not initialized") + return + } + + if (message.isMarkedForDeletion && !message.isConsumed) { + iterableApi.inAppManager.removeMessage(message) + IterableLogger.d(TAG, "Removed message: ${message.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.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 new file mode 100644 index 000000000..18dd0a3cf --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -0,0 +1,345 @@ +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.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 internal constructor( + activity: Activity, + private val htmlString: String?, + private val callbackOnCancel: Boolean, + private val message: IterableInAppMessage, + 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 inAppOpenTracked: Boolean = false + + companion object { + private const val TAG = "IterableInAppDialog" + private const val BACK_BUTTON = "itbl://backButton" + private const val DELAY_THRESHOLD_MS = 500L + private const val DISMISS_DELAY_MS = 400L + + @Volatile + @JvmStatic + private var notification: IterableInAppDialogNotification? = null + + @Volatile + @JvmStatic + private var clickCallback: IterableHelper.IterableUrlCallback? = null + + @Volatile + @JvmStatic + private var location: IterableInAppLocation? = null + + @JvmStatic + @JvmOverloads + fun createInstance( + activity: Activity, + htmlString: String, + callbackOnCancel: Boolean, + urlCallback: IterableHelper.IterableUrlCallback, + inAppLocation: IterableInAppLocation, + message: IterableInAppMessage, + backgroundAlpha: Double, + padding: Rect, + animate: Boolean = false, + inAppBgColor: IterableInAppMessage.InAppBgColor = + IterableInAppMessage.InAppBgColor(null, 0.0), + ): IterableInAppDialogNotification { + val existing = notification + if (existing != null) { + IterableLogger.w( + TAG, + "createInstance called while another dialog is showing; " + + "returning existing instance without overwriting callbacks" + ) + return existing + } + + val newInstance = IterableInAppDialogNotification( + activity, + htmlString, + callbackOnCancel, + message, + backgroundAlpha, + padding, + animate, + inAppBgColor.bgAlpha, + inAppBgColor.bgHexColor, + InAppServices.layout, + InAppServices.animation, + InAppServices.tracking, + InAppServices.webView, + InAppServices.orientation + ) + + clickCallback = urlCallback + location = inAppLocation + notification = newInstance + + return newInstance + } + + /** + * Returns the notification instance currently being shown + * + * @return notification instance + */ + @JvmStatic + fun getInstance(): IterableInAppDialogNotification? = notification + } + + override fun onStart() { + super.onStart() + + window?.let { layoutService.setWindowToFullScreen(it) } + + 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) + + requestWindowFeature(Window.FEATURE_NO_TITLE) + window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) + + val layout = layoutService.getInAppLayout(insetPadding) + window?.let { layoutService.configureWindowFlags(it, layout) } + + if (layout != InAppLayoutService.InAppLayout.FULLSCREEN) { + window?.let { layoutService.applyWindowGravity(it, insetPadding, "onCreate") } + } + + setOnCancelListener { + if (callbackOnCancel && clickCallback != null) { + clickCallback?.execute(null) + } + } + + setupBackPressHandling() + + val contentView = createContentView() + setContentView(contentView) + + setupOrientationListener() + + if (!inAppOpenTracked) { + trackingService.trackInAppOpen(message, location) + inAppOpenTracked = true + } + + prepareToShowWebView() + } + + override fun dismiss() { + orientationService.disableListener(orientationListener) + orientationListener = null + + webViewService.cleanupWebView(webView) + webView = null + + // Always clear statics. Unlike DialogFragment, Dialog is not recreated + // after configuration changes, so a stale reference would permanently + // block isShowingInApp() and prevent future in-app messages. + notification = null + clickCallback = null + location = null + + super.dismiss() + } + + private fun setupBackPressHandling() { + setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + trackingService.trackInAppClick(message, BACK_BUTTON, location) + trackingService.trackInAppClose( + message, + BACK_BUTTON, + IterableInAppCloseAction.BACK, + location + ) + + processMessageRemoval() + + dismiss() + true + } else { + false + } + } + } + + private fun createContentView(): View { + val context = context + + webView = webViewService.createConfiguredWebView( + context, + this@IterableInAppDialogNotification, + htmlString ?: "" + ) + + val frameLayout = FrameLayout(context) + val layout = layoutService.getInAppLayout(insetPadding) + val isFullScreen = layout == InAppLayoutService.InAppLayout.FULLSCREEN + + if (isFullScreen) { + val params = webViewService.createWebViewLayoutParams(true) + frameLayout.addView(webView, params) + } else { + val webViewContainer = RelativeLayout(context) + + val containerParams = webViewService.createContainerLayoutParams(layout) + + val webViewParams = webViewService.createCenteredWebViewParams() + + webViewContainer.addView(webView, webViewParams) + frameLayout.addView(webViewContainer, containerParams) + + 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() { + 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 -> + val layout = layoutService.getInAppLayout(insetPadding) + animationService.showAndAnimateWebView(wv, shouldAnimate, context, layout) + } + } + + override fun setLoaded(loaded: Boolean) { + this.loaded = loaded + } + + override fun runResizeScript() { + // TODO(future PR): port IterableInAppFragmentHTMLNotification.resize(float) so the + // dialog window resizes natively to fit WebView content height (incl. debounced + // resize, gravity-aware RelativeLayout params, and full-screen fallback). + // Until then, Dialog hosts only invoke the JS `window.resize()` hook and rely on + // the HTML to self-size; content-sized in-apps that dynamically grow/shrink will + // not have the Dialog window follow. + webViewService.runResizeScript(webView) + } + + override fun onUrlClicked(url: String?) { + url?.let { + trackingService.trackInAppClick(message, it, location) + trackingService.trackInAppClose( + message, + it, + IterableInAppCloseAction.LINK, + location + ) + + clickCallback?.execute(Uri.parse(it)) + } + + processMessageRemoval() + hideWebView() + + } + + private fun hideWebView() { + val wv = webView + val win = window + val layout = layoutService.getInAppLayout(insetPadding) + + if (shouldAnimate && wv != null) { + animationService.hideAndAnimateWebView(wv, true, context, layout) + + if (win != null) { + animationService.hideInAppBackground( + win, + inAppBackgroundColor, + inAppBackgroundAlpha, + true + ) + } + + // Mirrors the 400ms post-animation dismiss delay used by + // IterableInAppFragmentHTMLNotification.hideWebView() so the exit animation + // has time to play before the dialog window is torn down. + wv.postDelayed({ dismiss() }, DISMISS_DELAY_MS) + } else { + dismiss() + } + } + + private fun processMessageRemoval() { + trackingService.removeMessage(message) + } +} + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDisplayer.java index 66dd34792..d0e5f25a6 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 + ); + } else { + return showIterableDialogNotificationHTML(currentActivity, + message.getContent().html, + message, + clickCallback, + message.getContent().backgroundAlpha, + message.getContent().padding, + message.getContent().inAppDisplaySettings.shouldAnimate, + message.getContent().inAppDisplaySettings.inAppBgColor, + true, location); + } } return false; } @@ -64,10 +79,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 message + * @param clickCallback + * @param backgroundAlpha + * @param padding + * @param shouldAnimate + * @param bgColor + * @param callbackOnCancel + * @param location + */ + static boolean showIterableDialogNotificationHTML(@NonNull Context context, @NonNull String htmlString, @NonNull IterableInAppMessage message, @NonNull final IterableHelper.IterableUrlCallback clickCallback, double backgroundAlpha, @NonNull Rect padding, boolean shouldAnimate, IterableInAppMessage.InAppBgColor bgColor, boolean callbackOnCancel, @NonNull IterableInAppLocation location) { + if (!(context instanceof Activity)) { + IterableLogger.w(IterableInAppManager.TAG, "To display in-app notifications, the context must be an Activity"); + return false; + } + + 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, + message, backgroundAlpha, padding, shouldAnimate, bgColor + ); + dialog.show(); + + IterableLogger.d(IterableInAppManager.TAG, "Displaying in-app notification via Dialog for ComponentActivity"); + + return true; + } } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java new file mode 100644 index 000000000..121bd7d0b --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppLayoutServiceTest.java @@ -0,0 +1,167 @@ +package com.iterable.iterableapi; + +import static org.junit.Assert.assertEquals; + +import android.view.Gravity; + +import org.junit.Before; +import org.junit.Test; + +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_whenBottomIsAutoExpand() { + // Arrange - top=0, bottom=-1 (AutoExpand sentinel from decodePadding) + InAppPadding padding = new InAppPadding(0, 0, 0, -1); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.TOP, result); + } + + @Test + public void getInAppLayout_shouldReturnBottom_whenTopIsAutoExpand() { + // Arrange - top=-1 (AutoExpand), bottom=0 + InAppPadding padding = new InAppPadding(0, -1, 0, 0); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.BOTTOM, result); + } + + @Test + public void getInAppLayout_shouldReturnCenter_whenBothTopAndBottomHavePadding() { + // Arrange - both have positive percentage padding + 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_shouldReturnCenter_whenBothAutoExpand() { + // Arrange - both are AutoExpand (-1) + InAppPadding padding = new InAppPadding(0, -1, 0, -1); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.CENTER, result); + } + + @Test + public void getInAppLayout_shouldReturnCenter_whenPositivePaddingOnBothEdges() { + // Arrange - both edges have positive percentage values + InAppPadding padding = new InAppPadding(0, 100, 0, 100); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.CENTER, result); + } + + // Vertical Location Tests (Business Logic - derives from layout type) + + @Test + public void getVerticalLocation_shouldReturnTop_whenTopLayout() { + // Arrange - top=0, bottom=-1 (AutoExpand) → TOP layout + InAppPadding padding = new InAppPadding(0, 0, 0, -1); + + // Act + int result = layoutService.getVerticalLocation(padding); + + // Assert + assertEquals(Gravity.TOP, result); + } + + @Test + public void getVerticalLocation_shouldReturnBottom_whenBottomLayout() { + // Arrange - top=-1 (AutoExpand), bottom=0 → BOTTOM layout + InAppPadding padding = new InAppPadding(0, -1, 0, 0); + + // Act + int result = layoutService.getVerticalLocation(padding); + + // Assert + assertEquals(Gravity.BOTTOM, result); + } + + @Test + public void getVerticalLocation_shouldReturnCenterVertical_whenCenterLayout() { + // Arrange - both have positive padding → CENTER layout + 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_shouldReturnBottom_whenTopIsNegativeAndBottomIsZero() { + // Arrange - any negative top value with zero bottom → BOTTOM + InAppPadding padding = new InAppPadding(0, -10, 0, 0); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.BOTTOM, result); + } + + @Test + public void getInAppLayout_shouldReturnCenter_whenTopIsPositiveAndBottomIsZero() { + // Arrange - positive top with zero bottom: neither edge is auto-expand + InAppPadding padding = new InAppPadding(0, 1000, 0, 0); + + // Act + InAppLayoutService.InAppLayout result = layoutService.getInAppLayout(padding); + + // Assert + assertEquals(InAppLayoutService.InAppLayout.CENTER, 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..fd62aa663 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppOrientationServiceTest.java @@ -0,0 +1,208 @@ +package com.iterable.iterableapi; + +import static org.junit.Assert.assertEquals; + +import org.junit.Before; +import org.junit.Test; + +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..29ee67dca --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/InAppTrackingServiceTest.java @@ -0,0 +1,218 @@ +package com.iterable.iterableapi; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@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 + IterableInAppLocation location = IterableInAppLocation.IN_APP; + + // Act + trackingService.trackInAppOpen(mockMessage, location); + + // Assert + verify(mockIterableApi).trackInAppOpen(mockMessage, location); + } + + @Test + public void trackInAppOpen_shouldUseDefaultLocation_whenLocationIsNull() { + // Act + trackingService.trackInAppOpen(mockMessage, null); + + // Assert + verify(mockIterableApi).trackInAppOpen(mockMessage, IterableInAppLocation.IN_APP); + } + + @Test + public void trackInAppOpen_shouldNotCrash_whenApiIsNull() { + // Arrange + InAppTrackingService nullApiService = new InAppTrackingService(null); + + // Act & Assert - should not throw exception + nullApiService.trackInAppOpen(mockMessage, IterableInAppLocation.IN_APP); + } + + // Track In-App Click Tests + + @Test + public void trackInAppClick_shouldCallApi_whenAllParametersProvided() { + // Arrange + String url = "https://example.com"; + IterableInAppLocation location = IterableInAppLocation.INBOX; + + // Act + trackingService.trackInAppClick(mockMessage, url, location); + + // Assert + verify(mockIterableApi).trackInAppClick(mockMessage, url, location); + } + + @Test + public void trackInAppClick_shouldUseDefaultLocation_whenLocationIsNull() { + // Arrange + String url = "https://example.com"; + + // Act + trackingService.trackInAppClick(mockMessage, url, null); + + // Assert + verify(mockIterableApi).trackInAppClick(mockMessage, url, IterableInAppLocation.IN_APP); + } + + @Test + public void trackInAppClick_shouldHandleBackButton() { + // Arrange + String backButton = "itbl://backButton"; + + // Act + trackingService.trackInAppClick(mockMessage, backButton, IterableInAppLocation.IN_APP); + + // Assert + verify(mockIterableApi).trackInAppClick(mockMessage, backButton, IterableInAppLocation.IN_APP); + } + + // Track In-App Close Tests + + @Test + public void trackInAppClose_shouldCallApi_whenAllParametersProvided() { + // Arrange + String url = "https://example.com"; + IterableInAppCloseAction action = IterableInAppCloseAction.LINK; + IterableInAppLocation location = IterableInAppLocation.IN_APP; + + // Act + trackingService.trackInAppClose(mockMessage, url, action, location); + + // Assert + verify(mockIterableApi).trackInAppClose(mockMessage, url, action, location); + } + + @Test + public void trackInAppClose_shouldUseDefaultLocation_whenLocationIsNull() { + // Arrange + String url = "https://example.com"; + IterableInAppCloseAction action = IterableInAppCloseAction.LINK; + + // Act + trackingService.trackInAppClose(mockMessage, url, action, null); + + // Assert + verify(mockIterableApi).trackInAppClose(mockMessage, url, action, IterableInAppLocation.IN_APP); + } + + @Test + public void trackInAppClose_shouldHandleBackAction() { + // Arrange + String backButton = "itbl://backButton"; + IterableInAppCloseAction action = IterableInAppCloseAction.BACK; + + // Act + trackingService.trackInAppClose(mockMessage, backButton, action, IterableInAppLocation.IN_APP); + + // Assert + verify(mockIterableApi).trackInAppClose(mockMessage, backButton, action, IterableInAppLocation.IN_APP); + } + + // Remove Message Tests + + @Test + public void removeMessage_shouldRemoveMessage_whenMarkedForDeletionAndNotConsumed() { + // Arrange + when(mockMessage.isMarkedForDeletion()).thenReturn(true); + when(mockMessage.isConsumed()).thenReturn(false); + when(mockIterableApi.getInAppManager()).thenReturn(mockInAppManager); + + // Act + trackingService.removeMessage(mockMessage); + + // Assert + verify(mockInAppManager).removeMessage(mockMessage); + } + + @Test + public void removeMessage_shouldNotRemove_whenNotMarkedForDeletion() { + // Arrange + when(mockMessage.isMarkedForDeletion()).thenReturn(false); + + // Act + trackingService.removeMessage(mockMessage); + + // Assert + verify(mockInAppManager, never()).removeMessage(any(IterableInAppMessage.class)); + } + + @Test + public void removeMessage_shouldNotRemove_whenAlreadyConsumed() { + // Arrange + when(mockMessage.isMarkedForDeletion()).thenReturn(true); + when(mockMessage.isConsumed()).thenReturn(true); + + // Act + trackingService.removeMessage(mockMessage); + + // Assert + verify(mockInAppManager, never()).removeMessage(any(IterableInAppMessage.class)); + } + + @Test + public void removeMessage_shouldNotCrash_whenApiIsNull() { + // Arrange + InAppTrackingService nullApiService = new InAppTrackingService(null); + + // Act & Assert - should not throw exception + nullApiService.removeMessage(mockMessage); + } + + // 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); + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java new file mode 100644 index 000000000..732765cc3 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java @@ -0,0 +1,182 @@ +package com.iterable.iterableapi; + +import android.graphics.Rect; + +import androidx.activity.ComponentActivity; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.robolectric.Robolectric; +import org.robolectric.android.controller.ActivityController; +import org.robolectric.shadows.ShadowDialog; + +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertTrue; + +public class IterableInAppDialogNotificationTest extends BaseTest { + + private ActivityController controller; + private ComponentActivity activity; + + @Before + public void setUp() { + IterableTestUtils.createIterableApiNew(); + controller = Robolectric.buildActivity(ComponentActivity.class).create().start().resume(); + activity = controller.get(); + } + + @After + public void tearDown() { + if (ShadowDialog.getLatestDialog() != null) { + ShadowDialog.getLatestDialog().dismiss(); + } + if (controller != null) { + controller.pause().stop().destroy(); + } + IterableTestUtils.resetIterableApi(); + } + + // ===== Singleton Lifecycle Tests ===== + + @Test + public void getInstance_shouldReturnNull_whenNoDialogCreated() { + assertNull(IterableInAppDialogNotification.getInstance()); + } + + @Test + public void getInstance_shouldReturnInstance_afterCreateInstance() { + createDialog(); + assertNotNull(IterableInAppDialogNotification.getInstance()); + } + + @Test + public void getInstance_shouldReturnNull_afterDismiss() { + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + dialog.dismiss(); + assertNull(IterableInAppDialogNotification.getInstance()); + } + + // ===== Show/Dismiss Tests ===== + + @Test + public void show_shouldDisplayDialog() { + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + assertTrue(dialog.isShowing()); + } + + @Test + public void dismiss_shouldCleanupSingletonState() { + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + assertNotNull(IterableInAppDialogNotification.getInstance()); + + dialog.dismiss(); + assertNull(IterableInAppDialogNotification.getInstance()); + } + + @Test + public void testDoNotCrashOnResizeAfterDismiss() { + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + dialog.dismiss(); + dialog.runResizeScript(); + } + + // ===== URL Click Tests ===== + + @Test + public void onUrlClicked_shouldNotCrash_whenUrlIsNull() { + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + dialog.onUrlClicked(null); + } + + @Test + public void onUrlClicked_shouldDismissDialog() { + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + assertTrue(dialog.isShowing()); + + dialog.onUrlClicked("https://example.com"); + assertNull(IterableInAppDialogNotification.getInstance()); + } + + // ===== Layout Variant Tests ===== + + @Test + public void showDialog_shouldDisplay_withFullscreenPadding() { + IterableInAppDialogNotification dialog = createDialogWithPadding(new Rect(0, 0, 0, 0)); + dialog.show(); + assertTrue(dialog.isShowing()); + } + + @Test + public void showDialog_shouldDisplay_withTopPadding() { + IterableInAppDialogNotification dialog = createDialogWithPadding(new Rect(0, 10, 0, 0)); + dialog.show(); + assertTrue(dialog.isShowing()); + } + + @Test + public void showDialog_shouldDisplay_withBottomPadding() { + IterableInAppDialogNotification dialog = createDialogWithPadding(new Rect(0, 0, 0, 10)); + dialog.show(); + assertTrue(dialog.isShowing()); + } + + @Test + public void showDialog_shouldDisplay_withCenterPadding() { + IterableInAppDialogNotification dialog = createDialogWithPadding(new Rect(0, 10, 0, 10)); + dialog.show(); + assertTrue(dialog.isShowing()); + } + + // ===== Displayer Integration Tests ===== + + @Test + public void displayer_shouldRejectDuplicate_whenDialogAlreadyShowing() { + createDialog().show(); + + boolean result = IterableInAppDisplayer.showIterableDialogNotificationHTML( + activity, "", mockMessage("msg-2"), uri -> { }, + 0.5, new Rect(), false, + new IterableInAppMessage.InAppBgColor(null, 0.0), + true, IterableInAppLocation.IN_APP + ); + + // Should reject since one is already showing + assertTrue(!result); + } + + // ===== Helper Methods ===== + + private IterableInAppDialogNotification createDialog() { + return createDialogWithPadding(new Rect(0, 0, 0, 0)); + } + + private IterableInAppDialogNotification createDialogWithPadding(Rect padding) { + return IterableInAppDialogNotification.createInstance( + activity, + "Test", + true, + uri -> { }, + IterableInAppLocation.IN_APP, + mockMessage("test-message"), + 0.5, + padding, + false, + new IterableInAppMessage.InAppBgColor(null, 0.0) + ); + } + + private IterableInAppMessage mockMessage(String messageId) { + IterableInAppMessage message = Mockito.mock(IterableInAppMessage.class); + Mockito.when(message.getMessageId()).thenReturn(messageId); + return message; + } +}