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.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.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..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.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.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..ddd3088e9 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -0,0 +1,322 @@ +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 internal 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 + + @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, + InAppServices.layout, + InAppServices.animation, + InAppServices.tracking, + InAppServices.webView, + InAppServices.orientation + ) + + return notification!! + } + + /** + * 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() + + trackingService.trackInAppOpen(messageId, location) + + prepareToShowWebView() + } + + override fun dismiss() { + backPressedCallback?.remove() + backPressedCallback = null + + orientationService.disableListener(orientationListener) + orientationListener = null + + webViewService.cleanupWebView(webView) + webView = null + + 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 + ) + + processMessageRemoval() + + dismiss() + } + } + + activity.onBackPressedDispatcher.addCallback(activity, backPressedCallback!!) + IterableLogger.d(TAG, "dialog notification back press handler registered") + } else { + IterableLogger.w(TAG, "Activity is not ComponentActivity, using legacy back press handling") + setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + trackingService.trackInAppClick(messageId, BACK_BUTTON, location) + trackingService.trackInAppClose( + messageId, + BACK_BUTTON, + IterableInAppCloseAction.BACK, + location + ) + + processMessageRemoval() + + dismiss() + true + } else { + false + } + } + } + } + + 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 -> + animationService.showAndAnimateWebView(wv, shouldAnimate, context) + } + } + + override fun setLoaded(loaded: Boolean) { + this.loaded = loaded + } + + override fun runResizeScript() { + webViewService.runResizeScript(webView) + } + + override fun onUrlClicked(url: String?) { + url?.let { + 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() { + 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(); 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); + } +} +