From 26432840beb8d2a5744a69262258fd5179346c16 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 20 Jan 2026 10:58:22 +0300 Subject: [PATCH 01/59] MOBILEWEBVIEW-3: Add mindbox webview --- kmp-common-sdk | 2 +- .../InAppMessageViewDisplayerImpl.kt | 22 ++ .../view/AbstractInAppViewHolder.kt | 34 +- .../presentation/view/InAppViewHolder.kt | 6 + .../presentation/view/WebAppInterface.kt | 29 -- .../view/WebViewInappViewHolder.kt | 318 +++++++----------- 6 files changed, 187 insertions(+), 224 deletions(-) delete mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebAppInterface.kt diff --git a/kmp-common-sdk b/kmp-common-sdk index eb48474d7..dcbfb2538 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit eb48474d74edb4ea1e75197adb1d36c48cef8d3e +Subproject commit dcbfb253873646e356d8ac23c8c9a46777f9c5d2 diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index f640a1fe9..57a587087 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -192,6 +192,28 @@ internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: isRestored: Boolean = false, ) { if (!isRestored) isActionExecuted = false + if (isRestored) { + val restoredHolder: InAppViewHolder<*>? = pausedHolder + ?.takeIf { it.canReuseOnRestore(wrapper.inAppType.inAppId) } + if (restoredHolder != null) { + currentHolder = restoredHolder + pausedHolder = null + currentActivity?.root?.let { root -> + restoredHolder.reattach(object : MindboxView { + override val container: ViewGroup = root + + override fun requestPermission() { + currentActivity?.let { activity -> + mindboxNotificationManager.requestPermission(activity = activity) + } + } + }) + } ?: run { + mindboxLogE("failed to reattach inApp: currentRoot is null") + } + return + } + } val callbackWrapper = InAppCallbackWrapper(inAppCallback) { wrapper.inAppActionCallbacks.onInAppDismiss.onDismiss() pausedHolder?.hide() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt index fd668e799..930900ccb 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt @@ -170,6 +170,25 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder inAppLayout.prepareLayoutForInApp(wrapper.inAppType) } + private fun attachToRoot(currentRoot: ViewGroup) { + if (_currentDialog == null) { + initView(currentRoot) + return + } + currentRoot.removeChildById(R.id.inapp_layout_container) + _currentDialog?.parent.safeAs()?.removeView(_currentDialog) + currentRoot.addView(currentDialog) + } + + private fun startPositionController(currentRoot: ViewGroup) { + positionController?.stop() + positionController = null + val isRepositioningEnabled = currentRoot.context.resources.getBoolean(R.bool.mindbox_support_inapp_on_fragment) + positionController = isRepositioningEnabled.takeIf { it }?.run { + InAppPositionController().apply { start(currentDialog) } + } + } + private fun restoreKeyboard() { typingView?.let { view -> view.requestFocus() @@ -184,11 +203,16 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder override fun show(currentRoot: MindboxView) { isInAppMessageActive = true - initView(currentRoot.container) - val isRepositioningEnabled = currentRoot.container.context.resources.getBoolean(R.bool.mindbox_support_inapp_on_fragment) - positionController = isRepositioningEnabled.takeIf { it }?.run { - InAppPositionController().apply { start(currentDialog) } - } + attachToRoot(currentRoot.container) + startPositionController(currentRoot.container) + hideKeyboard(currentRoot.container) + inAppActionHandler.mindboxView = currentRoot + } + + override fun reattach(currentRoot: MindboxView) { + isInAppMessageActive = true + attachToRoot(currentRoot.container) + startPositionController(currentRoot.container) hideKeyboard(currentRoot.container) inAppActionHandler.mindboxView = currentRoot } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt index c7424a93f..091c0875c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt @@ -12,6 +12,12 @@ internal interface InAppViewHolder { fun show(currentRoot: MindboxView) + fun reattach(currentRoot: MindboxView) { + show(currentRoot) + } + + fun canReuseOnRestore(inAppId: String): Boolean = false + fun hide() fun release() {} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebAppInterface.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebAppInterface.kt deleted file mode 100644 index f87e0f1f3..000000000 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebAppInterface.kt +++ /dev/null @@ -1,29 +0,0 @@ -package cloud.mindbox.mobile_sdk.inapp.presentation.view - -import android.annotation.SuppressLint -import android.webkit.JavascriptInterface -import cloud.mindbox.mobile_sdk.logger.mindboxLogI - -@SuppressLint("JavascriptInterface", "UNUSED") -internal class WebAppInterface( - private val paramsProvider: ParamProvider, - private val onAction: (String, String) -> Unit -) { - - @JavascriptInterface - fun receiveParam(key: String): String? { - return paramsProvider.get(key).also { - mindboxLogI("Call receiveParam key: $key, return: $it") - } - } - - @JavascriptInterface - fun postMessage(action: String, data: String) { - mindboxLogI("Call postMessage action: $action, data: $data") - onAction(action, data) - } -} - -internal fun interface ParamProvider { - fun get(key: String): String? -} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 667cf775c..17e2a817b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -1,16 +1,10 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view -import android.annotation.SuppressLint -import android.graphics.Color -import android.os.Build -import android.view.KeyEvent import android.view.ViewGroup -import android.webkit.* import android.widget.RelativeLayout -import androidx.core.view.isInvisible -import androidx.core.view.isVisible import cloud.mindbox.mobile_sdk.BuildConfig import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.fromJson import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto @@ -19,9 +13,11 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView +import cloud.mindbox.mobile_sdk.inapp.webview.* import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.logger.mindboxLogW import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.getShortUserAgent @@ -31,32 +27,33 @@ import cloud.mindbox.mobile_sdk.utils.Constants import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch import com.android.volley.Request import com.android.volley.RequestQueue +import com.android.volley.VolleyError import com.android.volley.toolbox.StringRequest import com.android.volley.toolbox.Volley +import com.google.gson.Gson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.lang.ref.WeakReference import java.util.Timer import java.util.TreeMap import kotlin.concurrent.timer +@OptIn(InternalMindboxApi::class) internal class WebViewInAppViewHolder( override val wrapper: InAppTypeWrapper, private val inAppCallback: InAppCallback, ) : AbstractInAppViewHolder() { companion object { - @SuppressLint("StaticFieldLeak") - private var webView: WeakReference = WeakReference(null) private const val INIT_TIMEOUT_MS = 7_000L private const val TIMER = "CLOSE_INAPP_TIMER" } private var closeInappTimer: Timer? = null + private var webViewController: WebViewController? = null - private val gson by mindboxInject { gson } + private val gson: Gson by mindboxInject { gson } override val isActive: Boolean get() = isInAppMessageActive @@ -70,153 +67,144 @@ internal class WebViewInAppViewHolder( } private fun addJavascriptInterface(layer: Layer.WebViewLayer, configuration: Configuration) { - webView.get()?.apply { - val params = TreeMap(String.CASE_INSENSITIVE_ORDER).apply { - put("sdkVersion", Mindbox.getSdkVersion()) - put("endpointId", configuration.endpointId) - put("deviceUuid", MindboxPreferences.deviceUuid) - put("sdkVersionNumeric", Constants.SDK_VERSION_NUMERIC.toString()) - putAll(layer.params) - } - val provider = ParamProvider { key -> - params[key] + val controller: WebViewController = webViewController ?: return + val params: TreeMap = TreeMap(String.CASE_INSENSITIVE_ORDER).apply { + put("sdkVersion", Mindbox.getSdkVersion()) + put("endpointId", configuration.endpointId) + put("deviceUuid", MindboxPreferences.deviceUuid) + put("sdkVersionNumeric", Constants.SDK_VERSION_NUMERIC.toString()) + putAll(layer.params) + } + val bridge: WebViewJsBridge = object : WebViewJsBridge { + override fun getParam(key: String): String? { + return params[key] } - addJavascriptInterface( - WebAppInterface(provider) { action, data -> - handleWebViewAction(action, data, object : WebViewAction { - override fun onInit() { - // Cancel timeout when init is received - mindboxLogI("WebView initialization completed " + Stopwatch.stop(TIMER)) - closeInappTimer?.cancel() - closeInappTimer = null - - wrapper.inAppActionCallbacks.onInAppShown.onShown() - webView.get()?.isVisible = true - } + override fun onAction(action: String, data: String) { + handleWebViewAction(action, data, object : WebViewAction { + override fun onInit() { + mindboxLogI("WebView initialization completed " + Stopwatch.stop(TIMER)) + closeInappTimer?.cancel() + closeInappTimer = null + wrapper.inAppActionCallbacks.onInAppShown.onShown() + controller.setVisibility(true) + } - override fun onCompleted(data: String) { - runCatching { - val actionDto = gson.fromJson(data).getOrThrow() - val (url, payload) = when (actionDto) { - is BackgroundDto.LayerDto.ImageLayerDto.ActionDto.RedirectUrlActionDto -> - actionDto.value to actionDto.intentPayload - is BackgroundDto.LayerDto.ImageLayerDto.ActionDto.PushPermissionActionDto -> - "" to actionDto.intentPayload - } + override fun onCompleted(data: String) { + runCatching { + val actionDto: BackgroundDto.LayerDto.ImageLayerDto.ActionDto = + gson.fromJson(data).getOrThrow() + val actionResult: Pair = when (actionDto) { + is BackgroundDto.LayerDto.ImageLayerDto.ActionDto.RedirectUrlActionDto -> + actionDto.value to actionDto.intentPayload - wrapper.inAppActionCallbacks.onInAppClick.onClick() - inAppCallback.onInAppClick( - wrapper.inAppType.inAppId, - url ?: "", - payload ?: "" - ) + is BackgroundDto.LayerDto.ImageLayerDto.ActionDto.PushPermissionActionDto -> + "" to actionDto.intentPayload } - mindboxLogI("In-app completed by webview action with data: $data") + val url: String? = actionResult.first + val payload: String? = actionResult.second + wrapper.inAppActionCallbacks.onInAppClick.onClick() + inAppCallback.onInAppClick( + wrapper.inAppType.inAppId, + url ?: "", + payload ?: "" + ) } + mindboxLogI("In-app completed by webview action with data: $data") + } - override fun onClose() { - inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) - mindboxLogI("In-app dismissed by webview action") - hide() - release() - } + override fun onClose() { + inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) + mindboxLogI("In-app dismissed by webview action") + hide() + release() + } - override fun onHide() { - webView.get()?.isInvisible = true - } + override fun onHide() { + controller.setVisibility(false) + } - override fun onLog(message: String) { - webView.get()?.mindboxLogI("JS: $message") - } - }) - }, - "SdkBridge" - ) + override fun onLog(message: String) { + mindboxLogI("JS: $message") + } + }) + } } + controller.setJsBridge(bridge) } - @SuppressLint("SetJavaScriptEnabled") - private fun createWebView(layer: Layer.WebViewLayer): WebView { + private fun createWebViewController(layer: Layer.WebViewLayer): WebViewController { mindboxLogI("Creating WebView for In-App: ${wrapper.inAppType.inAppId} with layer ${layer.type}") - return WebView(currentDialog.context).apply { - webViewClient = InAppWebClient( - onCriticalError = { + val controller: WebViewController = WebViewController.create(currentDialog.context, BuildConfig.DEBUG) + val view: WebViewPlatformView = controller.view + view.layoutParams = RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + controller.setEventListener(object : WebViewEventListener { + override fun onPageFinished(url: String?) { + mindboxLogD("onPageFinished: $url") + } + + override fun onError(error: WebViewError) { + val message = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}" + mindboxLogE(message) + if (error.isForMainFrame == true) { mindboxLogE("WebView critical error. Destroying In-App.") release() } - ) - - layoutParams = RelativeLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - settings.loadWithOverviewMode = true - settings.builtInZoomControls = true - settings.displayZoomControls = false - settings.defaultTextEncodingName = "utf-8" - settings.cacheMode = WebSettings.LOAD_NO_CACHE - settings.allowContentAccess = true - setBackgroundColor(Color.TRANSPARENT) - } + } + }) + return controller } - @SuppressLint("SetJavaScriptEnabled") fun addUrlSource(layer: Layer.WebViewLayer) { - if (webView.get() == null) { - WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) - webView = WeakReference(createWebView(layer).also { - it.visibility = ViewGroup.INVISIBLE - }) + if (webViewController == null) { + val controller: WebViewController = createWebViewController(layer) + controller.setVisibility(false) + webViewController = controller Mindbox.mindboxScope.launch { - val configuration = DbManager.listenConfigurations().first() + val configuration: Configuration = DbManager.listenConfigurations().first() withContext(Dispatchers.Main) { addJavascriptInterface(layer, configuration) - } - - webView.get()?.post { - webView.get()?.settings?.userAgentString += " " + configuration.getShortUserAgent() + controller.setUserAgentSuffix(configuration.getShortUserAgent()) } val requestQueue: RequestQueue = Volley.newRequestQueue(currentDialog.context) val stringRequest = StringRequest( Request.Method.GET, layer.contentUrl, - { response -> - webView.get()?.loadDataWithBaseURL( - layer.baseUrl, - response, - "text/html", - "UTF-8", - null + { response: String -> + val content = WebViewHtmlContent( + baseUrl = layer.baseUrl ?: "", + html = response ) - - Stopwatch.start(TIMER) - // Start timeout after loading the page - closeInappTimer = timer( - initialDelay = INIT_TIMEOUT_MS, - period = INIT_TIMEOUT_MS, - action = { - webView.get()?.post { - if (closeInappTimer != null) { - mindboxLogE("WebView initialization timed out after ${Stopwatch.stop(TIMER)}.") - release() + controller.executeOnViewThread { + controller.loadContent(content) + Stopwatch.start(TIMER) + closeInappTimer = timer( + initialDelay = INIT_TIMEOUT_MS, + period = INIT_TIMEOUT_MS, + action = { + controller.executeOnViewThread { + if (closeInappTimer != null) { + mindboxLogE("WebView initialization timed out after ${Stopwatch.stop(TIMER)}.") + release() + } } } - } - ) + ) + } }, - { error -> + { error: VolleyError -> mindboxLogE("Failed to fetch HTML content for In-App: $error. Destroying.") release() } ) - requestQueue.add(stringRequest) } } - webView.get()?.let { view -> + webViewController?.let { controller -> + val view: WebViewPlatformView = controller.view if (view.parent !== inAppLayout) { view.parent.safeAs()?.removeView(view) inAppLayout.addView(view) @@ -242,12 +230,26 @@ internal class WebViewInAppViewHolder( inAppLayout.requestFocus() } + override fun reattach(currentRoot: MindboxView) { + super.reattach(currentRoot) + wrapper.inAppType.layers.forEach { layer -> + when (layer) { + is Layer.WebViewLayer -> addUrlSource(layer) + else -> mindboxLogW("Layer is not supported") + } + } + inAppLayout.requestFocus() + } + + override fun canReuseOnRestore(inAppId: String): Boolean = wrapper.inAppType.inAppId == inAppId + override fun hide() { // Clean up timeout when hiding closeInappTimer?.cancel() closeInappTimer = null - webView.get()?.let { - inAppLayout.removeView(it) + webViewController?.let { controller -> + val view: WebViewPlatformView = controller.view + inAppLayout.removeView(view) } super.hide() } @@ -255,14 +257,8 @@ internal class WebViewInAppViewHolder( override fun release() { super.release() // Clean up WebView resources - webView.get()?.apply { - stopLoading() - loadUrl("about:blank") - clearHistory() - removeAllViews() - destroy() - } - webView.clear() + webViewController?.destroy() + webViewController = null } private interface WebViewAction { @@ -277,73 +273,17 @@ internal class WebViewInAppViewHolder( fun onLog(message: String) } - private fun WebView.handleWebViewAction(action: String, data: String, actions: WebViewAction) { - this.post { + private fun handleWebViewAction(action: String, data: String, actions: WebViewAction) { + val controller: WebViewController = webViewController ?: return + controller.executeOnViewThread { mindboxLogI("handleWebViewAction: Action $action with $data") when (action) { "collapse", "close" -> actions.onClose() - "init" -> actions.onInit() - "hide" -> actions.onHide() - "click" -> actions.onCompleted(data) - "log" -> actions.onLog(data) } } } - - internal class InAppWebClient(private val onCriticalError: () -> Unit) : WebViewClient() { - override fun onReceivedError( - view: WebView?, - request: WebResourceRequest?, - error: WebResourceError? - ) { - super.onReceivedError(view, request, error) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val message = "WebView error: code=${error?.errorCode}, description=${error?.description}, url=${request?.url}" - mindboxLogE(message) - if (request?.isForMainFrame == true) { - onCriticalError() - } - } - } - - @Suppress("DEPRECATION") - @Deprecated("Deprecated in Java") - override fun onReceivedError( - view: WebView?, - errorCode: Int, - description: String?, - failingUrl: String? - ) { - super.onReceivedError(view, errorCode, description, failingUrl) - val message = "WebView error (legacy): code=$errorCode, description=$description, url=$failingUrl" - mindboxLogE(message) - // In the old API, we can't be sure if it's the main frame, - // but any error is likely critical. The timeout will still act as a fallback. - if (failingUrl == view?.originalUrl) { - onCriticalError() - } - } - - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest? - ): Boolean { - mindboxLogD("shouldOverrideUrlLoading: ${request?.url}") - return super.shouldOverrideUrlLoading(view, request) - } - - override fun onPageFinished(view: WebView?, url: String?) { - mindboxLogD("onPageFinished: $url") - super.onPageFinished(view, url) - } - - override fun shouldOverrideKeyEvent(view: WebView?, event: KeyEvent?): Boolean { - mindboxLogD("shouldOverrideKeyEvent: $event") - return super.shouldOverrideKeyEvent(view, event) - } - } } From 0e0c490a7427b7280050c027bcf178115a9d60c2 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 20 Jan 2026 12:29:19 +0300 Subject: [PATCH 02/59] MOBILEWEBVIEW-3: Fix lint error --- .../inapp/presentation/view/WebViewInappViewHolder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 17e2a817b..afa34c20f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -53,7 +53,7 @@ internal class WebViewInAppViewHolder( private var closeInappTimer: Timer? = null private var webViewController: WebViewController? = null - private val gson: Gson by mindboxInject { gson } + private val gson: Gson by mindboxInject { this.gson } override val isActive: Boolean get() = isInAppMessageActive From b7a43d93c22ad65133af04979b95bbb00ad072de Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 20 Jan 2026 14:33:24 +0300 Subject: [PATCH 03/59] MOBILEWEBVIEW-3: Fix lint error --- kmp-common-sdk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kmp-common-sdk b/kmp-common-sdk index dcbfb2538..a0eabfffc 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit dcbfb253873646e356d8ac23c8c9a46777f9c5d2 +Subproject commit a0eabfffc672aea19a6dc046f50983454399fc4a From 8e1795f61681260404a9b36c9b60261cb16a5f5b Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 21 Jan 2026 09:39:22 +0300 Subject: [PATCH 04/59] MOBILEWEBVIEW-3: Follow code review --- kmp-common-sdk | 2 +- .../view/WebViewInappViewHolder.kt | 21 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/kmp-common-sdk b/kmp-common-sdk index a0eabfffc..1ceae5aa2 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit a0eabfffc672aea19a6dc046f50983454399fc4a +Subproject commit 1ceae5aa2f46903b3cd1d0eb16b06222ca0ca2ad diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index afa34c20f..686299868 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -257,6 +257,8 @@ internal class WebViewInAppViewHolder( override fun release() { super.release() // Clean up WebView resources + closeInappTimer?.cancel() + closeInappTimer = null webViewController?.destroy() webViewController = null } @@ -274,15 +276,16 @@ internal class WebViewInAppViewHolder( } private fun handleWebViewAction(action: String, data: String, actions: WebViewAction) { - val controller: WebViewController = webViewController ?: return - controller.executeOnViewThread { - mindboxLogI("handleWebViewAction: Action $action with $data") - when (action) { - "collapse", "close" -> actions.onClose() - "init" -> actions.onInit() - "hide" -> actions.onHide() - "click" -> actions.onCompleted(data) - "log" -> actions.onLog(data) + webViewController?.let { controller -> + controller.executeOnViewThread { + mindboxLogI("handleWebViewAction: Action $action with $data") + when (action) { + "collapse", "close" -> actions.onClose() + "init" -> actions.onInit() + "hide" -> actions.onHide() + "click" -> actions.onCompleted(data) + "log" -> actions.onLog(data) + } } } } From 6bb0c868d02b635dbe6722e797e92e4f9b1fc1ba Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 21 Jan 2026 09:52:20 +0300 Subject: [PATCH 05/59] MOBILEWEBVIEW-3: Refactoring --- .../InAppMessageViewDisplayerImpl.kt | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 57a587087..02e2f808a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -192,28 +192,8 @@ internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: isRestored: Boolean = false, ) { if (!isRestored) isActionExecuted = false - if (isRestored) { - val restoredHolder: InAppViewHolder<*>? = pausedHolder - ?.takeIf { it.canReuseOnRestore(wrapper.inAppType.inAppId) } - if (restoredHolder != null) { - currentHolder = restoredHolder - pausedHolder = null - currentActivity?.root?.let { root -> - restoredHolder.reattach(object : MindboxView { - override val container: ViewGroup = root - - override fun requestPermission() { - currentActivity?.let { activity -> - mindboxNotificationManager.requestPermission(activity = activity) - } - } - }) - } ?: run { - mindboxLogE("failed to reattach inApp: currentRoot is null") - } - return - } - } + if (isRestored && tryReattachRestoredInApp(wrapper.inAppType.inAppId)) return + val callbackWrapper = InAppCallbackWrapper(inAppCallback) { wrapper.inAppActionCallbacks.onInAppDismiss.onDismiss() pausedHolder?.hide() @@ -257,6 +237,32 @@ internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: } } + private fun tryReattachRestoredInApp(inAppId: String): Boolean { + val restoredHolder: InAppViewHolder<*> = pausedHolder + ?.takeIf { it.canReuseOnRestore(inAppId) } + ?: return false + currentHolder = restoredHolder + pausedHolder = null + val root: ViewGroup = currentActivity?.root ?: run { + mindboxLogE("failed to reattach inApp: currentRoot is null") + return true + } + restoredHolder.reattach(createMindboxView(root)) + return true + } + + private fun createMindboxView(root: ViewGroup): MindboxView { + return object : MindboxView { + override val container: ViewGroup = root + + override fun requestPermission() { + currentActivity?.let { activity -> + mindboxNotificationManager.requestPermission(activity = activity) + } + } + } + } + override fun hideCurrentInApp() { loggingRunCatching { if (isInAppActive()) { From 3a50ab05c11d4e39a94e3389c3cac270f2fbfe28 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 2 Feb 2026 10:24:20 +0300 Subject: [PATCH 06/59] MOBILEWEBVIEW-6: Add js brige --- kmp-common-sdk | 2 +- .../mobile_sdk/di/modules/DataModule.kt | 18 + .../inapp/presentation/view/WebViewAction.kt | 148 +++++++ .../view/WebViewInappViewHolder.kt | 394 +++++++++++++----- 4 files changed, 457 insertions(+), 105 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt diff --git a/kmp-common-sdk b/kmp-common-sdk index 1ceae5aa2..6720b2a1d 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 1ceae5aa2f46903b3cd1d0eb16b06222ca0ca2ad +Subproject commit 6720b2a1dbc19a552a82895c38058b75557f4e01 diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 65e570ff2..c103c98be 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -25,6 +25,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidato import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageDelayedManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManagerImpl +import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage import cloud.mindbox.mobile_sdk.managers.* import cloud.mindbox.mobile_sdk.managers.MobileConfigSettingsManagerImpl import cloud.mindbox.mobile_sdk.managers.RequestPermissionManager @@ -270,6 +271,23 @@ internal fun DataModule( override val gson: Gson by lazy { GsonBuilder() + .registerTypeAdapterFactory( + RuntimeTypeAdapterFactory + .of( + BridgeMessage::class.java, + BridgeMessage.TYPE_FIELD_NAME, + true + ).registerSubtype( + BridgeMessage.Request::class.java, + BridgeMessage.TYPE_REQUEST + ).registerSubtype( + BridgeMessage.Response::class.java, + BridgeMessage.TYPE_RESPONSE + ).registerSubtype( + BridgeMessage.Error::class.java, + BridgeMessage.TYPE_ERROR + ) + ) .registerTypeAdapterFactory( RuntimeTypeAdapterFactory .of( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt new file mode 100644 index 000000000..0bfdfd397 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -0,0 +1,148 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import com.google.gson.annotations.SerializedName +import java.util.UUID + +internal enum class WebViewAction(val value: String) { + @SerializedName("init") + INIT("init"), + + @SerializedName("ready") + READY("ready"), + + @SerializedName("click") + CLICK("click"), + + @SerializedName("close") + CLOSE("close"), + + @SerializedName("hide") + HIDE("hide"), + + @SerializedName("show") + SHOW("show"), + + @SerializedName("log") + LOG("log"), + + @SerializedName("alert") + ALERT("alert"), + + @SerializedName("toast") + TOAST("toast"), + UNKNOWN("unknown"), +} + +internal sealed class BridgeMessage { + abstract val version: Int + abstract val type: String + abstract val action: WebViewAction + abstract val payload: String? + abstract val id: String + abstract val timestamp: Long + + internal data class Request( + override val version: Int, + override val action: WebViewAction, + override val payload: String?, + override val id: String, + override val timestamp: Long, + override val type: String = TYPE_REQUEST, + ) : BridgeMessage() + + internal data class Response( + override val version: Int, + override val action: WebViewAction, + override val payload: String?, + override val id: String, + override val timestamp: Long, + override val type: String = TYPE_RESPONSE, + ) : BridgeMessage() + + internal data class Error( + override val version: Int, + override val action: WebViewAction, + override val payload: String?, + override val id: String, + override val timestamp: Long, + override val type: String = TYPE_ERROR, + ) : BridgeMessage() + + companion object { + const val VERSION = 1 + const val EMPTY_PAYLOAD = "{}" + const val TYPE_FIELD_NAME = "type" + const val TYPE_REQUEST = "request" + const val TYPE_RESPONSE = "response" + const val TYPE_ERROR = "error" + + fun createAction(action: WebViewAction, payload: String): Request = + Request( + id = UUID.randomUUID().toString(), + version = VERSION, + action = action, + payload = payload, + timestamp = System.currentTimeMillis(), + ) + + fun createResponseAction(message: Request, payload: String?): Response = + Response( + id = message.id, + version = message.version, + action = message.action, + payload = payload, + timestamp = System.currentTimeMillis(), + ) + + fun createErrorAction(message: Request, payload: String?): Error = + Error( + id = message.id, + version = message.version, + action = message.action, + payload = payload, + timestamp = System.currentTimeMillis(), + ) + } +} + +internal typealias WebViewActionHandler = (BridgeMessage.Request) -> String +internal typealias WebViewSuspendActionHandler = suspend (BridgeMessage.Request) -> String + +internal class WebViewActionHandlers { + + private val handlersByActionValue: MutableMap = mutableMapOf() + private val suspendHandlersByActionValue: MutableMap = mutableMapOf() + + fun register(actionValue: WebViewAction, handler: WebViewActionHandler) { + if (handlersByActionValue.containsKey(actionValue)) { + mindboxLogW("Handler for action $actionValue already registered") + } + handlersByActionValue[actionValue] = handler + } + + fun registerSuspend(actionValue: WebViewAction, handler: WebViewSuspendActionHandler) { + if (suspendHandlersByActionValue.containsKey(actionValue)) { + mindboxLogW("Suspend handler for action $actionValue already registered") + } + suspendHandlersByActionValue[actionValue] = handler + } + + fun hasSuspendHandler(actionValue: WebViewAction): Boolean { + return suspendHandlersByActionValue.containsKey(actionValue) + } + + fun handleRequest(message: BridgeMessage.Request): Result { + return runCatching { + handlersByActionValue[message.action]?.invoke(message) + ?: throw IllegalArgumentException("No handler for action ${message.action}") + } + } + + suspend fun handleRequestSuspend(message: BridgeMessage.Request): Result { + return runCatching { + suspendHandlersByActionValue[message.action]?.invoke(message) + ?: throw IllegalArgumentException("No suspend handler for action ${message.action}") + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 686299868..6f2a8772f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -2,6 +2,8 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.view.ViewGroup import android.widget.RelativeLayout +import android.widget.Toast +import androidx.appcompat.app.AlertDialog import cloud.mindbox.mobile_sdk.BuildConfig import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi @@ -31,12 +33,11 @@ import com.android.volley.VolleyError import com.android.volley.toolbox.StringRequest import com.android.volley.toolbox.Volley import com.google.gson.Gson -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.* import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.util.Timer import java.util.TreeMap +import java.util.concurrent.ConcurrentHashMap import kotlin.concurrent.timer @OptIn(InternalMindboxApi::class) @@ -48,10 +49,16 @@ internal class WebViewInAppViewHolder( companion object { private const val INIT_TIMEOUT_MS = 7_000L private const val TIMER = "CLOSE_INAPP_TIMER" + private const val JS_RETURN = "true" + private const val JS_BRIDGE = "window.receiveFromSDK" + private const val JS_CALL_BRIDGE = "$JS_BRIDGE(%s);" + private const val JS_CHECK_BRIDGE = "typeof $JS_BRIDGE === 'function'" } private var closeInappTimer: Timer? = null private var webViewController: WebViewController? = null + private val pendingResponsesById: MutableMap> = + ConcurrentHashMap() private val gson: Gson by mindboxInject { this.gson } @@ -66,8 +73,73 @@ internal class WebViewInAppViewHolder( } } - private fun addJavascriptInterface(layer: Layer.WebViewLayer, configuration: Configuration) { - val controller: WebViewController = webViewController ?: return + suspend fun sendActionAndAwaitResponse( + controller: WebViewController, + message: BridgeMessage.Request + ): BridgeMessage.Response { + val responseDeferred: CompletableDeferred = CompletableDeferred() + pendingResponsesById[message.id] = responseDeferred + sendActionInternal(controller = controller, message = message) { error -> + if (responseDeferred.isActive) { + responseDeferred.completeExceptionally( + IllegalStateException("Failed to send message ${message.action} to WebView: $error") + ) + } + } + return responseDeferred.await() + } + + private fun sendActionInternal( + controller: WebViewController, + message: BridgeMessage, + onError: ((String?) -> Unit)? = null + ) { + val json = gson.toJson(message) + controller.evaluateJavaScript(JS_CALL_BRIDGE.format(json)) { result -> + if (!checkEvaluateJavaScript(result)) { + onError?.invoke(result) + } + } + } + + private fun createWebViewActionHandlers( + controller: WebViewController, + layer: Layer.WebViewLayer + ): WebViewActionHandlers { + return WebViewActionHandlers().apply { + registerSuspend(WebViewAction.READY) { + executeReadyAction(layer) + } + register(WebViewAction.INIT) { + executeInitAction(controller) + } + register(WebViewAction.CLICK) { + executeCompletedAction(it) + } + register(WebViewAction.CLOSE) { + executeCloseAction() + } + register(WebViewAction.HIDE) { + executeHideAction(controller) + } + register(WebViewAction.LOG) { + executeLogAction(it) + } + register(WebViewAction.TOAST) { + executeToastAction(it) + } + register(WebViewAction.ALERT) { + executeAlertAction(it) + } + register(WebViewAction.UNKNOWN) { + executeLogAction(it) + } + } + } + + private suspend fun executeReadyAction(layer: Layer.WebViewLayer): String { + val configuration: Configuration = DbManager.listenConfigurations().first() + val params: TreeMap = TreeMap(String.CASE_INSENSITIVE_ORDER).apply { put("sdkVersion", Mindbox.getSdkVersion()) put("endpointId", configuration.endpointId) @@ -75,62 +147,76 @@ internal class WebViewInAppViewHolder( put("sdkVersionNumeric", Constants.SDK_VERSION_NUMERIC.toString()) putAll(layer.params) } - val bridge: WebViewJsBridge = object : WebViewJsBridge { - override fun getParam(key: String): String? { - return params[key] + + return gson.toJson(params) + } + + private fun executeInitAction(controller: WebViewController): String { + mindboxLogI("WebView initialization completed " + Stopwatch.stop(TIMER)) + closeInappTimer?.cancel() + closeInappTimer = null + wrapper.inAppActionCallbacks.onInAppShown.onShown() + controller.setVisibility(true) + return BridgeMessage.EMPTY_PAYLOAD + } + + private fun executeCompletedAction(message: BridgeMessage.Request): String { + runCatching { + val actionDto: BackgroundDto.LayerDto.ImageLayerDto.ActionDto = + gson.fromJson(message.payload).getOrThrow() + val actionResult: Pair = when (actionDto) { + is BackgroundDto.LayerDto.ImageLayerDto.ActionDto.RedirectUrlActionDto -> + actionDto.value to actionDto.intentPayload + + is BackgroundDto.LayerDto.ImageLayerDto.ActionDto.PushPermissionActionDto -> + "" to actionDto.intentPayload } + val url: String? = actionResult.first + val payload: String? = actionResult.second + wrapper.inAppActionCallbacks.onInAppClick.onClick() + inAppCallback.onInAppClick( + wrapper.inAppType.inAppId, + url ?: "", + payload ?: "" + ) + } + mindboxLogI("In-app completed by webview action with data: ${message.payload}") + return BridgeMessage.EMPTY_PAYLOAD + } - override fun onAction(action: String, data: String) { - handleWebViewAction(action, data, object : WebViewAction { - override fun onInit() { - mindboxLogI("WebView initialization completed " + Stopwatch.stop(TIMER)) - closeInappTimer?.cancel() - closeInappTimer = null - wrapper.inAppActionCallbacks.onInAppShown.onShown() - controller.setVisibility(true) - } + private fun executeCloseAction(): String { + inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) + mindboxLogI("In-app dismissed by webview action") + hide() + release() + return BridgeMessage.EMPTY_PAYLOAD + } - override fun onCompleted(data: String) { - runCatching { - val actionDto: BackgroundDto.LayerDto.ImageLayerDto.ActionDto = - gson.fromJson(data).getOrThrow() - val actionResult: Pair = when (actionDto) { - is BackgroundDto.LayerDto.ImageLayerDto.ActionDto.RedirectUrlActionDto -> - actionDto.value to actionDto.intentPayload - - is BackgroundDto.LayerDto.ImageLayerDto.ActionDto.PushPermissionActionDto -> - "" to actionDto.intentPayload - } - val url: String? = actionResult.first - val payload: String? = actionResult.second - wrapper.inAppActionCallbacks.onInAppClick.onClick() - inAppCallback.onInAppClick( - wrapper.inAppType.inAppId, - url ?: "", - payload ?: "" - ) - } - mindboxLogI("In-app completed by webview action with data: $data") - } + private fun executeHideAction(controller: WebViewController): String { + controller.setVisibility(false) + return BridgeMessage.EMPTY_PAYLOAD + } - override fun onClose() { - inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) - mindboxLogI("In-app dismissed by webview action") - hide() - release() - } + private fun executeLogAction(message: BridgeMessage.Request): String { + mindboxLogI("JS: ${message.payload}") + return BridgeMessage.EMPTY_PAYLOAD + } - override fun onHide() { - controller.setVisibility(false) - } + private fun executeToastAction(message: BridgeMessage.Request): String { + webViewController?.view?.context?.let { context -> + Toast.makeText(context, message.payload, Toast.LENGTH_LONG).show() + } + return BridgeMessage.EMPTY_PAYLOAD + } - override fun onLog(message: String) { - mindboxLogI("JS: $message") - } - }) - } + private fun executeAlertAction(message: BridgeMessage.Request): String { + webViewController?.view?.context?.let { context -> + AlertDialog.Builder(context) + .setMessage(message.payload) + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .show() } - controller.setJsBridge(bridge) + return BridgeMessage.EMPTY_PAYLOAD } private fun createWebViewController(layer: Layer.WebViewLayer): WebViewController { @@ -158,42 +244,143 @@ internal class WebViewInAppViewHolder( return controller } - fun addUrlSource(layer: Layer.WebViewLayer) { + internal fun checkEvaluateJavaScript(response: String?): Boolean { + return when (response) { + JS_RETURN -> true + else -> { + mindboxLogE("evaluateJavaScript return unexpected response: $response") + hide() + false + } + } + } + + private fun handleRequest(message: BridgeMessage.Request, controller: WebViewController, handlers: WebViewActionHandlers) { + val messageId: String = message.id + if (messageId.isBlank()) { + mindboxLogW("WebView request without id for action ${message.action}") + return + } + if (handlers.hasSuspendHandler(message.action)) { + Mindbox.mindboxScope.launch { + val responsePayload: String = handlers.handleRequestSuspend(message) + .getOrElse { error -> + sendErrorResponse(message = message, error = error, controller = controller) + return@launch + } + sendSuccessResponse(message = message, responsePayload = responsePayload, controller = controller) + } + return + } + val responsePayload: String = handlers.handleRequest(message) + .getOrElse { error -> + sendErrorResponse(message = message, error = error, controller = controller) + return + } + sendSuccessResponse(message = message, responsePayload = responsePayload, controller = controller) + } + + private fun sendSuccessResponse( + message: BridgeMessage.Request, + responsePayload: String?, + controller: WebViewController, + ) { + val responseMessage: BridgeMessage.Response = BridgeMessage.createResponseAction(message, responsePayload) + sendActionInternal(controller, responseMessage) + } + + private fun sendErrorResponse( + message: BridgeMessage.Request, + error: Throwable, + controller: WebViewController, + ) { + val errorMessage: BridgeMessage.Error = BridgeMessage.createErrorAction(message, error.message) + sendActionInternal(controller, errorMessage) + } + + private fun handleResponse(message: BridgeMessage.Response) { + val messageId: String = message.id + if (messageId.isBlank()) { + mindboxLogW("WebView response without id for action ${message.action}") + return + } + val responseDeferred: CompletableDeferred? = pendingResponsesById.remove(messageId) + if (responseDeferred == null) { + mindboxLogW("No pending response for id $messageId") + return + } + if (!responseDeferred.isCompleted) { + responseDeferred.complete(message) + } + } + + private fun handleError(message: BridgeMessage.Error) { + mindboxLogW("WebView error: ${message.payload}") + val messageId: String = message.id + if (messageId.isBlank()) { + mindboxLogW("WebView error without id for action ${message.action}") + return + } + val responseDeferred: CompletableDeferred? = pendingResponsesById.remove(messageId) + responseDeferred?.cancel("WebView error: ${message.payload}") + hide() + } + + private fun cancelPendingResponses(reason: String) { + val error: CancellationException = CancellationException(reason) + pendingResponsesById.values.forEach { deferred -> + if (!deferred.isCompleted) { + deferred.cancel(error) + } + } + pendingResponsesById.clear() + } + + private fun addUrlSource(layer: Layer.WebViewLayer) { if (webViewController == null) { val controller: WebViewController = createWebViewController(layer) - controller.setVisibility(false) webViewController = controller + val handlers: WebViewActionHandlers = createWebViewActionHandlers(controller, layer) + + controller.setVisibility(false) + controller.setJsBridge(bridge = { json -> + when (val message: BridgeMessage? = gson.fromJson(json).getOrNull()) { + is BridgeMessage.Request -> handleRequest(message, controller, handlers) + is BridgeMessage.Response -> handleResponse(message) + is BridgeMessage.Error -> handleError(message) + else -> mindboxLogW("Unknown message type: $json") + } + }) + Mindbox.mindboxScope.launch { val configuration: Configuration = DbManager.listenConfigurations().first() withContext(Dispatchers.Main) { - addJavascriptInterface(layer, configuration) controller.setUserAgentSuffix(configuration.getShortUserAgent()) } + controller.setEventListener(object : WebViewEventListener { + override fun onPageFinished(url: String?) { + webViewController?.evaluateJavaScript(JS_CHECK_BRIDGE, ::checkEvaluateJavaScript) + } + + override fun onError(error: WebViewError) { + super.onError(error) + mindboxLogE("WebView error: $error") + hide() + } + }) + val requestQueue: RequestQueue = Volley.newRequestQueue(currentDialog.context) val stringRequest = StringRequest( Request.Method.GET, layer.contentUrl, { response: String -> - val content = WebViewHtmlContent( - baseUrl = layer.baseUrl ?: "", - html = response - ) - controller.executeOnViewThread { - controller.loadContent(content) - Stopwatch.start(TIMER) - closeInappTimer = timer( - initialDelay = INIT_TIMEOUT_MS, - period = INIT_TIMEOUT_MS, - action = { - controller.executeOnViewThread { - if (closeInappTimer != null) { - mindboxLogE("WebView initialization timed out after ${Stopwatch.stop(TIMER)}.") - release() - } - } - } + onContentLoaded( + controller = controller, + content = WebViewHtmlContent( + baseUrl = layer.baseUrl ?: "", + html = response ) - } + ) }, { error: VolleyError -> mindboxLogE("Failed to fetch HTML content for In-App: $error. Destroying.") @@ -203,6 +390,7 @@ internal class WebViewInAppViewHolder( requestQueue.add(stringRequest) } } + webViewController?.let { controller -> val view: WebViewPlatformView = controller.view if (view.parent !== inAppLayout) { @@ -212,9 +400,32 @@ internal class WebViewInAppViewHolder( } ?: release() } + private fun onContentLoaded(controller: WebViewController, content: WebViewHtmlContent) { + controller.executeOnViewThread { + controller.loadContent(content) + startTimer(controller) + } + } + + private fun startTimer(controller: WebViewController) { + Stopwatch.start(TIMER) + closeInappTimer = timer( + initialDelay = INIT_TIMEOUT_MS, + period = INIT_TIMEOUT_MS, + action = { + controller.executeOnViewThread { + if (closeInappTimer != null) { + mindboxLogE("WebView initialization timed out after ${Stopwatch.stop(TIMER)}.") + release() + } + } + } + ) + } + override fun show(currentRoot: MindboxView) { super.show(currentRoot) - mindboxLogI("Try to show inapp with id ${wrapper.inAppType.inAppId}") + mindboxLogI("Try to show in-app with id ${wrapper.inAppType.inAppId}") wrapper.inAppType.layers.forEach { layer -> when (layer) { is Layer.WebViewLayer -> { @@ -247,6 +458,7 @@ internal class WebViewInAppViewHolder( // Clean up timeout when hiding closeInappTimer?.cancel() closeInappTimer = null + cancelPendingResponses("WebView In-App is hidden") webViewController?.let { controller -> val view: WebViewPlatformView = controller.view inAppLayout.removeView(view) @@ -259,34 +471,8 @@ internal class WebViewInAppViewHolder( // Clean up WebView resources closeInappTimer?.cancel() closeInappTimer = null + cancelPendingResponses("WebView In-App is released") webViewController?.destroy() webViewController = null } - - private interface WebViewAction { - fun onInit() - - fun onCompleted(data: String) - - fun onClose() - - fun onHide() - - fun onLog(message: String) - } - - private fun handleWebViewAction(action: String, data: String, actions: WebViewAction) { - webViewController?.let { controller -> - controller.executeOnViewThread { - mindboxLogI("handleWebViewAction: Action $action with $data") - when (action) { - "collapse", "close" -> actions.onClose() - "init" -> actions.onInit() - "hide" -> actions.onHide() - "click" -> actions.onCompleted(data) - "log" -> actions.onLog(data) - } - } - } - } } From 014d236750ce12c4faf4708dcb2f490f3d9acc37 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 2 Feb 2026 12:29:48 +0300 Subject: [PATCH 07/59] MOBILEWEBVIEW-6: Add message validator --- .../data/validators/BridgeMessageValidator.kt | 47 +++++++++++++++++++ .../inapp/presentation/view/WebViewAction.kt | 1 - .../view/WebViewInappViewHolder.kt | 14 ++++-- 3 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt new file mode 100644 index 000000000..015584b20 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt @@ -0,0 +1,47 @@ +package cloud.mindbox.mobile_sdk.inapp.data.validators + +import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage +import cloud.mindbox.mobile_sdk.logger.mindboxLogW + +internal class BridgeMessageValidator : Validator { + override fun isValid(item: BridgeMessage?): Boolean { + item ?: return false + + runCatching { + if (item.id.isBlank()) { + mindboxLogW("BridgeMessage id is empty") + return false + } + + if (item.type !in listOf( + BridgeMessage.TYPE_REQUEST, + BridgeMessage.TYPE_RESPONSE, + BridgeMessage.TYPE_ERROR + ) + ) { + mindboxLogW("BridgeMessage type ${item.type} is not supported") + return false + } + + if (item.action.value.isBlank()) { + mindboxLogW("BridgeMessage action is empty") + return false + } + + if (item.timestamp <= 0L) { + mindboxLogW("BridgeMessage timestamp is negative") + return false + } + + if (item.version > BridgeMessage.VERSION) { + mindboxLogW("BridgeMessage version ${item.version} is not supported") + return false + } + }.onFailure { error -> + mindboxLogW("BridgeMessage validation error: $error") + return false + } + + return true + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index 0bfdfd397..668a27b20 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -31,7 +31,6 @@ internal enum class WebViewAction(val value: String) { @SerializedName("toast") TOAST("toast"), - UNKNOWN("unknown"), } internal sealed class BridgeMessage { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 6f2a8772f..d05a82bca 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -10,6 +10,7 @@ import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.fromJson import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.validators.BridgeMessageValidator import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer @@ -61,6 +62,7 @@ internal class WebViewInAppViewHolder( ConcurrentHashMap() private val gson: Gson by mindboxInject { this.gson } + private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } override val isActive: Boolean get() = isInAppMessageActive @@ -131,9 +133,6 @@ internal class WebViewInAppViewHolder( register(WebViewAction.ALERT) { executeAlertAction(it) } - register(WebViewAction.UNKNOWN) { - executeLogAction(it) - } } } @@ -344,11 +343,16 @@ internal class WebViewInAppViewHolder( controller.setVisibility(false) controller.setJsBridge(bridge = { json -> - when (val message: BridgeMessage? = gson.fromJson(json).getOrNull()) { + val message = gson.fromJson(json).getOrNull() + if (!messageValidator.isValid(message)) { + return@setJsBridge + } + + when (message) { is BridgeMessage.Request -> handleRequest(message, controller, handlers) is BridgeMessage.Response -> handleResponse(message) is BridgeMessage.Error -> handleError(message) - else -> mindboxLogW("Unknown message type: $json") + else -> mindboxLogW("Unknown message type: $message") } }) From 2ca8c4b9c5597f0e0a2d6a57b9ffb1358ddc2291 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 2 Feb 2026 14:00:11 +0300 Subject: [PATCH 08/59] MOBILEWEBVIEW-6: Add tests --- .../validators/BridgeMessageValidatorTest.kt | 85 +++++++++++ .../view/WebViewActionHandlersTest.kt | 133 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidatorTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewActionHandlersTest.kt diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidatorTest.kt new file mode 100644 index 000000000..02fb37d02 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidatorTest.kt @@ -0,0 +1,85 @@ +package cloud.mindbox.mobile_sdk.inapp.data.validators + +import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage +import cloud.mindbox.mobile_sdk.inapp.presentation.view.WebViewAction +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BridgeMessageValidatorTest { + private val validator: BridgeMessageValidator = BridgeMessageValidator() + + @Test + fun `isValid returns false for null message`() { + val actualResult: Boolean = validator.isValid(null) + assertFalse(actualResult) + } + + @Test + fun `isValid returns false for blank id`() { + val message: BridgeMessage.Request = createRequest(id = " ") + val actualResult: Boolean = validator.isValid(message) + assertFalse(actualResult) + } + + @Test + fun `isValid returns false for unsupported type`() { + val message: BridgeMessage.Request = createRequest(type = "unsupported") + val actualResult: Boolean = validator.isValid(message) + assertFalse(actualResult) + } + + @Test + fun `isValid returns false for non-positive timestamp`() { + val zeroTimestampMessage: BridgeMessage.Request = createRequest(timestamp = 0L) + val negativeTimestampMessage: BridgeMessage.Request = createRequest(timestamp = -1L) + val zeroTimestampResult: Boolean = validator.isValid(zeroTimestampMessage) + val negativeTimestampResult: Boolean = validator.isValid(negativeTimestampMessage) + assertFalse(zeroTimestampResult) + assertFalse(negativeTimestampResult) + } + + @Test + fun `isValid returns false for unsupported version`() { + val message: BridgeMessage.Request = createRequest(version = BridgeMessage.VERSION + 1) + val actualResult: Boolean = validator.isValid(message) + assertFalse(actualResult) + } + + @Test + fun `isValid returns true for valid request message`() { + val message: BridgeMessage.Request = createRequest() + val actualResult: Boolean = validator.isValid(message) + assertTrue(actualResult) + } + + @Test + fun `isValid returns false when reflection sets null id`() { + val message: BridgeMessage.Request = createRequest() + setFieldValue(target = message, fieldName = "id", value = null) + val actualResult: Boolean = validator.isValid(message) + assertFalse(actualResult) + } + + private fun createRequest( + id: String = "request-id", + type: String = BridgeMessage.TYPE_REQUEST, + version: Int = BridgeMessage.VERSION, + timestamp: Long = 1L, + ): BridgeMessage.Request { + return BridgeMessage.Request( + version = version, + action = WebViewAction.INIT, + payload = BridgeMessage.EMPTY_PAYLOAD, + id = id, + timestamp = timestamp, + type = type, + ) + } + + private fun setFieldValue(target: Any, fieldName: String, value: Any?) { + val field = target.javaClass.getDeclaredField(fieldName) + field.isAccessible = true + field.set(target, value) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewActionHandlersTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewActionHandlersTest.kt new file mode 100644 index 000000000..5cf91ba7e --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewActionHandlersTest.kt @@ -0,0 +1,133 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.* +import org.junit.Assert.* +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class WebViewActionHandlersTest { + + @Test + fun `handleRequest returns payload from registered handler`() { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val expectedPayload: String = "payload" + val message: BridgeMessage.Request = createRequest(WebViewAction.INIT) + handlers.register(WebViewAction.INIT) { expectedPayload } + val actualResult: Result = handlers.handleRequest(message) + assertTrue(actualResult.isSuccess) + assertEquals(expectedPayload, actualResult.getOrNull()) + } + + @Test + fun `handleRequest returns failure when handler not registered`() { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val message: BridgeMessage.Request = createRequest(WebViewAction.INIT) + val actualResult: Result = handlers.handleRequest(message) + assertTrue(actualResult.isFailure) + } + + @Test + fun `handleRequestSuspend returns payload from registered suspend handler`() = runTest { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val expectedPayload: String = "payload" + val message: BridgeMessage.Request = createRequest(WebViewAction.READY) + handlers.registerSuspend(WebViewAction.READY) { expectedPayload } + val actualResult: Result = handlers.handleRequestSuspend(message) + assertTrue(actualResult.isSuccess) + assertEquals(expectedPayload, actualResult.getOrNull()) + } + + @Test + fun `handleRequestSuspend returns failure when suspend handler not registered`() = runTest { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val message: BridgeMessage.Request = createRequest(WebViewAction.READY) + val actualResult: Result = handlers.handleRequestSuspend(message) + assertTrue(actualResult.isFailure) + } + + @Test + fun `hasSuspendHandler returns true when handler registered`() { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + handlers.registerSuspend(WebViewAction.READY) { BridgeMessage.EMPTY_PAYLOAD } + val actualResult: Boolean = handlers.hasSuspendHandler(WebViewAction.READY) + assertTrue(actualResult) + } + + @Test + fun `hasSuspendHandler returns false when handler not registered`() { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val actualResult: Boolean = handlers.hasSuspendHandler(WebViewAction.READY) + assertFalse(actualResult) + } + + @Test + fun `handleRequestSuspend completes after delay`() = runTest { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val expectedPayload: String = "delayed" + val message: BridgeMessage.Request = createRequest(WebViewAction.READY) + handlers.registerSuspend(WebViewAction.READY) { + delay(100) + expectedPayload + } + val dispatcher: TestDispatcher = StandardTestDispatcher(testScheduler) + val deferredResult: Deferred> = + async(dispatcher) { handlers.handleRequestSuspend(message) } + runCurrent() + assertFalse(deferredResult.isCompleted) + advanceTimeBy(99) + runCurrent() + assertFalse(deferredResult.isCompleted) + advanceTimeBy(1) + runCurrent() + assertTrue(deferredResult.isCompleted) + assertEquals(expectedPayload, deferredResult.await().getOrNull()) + } + + @Test + fun `handleRequestSuspend processes multiple requests with different delays`() = runTest { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val firstPayload: String = "first" + val secondPayload: String = "second" + val firstMessage: BridgeMessage.Request = createRequest(WebViewAction.READY) + val secondMessage: BridgeMessage.Request = createRequest(WebViewAction.INIT) + handlers.registerSuspend(WebViewAction.READY) { + delay(50) + firstPayload + } + handlers.registerSuspend(WebViewAction.INIT) { + delay(150) + secondPayload + } + val dispatcher: TestDispatcher = StandardTestDispatcher(testScheduler) + val firstDeferred: Deferred> = + async(dispatcher) { handlers.handleRequestSuspend(firstMessage) } + val secondDeferred: Deferred> = + async(dispatcher) { handlers.handleRequestSuspend(secondMessage) } + runCurrent() + assertFalse(firstDeferred.isCompleted) + assertFalse(secondDeferred.isCompleted) + advanceTimeBy(50) + runCurrent() + assertTrue(firstDeferred.isCompleted) + assertFalse(secondDeferred.isCompleted) + advanceTimeBy(100) + runCurrent() + assertTrue(secondDeferred.isCompleted) + assertEquals(firstPayload, firstDeferred.await().getOrNull()) + assertEquals(secondPayload, secondDeferred.await().getOrNull()) + } + + private fun createRequest(action: WebViewAction): BridgeMessage.Request { + return BridgeMessage.Request( + version = BridgeMessage.VERSION, + action = action, + payload = BridgeMessage.EMPTY_PAYLOAD, + id = "request-id", + timestamp = 1L, + ) + } +} From 7406683314d54fb4d319a987b4ac26837ae63440 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 2 Feb 2026 14:11:12 +0300 Subject: [PATCH 09/59] MOBILEWEBVIEW-6: Update common sdk --- kmp-common-sdk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kmp-common-sdk b/kmp-common-sdk index 6720b2a1d..c76665d17 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 6720b2a1dbc19a552a82895c38058b75557f4e01 +Subproject commit c76665d177b190b59753d1c989f0e998dc83fe01 From 0473969d0a81da51b5226b6f6c448757599284e1 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 2 Feb 2026 20:32:38 +0300 Subject: [PATCH 10/59] MOBILEWEBVIEW-6: Follow code review --- kmp-common-sdk | 2 +- .../mobile_sdk/di/modules/DataModule.kt | 2 + .../data/validators/BridgeMessageValidator.kt | 6 +- .../inapp/presentation/view/WebViewAction.kt | 80 +++++------ .../view/WebViewInappViewHolder.kt | 129 +++++++----------- .../validators/BridgeMessageValidatorTest.kt | 2 + .../view/WebViewActionHandlersTest.kt | 3 +- 7 files changed, 101 insertions(+), 123 deletions(-) diff --git a/kmp-common-sdk b/kmp-common-sdk index c76665d17..15032dfa4 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit c76665d177b190b59753d1c989f0e998dc83fe01 +Subproject commit 15032dfa4642c0d59ed9cd21d0fe289ea0d437c6 diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index c103c98be..41f7ec0de 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.di.modules +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.inapp.data.checkers.MaxInappsPerDayLimitChecker import cloud.mindbox.mobile_sdk.inapp.data.checkers.MaxInappsPerSessionLimitChecker import cloud.mindbox.mobile_sdk.inapp.data.checkers.MinIntervalBetweenShowsLimitChecker @@ -38,6 +39,7 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder import kotlinx.coroutines.Dispatchers +@OptIn(InternalMindboxApi::class) internal fun DataModule( appContextModule: AppContextModule, apiModule: ApiModule diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt index 015584b20..60c24b513 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt @@ -1,8 +1,10 @@ package cloud.mindbox.mobile_sdk.inapp.data.validators +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage import cloud.mindbox.mobile_sdk.logger.mindboxLogW +@OptIn(InternalMindboxApi::class) internal class BridgeMessageValidator : Validator { override fun isValid(item: BridgeMessage?): Boolean { item ?: return false @@ -23,13 +25,13 @@ internal class BridgeMessageValidator : Validator { return false } - if (item.action.value.isBlank()) { + if (item.action.name.isEmpty()) { mindboxLogW("BridgeMessage action is empty") return false } if (item.timestamp <= 0L) { - mindboxLogW("BridgeMessage timestamp is negative") + mindboxLogW("BridgeMessage timestamp must be positive") return false } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index 668a27b20..d3a10aeb9 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -1,47 +1,47 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.logger.mindboxLogW import com.google.gson.annotations.SerializedName import java.util.UUID -internal enum class WebViewAction(val value: String) { +@InternalMindboxApi +public enum class WebViewAction { @SerializedName("init") - INIT("init"), + INIT, @SerializedName("ready") - READY("ready"), + READY, @SerializedName("click") - CLICK("click"), + CLICK, @SerializedName("close") - CLOSE("close"), + CLOSE, @SerializedName("hide") - HIDE("hide"), - - @SerializedName("show") - SHOW("show"), + HIDE, @SerializedName("log") - LOG("log"), + LOG, @SerializedName("alert") - ALERT("alert"), + ALERT, @SerializedName("toast") - TOAST("toast"), + TOAST, } -internal sealed class BridgeMessage { - abstract val version: Int - abstract val type: String - abstract val action: WebViewAction - abstract val payload: String? - abstract val id: String - abstract val timestamp: Long +@InternalMindboxApi +public sealed class BridgeMessage { + public abstract val version: Int + public abstract val type: String + public abstract val action: WebViewAction + public abstract val payload: String? + public abstract val id: String + public abstract val timestamp: Long - internal data class Request( + public data class Request( override val version: Int, override val action: WebViewAction, override val payload: String?, @@ -50,7 +50,7 @@ internal sealed class BridgeMessage { override val type: String = TYPE_REQUEST, ) : BridgeMessage() - internal data class Response( + public data class Response( override val version: Int, override val action: WebViewAction, override val payload: String?, @@ -59,7 +59,7 @@ internal sealed class BridgeMessage { override val type: String = TYPE_RESPONSE, ) : BridgeMessage() - internal data class Error( + public data class Error( override val version: Int, override val action: WebViewAction, override val payload: String?, @@ -68,15 +68,15 @@ internal sealed class BridgeMessage { override val type: String = TYPE_ERROR, ) : BridgeMessage() - companion object { - const val VERSION = 1 - const val EMPTY_PAYLOAD = "{}" - const val TYPE_FIELD_NAME = "type" - const val TYPE_REQUEST = "request" - const val TYPE_RESPONSE = "response" - const val TYPE_ERROR = "error" + public companion object { + public const val VERSION: Int = 1 + public const val EMPTY_PAYLOAD: String = "{}" + public const val TYPE_FIELD_NAME: String = "type" + public const val TYPE_REQUEST: String = "request" + public const val TYPE_RESPONSE: String = "response" + public const val TYPE_ERROR: String = "error" - fun createAction(action: WebViewAction, payload: String): Request = + public fun createAction(action: WebViewAction, payload: String): Request = Request( id = UUID.randomUUID().toString(), version = VERSION, @@ -85,7 +85,7 @@ internal sealed class BridgeMessage { timestamp = System.currentTimeMillis(), ) - fun createResponseAction(message: Request, payload: String?): Response = + public fun createResponseAction(message: Request, payload: String?): Response = Response( id = message.id, version = message.version, @@ -94,7 +94,7 @@ internal sealed class BridgeMessage { timestamp = System.currentTimeMillis(), ) - fun createErrorAction(message: Request, payload: String?): Error = + public fun createErrorAction(message: Request, payload: String?): Error = Error( id = message.id, version = message.version, @@ -105,22 +105,26 @@ internal sealed class BridgeMessage { } } -internal typealias WebViewActionHandler = (BridgeMessage.Request) -> String -internal typealias WebViewSuspendActionHandler = suspend (BridgeMessage.Request) -> String +@InternalMindboxApi +internal typealias BridgeMessageHandler = (BridgeMessage.Request) -> String + +@InternalMindboxApi +internal typealias BridgeSuspendMessageHandler = suspend (BridgeMessage.Request) -> String +@InternalMindboxApi internal class WebViewActionHandlers { - private val handlersByActionValue: MutableMap = mutableMapOf() - private val suspendHandlersByActionValue: MutableMap = mutableMapOf() + private val handlersByActionValue: MutableMap = mutableMapOf() + private val suspendHandlersByActionValue: MutableMap = mutableMapOf() - fun register(actionValue: WebViewAction, handler: WebViewActionHandler) { + fun register(actionValue: WebViewAction, handler: BridgeMessageHandler) { if (handlersByActionValue.containsKey(actionValue)) { mindboxLogW("Handler for action $actionValue already registered") } handlersByActionValue[actionValue] = handler } - fun registerSuspend(actionValue: WebViewAction, handler: WebViewSuspendActionHandler) { + fun registerSuspend(actionValue: WebViewAction, handler: BridgeSuspendMessageHandler) { if (suspendHandlersByActionValue.containsKey(actionValue)) { mindboxLogW("Suspend handler for action $actionValue already registered") } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index d05a82bca..d06dec208 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -34,8 +34,11 @@ import com.android.volley.VolleyError import com.android.volley.toolbox.StringRequest import com.android.volley.toolbox.Volley import com.google.gson.Gson -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import java.util.Timer import java.util.TreeMap import java.util.concurrent.ConcurrentHashMap @@ -106,39 +109,28 @@ internal class WebViewInAppViewHolder( private fun createWebViewActionHandlers( controller: WebViewController, - layer: Layer.WebViewLayer + layer: Layer.WebViewLayer, + configuration: Configuration ): WebViewActionHandlers { return WebViewActionHandlers().apply { - registerSuspend(WebViewAction.READY) { - executeReadyAction(layer) + register(WebViewAction.CLICK, ::handleClickAction) + register(WebViewAction.CLOSE, ::handleCloseAction) + register(WebViewAction.LOG, ::handleLogAction) + register(WebViewAction.TOAST, ::handleToastAction) + register(WebViewAction.ALERT, ::handleAlertAction) + register(WebViewAction.READY) { + handleReadyAction(layer, configuration) } register(WebViewAction.INIT) { - executeInitAction(controller) - } - register(WebViewAction.CLICK) { - executeCompletedAction(it) - } - register(WebViewAction.CLOSE) { - executeCloseAction() + handleInitAction(controller) } register(WebViewAction.HIDE) { - executeHideAction(controller) - } - register(WebViewAction.LOG) { - executeLogAction(it) - } - register(WebViewAction.TOAST) { - executeToastAction(it) - } - register(WebViewAction.ALERT) { - executeAlertAction(it) + handleHideAction(controller) } } } - private suspend fun executeReadyAction(layer: Layer.WebViewLayer): String { - val configuration: Configuration = DbManager.listenConfigurations().first() - + private fun handleReadyAction(layer: Layer.WebViewLayer, configuration: Configuration): String { val params: TreeMap = TreeMap(String.CASE_INSENSITIVE_ORDER).apply { put("sdkVersion", Mindbox.getSdkVersion()) put("endpointId", configuration.endpointId) @@ -150,7 +142,7 @@ internal class WebViewInAppViewHolder( return gson.toJson(params) } - private fun executeInitAction(controller: WebViewController): String { + private fun handleInitAction(controller: WebViewController): String { mindboxLogI("WebView initialization completed " + Stopwatch.stop(TIMER)) closeInappTimer?.cancel() closeInappTimer = null @@ -159,7 +151,7 @@ internal class WebViewInAppViewHolder( return BridgeMessage.EMPTY_PAYLOAD } - private fun executeCompletedAction(message: BridgeMessage.Request): String { + private fun handleClickAction(message: BridgeMessage.Request): String { runCatching { val actionDto: BackgroundDto.LayerDto.ImageLayerDto.ActionDto = gson.fromJson(message.payload).getOrThrow() @@ -183,32 +175,32 @@ internal class WebViewInAppViewHolder( return BridgeMessage.EMPTY_PAYLOAD } - private fun executeCloseAction(): String { + private fun handleCloseAction(message: BridgeMessage): String { inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) - mindboxLogI("In-app dismissed by webview action") + mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") hide() release() return BridgeMessage.EMPTY_PAYLOAD } - private fun executeHideAction(controller: WebViewController): String { + private fun handleHideAction(controller: WebViewController): String { controller.setVisibility(false) return BridgeMessage.EMPTY_PAYLOAD } - private fun executeLogAction(message: BridgeMessage.Request): String { + private fun handleLogAction(message: BridgeMessage.Request): String { mindboxLogI("JS: ${message.payload}") return BridgeMessage.EMPTY_PAYLOAD } - private fun executeToastAction(message: BridgeMessage.Request): String { + private fun handleToastAction(message: BridgeMessage.Request): String { webViewController?.view?.context?.let { context -> Toast.makeText(context, message.payload, Toast.LENGTH_LONG).show() } return BridgeMessage.EMPTY_PAYLOAD } - private fun executeAlertAction(message: BridgeMessage.Request): String { + private fun handleAlertAction(message: BridgeMessage.Request): String { webViewController?.view?.context?.let { context -> AlertDialog.Builder(context) .setMessage(message.payload) @@ -229,11 +221,11 @@ internal class WebViewInAppViewHolder( controller.setEventListener(object : WebViewEventListener { override fun onPageFinished(url: String?) { mindboxLogD("onPageFinished: $url") + webViewController?.evaluateJavaScript(JS_CHECK_BRIDGE, ::checkEvaluateJavaScript) } override fun onError(error: WebViewError) { - val message = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}" - mindboxLogE(message) + mindboxLogE("WebView error: code=${error.code}, description=${error.description}, url=${error.url}") if (error.isForMainFrame == true) { mindboxLogE("WebView critical error. Destroying In-App.") release() @@ -255,11 +247,6 @@ internal class WebViewInAppViewHolder( } private fun handleRequest(message: BridgeMessage.Request, controller: WebViewController, handlers: WebViewActionHandlers) { - val messageId: String = message.id - if (messageId.isBlank()) { - mindboxLogW("WebView request without id for action ${message.action}") - return - } if (handlers.hasSuspendHandler(message.action)) { Mindbox.mindboxScope.launch { val responsePayload: String = handlers.handleRequestSuspend(message) @@ -294,18 +281,14 @@ internal class WebViewInAppViewHolder( controller: WebViewController, ) { val errorMessage: BridgeMessage.Error = BridgeMessage.createErrorAction(message, error.message) + mindboxLogE("WebView send error response for ${message.action} with payload ${errorMessage.payload}") sendActionInternal(controller, errorMessage) } private fun handleResponse(message: BridgeMessage.Response) { - val messageId: String = message.id - if (messageId.isBlank()) { - mindboxLogW("WebView response without id for action ${message.action}") - return - } - val responseDeferred: CompletableDeferred? = pendingResponsesById.remove(messageId) + val responseDeferred: CompletableDeferred? = pendingResponsesById.remove(message.id) if (responseDeferred == null) { - mindboxLogW("No pending response for id $messageId") + mindboxLogW("No pending response for id $message.id") return } if (!responseDeferred.isCompleted) { @@ -315,12 +298,7 @@ internal class WebViewInAppViewHolder( private fun handleError(message: BridgeMessage.Error) { mindboxLogW("WebView error: ${message.payload}") - val messageId: String = message.id - if (messageId.isBlank()) { - mindboxLogW("WebView error without id for action ${message.action}") - return - } - val responseDeferred: CompletableDeferred? = pendingResponsesById.remove(messageId) + val responseDeferred: CompletableDeferred? = pendingResponsesById.remove(message.id) responseDeferred?.cancel("WebView error: ${message.payload}") hide() } @@ -335,44 +313,32 @@ internal class WebViewInAppViewHolder( pendingResponsesById.clear() } - private fun addUrlSource(layer: Layer.WebViewLayer) { + private fun renderLayer(layer: Layer.WebViewLayer) { if (webViewController == null) { val controller: WebViewController = createWebViewController(layer) webViewController = controller - val handlers: WebViewActionHandlers = createWebViewActionHandlers(controller, layer) - - controller.setVisibility(false) - controller.setJsBridge(bridge = { json -> - val message = gson.fromJson(json).getOrNull() - if (!messageValidator.isValid(message)) { - return@setJsBridge - } - - when (message) { - is BridgeMessage.Request -> handleRequest(message, controller, handlers) - is BridgeMessage.Response -> handleResponse(message) - is BridgeMessage.Error -> handleError(message) - else -> mindboxLogW("Unknown message type: $message") - } - }) Mindbox.mindboxScope.launch { val configuration: Configuration = DbManager.listenConfigurations().first() - withContext(Dispatchers.Main) { - controller.setUserAgentSuffix(configuration.getShortUserAgent()) - } - controller.setEventListener(object : WebViewEventListener { - override fun onPageFinished(url: String?) { - webViewController?.evaluateJavaScript(JS_CHECK_BRIDGE, ::checkEvaluateJavaScript) + val handlers: WebViewActionHandlers = createWebViewActionHandlers(controller, layer, configuration) + + controller.setVisibility(false) + controller.setJsBridge(bridge = { json -> + val message = gson.fromJson(json).getOrNull() + if (!messageValidator.isValid(message)) { + return@setJsBridge } - override fun onError(error: WebViewError) { - super.onError(error) - mindboxLogE("WebView error: $error") - hide() + when (message) { + is BridgeMessage.Request -> handleRequest(message, controller, handlers) + is BridgeMessage.Response -> handleResponse(message) + is BridgeMessage.Error -> handleError(message) + else -> mindboxLogW("Unknown message type: $message") } }) + controller.setUserAgentSuffix(configuration.getShortUserAgent()) + val requestQueue: RequestQueue = Volley.newRequestQueue(currentDialog.context) val stringRequest = StringRequest( Request.Method.GET, @@ -420,6 +386,7 @@ internal class WebViewInAppViewHolder( controller.executeOnViewThread { if (closeInappTimer != null) { mindboxLogE("WebView initialization timed out after ${Stopwatch.stop(TIMER)}.") + hide() release() } } @@ -433,7 +400,7 @@ internal class WebViewInAppViewHolder( wrapper.inAppType.layers.forEach { layer -> when (layer) { is Layer.WebViewLayer -> { - addUrlSource(layer) + renderLayer(layer) } else -> { @@ -449,7 +416,7 @@ internal class WebViewInAppViewHolder( super.reattach(currentRoot) wrapper.inAppType.layers.forEach { layer -> when (layer) { - is Layer.WebViewLayer -> addUrlSource(layer) + is Layer.WebViewLayer -> renderLayer(layer) else -> mindboxLogW("Layer is not supported") } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidatorTest.kt index 02fb37d02..5e50eb8f0 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidatorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidatorTest.kt @@ -1,11 +1,13 @@ package cloud.mindbox.mobile_sdk.inapp.data.validators +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage import cloud.mindbox.mobile_sdk.inapp.presentation.view.WebViewAction import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +@OptIn(InternalMindboxApi::class) class BridgeMessageValidatorTest { private val validator: BridgeMessageValidator = BridgeMessageValidator() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewActionHandlersTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewActionHandlersTest.kt index 5cf91ba7e..c4f2084fd 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewActionHandlersTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewActionHandlersTest.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import kotlinx.coroutines.Deferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -8,7 +9,7 @@ import kotlinx.coroutines.test.* import org.junit.Assert.* import org.junit.Test -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, InternalMindboxApi::class) class WebViewActionHandlersTest { @Test From e1412f8ec59a83a49e3e83eba3658ddea201d62a Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:03:52 +0300 Subject: [PATCH 11/59] MOBILEWEBVIEW-31: support featureToggle section in config --- .../mobile_sdk/di/modules/DataModule.kt | 5 +- .../mobile_sdk/di/modules/MindboxModule.kt | 1 + .../di/modules/PresentationModule.kt | 2 +- .../FeatureTogglesDtoBlankDeserializer.kt | 28 ++ .../deserializers/JsonElementExtensions.kt | 6 + .../data/managers/FeatureToggleManagerImpl.kt | 25 ++ .../MobileConfigSerializationManagerImpl.kt | 10 +- .../MobileConfigRepositoryImpl.kt | 15 +- .../managers/FeatureToggleManager.kt | 10 + .../InAppMessageViewDisplayerImpl.kt | 12 +- .../operation/response/InAppConfigResponse.kt | 14 +- .../FeatureTogglesDtoBlankDeserializerTest.kt | 160 ++++++++++ .../managers/FeatureToggleManagerImplTest.kt | 277 ++++++++++++++++++ ...ngsMobileConfigSerializationManagerTest.kt | 94 ++++++ .../MobileConfigRepositoryImplTest.kt | 3 +- .../InAppMessageViewDisplayerImplTest.kt | 2 +- .../MobileConfigSettingsManagerTest.kt | 2 +- .../mindbox/mobile_sdk/models/SettingsStub.kt | 6 +- ...igWithSettingsABTestsMonitoringInapps.json | 3 + .../Settings/FeatureTogglesConfig.json | 28 ++ .../FeatureTogglesError.json | 16 + .../FeatureTogglesFalse.json | 19 ++ ...ogglesShouldSendInAppShowErrorMissing.json | 17 ++ ...glesShouldSendInAppShowErrorTypeError.json | 19 ++ .../FeatureTogglesTypeError.json | 17 ++ .../Settings/SettingsConfig.json | 3 + 26 files changed, 780 insertions(+), 14 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializer.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/FeatureToggleManager.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializerTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt create mode 100644 sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesConfig.json create mode 100644 sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesError.json create mode 100644 sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesFalse.json create mode 100644 sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorMissing.json create mode 100644 sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorTypeError.json create mode 100644 sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesTypeError.json diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 41f7ec0de..6ec059ae4 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -18,6 +18,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageLoader import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.GeoSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager @@ -157,7 +158,8 @@ internal fun DataModule( timeSpanPositiveValidator = slidingExpirationParametersValidator, mobileConfigSettingsManager = mobileConfigSettingsManager, integerPositiveValidator = integerPositiveValidator, - inappSettingsManager = inappSettingsManager + inappSettingsManager = inappSettingsManager, + featureToggleManager = featureToggleManager ) } @@ -242,6 +244,7 @@ internal fun DataModule( } override val integerPositiveValidator: IntegerPositiveValidator by lazy { IntegerPositiveValidator() } override val inappSettingsManager: InappSettingsManagerImpl by lazy { InappSettingsManagerImpl(sessionStorageManager) } + override val featureToggleManager: FeatureToggleManager by lazy { FeatureToggleManagerImpl() } override val maxInappsPerSessionLimitChecker: Checker by lazy { MaxInappsPerSessionLimitChecker(sessionStorageManager) } override val maxInappsPerDayLimitChecker: Checker by lazy { MaxInappsPerDayLimitChecker(inAppRepository, sessionStorageManager, timeProvider) } override val minIntervalBetweenShowsLimitChecker: Checker by lazy { MinIntervalBetweenShowsLimitChecker(sessionStorageManager, inAppRepository, timeProvider) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index 8c2796e29..729ac3d96 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -114,6 +114,7 @@ internal interface DataModule : MindboxModule { val mobileConfigSettingsManager: MobileConfigSettingsManager val integerPositiveValidator: IntegerPositiveValidator val inappSettingsManager: InappSettingsManager + val featureToggleManager: FeatureToggleManager val maxInappsPerSessionLimitChecker: Checker val maxInappsPerDayLimitChecker: Checker val minIntervalBetweenShowsLimitChecker: Checker diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt index ec8be65c0..3fb51bd31 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt @@ -17,7 +17,7 @@ internal fun PresentationModule( AppContextModule by appContextModule { override val inAppMessageViewDisplayer by lazy { - InAppMessageViewDisplayerImpl(inAppImageSizeStorage) + InAppMessageViewDisplayerImpl(inAppImageSizeStorage, featureToggleManager) } override val inAppMessageManager by lazy { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializer.kt new file mode 100644 index 000000000..6b904cad1 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializer.kt @@ -0,0 +1,28 @@ +package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers + +import cloud.mindbox.mobile_sdk.models.operation.response.SettingsDtoBlank +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import java.lang.reflect.Type + +private typealias FeatureTogglesDtoBlank = SettingsDtoBlank.FeatureTogglesDtoBlank + +internal class FeatureTogglesDtoBlankDeserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): FeatureTogglesDtoBlank { + val jsonObject = json.asJsonObject + val result = mutableMapOf() + + jsonObject.entrySet().forEach { (key, value) -> + result[key] = value?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean } + ?.asJsonPrimitive + ?.asBoolean + } + + return FeatureTogglesDtoBlank(toggles = result) + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/JsonElementExtensions.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/JsonElementExtensions.kt index 27f6cdbae..e8198e416 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/JsonElementExtensions.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/JsonElementExtensions.kt @@ -39,3 +39,9 @@ internal fun JsonElement.getString(): String? { else -> null } } + +internal fun JsonObject.getAsBooleanOrNull(key: String): Boolean? { + return get(key)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean } + ?.asJsonPrimitive + ?.asBoolean +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt new file mode 100644 index 000000000..efe64102d --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt @@ -0,0 +1,25 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager +import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse +import java.util.concurrent.ConcurrentHashMap + +internal const val SEND_INAPP_SHOW_ERROR_FEATURE = "shouldSendInAppShowError" + +internal class FeatureToggleManagerImpl : FeatureToggleManager { + + private val toggles = ConcurrentHashMap() + + override fun applyToggles(config: InAppConfigResponse?) { + toggles.clear() + config?.settings?.featureToggles?.forEach { (key, value) -> + value?.let { + toggles[key] = value + } + } + } + + override fun isEnabled(key: String): Boolean { + return toggles[key] ?: false + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/MobileConfigSerializationManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/MobileConfigSerializationManagerImpl.kt index b9d9bfc3b..8d7ce6c5e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/MobileConfigSerializationManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/MobileConfigSerializationManagerImpl.kt @@ -110,12 +110,18 @@ internal class MobileConfigSerializationManagerImpl(private val gson: Gson) : } val inappSettings = runCatching { - gson.fromJson(json.asJsonObject.get("inapp"), SettingsDtoBlank.InappSettingsDtoBlank::class.java)?.copy() + gson.fromJson(json.asJsonObject.get("inapp"), InappSettingsDtoBlank::class.java)?.copy() }.getOrNull { mindboxLogE("Failed to parse inapp block in settings section ") } - SettingsDtoBlank(operations, ttl, slidingExpiration, inappSettings) + val featureToggles = runCatching { + gson.fromJson(json.asJsonObject.get("featureToggles"), FeatureTogglesDtoBlank::class.java)?.copy() + }.getOrNull { + mindboxLogE("Failed to parse featureToggles block in settings section") + } + + SettingsDtoBlank(operations, ttl, slidingExpiration, inappSettings, featureToggles) } }.getOrNull { mindboxLogE("Failed to parse settings block", it) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt index 34a864701..097abc998 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt @@ -7,6 +7,7 @@ import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.managers.data_filler.DataManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.data.validators.* +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator @@ -49,7 +50,8 @@ internal class MobileConfigRepositoryImpl( private val timeSpanPositiveValidator: TimeSpanPositiveValidator, private val mobileConfigSettingsManager: MobileConfigSettingsManager, private val integerPositiveValidator: IntegerPositiveValidator, - private val inappSettingsManager: InappSettingsManager + private val inappSettingsManager: InappSettingsManager, + private val featureToggleManager: FeatureToggleManager ) : MobileConfigRepository { private val mutex = Mutex() @@ -100,6 +102,7 @@ internal class MobileConfigRepositoryImpl( mobileConfigSettingsManager.saveSessionTime(config = filteredConfig) mobileConfigSettingsManager.checkPushTokenKeepalive(config = filteredConfig) inappSettingsManager.applySettings(config = filteredConfig) + featureToggleManager.applyToggles(config = filteredConfig) configState.value = updatedInAppConfig mindboxLogI(message = "Providing config: $updatedInAppConfig") } @@ -182,7 +185,12 @@ internal class MobileConfigRepositoryImpl( val inappSettings = runCatching { getInappSettings(configBlank) }.getOrNull { mindboxLogW("Unable to get inapp settings $it") } - return SettingsDto(operations, ttl, slidingExpiration, inappSettings) + + val featureToggles = runCatching { getFeatureToggles(configBlank) }.getOrNull { + mindboxLogW("Unable to get featureToggles settings $it") + } + + return SettingsDto(operations, ttl, slidingExpiration, inappSettings, featureToggles) } private fun getInAppTtl(configBlank: InAppConfigResponseBlank?): TtlDto? = @@ -241,6 +249,9 @@ internal class MobileConfigRepositoryImpl( null } + private fun getFeatureToggles(configBlank: InAppConfigResponseBlank?): Map? = + configBlank?.settings?.featureToggles?.toggles + private fun getABTests(configBlank: InAppConfigResponseBlank?): List { return try { if (configBlank?.abtests == null) return listOf() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/FeatureToggleManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/FeatureToggleManager.kt new file mode 100644 index 000000000..244e6a8ad --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/FeatureToggleManager.kt @@ -0,0 +1,10 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers + +import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse + +internal interface FeatureToggleManager { + + fun applyToggles(config: InAppConfigResponse?) + + fun isEnabled(key: String): Boolean +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 02e2f808a..d38a009fc 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -8,8 +8,10 @@ import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.fromJson import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_SHOW_ERROR_FEATURE import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer @@ -34,7 +36,10 @@ internal interface MindboxView { fun requestPermission() } -internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: InAppImageSizeStorage) : +internal class InAppMessageViewDisplayerImpl( + private val inAppImageSizeStorage: InAppImageSizeStorage, + private val featureToggleManager: FeatureToggleManager +) : InAppMessageViewDisplayer { companion object { @@ -191,6 +196,11 @@ internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: wrapper: InAppTypeWrapper, isRestored: Boolean = false, ) { + when (featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) { + true -> mindboxLogI("InApp.ShowFailure sending enabled") + false -> mindboxLogI("InApp.ShowFailure sending disabled") + } + if (!isRestored) isActionExecuted = false if (isRestored && tryReattachRestoredInApp(wrapper.inAppType.inAppId)) return diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt index 5125ed618..f9be86f54 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.models.operation.response import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.FeatureTogglesDtoBlankDeserializer import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.InAppIsPriorityDeserializer import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.TimeSpan @@ -31,7 +32,9 @@ internal data class SettingsDtoBlank( @SerializedName("slidingExpiration") val slidingExpiration: SlidingExpirationDtoBlank?, @SerializedName("inapp") - val inappSettings: InappSettingsDtoBlank? + val inappSettings: InappSettingsDtoBlank?, + @SerializedName("featureToggles") + val featureToggles: FeatureTogglesDtoBlank? ) { internal data class OperationDtoBlank( @SerializedName("systemName") @@ -60,6 +63,11 @@ internal data class SettingsDtoBlank( @SerializedName(InappSettingsDtoBlankDeserializer.MIN_INTERVAL_BETWEEN_SHOWS) val minIntervalBetweenShows: TimeSpan?, ) + + @JsonAdapter(FeatureTogglesDtoBlankDeserializer::class) + internal data class FeatureTogglesDtoBlank( + val toggles: Map + ) } internal data class SettingsDto( @@ -70,7 +78,9 @@ internal data class SettingsDto( @SerializedName("slidingExpiration") val slidingExpiration: SlidingExpirationDto?, @SerializedName("inapp") - val inapp: InappSettingsDto? + val inapp: InappSettingsDto?, + @SerializedName("featureToggles") + val featureToggles: Map? ) internal data class OperationDto( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializerTest.kt new file mode 100644 index 000000000..eb7ee8e93 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializerTest.kt @@ -0,0 +1,160 @@ +package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers + +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_SHOW_ERROR_FEATURE +import cloud.mindbox.mobile_sdk.models.operation.response.SettingsDtoBlank +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +internal class FeatureTogglesDtoBlankDeserializerTest { + private lateinit var gson: Gson + + @Before + fun setup() { + gson = GsonBuilder() + .create() + } + + @Test + fun `deserialize valid true value`() { + val json = JsonObject().apply { + addProperty("shouldSendInAppShowError", true) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertEquals(true, result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize valid false value`() { + val json = JsonObject().apply { + addProperty("shouldSendInAppShowError", false) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertEquals(false, result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize multiple keys`() { + val json = JsonObject().apply { + addProperty("shouldSendInAppShowError", true) + addProperty("anotherToggle", false) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertEquals(true, result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + assertEquals(false, result.toggles["anotherToggle"]) + } + + @Test + fun `deserialize string true value`() { + val json = JsonObject().apply { + addProperty("shouldSendInAppShowError", "true") + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize string false value`() { + val json = JsonObject().apply { + addProperty("shouldSendInAppShowError", "false") + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize number 1 value`() { + val json = JsonObject().apply { + addProperty("shouldSendInAppShowError", 1) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize invalid string value`() { + val json = JsonObject().apply { + addProperty("shouldSendInAppShowError", "invalid") + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize object value`() { + val json = JsonObject().apply { + add("shouldSendInAppShowError", JsonObject().apply { + addProperty("value", true) + }) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize array value`() { + val json = JsonObject().apply { + add("shouldSendInAppShowError", JsonArray().apply { + add(true) + }) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize empty string value`() { + val json = JsonObject().apply { + addProperty("shouldSendInAppShowError", "") + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize missing key`() { + val json = JsonObject() + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertTrue(result.toggles.isEmpty()) + } + + @Test + fun `deserialize null value`() { + val json = JsonObject().apply { + add("shouldSendInAppShowError", JsonNull.INSTANCE) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt new file mode 100644 index 000000000..0a3dde008 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt @@ -0,0 +1,277 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse +import cloud.mindbox.mobile_sdk.models.operation.response.SettingsDto +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class FeatureToggleManagerImplTest { + + private lateinit var featureToggleManager: FeatureToggleManagerImpl + + @Before + fun onTestStart() { + featureToggleManager = FeatureToggleManagerImpl() + } + + @Test + fun `applyToggles sets shouldSendInAppShowError to true when featureToggles contains true`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("shouldSendInAppShowError" to true) + ), + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles sets shouldSendInAppShowError to false when featureToggles contains false`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("shouldSendInAppShowError" to false) + ), + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles handles multiple toggles`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf( + "shouldSendInAppShowError" to true, + "anotherToggle" to false + ) + ), + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(false, featureToggleManager.isEnabled("anotherToggle")) + } + + @Test + fun `applyToggles ignores null values in featureToggles map`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf( + "shouldSendInAppShowError" to true, + "invalidToggle" to null + ) + ), + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(false, featureToggleManager.isEnabled("invalidToggle")) + } + + @Test + fun `applyToggles returns false when featureToggles is null`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = null + ), + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles returns false when settings is null`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = null, + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles returns false when config is null`() { + featureToggleManager.applyToggles(null) + + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `isEnabled returns false by default`() { + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles can change value from true to false`() { + val configTrue = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("shouldSendInAppShowError" to true) + ), + abtests = null + ) + featureToggleManager.applyToggles(configTrue) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + + val configFalse = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("shouldSendInAppShowError" to false) + ), + abtests = null + ) + featureToggleManager.applyToggles(configFalse) + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles can change value from false to true`() { + val configFalse = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("shouldSendInAppShowError" to false) + ), + abtests = null + ) + featureToggleManager.applyToggles(configFalse) + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + + val configTrue = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("shouldSendInAppShowError" to true) + ), + abtests = null + ) + featureToggleManager.applyToggles(configTrue) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles clears previous toggles when null config is applied`() { + val configTrue = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("shouldSendInAppShowError" to true) + ), + abtests = null + ) + featureToggleManager.applyToggles(configTrue) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + + featureToggleManager.applyToggles(null) + assertEquals(false, featureToggleManager.isEnabled("shouldSendInAppShowError")) + } + + @Test + fun `applyToggles clears previous toggles when new config is applied`() { + val config1 = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf( + "shouldSendInAppShowError" to true, + "toggle1" to true + ) + ), + abtests = null + ) + featureToggleManager.applyToggles(config1) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(true, featureToggleManager.isEnabled("toggle1")) + + val config2 = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("toggle2" to true) + ), + abtests = null + ) + featureToggleManager.applyToggles(config2) + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(false, featureToggleManager.isEnabled("toggle1")) + assertEquals(true, featureToggleManager.isEnabled("toggle2")) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/SettingsMobileConfigSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/SettingsMobileConfigSerializationManagerTest.kt index 9e4623b30..48bb9f388 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/SettingsMobileConfigSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/SettingsMobileConfigSerializationManagerTest.kt @@ -4,6 +4,7 @@ import android.app.Application import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationManagerImpl +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_SHOW_ERROR_FEATURE import cloud.mindbox.mobile_sdk.models.operation.response.ABTestDto import cloud.mindbox.mobile_sdk.models.operation.response.SdkVersion import io.mockk.every @@ -100,6 +101,7 @@ class SettingsMobileConfigSerializationManagerTest { assertNotNull(config.settings.ttl?.inApps) assertNotNull(config.settings.slidingExpiration?.config) assertNotNull(config.settings.slidingExpiration?.pushTokenKeepalive) + assertNotNull(config.settings.featureToggles) assertNotNull(config.abtests) assertEquals(2, config.abtests!!.size) @@ -126,6 +128,9 @@ class SettingsMobileConfigSerializationManagerTest { assertNotNull(config.inappSettings?.maxInappsPerDay) assertNotNull(config.inappSettings?.maxInappsPerSession) assertNotNull(config.inappSettings?.minIntervalBetweenShows) + + assertNotNull(config.featureToggles) + assertEquals(true, config.featureToggles?.toggles?.get(SEND_INAPP_SHOW_ERROR_FEATURE)) } // MARK: - Operations @@ -633,4 +638,93 @@ class SettingsMobileConfigSerializationManagerTest { assertNull("maxInappsPerDay must be `null` if the value is not a number", config.inappSettings?.maxInappsPerDay) assertNull("minIntervalBetweenShows must be `null` if the value is not a string", config.inappSettings?.minIntervalBetweenShows) } + + // MARK: - FeatureToggles + + @Test + fun settings_config_withFeatureToggles_shouldParseSuccessfully() { + val json = getJson("ConfigParsing/Settings/FeatureTogglesConfig.json") + val config = manager.deserializeSettings(json)!! + + assertNotNull("FeatureToggles must be successfully parsed", config.featureToggles) + assertEquals(true, config.featureToggles?.toggles?.get(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun settings_config_withFeatureTogglesError_shouldSetFeatureTogglesToNull() { + val json = getJson("ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesError.json") + val config = manager.deserializeSettings(json)!! + + assertNotNull("Operations must be successfully parsed", config.operations) + assertNotNull(config.operations?.get("viewProduct")) + assertNotNull(config.operations?.get("viewCategory")) + assertNotNull(config.operations?.get("setCart")) + + assertNotNull("TTL must be successfully parsed", config.ttl) + assertNotNull("TTL must be successfully parsed", config.ttl?.inApps) + + assertNull("FeatureToggles must be `null` if the key `featureToggles` is not found", config.featureToggles) + } + + @Test + fun settings_config_withFeatureTogglesTypeError_shouldSetFeatureTogglesToNull() { + val json = getJson("ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesTypeError.json") + val config = manager.deserializeSettings(json)!! + + assertNotNull("Operations must be successfully parsed", config.operations) + assertNotNull(config.operations?.get("viewProduct")) + assertNotNull(config.operations?.get("viewCategory")) + assertNotNull(config.operations?.get("setCart")) + + assertNotNull("TTL must be successfully parsed", config.ttl) + assertNotNull("TTL must be successfully parsed", config.ttl?.inApps) + + assertNull( + "FeatureToggles must be `null` if the type of `featureToggles` is not an object", + config.featureToggles + ) + } + + @Test + fun settings_config_withFeatureTogglesShouldSendInAppShowErrorMissing_shouldSetValueToNull() { + val json = getJson("ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorMissing.json") + val config = manager.deserializeSettings(json)!! + + assertNotNull("Operations must be successfully parsed", config.operations) + assertNotNull(config.operations?.get("viewProduct")) + assertNotNull(config.operations?.get("viewCategory")) + assertNotNull(config.operations?.get("setCart")) + + assertNotNull("TTL must be successfully parsed", config.ttl) + assertNotNull("TTL must be successfully parsed", config.ttl?.inApps) + + assertNotNull("FeatureToggles must be parsed if the object exists", config.featureToggles) + assertTrue("FeatureToggles should be empty if no valid values", config.featureToggles!!.toggles.isEmpty()) + } + + @Test + fun settings_config_withFeatureTogglesShouldSendInAppShowErrorTypeError_shouldSetValueToNull() { + val json = getJson("ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorTypeError.json") + val config = manager.deserializeSettings(json)!! + + assertNotNull("Operations must be successfully parsed", config.operations) + assertNotNull(config.operations?.get("viewProduct")) + assertNotNull(config.operations?.get("viewCategory")) + assertNotNull(config.operations?.get("setCart")) + + assertNotNull("TTL must be successfully parsed", config.ttl) + assertNotNull("TTL must be successfully parsed", config.ttl?.inApps) + + assertNotNull("FeatureToggles must be parsed if the object exists", config.featureToggles) + assertNull("shouldSendInAppShowError must be `null` if the value is not a boolean", config.featureToggles?.toggles?.get(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun settings_config_withFeatureTogglesFalse_shouldParseFalse() { + val json = getJson("ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesFalse.json") + val config = manager.deserializeSettings(json)!! + + assertNotNull("FeatureToggles must be successfully parsed", config.featureToggles) + assertEquals(false, config.featureToggles?.toggles?.get("shouldSendInAppShowError")) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt index aaaa78bf2..4d73a19fd 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt @@ -97,7 +97,8 @@ internal class MobileConfigRepositoryImplTest { sessionStorageManager = mockk(relaxed = true), mobileConfigSettingsManager = mockk(relaxed = true), integerPositiveValidator = mockk(relaxed = true), - inappSettingsManager = mockk(relaxed = true) + inappSettingsManager = mockk(relaxed = true), + featureToggleManager = mockk(relaxed = true) ) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt index 57bd95ead..68d5445be 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt @@ -25,7 +25,7 @@ internal class InAppMessageViewDisplayerImplTest { every { MindboxDI.appModule } returns mockk { every { gson } returns Gson() } - displayer = InAppMessageViewDisplayerImpl(mockk()) + displayer = InAppMessageViewDisplayerImpl(mockk(), mockk()) } @After diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MobileConfigSettingsManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MobileConfigSettingsManagerTest.kt index c28f1a36f..43d055ec1 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MobileConfigSettingsManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MobileConfigSettingsManagerTest.kt @@ -148,7 +148,7 @@ class MobileConfigSettingsManagerImplTest { @Test fun `checkPushTokenKeepalive not sends when SlidingExpiration is null`() { every { MindboxPreferences.lastInfoUpdateTime } returns now - val config = InAppConfigResponse(null, null, SettingsDto(null, null, null, null), null) + val config = InAppConfigResponse(null, null, SettingsDto(null, null, null, null, null), null) mobileConfigSettingsManager.checkPushTokenKeepalive(config) verify(exactly = 0) { MindboxEventManager.appKeepalive(any(), any()) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/SettingsStub.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/SettingsStub.kt index 58f912aee..4767fd3bf 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/SettingsStub.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/SettingsStub.kt @@ -21,7 +21,8 @@ internal class SettingsStub { config = config, pushTokenKeepalive = pushTokenKeepalive ), - inapp = null + inapp = null, + featureToggles = null ), abtests = null ) @@ -43,7 +44,8 @@ internal class SettingsStub { maxInappsPerSession = maxInappsPerSession, maxInappsPerDay = maxInappsPerDay, minIntervalBetweenShows = minIntervalBetweenShows - ) + ), + featureToggles = emptyMap() ), abtests = null ) diff --git a/sdk/src/test/resources/ConfigParsing/ConfigWithSettingsABTestsMonitoringInapps.json b/sdk/src/test/resources/ConfigParsing/ConfigWithSettingsABTestsMonitoringInapps.json index 4868abfbc..4234fccae 100644 --- a/sdk/src/test/resources/ConfigParsing/ConfigWithSettingsABTestsMonitoringInapps.json +++ b/sdk/src/test/resources/ConfigParsing/ConfigWithSettingsABTestsMonitoringInapps.json @@ -330,6 +330,9 @@ "slidingExpiration": { "config": "0.00:30:00", "pushTokenKeepalive": "0.00:40:00" + }, + "featureToggles": { + "shouldSendInAppShowError": true } }, "abtests": [ diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesConfig.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesConfig.json new file mode 100644 index 000000000..52f42bdab --- /dev/null +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesConfig.json @@ -0,0 +1,28 @@ +{ + "operations": { + "viewProduct": { + "systemName": "ViewProduct" + }, + "viewCategory": { + "systemName": "ViewCategory" + }, + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:30:00", + "pushTokenKeepalive": "0.00:40:00" + }, + "inapp": { + "maxInappsPerSession": "2147483647", + "maxInappsPerDay": "33", + "minIntervalBetweenShows": "00:30:00" + }, + "featureToggles": { + "shouldSendInAppShowError": true + } +} diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesError.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesError.json new file mode 100644 index 000000000..ea62a96c9 --- /dev/null +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesError.json @@ -0,0 +1,16 @@ +{ + "operations": { + "viewProduct": { + "systemName": "ViewProduct" + }, + "viewCategory": { + "systemName": "ViewCategory" + }, + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + } +} diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesFalse.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesFalse.json new file mode 100644 index 000000000..444d36ee4 --- /dev/null +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesFalse.json @@ -0,0 +1,19 @@ +{ + "operations": { + "viewProduct": { + "systemName": "ViewProduct" + }, + "viewCategory": { + "systemName": "ViewCategory" + }, + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "featureToggles": { + "shouldSendInAppShowError": false + } +} diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorMissing.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorMissing.json new file mode 100644 index 000000000..e269393a9 --- /dev/null +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorMissing.json @@ -0,0 +1,17 @@ +{ + "operations": { + "viewProduct": { + "systemName": "ViewProduct" + }, + "viewCategory": { + "systemName": "ViewCategory" + }, + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "featureToggles": {} +} diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorTypeError.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorTypeError.json new file mode 100644 index 000000000..45423cc62 --- /dev/null +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorTypeError.json @@ -0,0 +1,19 @@ +{ + "operations": { + "viewProduct": { + "systemName": "ViewProduct" + }, + "viewCategory": { + "systemName": "ViewCategory" + }, + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "featureToggles": { + "shouldSendInAppShowError": "true" + } +} diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesTypeError.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesTypeError.json new file mode 100644 index 000000000..45cffd769 --- /dev/null +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesTypeError.json @@ -0,0 +1,17 @@ +{ + "operations": { + "viewProduct": { + "systemName": "ViewProduct" + }, + "viewCategory": { + "systemName": "ViewCategory" + }, + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "featureToggles": "not an object" +} diff --git a/sdk/src/test/resources/ConfigParsing/Settings/SettingsConfig.json b/sdk/src/test/resources/ConfigParsing/Settings/SettingsConfig.json index 8deb8fe5d..52f42bdab 100644 --- a/sdk/src/test/resources/ConfigParsing/Settings/SettingsConfig.json +++ b/sdk/src/test/resources/ConfigParsing/Settings/SettingsConfig.json @@ -21,5 +21,8 @@ "maxInappsPerSession": "2147483647", "maxInappsPerDay": "33", "minIntervalBetweenShows": "00:30:00" + }, + "featureToggles": { + "shouldSendInAppShowError": true } } From 8d9a627d9bb1f0f710cbb275562fe3062d113cf5 Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:12:07 +0300 Subject: [PATCH 12/59] MOBILEWEBVIEW-31: change default logic (#673) --- .../FeatureTogglesDtoBlankDeserializer.kt | 16 +++++--- .../data/managers/FeatureToggleManagerImpl.kt | 2 +- .../managers/FeatureToggleManagerImplTest.kt | 38 +++++++++---------- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializer.kt index 6b904cad1..7da4678a8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializer.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializer.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers +import cloud.mindbox.mobile_sdk.logger.mindboxLogW import cloud.mindbox.mobile_sdk.models.operation.response.SettingsDtoBlank import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer @@ -16,13 +17,18 @@ internal class FeatureTogglesDtoBlankDeserializer : JsonDeserializer() - jsonObject.entrySet().forEach { (key, value) -> - result[key] = value?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean } - ?.asJsonPrimitive - ?.asBoolean - } + val booleanValue = when { + value?.isJsonPrimitive == true && value.asJsonPrimitive.isBoolean -> + value.asJsonPrimitive.asBoolean + else -> { + mindboxLogW("Feature toggle value is not boolean. key=$key, value=$value") + null + } + } + result[key] = booleanValue + } return FeatureTogglesDtoBlank(toggles = result) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt index efe64102d..a7e82b69f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt @@ -20,6 +20,6 @@ internal class FeatureToggleManagerImpl : FeatureToggleManager { } override fun isEnabled(key: String): Boolean { - return toggles[key] ?: false + return toggles[key] ?: true } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt index 0a3dde008..e817746e5 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt @@ -80,7 +80,7 @@ class FeatureToggleManagerImplTest { } @Test - fun `applyToggles ignores null values in featureToggles map`() { + fun `applyToggles return true when null values in featureToggles map`() { val config = InAppConfigResponse( inApps = null, monitoring = null, @@ -100,11 +100,11 @@ class FeatureToggleManagerImplTest { featureToggleManager.applyToggles(config) assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) - assertEquals(false, featureToggleManager.isEnabled("invalidToggle")) + assertEquals(true, featureToggleManager.isEnabled("invalidToggle")) } @Test - fun `applyToggles returns false when featureToggles is null`() { + fun `applyToggles returns true when featureToggles is null`() { val config = InAppConfigResponse( inApps = null, monitoring = null, @@ -120,11 +120,11 @@ class FeatureToggleManagerImplTest { featureToggleManager.applyToggles(config) - assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) } @Test - fun `applyToggles returns false when settings is null`() { + fun `applyToggles returns true when settings is null`() { val config = InAppConfigResponse( inApps = null, monitoring = null, @@ -134,19 +134,19 @@ class FeatureToggleManagerImplTest { featureToggleManager.applyToggles(config) - assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) } @Test - fun `applyToggles returns false when config is null`() { + fun `applyToggles returns true when config is null`() { featureToggleManager.applyToggles(null) - assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) } @Test - fun `isEnabled returns false by default`() { - assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + fun `isEnabled returns true by default`() { + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) } @Test @@ -225,15 +225,15 @@ class FeatureToggleManagerImplTest { ttl = null, slidingExpiration = null, inapp = null, - featureToggles = mapOf("shouldSendInAppShowError" to true) + featureToggles = mapOf("shouldSendInAppShowError" to false) ), abtests = null ) featureToggleManager.applyToggles(configTrue) - assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) featureToggleManager.applyToggles(null) - assertEquals(false, featureToggleManager.isEnabled("shouldSendInAppShowError")) + assertEquals(true, featureToggleManager.isEnabled("shouldSendInAppShowError")) } @Test @@ -247,14 +247,14 @@ class FeatureToggleManagerImplTest { slidingExpiration = null, inapp = null, featureToggles = mapOf( - "shouldSendInAppShowError" to true, + "shouldSendInAppShowError" to false, "toggle1" to true ) ), abtests = null ) featureToggleManager.applyToggles(config1) - assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) assertEquals(true, featureToggleManager.isEnabled("toggle1")) val config2 = InAppConfigResponse( @@ -265,13 +265,13 @@ class FeatureToggleManagerImplTest { ttl = null, slidingExpiration = null, inapp = null, - featureToggles = mapOf("toggle2" to true) + featureToggles = mapOf("toggle2" to false) ), abtests = null ) featureToggleManager.applyToggles(config2) - assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) - assertEquals(false, featureToggleManager.isEnabled("toggle1")) - assertEquals(true, featureToggleManager.isEnabled("toggle2")) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(true, featureToggleManager.isEnabled("toggle1")) + assertEquals(false, featureToggleManager.isEnabled("toggle2")) } } From d136817db3665482d36fb8a5df46237d9d9696a1 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 6 Feb 2026 11:52:46 +0300 Subject: [PATCH 13/59] MOBILEWEBVIEW-7: add back action --- kmp-common-sdk | 2 +- .../InAppMessageViewDisplayerImpl.kt | 20 ++-- .../inapp/presentation/view/WebViewAction.kt | 3 + .../view/WebViewInappViewHolder.kt | 101 ++++++++++++------ 4 files changed, 83 insertions(+), 43 deletions(-) diff --git a/kmp-common-sdk b/kmp-common-sdk index 15032dfa4..7d0a46995 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 15032dfa4642c0d59ed9cd21d0fe289ea0d437c6 +Subproject commit 7d0a469952c5291af01ded0cef9204aa1a5c466d diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index d38a009fc..f122afd38 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -2,6 +2,8 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import android.app.Activity import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.activity.OnBackPressedDispatcherOwner import androidx.annotation.VisibleForTesting import cloud.mindbox.mobile_sdk.addUnique import cloud.mindbox.mobile_sdk.di.mindboxInject @@ -34,6 +36,8 @@ internal interface MindboxView { val container: ViewGroup fun requestPermission() + + fun registerBack(onBack: OnBackPressedCallback) } internal class InAppMessageViewDisplayerImpl( @@ -232,16 +236,7 @@ internal class InAppMessageViewDisplayerImpl( } currentActivity?.root?.let { root -> - currentHolder?.show(object : MindboxView { - override val container: ViewGroup - get() = root - - override fun requestPermission() { - currentActivity?.let { activity -> - mindboxNotificationManager.requestPermission(activity = activity) - } - } - }) + currentHolder?.show(createMindboxView(root)) } ?: run { mindboxLogE("failed to show inApp: currentRoot is null") } @@ -270,6 +265,11 @@ internal class InAppMessageViewDisplayerImpl( mindboxNotificationManager.requestPermission(activity = activity) } } + + override fun registerBack(onBack: OnBackPressedCallback) { + val backOwner = currentActivity as? OnBackPressedDispatcherOwner + backOwner?.onBackPressedDispatcher?.addCallback(onBack) + } } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index d3a10aeb9..582a2dd26 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -22,6 +22,9 @@ public enum class WebViewAction { @SerializedName("hide") HIDE, + @SerializedName("back") + BACK, + @SerializedName("log") LOG, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index d06dec208..7b1e92e1f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.view.ViewGroup import android.widget.RelativeLayout import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import cloud.mindbox.mobile_sdk.BuildConfig import cloud.mindbox.mobile_sdk.Mindbox @@ -61,6 +62,7 @@ internal class WebViewInAppViewHolder( private var closeInappTimer: Timer? = null private var webViewController: WebViewController? = null + private var backPressedCallback: OnBackPressedCallback? = null private val pendingResponsesById: MutableMap> = ConcurrentHashMap() @@ -143,11 +145,10 @@ internal class WebViewInAppViewHolder( } private fun handleInitAction(controller: WebViewController): String { - mindboxLogI("WebView initialization completed " + Stopwatch.stop(TIMER)) - closeInappTimer?.cancel() - closeInappTimer = null + stopTimer() wrapper.inAppActionCallbacks.onInAppShown.onShown() controller.setVisibility(true) + backPressedCallback?.isEnabled = true return BridgeMessage.EMPTY_PAYLOAD } @@ -235,6 +236,23 @@ internal class WebViewInAppViewHolder( return controller } + private fun clearBackPressedCallback() { + backPressedCallback?.remove() + backPressedCallback = null + } + + private fun sendBackAction(controller: WebViewController) { + val message: BridgeMessage.Request = BridgeMessage.createAction( + WebViewAction.BACK, + BridgeMessage.EMPTY_PAYLOAD + ) + sendActionInternal(controller, message) { error -> + mindboxLogW("Failed to send back action to WebView: $error") + inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) + hide() + } + } + internal fun checkEvaluateJavaScript(response: String?): Boolean { return when (response) { JS_RETURN -> true @@ -329,11 +347,13 @@ internal class WebViewInAppViewHolder( return@setJsBridge } - when (message) { - is BridgeMessage.Request -> handleRequest(message, controller, handlers) - is BridgeMessage.Response -> handleResponse(message) - is BridgeMessage.Error -> handleError(message) - else -> mindboxLogW("Unknown message type: $message") + controller.executeOnViewThread { + when (message) { + is BridgeMessage.Request -> handleRequest(message, controller, handlers) + is BridgeMessage.Response -> handleResponse(message) + is BridgeMessage.Error -> handleError(message) + else -> mindboxLogW("Unknown message type: $message") + } } }) @@ -371,26 +391,30 @@ internal class WebViewInAppViewHolder( } private fun onContentLoaded(controller: WebViewController, content: WebViewHtmlContent) { - controller.executeOnViewThread { - controller.loadContent(content) - startTimer(controller) + controller.loadContent(content) + startTimer { + controller.executeOnViewThread { + mindboxLogE("WebView initialization timed out after ${Stopwatch.stop(TIMER)}.") + hide() + release() + } + } + } + + private fun stopTimer() { + closeInappTimer?.let { timer -> + mindboxLogI("WebView initialization completed " + Stopwatch.stop(TIMER)) + timer.cancel() } + closeInappTimer = null } - private fun startTimer(controller: WebViewController) { + private fun startTimer(onTimeOut: () -> Unit) { Stopwatch.start(TIMER) closeInappTimer = timer( initialDelay = INIT_TIMEOUT_MS, period = INIT_TIMEOUT_MS, - action = { - controller.executeOnViewThread { - if (closeInappTimer != null) { - mindboxLogE("WebView initialization timed out after ${Stopwatch.stop(TIMER)}.") - hide() - release() - } - } - } + action = { onTimeOut() } ) } @@ -399,17 +423,27 @@ internal class WebViewInAppViewHolder( mindboxLogI("Try to show in-app with id ${wrapper.inAppType.inAppId}") wrapper.inAppType.layers.forEach { layer -> when (layer) { - is Layer.WebViewLayer -> { - renderLayer(layer) - } - - else -> { - mindboxLogD("Layer is not supported") - } + is Layer.WebViewLayer -> renderLayer(layer) + else -> mindboxLogW("Layer is not supported") } } mindboxLogI("Show In-App ${wrapper.inAppType.inAppId} in holder ${this.hashCode()}") inAppLayout.requestFocus() + webViewController?.let { controller -> + currentRoot.registerBack(registerBackPressedCallback(controller)) + } + } + + private fun registerBackPressedCallback(controller: WebViewController): OnBackPressedCallback { + val isBackCallbackEnabled = backPressedCallback?.isEnabled ?: false + clearBackPressedCallback() + val callback = object : OnBackPressedCallback(isBackCallbackEnabled) { + override fun handleOnBackPressed() { + sendBackAction(controller) + } + } + backPressedCallback = callback + return callback } override fun reattach(currentRoot: MindboxView) { @@ -421,15 +455,18 @@ internal class WebViewInAppViewHolder( } } inAppLayout.requestFocus() + webViewController?.let { controller -> + currentRoot.registerBack(registerBackPressedCallback(controller)) + } } override fun canReuseOnRestore(inAppId: String): Boolean = wrapper.inAppType.inAppId == inAppId override fun hide() { // Clean up timeout when hiding - closeInappTimer?.cancel() - closeInappTimer = null + stopTimer() cancelPendingResponses("WebView In-App is hidden") + clearBackPressedCallback() webViewController?.let { controller -> val view: WebViewPlatformView = controller.view inAppLayout.removeView(view) @@ -440,9 +477,9 @@ internal class WebViewInAppViewHolder( override fun release() { super.release() // Clean up WebView resources - closeInappTimer?.cancel() - closeInappTimer = null + stopTimer() cancelPendingResponses("WebView In-App is released") + clearBackPressedCallback() webViewController?.destroy() webViewController = null } From 36d8bf0a8a31867dd632dce0cb39111039cf596a Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 6 Feb 2026 16:32:50 +0300 Subject: [PATCH 14/59] MOBILEWEBVIEW-7: refactoring contentUrl request --- .../view/WebViewInappViewHolder.kt | 36 +++++++++---------- .../mobile_sdk/managers/GatewayManager.kt | 21 +++++++++++ .../network/MindboxServiceGenerator.kt | 11 ++++++ 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 7b1e92e1f..178ef263d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -23,17 +23,13 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.logger.mindboxLogW import cloud.mindbox.mobile_sdk.managers.DbManager +import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.getShortUserAgent import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.safeAs import cloud.mindbox.mobile_sdk.utils.Constants import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch -import com.android.volley.Request -import com.android.volley.RequestQueue -import com.android.volley.VolleyError -import com.android.volley.toolbox.StringRequest -import com.android.volley.toolbox.Volley import com.google.gson.Gson import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -68,6 +64,7 @@ internal class WebViewInAppViewHolder( private val gson: Gson by mindboxInject { this.gson } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } + private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } override val isActive: Boolean get() = isInAppMessageActive @@ -359,25 +356,26 @@ internal class WebViewInAppViewHolder( controller.setUserAgentSuffix(configuration.getShortUserAgent()) - val requestQueue: RequestQueue = Volley.newRequestQueue(currentDialog.context) - val stringRequest = StringRequest( - Request.Method.GET, - layer.contentUrl, - { response: String -> - onContentLoaded( + layer.contentUrl?.let { contentUrl -> + runCatching { + gatewayManager.fetchWebViewContent(contentUrl) + }.onSuccess { response: String -> + onContentPageLoaded( controller = controller, content = WebViewHtmlContent( baseUrl = layer.baseUrl ?: "", html = response ) ) - }, - { error: VolleyError -> - mindboxLogE("Failed to fetch HTML content for In-App: $error. Destroying.") + }.onFailure { e -> + mindboxLogE("Failed to fetch HTML content for In-App: $e") + hide() release() } - ) - requestQueue.add(stringRequest) + } ?: run { + mindboxLogE("WebView content URL is null") + hide() + } } } @@ -390,8 +388,10 @@ internal class WebViewInAppViewHolder( } ?: release() } - private fun onContentLoaded(controller: WebViewController, content: WebViewHtmlContent) { - controller.loadContent(content) + private fun onContentPageLoaded(controller: WebViewController, content: WebViewHtmlContent) { + controller.executeOnViewThread { + controller.loadContent(content) + } startTimer { controller.executeOnViewThread { mindboxLogE("WebView initialization timed out after ${Stopwatch.stop(TIMER)}.") diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt index ba5b86d2d..7a856bef5 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt @@ -6,6 +6,7 @@ import cloud.mindbox.mobile_sdk.fromJsonTyped import cloud.mindbox.mobile_sdk.inapp.data.dto.GeoTargetingDto import cloud.mindbox.mobile_sdk.inapp.domain.models.* import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl +import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.models.* import cloud.mindbox.mobile_sdk.models.operation.OperationResponseBaseInternal import cloud.mindbox.mobile_sdk.models.operation.request.LogResponseDto @@ -20,6 +21,7 @@ import com.android.volley.DefaultRetryPolicy.DEFAULT_BACKOFF_MULT import com.android.volley.ParseError import com.android.volley.Request import com.android.volley.VolleyError +import com.android.volley.toolbox.StringRequest import com.google.gson.Gson import kotlinx.coroutines.* import org.json.JSONException @@ -445,6 +447,25 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic } } + suspend fun fetchWebViewContent(contentUrl: String): String { + return suspendCoroutine { continuation -> + try { + val request: StringRequest = StringRequest( + Request.Method.GET, + contentUrl, + { response -> continuation.resume(response) }, + { error -> continuation.resumeWithException(error) } + ).apply { + setShouldCache(false) + } + mindboxServiceGenerator.addToRequestQueue(request) + } catch (e: Exception) { + mindboxLogE("Failed to fetch WebView content", e) + continuation.resumeWithException(e) + } + } + } + private inline fun Continuation.resumeFromJson(json: String) { loggingRunCatching(null) { gson.fromJsonTyped(json) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/network/MindboxServiceGenerator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/network/MindboxServiceGenerator.kt index aafc85d37..5314ff6be 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/network/MindboxServiceGenerator.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/network/MindboxServiceGenerator.kt @@ -5,6 +5,7 @@ import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.models.MindboxRequest import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler +import com.android.volley.Request import com.android.volley.RequestQueue import com.android.volley.VolleyLog import kotlinx.coroutines.launch @@ -35,6 +36,16 @@ internal class MindboxServiceGenerator(private val requestQueue: RequestQueue) { } } + internal fun addToRequestQueue(request: Request<*>) = LoggingExceptionHandler.runCatching { + requestQueue.add(request) + mindboxLogD( + """ + ---> Method: ${request.method} ${request.url} + ---> End of request + """.trimIndent() + ) + } + private fun logMindboxRequest(request: MindboxRequest) { LoggingExceptionHandler.runCatching { val builder = StringBuilder() From 1065d40515042b0d0a2bf27ef5c729a9d1779858 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:11:12 +0300 Subject: [PATCH 15/59] WMSDK-608: Support app distribution from all branches (#670) Cherry pick a6e7858abacffaf18a4c8e10ebcd8c905f873e15 --- .github/workflows/distribute-manual.yml | 8 +- .github/workflows/distribute-reusable.yml | 194 +++++++++++++++++++++- 2 files changed, 193 insertions(+), 9 deletions(-) diff --git a/.github/workflows/distribute-manual.yml b/.github/workflows/distribute-manual.yml index 8c415f0f0..b0341fbb0 100644 --- a/.github/workflows/distribute-manual.yml +++ b/.github/workflows/distribute-manual.yml @@ -2,10 +2,16 @@ name: Distribute PushOk (manual) on: workflow_dispatch: + inputs: + app_ref: + description: "GitLab App branch (Optional)" + required: false + default: "" jobs: call-reusable: uses: ./.github/workflows/distribute-reusable.yml with: branch: ${{ github.ref_name }} - secrets: inherit \ No newline at end of file + app_ref: ${{ inputs.app_ref }} + secrets: inherit diff --git a/.github/workflows/distribute-reusable.yml b/.github/workflows/distribute-reusable.yml index 85c3f1a4c..229fd15f2 100644 --- a/.github/workflows/distribute-reusable.yml +++ b/.github/workflows/distribute-reusable.yml @@ -6,6 +6,13 @@ on: branch: required: true type: string + app_ref: + required: false + type: string + default: "" + secrets: + GITLAB_TRIGGER_TOKEN: + required: true jobs: distribution: @@ -16,16 +23,187 @@ jobs: with: ref: ${{ inputs.branch }} submodules: recursive + fetch-depth: 3 - name: Get last 3 commit messages + shell: bash run: | - commits=$(git log -3 --pretty=format:"%s") - echo "commits=$commits" >> $GITHUB_ENV + set -euo pipefail + commits="$(git log -3 --pretty=format:"%s")" + echo "commits<> "$GITHUB_ENV" + echo "$commits" >> "$GITHUB_ENV" + echo "EOF" >> "$GITHUB_ENV" - - name: Trigger build & send to FAD + - name: Debug payload that will be sent to GitLab + shell: bash + env: + SOURCE_BRANCH: ${{ inputs.branch }} + APP_REF_OVERRIDE: ${{ inputs.app_ref }} + DEFAULT_APP_REF: develop + INPUT_COMMITS: ${{ env.commits }} run: | - curl --location 'https://mindbox.gitlab.yandexcloud.net/api/v4/projects/900/trigger/pipeline' \ - --form 'token="${{ secrets.GITLAB_TRIGGER_TOKEN }}"' \ - --form 'ref="develop"' \ - --form "variables[INPUT_BRANCH]=\"${{ inputs.branch }}\"" \ - --form "variables[INPUT_COMMITS]=\"${{ env.commits }}\"" + set -euo pipefail + + # Trim override so " " becomes empty + APP_REF_OVERRIDE="$(printf '%s' "${APP_REF_OVERRIDE:-}" | xargs)" + + echo "---- DEBUG (GitHub -> GitLab trigger payload) ----" + echo "SDK branch (INPUT_BRANCH): $SOURCE_BRANCH" + echo "Manual App ref override: ${APP_REF_OVERRIDE:-}" + echo "Default App ref: $DEFAULT_APP_REF" + echo "" + echo "RAW INPUT_COMMITS (cat -A):" + printf '%s' "${INPUT_COMMITS:-}" | cat -A + echo "" + echo "RAW INPUT_COMMITS (printf %q):" + printf '%q\n' "${INPUT_COMMITS:-}" + echo "--------------------------------------------------" + + - name: Trigger build & send to FAD (override strict; else same->develop) + env: + GITLAB_HOST: mindbox.gitlab.yandexcloud.net + APP_PROJECT_ID: "900" + DEFAULT_APP_REF: develop + + SOURCE_BRANCH: ${{ inputs.branch }} + APP_REF_OVERRIDE: ${{ inputs.app_ref }} + + GITLAB_TRIGGER_TOKEN: ${{ secrets.GITLAB_TRIGGER_TOKEN }} + INPUT_COMMITS: ${{ env.commits }} + shell: bash + run: | + set -euo pipefail + + # Trim override so " " becomes empty + APP_REF_OVERRIDE="$(printf '%s' "${APP_REF_OVERRIDE:-}" | xargs)" + + # Normalize commits: + # - convert CRLF -> LF + # - if commits accidentally contain literal "\n", expand them to real newlines + normalize_commits() { + local raw="${1:-}" + # CRLF -> LF + raw="$(printf '%s' "$raw" | tr -d '\r')" + + # If it contains literal "\n" (backslash+n), expand escapes + if [[ "$raw" == *"\\n"* ]]; then + raw="$(printf '%b' "$raw")" + fi + + printf '%s' "$raw" + } + + COMMITS_TO_SEND="$(normalize_commits "${INPUT_COMMITS:-}")" + + echo "SDK branch (INPUT_BRANCH): $SOURCE_BRANCH" + echo "Manual App ref override: ${APP_REF_OVERRIDE:-}" + echo "Default App ref: $DEFAULT_APP_REF" + echo "" + echo "COMMITS_TO_SEND preview (cat -A):" + printf '%s' "$COMMITS_TO_SEND" | cat -A + echo "" + + trigger_pipeline() { + local ref="$1" + local tmp_body + tmp_body="$(mktemp)" + + local code + code="$(curl -sS -o "$tmp_body" -w '%{http_code}' --location \ + --retry 3 --retry-all-errors --retry-delay 2 \ + "https://${GITLAB_HOST}/api/v4/projects/${APP_PROJECT_ID}/trigger/pipeline" \ + --form "token=${GITLAB_TRIGGER_TOKEN}" \ + --form "ref=${ref}" \ + --form "variables[INPUT_BRANCH]=${SOURCE_BRANCH}" \ + --form "variables[INPUT_COMMITS]=${COMMITS_TO_SEND}")" + + local body + body="$(cat "$tmp_body" 2>/dev/null || true)" + rm -f "$tmp_body" + + echo "Trigger HTTP: $code (ref=$ref)" + echo "Response body:" + echo "$body" + + if [[ "$code" == "200" || "$code" == "201" ]]; then + local web_url + web_url="$( + printf '%s\n' "$body" | + grep -o '"web_url":"[^"]*"' | + head -n 1 | + cut -d'"' -f4 + )" + if [[ -n "${web_url:-}" ]]; then + echo "Pipeline URL: $web_url" + fi + return 0 + fi + + if [[ "$code" == "401" || "$code" == "403" ]]; then + echo "Auth error (HTTP $code). Check that GITLAB_TRIGGER_TOKEN is valid and has access to project ${APP_PROJECT_ID}." + return 1 + fi + + # Missing ref: GitLab returns 400 + "Reference not found" + if [[ "$code" == "400" || "$code" == "404" ]]; then + if [[ "$body" == *"Reference not found"* ]]; then + return 2 + fi + + echo "Got HTTP $code but it's NOT 'Reference not found'." + echo "This can happen if pipelines are blocked for triggers by workflow:rules or job rules when CI_PIPELINE_SOURCE == 'trigger'." + echo "Check the target repo .gitlab-ci.yml rules/workflow:rules." + return 1 + fi + + if [[ "$code" =~ ^5[0-9][0-9]$ ]]; then + echo "Server error (HTTP $code). GitLab/proxy might be temporarily unavailable." + return 1 + fi + + echo "Unexpected HTTP status: $code" + return 1 + } + + # If override is provided: try ONLY override; if missing ref -> fail + if [[ -n "$APP_REF_OVERRIDE" ]]; then + echo "Override provided -> trying ONLY App ref: $APP_REF_OVERRIDE" + trigger_pipeline "$APP_REF_OVERRIDE" || rc=$? + rc="${rc:-0}" + + if [[ "$rc" == "0" ]]; then + echo "Triggered on override ref." + exit 0 + fi + + if [[ "$rc" == "2" ]]; then + echo "ERROR: App ref not found: $APP_REF_OVERRIDE (GitLab returned 'Reference not found')" + exit 1 + fi + + echo "Trigger failed for reasons other than missing ref." + exit 1 + fi + + # No override: same branch -> fallback develop + desired_ref="$SOURCE_BRANCH" + fallback_ref="$DEFAULT_APP_REF" + + echo "No override -> trying App ref: $desired_ref" + trigger_pipeline "$desired_ref" || rc=$? + rc="${rc:-0}" + + if [[ "$rc" == "0" ]]; then + echo "Triggered on same branch." + exit 0 + fi + + if [[ "$rc" == "2" ]]; then + echo "Same branch not found. Falling back to: $fallback_ref" + trigger_pipeline "$fallback_ref" + echo "Triggered on fallback ref." + exit 0 + fi + + echo "Trigger failed for reasons other than missing ref." + exit 1 From e5b54b0daab24ef0001fd77e41b5770c61cd616a Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:37:10 +0300 Subject: [PATCH 16/59] MOBILEWEBVIEW-10: Add Inapp.ShowFailure operation * MOBILEWEBVIEW-10: add failure tracking in InAppProcessingManager * MOBILEWEBVIEW-10: refactoring.follow review * MOBILEWEBVIEW-10: add tracking in modal and snackbar * MOBILEWEBVIEW-10: add tracking in webview inapp * MOBILEWEBVIEW-10: refactoring * MOBILEWEBVIEW-10: follow review --------- Co-authored-by: sozinov --- .../cloud/mindbox/mobile_sdk/Extensions.kt | 17 ++ .../mobile_sdk/di/modules/DataModule.kt | 14 ++ .../mobile_sdk/di/modules/DomainModule.kt | 4 +- .../mobile_sdk/di/modules/MindboxModule.kt | 2 + .../di/modules/PresentationModule.kt | 2 +- .../data/managers/FeatureToggleManagerImpl.kt | 2 +- .../data/managers/InAppFailureTrackerImpl.kt | 86 +++++++ .../managers/InAppSerializationManagerImpl.kt | 10 + .../data/managers/SessionStorageManager.kt | 2 + .../data/repositories/InAppRepositoryImpl.kt | 13 + .../InAppTargetingErrorRepositoryImpl.kt | 22 ++ .../inapp/domain/InAppEventManagerImpl.kt | 3 +- .../domain/InAppProcessingManagerImpl.kt | 93 +++++++- .../extensions/TrackingFailureExtension.kt | 111 +++++++++ .../managers/InAppFailureTracker.kt | 22 ++ .../managers/InAppSerializationManager.kt | 4 + .../repositories/InAppRepository.kt | 3 + .../InAppTargetingErrorRepository.kt | 11 + .../domain/models/InAppFailuresWrapper.kt | 7 + .../inapp/domain/models/TargetingErrorKey.kt | 11 + .../inapp/domain/models/TreeTargeting.kt | 6 + .../domain/models/ViewProductSegmentNode.kt | 10 + .../InAppMessageViewDisplayerImpl.kt | 44 ++-- .../view/AbstractInAppViewHolder.kt | 30 ++- .../view/WebViewInappViewHolder.kt | 56 ++++- .../managers/MindboxEventManager.kt | 5 + .../operation/request/InAppHandleRequest.kt | 2 +- .../operation/request/InAppShowFailure.kt | 40 ++++ .../FeatureTogglesDtoBlankDeserializerTest.kt | 22 +- .../managers/FeatureToggleManagerImplTest.kt | 42 +++- .../managers/InAppFailureTrackerImplTest.kt | 222 ++++++++++++++++++ .../managers/InAppSerializationManagerTest.kt | 43 +++- .../managers/SessionStorageManagerTest.kt | 5 + .../InAppTargetingErrorRepositoryTest.kt | 93 ++++++++ .../inapp/domain/InAppInteractorImplTest.kt | 12 +- .../domain/InAppProcessingManagerTest.kt | 186 ++++++++++++++- .../inapp/domain/models/TreeTargetingTest.kt | 68 ++++++ .../models/ViewProductCategoryInNodeTest.kt | 5 + .../models/ViewProductCategoryNodeTest.kt | 5 + .../domain/models/ViewProductNodeTest.kt | 5 + .../models/ViewProductSegmentNodeTest.kt | 13 +- .../InAppMessageViewDisplayerImplTest.kt | 2 +- .../Settings/FeatureTogglesConfig.json | 2 +- .../Settings/SettingsConfig.json | 2 +- 44 files changed, 1264 insertions(+), 95 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryImpl.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppTargetingErrorRepository.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TargetingErrorKey.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt index 9780be6a3..ca2530714 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt @@ -22,6 +22,7 @@ import cloud.mindbox.mobile_sdk.Mindbox.logE import cloud.mindbox.mobile_sdk.Mindbox.logW import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.pushes.PushNotificationManager.EXTRA_UNIQ_PUSH_BUTTON_KEY import cloud.mindbox.mobile_sdk.pushes.PushNotificationManager.EXTRA_UNIQ_PUSH_KEY @@ -299,3 +300,19 @@ internal fun List.sortByPriority(): List { internal inline fun Queue.pollIf(predicate: (T) -> Boolean): T? { return peek()?.takeIf(predicate)?.let { poll() } } + +internal fun InAppType.getImageUrl(): String? { + return when (this) { + is InAppType.WebView -> this.layers + is InAppType.ModalWindow -> this.layers + is InAppType.Snackbar -> this.layers + } + .filterIsInstance() + .firstOrNull() + ?.source + ?.let { source -> + when (source) { + is Layer.ImageLayer.Source.UrlSource -> source.url + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 6ec059ae4..ef2645a60 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -20,6 +20,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.GeoSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.* @@ -202,6 +203,14 @@ internal fun DataModule( override val inAppSerializationManager: InAppSerializationManager get() = InAppSerializationManagerImpl(gson = gson) + override val inAppFailureTracker: InAppFailureTracker by lazy { + InAppFailureTrackerImpl( + timeProvider = timeProvider, + inAppRepository = inAppRepository, + featureToggleManager = featureToggleManager + ) + } + override val inAppSegmentationRepository: InAppSegmentationRepository by lazy { InAppSegmentationRepositoryImpl( inAppMapper = inAppMapper, @@ -209,6 +218,11 @@ internal fun DataModule( gatewayManager = gatewayManager, ) } + override val inAppTargetingErrorRepository: InAppTargetingErrorRepository by lazy { + InAppTargetingErrorRepositoryImpl( + sessionStorageManager = sessionStorageManager + ) + } override val monitoringValidator: MonitoringValidator by lazy { MonitoringValidator() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt index ffd36eab9..27cf9e677 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt @@ -43,8 +43,10 @@ internal fun DomainModule( InAppProcessingManagerImpl( inAppGeoRepository = inAppGeoRepository, inAppSegmentationRepository = inAppSegmentationRepository, + inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = inAppContentFetcher, - inAppRepository = inAppRepository + inAppRepository = inAppRepository, + inAppFailureTracker = inAppFailureTracker ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index 729ac3d96..98cb9d491 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -69,7 +69,9 @@ internal interface DataModule : MindboxModule { val callbackRepository: CallbackRepository val geoSerializationManager: GeoSerializationManager val inAppSerializationManager: InAppSerializationManager + val inAppFailureTracker: InAppFailureTracker val inAppSegmentationRepository: InAppSegmentationRepository + val inAppTargetingErrorRepository: InAppTargetingErrorRepository val inAppValidator: InAppValidator val inAppMapper: InAppMapper val gson: Gson diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt index 3fb51bd31..ec8be65c0 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt @@ -17,7 +17,7 @@ internal fun PresentationModule( AppContextModule by appContextModule { override val inAppMessageViewDisplayer by lazy { - InAppMessageViewDisplayerImpl(inAppImageSizeStorage, featureToggleManager) + InAppMessageViewDisplayerImpl(inAppImageSizeStorage) } override val inAppMessageManager by lazy { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt index a7e82b69f..3407125cb 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt @@ -4,7 +4,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleMa import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse import java.util.concurrent.ConcurrentHashMap -internal const val SEND_INAPP_SHOW_ERROR_FEATURE = "shouldSendInAppShowError" +internal const val SEND_INAPP_SHOW_ERROR_FEATURE = "MobileSdkShouldSendInAppShowError" internal class FeatureToggleManagerImpl : FeatureToggleManager { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt new file mode 100644 index 000000000..9635e904c --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt @@ -0,0 +1,86 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import cloud.mindbox.mobile_sdk.convertToString +import cloud.mindbox.mobile_sdk.convertToZonedDateTimeAtUTC +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import org.threeten.bp.Instant +import java.util.concurrent.CopyOnWriteArrayList + +internal class InAppFailureTrackerImpl( + private val timeProvider: TimeProvider, + private val inAppRepository: InAppRepository, + private val featureToggleManager: FeatureToggleManager +) : InAppFailureTracker { + + private val failures = CopyOnWriteArrayList() + + private fun trackFailure(failure: InAppShowFailure) { + if (failures.none { it.inAppId == failure.inAppId }) { + failures.add(failure) + } + } + + private fun sendFailures() { + if (!featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) { + mindboxLogI("Feature $SEND_INAPP_SHOW_ERROR_FEATURE is off. Skip send failures") + return + } + if (failures.isNotEmpty()) inAppRepository.sendInAppShowFailure(failures.toList()) + failures.clear() + } + + private fun sendSingleFailure(failure: InAppShowFailure) { + if (!featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) { + mindboxLogI("Feature $SEND_INAPP_SHOW_ERROR_FEATURE is off. Skip send failure") + return + } + inAppRepository.sendInAppShowFailure(listOf(failure)) + } + + override fun sendFailure(inAppId: String, failureReason: FailureReason, errorDetails: String?) { + val timestamp = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) + .convertToZonedDateTimeAtUTC() + .convertToString() + + sendSingleFailure( + failure = InAppShowFailure( + inAppId = inAppId, + failureReason = failureReason, + errorDetails = errorDetails?.take(COUNT_OF_CHARS_IN_ERROR_DETAILS), + timestamp = timestamp + ) + ) + } + + override fun collectFailure(inAppId: String, failureReason: FailureReason, errorDetails: String?) { + val timestamp = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) + .convertToZonedDateTimeAtUTC() + .convertToString() + trackFailure( + InAppShowFailure( + inAppId = inAppId, + failureReason = failureReason, + errorDetails = errorDetails?.take(COUNT_OF_CHARS_IN_ERROR_DETAILS), + timestamp = timestamp + ) + ) + } + + override fun sendCollectedFailures() { + sendFailures() + } + + override fun clearFailures() { + failures.clear() + } + + companion object { + private const val COUNT_OF_CHARS_IN_ERROR_DETAILS = 1000 + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt index d695adc72..674168cd3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt @@ -2,7 +2,9 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.fromJsonTyped import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper import cloud.mindbox.mobile_sdk.models.operation.request.InAppHandleRequest +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import cloud.mindbox.mobile_sdk.toJsonTyped import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler import cloud.mindbox.mobile_sdk.utils.loggingRunCatching @@ -23,6 +25,14 @@ internal class InAppSerializationManagerImpl(private val gson: Gson) : InAppSeri } } + override fun serializeToInAppShowFailuresString( + inAppShowFailures: List + ): String { + return loggingRunCatching("") { + gson.toJsonTyped(InAppFailuresWrapper(inAppShowFailures)) + } + } + override fun deserializeToShownInAppsMap(shownInApps: String): Map> { return loggingRunCatching(hashMapOf()) { gson.fromJsonTyped>>(shownInApps) ?: hashMapOf() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt index 7cbc4491e..4523924f6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt @@ -22,6 +22,7 @@ internal class SessionStorageManager(private val timeProvider: TimeProvider) { var inAppProductSegmentations: HashMap, Set> = HashMap() var processedProductSegmentations: MutableMap, ProductSegmentationFetchStatus> = mutableMapOf() + var lastTargetingErrors: MutableMap = mutableMapOf() var currentSessionInApps: List = emptyList() var shownInAppIdsWithEvents = mutableMapOf>() var configFetchingError: Boolean = false @@ -75,6 +76,7 @@ internal class SessionStorageManager(private val timeProvider: TimeProvider) { geoFetchStatus = GeoFetchStatus.GEO_NOT_FETCHED inAppProductSegmentations.clear() processedProductSegmentations.clear() + lastTargetingErrors.clear() currentSessionInApps = emptyList() shownInAppIdsWithEvents.clear() configFetchingError = false diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt index 307038365..f6f8d044e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt @@ -10,6 +10,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider import kotlinx.coroutines.flow.Flow @@ -130,6 +131,18 @@ internal class InAppRepositoryImpl( } } + override fun sendInAppShowFailure(failures: List) { + failures + .takeIf { it.isNotEmpty() } + ?.let { failures -> + inAppSerializationManager.serializeToInAppShowFailuresString(failures) + .takeIf { it.isNotBlank() } + ?.let { operationBody -> + MindboxEventManager.inAppShowFailure(context, operationBody) + } + } + } + override fun isInAppShown(inAppId: String): Boolean { return sessionStorageManager.inAppMessageShownInSession.any { it == inAppId } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryImpl.kt new file mode 100644 index 000000000..a679604fb --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryImpl.kt @@ -0,0 +1,22 @@ +package cloud.mindbox.mobile_sdk.inapp.data.repositories + +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getVolleyErrorDetails +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppTargetingErrorRepository +import cloud.mindbox.mobile_sdk.inapp.domain.models.TargetingErrorKey + +internal class InAppTargetingErrorRepositoryImpl( + private val sessionStorageManager: SessionStorageManager, +) : InAppTargetingErrorRepository { + override fun saveError(key: TargetingErrorKey, error: Throwable) { + sessionStorageManager.lastTargetingErrors[key] = "${error.message}. ${error.cause?.getVolleyErrorDetails() ?: "volleyError = null"}" + } + + override fun getError(key: TargetingErrorKey): String? { + return sessionStorageManager.lastTargetingErrors[key] + } + + override fun clearErrors() { + sessionStorageManager.lastTargetingErrors.clear() + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerImpl.kt index ab7225862..b407f808b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerImpl.kt @@ -14,7 +14,8 @@ internal class InAppEventManagerImpl : InAppEventManager { val isNotInAppEvent = (listOf( MindboxEventManager.IN_APP_OPERATION_VIEW_TYPE, MindboxEventManager.IN_APP_OPERATION_TARGETING_TYPE, - MindboxEventManager.IN_APP_OPERATION_CLICK_TYPE + MindboxEventManager.IN_APP_OPERATION_CLICK_TYPE, + MindboxEventManager.IN_APP_OPERATION_SHOW_FAILURE_TYPE ).contains(event.name).not()) return isAppStartUp || (isOrdinalEvent && diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt index ef21c7c72..f0ef6db2c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt @@ -2,22 +2,31 @@ package cloud.mindbox.mobile_sdk.inapp.domain import cloud.mindbox.mobile_sdk.Mindbox.logI import cloud.mindbox.mobile_sdk.getErrorResponseBodyData +import cloud.mindbox.mobile_sdk.getImageUrl +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.asVolleyError +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getProductFromTargetingData +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getVolleyErrorDetails +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackTargetingError import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppProcessingManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppTargetingErrorRepository import cloud.mindbox.mobile_sdk.inapp.domain.models.* import cloud.mindbox.mobile_sdk.logger.* import cloud.mindbox.mobile_sdk.models.InAppEventType -import com.android.volley.VolleyError +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import kotlinx.coroutines.* internal class InAppProcessingManagerImpl( private val inAppGeoRepository: InAppGeoRepository, private val inAppSegmentationRepository: InAppSegmentationRepository, + private val inAppTargetingErrorRepository: InAppTargetingErrorRepository, private val inAppContentFetcher: InAppContentFetcher, - private val inAppRepository: InAppRepository + private val inAppRepository: InAppRepository, + private val inAppFailureTracker: InAppFailureTracker ) : InAppProcessingManager { companion object { @@ -34,6 +43,7 @@ internal class InAppProcessingManagerImpl( var isTargetingErrorOccurred = false var isInAppContentFetched: Boolean? = null var targetingCheck = false + var imageFailureDetails: String? = null withContext(Dispatchers.IO) { val imageJob = launch(start = CoroutineStart.LAZY) { @@ -52,6 +62,7 @@ internal class InAppProcessingManagerImpl( is InAppContentFetchingError -> { isInAppContentFetched = false + imageFailureDetails = throwable.message + "\n Url is ${inApp.form.variants.first().getImageUrl()}" } } } @@ -65,6 +76,12 @@ internal class InAppProcessingManagerImpl( is GeoError -> { isTargetingErrorOccurred = true inAppGeoRepository.setGeoStatus(GeoFetchStatus.GEO_FETCH_ERROR) + if (throwable.shouldTrackTargetingError()) { + inAppTargetingErrorRepository.saveError( + key = TargetingErrorKey.Geo, + error = throwable + ) + } MindboxLoggerImpl.e(this, "Error fetching geo", throwable) } @@ -73,24 +90,28 @@ internal class InAppProcessingManagerImpl( inAppSegmentationRepository.setCustomerSegmentationStatus( CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR ) + if (throwable.shouldTrackTargetingError()) { + inAppTargetingErrorRepository.saveError( + key = TargetingErrorKey.CustomerSegmentation, + error = throwable + ) + } handleCustomerSegmentationErrorLog(throwable) } else -> { MindboxLoggerImpl.e(this, throwable.message ?: "", throwable) + inAppFailureTracker.sendFailure( + inAppId = inApp.id, + failureReason = FailureReason.UNKNOWN_ERROR, + errorDetails = "Unknown exception when checking target ${throwable.message}. ${throwable.cause?.getVolleyErrorDetails() ?: "volleyError=null"}" + ) throw throwable } } } } - listOf(imageJob.apply { - invokeOnCompletion { - if (targetingJob.isActive && isInAppContentFetched == false) { - targetingJob.cancel() - mindboxLogD("Cancelling targeting checking since content loading is $isInAppContentFetched") - } - } - }, targetingJob.apply { + listOf(imageJob, targetingJob.apply { invokeOnCompletion { if (imageJob.isActive && !targetingCheck) { imageJob.cancel() @@ -103,6 +124,14 @@ internal class InAppProcessingManagerImpl( } mindboxLogD("loading and targeting fetching finished") if (isTargetingErrorOccurred) return chooseInAppToShow(inApps, triggerEvent) + trackTargetingErrorIfAny(inApp, data) + if (isInAppContentFetched == false && targetingCheck) { + inAppFailureTracker.collectFailure( + inAppId = inApp.id, + failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, + errorDetails = imageFailureDetails + ) + } if (isInAppContentFetched == false) { mindboxLogD("Skipping inApp with id = ${inApp.id} due to content fetching error.") continue @@ -117,9 +146,11 @@ internal class InAppProcessingManagerImpl( inAppId = inApp.id, triggerEvent.hashCode() ) + inAppFailureTracker.clearFailures() return inApp } } + inAppFailureTracker.sendCollectedFailures() return null } @@ -164,7 +195,7 @@ internal class InAppProcessingManagerImpl( } private fun handleCustomerSegmentationErrorLog(error: CustomerSegmentationError) { - val volleyError = error.cause as? VolleyError + val volleyError = error.cause.asVolleyError() volleyError?.let { if ((volleyError.networkResponse?.statusCode == 400) && (volleyError.getErrorResponseBodyData() .contains(RESPONSE_STATUS_CUSTOMER_SEGMENTS_REQUIRE_CUSTOMER)) @@ -176,6 +207,46 @@ internal class InAppProcessingManagerImpl( mindboxLogW("Error fetching customer segmentations", error) } + private fun trackTargetingErrorIfAny(inApp: InApp, data: TargetingData) { + when { + inApp.targeting.hasSegmentationNode() && + inAppSegmentationRepository.getCustomerSegmentationFetched() == CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR -> { + inAppFailureTracker.collectFailure( + inAppId = inApp.id, + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = inAppTargetingErrorRepository.getError(TargetingErrorKey.CustomerSegmentation) + ?: "Unknown segmentation error" + ) + return + } + + inApp.targeting.hasGeoNode() && + inAppGeoRepository.getGeoFetchedStatus() == GeoFetchStatus.GEO_FETCH_ERROR -> { + inAppFailureTracker.collectFailure( + inAppId = inApp.id, + failureReason = FailureReason.GEO_TARGETING_FAILED, + errorDetails = inAppTargetingErrorRepository.getError(TargetingErrorKey.Geo) + ?: "Unknown geo error" + ) + return + } + + inApp.targeting.hasProductSegmentationNode() -> { + data.getProductFromTargetingData()?.let { product -> + inAppTargetingErrorRepository.getError( + TargetingErrorKey.ProductSegmentation(product) + )?.let { errorDetails -> + inAppFailureTracker.collectFailure( + inAppId = inApp.id, + failureReason = FailureReason.PRODUCT_SEGMENT_REQUEST_FAILED, + errorDetails = errorDetails + ) + } + } + } + } + } + private class TargetingDataWrapper( override val triggerEventName: String, override val operationBody: String? = null, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt new file mode 100644 index 000000000..e33b704e3 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt @@ -0,0 +1,111 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.extensions + +import cloud.mindbox.mobile_sdk.getErrorResponseBodyData +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker +import cloud.mindbox.mobile_sdk.inapp.domain.models.TargetingData +import cloud.mindbox.mobile_sdk.models.operation.request.OperationBodyRequest +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import com.android.volley.TimeoutError +import com.android.volley.VolleyError +import com.google.gson.Gson +import java.net.SocketTimeoutException +import cloud.mindbox.mobile_sdk.logger.mindboxLogE +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason + +internal fun VolleyError.isTimeoutError(): Boolean { + return this is TimeoutError || cause is SocketTimeoutException +} + +internal fun VolleyError.isServerError(): Boolean { + val statusCode = networkResponse?.statusCode ?: return false + return statusCode in 500..599 +} + +internal fun Throwable?.asVolleyError(): VolleyError? = this as? VolleyError + +internal fun Throwable.getVolleyErrorDetails(): String { + val volleyError = this.asVolleyError() ?: return "volleyError = null" + val statusCode = volleyError.networkResponse?.statusCode ?: "timeout error" + val networkTimeMs = volleyError.networkTimeMs + val body = volleyError.getErrorResponseBodyData() + return "statusCode=$statusCode, networkTimeMs=$networkTimeMs, body=$body" +} + +internal fun TargetingData.getProductFromTargetingData(): Pair? { + if (this !is TargetingData.OperationBody) return null + return parseOperationBody(this.operationBody) +} + +private fun parseOperationBody(operationBody: String?): Pair? = + loggingRunCatching(null) { + val body = Gson().fromJson(operationBody, OperationBodyRequest::class.java) ?: return@loggingRunCatching null + body.viewProductRequest + ?.product + ?.ids + ?.ids + ?.entries + ?.firstOrNull() + ?.takeIf { entry -> + entry.value?.isNotBlank() == true + } + ?.let { entry -> entry.key to entry.value!! } + } + +internal fun Throwable.shouldTrackTargetingError(): Boolean { + return this.cause.asVolleyError()?.let { volleyError -> + volleyError.isTimeoutError() || volleyError.isServerError() + } ?: false +} + +internal fun InAppFailureTracker.sendPresentationFailure( + inAppId: String, + errorDescription: String, + throwable: Throwable? = null +) { + val errorDetails = when { + throwable != null -> "$errorDescription: ${throwable.message ?: "Unknown error"}" + else -> errorDescription + } + mindboxLogE(errorDetails) + sendFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = errorDetails + ) +} + +internal fun InAppFailureTracker.sendFailureWithContext( + inAppId: String, + failureReason: FailureReason, + errorDescription: String, + throwable: Throwable? = null +) { + val errorDetails = when { + throwable != null -> "$errorDescription: ${throwable.message ?: "Unknown error"}" + else -> errorDescription + } + mindboxLogE(errorDetails) + sendFailure( + inAppId = inAppId, + failureReason = failureReason, + errorDetails = errorDetails + ) +} + +internal inline fun InAppFailureTracker.executeWithFailureTracking( + inAppId: String, + failureReason: FailureReason, + errorDescription: String, + crossinline onFailure: () -> Unit = {}, + block: () -> T +): Result { + return runCatching(block).onFailure { throwable -> + sendFailureWithContext( + inAppId = inAppId, + failureReason = failureReason, + errorDescription = errorDescription, + throwable = throwable + ) + onFailure() + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt new file mode 100644 index 000000000..9c4f4e268 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt @@ -0,0 +1,22 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers + +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason + +internal interface InAppFailureTracker { + + fun sendFailure( + inAppId: String, + failureReason: FailureReason, + errorDetails: String? + ) + + fun collectFailure( + inAppId: String, + failureReason: FailureReason, + errorDetails: String? + ) + + fun sendCollectedFailures() + + fun clearFailures() +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt index 9f5f55a6f..c4b92df67 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt @@ -1,5 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure + internal interface InAppSerializationManager { fun serializeToShownInAppsString(shownInApps: Map>): String @@ -8,5 +10,7 @@ internal interface InAppSerializationManager { fun serializeToInAppHandledString(inAppId: String): String + fun serializeToInAppShowFailuresString(inAppShowFailures: List): String + fun deserializeToShownInApps(shownInApps: String): Set } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt index d729fd476..f2167b44c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import kotlinx.coroutines.flow.Flow internal interface InAppRepository { @@ -34,6 +35,8 @@ internal interface InAppRepository { fun sendUserTargeted(inAppId: String) + fun sendInAppShowFailure(failures: List) + fun setInAppShown(inAppId: String) fun isInAppShown(inAppId: String): Boolean diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppTargetingErrorRepository.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppTargetingErrorRepository.kt new file mode 100644 index 000000000..8c482d6d6 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppTargetingErrorRepository.kt @@ -0,0 +1,11 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories + +import cloud.mindbox.mobile_sdk.inapp.domain.models.TargetingErrorKey + +internal interface InAppTargetingErrorRepository { + fun saveError(key: TargetingErrorKey, error: Throwable) + + fun getError(key: TargetingErrorKey): String? + + fun clearErrors() +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt new file mode 100644 index 000000000..2596787a6 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt @@ -0,0 +1,7 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.models + +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure + +internal data class InAppFailuresWrapper( + val failures: List +) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TargetingErrorKey.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TargetingErrorKey.kt new file mode 100644 index 000000000..ced3aac3b --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TargetingErrorKey.kt @@ -0,0 +1,11 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.models + +internal sealed interface TargetingErrorKey { + data object CustomerSegmentation : TargetingErrorKey + + data object Geo : TargetingErrorKey + + data class ProductSegmentation( + val product: Pair, + ) : TargetingErrorKey +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargeting.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargeting.kt index 601fcc233..c9d4e978c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargeting.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargeting.kt @@ -28,6 +28,8 @@ internal interface TargetingInfo { fun hasOperationNode(): Boolean + fun hasProductSegmentationNode(): Boolean = false + suspend fun getOperationsSet(): Set } @@ -238,6 +240,8 @@ internal sealed class TreeTargeting(open val type: String) : } return false } + + override fun hasProductSegmentationNode() = nodes.any { it.hasProductSegmentationNode() } } internal data class UnionNode( @@ -288,6 +292,8 @@ internal sealed class TreeTargeting(open val type: String) : } return false } + + override fun hasProductSegmentationNode() = nodes.any { it.hasProductSegmentationNode() } } internal data class SegmentNode( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNode.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNode.kt index 4d32f0007..0cbaa6808 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNode.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNode.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models import cloud.mindbox.mobile_sdk.di.mindboxInject +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackTargetingError import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.models.operation.request.OperationBodyRequest @@ -13,6 +14,7 @@ internal data class ViewProductSegmentNode( private val mobileConfigRepository by mindboxInject { mobileConfigRepository } private val inAppSegmentationRepository by mindboxInject { inAppSegmentationRepository } + private val inAppTargetingErrorRepository by mindboxInject { inAppTargetingErrorRepository } private val gson by mindboxInject { gson } private val sessionStorageManager by mindboxInject { sessionStorageManager } @@ -31,6 +33,12 @@ internal data class ViewProductSegmentNode( if (error is ProductSegmentationError) { sessionStorageManager.processedProductSegmentations[product] = ProductSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + if (error.shouldTrackTargetingError()) { + inAppTargetingErrorRepository.saveError( + key = TargetingErrorKey.ProductSegmentation(product), + error = error + ) + } mindboxLogE("Error fetching product segmentations for product $product") } } @@ -62,4 +70,6 @@ internal data class ViewProductSegmentNode( setOf(it) } ?: setOf() } + + override fun hasProductSegmentationNode(): Boolean = true } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index f122afd38..af18833e5 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -10,10 +10,11 @@ import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.fromJson import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto -import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_SHOW_ERROR_FEATURE +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage -import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer @@ -22,9 +23,9 @@ import cloud.mindbox.mobile_sdk.inapp.presentation.view.InAppViewHolder import cloud.mindbox.mobile_sdk.inapp.presentation.view.ModalWindowInAppViewHolder import cloud.mindbox.mobile_sdk.inapp.presentation.view.SnackbarInAppViewHolder import cloud.mindbox.mobile_sdk.inapp.presentation.view.WebViewInAppViewHolder -import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.postDelayedAnimation import cloud.mindbox.mobile_sdk.root import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch @@ -41,8 +42,7 @@ internal interface MindboxView { } internal class InAppMessageViewDisplayerImpl( - private val inAppImageSizeStorage: InAppImageSizeStorage, - private val featureToggleManager: FeatureToggleManager + private val inAppImageSizeStorage: InAppImageSizeStorage ) : InAppMessageViewDisplayer { @@ -63,6 +63,7 @@ internal class InAppMessageViewDisplayerImpl( private var pausedHolder: InAppViewHolder<*>? = null private val mindboxNotificationManager by mindboxInject { mindboxNotificationManager } private val gson by mindboxInject { gson } + private val inAppFailureTracker: InAppFailureTracker by mindboxInject { inAppFailureTracker } private fun isUiPresent(): Boolean = currentActivity?.isFinishing?.not() ?: false @@ -200,11 +201,6 @@ internal class InAppMessageViewDisplayerImpl( wrapper: InAppTypeWrapper, isRestored: Boolean = false, ) { - when (featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) { - true -> mindboxLogI("InApp.ShowFailure sending enabled") - false -> mindboxLogI("InApp.ShowFailure sending disabled") - } - if (!isRestored) isActionExecuted = false if (isRestored && tryReattachRestoredInApp(wrapper.inAppType.inAppId)) return @@ -236,9 +232,19 @@ internal class InAppMessageViewDisplayerImpl( } currentActivity?.root?.let { root -> - currentHolder?.show(createMindboxView(root)) + inAppFailureTracker.executeWithFailureTracking( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDescription = "Error when trying draw inapp", + onFailure = { runCatching { currentHolder?.hide() } } + ) { + currentHolder?.show(createMindboxView(root)) + } } ?: run { - mindboxLogE("failed to show inApp: currentRoot is null") + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "currentRoot is null" + ) } } @@ -249,10 +255,20 @@ internal class InAppMessageViewDisplayerImpl( currentHolder = restoredHolder pausedHolder = null val root: ViewGroup = currentActivity?.root ?: run { - mindboxLogE("failed to reattach inApp: currentRoot is null") + inAppFailureTracker.sendPresentationFailure( + inAppId = inAppId, + errorDescription = "failed to reattach inApp: currentRoot is null" + ) return true } - restoredHolder.reattach(createMindboxView(root)) + inAppFailureTracker.executeWithFailureTracking( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDescription = "Error when trying reattach InApp", + onFailure = { runCatching { restoredHolder.hide() } }, + ) { + restoredHolder.reattach(createMindboxView(root)) + } return true } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt index 930900ccb..70b85b427 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt @@ -16,7 +16,8 @@ import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageViewDisplayerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import cloud.mindbox.mobile_sdk.inapp.presentation.actions.InAppActionHandler -import cloud.mindbox.mobile_sdk.logger.mindboxLogE +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.removeChildById import cloud.mindbox.mobile_sdk.safeAs @@ -49,6 +50,7 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder private val mindboxNotificationManager by mindboxInject { mindboxNotificationManager } + internal val inAppFailureTracker: InAppFailureTracker by mindboxInject { inAppFailureTracker } private var inAppActionHandler = InAppActionHandler() @@ -116,17 +118,18 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder isFirstResource: Boolean ): Boolean { return runCatching { - this.mindboxLogE( - message = "Failed to load in-app image with url = $url", - exception = e - ?: RuntimeException("Failed to load in-app image with url = $url") + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Failed to load in-app image with url = $url", + throwable = e ) hide() false - }.getOrElse { - mindboxLogE( - "Unknown error when loading image from cache succeeded", - exception = it + }.getOrElse { throwable -> + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Unknown error after loading image from cache succeeded", + throwable = throwable ) false } @@ -150,10 +153,11 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder } } false - }.getOrElse { - mindboxLogE( - "Unknown error when loading image from cache failed", - exception = it + }.getOrElse { throwable -> + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Unknown error in onResourceReady callback", + throwable = throwable ) false } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 178ef263d..f03aafe80 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -12,6 +12,9 @@ import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.fromJson import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.validators.BridgeMessageValidator +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendFailureWithContext +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer @@ -26,6 +29,7 @@ import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.getShortUserAgent +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.safeAs import cloud.mindbox.mobile_sdk.utils.Constants @@ -225,8 +229,11 @@ internal class WebViewInAppViewHolder( override fun onError(error: WebViewError) { mindboxLogE("WebView error: code=${error.code}, description=${error.description}, url=${error.url}") if (error.isForMainFrame == true) { - mindboxLogE("WebView critical error. Destroying In-App.") - release() + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_INIT_FAILED, + errorDescription = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}" + ) } } }) @@ -368,24 +375,45 @@ internal class WebViewInAppViewHolder( ) ) }.onFailure { e -> - mindboxLogE("Failed to fetch HTML content for In-App: $e") + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.HTML_LOAD_FAILED, + errorDescription = "Failed to fetch HTML content for In-App", + throwable = e + ) hide() release() } } ?: run { - mindboxLogE("WebView content URL is null") - hide() + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.HTML_LOAD_FAILED, + errorDescription = "WebView content URL is null" + ) } } } webViewController?.let { controller -> - val view: WebViewPlatformView = controller.view - if (view.parent !== inAppLayout) { - view.parent.safeAs()?.removeView(view) - inAppLayout.addView(view) + inAppFailureTracker.executeWithFailureTracking( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDescription = "Error when trying WebView layout", + ) { + val view: WebViewPlatformView = controller.view + if (view.parent !== inAppLayout) { + view.parent.safeAs()?.removeView(view) + inAppLayout.addView(view) + } } - } ?: release() + } ?: run { + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "WebView controller is null when trying show inapp", + null + ) + release() + } } private fun onContentPageLoaded(controller: WebViewController, content: WebViewHtmlContent) { @@ -394,9 +422,11 @@ internal class WebViewInAppViewHolder( } startTimer { controller.executeOnViewThread { - mindboxLogE("WebView initialization timed out after ${Stopwatch.stop(TIMER)}.") - hide() - release() + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.HTML_LOAD_FAILED, + errorDescription = "WebView initialization timed out after ${Stopwatch.stop(TIMER)}." + ) } } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt index 92ea666b4..d71879114 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt @@ -28,6 +28,7 @@ internal object MindboxEventManager { const val IN_APP_OPERATION_VIEW_TYPE = "Inapp.Show" const val IN_APP_OPERATION_CLICK_TYPE = "Inapp.Click" const val IN_APP_OPERATION_TARGETING_TYPE = "Inapp.Targeting" + const val IN_APP_OPERATION_SHOW_FAILURE_TYPE = "Inapp.ShowFailure" private val gson = Gson() @@ -84,6 +85,10 @@ internal object MindboxEventManager { asyncOperation(context, IN_APP_OPERATION_TARGETING_TYPE, body) } + fun inAppShowFailure(context: Context, body: String) { + asyncOperation(context, IN_APP_OPERATION_SHOW_FAILURE_TYPE, body) + } + fun pushClicked( context: Context, clickData: TrackClickData, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt index 1d3a9b57f..dfd6ddb0a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt @@ -3,6 +3,6 @@ package cloud.mindbox.mobile_sdk.models.operation.request import com.google.gson.annotations.SerializedName internal data class InAppHandleRequest( - @SerializedName("inappid") + @SerializedName("inappId") val inAppId: String ) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt new file mode 100644 index 000000000..0fcce7a90 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt @@ -0,0 +1,40 @@ +package cloud.mindbox.mobile_sdk.models.operation.request + +import com.google.gson.annotations.SerializedName + +internal data class InAppShowFailure( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("failureReason") + val failureReason: FailureReason, + @SerializedName("errorDetails") + val errorDetails: String?, + @SerializedName("timestamp") + val timestamp: String +) + +internal enum class FailureReason(val value: String) { + @SerializedName("image_download_failed") + IMAGE_DOWNLOAD_FAILED("image_download_failed"), + + @SerializedName("presentation_failed") + PRESENTATION_FAILED("presentation_failed"), + + @SerializedName("geo_request_failed") + GEO_TARGETING_FAILED("geo_request_failed"), + + @SerializedName("customer_segment_request_failed") + CUSTOMER_SEGMENT_REQUEST_FAILED("customer_segment_request_failed"), + + @SerializedName("product_segmentation_request_failed") + PRODUCT_SEGMENT_REQUEST_FAILED("product_segmentation_request_failed"), + + @SerializedName("html_load_failed") + HTML_LOAD_FAILED("html_load_failed"), + + @SerializedName("webview_init_failed") + WEBVIEW_INIT_FAILED("webview_init_failed"), + + @SerializedName("unknown_error") + UNKNOWN_ERROR("unknown_error") +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializerTest.kt index eb7ee8e93..453a5ff96 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializerTest.kt @@ -25,7 +25,7 @@ internal class FeatureTogglesDtoBlankDeserializerTest { @Test fun `deserialize valid true value`() { val json = JsonObject().apply { - addProperty("shouldSendInAppShowError", true) + addProperty("MobileSdkShouldSendInAppShowError", true) } val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) @@ -36,7 +36,7 @@ internal class FeatureTogglesDtoBlankDeserializerTest { @Test fun `deserialize valid false value`() { val json = JsonObject().apply { - addProperty("shouldSendInAppShowError", false) + addProperty("MobileSdkShouldSendInAppShowError", false) } val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) @@ -47,7 +47,7 @@ internal class FeatureTogglesDtoBlankDeserializerTest { @Test fun `deserialize multiple keys`() { val json = JsonObject().apply { - addProperty("shouldSendInAppShowError", true) + addProperty("MobileSdkShouldSendInAppShowError", true) addProperty("anotherToggle", false) } @@ -60,7 +60,7 @@ internal class FeatureTogglesDtoBlankDeserializerTest { @Test fun `deserialize string true value`() { val json = JsonObject().apply { - addProperty("shouldSendInAppShowError", "true") + addProperty("MobileSdkShouldSendInAppShowError", "true") } val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) @@ -71,7 +71,7 @@ internal class FeatureTogglesDtoBlankDeserializerTest { @Test fun `deserialize string false value`() { val json = JsonObject().apply { - addProperty("shouldSendInAppShowError", "false") + addProperty("MobileSdkShouldSendInAppShowError", "false") } val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) @@ -82,7 +82,7 @@ internal class FeatureTogglesDtoBlankDeserializerTest { @Test fun `deserialize number 1 value`() { val json = JsonObject().apply { - addProperty("shouldSendInAppShowError", 1) + addProperty("MobileSdkShouldSendInAppShowError", 1) } val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) @@ -93,7 +93,7 @@ internal class FeatureTogglesDtoBlankDeserializerTest { @Test fun `deserialize invalid string value`() { val json = JsonObject().apply { - addProperty("shouldSendInAppShowError", "invalid") + addProperty("MobileSdkShouldSendInAppShowError", "invalid") } val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) @@ -104,7 +104,7 @@ internal class FeatureTogglesDtoBlankDeserializerTest { @Test fun `deserialize object value`() { val json = JsonObject().apply { - add("shouldSendInAppShowError", JsonObject().apply { + add("MobileSdkShouldSendInAppShowError", JsonObject().apply { addProperty("value", true) }) } @@ -117,7 +117,7 @@ internal class FeatureTogglesDtoBlankDeserializerTest { @Test fun `deserialize array value`() { val json = JsonObject().apply { - add("shouldSendInAppShowError", JsonArray().apply { + add("MobileSdkShouldSendInAppShowError", JsonArray().apply { add(true) }) } @@ -130,7 +130,7 @@ internal class FeatureTogglesDtoBlankDeserializerTest { @Test fun `deserialize empty string value`() { val json = JsonObject().apply { - addProperty("shouldSendInAppShowError", "") + addProperty("MobileSdkShouldSendInAppShowError", "") } val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) @@ -150,7 +150,7 @@ internal class FeatureTogglesDtoBlankDeserializerTest { @Test fun `deserialize null value`() { val json = JsonObject().apply { - add("shouldSendInAppShowError", JsonNull.INSTANCE) + add("MobileSdkShouldSendInAppShowError", JsonNull.INSTANCE) } val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt index e817746e5..ed69260ce 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt @@ -25,7 +25,7 @@ class FeatureToggleManagerImplTest { ttl = null, slidingExpiration = null, inapp = null, - featureToggles = mapOf("shouldSendInAppShowError" to true) + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to true) ), abtests = null ) @@ -35,6 +35,26 @@ class FeatureToggleManagerImplTest { assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) } + @Test + fun `applyToggles works with feature name containing special characters`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("!@#$%^&*()_+<>:;{},./|~`" to false) + ), + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(false, featureToggleManager.isEnabled("!@#$%^&*()_+<>:;{},./|~`")) + } + @Test fun `applyToggles sets shouldSendInAppShowError to false when featureToggles contains false`() { val config = InAppConfigResponse( @@ -45,7 +65,7 @@ class FeatureToggleManagerImplTest { ttl = null, slidingExpiration = null, inapp = null, - featureToggles = mapOf("shouldSendInAppShowError" to false) + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to false) ), abtests = null ) @@ -66,7 +86,7 @@ class FeatureToggleManagerImplTest { slidingExpiration = null, inapp = null, featureToggles = mapOf( - "shouldSendInAppShowError" to true, + "MobileSdkShouldSendInAppShowError" to true, "anotherToggle" to false ) ), @@ -90,7 +110,7 @@ class FeatureToggleManagerImplTest { slidingExpiration = null, inapp = null, featureToggles = mapOf( - "shouldSendInAppShowError" to true, + "MobileSdkShouldSendInAppShowError" to true, "invalidToggle" to null ) ), @@ -159,7 +179,7 @@ class FeatureToggleManagerImplTest { ttl = null, slidingExpiration = null, inapp = null, - featureToggles = mapOf("shouldSendInAppShowError" to true) + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to true) ), abtests = null ) @@ -174,7 +194,7 @@ class FeatureToggleManagerImplTest { ttl = null, slidingExpiration = null, inapp = null, - featureToggles = mapOf("shouldSendInAppShowError" to false) + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to false) ), abtests = null ) @@ -192,7 +212,7 @@ class FeatureToggleManagerImplTest { ttl = null, slidingExpiration = null, inapp = null, - featureToggles = mapOf("shouldSendInAppShowError" to false) + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to false) ), abtests = null ) @@ -207,7 +227,7 @@ class FeatureToggleManagerImplTest { ttl = null, slidingExpiration = null, inapp = null, - featureToggles = mapOf("shouldSendInAppShowError" to true) + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to true) ), abtests = null ) @@ -225,7 +245,7 @@ class FeatureToggleManagerImplTest { ttl = null, slidingExpiration = null, inapp = null, - featureToggles = mapOf("shouldSendInAppShowError" to false) + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to false) ), abtests = null ) @@ -233,7 +253,7 @@ class FeatureToggleManagerImplTest { assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) featureToggleManager.applyToggles(null) - assertEquals(true, featureToggleManager.isEnabled("shouldSendInAppShowError")) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) } @Test @@ -247,7 +267,7 @@ class FeatureToggleManagerImplTest { slidingExpiration = null, inapp = null, featureToggles = mapOf( - "shouldSendInAppShowError" to false, + "MobileSdkShouldSendInAppShowError" to false, "toggle1" to true ) ), diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt new file mode 100644 index 000000000..1168e73ab --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt @@ -0,0 +1,222 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +internal class InAppFailureTrackerImplTest { + + private val timeProvider: TimeProvider = mockk() + private val inAppRepository: InAppRepository = mockk(relaxed = true) + private val featureToggleManager: FeatureToggleManager = mockk() + private lateinit var inAppFailureTracker: InAppFailureTrackerImpl + + private val inAppId = "testInAppId" + private val currentTimeMillis = 1707523200000L + private val expectedTimestamp = "2024-02-10T00:00:00Z" + + @Before + fun onTestStart() { + every { timeProvider.currentTimeMillis() } returns currentTimeMillis + inAppFailureTracker = InAppFailureTrackerImpl( + timeProvider = timeProvider, + inAppRepository = inAppRepository, + featureToggleManager = featureToggleManager + ) + } + + @Test + fun `collectFailure does not send immediately`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "error" + ) + + verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } + } + + @Test + fun `sendFailure sends immediately when feature toggle is enabled`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val slot = slot>() + + inAppFailureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "error" + ) + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + val captured = slot.captured + assertEquals(1, captured.size) + assertEquals(inAppId, captured[0].inAppId) + assertEquals(FailureReason.PRESENTATION_FAILED, captured[0].failureReason) + assertEquals("error", captured[0].errorDetails) + assertEquals(expectedTimestamp, captured[0].timestamp) + } + + @Test + fun `sendFailure does not send when feature toggle is disabled`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns false + + inAppFailureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "error" + ) + + verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } + } + + @Test + fun `collectFailure does not add duplicate when same inAppId already tracked`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val slot = slot>() + + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "first" + ) + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, + errorDetails = "second" + ) + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + val captured = slot.captured + assertEquals(1, captured.size) + assertEquals(FailureReason.PRESENTATION_FAILED, captured[0].failureReason) + } + + @Test + fun `sendFailure truncates errorDetails to 1000 chars`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val longErrorDetails = "a".repeat(1500) + val slot = slot>() + + inAppFailureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.UNKNOWN_ERROR, + errorDetails = longErrorDetails + ) + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + assertEquals("a".repeat(1000), slot.captured[0].errorDetails) + } + + @Test + fun `collectFailure truncates errorDetails to 1000 chars`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val longErrorDetails = "a".repeat(1500) + val slot = slot>() + + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.UNKNOWN_ERROR, + errorDetails = longErrorDetails + ) + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + assertEquals("a".repeat(1000), slot.captured[0].errorDetails) + } + + @Test + fun `sendCollectedFailures sends all failures when feature toggle is enabled`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val slot = slot>() + + inAppFailureTracker.collectFailure( + inAppId = "inApp1", + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = null + ) + inAppFailureTracker.collectFailure( + inAppId = "inApp2", + failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, + errorDetails = "details" + ) + + inAppFailureTracker.sendCollectedFailures() + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + val captured = slot.captured + assertEquals(2, captured.size) + assertEquals(1, captured.count { it.inAppId == "inApp1" && it.failureReason == FailureReason.PRESENTATION_FAILED }) + assertEquals(1, captured.count { it.inAppId == "inApp2" && it.failureReason == FailureReason.IMAGE_DOWNLOAD_FAILED }) + } + + @Test + fun `sendCollectedFailures clears failures after sending`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = null + ) + inAppFailureTracker.sendCollectedFailures() + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(any()) } + } + + @Test + fun `sendCollectedFailures does not send when feature toggle is disabled`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns false + + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = null + ) + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } + } + + @Test + fun `clearFailures clears collected failures`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = null + ) + + inAppFailureTracker.clearFailures() + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } + } + + @Test + fun `sendFailure with null errorDetails`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val slot = slot>() + + inAppFailureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.GEO_TARGETING_FAILED, + errorDetails = null + ) + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + assertEquals(null, slot.captured[0].errorDetails) + assertEquals(inAppId, slot.captured[0].inAppId) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt index b9e02efb2..d83e559ef 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt @@ -1,6 +1,9 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import com.google.gson.Gson import com.google.gson.reflect.TypeToken import io.mockk.every @@ -67,7 +70,7 @@ internal class InAppSerializationManagerTest { @Test fun `serialize to inApp handled string success`() { - val expectedResult = "{\"inappid\":\"${inAppId}\"}" + val expectedResult = "{\"inappId\":\"${inAppId}\"}" val actualResult = inAppSerializationManager.serializeToInAppHandledString(inAppId) assertEquals(expectedResult, actualResult) } @@ -123,4 +126,42 @@ internal class InAppSerializationManagerTest { val actualResult = inAppSerializationManager.deserializeToShownInApps(testString) assertEquals(expectedResult, actualResult) } + + @Test + fun `serializeToInAppShowFailuresString returns valid JSON string`() { + val inAppShowFailures = listOf( + InAppShowFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "error", + timestamp = "2024-02-10T00:00:00Z" + ) + ) + val expectedJson = Gson().toJson(InAppFailuresWrapper(inAppShowFailures)) + + val actualJson = inAppSerializationManager.serializeToInAppShowFailuresString(inAppShowFailures) + + assertEquals(expectedJson, actualJson) + } + + @Test + fun `serializeToInAppShowFailuresString returns empty string when exception occurs`() { + val gson: Gson = mockk() + val inAppShowFailures = listOf( + InAppShowFailure( + inAppId = inAppId, + failureReason = FailureReason.UNKNOWN_ERROR, + errorDetails = null, + timestamp = "2024-02-10T00:00:00Z" + ) + ) + every { + gson.toJson(any(), object : TypeToken() {}.type) + } throws RuntimeException("Serialization error") + inAppSerializationManager = InAppSerializationManagerImpl(gson) + + val actualJson = inAppSerializationManager.serializeToInAppShowFailuresString(inAppShowFailures) + + assertEquals("", actualJson) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManagerTest.kt index 458239887..b9d167451 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManagerTest.kt @@ -4,6 +4,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationFetchSta import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoFetchStatus import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppShowLimitsSettings import cloud.mindbox.mobile_sdk.inapp.domain.models.ProductSegmentationFetchStatus +import cloud.mindbox.mobile_sdk.inapp.domain.models.TargetingErrorKey import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.utils.TimeProvider import io.mockk.every @@ -117,6 +118,9 @@ class SessionStorageManagerTest { configFetchingError = true sessionTime = 1000L.milliseconds inAppShowLimitsSettings = InAppShowLimitsSettings(maxInappsPerSession = 20, maxInappsPerDay = 20, minIntervalBetweenShows = Milliseconds(100)) + lastTargetingErrors[TargetingErrorKey.CustomerSegmentation] = "error in customer segment" + lastTargetingErrors[TargetingErrorKey.Geo] = "error in geo" + lastTargetingErrors[TargetingErrorKey.ProductSegmentation(Pair("product", "45"))] = "error in product segment" } sessionStorageManager.clearSessionData() @@ -134,6 +138,7 @@ class SessionStorageManagerTest { assertFalse(sessionStorageManager.configFetchingError) assertEquals(0L, sessionStorageManager.sessionTime.inWholeMilliseconds) assertEquals(InAppShowLimitsSettings(), sessionStorageManager.inAppShowLimitsSettings) + assertTrue(sessionStorageManager.lastTargetingErrors.isEmpty()) } @Test diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryTest.kt new file mode 100644 index 000000000..4af7ea6fa --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryTest.kt @@ -0,0 +1,93 @@ +package cloud.mindbox.mobile_sdk.inapp.data.repositories + +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationError +import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoError +import cloud.mindbox.mobile_sdk.inapp.domain.models.ProductSegmentationError +import cloud.mindbox.mobile_sdk.inapp.domain.models.TargetingErrorKey +import com.android.volley.NetworkResponse +import com.android.volley.VolleyError +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class InAppTargetingErrorRepositoryTest { + private val sessionStorageManager = mockk(relaxUnitFun = true) + private val repository = InAppTargetingErrorRepositoryImpl(sessionStorageManager) + + @Test + fun `saveError stores customer segmentation error`() { + val errors = mutableMapOf() + val responseBody = """{"error":"customer segmentation failed"}""" + val volleyError = createVolleyError(statusCode = 500, responseBody = responseBody, networkTimeMs = 100) + val throwable = CustomerSegmentationError(volleyError) + every { sessionStorageManager.lastTargetingErrors } returns errors + repository.saveError(TargetingErrorKey.CustomerSegmentation, throwable) + val expectedDetails = "statusCode=500, networkTimeMs=${volleyError.networkTimeMs}, body=$responseBody" + assertEquals("${throwable.message}. $expectedDetails", errors[TargetingErrorKey.CustomerSegmentation]) + } + + @Test + fun `saveError stores geo error`() { + val errors = mutableMapOf() + val responseBody = """{"error":"geo failed"}""" + val volleyError = createVolleyError(statusCode = 503, responseBody = responseBody, networkTimeMs = 200) + val throwable = GeoError(volleyError) + every { sessionStorageManager.lastTargetingErrors } returns errors + repository.saveError(TargetingErrorKey.Geo, throwable) + val expectedDetails = "statusCode=503, networkTimeMs=${volleyError.networkTimeMs}, body=$responseBody" + assertEquals("${throwable.message}. $expectedDetails", errors[TargetingErrorKey.Geo]) + } + + @Test + fun `saveError stores product segmentation error`() { + val product = "website" to "ProductRandomName" + val productKey = TargetingErrorKey.ProductSegmentation(product) + val errors = mutableMapOf() + val responseBody = """{"error":"product segmentation failed"}""" + val volleyError = createVolleyError(statusCode = 504, responseBody = responseBody, networkTimeMs = 300) + val throwable = ProductSegmentationError(volleyError) + every { sessionStorageManager.lastTargetingErrors } returns errors + repository.saveError(productKey, throwable) + val expectedDetails = "statusCode=504, networkTimeMs=${volleyError.networkTimeMs}, body=$responseBody" + assertEquals("${throwable.message}. $expectedDetails", errors[productKey]) + } + + @Test + fun `getError returns saved error`() { + val product = "website" to "ProductRandomName" + val productKey = TargetingErrorKey.ProductSegmentation(product) + val errorDetails = "Product segmentation fetch failed" + every { sessionStorageManager.lastTargetingErrors[productKey] } returns errorDetails + val result = repository.getError(productKey) + assertEquals(errorDetails, result) + } + + @Test + fun `getError returns null when no error saved`() { + every { sessionStorageManager.lastTargetingErrors[TargetingErrorKey.Geo] } returns null + val result = repository.getError(TargetingErrorKey.Geo) + assertEquals(null, result) + } + + @Test + fun `clearErrors clears all stored errors`() { + val errors = mutableMapOf( + TargetingErrorKey.Geo to "Geo error", + TargetingErrorKey.CustomerSegmentation to "Customer error" + ) + every { sessionStorageManager.lastTargetingErrors } returns errors + repository.clearErrors() + assertEquals(emptyMap(), errors) + } + + private fun createVolleyError( + statusCode: Int, + responseBody: String, + networkTimeMs: Long, + ): VolleyError { + val response = NetworkResponse(statusCode, responseBody.toByteArray(), false, networkTimeMs, emptyList()) + return VolleyError(response) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt index a3f15c989..9b5d2e42d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt @@ -6,12 +6,14 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppEventManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFilteringManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFrequencyManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppProcessingManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppTargetingErrorRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.InAppStub @@ -74,11 +76,17 @@ class InAppInteractorImplTest { @RelaxedMockK private lateinit var inAppSegmentationRepository: InAppSegmentationRepository + @RelaxedMockK + private lateinit var inAppTargetingErrorRepository: InAppTargetingErrorRepository + @MockK private lateinit var inAppContentFetcher: InAppContentFetcher private lateinit var interactor: InAppInteractor + @RelaxedMockK + private lateinit var inAppFailureTracker: InAppFailureTracker + @Before fun setup() { interactor = InAppInteractorImpl( @@ -162,8 +170,10 @@ class InAppInteractorImplTest { val realProcessingManager = InAppProcessingManagerImpl( inAppGeoRepository, inAppSegmentationRepository, + inAppTargetingErrorRepository, inAppContentFetcher, - inAppRepository + inAppRepository, + inAppFailureTracker ) interactor = InAppInteractorImpl( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt index b68df4835..9d44cdb83 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt @@ -6,15 +6,20 @@ import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppGeoRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppSegmentationRepositoryImpl +import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppTargetingErrorRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.GeoSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppTargetingErrorRepository import cloud.mindbox.mobile_sdk.inapp.domain.models.* import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.models.* +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.utils.TimeProvider +import com.android.volley.NetworkResponse import com.android.volley.VolleyError import com.google.gson.Gson import io.mockk.* @@ -79,6 +84,9 @@ internal class InAppProcessingManagerTest { private val inAppMapper: InAppMapper = mockk(relaxed = true) private val geoSerializationManager: GeoSerializationManager = mockk(relaxed = true) private val gatewayManager: GatewayManager = mockk(relaxed = true) + private val inAppFailureTracker: InAppFailureTracker = mockk(relaxed = true) + private val inAppTargetingErrorRepository: InAppTargetingErrorRepository = + spyk(InAppTargetingErrorRepositoryImpl(sessionStorageManager)) private val inAppGeoRepositoryTestImpl: InAppGeoRepositoryImpl = spyk( @@ -102,14 +110,18 @@ internal class InAppProcessingManagerTest { private fun setDIModule( geoRepository: InAppGeoRepository, - segmentationRepository: InAppSegmentationRepository + segmentationRepository: InAppSegmentationRepository, + targetingErrorRepository: InAppTargetingErrorRepository = inAppTargetingErrorRepository ) { - every { MindboxDI.appModule } returns mockk { - every { inAppGeoRepository } returns geoRepository - every { inAppSegmentationRepository } returns segmentationRepository - every { inAppRepository } returns mockInAppRepository - every { gson } returns Gson() - } + val appModuleMock = mockk(relaxed = true) + every { appModuleMock.inAppGeoRepository } returns geoRepository + every { appModuleMock.inAppSegmentationRepository } returns segmentationRepository + every { appModuleMock.inAppTargetingErrorRepository } returns targetingErrorRepository + every { appModuleMock.inAppRepository } returns mockInAppRepository + every { appModuleMock.gson } returns Gson() + every { appModuleMock.sessionStorageManager } returns sessionStorageManager + every { appModuleMock.inAppProcessingManager } returns inAppProcessingManager + every { MindboxDI.appModule } returns appModuleMock } @Before @@ -122,15 +134,19 @@ internal class InAppProcessingManagerTest { private val inAppProcessingManager = InAppProcessingManagerImpl( inAppGeoRepository = mockkInAppGeoRepository, inAppSegmentationRepository = mockkInAppSegmentationRepository, + inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, - inAppRepository = mockInAppRepository + inAppRepository = mockInAppRepository, + inAppFailureTracker = inAppFailureTracker ) private val inAppProcessingManagerTestImpl = InAppProcessingManagerImpl( inAppGeoRepository = inAppGeoRepositoryTestImpl, inAppSegmentationRepository = inAppSegmentationRepositoryTestImpl, + inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, - inAppRepository = mockInAppRepository + inAppRepository = mockInAppRepository, + inAppFailureTracker = inAppFailureTracker ) private fun setupTestGeoRepositoryForErrorScenario() { @@ -377,10 +393,14 @@ internal class InAppProcessingManagerTest { val inAppProcessingManager = InAppProcessingManagerImpl( inAppGeoRepository = mockk { coEvery { fetchGeo() } throws GeoError(VolleyError()) + every { getGeoFetchedStatus() } returns GeoFetchStatus.GEO_FETCH_ERROR + every { setGeoStatus(any()) } just runs }, inAppSegmentationRepository = mockkInAppSegmentationRepository, + inAppTargetingErrorRepository = mockk(relaxed = true), inAppContentFetcher = mockkInAppContentFetcher, - inAppRepository = mockInAppRepository + inAppRepository = mockInAppRepository, + inAppFailureTracker = mockk(relaxed = true) ) val expectedResult = InAppStub.getInApp().copy( @@ -555,4 +575,150 @@ internal class InAppProcessingManagerTest { verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } } + + @Test + fun `choose inApp to show tracks product segmentation failure when ViewProductSegmentNode has error`() = runTest { + val viewProductBody = """{ + "viewProduct": { + "product": { + "ids": { + "website": "ProductRandomName" + } + } + } + }""".trimIndent() + val product = "website" to "ProductRandomName" + val viewProductEvent = InAppEventType.OrdinalEvent( + EventType.SyncOperation("viewProduct"), + viewProductBody + ) + val inAppWithProductSegId = "inAppWithProductSeg" + val validId = "validId" + val serverError = VolleyError(NetworkResponse(500, null, false, 0, emptyList())) + val mockSegmentationRepo = mockk { + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + coEvery { fetchCustomerSegmentations() } just runs + every { getProductSegmentationFetched(product) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + coEvery { fetchProductSegmentation(product) } throws ProductSegmentationError(serverError) + every { getProductSegmentations(product) } returns emptySet() + } + val targetingErrorRepository = mockk { + every { + getError(TargetingErrorKey.ProductSegmentation(product)) + } returns "Product segmentation fetch failed. statusCode=500" + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, mockSegmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = mockSegmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = inAppWithProductSegId, + targeting = InAppStub.getTargetingUnionNode().copy( + nodes = listOf( + InAppStub.viewProductSegmentNode.copy( + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationExternalId", + segmentExternalId = "segmentExternalId" + ) + ) + ), + form = InAppStub.getInApp().form.copy( + listOf(InAppStub.getModalWindow().copy(inAppId = inAppWithProductSegId)) + ) + ), + InAppStub.getInApp().copy( + id = validId, + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + listOf(InAppStub.getModalWindow().copy(inAppId = validId)) + ) + ) + ) + + val result = processingManager.chooseInAppToShow(testInAppList, viewProductEvent) + + assertNotNull(result) + assertEquals(validId, result?.id) + verify(exactly = 1) { + failureTracker.collectFailure( + inAppId = inAppWithProductSegId, + failureReason = FailureReason.PRODUCT_SEGMENT_REQUEST_FAILED, + errorDetails = "Product segmentation fetch failed. statusCode=500" + ) + } + verify(exactly = 1) { failureTracker.clearFailures() } + verify(exactly = 0) { failureTracker.sendCollectedFailures() } + } + + @Test + fun `choose inApp to show geo error saves last geo error details`() = runTest { + val errorDetails = "Geo fetch failed. statusCode=500" + val geoRepo = mockk { + coEvery { fetchGeo() } throws GeoError(VolleyError()) + every { getGeoFetchedStatus() } returns GeoFetchStatus.GEO_FETCH_ERROR + every { setGeoStatus(any()) } just runs + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.Geo) } returns errorDetails + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(geoRepo, mockkInAppSegmentationRepository, targetingErrorRepository) + every { geoRepo.getGeo() } returns GeoTargetingStub.getGeoTargeting().copy( + cityId = "234", regionId = "regionId", countryId = "123" + ) + val validId = "validId" + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + targeting = InAppStub.getTargetingRegionNode().copy( + type = "", kind = Kind.POSITIVE, ids = listOf("otherRegionId") + ) + ), + InAppStub.getInApp().copy( + id = validId, + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + listOf(InAppStub.getModalWindow().copy(inAppId = validId)) + ) + ) + ) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = geoRepo, + inAppSegmentationRepository = mockkInAppSegmentationRepository, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker + ) + + val result = processingManager.chooseInAppToShow(testInAppList, event) + + assertNotNull(result) + assertEquals(validId, result?.id) + verify(exactly = 1) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.GEO_TARGETING_FAILED, + errorDetails = errorDetails + ) + } + verify(exactly = 1) { failureTracker.clearFailures() } + verify(exactly = 0) { failureTracker.sendCollectedFailures() } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargetingTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargetingTest.kt index cb51ed1be..fe249737d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargetingTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargetingTest.kt @@ -75,6 +75,74 @@ class TreeTargetingTest { assertTrue(InAppStub.getTargetingTrueNode().checkTargeting(mockk())) } + @Test + fun `TrueNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingTrueNode().hasProductSegmentationNode()) + } + + @Test + fun `CountryNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingCountryNode().hasProductSegmentationNode()) + } + + @Test + fun `CityNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingCityNode().hasProductSegmentationNode()) + } + + @Test + fun `RegionNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingRegionNode().hasProductSegmentationNode()) + } + + @Test + fun `SegmentNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingSegmentNode().hasProductSegmentationNode()) + } + + @Test + fun `VisitNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingVisitNode().hasProductSegmentationNode()) + } + + @Test + fun `PushPermissionNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingPushPermissionNode().hasProductSegmentationNode()) + } + + @Test + fun `OperationNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingOperationNode().hasProductSegmentationNode()) + } + + @Test + fun `IntersectionNode hasProductSegmentationNode false when no child has it`() { + val node = InAppStub.getTargetingIntersectionNode() + .copy(nodes = listOf(InAppStub.getTargetingTrueNode(), InAppStub.getTargetingCityNode())) + assertFalse(node.hasProductSegmentationNode()) + } + + @Test + fun `IntersectionNode hasProductSegmentationNode true when child has it`() { + val node = InAppStub.getTargetingIntersectionNode() + .copy(nodes = listOf(InAppStub.getTargetingTrueNode(), InAppStub.viewProductSegmentNode)) + assertTrue(node.hasProductSegmentationNode()) + } + + @Test + fun `UnionNode hasProductSegmentationNode false when no child has it`() { + val node = InAppStub.getTargetingUnionNode() + .copy(nodes = listOf(InAppStub.getTargetingTrueNode(), InAppStub.getTargetingCityNode())) + assertFalse(node.hasProductSegmentationNode()) + } + + @Test + fun `UnionNode hasProductSegmentationNode true when child has it`() { + val node = InAppStub.getTargetingUnionNode() + .copy(nodes = listOf(InAppStub.getTargetingTrueNode(), InAppStub.viewProductSegmentNode)) + assertTrue(node.hasProductSegmentationNode()) + } + @Test fun `country targeting positive success check`() { assertTrue( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryInNodeTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryInNodeTest.kt index ad788e571..8ce8e4249 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryInNodeTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryInNodeTest.kt @@ -67,6 +67,11 @@ class ViewProductCategoryInNodeTest { assertFalse(InAppStub.viewProductCategoryInNode.hasSegmentationNode()) } + @Test + fun `hasProductSegmentationNode always false`() { + assertFalse(InAppStub.viewProductCategoryInNode.hasProductSegmentationNode()) + } + @Test fun `getOperationsSet return viewCategory`() = runTest { assertEquals( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryNodeTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryNodeTest.kt index 8adc313f1..e5b25f935 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryNodeTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryNodeTest.kt @@ -67,6 +67,11 @@ class ViewProductCategoryNodeTest { assertFalse(InAppStub.viewProductCategoryNode.hasSegmentationNode()) } + @Test + fun `hasProductSegmentationNode always false`() { + assertFalse(InAppStub.viewProductCategoryNode.hasProductSegmentationNode()) + } + @Test fun `getOperationsSet return viewCategory`() = runTest { assertEquals( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductNodeTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductNodeTest.kt index 443750f4e..e43a09aae 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductNodeTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductNodeTest.kt @@ -67,6 +67,11 @@ class ViewProductNodeTest { assertFalse(InAppStub.viewProductNode.hasSegmentationNode()) } + @Test + fun `hasProductSegmentationNode always false`() { + assertFalse(InAppStub.viewProductNode.hasProductSegmentationNode()) + } + @Test fun `checkTargeting after AppStartup`() = runTest { MindboxEventManager.eventFlow.resetReplayCache() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNodeTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNodeTest.kt index 9b2dba548..ff054048c 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNodeTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNodeTest.kt @@ -2,6 +2,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppTargetingErrorRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository import cloud.mindbox.mobile_sdk.managers.MindboxEventManager @@ -31,7 +32,8 @@ class ViewProductSegmentNodeTest { } private val mockkInAppSegmentationRepository: InAppSegmentationRepository = mockk() - private val sessionStorageManager = mockk() + private val sessionStorageManagerMock = mockk() + private val inAppTargetingErrorRepositoryMock = mockk() @get:Rule val mockkRule = MockKRule(this) @@ -47,6 +49,8 @@ class ViewProductSegmentNodeTest { every { mobileConfigRepository } returns mockkMobileConfigRepository every { inAppSegmentationRepository } returns mockkInAppSegmentationRepository every { gson } returns Gson() + every { inAppTargetingErrorRepository } returns inAppTargetingErrorRepositoryMock + every { sessionStorageManager } returns sessionStorageManagerMock } } @@ -65,6 +69,11 @@ class ViewProductSegmentNodeTest { assertFalse(InAppStub.viewProductSegmentNode.hasSegmentationNode()) } + @Test + fun `hasProductSegmentationNode always true`() { + assertTrue(InAppStub.viewProductSegmentNode.hasProductSegmentationNode()) + } + @Test fun `check targeting positive success`() = runTest { val productSegmentation = @@ -259,7 +268,7 @@ class ViewProductSegmentNodeTest { "website" to "successProduct" to ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS, "website" to "errorProduct" to ProductSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR ) - every { sessionStorageManager.processedProductSegmentations } returns processedProducts + every { sessionStorageManagerMock.processedProductSegmentations } returns processedProducts every { mockkInAppSegmentationRepository.getProductSegmentationFetched("website" to "successProduct") } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS every { mockkInAppSegmentationRepository.getProductSegmentationFetched("website" to "errorProduct") } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR every { mockkInAppSegmentationRepository.getProductSegmentationFetched("website" to "newProduct") } returns ProductSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt index 68d5445be..57bd95ead 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt @@ -25,7 +25,7 @@ internal class InAppMessageViewDisplayerImplTest { every { MindboxDI.appModule } returns mockk { every { gson } returns Gson() } - displayer = InAppMessageViewDisplayerImpl(mockk(), mockk()) + displayer = InAppMessageViewDisplayerImpl(mockk()) } @After diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesConfig.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesConfig.json index 52f42bdab..69299dac1 100644 --- a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesConfig.json +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesConfig.json @@ -23,6 +23,6 @@ "minIntervalBetweenShows": "00:30:00" }, "featureToggles": { - "shouldSendInAppShowError": true + "MobileSdkShouldSendInAppShowError": true } } diff --git a/sdk/src/test/resources/ConfigParsing/Settings/SettingsConfig.json b/sdk/src/test/resources/ConfigParsing/Settings/SettingsConfig.json index 52f42bdab..69299dac1 100644 --- a/sdk/src/test/resources/ConfigParsing/Settings/SettingsConfig.json +++ b/sdk/src/test/resources/ConfigParsing/Settings/SettingsConfig.json @@ -23,6 +23,6 @@ "minIntervalBetweenShows": "00:30:00" }, "featureToggles": { - "shouldSendInAppShowError": true + "MobileSdkShouldSendInAppShowError": true } } From 1c4fab001d4623e9c7e707015bbe06041f88139c Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 21 Jan 2026 10:18:31 +0300 Subject: [PATCH 17/59] MOBILEWEBVIEW-3: Fix InAppPositionController for BottomSheet --- .../view/InAppPositionController.kt | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionController.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionController.kt index f894b5b98..7381f7392 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionController.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionController.kt @@ -16,6 +16,7 @@ internal class InAppPositionController { private var inAppView: View? = null private var originalParent: ViewGroup? = null private var inAppOriginalIndex: Int = -1 + private var hostActivity: FragmentActivity? = null private val fragmentLifecycleCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() { override fun onFragmentResumed(fm: FragmentManager, f: Fragment) { @@ -39,27 +40,32 @@ internal class InAppPositionController { this.inAppOriginalIndex = parent.indexOfChild(inAppView) } - entryView.findActivity().safeAs() - ?.supportFragmentManager - ?.registerFragmentLifecycleCallbacks( - fragmentLifecycleCallbacks, - true - ) + entryView.findActivity().safeAs()?.apply { + hostActivity = this + supportFragmentManager + .registerFragmentLifecycleCallbacks( + fragmentLifecycleCallbacks, + true + ) + } + repositionInApp() } fun stop(): Unit = loggingRunCatching { - originalParent?.findActivity().safeAs() - ?.supportFragmentManager - ?.unregisterFragmentLifecycleCallbacks( - fragmentLifecycleCallbacks - ) + hostActivity?.apply { + supportFragmentManager + .unregisterFragmentLifecycleCallbacks( + fragmentLifecycleCallbacks + ) + } inAppView = null originalParent = null + hostActivity = null } private fun repositionInApp(): Unit = loggingRunCatching { - val activity = inAppView?.findActivity().safeAs() ?: return@loggingRunCatching + val activity = hostActivity ?: return@loggingRunCatching val topDialog = findTopDialogFragment(activity.supportFragmentManager) val targetParent = topDialog?.dialog?.window?.decorView.safeAs() if (targetParent != null) { From 8ae583f91d8d81de88da6d6c6fd89464d4cdd051 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 11 Feb 2026 09:59:07 +0300 Subject: [PATCH 18/59] MOBILEWEBVIEW-8: Add data collector --- .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 4 +- .../mobile_sdk/di/modules/DomainModule.kt | 1 + .../data/managers/PermissionManagerImpl.kt | 90 +++++++++- .../data/managers/SessionStorageManager.kt | 5 + .../inapp/domain/InAppInteractorImpl.kt | 12 +- .../domain/interfaces/PermissionManager.kt | 22 ++- .../inapp/presentation/view/DataCollector.kt | 164 ++++++++++++++++++ .../view/InAppConstraintLayout.kt | 24 ++- .../view/WebViewInappViewHolder.kt | 37 ++-- 9 files changed, 334 insertions(+), 25 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index 95e4bf48a..237bf66ec 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -1358,7 +1358,9 @@ public object Mindbox : MindboxLog { requestUrl = requestUrl, sdkVersionNumeric = Constants.SDK_VERSION_NUMERIC ) - + if (source != null || requestUrl != null) { + sessionStorageManager.lastTrackVisitData = trackVisitData + } MindboxEventManager.appStarted(applicationContext, trackVisitData) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt index 27cf9e677..a2cf4ba4f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt @@ -33,6 +33,7 @@ internal fun DomainModule( maxInappsPerDayLimitChecker = maxInappsPerDayLimitChecker, minIntervalBetweenShowsLimitChecker = minIntervalBetweenShowsLimitChecker, timeProvider = timeProvider, + sessionStorageManager = sessionStorageManager, ) } override val callbackInteractor: CallbackInteractor by lazy { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/PermissionManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/PermissionManagerImpl.kt index 8f26a2503..7e2209a16 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/PermissionManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/PermissionManagerImpl.kt @@ -1,17 +1,101 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers +import android.Manifest import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat import androidx.core.app.NotificationManagerCompat import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus import cloud.mindbox.mobile_sdk.logger.mindboxLogE internal class PermissionManagerImpl(private val context: Context) : PermissionManager { - override fun isNotificationEnabled(): Boolean { + + override fun getCameraPermissionStatus(): PermissionStatus { + return runCatching { + resolveRuntimePermissionStatus(Manifest.permission.CAMERA) + }.getOrElse { _ -> + mindboxLogE("Unknown error checking camera permission status") + PermissionStatus.NOT_DETERMINED + } + } + + override fun getLocationPermissionStatus(): PermissionStatus { return runCatching { - NotificationManagerCompat.from(context).areNotificationsEnabled() + val fineStatus: PermissionStatus = resolveRuntimePermissionStatus(Manifest.permission.ACCESS_FINE_LOCATION) + val coarseStatus: PermissionStatus = resolveRuntimePermissionStatus(Manifest.permission.ACCESS_COARSE_LOCATION) + when { + fineStatus == PermissionStatus.GRANTED || coarseStatus == PermissionStatus.GRANTED -> PermissionStatus.GRANTED + else -> PermissionStatus.DENIED + } + }.getOrElse { _ -> + mindboxLogE("Unknown error checking location permission status") + PermissionStatus.NOT_DETERMINED + } + } + + override fun getMicrophonePermissionStatus(): PermissionStatus { + return runCatching { + resolveRuntimePermissionStatus(Manifest.permission.RECORD_AUDIO) + }.getOrElse { _ -> + mindboxLogE("Unknown error checking microphone permission status") + PermissionStatus.NOT_DETERMINED + } + } + + override fun getNotificationPermissionStatus(): PermissionStatus { + return runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val runtimeStatus: PermissionStatus = resolveRuntimePermissionStatus(Manifest.permission.POST_NOTIFICATIONS) + val areNotificationsEnabled: Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled() + if (runtimeStatus == PermissionStatus.GRANTED && areNotificationsEnabled) { + PermissionStatus.GRANTED + } else { + PermissionStatus.DENIED + } + } else { + if (NotificationManagerCompat.from(context).areNotificationsEnabled()) { + PermissionStatus.GRANTED + } else { + PermissionStatus.DENIED + } + } }.getOrElse { _ -> mindboxLogE("Unknown error checking notification permission status") - true + PermissionStatus.NOT_DETERMINED } } + + override fun getPhotoLibraryPermissionStatus(): PermissionStatus { + return runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val imagesStatus: PermissionStatus = resolveRuntimePermissionStatus(Manifest.permission.READ_MEDIA_IMAGES) + if (imagesStatus == PermissionStatus.GRANTED) { + return@runCatching PermissionStatus.GRANTED + } + val selectedPhotosGranted: Boolean = ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + ) == PackageManager.PERMISSION_GRANTED + if (selectedPhotosGranted) { + PermissionStatus.LIMITED + } else { + imagesStatus + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + resolveRuntimePermissionStatus(Manifest.permission.READ_MEDIA_IMAGES) + } else { + resolveRuntimePermissionStatus(Manifest.permission.READ_EXTERNAL_STORAGE) + } + }.getOrElse { _ -> + mindboxLogE("Unknown error checking photo library permission status") + PermissionStatus.NOT_DETERMINED + } + } + + private fun resolveRuntimePermissionStatus(permission: String): PermissionStatus { + val isGranted: Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + return if (isGranted) PermissionStatus.GRANTED else PermissionStatus.DENIED + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt index 4523924f6..4a3c71418 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt @@ -2,6 +2,8 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.inapp.domain.models.* import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.models.InAppEventType +import cloud.mindbox.mobile_sdk.models.TrackVisitData import cloud.mindbox.mobile_sdk.utils.TimeProvider import cloud.mindbox.mobile_sdk.utils.loggingRunCatching import java.util.concurrent.atomic.AtomicLong @@ -28,6 +30,8 @@ internal class SessionStorageManager(private val timeProvider: TimeProvider) { var configFetchingError: Boolean = false var sessionTime: Duration = 0L.milliseconds var inAppShowLimitsSettings: InAppShowLimitsSettings = InAppShowLimitsSettings() + var lastTrackVisitData: TrackVisitData? = null + var inAppTriggerEvent: InAppEventType? = null val lastTrackVisitSendTime: AtomicLong = AtomicLong(0L) @@ -82,6 +86,7 @@ internal class SessionStorageManager(private val timeProvider: TimeProvider) { configFetchingError = false sessionTime = 0L.milliseconds inAppShowLimitsSettings = InAppShowLimitsSettings() + inAppTriggerEvent = null } private fun notifySessionExpired() { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt index 6c9813a91..26b020c35 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt @@ -2,6 +2,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain import cloud.mindbox.mobile_sdk.InitializeLock import cloud.mindbox.mobile_sdk.abtests.InAppABTestLogic +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppEventManager @@ -18,9 +19,9 @@ import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.toTimestamp import cloud.mindbox.mobile_sdk.sortByPriority import cloud.mindbox.mobile_sdk.utils.TimeProvider +import cloud.mindbox.mobile_sdk.utils.allAllow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* -import cloud.mindbox.mobile_sdk.utils.allAllow internal class InAppInteractorImpl( private val mobileConfigRepository: MobileConfigRepository, @@ -33,7 +34,8 @@ internal class InAppInteractorImpl( private val maxInappsPerSessionLimitChecker: Checker, private val maxInappsPerDayLimitChecker: Checker, private val minIntervalBetweenShowsLimitChecker: Checker, - private val timeProvider: TimeProvider + private val timeProvider: TimeProvider, + private val sessionStorageManager: SessionStorageManager ) : InAppInteractor, MindboxLog { private val inAppTargetingChannel = Channel(Channel.UNLIMITED) @@ -69,7 +71,7 @@ internal class InAppInteractorImpl( } mindboxLogI("Event: ${event.name} combined with $filteredInApps") val prioritySortedInApps = filteredInApps.sortByPriority() - inAppProcessingManager.chooseInAppToShow( + val inApp: InApp? = inAppProcessingManager.chooseInAppToShow( prioritySortedInApps, event ).also { @@ -78,6 +80,10 @@ internal class InAppInteractorImpl( InitializeLock.complete(InitializeLock.State.APP_STARTED) } } + inApp?.let { + sessionStorageManager.inAppTriggerEvent = event + } + inApp } .onEach { inApp -> inApp?.let { mindboxLogI("InApp ${inApp.id} isPriority=${inApp.isPriority}, delayTime=${inApp.delayTime}, skipLimitChecks=${inApp.isPriority}") } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/PermissionManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/PermissionManager.kt index 1f9a78aed..4b81ffe16 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/PermissionManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/PermissionManager.kt @@ -1,6 +1,26 @@ package cloud.mindbox.mobile_sdk.inapp.domain.interfaces +internal enum class PermissionStatus(val value: String) { + GRANTED("granted"), + DENIED("denied"), + NOT_DETERMINED("notDetermined"), + RESTRICTED("restricted"), + LIMITED("limited"), +} + internal interface PermissionManager { - fun isNotificationEnabled(): Boolean + fun getCameraPermissionStatus(): PermissionStatus + + fun getLocationPermissionStatus(): PermissionStatus + + fun getMicrophonePermissionStatus(): PermissionStatus + + fun getNotificationPermissionStatus(): PermissionStatus + + fun getPhotoLibraryPermissionStatus(): PermissionStatus + + fun isNotificationEnabled(): Boolean { + return getNotificationPermissionStatus() == PermissionStatus.GRANTED + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt new file mode 100644 index 000000000..089816098 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt @@ -0,0 +1,164 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import cloud.mindbox.mobile_sdk.BuildConfig +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.models.Configuration +import cloud.mindbox.mobile_sdk.models.InAppEventType +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.utils.Constants +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import java.util.Locale +import android.content.res.Configuration as UiConfiguration + +internal class DataCollector( + private val appContext: Context, + private val sessionStorageManager: SessionStorageManager, + private val permissionManager: PermissionManager, + private val configuration: Configuration, + private val params: Map, + private val inAppInsets: InAppInsets, + private val gson: Gson, +) { + + private val providers: MutableMap by lazy { + mutableMapOf( + KEY_DEVICE_UUID to Provider.string(MindboxPreferences.deviceUuid), + KEY_ENDPOINT_ID to Provider.string(configuration.endpointId), + KEY_INSETS to createInsetsPayload(inAppInsets), + KEY_LOCALE to Provider.string(resolveLocale()), + KEY_OPERATION_NAME to Provider.string((sessionStorageManager.inAppTriggerEvent as? InAppEventType.OrdinalEvent)?.name), + KEY_OPERATION_BODY to Provider.string((sessionStorageManager.inAppTriggerEvent as? InAppEventType.OrdinalEvent)?.body), + KEY_PERMISSIONS to createPermissionsPayload(), + KEY_PLATFORM to Provider.string(VALUE_PLATFORM), + KEY_SDK_VERSION to Provider.string(BuildConfig.VERSION_NAME), + KEY_SDK_VERSION_NUMERIC to Provider.string(Constants.SDK_VERSION_NUMERIC.toString()), + KEY_THEME to Provider.string(resolveTheme()), + KEY_TRACK_VISIT_SOURCE to Provider.string(sessionStorageManager.lastTrackVisitData?.source), + KEY_TRACK_VISIT_REQUEST_URL to Provider.string(sessionStorageManager.lastTrackVisitData?.requestUrl), + KEY_USER_VISIT_COUNT to Provider.string(MindboxPreferences.userVisitCount.toString()), + KEY_VERSION to Provider.string(configuration.versionName), + ).apply { + params.forEach { (key, value) -> + put(key, Provider.string(value)) + } + } + } + + companion object Companion { + private const val KEY_DEVICE_UUID = "deviceUuid" + private const val KEY_ENDPOINT_ID = "endpointId" + private const val KEY_INSETS = "insets" + private const val KEY_LOCALE = "locale" + private const val KEY_OPERATION_BODY = "operationBody" + private const val KEY_OPERATION_NAME = "operationName" + private const val KEY_PERMISSIONS = "permissions" + private const val KEY_PERMISSIONS_CAMERA = "camera" + private const val KEY_PERMISSIONS_LOCATION = "location" + private const val KEY_PERMISSIONS_MICROPHONE = "microphone" + private const val KEY_PERMISSIONS_NOTIFICATIONS = "notifications" + private const val KEY_PERMISSIONS_PHOTO_LIBRARY = "photoLibrary" + private const val KEY_PLATFORM = "platform" + private const val KEY_SDK_VERSION = "sdkVersion" + private const val KEY_SDK_VERSION_NUMERIC = "sdkVersionNumeric" + private const val KEY_THEME = "theme" + private const val KEY_TRACK_VISIT_SOURCE = "trackVisitSource" + private const val KEY_TRACK_VISIT_REQUEST_URL = "trackVisitRequestUrl" + private const val KEY_USER_VISIT_COUNT = "userVisitCount" + private const val KEY_VERSION = "version" + private const val VALUE_PLATFORM = "android" + private const val VALUE_THEME_DARK = "dark" + private const val VALUE_THEME_LIGHT = "light" + } + + internal fun interface Provider { + fun get(): JsonElement? + + companion object { + fun string(value: String?) = Provider { + if (value.isNullOrBlank()) return@Provider null + JsonPrimitive(value) + } + + fun number(value: Number) = Provider { + JsonPrimitive(value) + } + + fun objectIntParams(vararg pairs: Pair) = Provider { + JsonObject().apply { + pairs.forEach { (key, value) -> + addProperty(key, value) + } + } + } + + fun objectStringParams(vararg pairs: Pair) = Provider { + JsonObject().apply { + pairs.forEach { (key, value) -> + addProperty(key, value) + } + } + } + + fun objectStringParams(map: Map) = Provider { + JsonObject().apply { + map.forEach { (key, value) -> + addProperty(key, value) + } + } + } + } + } + + internal fun get(): String { + val payload = JsonObject() + providers.forEach { (key, provider) -> + provider.get()?.let { value -> + payload.add(key, value) + } + } + return gson.toJson(payload) + } + + private fun createPermissionsPayload(): Provider { + val cameraStatus: String = permissionManager.getCameraPermissionStatus().value + val locationStatus: String = permissionManager.getLocationPermissionStatus().value + val microphoneStatus: String = permissionManager.getMicrophonePermissionStatus().value + val notificationsStatus: String = permissionManager.getNotificationPermissionStatus().value + val photoLibraryStatus: String = permissionManager.getPhotoLibraryPermissionStatus().value + return Provider { + JsonObject().apply { + add(KEY_PERMISSIONS_CAMERA, JsonObject().apply { addProperty("status", cameraStatus) }) + add(KEY_PERMISSIONS_LOCATION, JsonObject().apply { addProperty("status", locationStatus) }) + add(KEY_PERMISSIONS_MICROPHONE, JsonObject().apply { addProperty("status", microphoneStatus) }) + add(KEY_PERMISSIONS_NOTIFICATIONS, JsonObject().apply { addProperty("status", notificationsStatus) }) + add(KEY_PERMISSIONS_PHOTO_LIBRARY, JsonObject().apply { addProperty("status", photoLibraryStatus) }) + } + } + } + + private fun resolveTheme(): String { + val uiMode: Int = appContext.resources.configuration.uiMode + val isDarkTheme: Boolean = (uiMode and UiConfiguration.UI_MODE_NIGHT_MASK) == UiConfiguration.UI_MODE_NIGHT_YES + return if (isDarkTheme) VALUE_THEME_DARK else VALUE_THEME_LIGHT + } + + private fun resolveLocale(): String { + return Locale.getDefault().toLanguageTag().replace("-", "_") + } + + private fun createInsetsPayload(insets: InAppInsets): Provider { + return Provider { + JsonObject().apply { + addProperty(InAppInsets.BOTTOM, insets.bottom) + addProperty(InAppInsets.LEFT, insets.left) + addProperty(InAppInsets.RIGHT, insets.right) + addProperty(InAppInsets.TOP, insets.top) + } + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt index 7363418e0..5e2e811df 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt @@ -24,6 +24,7 @@ internal class InAppConstraintLayout : ConstraintLayout, BackButtonLayout { } private var swipeToDismissCallback: (() -> Unit)? = null + internal var webViewInsets: InAppInsets = InAppInsets() constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) @@ -200,14 +201,17 @@ internal class InAppConstraintLayout : ConstraintLayout, BackButtonLayout { gravity = Gravity.CENTER height = FrameLayout.LayoutParams.MATCH_PARENT } - ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInset -> + ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInset -> val inset = windowInset.getInsets( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime() + or WindowInsetsCompat.Type.navigationBars() ) - - view.updatePadding( + webViewInsets = InAppInsets( + left = inset.left, + top = inset.top, + right = inset.right, bottom = maxOf(inset.bottom, getNavigationBarHeight()) ) mindboxLogI("Webview Insets: $inset") @@ -262,3 +266,17 @@ internal class InAppConstraintLayout : ConstraintLayout, BackButtonLayout { return handled ?: super.dispatchKeyEvent(event) } } + +internal data class InAppInsets( + val left: Int = 0, + val top: Int = 0, + val right: Int = 0, + val bottom: Int = 0 +) { + companion object { + const val LEFT = "left" + const val TOP = "top" + const val RIGHT = "right" + const val BOTTOM = "bottom" + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index f03aafe80..fdc93dcc8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view +import android.app.Application import android.view.ViewGroup import android.widget.RelativeLayout import android.widget.Toast @@ -11,7 +12,9 @@ import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.fromJson import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.validators.BridgeMessageValidator +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendFailureWithContext import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure @@ -29,10 +32,8 @@ import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.getShortUserAgent -import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.safeAs -import cloud.mindbox.mobile_sdk.utils.Constants import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch import com.google.gson.Gson import kotlinx.coroutines.CancellationException @@ -41,7 +42,6 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.util.Timer -import java.util.TreeMap import java.util.concurrent.ConcurrentHashMap import kotlin.concurrent.timer @@ -69,6 +69,9 @@ internal class WebViewInAppViewHolder( private val gson: Gson by mindboxInject { this.gson } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } + private val sessionStorageManager: SessionStorageManager by mindboxInject { sessionStorageManager } + private val permissionManager: PermissionManager by mindboxInject { permissionManager } + private val appContext: Application by mindboxInject { appContext } override val isActive: Boolean get() = isInAppMessageActive @@ -102,6 +105,7 @@ internal class WebViewInAppViewHolder( message: BridgeMessage, onError: ((String?) -> Unit)? = null ) { + mindboxLogI("SDK -> send message $message") val json = gson.toJson(message) controller.evaluateJavaScript(JS_CALL_BRIDGE.format(json)) { result -> if (!checkEvaluateJavaScript(result)) { @@ -122,7 +126,7 @@ internal class WebViewInAppViewHolder( register(WebViewAction.TOAST, ::handleToastAction) register(WebViewAction.ALERT, ::handleAlertAction) register(WebViewAction.READY) { - handleReadyAction(layer, configuration) + handleReadyAction(configuration, inAppLayout.webViewInsets, layer.params) } register(WebViewAction.INIT) { handleInitAction(controller) @@ -133,16 +137,20 @@ internal class WebViewInAppViewHolder( } } - private fun handleReadyAction(layer: Layer.WebViewLayer, configuration: Configuration): String { - val params: TreeMap = TreeMap(String.CASE_INSENSITIVE_ORDER).apply { - put("sdkVersion", Mindbox.getSdkVersion()) - put("endpointId", configuration.endpointId) - put("deviceUuid", MindboxPreferences.deviceUuid) - put("sdkVersionNumeric", Constants.SDK_VERSION_NUMERIC.toString()) - putAll(layer.params) - } - - return gson.toJson(params) + private fun handleReadyAction( + configuration: Configuration, + insets: InAppInsets, + params: Map, + ): String { + return DataCollector( + appContext = appContext, + sessionStorageManager = sessionStorageManager, + permissionManager = permissionManager, + gson = gson, + configuration = configuration, + params = params, + inAppInsets = insets, + ).get() } private fun handleInitAction(controller: WebViewController): String { @@ -347,6 +355,7 @@ internal class WebViewInAppViewHolder( controller.setVisibility(false) controller.setJsBridge(bridge = { json -> val message = gson.fromJson(json).getOrNull() + mindboxLogI("SDK <- receive message $message") if (!messageValidator.isValid(message)) { return@setJsBridge } From 6cc3e3cf64c24a2da4f686a45d53fc61234ba8c4 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 12 Feb 2026 15:59:19 +0300 Subject: [PATCH 19/59] MOBILEWEBVIEW-8: ADd constant for status --- .../inapp/presentation/view/DataCollector.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt index 089816098..2abed54ff 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt @@ -57,6 +57,7 @@ internal class DataCollector( private const val KEY_OPERATION_BODY = "operationBody" private const val KEY_OPERATION_NAME = "operationName" private const val KEY_PERMISSIONS = "permissions" + private const val KEY_PERMISSIONS_STATUS = "status" private const val KEY_PERMISSIONS_CAMERA = "camera" private const val KEY_PERMISSIONS_LOCATION = "location" private const val KEY_PERMISSIONS_MICROPHONE = "microphone" @@ -132,11 +133,11 @@ internal class DataCollector( val photoLibraryStatus: String = permissionManager.getPhotoLibraryPermissionStatus().value return Provider { JsonObject().apply { - add(KEY_PERMISSIONS_CAMERA, JsonObject().apply { addProperty("status", cameraStatus) }) - add(KEY_PERMISSIONS_LOCATION, JsonObject().apply { addProperty("status", locationStatus) }) - add(KEY_PERMISSIONS_MICROPHONE, JsonObject().apply { addProperty("status", microphoneStatus) }) - add(KEY_PERMISSIONS_NOTIFICATIONS, JsonObject().apply { addProperty("status", notificationsStatus) }) - add(KEY_PERMISSIONS_PHOTO_LIBRARY, JsonObject().apply { addProperty("status", photoLibraryStatus) }) + add(KEY_PERMISSIONS_CAMERA, JsonObject().apply { addProperty(KEY_PERMISSIONS_STATUS, cameraStatus) }) + add(KEY_PERMISSIONS_LOCATION, JsonObject().apply { addProperty(KEY_PERMISSIONS_STATUS, locationStatus) }) + add(KEY_PERMISSIONS_MICROPHONE, JsonObject().apply { addProperty(KEY_PERMISSIONS_STATUS, microphoneStatus) }) + add(KEY_PERMISSIONS_NOTIFICATIONS, JsonObject().apply { addProperty(KEY_PERMISSIONS_STATUS, notificationsStatus) }) + add(KEY_PERMISSIONS_PHOTO_LIBRARY, JsonObject().apply { addProperty(KEY_PERMISSIONS_STATUS, photoLibraryStatus) }) } } } From a6d3560e8f7a1d3b1d2ed6932d4d69684540ae7a Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 12 Feb 2026 17:15:16 +0300 Subject: [PATCH 20/59] MOBILEWEBVIEW-8: Add tests --- .../inapp/domain/InAppInteractorImplTest.kt | 10 +- .../presentation/view/DataCollectorTest.kt | 177 ++++++++++++++++++ 2 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt index 9b5d2e42d..fc7adfba9 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt @@ -2,6 +2,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain import app.cash.turbine.test import cloud.mindbox.mobile_sdk.abtests.InAppABTestLogic +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor @@ -79,6 +80,9 @@ class InAppInteractorImplTest { @RelaxedMockK private lateinit var inAppTargetingErrorRepository: InAppTargetingErrorRepository + @RelaxedMockK + private lateinit var sessionStorageManager: SessionStorageManager + @MockK private lateinit var inAppContentFetcher: InAppContentFetcher @@ -100,7 +104,8 @@ class InAppInteractorImplTest { maxInappsPerSessionLimitChecker, maxInappsPerDayLimitChecker, minIntervalBetweenShowsLimitChecker, - timeProvider + timeProvider, + sessionStorageManager ) coEvery { mobileConfigRepository.getInAppsSection() } returns emptyList() @@ -187,7 +192,8 @@ class InAppInteractorImplTest { maxInappsPerSessionLimitChecker, maxInappsPerDayLimitChecker, minIntervalBetweenShowsLimitChecker, - timeProvider + timeProvider, + sessionStorageManager ) coEvery { mobileConfigRepository.getInAppsSection() } returns inAppsFromConfig diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt new file mode 100644 index 000000000..e94cbb996 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt @@ -0,0 +1,177 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.content.res.Resources +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus +import cloud.mindbox.mobile_sdk.models.Configuration +import cloud.mindbox.mobile_sdk.models.EventType +import cloud.mindbox.mobile_sdk.models.InAppEventType +import cloud.mindbox.mobile_sdk.models.TrackVisitData +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.utils.Constants +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Locale +import android.content.res.Configuration as UiConfiguration + +class DataCollectorTest { + private lateinit var appContext: Context + private lateinit var permissionManager: PermissionManager + private lateinit var sessionStorageManager: SessionStorageManager + private lateinit var resources: Resources + private lateinit var uiConfiguration: UiConfiguration + private val gson: Gson = Gson() + private var previousLocale: Locale = Locale.getDefault() + + @Before + fun onTestStart() { + previousLocale = Locale.getDefault() + appContext = mockk() + resources = mockk() + uiConfiguration = UiConfiguration() + permissionManager = mockk() + sessionStorageManager = SessionStorageManager(timeProvider = mockk()) + every { appContext.resources } returns resources + every { resources.configuration } returns uiConfiguration + mockkObject(MindboxPreferences) + } + + @After + fun onTestFinish() { + Locale.setDefault(previousLocale) + unmockkAll() + } + + @Test + fun `get builds payload with main data and permissions`() { + Locale.setDefault(Locale.forLanguageTag("en-US")) + uiConfiguration.uiMode = UiConfiguration.UI_MODE_NIGHT_NO + every { MindboxPreferences.deviceUuid } returns "device-uuid" + every { MindboxPreferences.userVisitCount } returns 7 + every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.GRANTED + every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.DENIED + every { permissionManager.getMicrophonePermissionStatus() } returns PermissionStatus.NOT_DETERMINED + every { permissionManager.getNotificationPermissionStatus() } returns PermissionStatus.RESTRICTED + every { permissionManager.getPhotoLibraryPermissionStatus() } returns PermissionStatus.LIMITED + sessionStorageManager.lastTrackVisitData = TrackVisitData( + ianaTimeZone = "Europe/Moscow", + endpointId = "endpoint-id", + source = "link", + requestUrl = "https://mindbox.cloud/path", + sdkVersionNumeric = Constants.SDK_VERSION_NUMERIC, + ) + sessionStorageManager.inAppTriggerEvent = InAppEventType.OrdinalEvent( + eventType = EventType.AsyncOperation("OpenScreen"), + body = "{\"screen\":\"home\"}", + ) + val dataCollector: DataCollector = DataCollector( + appContext = appContext, + sessionStorageManager = sessionStorageManager, + permissionManager = permissionManager, + configuration = createConfiguration(endpointId = "endpoint-id", versionName = "1.2.3"), + params = mapOf("customKey" to "customValue"), + inAppInsets = InAppInsets(left = 1, top = 2, right = 3, bottom = 4), + gson = gson, + ) + val actualPayload: String = dataCollector.get() + val actualJson: JsonObject = JsonParser.parseString(actualPayload).asJsonObject + assertEquals("device-uuid", actualJson.get("deviceUuid").asString) + assertEquals("endpoint-id", actualJson.get("endpointId").asString) + assertEquals("en_US", actualJson.get("locale").asString) + assertEquals("OpenScreen", actualJson.get("operationName").asString) + assertEquals("{\"screen\":\"home\"}", actualJson.get("operationBody").asString) + assertEquals("android", actualJson.get("platform").asString) + assertEquals("light", actualJson.get("theme").asString) + assertEquals("link", actualJson.get("trackVisitSource").asString) + assertEquals("https://mindbox.cloud/path", actualJson.get("trackVisitRequestUrl").asString) + assertEquals("7", actualJson.get("userVisitCount").asString) + assertEquals("1.2.3", actualJson.get("version").asString) + assertEquals("customValue", actualJson.get("customKey").asString) + assertEquals(1, actualJson.getAsJsonObject("insets").get("left").asInt) + assertEquals(2, actualJson.getAsJsonObject("insets").get("top").asInt) + assertEquals(3, actualJson.getAsJsonObject("insets").get("right").asInt) + assertEquals(4, actualJson.getAsJsonObject("insets").get("bottom").asInt) + assertEquals("granted", getPermissionStatus(actualJson, "camera")) + assertEquals("denied", getPermissionStatus(actualJson, "location")) + assertEquals("notDetermined", getPermissionStatus(actualJson, "microphone")) + assertEquals("restricted", getPermissionStatus(actualJson, "notifications")) + assertEquals("limited", getPermissionStatus(actualJson, "photoLibrary")) + assertTrue(actualJson.has("sdkVersion")) + assertEquals(Constants.SDK_VERSION_NUMERIC.toString(), actualJson.get("sdkVersionNumeric").asString) + } + + @Test + fun `get ignores blank values and applies params override`() { + Locale.setDefault(Locale.forLanguageTag("ru-RU")) + uiConfiguration.uiMode = UiConfiguration.UI_MODE_NIGHT_YES + every { MindboxPreferences.deviceUuid } returns "" + every { MindboxPreferences.userVisitCount } returns 3 + every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.GRANTED + every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.GRANTED + every { permissionManager.getMicrophonePermissionStatus() } returns PermissionStatus.GRANTED + every { permissionManager.getNotificationPermissionStatus() } returns PermissionStatus.GRANTED + every { permissionManager.getPhotoLibraryPermissionStatus() } returns PermissionStatus.GRANTED + sessionStorageManager.inAppTriggerEvent = InAppEventType.AppStartup + sessionStorageManager.lastTrackVisitData = TrackVisitData( + ianaTimeZone = "Europe/Moscow", + endpointId = "endpoint-id", + source = null, + requestUrl = " ", + sdkVersionNumeric = Constants.SDK_VERSION_NUMERIC, + ) + val dataCollector: DataCollector = DataCollector( + appContext = appContext, + sessionStorageManager = sessionStorageManager, + permissionManager = permissionManager, + configuration = createConfiguration(endpointId = "", versionName = "2.0.0"), + params = mapOf("endpointId" to "overridden-endpoint"), + inAppInsets = InAppInsets(), + gson = gson, + ) + val actualPayload: String = dataCollector.get() + val actualJson: JsonObject = JsonParser.parseString(actualPayload).asJsonObject + assertFalse(actualJson.has("deviceUuid")) + assertFalse(actualJson.has("operationName")) + assertFalse(actualJson.has("operationBody")) + assertFalse(actualJson.has("trackVisitSource")) + assertFalse(actualJson.has("trackVisitRequestUrl")) + assertEquals("overridden-endpoint", actualJson.get("endpointId").asString) + assertEquals("dark", actualJson.get("theme").asString) + assertEquals("ru_RU", actualJson.get("locale").asString) + } + + private fun getPermissionStatus(payload: JsonObject, permissionKey: String): String { + return payload + .getAsJsonObject("permissions") + .getAsJsonObject(permissionKey) + .get("status") + .asString + } + + private fun createConfiguration(endpointId: String, versionName: String): Configuration { + return Configuration( + previousInstallationId = "prev-installation", + previousDeviceUUID = "prev-device", + endpointId = endpointId, + domain = "api.test.mindbox.cloud", + packageName = "cloud.mindbox.test", + versionName = versionName, + versionCode = "100", + subscribeCustomerIfCreated = false, + shouldCreateCustomer = true, + ) + } +} From f5df2634c5c8aeb11556fb8bf4b5cf2ce9055d94 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 12 Feb 2026 18:20:25 +0300 Subject: [PATCH 21/59] MOBILEWEBVIEW-8: Fix ime padding --- .../inapp/presentation/view/InAppConstraintLayout.kt | 10 ++++++++-- .../inapp/presentation/view/WebViewInappViewHolder.kt | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt index 5e2e811df..796093bf9 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt @@ -201,19 +201,25 @@ internal class InAppConstraintLayout : ConstraintLayout, BackButtonLayout { gravity = Gravity.CENTER height = FrameLayout.LayoutParams.MATCH_PARENT } - ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInset -> + ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInset -> val inset = windowInset.getInsets( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() - or WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.navigationBars() ) + webViewInsets = InAppInsets( left = inset.left, top = inset.top, right = inset.right, bottom = maxOf(inset.bottom, getNavigationBarHeight()) ) + + view.updatePadding( + bottom = windowInset.getInsets( + WindowInsetsCompat.Type.ime() + ).bottom + ) mindboxLogI("Webview Insets: $inset") WindowInsetsCompat.CONSUMED } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index fdc93dcc8..8ce1cf905 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -14,10 +14,10 @@ import cloud.mindbox.mobile_sdk.fromJson import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.validators.BridgeMessageValidator -import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendFailureWithContext import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer @@ -32,7 +32,7 @@ import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.getShortUserAgent -import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.safeAs import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch import com.google.gson.Gson From adad8ae8c1490d290c034425d8d1e390ce6889dc Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:11:48 +0300 Subject: [PATCH 22/59] MOBILEWEBVIEW-10: change field name --- .../mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt | 4 ++-- .../mobile_sdk/models/operation/request/InAppShowFailure.kt | 4 ++-- .../inapp/data/managers/InAppFailureTrackerImplTest.kt | 2 +- .../inapp/data/managers/InAppSerializationManagerTest.kt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt index 9635e904c..df92a73ea 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt @@ -53,7 +53,7 @@ internal class InAppFailureTrackerImpl( inAppId = inAppId, failureReason = failureReason, errorDetails = errorDetails?.take(COUNT_OF_CHARS_IN_ERROR_DETAILS), - timestamp = timestamp + dateTimeUtc = timestamp ) ) } @@ -67,7 +67,7 @@ internal class InAppFailureTrackerImpl( inAppId = inAppId, failureReason = failureReason, errorDetails = errorDetails?.take(COUNT_OF_CHARS_IN_ERROR_DETAILS), - timestamp = timestamp + dateTimeUtc = timestamp ) ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt index 0fcce7a90..4705de286 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt @@ -9,8 +9,8 @@ internal data class InAppShowFailure( val failureReason: FailureReason, @SerializedName("errorDetails") val errorDetails: String?, - @SerializedName("timestamp") - val timestamp: String + @SerializedName("dateTimeUtc") + val dateTimeUtc: String ) internal enum class FailureReason(val value: String) { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt index 1168e73ab..3638f2e16 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt @@ -64,7 +64,7 @@ internal class InAppFailureTrackerImplTest { assertEquals(inAppId, captured[0].inAppId) assertEquals(FailureReason.PRESENTATION_FAILED, captured[0].failureReason) assertEquals("error", captured[0].errorDetails) - assertEquals(expectedTimestamp, captured[0].timestamp) + assertEquals(expectedTimestamp, captured[0].dateTimeUtc) } @Test diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt index d83e559ef..9de8b9487 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt @@ -134,7 +134,7 @@ internal class InAppSerializationManagerTest { inAppId = inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDetails = "error", - timestamp = "2024-02-10T00:00:00Z" + dateTimeUtc = "2024-02-10T00:00:00Z" ) ) val expectedJson = Gson().toJson(InAppFailuresWrapper(inAppShowFailures)) @@ -152,7 +152,7 @@ internal class InAppSerializationManagerTest { inAppId = inAppId, failureReason = FailureReason.UNKNOWN_ERROR, errorDetails = null, - timestamp = "2024-02-10T00:00:00Z" + dateTimeUtc = "2024-02-10T00:00:00Z" ) ) every { From b820c2a5cfc87235b29f62020d2b07e036dc7734 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 16 Feb 2026 10:43:36 +0300 Subject: [PATCH 23/59] MOBILEWEBVIEW-8: Fix permissions in ready action --- .../inapp/presentation/view/DataCollector.kt | 35 +++++++----- .../presentation/view/DataCollectorTest.kt | 54 +++++++++++++++++-- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt index 2abed54ff..6d2811835 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt @@ -4,6 +4,7 @@ import android.content.Context import cloud.mindbox.mobile_sdk.BuildConfig import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.repository.MindboxPreferences @@ -13,6 +14,7 @@ import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonPrimitive import java.util.Locale +import kotlin.math.roundToInt import android.content.res.Configuration as UiConfiguration internal class DataCollector( @@ -126,18 +128,19 @@ internal class DataCollector( } private fun createPermissionsPayload(): Provider { - val cameraStatus: String = permissionManager.getCameraPermissionStatus().value - val locationStatus: String = permissionManager.getLocationPermissionStatus().value - val microphoneStatus: String = permissionManager.getMicrophonePermissionStatus().value - val notificationsStatus: String = permissionManager.getNotificationPermissionStatus().value - val photoLibraryStatus: String = permissionManager.getPhotoLibraryPermissionStatus().value + val map = mapOf( + KEY_PERMISSIONS_CAMERA to permissionManager.getCameraPermissionStatus().value, + KEY_PERMISSIONS_LOCATION to permissionManager.getLocationPermissionStatus().value, + KEY_PERMISSIONS_MICROPHONE to permissionManager.getMicrophonePermissionStatus().value, + KEY_PERMISSIONS_NOTIFICATIONS to permissionManager.getNotificationPermissionStatus().value, + KEY_PERMISSIONS_PHOTO_LIBRARY to permissionManager.getPhotoLibraryPermissionStatus().value, + ).filter { (_, value) -> value == PermissionStatus.GRANTED.value } + return Provider { JsonObject().apply { - add(KEY_PERMISSIONS_CAMERA, JsonObject().apply { addProperty(KEY_PERMISSIONS_STATUS, cameraStatus) }) - add(KEY_PERMISSIONS_LOCATION, JsonObject().apply { addProperty(KEY_PERMISSIONS_STATUS, locationStatus) }) - add(KEY_PERMISSIONS_MICROPHONE, JsonObject().apply { addProperty(KEY_PERMISSIONS_STATUS, microphoneStatus) }) - add(KEY_PERMISSIONS_NOTIFICATIONS, JsonObject().apply { addProperty(KEY_PERMISSIONS_STATUS, notificationsStatus) }) - add(KEY_PERMISSIONS_PHOTO_LIBRARY, JsonObject().apply { addProperty(KEY_PERMISSIONS_STATUS, photoLibraryStatus) }) + map.forEach { (key, value) -> + add(key, JsonObject().apply { addProperty(KEY_PERMISSIONS_STATUS, value) }) + } } } } @@ -153,12 +156,16 @@ internal class DataCollector( } private fun createInsetsPayload(insets: InAppInsets): Provider { + val density: Float = appContext.resources.displayMetrics.density + + fun Int.toCssPixel(): Int = (this / density).roundToInt() + return Provider { JsonObject().apply { - addProperty(InAppInsets.BOTTOM, insets.bottom) - addProperty(InAppInsets.LEFT, insets.left) - addProperty(InAppInsets.RIGHT, insets.right) - addProperty(InAppInsets.TOP, insets.top) + addProperty(InAppInsets.BOTTOM, insets.bottom.toCssPixel()) + addProperty(InAppInsets.LEFT, insets.left.toCssPixel()) + addProperty(InAppInsets.RIGHT, insets.right.toCssPixel()) + addProperty(InAppInsets.TOP, insets.top.toCssPixel()) } } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt index e94cbb996..7739971dd 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt @@ -2,6 +2,8 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.content.Context import android.content.res.Resources +import android.util.DisplayMetrics +import org.junit.Assert.assertNotNull import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus @@ -44,8 +46,10 @@ class DataCollectorTest { uiConfiguration = UiConfiguration() permissionManager = mockk() sessionStorageManager = SessionStorageManager(timeProvider = mockk()) + val displayMetrics = DisplayMetrics().apply { density = 1f } every { appContext.resources } returns resources every { resources.configuration } returns uiConfiguration + every { resources.displayMetrics } returns displayMetrics mockkObject(MindboxPreferences) } @@ -104,11 +108,12 @@ class DataCollectorTest { assertEquals(2, actualJson.getAsJsonObject("insets").get("top").asInt) assertEquals(3, actualJson.getAsJsonObject("insets").get("right").asInt) assertEquals(4, actualJson.getAsJsonObject("insets").get("bottom").asInt) + val permissionsJson: JsonObject = actualJson.getAsJsonObject("permissions") assertEquals("granted", getPermissionStatus(actualJson, "camera")) - assertEquals("denied", getPermissionStatus(actualJson, "location")) - assertEquals("notDetermined", getPermissionStatus(actualJson, "microphone")) - assertEquals("restricted", getPermissionStatus(actualJson, "notifications")) - assertEquals("limited", getPermissionStatus(actualJson, "photoLibrary")) + assertFalse(permissionsJson.has("location")) + assertFalse(permissionsJson.has("microphone")) + assertFalse(permissionsJson.has("notifications")) + assertFalse(permissionsJson.has("photoLibrary")) assertTrue(actualJson.has("sdkVersion")) assertEquals(Constants.SDK_VERSION_NUMERIC.toString(), actualJson.get("sdkVersionNumeric").asString) } @@ -151,6 +156,47 @@ class DataCollectorTest { assertEquals("overridden-endpoint", actualJson.get("endpointId").asString) assertEquals("dark", actualJson.get("theme").asString) assertEquals("ru_RU", actualJson.get("locale").asString) + val permissionsJson: JsonObject = actualJson.getAsJsonObject("permissions") + assertEquals(5, permissionsJson.keySet().size) + assertEquals("granted", getPermissionStatus(actualJson, "camera")) + assertEquals("granted", getPermissionStatus(actualJson, "location")) + assertEquals("granted", getPermissionStatus(actualJson, "microphone")) + assertEquals("granted", getPermissionStatus(actualJson, "notifications")) + assertEquals("granted", getPermissionStatus(actualJson, "photoLibrary")) + } + + @Test + fun `get converts insets to CSS pixels when density is not 1f`() { + val density = 2.5f + val displayMetrics = DisplayMetrics().apply { this.density = density } + every { resources.displayMetrics } returns displayMetrics + every { MindboxPreferences.deviceUuid } returns "device-uuid" + every { MindboxPreferences.userVisitCount } returns 0 + every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.DENIED + every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.DENIED + every { permissionManager.getMicrophonePermissionStatus() } returns PermissionStatus.DENIED + every { permissionManager.getNotificationPermissionStatus() } returns PermissionStatus.DENIED + every { permissionManager.getPhotoLibraryPermissionStatus() } returns PermissionStatus.DENIED + sessionStorageManager.lastTrackVisitData = null + sessionStorageManager.inAppTriggerEvent = InAppEventType.AppStartup + val inAppInsets = InAppInsets(left = 5, top = 10, right = 15, bottom = 20) + val dataCollector = DataCollector( + appContext = appContext, + sessionStorageManager = sessionStorageManager, + permissionManager = permissionManager, + configuration = createConfiguration(endpointId = "endpoint-id", versionName = "1.0.0"), + params = emptyMap(), + inAppInsets = inAppInsets, + gson = gson, + ) + val actualPayload = dataCollector.get() + val actualJson = JsonParser.parseString(actualPayload).asJsonObject + val insetsJson = actualJson.getAsJsonObject("insets") + assertNotNull(insetsJson) + assertEquals(2, insetsJson.get("left").asInt) + assertEquals(4, insetsJson.get("top").asInt) + assertEquals(6, insetsJson.get("right").asInt) + assertEquals(8, insetsJson.get("bottom").asInt) } private fun getPermissionStatus(payload: JsonObject, permissionKey: String): String { From fa4a897272770726be7b3a961ad3b5f254b332ba Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 16 Feb 2026 12:04:31 +0300 Subject: [PATCH 24/59] MOBILEWEBVIEW-7: Fix back action on reattach webview --- .../inapp/presentation/view/WebViewInappViewHolder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 8ce1cf905..083237dc2 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -250,7 +250,6 @@ internal class WebViewInAppViewHolder( private fun clearBackPressedCallback() { backPressedCallback?.remove() - backPressedCallback = null } private fun sendBackAction(controller: WebViewController) { @@ -521,5 +520,6 @@ internal class WebViewInAppViewHolder( clearBackPressedCallback() webViewController?.destroy() webViewController = null + backPressedCallback = null } } From a591388e96091532a6787ee7876cc428434a8b18 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 16 Feb 2026 17:43:49 +0300 Subject: [PATCH 25/59] MOBILEWEBVIEW-46: Add sync/async operations --- .../inapp/presentation/view/DataCollector.kt | 3 + .../inapp/presentation/view/WebViewAction.kt | 6 + .../view/WebViewInappViewHolder.kt | 24 ++- .../view/WebViewOperationExecutor.kt | 66 +++++++ .../managers/MindboxEventManager.kt | 2 +- .../presentation/view/DataCollectorTest.kt | 3 + .../view/WebViewOperationExecutorTest.kt | 165 ++++++++++++++++++ 7 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt index 6d2811835..3f1a63c2d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt @@ -25,12 +25,14 @@ internal class DataCollector( private val params: Map, private val inAppInsets: InAppInsets, private val gson: Gson, + private val inAppId: String, ) { private val providers: MutableMap by lazy { mutableMapOf( KEY_DEVICE_UUID to Provider.string(MindboxPreferences.deviceUuid), KEY_ENDPOINT_ID to Provider.string(configuration.endpointId), + KEY_IN_APP_ID to Provider.string(inAppId), KEY_INSETS to createInsetsPayload(inAppInsets), KEY_LOCALE to Provider.string(resolveLocale()), KEY_OPERATION_NAME to Provider.string((sessionStorageManager.inAppTriggerEvent as? InAppEventType.OrdinalEvent)?.name), @@ -54,6 +56,7 @@ internal class DataCollector( companion object Companion { private const val KEY_DEVICE_UUID = "deviceUuid" private const val KEY_ENDPOINT_ID = "endpointId" + private const val KEY_IN_APP_ID = "inAppId" private const val KEY_INSETS = "insets" private const val KEY_LOCALE = "locale" private const val KEY_OPERATION_BODY = "operationBody" diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index 582a2dd26..a7c423ee0 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -33,6 +33,12 @@ public enum class WebViewAction { @SerializedName("toast") TOAST, + + @SerializedName("syncOperation") + SYNC_OPERATION, + + @SerializedName("asyncOperation") + ASYNC_OPERATION, } @InternalMindboxApi diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 083237dc2..7e512a4ab 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -72,6 +72,9 @@ internal class WebViewInAppViewHolder( private val sessionStorageManager: SessionStorageManager by mindboxInject { sessionStorageManager } private val permissionManager: PermissionManager by mindboxInject { permissionManager } private val appContext: Application by mindboxInject { appContext } + private val operationExecutor: WebViewOperationExecutor by lazy { + MindboxWebViewOperationExecutor() + } override val isActive: Boolean get() = isInAppMessageActive @@ -125,8 +128,15 @@ internal class WebViewInAppViewHolder( register(WebViewAction.LOG, ::handleLogAction) register(WebViewAction.TOAST, ::handleToastAction) register(WebViewAction.ALERT, ::handleAlertAction) + register(WebViewAction.ASYNC_OPERATION, ::handleAsyncOperationAction) + registerSuspend(WebViewAction.SYNC_OPERATION, ::handleSyncOperationAction) register(WebViewAction.READY) { - handleReadyAction(configuration, inAppLayout.webViewInsets, layer.params) + handleReadyAction( + configuration = configuration, + insets = inAppLayout.webViewInsets, + params = layer.params, + inAppId = wrapper.inAppType.inAppId, + ) } register(WebViewAction.INIT) { handleInitAction(controller) @@ -141,6 +151,7 @@ internal class WebViewInAppViewHolder( configuration: Configuration, insets: InAppInsets, params: Map, + inAppId: String, ): String { return DataCollector( appContext = appContext, @@ -150,6 +161,7 @@ internal class WebViewInAppViewHolder( configuration = configuration, params = params, inAppInsets = insets, + inAppId = inAppId, ).get() } @@ -174,7 +186,6 @@ internal class WebViewInAppViewHolder( } val url: String? = actionResult.first val payload: String? = actionResult.second - wrapper.inAppActionCallbacks.onInAppClick.onClick() inAppCallback.onInAppClick( wrapper.inAppType.inAppId, url ?: "", @@ -220,6 +231,15 @@ internal class WebViewInAppViewHolder( return BridgeMessage.EMPTY_PAYLOAD } + private fun handleAsyncOperationAction(message: BridgeMessage.Request): String { + operationExecutor.executeAsyncOperation(appContext, message.payload) + return BridgeMessage.EMPTY_PAYLOAD + } + + private suspend fun handleSyncOperationAction(message: BridgeMessage.Request): String { + return operationExecutor.executeSyncOperation(message.payload) + } + private fun createWebViewController(layer: Layer.WebViewLayer): WebViewController { mindboxLogI("Creating WebView for In-App: ${wrapper.inAppType.inAppId} with layer ${layer.type}") val controller: WebViewController = WebViewController.create(currentDialog.context, BuildConfig.DEBUG) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt new file mode 100644 index 000000000..44c0bb61b --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt @@ -0,0 +1,66 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.app.Application +import cloud.mindbox.mobile_sdk.managers.MindboxEventManager +import cloud.mindbox.mobile_sdk.models.MindboxError +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal interface WebViewOperationExecutor { + + fun executeAsyncOperation(context: Application, payload: String?) + + suspend fun executeSyncOperation(payload: String?): String +} + +internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { + + companion object { + private const val OPERATION_FIELD = "operation" + private const val BODY_FIELD = "body" + } + + override fun executeAsyncOperation(context: Application, payload: String?) { + val (operation, body) = parseOperationRequest(payload) + MindboxEventManager.asyncOperation( + context = context, + name = operation, + body = body, + ) + } + + override suspend fun executeSyncOperation(payload: String?): String { + val (operation, body) = parseOperationRequest(payload) + return suspendCancellableCoroutine { continuation -> + MindboxEventManager.syncOperation( + name = operation, + bodyJson = body, + onSuccess = { responseBody: String -> + if (continuation.isActive) { + continuation.resume(responseBody) + } + }, + onError = { error: MindboxError -> + if (continuation.isActive) { + continuation.resumeWithException( + IllegalStateException(error.toJson()) + ) + } + }, + ) + } + } + + private fun parseOperationRequest(payload: String?): Pair { + val jsonObject: JsonObject = JsonParser.parseString(payload).getAsJsonObject() + ?: throw IllegalArgumentException("Payload is not a valid JSON") + val operation: String = jsonObject.getAsJsonPrimitive(OPERATION_FIELD)?.asString + ?: throw IllegalArgumentException("Operation is not provided") + val body: String = jsonObject.getAsJsonObject(BODY_FIELD)?.toString() + ?: throw IllegalArgumentException("Body is not provided") + return operation to body + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt index d71879114..7f2120877 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt @@ -109,7 +109,7 @@ internal object MindboxEventManager { ) } - fun asyncOperation(context: Context, name: String, body: String) = + fun asyncOperation(context: Context, name: String, body: String): Unit = asyncOperation( context, Event( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt index 7739971dd..64f228513 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt @@ -89,6 +89,7 @@ class DataCollectorTest { params = mapOf("customKey" to "customValue"), inAppInsets = InAppInsets(left = 1, top = 2, right = 3, bottom = 4), gson = gson, + inAppId = "inapp-id", ) val actualPayload: String = dataCollector.get() val actualJson: JsonObject = JsonParser.parseString(actualPayload).asJsonObject @@ -145,6 +146,7 @@ class DataCollectorTest { params = mapOf("endpointId" to "overridden-endpoint"), inAppInsets = InAppInsets(), gson = gson, + inAppId = "inapp-id", ) val actualPayload: String = dataCollector.get() val actualJson: JsonObject = JsonParser.parseString(actualPayload).asJsonObject @@ -188,6 +190,7 @@ class DataCollectorTest { params = emptyMap(), inAppInsets = inAppInsets, gson = gson, + inAppId = "inapp-id", ) val actualPayload = dataCollector.get() val actualJson = JsonParser.parseString(actualPayload).asJsonObject diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt new file mode 100644 index 000000000..c3f3b106d --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt @@ -0,0 +1,165 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.app.Application +import cloud.mindbox.mobile_sdk.managers.MindboxEventManager +import cloud.mindbox.mobile_sdk.models.MindboxError +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +class WebViewOperationExecutorTest { + + private lateinit var executor: MindboxWebViewOperationExecutor + + @Before + fun onTestStart() { + executor = MindboxWebViewOperationExecutor() + mockkObject(MindboxEventManager) + } + + @After + fun onTestEnd() { + unmockkObject(MindboxEventManager) + } + + @Test + fun `executeAsyncOperation sends parsed operation and body to event manager`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home"}""", + ) + } + } + + @Test + fun `executeAsyncOperation throws when payload misses operation`() { + val context: Application = mockk() + val payload: String = """{"body":{"screen":"home"}}""" + try { + executor.executeAsyncOperation(context, payload) + fail("Expected IllegalArgumentException") + } catch (exception: IllegalArgumentException) { + assertEquals("Operation is not provided", exception.message) + } + verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } + } + + @Test + fun `executeAsyncOperation throws when payload is invalid json empty or null`() { + val context: Application = mockk() + val payloads: List = listOf("not-json", "", null) + payloads.forEach { payload: String? -> + try { + executor.executeAsyncOperation(context, payload) + fail("Expected exception for payload: $payload") + } catch (exception: Exception) { + // Expected: payload cannot be parsed to required JSON object. + } + } + verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } + } + + @Test + fun `executeSyncOperation returns response when event manager succeeds`() = runTest { + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + val expectedResponse: String = """{"result":"ok"}""" + every { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } answers { + val onSuccess: (String) -> Unit = arg(2) + onSuccess(expectedResponse) + } + val actualResponse: String = executor.executeSyncOperation(payload) + assertEquals(expectedResponse, actualResponse) + verify(exactly = 1) { + MindboxEventManager.syncOperation( + name = "OpenScreen", + bodyJson = """{"screen":"home"}""", + onSuccess = any(), + onError = any(), + ) + } + } + + @Test + fun `executeSyncOperation throws IllegalStateException when event manager returns error`() = runTest { + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + val expectedError: MindboxError = MindboxError.Unknown(Throwable("network failure")) + every { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } answers { + val onError: (MindboxError) -> Unit = arg(3) + onError(expectedError) + } + try { + executor.executeSyncOperation(payload) + fail("Expected IllegalStateException") + } catch (exception: IllegalStateException) { + assertEquals(expectedError.toJson(), exception.message) + } + } + + @Test + fun `executeSyncOperation throws when payload misses body`() = runTest { + val payload: String = """{"operation":"OpenScreen"}""" + try { + executor.executeSyncOperation(payload) + fail("Expected IllegalArgumentException") + } catch (exception: IllegalArgumentException) { + assertEquals("Body is not provided", exception.message) + } + verify(exactly = 0) { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } + } + + @Test + fun `executeSyncOperation throws when payload is invalid json empty or null`() = runTest { + val payloads: List = listOf("not-json", "", null) + payloads.forEach { payload: String? -> + try { + executor.executeSyncOperation(payload) + fail("Expected exception for payload: $payload") + } catch (exception: Exception) { + // Expected: payload cannot be parsed to required JSON object. + } + } + verify(exactly = 0) { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } + } +} From 4eb8a61b735dce02945a4d48ec8e501866cc93f2 Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:21:58 +0300 Subject: [PATCH 26/59] MOBILEWEBVIEW-5: support web layer --- .../mobile_sdk/di/modules/DataModule.kt | 6 +- .../mobile_sdk/di/modules/MindboxModule.kt | 1 + .../inapp/data/dto/BackgroundDto.kt | 3 + .../mobile_sdk/inapp/data/dto/FormBlankDto.kt | 14 -- .../mobile_sdk/inapp/data/dto/PayloadDto.kt | 13 -- .../WebViewParamsDeserializer.kt | 39 ++++ .../MobileConfigSerializationManagerImpl.kt | 16 +- .../data/managers/data_filler/DataManager.kt | 7 - .../inapp/data/mapper/InAppMapper.kt | 32 +-- .../data/validators/ModalWindowValidator.kt | 7 +- .../data/validators/SnackbarValidator.kt | 23 ++- .../data/validators/WebViewLayerValidator.kt | 36 ++++ .../InAppMessageViewDisplayerImpl.kt | 47 +---- .../mindbox/mobile_sdk/utils/Constants.kt | 2 +- .../WebViewParamsDeserializerTest.kt | 131 ++++++++++++ .../MobileConfigSerializationManagerTest.kt | 98 +++++++++ .../inapp/data/mapper/InAppMapperTest.kt | 101 +++++++++ .../validators/ModalWindowValidatorTest.kt | 55 +++++ .../data/validators/SnackbarValidatorTest.kt | 43 ++++ .../validators/WebViewLayerValidatorTest.kt | 93 +++++++++ .../domain/InAppProcessingManagerTest.kt | 47 ++++- .../InAppMessageViewDisplayerImplTest.kt | 192 ------------------ .../mindbox/mobile_sdk/models/InAppStub.kt | 13 ++ 23 files changed, 694 insertions(+), 325 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializer.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidator.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializerTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidatorTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index ef2645a60..6695ffa47 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -61,11 +61,14 @@ internal fun DataModule( override val modalWindowValidator: ModalWindowValidator by lazy { ModalWindowValidator( imageLayerValidator = imageLayerValidator, + webViewLayerValidator = webViewLayerValidator, elementValidator = modalElementValidator ) } override val imageLayerValidator: ImageLayerValidator get() = ImageLayerValidator() + override val webViewLayerValidator: WebViewLayerValidator + get() = WebViewLayerValidator() override val modalElementValidator: ModalElementValidator by lazy { ModalElementValidator( @@ -332,9 +335,6 @@ internal fun DataModule( ).registerSubtype( PayloadBlankDto.SnackBarBlankDto::class.java, PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME - ).registerSubtype( - PayloadBlankDto.WebViewBlankDto::class.java, - PayloadDto.WebViewDto.WEBVIEW_JSON_NAME ) ).registerTypeAdapterFactory( RuntimeTypeAdapterFactory diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index 98cb9d491..4d5f84995 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -90,6 +90,7 @@ internal interface DataModule : MindboxModule { val modalElementDtoDataFiller: ModalElementDtoDataFiller val modalWindowValidator: ModalWindowValidator val imageLayerValidator: ImageLayerValidator + val webViewLayerValidator: WebViewLayerValidator val modalElementValidator: ModalElementValidator val snackbarValidator: SnackbarValidator val closeButtonModalElementValidator: CloseButtonModalElementValidator diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/BackgroundDto.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/BackgroundDto.kt index 6898ffa48..81fde851b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/BackgroundDto.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/BackgroundDto.kt @@ -1,5 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.dto +import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.WebViewParamsDeserializer +import com.google.gson.annotations.JsonAdapter import com.google.gson.annotations.SerializedName internal data class BackgroundDto( @@ -68,6 +70,7 @@ internal data class BackgroundDto( @SerializedName("${"$"}type") val type: String?, @SerializedName("params") + @JsonAdapter(WebViewParamsDeserializer::class) val params: Map?, ) : LayerDto() { internal companion object { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/FormBlankDto.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/FormBlankDto.kt index e92b31539..71fddb49f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/FormBlankDto.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/FormBlankDto.kt @@ -67,20 +67,6 @@ internal sealed class PayloadBlankDto { val elements: List? ) } - - data class WebViewBlankDto( - @SerializedName("content") - val content: ContentBlankDto?, - @SerializedName("${"$"}type") - val type: String? - ) : PayloadBlankDto() { - internal data class ContentBlankDto( - @SerializedName("background") - val background: BackgroundBlankDto?, - @SerializedName("elements") - val elements: List? - ) - } } internal data class BackgroundBlankDto( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/PayloadDto.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/PayloadDto.kt index dee31da95..c8b7e5b11 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/PayloadDto.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/PayloadDto.kt @@ -1,6 +1,5 @@ package cloud.mindbox.mobile_sdk.inapp.data.dto -import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto.SnackbarDto.ContentDto import cloud.mindbox.mobile_sdk.isInRange import com.google.gson.annotations.SerializedName @@ -8,18 +7,6 @@ import com.google.gson.annotations.SerializedName * In-app types **/ internal sealed class PayloadDto { - - data class WebViewDto( - @SerializedName("${"$"}type") - val type: String?, - @SerializedName("content") - val content: ModalWindowDto.ContentDto?, - ) : PayloadDto() { - internal companion object { - const val WEBVIEW_JSON_NAME = "webview" - } - } - data class SnackbarDto( @SerializedName("content") val content: ContentDto?, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializer.kt new file mode 100644 index 000000000..9decedb76 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializer.kt @@ -0,0 +1,39 @@ +package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers + +import com.google.gson.Gson +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import java.lang.reflect.Type + +internal class WebViewParamsDeserializer : JsonDeserializer?> { + + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): Map? { + if (json.isJsonNull) return null + if (!json.isJsonObject) return emptyMap() + return json.asJsonObject.entrySet().mapNotNull { (key, value) -> + value.toParamString()?.let { key to it } + }.toMap() + } + + private fun JsonElement.toParamString(): String? { + if (isJsonNull) return null + return when { + isJsonPrimitive -> when { + asJsonPrimitive.isString -> asString + asJsonPrimitive.isNumber -> asNumber.toString() + asJsonPrimitive.isBoolean -> asBoolean.toString() + else -> asString + } + else -> GSON.toJson(this) + } + } + + private companion object { + private val GSON = Gson() + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/MobileConfigSerializationManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/MobileConfigSerializationManagerImpl.kt index 8d7ce6c5e..2dd127f13 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/MobileConfigSerializationManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/MobileConfigSerializationManagerImpl.kt @@ -151,19 +151,6 @@ internal class MobileConfigSerializationManagerImpl(private val gson: Gson) : variants = blankResult.getOrNull()?.variants?.filterNotNull() ?.map { payloadBlankDto -> when (payloadBlankDto) { - is PayloadBlankDto.WebViewBlankDto -> { - PayloadDto.WebViewDto( - content = PayloadDto.ModalWindowDto.ContentDto( - background = BackgroundDto( - layers = payloadBlankDto.content?.background?.layers?.mapNotNull { - deserializeToBackgroundLayersDto(it as JsonObject) - }), - elements = payloadBlankDto.content?.elements?.mapNotNull { - deserializeToElementDto(it) - } - ), type = PayloadDto.WebViewDto.WEBVIEW_JSON_NAME - ) - } is PayloadBlankDto.ModalWindowBlankDto -> { PayloadDto.ModalWindowDto( content = PayloadDto.ModalWindowDto.ContentDto( @@ -177,6 +164,7 @@ internal class MobileConfigSerializationManagerImpl(private val gson: Gson) : ), type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME ) } + is PayloadBlankDto.SnackBarBlankDto -> { PayloadDto.SnackbarDto( content = PayloadDto.SnackbarDto.ContentDto( @@ -199,7 +187,7 @@ internal class MobileConfigSerializationManagerImpl(private val gson: Gson) : top = payloadBlankDto.content?.position?.margin?.top ) ) - ), type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME + ), type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/data_filler/DataManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/data_filler/DataManager.kt index 047df3c26..870a79d68 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/data_filler/DataManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/data_filler/DataManager.kt @@ -11,13 +11,6 @@ internal class DataManager( ) { fun fillFormData(item: FormDto?): FormDto? = item?.copy(variants = item.variants?.filterNotNull()?.map { payloadDto -> when (payloadDto) { - is PayloadDto.WebViewDto -> { - payloadDto.copy( - content = payloadDto.content, - type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME - ) - } - is PayloadDto.ModalWindowDto -> { modalWindowDtoDataFiller.fillData(payloadDto) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt index be7b5bda1..60f617b59 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt @@ -78,7 +78,7 @@ internal class InAppMapper { ) } - private fun mapModalWindowLayers(layers: List?): List { + private fun mapBackgroundLayers(layers: List?): List { return layers?.map { layerDto -> when (layerDto) { is BackgroundDto.LayerDto.ImageLayerDto -> { @@ -244,28 +244,28 @@ internal class InAppMapper { form = Form( variants = inAppDto.form?.variants?.map { payloadDto -> when (payloadDto) { - is PayloadDto.WebViewDto -> { - InAppType.WebView( - inAppId = inAppDto.id, - type = PayloadDto.WebViewDto.WEBVIEW_JSON_NAME, - layers = mapModalWindowLayers(payloadDto.content?.background?.layers), - ) - } - is PayloadDto.ModalWindowDto -> { - InAppType.ModalWindow( - type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME, - layers = mapModalWindowLayers(payloadDto.content?.background?.layers), - inAppId = inAppDto.id, - elements = mapElements(payloadDto.content?.elements) - ) + val layers = mapBackgroundLayers(payloadDto.content?.background?.layers) + when (layers.firstOrNull()) { + is Layer.WebViewLayer -> InAppType.WebView( + inAppId = inAppDto.id, + type = BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME, + layers = layers, + ) + else -> InAppType.ModalWindow( + type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME, + layers = layers, + inAppId = inAppDto.id, + elements = mapElements(payloadDto.content?.elements) + ) + } } is PayloadDto.SnackbarDto -> { InAppType.Snackbar( inAppId = inAppDto.id, type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME, - layers = mapModalWindowLayers(payloadDto.content?.background?.layers), + layers = mapBackgroundLayers(payloadDto.content?.background?.layers), elements = mapElements(payloadDto.content?.elements), position = InAppType.Snackbar.Position( gravity = InAppType.Snackbar.Position.Gravity( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidator.kt index 39388a38a..1e5f83d94 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidator.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidator.kt @@ -6,6 +6,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI internal class ModalWindowValidator( private val imageLayerValidator: ImageLayerValidator, + private val webViewLayerValidator: WebViewLayerValidator, private val elementValidator: ModalElementValidator ) : Validator { @@ -27,7 +28,11 @@ internal class ModalWindowValidator( mindboxLogI("Finish checking image layer and it's validity = $rez") !rez } - else -> false + is BackgroundDto.LayerDto.WebViewLayerDto -> { + val rez = webViewLayerValidator.isValid(layerDto) + mindboxLogI("Finish checking webview layer and it's validity = $rez") + !rez + } } } if (invalidLayer != null) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidator.kt index fc8669c75..3b70df261 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidator.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidator.kt @@ -2,7 +2,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.validators import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto -import cloud.mindbox.mobile_sdk.logger.mindboxLogD +import cloud.mindbox.mobile_sdk.logger.mindboxLogI internal class SnackbarValidator( private val imageLayerValidator: ImageLayerValidator, @@ -10,41 +10,44 @@ internal class SnackbarValidator( ) : Validator { override fun isValid(item: PayloadDto.SnackbarDto?): Boolean { if (item?.type != PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME) { - mindboxLogD("InApp is not valid. Expected type is ${PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME}. Actual type = ${item?.type}") + mindboxLogI("InApp is not valid. Expected type is ${PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME}. Actual type = ${item?.type}") return false } val layers = item.content?.background?.layers?.filterNotNull() if (layers.isNullOrEmpty()) { - mindboxLogD("InApp is not valid. Layers should not be empty. Layers are = $layers") + mindboxLogI("InApp is not valid. Layers should not be empty. Layers are = $layers") return false } val invalidLayer = layers.find { layerDto -> when (layerDto) { is BackgroundDto.LayerDto.ImageLayerDto -> { - mindboxLogD("Start checking image layer") + mindboxLogI("Start checking image layer") val rez = imageLayerValidator.isValid(layerDto) - mindboxLogD("Finish checking image layer and it's validity = $rez") + mindboxLogI("Finish checking image layer and it's validity = $rez") !rez } - else -> false + else -> { + mindboxLogI("InApp is not valid. Snackbar supports only image layer, got ${layerDto.javaClass.simpleName}") + true + } } } if (invalidLayer != null) { - mindboxLogD("InApp is not valid. At least one layer is invalid") + mindboxLogI("InApp is not valid. At least one layer is invalid") return false } val isValidMargin = item.content.position.margin.isValidPosition() if (!isValidMargin) { - mindboxLogD("InApp has invalid margin") + mindboxLogI("InApp has invalid margin") return false } item.content.elements?.forEach { elementDto -> if (!elementValidator.isValid(elementDto)) { - mindboxLogD("InApp is not valid. At least one element is invalid") + mindboxLogI("InApp is not valid. At least one element is invalid") return false } } - mindboxLogD("Current inApp payload is valid") + mindboxLogI("Current inApp payload is valid") return true } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidator.kt new file mode 100644 index 000000000..b1c347942 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidator.kt @@ -0,0 +1,36 @@ +package cloud.mindbox.mobile_sdk.inapp.data.validators + +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.logger.mindboxLogW + +internal class WebViewLayerValidator : Validator { + + override fun isValid(item: BackgroundDto.LayerDto.WebViewLayerDto?): Boolean { + if (item == null) { + mindboxLogW("InApp is invalid. WebView layer is null") + return false + } + if (item.type != BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME) { + mindboxLogW( + "InApp is invalid. WebView layer is expected to have type = ${BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME}. " + + "Actual type = ${item.type}" + ) + return false + } + if (item.baseUrl.isNullOrBlank()) { + mindboxLogW( + "InApp is invalid. WebView layer is expected to have non-blank baseUrl. " + + "Actual baseUrl = ${item.baseUrl}" + ) + return false + } + if (item.contentUrl.isNullOrBlank()) { + mindboxLogW( + "InApp is invalid. WebView layer is expected to have non-blank contentUrl. " + + "Actual contentUrl = ${item.contentUrl}" + ) + return false + } + return true + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index af18833e5..1d6c50d45 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -4,12 +4,8 @@ import android.app.Activity import android.view.ViewGroup import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedDispatcherOwner -import androidx.annotation.VisibleForTesting import cloud.mindbox.mobile_sdk.addUnique import cloud.mindbox.mobile_sdk.di.mindboxInject -import cloud.mindbox.mobile_sdk.fromJson -import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto -import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks @@ -17,7 +13,6 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper -import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.* import cloud.mindbox.mobile_sdk.inapp.presentation.view.InAppViewHolder import cloud.mindbox.mobile_sdk.inapp.presentation.view.ModalWindowInAppViewHolder @@ -62,7 +57,6 @@ internal class InAppMessageViewDisplayerImpl( private var currentHolder: InAppViewHolder<*>? = null private var pausedHolder: InAppViewHolder<*>? = null private val mindboxNotificationManager by mindboxInject { mindboxNotificationManager } - private val gson by mindboxInject { gson } private val inAppFailureTracker: InAppFailureTracker by mindboxInject { inAppFailureTracker } private fun isUiPresent(): Boolean = currentActivity?.isFinishing?.not() ?: false @@ -131,50 +125,11 @@ internal class InAppMessageViewDisplayerImpl( currentHolder = null } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun getWebViewFromPayload(inAppType: InAppType, inAppId: String): InAppType.WebView? { - val layer = when (inAppType) { - is InAppType.Snackbar -> inAppType.layers.firstOrNull() - is InAppType.ModalWindow -> inAppType.layers.firstOrNull() - is InAppType.WebView -> return inAppType - } - if (layer !is Layer.ImageLayer) { - return null - } - - val payload = when (layer.action) { - is Layer.ImageLayer.Action.RedirectUrlAction -> layer.action.payload - is Layer.ImageLayer.Action.PushPermissionAction -> layer.action.payload - } - runCatching { - val layerDto = gson.fromJson(payload).getOrThrow() - requireNotNull(layerDto.type) - requireNotNull(layerDto.contentUrl) - requireNotNull(layerDto.baseUrl) - Layer.WebViewLayer( - baseUrl = layerDto.baseUrl, - contentUrl = layerDto.contentUrl, - type = layerDto.type, - params = layerDto.params ?: emptyMap() - ) - }.getOrNull()?.let { webView -> - return InAppType.WebView( - inAppId = inAppId, - type = PayloadDto.WebViewDto.WEBVIEW_JSON_NAME, - layers = listOf(webView), - ) - } - - return null - } - override fun tryShowInAppMessage( inAppType: InAppType, inAppActionCallbacks: InAppActionCallbacks ) { - val wrapper = getWebViewFromPayload(inAppType, inAppType.inAppId)?.let { - InAppTypeWrapper(it, inAppActionCallbacks) - } ?: InAppTypeWrapper(inAppType, inAppActionCallbacks) + val wrapper = InAppTypeWrapper(inAppType, inAppActionCallbacks) if (isUiPresent() && currentHolder == null && pausedHolder == null) { val duration = Stopwatch.track(Stopwatch.INIT_SDK) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt index d716d7008..983bfe155 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt @@ -1,7 +1,7 @@ package cloud.mindbox.mobile_sdk.utils internal object Constants { - internal const val SDK_VERSION_NUMERIC = 11 + internal const val SDK_VERSION_NUMERIC = 12 internal const val TYPE_JSON_NAME = "\$type" internal const val POST_NOTIFICATION = "android.permission.POST_NOTIFICATIONS" internal const val NOTIFICATION_SETTINGS = "android.settings.APP_NOTIFICATION_SETTINGS" diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializerTest.kt new file mode 100644 index 000000000..2179898bc --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializerTest.kt @@ -0,0 +1,131 @@ +package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers + +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +internal class WebViewParamsDeserializerTest { + + private lateinit var gson: Gson + + @Before + fun setUp() { + gson = Gson() + } + + @Test + fun `deserialize converts all values to string`() { + val nestedObject = JsonObject().apply { addProperty("nested", "value") } + val json = JsonObject().apply { + addProperty("formId", "73379") + addProperty("validKey", "validValue") + addProperty("numberKey", 123) + add("objectKey", nestedObject) + add("nullKey", JsonNull.INSTANCE) + } + val webViewLayerJson = createWebViewLayerJson( + baseUrl = "https://inapp.local", + contentUrl = "https://api.example.com", + params = json + ) + val result = gson.fromJson(webViewLayerJson, BackgroundDto.LayerDto.WebViewLayerDto::class.java) + assertEquals("73379", result.params!!["formId"]) + assertEquals("validValue", result.params["validKey"]) + assertEquals("123", result.params["numberKey"]) + assertEquals("{\"nested\":\"value\"}", result.params["objectKey"]) + assertFalse(result.params.containsKey("nullKey")) + } + + @Test + fun `deserialize returns null when params is null`() { + val webViewLayerJson = JsonObject().apply { + addProperty("baseUrl", "https://inapp.local") + addProperty("contentUrl", "https://api.example.com") + addProperty("\$type", "webview") + add("params", JsonNull.INSTANCE) + } + val result = gson.fromJson(webViewLayerJson, BackgroundDto.LayerDto.WebViewLayerDto::class.java) + assertNull(result.params) + } + + @Test + fun `deserialize returns empty map when params is not object`() { + val webViewLayerJson = JsonObject().apply { + addProperty("baseUrl", "https://inapp.local") + addProperty("contentUrl", "https://api.example.com") + addProperty("\$type", "webview") + add("params", JsonArray().apply { add("notAnObject") }) + } + val result = gson.fromJson(webViewLayerJson, BackgroundDto.LayerDto.WebViewLayerDto::class.java) + assertTrue(result.params?.isEmpty() == true) + } + + @Test + fun `deserialize converts number and boolean primitive to string`() { + val json = JsonObject().apply { + addProperty("stringVal", "ok") + addProperty("intVal", 42) + addProperty("doubleVal", 3.14) + addProperty("boolVal", true) + } + val webViewLayerJson = createWebViewLayerJson( + baseUrl = "https://inapp.local", + contentUrl = "https://api.example.com", + params = json + ) + val result = gson.fromJson(webViewLayerJson, BackgroundDto.LayerDto.WebViewLayerDto::class.java) + assertEquals("ok", result.params!!["stringVal"]) + assertEquals("42", result.params["intVal"]) + assertEquals("3.14", result.params["doubleVal"]) + assertEquals("true", result.params["boolVal"]) + } + + @Test + fun `deserialize empty object returns empty map`() { + val webViewLayerJson = createWebViewLayerJson( + baseUrl = "https://inapp.local", + contentUrl = "https://api.example.com", + params = JsonObject() + ) + val result = gson.fromJson(webViewLayerJson, BackgroundDto.LayerDto.WebViewLayerDto::class.java) + assertTrue(result.params?.isEmpty() == true) + } + + @Test + fun `deserialize preserves all string values`() { + val json = JsonObject().apply { + addProperty("key1", "value1") + addProperty("key2", "") + addProperty("key3", "value3") + } + val webViewLayerJson = createWebViewLayerJson( + baseUrl = "https://inapp.local", + contentUrl = "https://api.example.com", + params = json + ) + val result = gson.fromJson(webViewLayerJson, BackgroundDto.LayerDto.WebViewLayerDto::class.java) + assertEquals( + mapOf("key1" to "value1", "key2" to "", "key3" to "value3"), + result.params + ) + } + + private fun createWebViewLayerJson( + baseUrl: String, + contentUrl: String, + params: JsonObject + ): JsonObject = JsonObject().apply { + addProperty("baseUrl", baseUrl) + addProperty("contentUrl", contentUrl) + addProperty("\$type", "webview") + add("params", params) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/MobileConfigSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/MobileConfigSerializationManagerTest.kt index 3f8ffc728..f0dc3fb56 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/MobileConfigSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/MobileConfigSerializationManagerTest.kt @@ -1,6 +1,8 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers.serialization import cloud.mindbox.mobile_sdk.di.modules.DataModule +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationManagerImpl import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager import cloud.mindbox.mobile_sdk.models.InAppStub @@ -16,6 +18,8 @@ import io.mockk.impl.annotations.MockK import io.mockk.junit4.MockKRule import io.mockk.mockk import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Before import org.junit.Rule @@ -480,6 +484,100 @@ internal class MobileConfigSerializationManagerTest { assertEquals(expectedResult, actualResult) } + @Test + fun `deserialize to modal window inApp form dto with webview layer success`() { + val baseUrl = "https://inapp.local/popup" + val contentUrl = "https://inapp-dev.html" + val formId = "73379" + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = baseUrl, + contentUrl = contentUrl, + type = "webview", + params = mapOf("formId" to formId) + ) + val expectedResult = InAppStub.getFormDto().copy( + variants = listOf( + InAppStub.getModalWindowDto().copy( + type = "modal", + content = InAppStub.getModalWindowContentDto().copy( + background = InAppStub.getBackgroundDto().copy( + layers = listOf(webViewLayerDto) + ), + elements = null + ) + ) + ) + ) + val actualResult = mobileConfigSerializationManager.deserializeToInAppFormDto(JsonObject().apply { + add("variants", JsonArray().apply { + val variantObject = JsonObject().apply { + addProperty("${"$"}type", "modal") + add("content", JsonObject().apply { + add("background", JsonObject().apply { + add("layers", JsonArray().apply { + val webViewLayerObject = JsonObject().apply { + addProperty("${"$"}type", "webview") + addProperty("baseUrl", baseUrl) + addProperty("contentUrl", contentUrl) + add("params", JsonObject().apply { + addProperty("formId", formId) + }) + } + add(webViewLayerObject) + }) + }) + add("elements", com.google.gson.JsonNull.INSTANCE) + }) + } + add(variantObject) + }) + }) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `deserialize webview layer params converts all values to string`() { + val baseUrl = "https://inapp.local/popup" + val contentUrl = "https://inapp-dev.html" + val actualResult = mobileConfigSerializationManager.deserializeToInAppFormDto(JsonObject().apply { + add("variants", JsonArray().apply { + val variantObject = JsonObject().apply { + addProperty("${"$"}type", "modal") + add("content", JsonObject().apply { + add("background", JsonObject().apply { + add("layers", JsonArray().apply { + val webViewLayerObject = JsonObject().apply { + addProperty("${"$"}type", "webview") + addProperty("baseUrl", baseUrl) + addProperty("contentUrl", contentUrl) + add("params", JsonObject().apply { + addProperty("formId", "73379") + addProperty("validKey", "validValue") + addProperty("numberKey", 123) + add("objectKey", JsonObject().apply { addProperty("nested", "value") }) + add("nullKey", com.google.gson.JsonNull.INSTANCE) + }) + } + add(webViewLayerObject) + }) + }) + add("elements", JsonArray()) + }) + } + add(variantObject) + }) + }) + val layers = actualResult?.variants?.firstOrNull() + ?.let { it as? PayloadDto.ModalWindowDto }?.content?.background?.layers + val webViewLayer = layers?.firstOrNull() as? BackgroundDto.LayerDto.WebViewLayerDto + assertNotNull(webViewLayer) + assertEquals("73379", webViewLayer?.params!!["formId"]) + assertEquals("validValue", webViewLayer.params["validKey"]) + assertEquals("123", webViewLayer.params["numberKey"]) + assertEquals("{\"nested\":\"value\"}", webViewLayer.params["objectKey"]) + assertFalse(webViewLayer.params.containsKey("nullKey")) + } + @Test fun `deserialize to inApp formDto invalid json object`() { assertNull(mobileConfigSerializationManager.deserializeToInAppFormDto(JsonObject())) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapperTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapperTest.kt index 850a0eafc..b6a1c72ae 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapperTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapperTest.kt @@ -1,8 +1,14 @@ package cloud.mindbox.mobile_sdk.inapp.data.mapper +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.domain.models.TreeTargeting +import cloud.mindbox.mobile_sdk.models.InAppStub import cloud.mindbox.mobile_sdk.models.TimeSpan import cloud.mindbox.mobile_sdk.models.TreeTargetingDto +import cloud.mindbox.mobile_sdk.models.operation.response.FormDto import cloud.mindbox.mobile_sdk.models.operation.response.FrequencyDto import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse import cloud.mindbox.mobile_sdk.models.operation.response.InAppDto @@ -213,4 +219,99 @@ class InAppMapperTest { ) assertNull(result.inApps.first().delayTime) } + + @Test + fun `mapToInAppConfig maps ModalWindowDto with webview layer to InAppType WebView`() { + val mapper = InAppMapper() + val inAppId = "webview-inapp-id" + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://inapp-dev.html", + type = BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME, + params = mapOf("formId" to "73379") + ) + val modalWindowDto = PayloadDto.ModalWindowDto( + content = PayloadDto.ModalWindowDto.ContentDto( + background = InAppStub.getBackgroundDto().copy( + layers = listOf(webViewLayerDto) + ), + elements = null + ), + type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME + ) + val result = mapper.mapToInAppConfig( + InAppConfigResponse( + inApps = listOf( + InAppDto( + id = inAppId, + isPriority = false, + delayTime = null, + frequency = FrequencyDto.FrequencyOnceDto( + type = "once", + kind = "lifetime", + ), + sdkVersion = null, + targeting = TreeTargetingDto.TrueNodeDto(type = ""), + form = FormDto(variants = listOf(modalWindowDto)) + ) + ), + monitoring = null, + abtests = null, + settings = null, + ) + ) + val inApp = result.inApps.first() + assertTrue(inApp.form.variants.first() is InAppType.WebView) + val webView = inApp.form.variants.first() as InAppType.WebView + assertEquals(inAppId, webView.inAppId) + assertEquals(BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME, webView.type) + assertEquals(1, webView.layers.size) + assertTrue(webView.layers.first() is Layer.WebViewLayer) + val layer = webView.layers.first() as Layer.WebViewLayer + assertEquals("https://inapp.local/popup", layer.baseUrl) + assertEquals("https://inapp-dev.html", layer.contentUrl) + assertEquals(mapOf("formId" to "73379"), layer.params) + } + + @Test + fun `mapToInAppConfig maps ModalWindowDto with image layer to InAppType ModalWindow`() { + val mapper = InAppMapper() + val inAppId = "modal-inapp-id" + val modalWindowDto = InAppStub.getModalWindowDto().copy( + content = InAppStub.getModalWindowContentDto().copy( + background = InAppStub.getBackgroundDto().copy( + layers = listOf(InAppStub.getImageLayerDto()) + ), + elements = emptyList() + ) + ) + val result = mapper.mapToInAppConfig( + InAppConfigResponse( + inApps = listOf( + InAppDto( + id = inAppId, + isPriority = false, + delayTime = null, + frequency = FrequencyDto.FrequencyOnceDto( + type = "once", + kind = "lifetime", + ), + sdkVersion = null, + targeting = TreeTargetingDto.TrueNodeDto(type = ""), + form = FormDto(variants = listOf(modalWindowDto)) + ) + ), + monitoring = null, + abtests = null, + settings = null, + ) + ) + val inApp = result.inApps.first() + assertTrue(inApp.form.variants.first() is InAppType.ModalWindow) + val modalWindow = inApp.form.variants.first() as InAppType.ModalWindow + assertEquals(inAppId, modalWindow.inAppId) + assertEquals(PayloadDto.ModalWindowDto.MODAL_JSON_NAME, modalWindow.type) + assertEquals(1, modalWindow.layers.size) + assertTrue(modalWindow.layers.first() is Layer.ImageLayer) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidatorTest.kt index bb367badd..e1ada3fa5 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidatorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidatorTest.kt @@ -1,5 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.validators +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto import cloud.mindbox.mobile_sdk.models.InAppStub import io.mockk.every import io.mockk.impl.annotations.InjectMockKs @@ -18,6 +20,9 @@ internal class ModalWindowValidatorTest { @MockK private lateinit var imageLayerValidator: ImageLayerValidator + @MockK + private lateinit var webViewLayerValidator: WebViewLayerValidator + @MockK private lateinit var elementValidator: ModalElementValidator @@ -73,4 +78,54 @@ internal class ModalWindowValidatorTest { ) assertFalse(modalWindowValidator.isValid(modalWindowDto)) } + + @Test + fun `test isValid returns true when webview layer is valid`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://inapp-dev.html", + type = "webview", + params = mapOf("formId" to "73379") + ) + val modalWindowDto = PayloadDto.ModalWindowDto( + content = PayloadDto.ModalWindowDto.ContentDto( + background = InAppStub.getBackgroundDto().copy( + layers = listOf(webViewLayerDto) + ), + elements = null + ), + type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME + ) + + every { + webViewLayerValidator.isValid(any()) + } returns true + + assertTrue(modalWindowValidator.isValid(modalWindowDto)) + } + + @Test + fun `test isValid returns false when webview layer is invalid`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = null, + contentUrl = "https://inapp-dev.html", + type = "webview", + params = null + ) + val modalWindowDto = PayloadDto.ModalWindowDto( + content = PayloadDto.ModalWindowDto.ContentDto( + background = InAppStub.getBackgroundDto().copy( + layers = listOf(webViewLayerDto) + ), + elements = null + ), + type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME + ) + + every { + webViewLayerValidator.isValid(any()) + } returns false + + assertFalse(modalWindowValidator.isValid(modalWindowDto)) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidatorTest.kt index d090569c7..4275f4235 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidatorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidatorTest.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.data.validators +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto import cloud.mindbox.mobile_sdk.models.InAppStub import cloud.mindbox.mobile_sdk.models.PayloadDtoStub @@ -296,6 +297,48 @@ internal class SnackbarValidatorTest { assertFalse(rez) } + @Test + fun `validate snackbar returns false when layer is webview`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://api.example.com/inapp.html", + type = "webview", + params = mapOf("formId" to "73379") + ) + val testItem = InAppStub.getSnackbarDto().copy( + type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME, + content = InAppStub.getSnackbarContentDto().copy( + background = InAppStub.getBackgroundDto().copy( + layers = listOf(webViewLayerDto) + ), + elements = listOf( + InAppStub.getCloseButtonElementDto().copy( + color = null, + lineWidth = null, + position = null, + size = null, + type = null + ) + ), + position = PayloadDtoStub.getSnackbarPositionDto().copy( + gravity = PayloadDtoStub.getSnackbarGravityDto().copy( + horizontal = null, + vertical = null + ), + margin = PayloadDtoStub.getSnackbarMarginDto().copy( + bottom = 1.0, + kind = "dp", + left = 1.0, + right = 1.0, + top = 1.0 + ) + ) + ) + ) + val rez = snackbarValidator.isValid(testItem) + assertFalse(rez) + } + @Test fun `validate snackbar success`() { val testItem = InAppStub.getSnackbarDto().copy( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidatorTest.kt new file mode 100644 index 000000000..445c82ce7 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidatorTest.kt @@ -0,0 +1,93 @@ +package cloud.mindbox.mobile_sdk.inapp.data.validators + +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class WebViewLayerValidatorTest { + + private val webViewLayerValidator = WebViewLayerValidator() + + @Test + fun `isValid returns true for valid WebViewLayerDto`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://inapp-dev.html", + type = "webview", + params = mapOf("formId" to "73379") + ) + assertTrue(webViewLayerValidator.isValid(webViewLayerDto)) + } + + @Test + fun `isValid returns true for valid WebViewLayerDto with empty params`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://inapp-dev.html", + type = "webview", + params = null + ) + assertTrue(webViewLayerValidator.isValid(webViewLayerDto)) + } + + @Test + fun `isValid returns false when baseUrl is null`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = null, + contentUrl = "https://inapp-dev.html", + type = "webview", + params = null + ) + assertFalse(webViewLayerValidator.isValid(webViewLayerDto)) + } + + @Test + fun `isValid returns false when baseUrl is blank`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = " ", + contentUrl = "https://inapp-dev.html", + type = "webview", + params = null + ) + assertFalse(webViewLayerValidator.isValid(webViewLayerDto)) + } + + @Test + fun `isValid returns false when contentUrl is null`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = null, + type = "webview", + params = null + ) + assertFalse(webViewLayerValidator.isValid(webViewLayerDto)) + } + + @Test + fun `isValid returns false when type is not webview`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://inapp-dev.html", + type = "image", + params = null + ) + assertFalse(webViewLayerValidator.isValid(webViewLayerDto)) + } + + @Test + fun `isValid returns false when item is null`() { + assertFalse(webViewLayerValidator.isValid(null)) + } + + @Test + fun `isValid returns false when contentUrl is blank`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = " ", + type = "webview", + params = null + ) + assertFalse(webViewLayerValidator.isValid(webViewLayerDto)) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt index 9d44cdb83..54db2c0bb 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt @@ -219,7 +219,7 @@ internal class InAppProcessingManagerTest { id = validId, targeting = InAppStub.getTargetingTrueNode(), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = validId ) @@ -241,7 +241,7 @@ internal class InAppProcessingManagerTest { id = validId, targeting = InAppStub.getTargetingTrueNode(), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = validId ) @@ -254,6 +254,37 @@ internal class InAppProcessingManagerTest { assertEquals(expectedResult, actualResult) } + @Test + fun `choose inApp to show chooses WebView inApp when targeting matches`() = runTest { + val validId = "webview-valid-id" + val expectedResult = InAppStub.getInApp().copy( + id = validId, + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getWebView().copy(inAppId = validId)) + ) + ) + val actualResult = inAppProcessingManager.chooseInAppToShow( + listOf( + InAppStub.getInApp().copy( + id = "123", + targeting = InAppStub.getTargetingRegionNode().copy( + type = "", kind = Kind.POSITIVE, ids = listOf("otherRegionId") + ) + ), + InAppStub.getInApp().copy( + id = validId, + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getWebView().copy(inAppId = validId)) + ) + ), + ), + event + ) + assertEquals(expectedResult, actualResult) + } + @Test fun `choose inApp to show has no choosable inApps`() = runTest { assertNull( @@ -330,7 +361,7 @@ internal class InAppProcessingManagerTest { id = validId, targeting = InAppStub.getTargetingTrueNode(), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = validId ) @@ -348,7 +379,7 @@ internal class InAppProcessingManagerTest { id = validId, targeting = InAppStub.getTargetingTrueNode(), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = validId ) @@ -381,7 +412,7 @@ internal class InAppProcessingManagerTest { id = validId, targeting = InAppStub.getTargetingTrueNode(), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = validId ) @@ -426,7 +457,7 @@ internal class InAppProcessingManagerTest { setupTestGeoRepositoryForErrorScenario() val testInApp = InAppStub.getInApp().copy( targeting = TreeTargeting.UnionNode( - type = TreeTargetingDto.UnionNodeDto.Companion.OR_JSON_NAME, + type = TreeTargetingDto.UnionNodeDto.OR_JSON_NAME, nodes = listOf( InAppStub.getTargetingCountryNode().copy(kind = Kind.NEGATIVE), InAppStub.getTargetingTrueNode() @@ -446,7 +477,7 @@ internal class InAppProcessingManagerTest { setupTestSegmentationRepositoryForErrorScenario() val testInApp = InAppStub.getInApp().copy( targeting = TreeTargeting.UnionNode( - type = TreeTargetingDto.UnionNodeDto.Companion.OR_JSON_NAME, + type = TreeTargetingDto.UnionNodeDto.OR_JSON_NAME, nodes = listOf( InAppStub.getTargetingSegmentNode().copy(kind = Kind.NEGATIVE), InAppStub.getTargetingTrueNode() @@ -464,7 +495,7 @@ internal class InAppProcessingManagerTest { val testInApp = InAppStub.getInApp().copy( targeting = TreeTargeting.UnionNode( - type = TreeTargetingDto.UnionNodeDto.Companion.OR_JSON_NAME, + type = TreeTargetingDto.UnionNodeDto.OR_JSON_NAME, nodes = listOf( spyk(InAppStub.getTargetingViewProductSegmentNode().copy(kind = Kind.NEGATIVE)) { coEvery { fetchTargetingInfo(any()) } throws ProductSegmentationError(VolleyError()) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt index 57bd95ead..548652e78 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt @@ -1,19 +1,13 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import cloud.mindbox.mobile_sdk.di.MindboxDI -import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto -import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType -import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import com.google.gson.Gson import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull import org.junit.Before -import org.junit.Test internal class InAppMessageViewDisplayerImplTest { @@ -32,190 +26,4 @@ internal class InAppMessageViewDisplayerImplTest { fun tearDown() { unmockkAll() } - - @Test - fun `getWebViewFromPayload returns WebView for valid redirectUrl payload`() { - val payload = """ - {"${'$'}type":"webview","baseUrl":"https://base","contentUrl":"/content","params":{"a":"b"}} - """.trimIndent() - val imageLayer = Layer.ImageLayer( - action = Layer.ImageLayer.Action.RedirectUrlAction(url = "https://example", payload = payload), - source = Layer.ImageLayer.Source.UrlSource(url = "https://img") - ) - val inApp = InAppType.Snackbar( - inAppId = "inapp-1", - type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME, - layers = listOf(imageLayer), - elements = emptyList(), - position = InAppType.Snackbar.Position( - gravity = InAppType.Snackbar.Position.Gravity( - horizontal = InAppType.Snackbar.Position.Gravity.HorizontalGravity.CENTER, - vertical = InAppType.Snackbar.Position.Gravity.VerticalGravity.TOP - ), - margin = InAppType.Snackbar.Position.Margin( - kind = InAppType.Snackbar.Position.Margin.MarginKind.DP, - top = 0, - left = 0, - right = 0, - bottom = 0 - ) - ) - ) - val expected = InAppType.WebView( - inAppId = "inapp-1", - type = PayloadDto.WebViewDto.WEBVIEW_JSON_NAME, - layers = listOf( - Layer.WebViewLayer( - baseUrl = "https://base", - contentUrl = "/content", - type = "webview", - params = mapOf("a" to "b") - ) - ) - ) - val actual = requireNotNull(displayer.getWebViewFromPayload(inApp, inApp.inAppId)) - assertEquals(expected, actual) - } - - @Test - fun `getWebViewFromPayload returns WebView for valid pushPermission payload`() { - val payload = """ - {"${'$'}type":"webview","baseUrl":"https://b","contentUrl":"/c"} - """.trimIndent() - val imageLayer = Layer.ImageLayer( - action = Layer.ImageLayer.Action.PushPermissionAction(payload = payload), - source = Layer.ImageLayer.Source.UrlSource(url = "https://img") - ) - val inApp = InAppType.ModalWindow( - inAppId = "inapp-2", - type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME, - layers = listOf(imageLayer), - elements = emptyList() - ) - val expected = InAppType.WebView( - inAppId = "inapp-2", - type = PayloadDto.WebViewDto.WEBVIEW_JSON_NAME, - layers = listOf( - Layer.WebViewLayer( - baseUrl = "https://b", - contentUrl = "/c", - type = "webview", - params = emptyMap() - ) - ) - ) - val actual = requireNotNull(displayer.getWebViewFromPayload(inApp, inApp.inAppId)) - assertEquals(expected, actual) - } - - @Test - fun `getWebViewFromPayload returns null for empty json object`() { - val payload = "{}" - val imageLayer = Layer.ImageLayer( - action = Layer.ImageLayer.Action.RedirectUrlAction(url = "https://example", payload = payload), - source = Layer.ImageLayer.Source.UrlSource(url = "https://img") - ) - val inApp = InAppType.Snackbar( - inAppId = "inapp-3", - type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME, - layers = listOf(imageLayer), - elements = emptyList(), - position = InAppType.Snackbar.Position( - gravity = InAppType.Snackbar.Position.Gravity( - horizontal = InAppType.Snackbar.Position.Gravity.HorizontalGravity.CENTER, - vertical = InAppType.Snackbar.Position.Gravity.VerticalGravity.TOP - ), - margin = InAppType.Snackbar.Position.Margin( - kind = InAppType.Snackbar.Position.Margin.MarginKind.DP, - top = 0, - left = 0, - right = 0, - bottom = 0 - ) - ) - ) - val actual = displayer.getWebViewFromPayload(inApp, inApp.inAppId) - assertNull(actual) - } - - @Test - fun `getWebViewFromPayload returns null for wrong json object`() { - val payload = """ - {"type":"1","baseUrl":"b","contentUrl":"c"} - """.trimIndent() - val imageLayer = Layer.ImageLayer( - action = Layer.ImageLayer.Action.RedirectUrlAction(url = "https://example", payload = payload), - source = Layer.ImageLayer.Source.UrlSource(url = "https://img") - ) - val inApp = InAppType.Snackbar( - inAppId = "inapp-4", - type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME, - layers = listOf(imageLayer), - elements = emptyList(), - position = InAppType.Snackbar.Position( - gravity = InAppType.Snackbar.Position.Gravity( - horizontal = InAppType.Snackbar.Position.Gravity.HorizontalGravity.CENTER, - vertical = InAppType.Snackbar.Position.Gravity.VerticalGravity.TOP - ), - margin = InAppType.Snackbar.Position.Margin( - kind = InAppType.Snackbar.Position.Margin.MarginKind.DP, - top = 0, - left = 0, - right = 0, - bottom = 0 - ) - ) - ) - val actual = displayer.getWebViewFromPayload(inApp, inApp.inAppId) - assertNull(actual) - } - - @Test - fun `getWebViewFromPayload returns null for missing fields`() { - val payload = """ - {"${'$'}type":"webview","baseUrl":"https://base"} - """.trimIndent() - val imageLayer = Layer.ImageLayer( - action = Layer.ImageLayer.Action.PushPermissionAction(payload = payload), - source = Layer.ImageLayer.Source.UrlSource(url = "https://img") - ) - val inApp = InAppType.ModalWindow( - inAppId = "inapp-4", - type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME, - layers = listOf(imageLayer), - elements = emptyList() - ) - val actual = displayer.getWebViewFromPayload(inApp, inApp.inAppId) - assertNull(actual) - } - - @Test - fun `getWebViewFromPayload returns null for invalid json`() { - val payload = "not a json" - val imageLayer = Layer.ImageLayer( - action = Layer.ImageLayer.Action.RedirectUrlAction(url = "https://example", payload = payload), - source = Layer.ImageLayer.Source.UrlSource(url = "https://img") - ) - val inApp = InAppType.Snackbar( - inAppId = "inapp-5", - type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME, - layers = listOf(imageLayer), - elements = emptyList(), - position = InAppType.Snackbar.Position( - gravity = InAppType.Snackbar.Position.Gravity( - horizontal = InAppType.Snackbar.Position.Gravity.HorizontalGravity.CENTER, - vertical = InAppType.Snackbar.Position.Gravity.VerticalGravity.TOP - ), - margin = InAppType.Snackbar.Position.Margin( - kind = InAppType.Snackbar.Position.Margin.MarginKind.DP, - top = 0, - left = 0, - right = 0, - bottom = 0 - ) - ) - ) - val actual = displayer.getWebViewFromPayload(inApp, inApp.inAppId) - assertNull(actual) - } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt index 6b5482026..066ac53b6 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt @@ -311,6 +311,19 @@ internal class InAppStub { type = "", inAppId = "", layers = listOf(), elements = listOf() ) + fun getWebView() = InAppType.WebView( + inAppId = "", + type = "webview", + layers = listOf( + Layer.WebViewLayer( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://inapp-dev.html", + type = "webview", + params = mapOf("formId" to "73379") + ) + ) + ) + val viewProductNode: ViewProductNode = ViewProductNode( type = "", kind = KindSubstring.SUBSTRING, value = "" ) From 1343f3e9625d1fd85f447c967987fdb81ef9d672 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 17 Feb 2026 18:34:35 +0300 Subject: [PATCH 27/59] MOBILEWEBVIEW-46: Follow code review --- .../view/WebViewOperationExecutor.kt | 3 +-- .../view/WebViewOperationExecutorTest.kt | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt index 44c0bb61b..80812e9f1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt @@ -55,8 +55,7 @@ internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { } private fun parseOperationRequest(payload: String?): Pair { - val jsonObject: JsonObject = JsonParser.parseString(payload).getAsJsonObject() - ?: throw IllegalArgumentException("Payload is not a valid JSON") + val jsonObject: JsonObject = JsonParser.parseString(payload).asJsonObject val operation: String = jsonObject.getAsJsonPrimitive(OPERATION_FIELD)?.asString ?: throw IllegalArgumentException("Operation is not provided") val body: String = jsonObject.getAsJsonObject(BODY_FIELD)?.toString() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt index c3f3b106d..ee49e2892 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt @@ -3,11 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.app.Application import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.MindboxError -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.unmockkObject -import io.mockk.verify +import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -58,6 +54,19 @@ class WebViewOperationExecutorTest { verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } } + @Test + fun `executeAsyncOperation throws when payload misses body`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen"}""" + try { + executor.executeAsyncOperation(context, payload) + fail("Expected IllegalArgumentException") + } catch (exception: IllegalArgumentException) { + assertEquals("Body is not provided", exception.message) + } + verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } + } + @Test fun `executeAsyncOperation throws when payload is invalid json empty or null`() { val context: Application = mockk() From bb6d08e7db7870d7d6e5d8766352f8134cf87426 Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:46:25 +0300 Subject: [PATCH 28/59] MOBILEWEBVIEW-10: fix bugs and delete sending error when no internet --- .../data/managers/InAppFailureTrackerImpl.kt | 3 +- .../domain/InAppProcessingManagerImpl.kt | 45 +-- .../extensions/TrackingFailureExtension.kt | 32 ++- .../view/WebViewInappViewHolder.kt | 38 ++- .../operation/request/InAppShowFailure.kt | 12 +- .../domain/InAppProcessingManagerTest.kt | 267 ++++++++++++++++++ .../TrackingFailureExtensionTest.kt | 122 ++++++++ 7 files changed, 472 insertions(+), 47 deletions(-) create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt index df92a73ea..8b9fd6a3b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt @@ -27,11 +27,12 @@ internal class InAppFailureTrackerImpl( } private fun sendFailures() { + if (failures.isEmpty()) return if (!featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) { mindboxLogI("Feature $SEND_INAPP_SHOW_ERROR_FEATURE is off. Skip send failures") return } - if (failures.isNotEmpty()) inAppRepository.sendInAppShowFailure(failures.toList()) + inAppRepository.sendInAppShowFailure(failures.toList()) failures.clear() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt index f0ef6db2c..421e25a2b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt @@ -6,6 +6,7 @@ import cloud.mindbox.mobile_sdk.getImageUrl import cloud.mindbox.mobile_sdk.inapp.domain.extensions.asVolleyError import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getProductFromTargetingData import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getVolleyErrorDetails +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackImageDownloadError import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackTargetingError import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker @@ -43,7 +44,7 @@ internal class InAppProcessingManagerImpl( var isTargetingErrorOccurred = false var isInAppContentFetched: Boolean? = null var targetingCheck = false - var imageFailureDetails: String? = null + var imageFetchError: Throwable? = null withContext(Dispatchers.IO) { val imageJob = launch(start = CoroutineStart.LAZY) { @@ -62,7 +63,7 @@ internal class InAppProcessingManagerImpl( is InAppContentFetchingError -> { isInAppContentFetched = false - imageFailureDetails = throwable.message + "\n Url is ${inApp.form.variants.first().getImageUrl()}" + imageFetchError = throwable } } } @@ -126,11 +127,13 @@ internal class InAppProcessingManagerImpl( if (isTargetingErrorOccurred) return chooseInAppToShow(inApps, triggerEvent) trackTargetingErrorIfAny(inApp, data) if (isInAppContentFetched == false && targetingCheck) { - inAppFailureTracker.collectFailure( - inAppId = inApp.id, - failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, - errorDetails = imageFailureDetails - ) + imageFetchError?.takeIf { it.shouldTrackImageDownloadError() }?.let { error -> + inAppFailureTracker.collectFailure( + inAppId = inApp.id, + failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, + errorDetails = error.message + "\n Url is ${inApp.form.variants.first().getImageUrl()}" + ) + } } if (isInAppContentFetched == false) { mindboxLogD("Skipping inApp with id = ${inApp.id} due to content fetching error.") @@ -211,23 +214,27 @@ internal class InAppProcessingManagerImpl( when { inApp.targeting.hasSegmentationNode() && inAppSegmentationRepository.getCustomerSegmentationFetched() == CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR -> { - inAppFailureTracker.collectFailure( - inAppId = inApp.id, - failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, - errorDetails = inAppTargetingErrorRepository.getError(TargetingErrorKey.CustomerSegmentation) - ?: "Unknown segmentation error" - ) + inAppTargetingErrorRepository.getError(TargetingErrorKey.CustomerSegmentation) + ?.let { errorDetails -> + inAppFailureTracker.collectFailure( + inAppId = inApp.id, + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = errorDetails + ) + } return } inApp.targeting.hasGeoNode() && inAppGeoRepository.getGeoFetchedStatus() == GeoFetchStatus.GEO_FETCH_ERROR -> { - inAppFailureTracker.collectFailure( - inAppId = inApp.id, - failureReason = FailureReason.GEO_TARGETING_FAILED, - errorDetails = inAppTargetingErrorRepository.getError(TargetingErrorKey.Geo) - ?: "Unknown geo error" - ) + inAppTargetingErrorRepository.getError(TargetingErrorKey.Geo) + ?.let { errorDetails -> + inAppFailureTracker.collectFailure( + inAppId = inApp.id, + failureReason = FailureReason.GEO_TARGETING_FAILED, + errorDetails = errorDetails + ) + } return } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt index e33b704e3..663c70bea 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt @@ -5,10 +5,15 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTra import cloud.mindbox.mobile_sdk.inapp.domain.models.TargetingData import cloud.mindbox.mobile_sdk.models.operation.request.OperationBodyRequest import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import com.android.volley.NoConnectionError import com.android.volley.TimeoutError import com.android.volley.VolleyError +import com.bumptech.glide.load.HttpException +import com.bumptech.glide.load.engine.GlideException import com.google.gson.Gson +import java.net.ConnectException import java.net.SocketTimeoutException +import java.net.UnknownHostException import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason @@ -16,11 +21,32 @@ internal fun VolleyError.isTimeoutError(): Boolean { return this is TimeoutError || cause is SocketTimeoutException } +internal fun VolleyError.isNoConnectionError(): Boolean { + return this is NoConnectionError +} + internal fun VolleyError.isServerError(): Boolean { val statusCode = networkResponse?.statusCode ?: return false return statusCode in 500..599 } +internal fun Throwable.shouldTrackTargetingError(): Boolean { + val volleyError = cause.asVolleyError() ?: return false + return volleyError.isServerError() && !volleyError.isTimeoutError() && !volleyError.isNoConnectionError() +} + +internal fun Throwable.shouldTrackImageDownloadError(): Boolean { + val glideException = cause as? GlideException ?: return true + return glideException.rootCauses.none { rootCause -> + when { + rootCause is SocketTimeoutException || rootCause.cause is SocketTimeoutException -> true + rootCause is HttpException && rootCause.statusCode <= 0 -> + rootCause.cause is UnknownHostException || rootCause.cause is ConnectException + else -> false + } + } +} + internal fun Throwable?.asVolleyError(): VolleyError? = this as? VolleyError internal fun Throwable.getVolleyErrorDetails(): String { @@ -51,12 +77,6 @@ private fun parseOperationBody(operationBody: String?): Pair? = ?.let { entry -> entry.key to entry.value!! } } -internal fun Throwable.shouldTrackTargetingError(): Boolean { - return this.cause.asVolleyError()?.let { volleyError -> - volleyError.isTimeoutError() || volleyError.isServerError() - } ?: false -} - internal fun InAppFailureTracker.sendPresentationFailure( inAppId: String, errorDescription: String, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 7e512a4ab..b079046fc 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -16,7 +16,6 @@ import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.validators.BridgeMessageValidator import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendFailureWithContext -import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper @@ -259,7 +258,7 @@ internal class WebViewInAppViewHolder( if (error.isForMainFrame == true) { inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, - failureReason = FailureReason.WEBVIEW_INIT_FAILED, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, errorDescription = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}" ) } @@ -288,7 +287,11 @@ internal class WebViewInAppViewHolder( return when (response) { JS_RETURN -> true else -> { - mindboxLogE("evaluateJavaScript return unexpected response: $response") + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "evaluateJavaScript return unexpected response: $response" + ) hide() false } @@ -396,7 +399,6 @@ internal class WebViewInAppViewHolder( gatewayManager.fetchWebViewContent(contentUrl) }.onSuccess { response: String -> onContentPageLoaded( - controller = controller, content = WebViewHtmlContent( baseUrl = layer.baseUrl ?: "", html = response @@ -405,7 +407,7 @@ internal class WebViewInAppViewHolder( }.onFailure { e -> inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, - failureReason = FailureReason.HTML_LOAD_FAILED, + failureReason = FailureReason.WEBVIEW_LOAD_FAILED, errorDescription = "Failed to fetch HTML content for In-App", throwable = e ) @@ -415,7 +417,7 @@ internal class WebViewInAppViewHolder( } ?: run { inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, - failureReason = FailureReason.HTML_LOAD_FAILED, + failureReason = FailureReason.WEBVIEW_LOAD_FAILED, errorDescription = "WebView content URL is null" ) } @@ -435,27 +437,33 @@ internal class WebViewInAppViewHolder( } } } ?: run { - inAppFailureTracker.sendPresentationFailure( + inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, - errorDescription = "WebView controller is null when trying show inapp", - null + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "WebView controller is null when trying show inapp" ) release() } } - private fun onContentPageLoaded(controller: WebViewController, content: WebViewHtmlContent) { - controller.executeOnViewThread { - controller.loadContent(content) - } - startTimer { + private fun onContentPageLoaded(content: WebViewHtmlContent) { + webViewController?.let { controller -> controller.executeOnViewThread { + controller.loadContent(content) + } + startTimer { inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, - failureReason = FailureReason.HTML_LOAD_FAILED, + failureReason = FailureReason.WEBVIEW_LOAD_FAILED, errorDescription = "WebView initialization timed out after ${Stopwatch.stop(TIMER)}." ) + controller.executeOnViewThread { + hide() + release() + } } + } ?: run { + mindboxLogW("WebView controller is null when loading content, skipping") } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt index 4705de286..c3a0b4fe4 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt @@ -23,17 +23,17 @@ internal enum class FailureReason(val value: String) { @SerializedName("geo_request_failed") GEO_TARGETING_FAILED("geo_request_failed"), - @SerializedName("customer_segment_request_failed") - CUSTOMER_SEGMENT_REQUEST_FAILED("customer_segment_request_failed"), + @SerializedName("customer_segmentation_request_failed") + CUSTOMER_SEGMENT_REQUEST_FAILED("customer_segmentation_request_failed"), @SerializedName("product_segmentation_request_failed") PRODUCT_SEGMENT_REQUEST_FAILED("product_segmentation_request_failed"), - @SerializedName("html_load_failed") - HTML_LOAD_FAILED("html_load_failed"), + @SerializedName("webview_load_failed") + WEBVIEW_LOAD_FAILED("webview_load_failed"), - @SerializedName("webview_init_failed") - WEBVIEW_INIT_FAILED("webview_init_failed"), + @SerializedName("webview_presentation_failed") + WEBVIEW_PRESENTATION_FAILED("webview_presentation_failed"), @SerializedName("unknown_error") UNKNOWN_ERROR("unknown_error") diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt index 54db2c0bb..03407e358 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt @@ -752,4 +752,271 @@ internal class InAppProcessingManagerTest { verify(exactly = 1) { failureTracker.clearFailures() } verify(exactly = 0) { failureTracker.sendCollectedFailures() } } + + @Test + fun `trackTargetingErrorIfAny collects customer segmentation failure when error was saved`() = runTest { + val errorDetails = "Customer segmentation fetch failed. statusCode=500" + val segmentationRepo = mockk { + coEvery { fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError()) + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + every { setCustomerSegmentationStatus(any()) } just runs + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + every { getProductSegmentationFetched(any()) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.CustomerSegmentation) } returns errorDetails + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, segmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = segmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + targeting = InAppStub.getTargetingSegmentNode().copy( + type = "", + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "123")) + ) + ), + InAppStub.getInApp().copy( + id = "validId", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "validId")) + ) + ) + ) + + val result = processingManager.chooseInAppToShow(testInAppList, event) + + assertNotNull(result) + assertEquals("validId", result?.id) + verify(exactly = 1) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = errorDetails + ) + } + } + + @Test + fun `trackTargetingErrorIfAny does not collect customer segmentation failure when error was not saved`() = runTest { + val segmentationRepo = mockk { + coEvery { fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError()) + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + every { setCustomerSegmentationStatus(any()) } just runs + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + every { getProductSegmentationFetched(any()) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.CustomerSegmentation) } returns null + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, segmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = segmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + targeting = InAppStub.getTargetingSegmentNode().copy( + type = "", + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "123")) + ) + ), + InAppStub.getInApp().copy( + id = "validId", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "validId")) + ) + ) + ) + + val result = processingManager.chooseInAppToShow(testInAppList, event) + + assertNotNull(result) + assertEquals("validId", result?.id) + verify(exactly = 0) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = any() + ) + } + } + + @Test + fun `trackTargetingErrorIfAny does not collect geo failure when error was not saved`() = runTest { + val geoRepo = mockk { + coEvery { fetchGeo() } throws GeoError(VolleyError()) + every { getGeoFetchedStatus() } returns GeoFetchStatus.GEO_FETCH_ERROR + every { setGeoStatus(any()) } just runs + every { getGeo() } returns GeoTargetingStub.getGeoTargeting().copy( + cityId = "234", regionId = "regionId", countryId = "123" + ) + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.Geo) } returns null + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(geoRepo, mockkInAppSegmentationRepository, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = geoRepo, + inAppSegmentationRepository = mockkInAppSegmentationRepository, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + targeting = InAppStub.getTargetingRegionNode().copy( + type = "", kind = Kind.POSITIVE, ids = listOf("otherRegionId") + ), + form = InAppStub.getInApp().form.copy( + listOf(InAppStub.getModalWindow().copy(inAppId = "123")) + ) + ), + InAppStub.getInApp().copy( + id = "validId", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + listOf(InAppStub.getModalWindow().copy(inAppId = "validId")) + ) + ) + ) + + val result = processingManager.chooseInAppToShow(testInAppList, event) + + assertNotNull(result) + assertEquals("validId", result?.id) + verify(exactly = 0) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.GEO_TARGETING_FAILED, + errorDetails = any() + ) + } + } + + @Test + fun `trackTargetingErrorIfAny does not collect product segmentation failure when error was not saved`() = runTest { + val viewProductBody = """{ + "viewProduct": { + "product": { + "ids": { + "website": "ProductRandomName" + } + } + } + }""".trimIndent() + val product = "website" to "ProductRandomName" + val viewProductEvent = InAppEventType.OrdinalEvent( + EventType.SyncOperation("viewProduct"), + viewProductBody + ) + val inAppWithProductSegId = "inAppWithProductSeg" + val validId = "validId" + val mockSegmentationRepo = mockk { + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + coEvery { fetchCustomerSegmentations() } just runs + every { getProductSegmentationFetched(product) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + coEvery { fetchProductSegmentation(product) } throws ProductSegmentationError(VolleyError()) + every { getProductSegmentations(product) } returns emptySet() + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.ProductSegmentation(product)) } returns null + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, mockSegmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = mockSegmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = inAppWithProductSegId, + targeting = InAppStub.getTargetingUnionNode().copy( + nodes = listOf( + InAppStub.viewProductSegmentNode.copy( + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationExternalId", + segmentExternalId = "segmentExternalId" + ) + ) + ), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = inAppWithProductSegId)) + ) + ), + InAppStub.getInApp().copy( + id = validId, + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = validId)) + ) + ) + ) + + val result = processingManager.chooseInAppToShow(testInAppList, viewProductEvent) + + assertNotNull(result) + assertEquals(validId, result?.id) + verify(exactly = 0) { + failureTracker.collectFailure( + inAppId = inAppWithProductSegId, + failureReason = FailureReason.PRODUCT_SEGMENT_REQUEST_FAILED, + errorDetails = any() + ) + } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt new file mode 100644 index 000000000..5f7dafb09 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt @@ -0,0 +1,122 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.extensions + +import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationError +import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoError +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppContentFetchingError +import cloud.mindbox.mobile_sdk.inapp.domain.models.ProductSegmentationError +import com.android.volley.NetworkResponse +import com.android.volley.NoConnectionError +import com.android.volley.TimeoutError +import com.android.volley.VolleyError +import com.bumptech.glide.load.HttpException +import com.bumptech.glide.load.engine.GlideException +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +internal class TrackingFailureExtensionTest { + + @Test + fun `shouldTrackTargetingError returns true for 5xx server error`() { + val serverError = VolleyError(NetworkResponse(500, null, false, 0, emptyList())) + val geoError = GeoError(serverError) + assertTrue(geoError.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackTargetingError returns true for 503 server error`() { + val serverError = VolleyError(NetworkResponse(503, null, false, 0, emptyList())) + val segmentationError = CustomerSegmentationError(serverError) + assertTrue(segmentationError.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackTargetingError returns false for TimeoutError`() { + val timeoutError = TimeoutError() + val geoError = GeoError(timeoutError) + assertFalse(geoError.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackTargetingError returns false for NoConnectionError`() { + val noConnectionError = NoConnectionError() + val segmentationError = CustomerSegmentationError(noConnectionError) + assertFalse(segmentationError.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackTargetingError returns false for VolleyError with SocketTimeoutException cause`() { + val volleyError = VolleyError(SocketTimeoutException("timeout")) + val productError = ProductSegmentationError(volleyError) + assertFalse(productError.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackTargetingError returns false for 4xx client error`() { + val clientError = VolleyError(NetworkResponse(404, null, false, 0, emptyList())) + val geoError = GeoError(clientError) + assertFalse(geoError.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackTargetingError returns false when cause is not VolleyError`() { + val throwable = Exception(IllegalStateException("not volley")) + assertFalse(throwable.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackImageDownloadError returns false for GlideException with SocketTimeoutException in rootCauses`() { + val glideException = GlideException("load failed", listOf(SocketTimeoutException("timeout"))) + val inAppError = InAppContentFetchingError(glideException) + assertFalse(inAppError.shouldTrackImageDownloadError()) + } + + @Test + fun `shouldTrackImageDownloadError returns false for GlideException with HttpException and UnknownHostException`() { + val httpException = HttpException("connection failed", -1, UnknownHostException("no host")) + val glideException = GlideException("load failed", listOf(httpException)) + val inAppError = InAppContentFetchingError(glideException) + assertFalse(inAppError.shouldTrackImageDownloadError()) + } + + @Test + fun `shouldTrackImageDownloadError returns false for GlideException with HttpException and ConnectException`() { + val httpException = HttpException("connection failed", -1, ConnectException("connection refused")) + val glideException = GlideException("load failed", listOf(httpException)) + val inAppError = InAppContentFetchingError(glideException) + assertFalse(inAppError.shouldTrackImageDownloadError()) + } + + @Test + fun `shouldTrackImageDownloadError returns true for GlideException with 404 HttpException`() { + val httpException = HttpException("not found", 404) + val glideException = GlideException("load failed", listOf(httpException)) + val inAppError = InAppContentFetchingError(glideException) + assertTrue(inAppError.shouldTrackImageDownloadError()) + } + + @Test + fun `shouldTrackImageDownloadError returns true for GlideException with 500 HttpException`() { + val httpException = HttpException("server error", 500) + val glideException = GlideException("load failed", listOf(httpException)) + val inAppError = InAppContentFetchingError(glideException) + assertTrue(inAppError.shouldTrackImageDownloadError()) + } + + @Test + fun `shouldTrackImageDownloadError returns true when cause is not GlideException`() { + val throwable = Exception("generic error") + assertTrue(throwable.shouldTrackImageDownloadError()) + } + + @Test + fun `shouldTrackImageDownloadError returns false for GlideException with SocketTimeoutException as cause of rootCause`() { + val rootCause = Exception(SocketTimeoutException("timeout")) + val glideException = GlideException("load failed", listOf(rootCause)) + val inAppError = InAppContentFetchingError(glideException) + assertFalse(inAppError.shouldTrackImageDownloadError()) + } +} From 569fd39c224d9107601f494588d6b67bddbd8672 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 18 Feb 2026 17:29:35 +0300 Subject: [PATCH 29/59] MOBILEWEBVIEW-54: Fix js bridge --- .../inapp/presentation/view/DataCollector.kt | 2 +- .../presentation/view/WebViewInappViewHolder.kt | 14 ++++++++------ .../inapp/presentation/view/DataCollectorTest.kt | 9 +++------ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt index 3f1a63c2d..33df3acd3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt @@ -54,7 +54,7 @@ internal class DataCollector( } companion object Companion { - private const val KEY_DEVICE_UUID = "deviceUuid" + private const val KEY_DEVICE_UUID = "deviceUUID" private const val KEY_ENDPOINT_ID = "endpointId" private const val KEY_IN_APP_ID = "inAppId" private const val KEY_INSETS = "insets" diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 7e512a4ab..861513ddb 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.json.JSONObject import java.util.Timer import java.util.concurrent.ConcurrentHashMap import kotlin.concurrent.timer @@ -55,8 +56,8 @@ internal class WebViewInAppViewHolder( private const val INIT_TIMEOUT_MS = 7_000L private const val TIMER = "CLOSE_INAPP_TIMER" private const val JS_RETURN = "true" - private const val JS_BRIDGE = "window.receiveFromSDK" - private const val JS_CALL_BRIDGE = "$JS_BRIDGE(%s);" + private const val JS_BRIDGE = "window.bridgeMessagesHandlers.emit" + private const val JS_CALL_BRIDGE = "(()=>{try{$JS_BRIDGE(%s);return!0}catch(_){return!1}})()" private const val JS_CHECK_BRIDGE = "typeof $JS_BRIDGE === 'function'" } @@ -109,8 +110,9 @@ internal class WebViewInAppViewHolder( onError: ((String?) -> Unit)? = null ) { mindboxLogI("SDK -> send message $message") - val json = gson.toJson(message) - controller.evaluateJavaScript(JS_CALL_BRIDGE.format(json)) { result -> + val json: String = gson.toJson(message) + val escapedJson: String = JSONObject.quote(json) + controller.evaluateJavaScript(JS_CALL_BRIDGE.format(escapedJson)) { result -> if (!checkEvaluateJavaScript(result)) { onError?.invoke(result) } @@ -373,8 +375,8 @@ internal class WebViewInAppViewHolder( controller.setVisibility(false) controller.setJsBridge(bridge = { json -> + mindboxLogI("SDK <- receive message $json") val message = gson.fromJson(json).getOrNull() - mindboxLogI("SDK <- receive message $message") if (!messageValidator.isValid(message)) { return@setJsBridge } @@ -393,7 +395,7 @@ internal class WebViewInAppViewHolder( layer.contentUrl?.let { contentUrl -> runCatching { - gatewayManager.fetchWebViewContent(contentUrl) + gatewayManager.fetchWebViewContent("https://mobile-static-staging.mindbox.ru/inapps/webview/content/index.html") }.onSuccess { response: String -> onContentPageLoaded( controller = controller, diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt index 64f228513..2962012e6 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt @@ -3,7 +3,6 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.content.Context import android.content.res.Resources import android.util.DisplayMetrics -import org.junit.Assert.assertNotNull import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus @@ -21,9 +20,7 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue +import org.junit.Assert.* import org.junit.Before import org.junit.Test import java.util.Locale @@ -93,7 +90,7 @@ class DataCollectorTest { ) val actualPayload: String = dataCollector.get() val actualJson: JsonObject = JsonParser.parseString(actualPayload).asJsonObject - assertEquals("device-uuid", actualJson.get("deviceUuid").asString) + assertEquals("device-uuid", actualJson.get("deviceUUID").asString) assertEquals("endpoint-id", actualJson.get("endpointId").asString) assertEquals("en_US", actualJson.get("locale").asString) assertEquals("OpenScreen", actualJson.get("operationName").asString) @@ -150,7 +147,7 @@ class DataCollectorTest { ) val actualPayload: String = dataCollector.get() val actualJson: JsonObject = JsonParser.parseString(actualPayload).asJsonObject - assertFalse(actualJson.has("deviceUuid")) + assertFalse(actualJson.has("deviceUUID")) assertFalse(actualJson.has("operationName")) assertFalse(actualJson.has("operationBody")) assertFalse(actualJson.has("trackVisitSource")) From 44da11331994d471ed2fd40df685634abf696e0c Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 19 Feb 2026 15:34:18 +0300 Subject: [PATCH 30/59] MOBILEWEBVIEW-54: Follow code review --- .../inapp/presentation/view/WebViewInappViewHolder.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 861513ddb..f57e14159 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -56,9 +56,10 @@ internal class WebViewInAppViewHolder( private const val INIT_TIMEOUT_MS = 7_000L private const val TIMER = "CLOSE_INAPP_TIMER" private const val JS_RETURN = "true" - private const val JS_BRIDGE = "window.bridgeMessagesHandlers.emit" + private const val JS_BRIDGE_CLASS = "window.bridgeMessagesHandlers" + private const val JS_BRIDGE = "$JS_BRIDGE_CLASS.emit" private const val JS_CALL_BRIDGE = "(()=>{try{$JS_BRIDGE(%s);return!0}catch(_){return!1}})()" - private const val JS_CHECK_BRIDGE = "typeof $JS_BRIDGE === 'function'" + private const val JS_CHECK_BRIDGE = "(() => typeof $JS_BRIDGE_CLASS !== 'undefined' && typeof $JS_BRIDGE === 'function')()" } private var closeInappTimer: Timer? = null @@ -395,7 +396,7 @@ internal class WebViewInAppViewHolder( layer.contentUrl?.let { contentUrl -> runCatching { - gatewayManager.fetchWebViewContent("https://mobile-static-staging.mindbox.ru/inapps/webview/content/index.html") + gatewayManager.fetchWebViewContent(contentUrl) }.onSuccess { response: String -> onContentPageLoaded( controller = controller, From 32e25a99415847dc87c077f3e61be771d5ae8464 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 2 Mar 2026 12:07:42 +0300 Subject: [PATCH 31/59] MOBILEWEBVIEW-57: Add link router for webview --- kmp-common-sdk | 2 +- .../inapp/presentation/view/WebViewAction.kt | 7 + .../view/WebViewInappViewHolder.kt | 81 ++++++ .../presentation/view/WebViewLinkRouter.kt | 139 ++++++++++ .../view/WebViewLinkRouterTest.kt | 257 ++++++++++++++++++ 5 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouterTest.kt diff --git a/kmp-common-sdk b/kmp-common-sdk index 7d0a46995..80e199ff1 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 7d0a469952c5291af01ded0cef9204aa1a5c466d +Subproject commit 80e199ff1d25f1bed6283287b4a89be6e3e65e10 diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index a7c423ee0..dc594a1cc 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -39,6 +39,12 @@ public enum class WebViewAction { @SerializedName("asyncOperation") ASYNC_OPERATION, + + @SerializedName("openLink") + OPEN_LINK, + + @SerializedName("navigationIntercepted") + NAVIGATION_INTERCEPTED, } @InternalMindboxApi @@ -80,6 +86,7 @@ public sealed class BridgeMessage { public companion object { public const val VERSION: Int = 1 public const val EMPTY_PAYLOAD: String = "{}" + public const val SUCCESS_PAYLOAD: String = """{"success":true}""" public const val TYPE_FIELD_NAME: String = "type" public const val TYPE_REQUEST: String = "request" public const val TYPE_RESPONSE: String = "response" diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 7992f238c..d67db46ae 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -1,11 +1,13 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.app.Application +import android.net.Uri import android.view.ViewGroup import android.widget.RelativeLayout import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri import cloud.mindbox.mobile_sdk.BuildConfig import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi @@ -41,6 +43,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.json.JSONObject +import java.util.Locale import java.util.Timer import java.util.concurrent.ConcurrentHashMap import kotlin.concurrent.timer @@ -64,6 +67,7 @@ internal class WebViewInAppViewHolder( private var closeInappTimer: Timer? = null private var webViewController: WebViewController? = null private var backPressedCallback: OnBackPressedCallback? = null + private var currentWebViewOrigin: String? = null private val pendingResponsesById: MutableMap> = ConcurrentHashMap() @@ -76,6 +80,9 @@ internal class WebViewInAppViewHolder( private val operationExecutor: WebViewOperationExecutor by lazy { MindboxWebViewOperationExecutor() } + private val linkRouter: WebViewLinkRouter by lazy { + MindboxWebViewLinkRouter(appContext) + } override val isActive: Boolean get() = isInAppMessageActive @@ -131,6 +138,7 @@ internal class WebViewInAppViewHolder( register(WebViewAction.TOAST, ::handleToastAction) register(WebViewAction.ALERT, ::handleAlertAction) register(WebViewAction.ASYNC_OPERATION, ::handleAsyncOperationAction) + register(WebViewAction.OPEN_LINK, ::handleOpenLinkAction) registerSuspend(WebViewAction.SYNC_OPERATION, ::handleSyncOperationAction) register(WebViewAction.READY) { handleReadyAction( @@ -238,6 +246,14 @@ internal class WebViewInAppViewHolder( return BridgeMessage.EMPTY_PAYLOAD } + private fun handleOpenLinkAction(message: BridgeMessage.Request): String { + linkRouter.executeOpenLink(message.payload) + .getOrElse { error: Throwable -> + throw IllegalStateException(error.message ?: "Navigation error") + } + return BridgeMessage.SUCCESS_PAYLOAD + } + private suspend fun handleSyncOperationAction(message: BridgeMessage.Request): String { return operationExecutor.executeSyncOperation(message.payload) } @@ -253,9 +269,14 @@ internal class WebViewInAppViewHolder( controller.setEventListener(object : WebViewEventListener { override fun onPageFinished(url: String?) { mindboxLogD("onPageFinished: $url") + currentWebViewOrigin = resolveOrigin(url) ?: currentWebViewOrigin webViewController?.evaluateJavaScript(JS_CHECK_BRIDGE, ::checkEvaluateJavaScript) } + override fun onShouldOverrideUrlLoading(url: String?, isForMainFrame: Boolean?): Boolean { + return handleShouldOverrideUrlLoading(url = url, isForMainFrame = isForMainFrame) + } + override fun onError(error: WebViewError) { mindboxLogE("WebView error: code=${error.code}, description=${error.description}, url=${error.url}") if (error.isForMainFrame == true) { @@ -270,6 +291,60 @@ internal class WebViewInAppViewHolder( return controller } + private fun handleShouldOverrideUrlLoading(url: String?, isForMainFrame: Boolean?): Boolean { + if (isForMainFrame != true) { + return false + } + if (shouldAllowLocalNavigation(url)) { + return false + } + val normalizedUrl: String = url?.trim().orEmpty() + sendNavigationInterceptedEvent(url = normalizedUrl) + return true + } + + private fun sendNavigationInterceptedEvent(url: String) { + val controller: WebViewController = webViewController ?: return + val payload: String = gson.toJson(NavigationInterceptedPayload(url = url)) + val message: BridgeMessage.Request = BridgeMessage.createAction( + action = WebViewAction.NAVIGATION_INTERCEPTED, + payload = payload + ) + sendActionInternal(controller, message) { error -> + mindboxLogW("Failed to send navigationIntercepted event to WebView: $error") + } + } + + private fun shouldAllowLocalNavigation(url: String?): Boolean { + if (url.isNullOrBlank()) { + return true + } + val normalizedUrl: String = url.trim() + if (normalizedUrl.startsWith("#")) { + return true + } + if (normalizedUrl.startsWith("about:blank")) { + return true + } + val targetOrigin: String = resolveOrigin(normalizedUrl) ?: return false + val sourceOrigin: String = currentWebViewOrigin ?: return false + return targetOrigin == sourceOrigin + } + + private fun resolveOrigin(url: String?): String? { + if (url.isNullOrBlank()) { + return null + } + val parsedUri: Uri = runCatching { url.toUri() }.getOrNull() ?: return null + val scheme: String = parsedUri.scheme?.lowercase(Locale.US).orEmpty() + val host: String = parsedUri.host?.lowercase(Locale.US).orEmpty() + if (scheme.isBlank() || host.isBlank()) { + return null + } + val normalizedPort: String = if (parsedUri.port >= 0) ":${parsedUri.port}" else "" + return "$scheme://$host$normalizedPort" + } + private fun clearBackPressedCallback() { backPressedCallback?.remove() } @@ -401,6 +476,7 @@ internal class WebViewInAppViewHolder( runCatching { gatewayManager.fetchWebViewContent(contentUrl) }.onSuccess { response: String -> + currentWebViewOrigin = resolveOrigin(layer.baseUrl) onContentPageLoaded( content = WebViewHtmlContent( baseUrl = layer.baseUrl ?: "", @@ -549,8 +625,13 @@ internal class WebViewInAppViewHolder( stopTimer() cancelPendingResponses("WebView In-App is released") clearBackPressedCallback() + currentWebViewOrigin = null webViewController?.destroy() webViewController = null backPressedCallback = null } + + private data class NavigationInterceptedPayload( + val url: String + ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt new file mode 100644 index 000000000..3c461f8a8 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt @@ -0,0 +1,139 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri +import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import com.google.gson.JsonParser + +internal interface WebViewLinkRouter { + fun executeOpenLink(request: String?): Result +} + +internal class MindboxWebViewLinkRouter( + private val context: Context, +) : WebViewLinkRouter { + + companion object { + private const val SCHEME_HTTP = "http" + private const val SCHEME_HTTPS = "https" + private const val SCHEME_INTENT = "intent" + private const val SCHEME_TEL = "tel" + private const val SCHEME_MAILTO = "mailto" + private const val SCHEME_SMS = "sms" + private const val KEY_URL = "url" + private val BLOCKED_SCHEMES: Set = setOf("javascript", "file", "data", "blob") + private const val ERROR_MISSING_URL = "Invalid payload: missing or empty 'url' field" + } + + override fun executeOpenLink(request: String?): Result { + return runCatching { + val url: String = extractTargetUrl(request) + val parsedUri = parseUrl(url) + routeByScheme( + parsedUri = parsedUri, + targetUrl = url, + ) + } + } + + private fun extractTargetUrl(request: String?): String { + if (request.isNullOrBlank()) { + throw IllegalStateException(ERROR_MISSING_URL) + } + val parsedJsonElement = runCatching { JsonParser.parseString(request) }.getOrNull() + ?: throw IllegalStateException(ERROR_MISSING_URL) + if (!parsedJsonElement.isJsonObject) { + throw IllegalStateException(ERROR_MISSING_URL) + } + val url: String = parsedJsonElement.asJsonObject.get(KEY_URL)?.asString?.trim().orEmpty() + if (url.isBlank()) { + throw IllegalStateException(ERROR_MISSING_URL) + } + return url + } + + private fun parseUrl(url: String): Uri { + val parsedUri: Uri = url.toUri() + val scheme: String = parsedUri.scheme?.lowercase().orEmpty() + if (scheme.isBlank()) { + throw IllegalStateException("Invalid URL: '$parsedUri' could not be parsed") + } + if (scheme in BLOCKED_SCHEMES) { + throw IllegalStateException("Blocked URL scheme: '$scheme'") + } + return parsedUri + } + + private fun routeByScheme( + parsedUri: Uri, + targetUrl: String, + ): String { + val scheme = parsedUri.scheme + requireNotNull(scheme) { "Url scheme must be not null" } + return when (scheme.lowercase()) { + SCHEME_INTENT -> openIntentUri(targetUrl) + SCHEME_TEL -> openDialLink(parsedUri, targetUrl) + SCHEME_SMS, SCHEME_MAILTO -> openSendToLink(parsedUri, targetUrl) + SCHEME_HTTP, SCHEME_HTTPS -> openUriWithViewIntent(parsedUri, targetUrl) + else -> openUriWithViewIntent(parsedUri, targetUrl) + } + } + + private fun openIntentUri(rawIntentUri: String): String { + val parsedIntent: Intent = runCatching { Intent.parseUri(rawIntentUri, Intent.URI_INTENT_SCHEME) } + .getOrElse { + mindboxLogW("Intent URI parse failed: $rawIntentUri") + throw IllegalStateException("Invalid URL: '$rawIntentUri' could not be parsed") + } + if (parsedIntent.action.isNullOrBlank()) { + parsedIntent.action = Intent.ACTION_VIEW + } + parsedIntent.selector = null + parsedIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return startIntent(parsedIntent, rawIntentUri) + } + + private fun openDialLink(uri: Uri, rawUrl: String): String { + val dialIntent: Intent = Intent(Intent.ACTION_DIAL, uri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return startIntent(dialIntent, rawUrl) + } + + private fun openSendToLink(uri: Uri, rawUrl: String): String { + val smsIntent: Intent = Intent(Intent.ACTION_SENDTO, uri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return startIntent(smsIntent, rawUrl) + } + + private fun openUriWithViewIntent(uri: Uri, rawUrl: String): String { + val intent: Intent = Intent(Intent.ACTION_VIEW, uri).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return startIntent(intent, rawUrl) + } + + private fun startIntent(intent: Intent, rawUrl: String): String { + return try { + context.startActivity(intent) + rawUrl + } catch (error: ActivityNotFoundException) { + mindboxLogW("Activity not found for URI: $rawUrl") + throw IllegalStateException( + "ActivityNotFoundException: ${error.message ?: "No activity found to handle URL"}" + ) + } catch (error: SecurityException) { + mindboxLogW("Security exception for URI: $rawUrl") + throw IllegalStateException( + "SecurityException: ${error.message ?: "Cannot open URL"}" + ) + } catch (error: Throwable) { + throw IllegalStateException(error.message ?: "Navigation failed: unable to open URL") + } + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouterTest.kt new file mode 100644 index 000000000..beff15ffd --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouterTest.kt @@ -0,0 +1,257 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.IntentFilter +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +internal class WebViewLinkRouterTest { + + private lateinit var context: Context + private lateinit var router: MindboxWebViewLinkRouter + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + router = MindboxWebViewLinkRouter(context) + } + + @Test + fun `executeOpenLink opens web links from pdf cases`() { + registerBrowsableHandler("https") + val inputUrls: List = listOf( + "https://www.google.com", + "https://habr.com/ru/articles/", + "https://test-site.g.mindbox.ru", + "https://test-site.g.mindbox.ru/some/path?param=1", + "https://mindbox.ru", + "https://mindbox.ru/products", + "https://www.youtube.com/watch?v=abc", + "https://t.me/durov", + ) + inputUrls.forEach { inputUrl: String -> + val actualResult: Result = executeOpenLink(url = inputUrl) + assertTrue(actualResult.isSuccess) + assertEquals(inputUrl, actualResult.getOrNull()) + } + } + + @Test + fun `executeOpenLink opens deeplink schemes from pdf cases`() { + registerBrowsableHandler("pushok") + val inputUrls: List = listOf( + "pushok://", + "pushok://product/123", + "pushok://catalog?category=shoes&sort=price", + ) + inputUrls.forEach { inputUrl: String -> + val actualResult: Result = executeOpenLink(url = inputUrl) + assertTrue(actualResult.isSuccess) + assertEquals(inputUrl, actualResult.getOrNull()) + } + } + + @Test + fun `executeOpenLink opens intent uri`() { + registerBrowsableHandler("myapp") + val intentUrl: String = + "intent://catalog/item/1#Intent;scheme=myapp;S.browser_fallback_url=https%3A%2F%2Fmindbox.ru;end" + val result: Result = router.executeOpenLink("""{"url":"$intentUrl"}""") + assertTrue(result.isSuccess) + assertEquals(intentUrl, result.getOrNull()) + } + + @Test + fun `executeOpenLink opens tg deeplink when handler exists`() { + registerBrowsableHandler("tg") + val inputUrl: String = "tg://resolve?domain=durov" + val result: Result = executeOpenLink(url = inputUrl) + assertTrue(result.isSuccess) + assertEquals(inputUrl, result.getOrNull()) + } + + @Test + fun `executeOpenLink returns error for tg deeplink when handler missing`() { + val activityNotFoundRouter: MindboxWebViewLinkRouter = createRouterWithActivityNotFoundError() + val result: Result = activityNotFoundRouter.executeOpenLink("""{"url":"tg://resolve?domain=durov"}""") + assertFalse(result.isSuccess) + assertErrorContains(result = result, expectedMessagePart = "ActivityNotFoundException") + } + + @Test + fun `executeOpenLink opens system schemes from pdf cases`() { + registerActionHandler(action = Intent.ACTION_DIAL, scheme = "tel") + registerActionHandler(action = Intent.ACTION_SENDTO, scheme = "mailto") + registerActionHandler(action = Intent.ACTION_SENDTO, scheme = "sms") + val inputUrls: List = listOf( + "tel:+1234567890", + "mailto:test@example.com", + "sms:+1234567890", + ) + inputUrls.forEach { inputUrl: String -> + val actualResult: Result = executeOpenLink(url = inputUrl) + assertTrue(actualResult.isSuccess) + assertEquals(inputUrl, actualResult.getOrNull()) + } + } + + @Test + fun `executeOpenLink opens android only schemes when handler exists`() { + registerBrowsableHandler("geo") + registerBrowsableHandler("market") + val geoResult: Result = executeOpenLink(url = "geo:55.7558,37.6173?q=Moscow") + assertTrue(geoResult.isSuccess) + assertEquals("geo:55.7558,37.6173?q=Moscow", geoResult.getOrNull()) + val marketResult: Result = executeOpenLink(url = "market://details?id=com.google.android.gm") + assertTrue(marketResult.isSuccess) + assertEquals("market://details?id=com.google.android.gm", marketResult.getOrNull()) + } + + @Test + fun `executeOpenLink returns error for iOS only schemes without handler`() { + val activityNotFoundRouter: MindboxWebViewLinkRouter = createRouterWithActivityNotFoundError() + val mapsResult: Result = activityNotFoundRouter.executeOpenLink("""{"url":"maps://?q=Moscow"}""") + val appStoreResult: Result = + activityNotFoundRouter.executeOpenLink("""{"url":"itms-apps://apps.apple.com/app/id389801252"}""") + assertFalse(mapsResult.isSuccess) + assertFalse(appStoreResult.isSuccess) + assertErrorContains(result = mapsResult, expectedMessagePart = "ActivityNotFoundException") + assertErrorContains(result = appStoreResult, expectedMessagePart = "ActivityNotFoundException") + } + + @Test + fun `executeOpenLink returns error for blocked schemes from pdf cases`() { + val blockedUrls: List = listOf( + "javascript:alert(1)", + "file:///etc/passwd", + "data:text/html,

blocked

", + "blob:https://example.com/uuid", + ) + blockedUrls.forEach { blockedUrl: String -> + val actualResult: Result = executeOpenLink(url = blockedUrl) + assertFalse(actualResult.isSuccess) + assertErrorContains(result = actualResult, expectedMessagePart = "Blocked URL scheme") + } + } + + @Test + fun `executeOpenLink returns error for invalid or missing scheme urls`() { + val invalidResult: Result = executeOpenLink(url = "not a url at all") + val missingSchemeResult: Result = executeOpenLink(url = "://missing-scheme") + assertFalse(invalidResult.isSuccess) + assertFalse(missingSchemeResult.isSuccess) + assertErrorContains(result = invalidResult, expectedMessagePart = "Invalid URL") + assertErrorContains(result = missingSchemeResult, expectedMessagePart = "Invalid URL") + } + + @Test + fun `executeOpenLink returns error for unknown scheme without activity`() { + val activityNotFoundRouter: MindboxWebViewLinkRouter = createRouterWithActivityNotFoundError() + val result: Result = activityNotFoundRouter.executeOpenLink("""{"url":"nonexistent-scheme://test"}""") + assertFalse(result.isSuccess) + assertErrorContains(result = result, expectedMessagePart = "ActivityNotFoundException") + } + + @Test + fun `executeOpenLink returns error for invalid payload cases from pdf`() { + val nullPayloadResult: Result = router.executeOpenLink(null) + val emptyPayloadResult: Result = router.executeOpenLink("") + val blankPayloadResult: Result = router.executeOpenLink(" ") + val missingUrlResult: Result = router.executeOpenLink("""{"foo":"bar"}""") + val emptyUrlResult: Result = router.executeOpenLink("""{"url":""}""") + val invalidJsonResult: Result = router.executeOpenLink("""{not-json}""") + val notObjectJsonResult: Result = router.executeOpenLink("""["https://mindbox.ru"]""") + val payloadResults: List> = listOf( + nullPayloadResult, + emptyPayloadResult, + blankPayloadResult, + missingUrlResult, + emptyUrlResult, + invalidJsonResult, + notObjectJsonResult, + ) + payloadResults.forEach { actualResult: Result -> + assertFalse(actualResult.isSuccess) + assertErrorContains( + result = actualResult, + expectedMessagePart = "Invalid payload: missing or empty 'url' field", + ) + } + } + + private fun executeOpenLink(url: String): Result { + return router.executeOpenLink("""{"url":"$url"}""") + } + + private fun assertErrorContains( + result: Result, + expectedMessagePart: String, + ) { + val actualError: Throwable? = result.exceptionOrNull() + assertNotNull(actualError) + val actualMessage: String = actualError?.message.orEmpty() + assertTrue(actualMessage.contains(expectedMessagePart)) + } + + private fun createRouterWithActivityNotFoundError(): MindboxWebViewLinkRouter { + val wrappedContext: Context = object : ContextWrapper(context) { + override fun startActivity(intent: Intent) { + throw ActivityNotFoundException("No activity found") + } + } + return MindboxWebViewLinkRouter(wrappedContext) + } + + private fun registerBrowsableHandler(scheme: String) { + registerHandler( + action = Intent.ACTION_VIEW, + scheme = scheme, + isBrowsable = true, + ) + } + + private fun registerActionHandler( + action: String, + scheme: String, + ) { + registerHandler( + action = action, + scheme = scheme, + isBrowsable = false, + ) + } + + private fun registerHandler( + action: String, + scheme: String, + isBrowsable: Boolean, + ) { + val componentName: ComponentName = ComponentName("com.example", "TestActivityFor_${action}_$scheme") + val packageManager = shadowOf(RuntimeEnvironment.getApplication().packageManager) + packageManager.addActivityIfNotPresent(componentName) + packageManager.addIntentFilterForActivity( + componentName, + IntentFilter(action).apply { + addCategory(Intent.CATEGORY_DEFAULT) + if (isBrowsable) { + addCategory(Intent.CATEGORY_BROWSABLE) + } + addDataScheme(scheme) + } + ) + } +} From 5b9326a1d859a79a7f9cad5da666eb632ee9d0dc Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 2 Mar 2026 14:54:25 +0300 Subject: [PATCH 32/59] MOBILEWEBVIEW-57: Change error format --- .../inapp/presentation/view/WebViewAction.kt | 1 + .../inapp/presentation/view/WebViewInappViewHolder.kt | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index dc594a1cc..d9f718f9b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -87,6 +87,7 @@ public sealed class BridgeMessage { public const val VERSION: Int = 1 public const val EMPTY_PAYLOAD: String = "{}" public const val SUCCESS_PAYLOAD: String = """{"success":true}""" + public const val UNKNOWN_ERROR_PAYLOAD: String = """{"error":"Unknown error"}""" public const val TYPE_FIELD_NAME: String = "type" public const val TYPE_REQUEST: String = "request" public const val TYPE_RESPONSE: String = "response" diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index d67db46ae..cd5fd26d7 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -410,7 +410,12 @@ internal class WebViewInAppViewHolder( error: Throwable, controller: WebViewController, ) { - val errorMessage: BridgeMessage.Error = BridgeMessage.createErrorAction(message, error.message) + val json: String = runCatching { + val payload = ErrorPayload(error = requireNotNull(error.message)) + gson.toJson(payload) + }.getOrDefault(BridgeMessage.UNKNOWN_ERROR_PAYLOAD) + + val errorMessage: BridgeMessage.Error = BridgeMessage.createErrorAction(message, json) mindboxLogE("WebView send error response for ${message.action} with payload ${errorMessage.payload}") sendActionInternal(controller, errorMessage) } @@ -634,4 +639,8 @@ internal class WebViewInAppViewHolder( private data class NavigationInterceptedPayload( val url: String ) + + private data class ErrorPayload( + val error: String + ) } From 2410acdedc08098050cf01649c1721dfaa3df81f Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:12:52 +0300 Subject: [PATCH 33/59] MOBILEWEBVIEW-60: add timeToDisplay for Inapp.Show action --- kmp-common-sdk | 2 +- .../di/modules/PresentationModule.kt | 3 +- .../deserializers/InAppTagsDeserializer.kt | 29 ++++ .../managers/InAppSerializationManagerImpl.kt | 23 ++- .../inapp/data/mapper/InAppMapper.kt | 6 +- .../data/repositories/InAppRepositoryImpl.kt | 8 +- .../inapp/domain/InAppInteractorImpl.kt | 23 ++- .../interfaces/interactors/InAppInteractor.kt | 10 +- .../managers/InAppSerializationManager.kt | 4 +- .../repositories/InAppRepository.kt | 2 +- .../inapp/domain/models/InAppConfig.kt | 1 + .../inapp/domain/models/InAppTypeWrapper.kt | 3 +- .../InAppMessageDelayedManager.kt | 13 +- .../presentation/InAppMessageManagerImpl.kt | 33 +++- .../presentation/InAppMessageViewDisplayer.kt | 3 +- .../InAppMessageViewDisplayerImpl.kt | 10 +- .../view/WebViewInappViewHolder.kt | 2 + .../operation/request/InAppHandleRequest.kt | 9 ++ .../operation/response/InAppConfigResponse.kt | 6 + .../mindbox/mobile_sdk/utils/TimeProvider.kt | 5 + .../InAppTagsDeserializerTest.kt | 101 ++++++++++++ .../managers/InAppSerializationManagerTest.kt | 47 ++++-- .../MobileConfigSerializationManagerTest.kt | 146 ++++++++++++++++++ .../inapp/data/mapper/InAppMapperTest.kt | 116 +++++++++++++- .../data/repositories/InAppRepositoryTest.kt | 24 +-- .../inapp/domain/InAppInteractorImplTest.kt | 18 +-- .../InAppMessageDelayedManagerTest.kt | 36 ++--- .../presentation/InAppMessageManagerTest.kt | 70 +++++---- .../MobileConfigSettingsManagerTest.kt | 17 +- .../mindbox/mobile_sdk/models/InAppStub.kt | 9 +- .../mobile_sdk/utils/TimeProviderTest.kt | 73 +++++++++ ...igWithSettingsABTestsMonitoringInapps.json | 4 + 32 files changed, 722 insertions(+), 134 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializer.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializerTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/TimeProviderTest.kt diff --git a/kmp-common-sdk b/kmp-common-sdk index 7d0a46995..80e199ff1 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 7d0a469952c5291af01ded0cef9204aa1a5c466d +Subproject commit 80e199ff1d25f1bed6283287b4a89be6e3e65e10 diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt index ec8be65c0..de83c9b68 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt @@ -28,7 +28,8 @@ internal fun PresentationModule( monitoringInteractor = monitoringInteractor, sessionStorageManager = sessionStorageManager, userVisitManager = userVisitManager, - inAppMessageDelayedManager = inAppMessageDelayedManager + inAppMessageDelayedManager = inAppMessageDelayedManager, + timeProvider = timeProvider ) } override val clipboardManager: ClipboardManager by lazy { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializer.kt new file mode 100644 index 000000000..415e6e2f1 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializer.kt @@ -0,0 +1,29 @@ +package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import java.lang.reflect.Type + +internal class InAppTagsDeserializer : JsonDeserializer?> { + + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext, + ): Map? { + if (json.isJsonNull) return null + if (!json.isJsonObject) return null + return json.asJsonObject.entrySet().mapNotNull { (key, value) -> + if (value.isJsonPrimitive && value.asJsonPrimitive.isString) { + key to value.asString + } else { + null + } + }.toMap() + } + + companion object { + const val TAGS = "tags" + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt index 674168cd3..d80cff2c1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt @@ -4,6 +4,7 @@ import cloud.mindbox.mobile_sdk.fromJsonTyped import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper import cloud.mindbox.mobile_sdk.models.operation.request.InAppHandleRequest +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import cloud.mindbox.mobile_sdk.toJsonTyped import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler @@ -13,9 +14,25 @@ import com.google.gson.reflect.TypeToken internal class InAppSerializationManagerImpl(private val gson: Gson) : InAppSerializationManager { - override fun serializeToInAppHandledString(inAppId: String): String { - return LoggingExceptionHandler.runCatching("") { - gson.toJson(InAppHandleRequest(inAppId), InAppHandleRequest::class.java) + override fun serializeToInAppShownActionString( + inAppId: String, + timeToDisplay: String, + tags: Map?, + ): String { + return loggingRunCatching("") { + gson.toJsonTyped( + InAppShowRequest( + inAppId = inAppId, + timeToDisplay = timeToDisplay, + tags = tags, + ) + ) + } + } + + override fun serializeToInAppActionString(inAppId: String): String { + return loggingRunCatching("") { + gson.toJsonTyped(InAppHandleRequest(inAppId = inAppId)) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt index 60f617b59..35715f30c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt @@ -62,7 +62,8 @@ internal class InAppMapper { sdkVersion = inApp.sdkVersion, targeting = targetingDto, frequency = frequencyDto, - form = formDto + form = formDto, + tags = inApp.tags, ) } } @@ -303,7 +304,8 @@ internal class InAppMapper { ), minVersion = inAppDto.sdkVersion?.minVersion, maxVersion = inAppDto.sdkVersion?.maxVersion, - frequency = Frequency(getDelay(inAppDto.frequency)) + frequency = Frequency(getDelay(inAppDto.frequency)), + tags = inAppDto.tags?.takeIf { it.isNotEmpty() } ) } ?: emptyList(), monitoring = inAppConfigResponse.monitoring?.map { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt index f6f8d044e..91cc66bf1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt @@ -98,8 +98,8 @@ internal class InAppRepositoryImpl( mindboxLogI("Increase count of shown inapp per day") } - override fun sendInAppShown(inAppId: String) { - inAppSerializationManager.serializeToInAppHandledString(inAppId).apply { + override fun sendInAppShown(inAppId: String, timeToDisplay: String, tags: Map?) { + inAppSerializationManager.serializeToInAppShownActionString(inAppId, timeToDisplay, tags).apply { if (isNotBlank()) { MindboxEventManager.inAppShown( context, @@ -110,7 +110,7 @@ internal class InAppRepositoryImpl( } override fun sendInAppClicked(inAppId: String) { - inAppSerializationManager.serializeToInAppHandledString(inAppId).apply { + inAppSerializationManager.serializeToInAppActionString(inAppId).apply { if (isNotBlank()) { MindboxEventManager.inAppClicked( context, @@ -121,7 +121,7 @@ internal class InAppRepositoryImpl( } override fun sendUserTargeted(inAppId: String) { - inAppSerializationManager.serializeToInAppHandledString(inAppId).apply { + inAppSerializationManager.serializeToInAppActionString(inAppId).apply { if (isNotBlank()) { MindboxEventManager.sendUserTargeted( context, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt index 26b020c35..758e8613b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt @@ -13,6 +13,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppReposi import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.logger.MindboxLog +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.models.InAppEventType @@ -40,7 +41,7 @@ internal class InAppInteractorImpl( private val inAppTargetingChannel = Channel(Channel.UNLIMITED) - override suspend fun processEventAndConfig(): Flow { + override suspend fun processEventAndConfig(): Flow> { val inApps: List = mobileConfigRepository.getInAppsSection() .let { inApps -> inAppRepository.saveCurrentSessionInApps(inApps) @@ -63,9 +64,10 @@ internal class InAppInteractorImpl( } return inAppRepository.listenInAppEvents() .filter { event -> inAppEventManager.isValidInAppEvent(event) } - .onEach { - mindboxLogD("Event triggered: ${it.name}") + .onEach { event -> + mindboxLogD("Event triggered: ${event.name}") }.map { event -> + val triggerTimeMillis = timeProvider.currentTimestamp() val filteredInApps = inAppFilteringManager.filterUnShownInAppsByEvent(inApps, event).let { inAppFrequencyManager.filterInAppsFrequency(it) } @@ -83,10 +85,10 @@ internal class InAppInteractorImpl( inApp?.let { sessionStorageManager.inAppTriggerEvent = event } - inApp + inApp?.let { inapp -> inapp to timeProvider.elapsedSince(triggerTimeMillis) } } - .onEach { inApp -> - inApp?.let { mindboxLogI("InApp ${inApp.id} isPriority=${inApp.isPriority}, delayTime=${inApp.delayTime}, skipLimitChecks=${inApp.isPriority}") } + .onEach { pair -> + pair?.let { (inApp, preparedTime) -> mindboxLogI("InApp ${inApp.id} isPriority=${inApp.isPriority}, delayTime=${inApp.delayTime}, skipLimitChecks=${inApp.isPriority}, preparedTime = ${preparedTime.interval} ms") } ?: mindboxLogI("No inapps to show found") } .filterNotNull() @@ -104,9 +106,14 @@ internal class InAppInteractorImpl( ) } - override fun saveShownInApp(id: String, timeStamp: Long) { + override fun saveShownInApp( + id: String, + timeStamp: Long, + timeToDisplay: String, + tags: Map? + ) { inAppRepository.setInAppShown(id) - inAppRepository.sendInAppShown(id) + inAppRepository.sendInAppShown(id, timeToDisplay, tags) inAppRepository.saveShownInApp(id, timeStamp) inAppRepository.saveInAppStateChangeTime(timeStamp.toTimestamp()) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt index cf558beb4..44d940eb6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp +import cloud.mindbox.mobile_sdk.models.Milliseconds import kotlinx.coroutines.flow.Flow internal interface InAppInteractor { @@ -9,9 +10,14 @@ internal interface InAppInteractor { fun setInAppShown(inAppId: String) - suspend fun processEventAndConfig(): Flow + suspend fun processEventAndConfig(): Flow> - fun saveShownInApp(id: String, timeStamp: Long) + fun saveShownInApp( + id: String, + timeStamp: Long, + timeToDisplay: String, + tags: Map? + ) fun sendInAppClicked(inAppId: String) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt index c4b92df67..e01acce06 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt @@ -8,7 +8,9 @@ internal interface InAppSerializationManager { fun deserializeToShownInAppsMap(shownInApps: String): Map> - fun serializeToInAppHandledString(inAppId: String): String + fun serializeToInAppShownActionString(inAppId: String, timeToDisplay: String, tags: Map?): String + + fun serializeToInAppActionString(inAppId: String): String fun serializeToInAppShowFailuresString(inAppShowFailures: List): String diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt index f2167b44c..eba668ef2 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt @@ -29,7 +29,7 @@ internal interface InAppRepository { fun saveShownInApp(id: String, timeStamp: Long) - fun sendInAppShown(inAppId: String) + fun sendInAppShown(inAppId: String, timeToDisplay: String, tags: Map?) fun sendInAppClicked(inAppId: String) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppConfig.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppConfig.kt index 5283addda..e5a3f75b6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppConfig.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppConfig.kt @@ -28,6 +28,7 @@ internal data class InApp( val frequency: Frequency, val targeting: TreeTargeting, val form: Form, + val tags: Map?, ) internal data class Frequency(val delay: Delay) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt index 0fab35bf6..f520c7ab9 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt @@ -4,7 +4,8 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks internal data class InAppTypeWrapper( val inAppType: T, - val inAppActionCallbacks: InAppActionCallbacks + val inAppActionCallbacks: InAppActionCallbacks, + val onRenderStart: () -> Unit, ) internal fun interface OnInAppClick { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManager.kt index 266e242fd..36f375e5c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManager.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.logger.mindboxLogD +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.pollIf import cloud.mindbox.mobile_sdk.utils.TimeProvider @@ -33,16 +34,17 @@ internal class InAppMessageDelayedManager(private val timeProvider: TimeProvider pendingInAppComparator ) - private val _inAppToShowFlow = MutableSharedFlow() + private val _inAppToShowFlow = MutableSharedFlow>() val inAppToShowFlow = _inAppToShowFlow.asSharedFlow() private data class PendingInApp( val inApp: InApp, val showTimeMillis: Long, - val sequenceNumber: Long + val sequenceNumber: Long, + val preparedTimeMs: Milliseconds, ) - internal fun process(inApp: InApp) { + internal fun process(inApp: InApp, preparedTimeMs: Milliseconds) { coroutineScope.launchWithLock(processingMutex) { mindboxLogD("Processing In-App: ${inApp.id}, Priority: ${inApp.isPriority}, Delay: ${inApp.delayTime}") val delay = inApp.delayTime?.interval ?: 0L @@ -52,7 +54,8 @@ internal class InAppMessageDelayedManager(private val timeProvider: TimeProvider PendingInApp( inApp = inApp, showTimeMillis = showTime, - sequenceNumber = sequenceNumber.getAndIncrement() + sequenceNumber = sequenceNumber.getAndIncrement(), + preparedTimeMs = preparedTimeMs, ) ) processQueue() @@ -73,7 +76,7 @@ internal class InAppMessageDelayedManager(private val timeProvider: TimeProvider pendingInApps.pollIf { it.showTimeMillis <= now }?.let { showCandidate -> mindboxLogI("Winner found: ${showCandidate.inApp.id}. Emitting to show.") - _inAppToShowFlow.emit(showCandidate.inApp) + _inAppToShowFlow.emit(showCandidate.inApp to showCandidate.preparedTimeMs) do { val inApp = pendingInApps.pollIf { it.showTimeMillis <= now }.also { discarded -> diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt index 18c418e1e..e7916b306 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt @@ -6,16 +6,21 @@ import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.OnInAppClick import cloud.mindbox.mobile_sdk.inapp.domain.models.OnInAppDismiss import cloud.mindbox.mobile_sdk.inapp.domain.models.OnInAppShown import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.millisToTimeSpan +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.models.Timestamp import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.managers.UserVisitManager import cloud.mindbox.mobile_sdk.monitoring.domain.interfaces.MonitoringInteractor import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler +import cloud.mindbox.mobile_sdk.utils.TimeProvider import com.android.volley.VolleyError import kotlinx.coroutines.* import kotlinx.coroutines.flow.collect @@ -28,7 +33,8 @@ internal class InAppMessageManagerImpl( private val monitoringInteractor: MonitoringInteractor, private val sessionStorageManager: SessionStorageManager, private val userVisitManager: UserVisitManager, - private val inAppMessageDelayedManager: InAppMessageDelayedManager + private val inAppMessageDelayedManager: InAppMessageDelayedManager, + private val timeProvider: TimeProvider ) : InAppMessageManager { init { @@ -65,15 +71,15 @@ internal class InAppMessageManagerImpl( private suspend fun handleInAppFromInteractor() { inAppInteractor.processEventAndConfig() - .onEach { inApp -> + .onEach { (inApp, preparedTimeMs) -> mindboxLogI("Got in-app from interactor: ${inApp.id}. Processing with DelayedManager.") - inAppMessageDelayedManager.process(inApp) + inAppMessageDelayedManager.process(inApp, preparedTimeMs) } .collect() } private suspend fun handleInAppFromDelayedManager() { - inAppMessageDelayedManager.inAppToShowFlow.collect { inApp -> + inAppMessageDelayedManager.inAppToShowFlow.collect { (inApp, preparedTimeMs) -> mindboxLogI("Got in-app from DelayedManager: ${inApp.id}") withContext(Dispatchers.Main) { if (inAppMessageViewDisplayer.isInAppActive()) { @@ -92,14 +98,18 @@ internal class InAppMessageManagerImpl( return@withContext } + var renderStartTime = Timestamp(0L) + val tags = inApp.tags?.takeIf { it.isNotEmpty() } + inAppMessageViewDisplayer.tryShowInAppMessage( inAppType = inAppMessage, + onRenderStart = { renderStartTime = timeProvider.currentTimestamp() }, inAppActionCallbacks = object : InAppActionCallbacks { override val onInAppClick = OnInAppClick { inAppInteractor.sendInAppClicked(inAppMessage.inAppId) } override val onInAppShown = OnInAppShown { - inAppInteractor.saveShownInApp(inAppMessage.inAppId, System.currentTimeMillis()) + handleInAppShown(renderStartTime, preparedTimeMs, inAppMessage, tags) } override val onInAppDismiss = OnInAppDismiss { inAppInteractor.saveInAppDismissTime() @@ -194,6 +204,19 @@ internal class InAppMessageManagerImpl( } } + private fun handleInAppShown( + renderStartTime: Timestamp, + preparedTimeMs: Milliseconds, + inAppMessage: InAppType, + tags: Map? + ) { + val shownTime = timeProvider.currentTimestamp() + val renderTime = shownTime - renderStartTime + mindboxLogI("Render time is ${renderTime.ms}ms, prepared time is ${preparedTimeMs.interval}ms") + val timeToDisplay = (preparedTimeMs.interval + renderTime.ms).millisToTimeSpan() + inAppInteractor.saveShownInApp(inAppMessage.inAppId, shownTime.ms, timeToDisplay, tags) + } + companion object { const val CONFIG_NOT_FOUND = 404 } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt index 73f99d201..ab5637284 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt @@ -14,7 +14,8 @@ internal interface InAppMessageViewDisplayer { fun tryShowInAppMessage( inAppType: InAppType, - inAppActionCallbacks: InAppActionCallbacks + inAppActionCallbacks: InAppActionCallbacks, + onRenderStart: () -> Unit = {}, ) fun registerCurrentActivity(activity: Activity) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 1d6c50d45..97101257f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -127,9 +127,10 @@ internal class InAppMessageViewDisplayerImpl( override fun tryShowInAppMessage( inAppType: InAppType, - inAppActionCallbacks: InAppActionCallbacks + inAppActionCallbacks: InAppActionCallbacks, + onRenderStart: () -> Unit, ) { - val wrapper = InAppTypeWrapper(inAppType, inAppActionCallbacks) + val wrapper = InAppTypeWrapper(inAppType, inAppActionCallbacks, onRenderStart) if (isUiPresent() && currentHolder == null && pausedHolder == null) { val duration = Stopwatch.track(Stopwatch.INIT_SDK) @@ -156,7 +157,10 @@ internal class InAppMessageViewDisplayerImpl( wrapper: InAppTypeWrapper, isRestored: Boolean = false, ) { - if (!isRestored) isActionExecuted = false + if (!isRestored) { + wrapper.onRenderStart() + isActionExecuted = false + } if (isRestored && tryReattachRestoredInApp(wrapper.inAppType.inAppId)) return val callbackWrapper = InAppCallbackWrapper(inAppCallback) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 7992f238c..0d9733fef 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -264,6 +264,7 @@ internal class WebViewInAppViewHolder( failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, errorDescription = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}" ) + release() } } }) @@ -423,6 +424,7 @@ internal class WebViewInAppViewHolder( failureReason = FailureReason.WEBVIEW_LOAD_FAILED, errorDescription = "WebView content URL is null" ) + hide() } } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt index dfd6ddb0a..b8256c07a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt @@ -6,3 +6,12 @@ internal data class InAppHandleRequest( @SerializedName("inappId") val inAppId: String ) + +internal data class InAppShowRequest( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("timeToDisplay") + val timeToDisplay: String, + @SerializedName("tags") + val tags: Map? +) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt index f9be86f54..f42155dfd 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt @@ -12,6 +12,7 @@ import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.SlidingExpirationDt import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.InappSettingsDtoBlankDeserializer import com.google.gson.annotations.JsonAdapter import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.InAppDelayTimeDeserializer +import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.InAppTagsDeserializer internal data class InAppConfigResponse( @SerializedName("inapps") @@ -130,6 +131,8 @@ internal data class InAppDto( val targeting: TreeTargetingDto?, @SerializedName("form") val form: FormDto?, + @SerializedName(InAppTagsDeserializer.TAGS) + val tags: Map?, ) internal sealed class FrequencyDto { @@ -223,5 +226,8 @@ internal data class InAppConfigResponseBlank( // FormDto. Parsed after filtering inApp versions. @SerializedName("form") val form: JsonObject?, + @SerializedName("tags") + @JsonAdapter(InAppTagsDeserializer::class) + val tags: Map?, ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/TimeProvider.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/TimeProvider.kt index 30d1107d6..2313be075 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/TimeProvider.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/TimeProvider.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.utils +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.Timestamp import cloud.mindbox.mobile_sdk.models.toTimestamp @@ -7,10 +8,14 @@ internal interface TimeProvider { fun currentTimeMillis(): Long fun currentTimestamp(): Timestamp + + fun elapsedSince(startTimeMillis: Timestamp): Milliseconds } internal class SystemTimeProvider : TimeProvider { override fun currentTimeMillis() = System.currentTimeMillis() override fun currentTimestamp() = System.currentTimeMillis().toTimestamp() + + override fun elapsedSince(startTimeMillis: Timestamp): Milliseconds = Milliseconds(currentTimeMillis() - startTimeMillis.ms) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializerTest.kt new file mode 100644 index 000000000..5d03521fe --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializerTest.kt @@ -0,0 +1,101 @@ +package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers + +import cloud.mindbox.mobile_sdk.fromJsonTyped +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +internal class InAppTagsDeserializerTest { + + private lateinit var gson: Gson + + @Before + fun setUp() { + val mapType = object : TypeToken?>() {}.type + gson = GsonBuilder() + .registerTypeAdapter(mapType, InAppTagsDeserializer()) + .create() + } + + private fun deserialize(json: String): Map? = + gson.fromJsonTyped?>(json) + + @Test + fun `deserialize returns string values as is`() { + val inputJson = """{"layer": "webView", "type": "onboarding"}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "webView", "type" to "onboarding"), actualResult) + } + + @Test + fun `deserialize skips number values`() { + val inputJson = """{"layer": "webView", "count": 42}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "webView"), actualResult) + } + + @Test + fun `deserialize skips boolean values`() { + val inputJson = """{"layer": "webView", "isActive": true}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "webView"), actualResult) + } + + @Test + fun `deserialize skips object values`() { + val inputJson = """{"layer": "webView", "nested": {"key": "value"}}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "webView"), actualResult) + } + + @Test + fun `deserialize skips null values`() { + val inputJson = """{"layer": "webView", "nullKey": null}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "webView"), actualResult) + } + + @Test + fun `deserialize returns null when json is null`() { + val actualResult = deserialize("null") + assertNull(actualResult) + } + + @Test + fun `deserialize returns null when json is not an object`() { + val actualResult = deserialize("""["item1", "item2"]""") + assertNull(actualResult) + } + + @Test + fun `deserialize returns empty map when all values are non-string`() { + val inputJson = """{"count": 42, "flag": true, "nested": {}}""" + val actualResult = deserialize(inputJson) + assertTrue(actualResult?.isEmpty() == true) + } + + @Test + fun `deserialize returns empty map for empty object`() { + val actualResult = deserialize("{}") + assertTrue(actualResult?.isEmpty() == true) + } + + @Test + fun `deserialize preserves empty string values`() { + val inputJson = """{"layer": "", "type": "onboarding"}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "", "type" to "onboarding"), actualResult) + } + + @Test + fun `deserialize skips array values`() { + val inputJson = """{"layer": "webView", "items": [1, 2, 3]}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "webView"), actualResult) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt index 9de8b9487..71e300061 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt @@ -3,12 +3,14 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import com.google.gson.Gson import com.google.gson.reflect.TypeToken import io.mockk.every import io.mockk.mockk import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test @@ -69,22 +71,49 @@ internal class InAppSerializationManagerTest { } @Test - fun `serialize to inApp handled string success`() { + fun `serializeToInAppActionString returns JSON with inAppId only`() { val expectedResult = "{\"inappId\":\"${inAppId}\"}" - val actualResult = inAppSerializationManager.serializeToInAppHandledString(inAppId) + val actualResult = inAppSerializationManager.serializeToInAppActionString(inAppId) assertEquals(expectedResult, actualResult) } @Test - fun `serialize to inApp handled string error`() { + fun `serializeToInAppActionString returns empty string on error`() { val gson: Gson = mockk() - every { - gson.toJson(any()) - } throws Error("errorMessage") + every { gson.toJson(any(), any>()) } throws Error("errorMessage") inAppSerializationManager = InAppSerializationManagerImpl(gson) - val expectedResult = "" - val actualResult = inAppSerializationManager.serializeToInAppHandledString(inAppId) - assertEquals(expectedResult, actualResult) + val actualResult = inAppSerializationManager.serializeToInAppActionString(inAppId) + assertEquals("", actualResult) + } + + @Test + fun `serializeToInAppShownString returns JSON with inAppId timeToDisplay and tags`() { + val timeToDisplay = "0:00:00:00.2250000" + val tags = mapOf("layer" to "webView", "type" to "onboarding") + val actualResult = inAppSerializationManager.serializeToInAppShownActionString(inAppId, timeToDisplay, tags) + val parsed = gson.fromJson(actualResult, InAppShowRequest::class.java) + assertEquals(inAppId, parsed.inAppId) + assertEquals(timeToDisplay, parsed.timeToDisplay) + assertEquals(tags, parsed.tags) + } + + @Test + fun `serializeToInAppShownString omits tags when null`() { + val timeToDisplay = "0:00:00:00.2250000" + val actualResult = inAppSerializationManager.serializeToInAppShownActionString(inAppId, timeToDisplay, null) + val parsed = gson.fromJson(actualResult, InAppShowRequest::class.java) + assertEquals(inAppId, parsed.inAppId) + assertEquals(timeToDisplay, parsed.timeToDisplay) + assertNull(parsed.tags) + } + + @Test + fun `serializeToInAppShownString returns empty string on error`() { + val gson: Gson = mockk() + every { gson.toJson(any(), any>()) } throws Error("errorMessage") + inAppSerializationManager = InAppSerializationManagerImpl(gson) + val actualResult = inAppSerializationManager.serializeToInAppShownActionString(inAppId, "0:00:00:00.2250000", null) + assertEquals("", actualResult) } @Test diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/MobileConfigSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/MobileConfigSerializationManagerTest.kt index f0dc3fb56..72bf9fc91 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/MobileConfigSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/MobileConfigSerializationManagerTest.kt @@ -662,4 +662,150 @@ internal class MobileConfigSerializationManagerTest { }) })) } + + @Test + fun `deserialize to config dto blank with tags success`() { + val successJson = gson.toJson(JsonObject().apply { + add("inapps", JsonArray().apply { + add(JsonObject().apply { + addProperty("id", "040810aa-d135-49f4-8916-7e68dcc61c71") + add("sdkVersion", JsonObject().apply { + addProperty("min", 1) + add("max", com.google.gson.JsonNull.INSTANCE) + }) + add("targeting", JsonObject().apply { + addProperty("${"$"}type", "true") + }) + add("tags", JsonObject().apply { + addProperty("layer", "webView") + addProperty("type", "onboarding") + }) + add("form", JsonObject().apply { + add("variants", JsonArray()) + }) + }) + }) + }) + val expectedResult = InAppConfigStub.getConfigResponseBlank().copy( + inApps = listOf( + InAppStub.getInAppDtoBlank().copy( + id = "040810aa-d135-49f4-8916-7e68dcc61c71", + sdkVersion = InAppStub.getSdkVersion().copy(minVersion = 1, maxVersion = null), + targeting = JsonObject().apply { addProperty("${'$'}type", "true") }, + form = JsonObject().apply { add("variants", JsonArray()) }, + tags = mapOf("layer" to "webView", "type" to "onboarding") + ) + ) + ) + val actualResult = mobileConfigSerializationManager.deserializeToConfigDtoBlank(successJson) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `deserialize to config dto blank without tags field success`() { + val successJson = gson.toJson(JsonObject().apply { + add("inapps", JsonArray().apply { + add(JsonObject().apply { + addProperty("id", "040810aa-d135-49f4-8916-7e68dcc61c71") + add("sdkVersion", JsonObject().apply { + addProperty("min", 1) + add("max", com.google.gson.JsonNull.INSTANCE) + }) + add("targeting", JsonObject().apply { + addProperty("${"$"}type", "true") + }) + add("form", JsonObject().apply { + add("variants", JsonArray()) + }) + }) + }) + }) + val expectedResult = InAppConfigStub.getConfigResponseBlank().copy( + inApps = listOf( + InAppStub.getInAppDtoBlank().copy( + id = "040810aa-d135-49f4-8916-7e68dcc61c71", + sdkVersion = InAppStub.getSdkVersion().copy(minVersion = 1, maxVersion = null), + targeting = JsonObject().apply { addProperty("${'$'}type", "true") }, + form = JsonObject().apply { add("variants", JsonArray()) }, + tags = null + ) + ) + ) + val actualResult = mobileConfigSerializationManager.deserializeToConfigDtoBlank(successJson) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `deserialize to config dto blank with empty tags success`() { + val successJson = gson.toJson(JsonObject().apply { + add("inapps", JsonArray().apply { + add(JsonObject().apply { + addProperty("id", "040810aa-d135-49f4-8916-7e68dcc61c71") + add("sdkVersion", JsonObject().apply { + addProperty("min", 1) + add("max", com.google.gson.JsonNull.INSTANCE) + }) + add("targeting", JsonObject().apply { + addProperty("${"$"}type", "true") + }) + add("tags", JsonObject()) + add("form", JsonObject().apply { + add("variants", JsonArray()) + }) + }) + }) + }) + val expectedResult = InAppConfigStub.getConfigResponseBlank().copy( + inApps = listOf( + InAppStub.getInAppDtoBlank().copy( + id = "040810aa-d135-49f4-8916-7e68dcc61c71", + sdkVersion = InAppStub.getSdkVersion().copy(minVersion = 1, maxVersion = null), + targeting = JsonObject().apply { addProperty("${'$'}type", "true") }, + form = JsonObject().apply { add("variants", JsonArray()) }, + tags = emptyMap() + ) + ) + ) + val actualResult = mobileConfigSerializationManager.deserializeToConfigDtoBlank(successJson) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `deserialize to config dto blank with non-string tag values skips them success`() { + val successJson = gson.toJson(JsonObject().apply { + add("inapps", JsonArray().apply { + add(JsonObject().apply { + addProperty("id", "040810aa-d135-49f4-8916-7e68dcc61c71") + add("sdkVersion", JsonObject().apply { + addProperty("min", 1) + add("max", com.google.gson.JsonNull.INSTANCE) + }) + add("targeting", JsonObject().apply { + addProperty("${"$"}type", "true") + }) + add("tags", JsonObject().apply { + addProperty("layer", "webView") + addProperty("count", 42) + addProperty("flag", true) + }) + add("form", JsonObject().apply { + add("variants", JsonArray()) + }) + }) + }) + }) + val expectedResult = InAppConfigStub.getConfigResponseBlank().copy( + inApps = listOf( + InAppStub.getInAppDtoBlank().copy( + id = "040810aa-d135-49f4-8916-7e68dcc61c71", + sdkVersion = InAppStub.getSdkVersion().copy(minVersion = 1, maxVersion = null), + targeting = JsonObject().apply { addProperty("${'$'}type", "true") }, + form = JsonObject().apply { add("variants", JsonArray()) }, + tags = mapOf("layer" to "webView") + ) + ) + ) + val actualResult = mobileConfigSerializationManager.deserializeToConfigDtoBlank(successJson) + assertEquals(expectedResult, actualResult) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapperTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapperTest.kt index b6a1c72ae..f482fdbfe 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapperTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapperTest.kt @@ -54,6 +54,7 @@ class InAppMapperTest { ), ), form = null, + tags = null, ) ), monitoring = null, @@ -105,6 +106,7 @@ class InAppMapperTest { ), ), form = null, + tags = null, ) ), monitoring = null, @@ -140,6 +142,7 @@ class InAppMapperTest { ), ), form = null, + tags = null, ) ), monitoring = null, @@ -175,6 +178,7 @@ class InAppMapperTest { ), ), form = null, + tags = null, ) ), monitoring = null, @@ -210,6 +214,7 @@ class InAppMapperTest { ), ), form = null, + tags = null, ) ), monitoring = null, @@ -252,7 +257,8 @@ class InAppMapperTest { ), sdkVersion = null, targeting = TreeTargetingDto.TrueNodeDto(type = ""), - form = FormDto(variants = listOf(modalWindowDto)) + form = FormDto(variants = listOf(modalWindowDto)), + tags = null, ) ), monitoring = null, @@ -298,7 +304,8 @@ class InAppMapperTest { ), sdkVersion = null, targeting = TreeTargetingDto.TrueNodeDto(type = ""), - form = FormDto(variants = listOf(modalWindowDto)) + form = FormDto(variants = listOf(modalWindowDto)), + tags = null, ) ), monitoring = null, @@ -314,4 +321,109 @@ class InAppMapperTest { assertEquals(1, modalWindow.layers.size) assertTrue(modalWindow.layers.first() is Layer.ImageLayer) } + + @Test + fun `mapToInAppConfig maps tags from InAppDto to InApp`() { + val mapper = InAppMapper() + val inputTags = mapOf("layer" to "webView", "type" to "onboarding") + val result = mapper.mapToInAppConfig( + InAppConfigResponse( + inApps = listOf( + InAppDto( + id = "test-id", + isPriority = false, + delayTime = null, + frequency = FrequencyDto.FrequencyOnceDto(type = "once", kind = "lifetime"), + sdkVersion = null, + targeting = TreeTargetingDto.TrueNodeDto(type = ""), + form = null, + tags = inputTags, + ) + ), + monitoring = null, + abtests = null, + settings = null, + ) + ) + assertEquals(inputTags, result.inApps.first().tags) + } + + @Test + fun `mapToInAppConfig maps null tags to null in InApp`() { + val mapper = InAppMapper() + val result = mapper.mapToInAppConfig( + InAppConfigResponse( + inApps = listOf( + InAppDto( + id = "test-id", + isPriority = false, + delayTime = null, + frequency = FrequencyDto.FrequencyOnceDto(type = "once", kind = "lifetime"), + sdkVersion = null, + targeting = TreeTargetingDto.TrueNodeDto(type = ""), + form = null, + tags = null, + ) + ), + monitoring = null, + abtests = null, + settings = null, + ) + ) + assertNull(result.inApps.first().tags) + } + + @Test + fun `mapToInAppConfig maps empty tags map to null in InApp`() { + val mapper = InAppMapper() + val result = mapper.mapToInAppConfig( + InAppConfigResponse( + inApps = listOf( + InAppDto( + id = "test-id", + isPriority = false, + delayTime = null, + frequency = FrequencyDto.FrequencyOnceDto(type = "once", kind = "lifetime"), + sdkVersion = null, + targeting = TreeTargetingDto.TrueNodeDto(type = ""), + form = null, + tags = emptyMap(), + ) + ), + monitoring = null, + abtests = null, + settings = null, + ) + ) + assertNull(result.inApps.first().tags) + } + + @Test + fun `mapToInAppDto maps tags from InAppDtoBlank to InAppDto`() { + val mapper = InAppMapper() + val inputTags = mapOf("layer" to "webView", "type" to "onboarding") + val inputDtoBlank = InAppStub.getInAppDtoBlank().copy(tags = inputTags) + val result = mapper.mapToInAppDto( + inAppDtoBlank = inputDtoBlank, + delayTime = null, + formDto = null, + frequencyDto = FrequencyDto.FrequencyOnceDto(type = "once", kind = "lifetime"), + targetingDto = null, + ) + assertEquals(inputTags, result.tags) + } + + @Test + fun `mapToInAppDto maps null tags from InAppDtoBlank to InAppDto`() { + val mapper = InAppMapper() + val inputDtoBlank = InAppStub.getInAppDtoBlank().copy(tags = null) + val result = mapper.mapToInAppDto( + inAppDtoBlank = inputDtoBlank, + delayTime = null, + formDto = null, + frequencyDto = FrequencyDto.FrequencyOnceDto(type = "once", kind = "lifetime"), + targetingDto = null, + ) + assertNull(result.tags) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt index b3758017a..df681930d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt @@ -126,8 +126,8 @@ class InAppRepositoryTest { fun `send in app shown success`() { val testInAppId = "testInAppId" val serializedString = "serializedString" - every { inAppSerializationManager.serializeToInAppHandledString(any()) } returns serializedString - inAppRepository.sendInAppShown(testInAppId) + every { inAppSerializationManager.serializeToInAppShownActionString(any(), any(), any()) } returns serializedString + inAppRepository.sendInAppShown(testInAppId, "0:00:00:00.2250000", null) verify(exactly = 1) { MindboxEventManager.inAppShown(context, serializedString) } @@ -137,8 +137,8 @@ class InAppRepositoryTest { fun `send in app shown empty string`() { val testInAppId = "testInAppId" val serializedString = "" - every { inAppSerializationManager.serializeToInAppHandledString(any()) } returns serializedString - inAppRepository.sendInAppShown(testInAppId) + every { inAppSerializationManager.serializeToInAppShownActionString(any(), any(), any()) } returns serializedString + inAppRepository.sendInAppShown(testInAppId, "0:00:00:00.2250000", null) verify(exactly = 0) { MindboxEventManager.inAppShown(context, serializedString) } @@ -148,7 +148,7 @@ class InAppRepositoryTest { fun `send in app clicked success`() { val testInAppId = "testInAppId" val serializedString = "serializedString" - every { inAppSerializationManager.serializeToInAppHandledString(any()) } returns serializedString + every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString inAppRepository.sendInAppClicked(testInAppId) verify(exactly = 1) { MindboxEventManager.inAppClicked(context, serializedString) @@ -159,7 +159,7 @@ class InAppRepositoryTest { fun `send in app clicked empty string`() { val testInAppId = "testInAppId" val serializedString = "" - every { inAppSerializationManager.serializeToInAppHandledString(any()) } returns serializedString + every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString inAppRepository.sendInAppClicked(testInAppId) verify(exactly = 0) { MindboxEventManager.inAppClicked(context, serializedString) @@ -170,10 +170,10 @@ class InAppRepositoryTest { fun `send user targeted success`() { val testInAppId = "testInAppId" val serializedString = "serializedString" - every { inAppSerializationManager.serializeToInAppHandledString(any()) } returns serializedString - inAppRepository.sendInAppClicked(testInAppId) + every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString + inAppRepository.sendUserTargeted(testInAppId) verify(exactly = 1) { - MindboxEventManager.inAppClicked(context, serializedString) + MindboxEventManager.sendUserTargeted(context, serializedString) } } @@ -181,10 +181,10 @@ class InAppRepositoryTest { fun `send user targeted string`() { val testInAppId = "testInAppId" val serializedString = "" - every { inAppSerializationManager.serializeToInAppHandledString(any()) } returns serializedString - inAppRepository.sendInAppClicked(testInAppId) + every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString + inAppRepository.sendUserTargeted(testInAppId) verify(exactly = 0) { - MindboxEventManager.inAppClicked(context, serializedString) + MindboxEventManager.sendUserTargeted(context, serializedString) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt index fc7adfba9..6f7dd52de 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt @@ -68,7 +68,7 @@ class InAppInteractorImplTest { @MockK private lateinit var minIntervalBetweenShowsLimitChecker: Checker - @MockK + @RelaxedMockK private lateinit var timeProvider: TimeProvider @RelaxedMockK @@ -122,7 +122,7 @@ class InAppInteractorImplTest { isPriority = false, targeting = InAppStub.getTargetingTrueNode().copy("true"), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = "nonPriorityInapp1" ) @@ -134,7 +134,7 @@ class InAppInteractorImplTest { isPriority = true, targeting = InAppStub.getTargetingTrueNode().copy("true"), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = "priorityInapp" ) @@ -147,7 +147,7 @@ class InAppInteractorImplTest { isPriority = true, targeting = InAppStub.getTargetingTrueNode().copy("true"), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = "priorityInapp2" ) @@ -159,7 +159,7 @@ class InAppInteractorImplTest { isPriority = false, targeting = InAppStub.getTargetingTrueNode().copy("true"), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = "nonPriorityInApp2" ) @@ -213,19 +213,19 @@ class InAppInteractorImplTest { interactor.processEventAndConfig().test { eventFlow.emit(InAppEventType.AppStartup) val firstItem = awaitItem() - assertEquals(priorityInApp, firstItem) + assertEquals(priorityInApp, firstItem.first) eventFlow.emit(InAppEventType.AppStartup) val secondItem = awaitItem() - assertEquals(priorityInAppTwo, secondItem) + assertEquals(priorityInAppTwo, secondItem.first) eventFlow.emit(InAppEventType.AppStartup) val thirdItem = awaitItem() - assertEquals(nonPriorityInApp, thirdItem) + assertEquals(nonPriorityInApp, thirdItem.first) eventFlow.emit(InAppEventType.AppStartup) val fourthItem = awaitItem() - assertEquals(nonPriorityInAppTwo, fourthItem) + assertEquals(nonPriorityInAppTwo, fourthItem.first) cancelAndIgnoreRemainingEvents() } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManagerTest.kt index c4b35a9bf..7946d9a24 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManagerTest.kt @@ -25,10 +25,10 @@ class InAppMessageDelayedManagerTest { val inApp = InAppStub.getInApp().copy(delayTime = Milliseconds(10000)) every { timeProvider.currentTimeMillis() } answers { testDispatcher.scheduler.currentTime } - inAppMessageDelayedManager.process(inApp) + inAppMessageDelayedManager.process(inApp, Milliseconds(0L)) inAppMessageDelayedManager.inAppToShowFlow.test { advanceTimeBy(10_001) - assertEquals(inApp, awaitItem()) + assertEquals(inApp, awaitItem().first) cancelAndIgnoreRemainingEvents() } } @@ -38,13 +38,13 @@ class InAppMessageDelayedManagerTest { every { timeProvider.currentTimeMillis() } answers { testDispatcher.scheduler.currentTime } val inApp = InAppStub.getInApp().copy(delayTime = Milliseconds(10000)) - inAppMessageDelayedManager.process(inApp) + inAppMessageDelayedManager.process(inApp, Milliseconds(0L)) inAppMessageDelayedManager.inAppToShowFlow.test { advanceTimeBy(9_999) expectNoEvents() advanceTimeBy(1) - assertEquals(inApp, awaitItem()) + assertEquals(inApp, awaitItem().first) cancelAndIgnoreRemainingEvents() } } @@ -53,7 +53,7 @@ class InAppMessageDelayedManagerTest { fun `clearSession should cancel pending jobs and clear queue`() = runTest(testDispatcher.scheduler) { every { timeProvider.currentTimeMillis() } answers { testDispatcher.scheduler.currentTime } val inApp = InAppStub.getInApp().copy(delayTime = Milliseconds(10000)) - inAppMessageDelayedManager.process(inApp) + inAppMessageDelayedManager.process(inApp, Milliseconds(0L)) inAppMessageDelayedManager.clearSession() inAppMessageDelayedManager.inAppToShowFlow.test { testDispatcher.scheduler.advanceUntilIdle() @@ -67,11 +67,11 @@ class InAppMessageDelayedManagerTest { val inAppOne = InAppStub.getInApp().copy(id = "inApp1", delayTime = Milliseconds(10000)) val inAppTwo = InAppStub.getInApp().copy(id = "inApp2", delayTime = Milliseconds(5000), isPriority = true) - inAppMessageDelayedManager.process(inAppOne) - inAppMessageDelayedManager.process(inAppTwo) + inAppMessageDelayedManager.process(inAppOne, Milliseconds(0L)) + inAppMessageDelayedManager.process(inAppTwo, Milliseconds(0L)) inAppMessageDelayedManager.inAppToShowFlow.test { - assertEquals(inAppTwo, awaitItem()) + assertEquals(inAppTwo, awaitItem().first) cancelAndIgnoreRemainingEvents() } } @@ -82,11 +82,11 @@ class InAppMessageDelayedManagerTest { val inAppNonPriority = InAppStub.getInApp().copy(id = "inApp1", delayTime = Milliseconds(5000)) val inAppPriority = InAppStub.getInApp().copy(id = "inApp2", delayTime = Milliseconds(5000), isPriority = true) - inAppMessageDelayedManager.process(inAppNonPriority) - inAppMessageDelayedManager.process(inAppPriority) + inAppMessageDelayedManager.process(inAppNonPriority, Milliseconds(0L)) + inAppMessageDelayedManager.process(inAppPriority, Milliseconds(0L)) inAppMessageDelayedManager.inAppToShowFlow.test { - assertEquals(inAppPriority, awaitItem()) + assertEquals(inAppPriority, awaitItem().first) cancelAndIgnoreRemainingEvents() } } @@ -97,11 +97,11 @@ class InAppMessageDelayedManagerTest { val inAppFirst = InAppStub.getInApp().copy(id = "inApp1", delayTime = Milliseconds(5000), isPriority = true) val inAppSecond = InAppStub.getInApp().copy(id = "inApp2", delayTime = Milliseconds(5000), isPriority = true) - inAppMessageDelayedManager.process(inAppFirst) - inAppMessageDelayedManager.process(inAppSecond) + inAppMessageDelayedManager.process(inAppFirst, Milliseconds(0L)) + inAppMessageDelayedManager.process(inAppSecond, Milliseconds(0L)) inAppMessageDelayedManager.inAppToShowFlow.test { - assertEquals(inAppFirst, awaitItem()) + assertEquals(inAppFirst, awaitItem().first) cancelAndIgnoreRemainingEvents() } } @@ -113,12 +113,12 @@ class InAppMessageDelayedManagerTest { val inAppLoser1 = InAppStub.getInApp().copy(id = "loser1", delayTime = Milliseconds(5000), isPriority = false) val inAppLoser2 = InAppStub.getInApp().copy(id = "loser2", delayTime = Milliseconds(3000), isPriority = false) - inAppMessageDelayedManager.process(inAppWinner) - inAppMessageDelayedManager.process(inAppLoser1) - inAppMessageDelayedManager.process(inAppLoser2) + inAppMessageDelayedManager.process(inAppWinner, Milliseconds(0L)) + inAppMessageDelayedManager.process(inAppLoser1, Milliseconds(0L)) + inAppMessageDelayedManager.process(inAppLoser2, Milliseconds(0L)) advanceTimeBy(5000) inAppMessageDelayedManager.inAppToShowFlow.test { - assertEquals(inAppWinner, awaitItem()) + assertEquals(inAppWinner, awaitItem().first) expectNoEvents() } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt index 5d784fbeb..c0e33f37f 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt @@ -7,10 +7,12 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.managers.UserVisitManager import cloud.mindbox.mobile_sdk.models.InAppStub +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.monitoring.domain.interfaces.MonitoringInteractor import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.sortByPriority import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler +import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider import cloud.mindbox.mobile_sdk.utils.mockLogger import cloud.mindbox.mobile_sdk.utils.mockPreferencesConfigSetter import com.android.volley.NetworkResponse @@ -55,6 +57,8 @@ internal class InAppMessageManagerTest { private val testDispatcher = StandardTestDispatcher() + private val timeProvider = mockk() + /** * sets a thread to be used as main dispatcher for running on JVM * **/ @@ -90,7 +94,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) coEvery { inAppMessageInteractor.fetchMobileConfig() @@ -112,7 +117,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) mockkObject(LoggingExceptionHandler) every { MindboxPreferences.inAppConfig } returns "test" @@ -132,14 +138,14 @@ internal class InAppMessageManagerTest { @Test fun `in app messages success message shown`() = runTest { - val inAppToShowFlow = MutableSharedFlow() + val inAppToShowFlow = MutableSharedFlow>() val inApp = InAppStub.getInApp() every { inAppMessageViewDisplayer.isInAppActive() } returns false every { inAppMessageInteractor.areShowAndFrequencyLimitsAllowed(any()) } returns true every { inAppMessageDelayedManager.inAppToShowFlow } returns inAppToShowFlow - every { inAppMessageDelayedManager.process(inApp) } coAnswers { + every { inAppMessageDelayedManager.process(inApp, any()) } coAnswers { this@runTest.launch { - inAppToShowFlow.emit(inApp) + inAppToShowFlow.emit(inApp to Milliseconds(0L)) } } @@ -150,28 +156,27 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) coEvery { inAppMessageInteractor.processEventAndConfig() }.answers { flow { - emit( - inApp - ) + emit(inApp to Milliseconds(0L)) } } inAppMessageManager.listenEventAndInApp() advanceUntilIdle() - verify(exactly = 1) { inAppMessageDelayedManager.process(inApp) } - verify(exactly = 1) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any()) } + verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } + verify(exactly = 1) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } } @Test fun `in app messages success message not shown when inApp already active`() = runTest { - val inAppToShowFlow = MutableSharedFlow() + val inAppToShowFlow = MutableSharedFlow>() val inApp = InAppStub.getInApp() every { inAppMessageInteractor.areShowAndFrequencyLimitsAllowed(any()) } returns true every { inAppMessageViewDisplayer.isInAppActive() } returns true @@ -182,7 +187,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) coEvery { inAppMessageInteractor.listenToTargetingEvents() @@ -191,28 +197,26 @@ internal class InAppMessageManagerTest { inAppMessageInteractor.processEventAndConfig() }.answers { flow { - emit( - inApp - ) + emit(inApp to Milliseconds(0L)) } } every { inAppMessageDelayedManager.inAppToShowFlow } returns inAppToShowFlow - every { inAppMessageDelayedManager.process(inApp) } answers { + every { inAppMessageDelayedManager.process(inApp, any()) } answers { this@runTest.launch { - inAppToShowFlow.emit(inApp) + inAppToShowFlow.emit(inApp to Milliseconds(0L)) } } inAppMessageManager.listenEventAndInApp() advanceUntilIdle() - verify(exactly = 1) { inAppMessageDelayedManager.process(inApp) } + verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } coVerify(exactly = 1) { inAppMessageInteractor.listenToTargetingEvents() } - verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any()) } + verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } } @Test fun `in app messages success message not shown when inApp frequency or limits not allowed`() = runTest { - val inAppToShowFlow = MutableSharedFlow() + val inAppToShowFlow = MutableSharedFlow>() val inApp = InAppStub.getInApp() every { inAppMessageInteractor.areShowAndFrequencyLimitsAllowed(any()) } returns false every { inAppMessageViewDisplayer.isInAppActive() } returns false @@ -223,7 +227,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) coEvery { inAppMessageInteractor.listenToTargetingEvents() @@ -232,23 +237,21 @@ internal class InAppMessageManagerTest { inAppMessageInteractor.processEventAndConfig() }.answers { flow { - emit( - inApp - ) + emit(inApp to Milliseconds(0L)) } } every { inAppMessageDelayedManager.inAppToShowFlow } returns inAppToShowFlow - every { inAppMessageDelayedManager.process(inApp) } answers { + every { inAppMessageDelayedManager.process(inApp, any()) } answers { this@runTest.launch { - inAppToShowFlow.emit(inApp) + inAppToShowFlow.emit(inApp to Milliseconds(0L)) } } inAppMessageManager.listenEventAndInApp() advanceUntilIdle() - verify(exactly = 1) { inAppMessageDelayedManager.process(inApp) } + verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } coVerify(exactly = 1) { inAppMessageInteractor.listenToTargetingEvents() } - verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any()) } + verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } } @Test @@ -260,7 +263,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) coEvery { inAppMessageInteractor.processEventAndConfig() @@ -294,7 +298,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) mockkConstructor(NetworkResponse::class) val networkResponse = mockk() @@ -328,7 +333,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) mockkConstructor(NetworkResponse::class) val networkResponse = mockk() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MobileConfigSettingsManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MobileConfigSettingsManagerTest.kt index 43d055ec1..5a22a40b7 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MobileConfigSettingsManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MobileConfigSettingsManagerTest.kt @@ -4,13 +4,11 @@ import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.SettingsStub.Companion.getSlidingExpiration -import cloud.mindbox.mobile_sdk.models.Timestamp import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse import cloud.mindbox.mobile_sdk.models.operation.response.SettingsDto import cloud.mindbox.mobile_sdk.models.toTimestamp import cloud.mindbox.mobile_sdk.pushes.PushNotificationManager import cloud.mindbox.mobile_sdk.repository.MindboxPreferences -import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider import cloud.mindbox.mobile_sdk.utils.TimeProvider import io.mockk.* import kotlinx.coroutines.test.runTest @@ -21,21 +19,18 @@ import org.junit.Test class MobileConfigSettingsManagerImplTest { + private val mockTimeProvider = mockk() private lateinit var sessionStorageManager: SessionStorageManager private lateinit var mobileConfigSettingsManager: MobileConfigSettingsManagerImpl private val now = 100_000L @Before fun onTestStart() { - val realSessionStorageManager = SessionStorageManager(SystemTimeProvider()) - sessionStorageManager = spyk(realSessionStorageManager) - mobileConfigSettingsManager = MobileConfigSettingsManagerImpl(mockk(), sessionStorageManager, object : TimeProvider { - override fun currentTimeMillis(): Long = now - - override fun currentTimestamp(): Timestamp { - return now.toTimestamp() - } - }) + every { mockTimeProvider.currentTimeMillis() } returns now + every { mockTimeProvider.currentTimestamp() } returns now.toTimestamp() + + sessionStorageManager = spyk(SessionStorageManager(mockTimeProvider)) + mobileConfigSettingsManager = MobileConfigSettingsManagerImpl(mockk(), sessionStorageManager, mockTimeProvider) mockkObject(Mindbox) mockkObject(MindboxPreferences) mockkObject(MindboxEventManager) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt index 066ac53b6..7d374bb6c 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt @@ -22,7 +22,8 @@ internal class InAppStub { ), form = Form(variants = listOf(getModalWindow())), isPriority = false, - delayTime = null + delayTime = null, + tags = null, ) fun getInAppDto(): InAppDto = InAppDto( @@ -32,7 +33,8 @@ internal class InAppStub { targeting = (TreeTargetingDto.TrueNodeDto("")), form = FormDto(variants = listOf(getModalWindowDto())), isPriority = false, - delayTime = null + delayTime = null, + tags = null, ) fun getFrequencyOnceDto(): FrequencyDto.FrequencyOnceDto = FrequencyDto.FrequencyOnceDto( @@ -157,7 +159,8 @@ internal class InAppStub { sdkVersion = null, targeting = null, frequency = null, - form = null + form = null, + tags = null, ) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/TimeProviderTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/TimeProviderTest.kt new file mode 100644 index 000000000..0f16a400d --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/TimeProviderTest.kt @@ -0,0 +1,73 @@ +package cloud.mindbox.mobile_sdk.utils + +import cloud.mindbox.mobile_sdk.models.Timestamp +import io.mockk.every +import io.mockk.spyk +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class TimeProviderTest { + + private lateinit var timeProvider: SystemTimeProvider + + @Before + fun setup() { + timeProvider = spyk(SystemTimeProvider()) + } + + @Test + fun `elapsedSince returns positive difference when current time is greater`() { + val inputStartTimeMillis = Timestamp(1000L) + val expectedElapsed = 500L + every { timeProvider.currentTimeMillis() } returns 1500L + + val actualElapsed = timeProvider.elapsedSince(inputStartTimeMillis) + + assertEquals(expectedElapsed, actualElapsed.interval) + } + + @Test + fun `elapsedSince returns zero when current time equals start time`() { + val inputStartTimeMillis = Timestamp(1000L) + val expectedElapsed = 0L + every { timeProvider.currentTimeMillis() } returns 1000L + + val actualElapsed = timeProvider.elapsedSince(inputStartTimeMillis) + + assertEquals(expectedElapsed, actualElapsed.interval) + } + + @Test + fun `elapsedSince returns negative value when current time is less than start time`() { + val inputStartTimeMillis = Timestamp(2000L) + val expectedElapsed = -1000L + every { timeProvider.currentTimeMillis() } returns 1000L + + val actualElapsed = timeProvider.elapsedSince(inputStartTimeMillis) + + assertEquals(expectedElapsed, actualElapsed.interval) + } + + @Test + fun `elapsedSince returns correct value when start time is zero`() { + val inputStartTimeMillis = Timestamp(0L) + val expectedElapsed = 5000L + every { timeProvider.currentTimeMillis() } returns 5000L + + val actualElapsed = timeProvider.elapsedSince(inputStartTimeMillis) + + assertEquals(expectedElapsed, actualElapsed.interval) + } + + @Test + fun `elapsedSince returns correct value for large timestamps`() { + val inputStartTimeMillis = Timestamp(1_700_000_000_000L) + val expectedElapsed = 3500L + every { timeProvider.currentTimeMillis() } returns 1_700_000_003_500L + + val actualElapsed = timeProvider.elapsedSince(inputStartTimeMillis) + + assertEquals(expectedElapsed, actualElapsed.interval) + } +} diff --git a/sdk/src/test/resources/ConfigParsing/ConfigWithSettingsABTestsMonitoringInapps.json b/sdk/src/test/resources/ConfigParsing/ConfigWithSettingsABTestsMonitoringInapps.json index 4234fccae..045b13cac 100644 --- a/sdk/src/test/resources/ConfigParsing/ConfigWithSettingsABTestsMonitoringInapps.json +++ b/sdk/src/test/resources/ConfigParsing/ConfigWithSettingsABTestsMonitoringInapps.json @@ -20,6 +20,10 @@ ], "$type": "and" }, + "tags": { + "layer": "webView", + "type": "modal" + }, "form": { "variants": [ { From 29f903132d5d8cb5984acdb1eb97e521ea8add68 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 2 Mar 2026 16:36:30 +0300 Subject: [PATCH 34/59] MOBILEWEBVIEW-57: Fix log --- .../mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt index 3c461f8a8..26cbb475b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt @@ -59,7 +59,7 @@ internal class MindboxWebViewLinkRouter( val parsedUri: Uri = url.toUri() val scheme: String = parsedUri.scheme?.lowercase().orEmpty() if (scheme.isBlank()) { - throw IllegalStateException("Invalid URL: '$parsedUri' could not be parsed") + throw IllegalStateException("Invalid URL: '$url' could not be parsed") } if (scheme in BLOCKED_SCHEMES) { throw IllegalStateException("Blocked URL scheme: '$scheme'") From 728ddff179f0585c763b5bb332f57e3ffc84f20c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 08:18:11 +0000 Subject: [PATCH 35/59] Bump SDK version to 2.15.0-rc --- example/app/build.gradle | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/app/build.gradle b/example/app/build.gradle index 85e4b4269..1fbb71f6d 100644 --- a/example/app/build.gradle +++ b/example/app/build.gradle @@ -92,7 +92,7 @@ dependencies { implementation 'com.google.code.gson:gson:2.11.0' //Mindbox - implementation 'cloud.mindbox:mobile-sdk:2.14.5' + implementation 'cloud.mindbox:mobile-sdk:2.15.0-rc' implementation 'cloud.mindbox:mindbox-firebase' implementation 'cloud.mindbox:mindbox-huawei' implementation 'cloud.mindbox:mindbox-rustore' diff --git a/gradle.properties b/gradle.properties index d74a443de..c9ffc8477 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,7 +20,7 @@ android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # SDK version property -SDK_VERSION_NAME=2.14.5 +SDK_VERSION_NAME=2.15.0-rc USE_LOCAL_MINDBOX_COMMON=true android.nonTransitiveRClass=false kotlin.mpp.androidGradlePluginCompatibility.nowarn=true From fc8de0164586904453e8edf4da448df5dade5717 Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:29:54 +0300 Subject: [PATCH 36/59] MOBILEWEBVIEW-75: fix back button for modal window --- .../presentation/view/BackButtonHandler.kt | 22 ------------- .../presentation/view/BackButtonLayout.kt | 7 ----- .../view/InAppConstraintLayout.kt | 20 +----------- .../view/ModalWindowInAppViewHolder.kt | 31 ++++++++++++++++--- .../view/WebViewInappViewHolder.kt | 8 +---- 5 files changed, 28 insertions(+), 60 deletions(-) delete mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandler.kt delete mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonLayout.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandler.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandler.kt deleted file mode 100644 index d57d97d12..000000000 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandler.kt +++ /dev/null @@ -1,22 +0,0 @@ -package cloud.mindbox.mobile_sdk.inapp.presentation.view - -import android.view.KeyEvent -import android.view.View -import android.view.ViewGroup - -internal class BackButtonHandler( - private val viewGroup: ViewGroup, - private val listener: View.OnClickListener?, -) { - /** Returning "true" or "false" if the event was handled, "null" otherwise. */ - fun dispatchKeyEvent(event: KeyEvent?): Boolean? { - if (event != null && event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { - if (listener != null) { - listener.onClick(viewGroup) - return true - } - return false - } - return null - } -} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonLayout.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonLayout.kt deleted file mode 100644 index e07afa05b..000000000 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonLayout.kt +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.mindbox.mobile_sdk.inapp.presentation.view - -import android.view.View - -internal interface BackButtonLayout { - fun setDismissListener(listener: View.OnClickListener?) -} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt index 796093bf9..e213de238 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt @@ -15,9 +15,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.px import kotlin.math.abs -internal class InAppConstraintLayout : ConstraintLayout, BackButtonLayout { - - private var backButtonHandler: BackButtonHandler? = null +internal class InAppConstraintLayout : ConstraintLayout { fun setSwipeToDismissCallback(callback: () -> Unit) { swipeToDismissCallback = callback @@ -255,22 +253,6 @@ internal class InAppConstraintLayout : ConstraintLayout, BackButtonLayout { ) : super( context, attrs, defStyleAttr, defStyleRes ) - - override fun setDismissListener(listener: OnClickListener?) { - backButtonHandler = BackButtonHandler(this, listener) - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = - if (keyCode == KeyEvent.KEYCODE_BACK && backButtonHandler != null) { - true - } else { - super.onKeyDown(keyCode, event) - } - - override fun dispatchKeyEvent(event: KeyEvent?): Boolean { - val handled = backButtonHandler?.dispatchKeyEvent(event) - return handled ?: super.dispatchKeyEvent(event) - } } internal data class InAppInsets( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt index 53bc685ae..89ff7583c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt @@ -4,6 +4,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import androidx.activity.OnBackPressedCallback import androidx.core.view.isVisible import cloud.mindbox.mobile_sdk.R import cloud.mindbox.mobile_sdk.inapp.domain.models.Element @@ -21,16 +22,30 @@ internal class ModalWindowInAppViewHolder( ) : AbstractInAppViewHolder() { private var currentBackground: ViewGroup? = null + private var backPressedCallback: OnBackPressedCallback? = null override val isActive: Boolean get() = isInAppMessageActive - override fun bind() { - inAppLayout.setDismissListener { - inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) - mindboxLogI("In-app dismissed by dialog click") - hide() + private fun registerBackPressedCallback(): OnBackPressedCallback { + clearBackPressedCallback() + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) + mindboxLogI("In-app dismissed by back press") + hide() + } } + backPressedCallback = callback + return callback + } + + private fun clearBackPressedCallback() { + backPressedCallback?.remove() + backPressedCallback = null + } + + override fun bind() { wrapper.inAppType.elements.forEach { element -> when (element) { is Element.CloseButton -> { @@ -88,6 +103,12 @@ internal class ModalWindowInAppViewHolder( } mindboxLogI("Show ${wrapper.inAppType.inAppId} on ${this.hashCode()}") currentDialog.requestFocus() + currentRoot.registerBack(registerBackPressedCallback()) + } + + override fun hide() { + clearBackPressedCallback() + super.hide() } override fun initView(currentRoot: ViewGroup) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index f3cb5aae0..eb288fad1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -87,13 +87,7 @@ internal class WebViewInAppViewHolder( override val isActive: Boolean get() = isInAppMessageActive - override fun bind() { - inAppLayout.setDismissListener { - inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) - mindboxLogI("In-app dismissed by dialog click") - hide() - } - } + override fun bind() {} suspend fun sendActionAndAwaitResponse( controller: WebViewController, From 1416c0cbeb593b5ed7b5304d6aaa8893a6576d28 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 4 Mar 2026 12:12:14 +0300 Subject: [PATCH 37/59] MOBILEWEBVIEW-34: Refactoring close inapp --- .../presentation/InAppMessageManagerImpl.kt | 8 +-- .../presentation/InAppMessageViewDisplayer.kt | 2 +- .../InAppMessageViewDisplayerImpl.kt | 31 +++++------ .../view/AbstractInAppViewHolder.kt | 24 +++++---- .../view/InAppConstraintLayout.kt | 2 +- .../presentation/view/InAppViewHolder.kt | 12 ++++- .../view/ModalWindowInAppViewHolder.kt | 19 ++++--- .../view/SnackbarInAppViewHolder.kt | 20 ++++---- .../view/WebViewInappViewHolder.kt | 51 +++++++------------ 9 files changed, 80 insertions(+), 89 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt index e7916b306..e2c0c71be 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt @@ -4,19 +4,19 @@ import android.app.Activity import cloud.mindbox.mobile_sdk.InitializeLock import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager -import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.OnInAppClick import cloud.mindbox.mobile_sdk.inapp.domain.models.OnInAppDismiss import cloud.mindbox.mobile_sdk.inapp.domain.models.OnInAppShown import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.managers.MindboxEventManager +import cloud.mindbox.mobile_sdk.managers.UserVisitManager import cloud.mindbox.mobile_sdk.millisToTimeSpan import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.Timestamp -import cloud.mindbox.mobile_sdk.managers.MindboxEventManager -import cloud.mindbox.mobile_sdk.managers.UserVisitManager import cloud.mindbox.mobile_sdk.monitoring.domain.interfaces.MonitoringInteractor import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler @@ -189,7 +189,7 @@ internal class InAppMessageManagerImpl( override fun handleSessionExpiration() { inAppScope.launch { withContext(Dispatchers.Main) { - inAppMessageViewDisplayer.hideCurrentInApp() + inAppMessageViewDisplayer.closeCurrentInApp() } processingJob?.cancel() inAppInteractor.resetInAppConfigAndEvents() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt index ab5637284..b0026e5ee 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt @@ -24,5 +24,5 @@ internal interface InAppMessageViewDisplayer { fun isInAppActive(): Boolean - fun hideCurrentInApp() + fun closeCurrentInApp() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 97101257f..39002e88e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -74,10 +74,9 @@ internal class InAppMessageViewDisplayerImpl( inAppActionCallbacks = wrapper.inAppActionCallbacks.copy(onInAppShown = { mindboxLogI("Skip InApp.Show for restored inApp") currentActivity?.postDelayedAnimation { - pausedHolder?.hide() + pausedHolder?.onClose() } - } - ) + }) ), isRestored = true ) @@ -113,7 +112,7 @@ internal class InAppMessageViewDisplayerImpl( override fun onStopCurrentActivity(activity: Activity) { mindboxLogI("onStopCurrentActivity: ${activity.hashCode()}") - pausedHolder?.hide() + pausedHolder?.onClose() } override fun onPauseCurrentActivity(activity: Activity) { @@ -165,25 +164,26 @@ internal class InAppMessageViewDisplayerImpl( val callbackWrapper = InAppCallbackWrapper(inAppCallback) { wrapper.inAppActionCallbacks.onInAppDismiss.onDismiss() - pausedHolder?.hide() - pausedHolder = null - currentHolder = null } + val controller = InAppViewHolder.InAppController { closeCurrentInApp() } @Suppress("UNCHECKED_CAST") currentHolder = when (wrapper.inAppType) { is InAppType.WebView -> WebViewInAppViewHolder( wrapper = wrapper as InAppTypeWrapper, + controller = controller, inAppCallback = callbackWrapper ) is InAppType.ModalWindow -> ModalWindowInAppViewHolder( wrapper = wrapper as InAppTypeWrapper, + controller = controller, inAppCallback = callbackWrapper ) is InAppType.Snackbar -> SnackbarInAppViewHolder( wrapper = wrapper as InAppTypeWrapper, + controller = controller, inAppCallback = callbackWrapper, inAppImageSizeStorage = inAppImageSizeStorage, isFirstShow = !isRestored @@ -195,7 +195,7 @@ internal class InAppMessageViewDisplayerImpl( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying draw inapp", - onFailure = { runCatching { currentHolder?.hide() } } + onFailure = ::closeCurrentInApp ) { currentHolder?.show(createMindboxView(root)) } @@ -224,7 +224,7 @@ internal class InAppMessageViewDisplayerImpl( inAppId = inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying reattach InApp", - onFailure = { runCatching { restoredHolder.hide() } }, + onFailure = ::closeCurrentInApp, ) { restoredHolder.reattach(createMindboxView(root)) } @@ -248,7 +248,8 @@ internal class InAppMessageViewDisplayerImpl( } } - override fun hideCurrentInApp() { + override fun closeCurrentInApp() { + mindboxLogI("Close current in-app ${currentHolder?.wrapper?.inAppType?.inAppId}") loggingRunCatching { if (isInAppActive()) { currentHolder?.wrapper?.inAppActionCallbacks @@ -256,15 +257,9 @@ internal class InAppMessageViewDisplayerImpl( ?.onInAppDismiss ?.onDismiss() } - currentHolder?.apply { - hide() - release() - } + currentHolder?.onClose() currentHolder = null - pausedHolder?.apply { - hide() - release() - } + pausedHolder?.onClose() pausedHolder = null inAppQueue.clear() isActionExecuted = false diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt index 70b85b427..c8e07a575 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt @@ -10,14 +10,15 @@ import android.widget.FrameLayout import android.widget.ImageView import cloud.mindbox.mobile_sdk.R import cloud.mindbox.mobile_sdk.di.mindboxInject +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageViewDisplayerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import cloud.mindbox.mobile_sdk.inapp.presentation.actions.InAppActionHandler -import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure -import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.removeChildById import cloud.mindbox.mobile_sdk.safeAs @@ -29,9 +30,15 @@ import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target -internal abstract class AbstractInAppViewHolder : InAppViewHolder { +internal abstract class AbstractInAppViewHolder( + final override val wrapper: InAppTypeWrapper, + final override val inAppController: InAppViewHolder.InAppController, + final override val inAppCallback: InAppCallback, +) : InAppViewHolder { protected open var isInAppMessageActive = false + override val isActive: Boolean + get() = isInAppMessageActive private var positionController: InAppPositionController? = null @@ -47,9 +54,6 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder protected val preparedImages: MutableMap = mutableMapOf() - private val mindboxNotificationManager by mindboxInject { - mindboxNotificationManager - } internal val inAppFailureTracker: InAppFailureTracker by mindboxInject { inAppFailureTracker } private var inAppActionHandler = InAppActionHandler() @@ -96,7 +100,7 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder if (shouldDismiss) { inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by click") - hide() + inAppController.close() } inAppData.onCompleted?.invoke() @@ -123,7 +127,7 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder errorDescription = "Failed to load in-app image with url = $url", throwable = e ) - hide() + inAppController.close() false }.getOrElse { throwable -> inAppFailureTracker.sendPresentationFailure( @@ -221,11 +225,11 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder inAppActionHandler.mindboxView = currentRoot } - override fun hide() { + override fun onClose() { positionController?.stop() positionController = null currentDialog.parent.safeAs()?.removeView(_currentDialog) - mindboxLogI("hide ${wrapper.inAppType.inAppId} on ${this.hashCode()}") + mindboxLogI("Close ${wrapper.inAppType.inAppId} on ${this.hashCode()}") restoreKeyboard() } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt index e213de238..8313f3a3f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt @@ -219,7 +219,7 @@ internal class InAppConstraintLayout : ConstraintLayout { ).bottom ) mindboxLogI("Webview Insets: $inset") - WindowInsetsCompat.CONSUMED + windowInset } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt index 091c0875c..b04f688d8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt @@ -2,12 +2,17 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView internal interface InAppViewHolder { val wrapper: InAppTypeWrapper + val inAppController: InAppController + + val inAppCallback: InAppCallback + val isActive: Boolean fun show(currentRoot: MindboxView) @@ -18,7 +23,10 @@ internal interface InAppViewHolder { fun canReuseOnRestore(inAppId: String): Boolean = false - fun hide() + fun onClose() - fun release() {} + fun interface InAppController { + + fun close() + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt index 89ff7583c..e6a494012 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt @@ -17,29 +17,28 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.removeChildById internal class ModalWindowInAppViewHolder( - override val wrapper: InAppTypeWrapper, - private val inAppCallback: InAppCallback, -) : AbstractInAppViewHolder() { + wrapper: InAppTypeWrapper, + controller: InAppViewHolder.InAppController, + inAppCallback: InAppCallback, +) : AbstractInAppViewHolder(wrapper, controller, inAppCallback) { private var currentBackground: ViewGroup? = null private var backPressedCallback: OnBackPressedCallback? = null - override val isActive: Boolean - get() = isInAppMessageActive - private fun registerBackPressedCallback(): OnBackPressedCallback { clearBackPressedCallback() val callback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by back press") - hide() + inAppController.close() } } backPressedCallback = callback return callback } + private fun clearBackPressedCallback() { backPressedCallback?.remove() backPressedCallback = null @@ -54,9 +53,9 @@ internal class ModalWindowInAppViewHolder( element ).apply { setOnClickListener { - mindboxLogI("In-app dismissed by close click") inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) - hide() + mindboxLogI("In-app dismissed by close click") + inAppController.close() } } inAppLayout.addView(inAppCrossView) @@ -68,7 +67,7 @@ internal class ModalWindowInAppViewHolder( setOnClickListener { inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by background click") - hide() + inAppController.close() } isVisible = true diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt index 87a86f856..49ce7af4e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt @@ -11,14 +11,12 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.px internal class SnackbarInAppViewHolder( - override val wrapper: InAppTypeWrapper, - private val inAppCallback: InAppCallback, + wrapper: InAppTypeWrapper, + controller: InAppViewHolder.InAppController, + inAppCallback: InAppCallback, private val inAppImageSizeStorage: InAppImageSizeStorage, private val isFirstShow: Boolean = true, -) : AbstractInAppViewHolder() { - - override val isActive: Boolean - get() = isInAppMessageActive +) : AbstractInAppViewHolder(wrapper, controller, inAppCallback) { private var requiredSizes: HashMap = HashMap() @@ -42,7 +40,7 @@ internal class SnackbarInAppViewHolder( super.initView(currentRoot) inAppLayout.setSwipeToDismissCallback { mindboxLogI("In-app dismissed by swipe") - hideWithAnimation() + closeWithAnimation() } } @@ -85,7 +83,7 @@ internal class SnackbarInAppViewHolder( val inAppCrossView = InAppCrossView(currentDialog.context, element).apply { setOnClickListener { mindboxLogI("In-app dismissed by close click") - hideWithAnimation() + closeWithAnimation() } } inAppLayout.addView(inAppCrossView) @@ -101,17 +99,17 @@ internal class SnackbarInAppViewHolder( } } - private fun hideWithAnimation() { + private fun closeWithAnimation() { inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) when (wrapper.inAppType.position.gravity.vertical) { SnackbarPosition.TOP -> inAppLayout.slideDown( isReverse = true, - onAnimationEnd = ::hide + onAnimationEnd = inAppController::close ) SnackbarPosition.BOTTOM -> inAppLayout.slideUp( isReverse = true, - onAnimationEnd = ::hide + onAnimationEnd = inAppController::close ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index eb288fad1..ae06e8153 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -50,9 +50,10 @@ import kotlin.concurrent.timer @OptIn(InternalMindboxApi::class) internal class WebViewInAppViewHolder( - override val wrapper: InAppTypeWrapper, - private val inAppCallback: InAppCallback, -) : AbstractInAppViewHolder() { + wrapper: InAppTypeWrapper, + controller: InAppViewHolder.InAppController, + inAppCallback: InAppCallback, +) : AbstractInAppViewHolder(wrapper, controller, inAppCallback) { companion object { private const val INIT_TIMEOUT_MS = 7_000L @@ -84,9 +85,6 @@ internal class WebViewInAppViewHolder( MindboxWebViewLinkRouter(appContext) } - override val isActive: Boolean - get() = isInAppMessageActive - override fun bind() {} suspend fun sendActionAndAwaitResponse( @@ -203,8 +201,7 @@ internal class WebViewInAppViewHolder( private fun handleCloseAction(message: BridgeMessage): String { inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") - hide() - release() + inAppController.close() return BridgeMessage.EMPTY_PAYLOAD } @@ -279,7 +276,7 @@ internal class WebViewInAppViewHolder( failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, errorDescription = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}" ) - release() + inAppController.close() } } }) @@ -342,6 +339,7 @@ internal class WebViewInAppViewHolder( private fun clearBackPressedCallback() { backPressedCallback?.remove() + backPressedCallback = null } private fun sendBackAction(controller: WebViewController) { @@ -352,7 +350,7 @@ internal class WebViewInAppViewHolder( sendActionInternal(controller, message) { error -> mindboxLogW("Failed to send back action to WebView: $error") inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) - hide() + inAppController.close() } } @@ -365,7 +363,7 @@ internal class WebViewInAppViewHolder( failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, errorDescription = "evaluateJavaScript return unexpected response: $response" ) - hide() + inAppController.close() false } } @@ -430,7 +428,7 @@ internal class WebViewInAppViewHolder( mindboxLogW("WebView error: ${message.payload}") val responseDeferred: CompletableDeferred? = pendingResponsesById.remove(message.id) responseDeferred?.cancel("WebView error: ${message.payload}") - hide() + inAppController.close() } private fun cancelPendingResponses(reason: String) { @@ -490,8 +488,7 @@ internal class WebViewInAppViewHolder( errorDescription = "Failed to fetch HTML content for In-App", throwable = e ) - hide() - release() + inAppController.close() } } ?: run { inAppFailureTracker.sendFailureWithContext( @@ -499,7 +496,7 @@ internal class WebViewInAppViewHolder( failureReason = FailureReason.WEBVIEW_LOAD_FAILED, errorDescription = "WebView content URL is null" ) - hide() + inAppController.close() } } } @@ -522,7 +519,7 @@ internal class WebViewInAppViewHolder( failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, errorDescription = "WebView controller is null when trying show inapp" ) - release() + inAppController.close() } } @@ -538,8 +535,7 @@ internal class WebViewInAppViewHolder( errorDescription = "WebView initialization timed out after ${Stopwatch.stop(TIMER)}." ) controller.executeOnViewThread { - hide() - release() + inAppController.close() } } } ?: run { @@ -608,28 +604,19 @@ internal class WebViewInAppViewHolder( override fun canReuseOnRestore(inAppId: String): Boolean = wrapper.inAppType.inAppId == inAppId - override fun hide() { - // Clean up timeout when hiding + override fun onClose() { stopTimer() - cancelPendingResponses("WebView In-App is hidden") + cancelPendingResponses("WebView In-App is closed") clearBackPressedCallback() webViewController?.let { controller -> val view: WebViewPlatformView = controller.view - inAppLayout.removeView(view) + view.parent.safeAs()?.removeView(view) + controller.destroy() } - super.hide() - } - - override fun release() { - super.release() - // Clean up WebView resources - stopTimer() - cancelPendingResponses("WebView In-App is released") - clearBackPressedCallback() currentWebViewOrigin = null webViewController?.destroy() webViewController = null - backPressedCallback = null + super.onClose() } private data class NavigationInterceptedPayload( From 1c4c988a9f03a6e1d9851f97f2b2adf6efcebecb Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 5 Mar 2026 12:14:40 +0300 Subject: [PATCH 38/59] MOBILEWEBVIEW-34: Refactoring stop inapp --- .../inapp/presentation/InAppMessageViewDisplayerImpl.kt | 2 +- .../inapp/presentation/view/AbstractInAppViewHolder.kt | 4 ++++ .../mobile_sdk/inapp/presentation/view/InAppViewHolder.kt | 2 ++ .../inapp/presentation/view/WebViewInappViewHolder.kt | 4 ++++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 39002e88e..3066c79fa 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -112,7 +112,7 @@ internal class InAppMessageViewDisplayerImpl( override fun onStopCurrentActivity(activity: Activity) { mindboxLogI("onStopCurrentActivity: ${activity.hashCode()}") - pausedHolder?.onClose() + pausedHolder?.onStop() } override fun onPauseCurrentActivity(activity: Activity) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt index c8e07a575..b43233e9f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt @@ -232,4 +232,8 @@ internal abstract class AbstractInAppViewHolder( mindboxLogI("Close ${wrapper.inAppType.inAppId} on ${this.hashCode()}") restoreKeyboard() } + + override fun onStop() { + onClose() + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt index b04f688d8..927212e66 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt @@ -25,6 +25,8 @@ internal interface InAppViewHolder { fun onClose() + fun onStop() + fun interface InAppController { fun close() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index ae06e8153..1da0b27c8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -604,6 +604,10 @@ internal class WebViewInAppViewHolder( override fun canReuseOnRestore(inAppId: String): Boolean = wrapper.inAppType.inAppId == inAppId + override fun onStop() { + // do nothing + } + override fun onClose() { stopTimer() cancelPendingResponses("WebView In-App is closed") From 5853c2d692cff869d9cc24973d350f70e87426b4 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 6 Mar 2026 18:52:55 +0300 Subject: [PATCH 39/59] MOBILEWEBVIEW-34: Fix trigger dismiss --- .../presentation/InAppMessageManagerImpl.kt | 2 +- .../presentation/InAppMessageViewDisplayer.kt | 2 +- .../InAppMessageViewDisplayerImpl.kt | 16 +++++++++++----- .../view/ModalWindowInAppViewHolder.kt | 5 ++--- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt index e2c0c71be..15edca038 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt @@ -189,7 +189,7 @@ internal class InAppMessageManagerImpl( override fun handleSessionExpiration() { inAppScope.launch { withContext(Dispatchers.Main) { - inAppMessageViewDisplayer.closeCurrentInApp() + inAppMessageViewDisplayer.dismissCurrentInApp() } processingJob?.cancel() inAppInteractor.resetInAppConfigAndEvents() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt index b0026e5ee..db2a4fdbb 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt @@ -24,5 +24,5 @@ internal interface InAppMessageViewDisplayer { fun isInAppActive(): Boolean - fun closeCurrentInApp() + fun dismissCurrentInApp() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 3066c79fa..b9c2e4412 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -165,7 +165,7 @@ internal class InAppMessageViewDisplayerImpl( val callbackWrapper = InAppCallbackWrapper(inAppCallback) { wrapper.inAppActionCallbacks.onInAppDismiss.onDismiss() } - val controller = InAppViewHolder.InAppController { closeCurrentInApp() } + val controller = InAppViewHolder.InAppController { closeInApp() } @Suppress("UNCHECKED_CAST") currentHolder = when (wrapper.inAppType) { @@ -189,13 +189,14 @@ internal class InAppMessageViewDisplayerImpl( isFirstShow = !isRestored ) } + pausedHolder = null currentActivity?.root?.let { root -> inAppFailureTracker.executeWithFailureTracking( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying draw inapp", - onFailure = ::closeCurrentInApp + onFailure = ::closeInApp ) { currentHolder?.show(createMindboxView(root)) } @@ -224,7 +225,7 @@ internal class InAppMessageViewDisplayerImpl( inAppId = inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying reattach InApp", - onFailure = ::closeCurrentInApp, + onFailure = ::closeInApp, ) { restoredHolder.reattach(createMindboxView(root)) } @@ -248,8 +249,7 @@ internal class InAppMessageViewDisplayerImpl( } } - override fun closeCurrentInApp() { - mindboxLogI("Close current in-app ${currentHolder?.wrapper?.inAppType?.inAppId}") + override fun dismissCurrentInApp() { loggingRunCatching { if (isInAppActive()) { currentHolder?.wrapper?.inAppActionCallbacks @@ -257,6 +257,12 @@ internal class InAppMessageViewDisplayerImpl( ?.onInAppDismiss ?.onDismiss() } + } + closeInApp() + } + + private fun closeInApp() { + loggingRunCatching { currentHolder?.onClose() currentHolder = null pausedHolder?.onClose() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt index e6a494012..42a3101bf 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt @@ -38,7 +38,6 @@ internal class ModalWindowInAppViewHolder( return callback } - private fun clearBackPressedCallback() { backPressedCallback?.remove() backPressedCallback = null @@ -105,9 +104,9 @@ internal class ModalWindowInAppViewHolder( currentRoot.registerBack(registerBackPressedCallback()) } - override fun hide() { + override fun onClose() { clearBackPressedCallback() - super.hide() + super.onClose() } override fun initView(currentRoot: ViewGroup) { From 55f1f0587fb98a42d6587578183773118c0dad6d Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 11 Mar 2026 17:43:57 +0300 Subject: [PATCH 40/59] MOBILEWEBVIEW-94: Add local state storage --- .../inapp/presentation/view/WebViewAction.kt | 9 ++ .../view/WebViewInappViewHolder.kt | 27 ++++++ .../view/WebViewLocalStateStore.kt | 80 ++++++++++++++++ .../repository/MindboxPreferences.kt | 12 +++ .../view/WebViewLocalStateStoreTest.kt | 96 +++++++++++++++++++ 5 files changed, 224 insertions(+) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index d9f718f9b..e7be54989 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -45,6 +45,15 @@ public enum class WebViewAction { @SerializedName("navigationIntercepted") NAVIGATION_INTERCEPTED, + + @SerializedName("localState.get") + LOCAL_STATE_GET, + + @SerializedName("localState.set") + LOCAL_STATE_SET, + + @SerializedName("localState.init") + LOCAL_STATE_INIT, } @InternalMindboxApi diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 1da0b27c8..e04e1efeb 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -84,6 +84,9 @@ internal class WebViewInAppViewHolder( private val linkRouter: WebViewLinkRouter by lazy { MindboxWebViewLinkRouter(appContext) } + private val localStateStore: WebViewLocalStateStore by lazy { + WebViewLocalStateStore(appContext) + } override fun bind() {} @@ -132,6 +135,15 @@ internal class WebViewInAppViewHolder( register(WebViewAction.ASYNC_OPERATION, ::handleAsyncOperationAction) register(WebViewAction.OPEN_LINK, ::handleOpenLinkAction) registerSuspend(WebViewAction.SYNC_OPERATION, ::handleSyncOperationAction) + register(WebViewAction.LOCAL_STATE_GET) { message -> + handleLocalStateGetAction(message) + } + register(WebViewAction.LOCAL_STATE_SET) { message -> + handleLocalStateSetAction(message) + } + register(WebViewAction.LOCAL_STATE_INIT) { message -> + handleLocalStateInitAction(message) + } register(WebViewAction.READY) { handleReadyAction( configuration = configuration, @@ -249,6 +261,21 @@ internal class WebViewInAppViewHolder( return operationExecutor.executeSyncOperation(message.payload) } + private fun handleLocalStateGetAction(message: BridgeMessage.Request): String { + val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD + return localStateStore.getState(payload) + } + + private fun handleLocalStateSetAction(message: BridgeMessage.Request): String { + val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD + return localStateStore.setState(payload) + } + + private fun handleLocalStateInitAction(message: BridgeMessage.Request): String { + val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD + return localStateStore.initState(payload) + } + private fun createWebViewController(layer: Layer.WebViewLayer): WebViewController { mindboxLogI("Creating WebView for In-App: ${wrapper.inAppType.inAppId} with layer ${layer.type}") val controller: WebViewController = WebViewController.create(currentDialog.context, BuildConfig.DEBUG) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt new file mode 100644 index 000000000..551dc69fd --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt @@ -0,0 +1,80 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import org.json.JSONArray +import org.json.JSONObject + +internal class WebViewLocalStateStore( + context: Context +) { + companion object { + private const val LOCAL_STATE_FILE_NAME: String = "mindbox_webview_local_state" + private const val FIELD_DATA: String = "data" + private const val FIELD_VERSION: String = "version" + } + + private val localStatePreferences: SharedPreferences = + context.getSharedPreferences(LOCAL_STATE_FILE_NAME, Context.MODE_PRIVATE) + + fun getState(payload: String): String { + val requestedKeys: JSONArray = JSONObject(payload).optJSONArray(FIELD_DATA) ?: JSONArray() + val keys: List = (0.. requestedKeys.getString(i) } + val savedData: Map = localStatePreferences.all.mapValues { it.value?.toString() } + + return buildResponse( + data = savedData + .takeIf { keys.isEmpty() } + ?: keys.associateWith { key -> savedData[key] } + ) + } + + fun setState(payload: String): String { + val jsonData: JSONObject = JSONObject(payload).getJSONObject(FIELD_DATA) + val dataToSet = jsonData.toMap() + + localStatePreferences.edit { + dataToSet.forEach { (key, value) -> + value?.let { putString(key, value) } + ?: remove(key) + } + } + + return buildResponse(data = dataToSet) + } + + fun initState(payload: String): String { + val payloadObject: JSONObject = JSONObject(payload) + val jsonData: JSONObject = payloadObject.getJSONObject(FIELD_DATA) + val version: Int = payloadObject.getInt(FIELD_VERSION) + require(version > 0) { "Version must be greater than 0" } + + MindboxPreferences.localStateVersion = version + + return setState(payload = payload) + } + + private fun JSONObject.toMap(): Map { + val keysIterator: Iterator = this.keys() + val resultMap: MutableMap = mutableMapOf() + while (keysIterator.hasNext()) { + val key: String = keysIterator.next() + val value: Any? = this.opt(key) + if (value == null || value == JSONObject.NULL) { + resultMap[key] = null + } else { + resultMap[key] = value.toString() + } + } + return resultMap + } + + private fun buildResponse(data: Map): String { + val responseObject: JSONObject = JSONObject() + .put(FIELD_DATA, JSONObject(data)) + .put(FIELD_VERSION, MindboxPreferences.localStateVersion) + return responseObject.toString() + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt index 8c8500155..f3725130e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt @@ -36,6 +36,8 @@ internal object MindboxPreferences { private const val KEY_SDK_VERSION_CODE = "key_sdk_version_code" private const val KEY_LAST_INFO_UPDATE_TIME = "key_last_info_update_time" private const val KEY_LAST_INAPP_CHANGE_STATE_TIME = "key_last_inapp_change_state_time" + private const val KEY_LOCAL_STATE_VERSION = "local_state_version" + private const val DEFAULT_LOCAL_STATE_VERSION = 1 private val prefScope = CoroutineScope(Dispatchers.Default) @@ -252,4 +254,14 @@ internal object MindboxPreferences { SharedPreferencesManager.put(KEY_LAST_INAPP_CHANGE_STATE_TIME, value.ms) } } + + var localStateVersion: Int + get() = loggingRunCatching(defaultValue = DEFAULT_LOCAL_STATE_VERSION) { + SharedPreferencesManager.getInt(KEY_LOCAL_STATE_VERSION, DEFAULT_LOCAL_STATE_VERSION) + } + set(value) { + loggingRunCatching { + SharedPreferencesManager.put(KEY_LOCAL_STATE_VERSION, value) + } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt new file mode 100644 index 000000000..20b304e31 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt @@ -0,0 +1,96 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.managers.SharedPreferencesManager +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class WebViewLocalStateStoreTest { + + companion object { + private const val LOCAL_STATE_FILE_NAME: String = "mindbox_webview_local_state" + } + + private lateinit var context: Context + private lateinit var store: WebViewLocalStateStore + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + context.getSharedPreferences(LOCAL_STATE_FILE_NAME, Context.MODE_PRIVATE).edit().clear().apply() + context.getSharedPreferences("preferences", Context.MODE_PRIVATE).edit().clear().apply() + SharedPreferencesManager.with(context) + MindboxPreferences.localStateVersion = 1 + store = WebViewLocalStateStore(context) + } + + @Test + fun `getState returns default version and empty data when storage is empty`() { + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + assertEquals(1, actualResponse.getInt("version")) + assertEquals(0, actualResponse.getJSONObject("data").length()) + } + + @Test + fun `initState stores values and getState returns requested keys with null for missing`() { + store.initState("""{"data":{"key1":"value1","key2":"value2"},"version":2}""") + val actualResponse: JSONObject = store.getState("""{"data":["key1","missing"]}""").toJsonObject() + assertEquals(2, actualResponse.getInt("version")) + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertEquals("value1", actualData.getString("key1")) + assertTrue(actualData.isNull("missing")) + } + + @Test + fun `setState updates values and removes fields with null`() { + store.initState("""{"data":{"key1":"value1","key2":"value2"},"version":3}""") + store.setState("""{"data":{"key1":"updated","key2":null,"key3":"value3"}}""") + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + assertEquals(3, actualResponse.getInt("version")) + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertEquals("updated", actualData.getString("key1")) + assertFalse(actualData.has("key2")) + assertEquals("value3", actualData.getString("key3")) + } + + @Test + fun `initState returns error when requested version is lower than current`() { + store.initState("""{"data":{"key":"value"},"version":5}""") + val actualError: IllegalArgumentException = assertThrows(IllegalArgumentException::class.java) { + store.initState("""{"data":{"key":"next"},"version":0}""") + } + assertTrue(actualError.message?.contains("Version must be greater than 0") == true) + } + + @Test + fun `initState returns error when data field is missing`() { + val actualError: Exception = assertThrows(Exception::class.java) { + store.initState("""{"version":2}""") + } + assertTrue(actualError.message?.isNotBlank() == true) + } + + @Test + fun `initState stores version in sdk preferences`() { + store.initState("""{"data":{"key":"value"},"version":7}""") + assertEquals(7, MindboxPreferences.localStateVersion) + } + + @Test + fun `setState stores each data key as separate preference key`() { + store.setState("""{"data":{"firstKey":"firstValue","secondKey":"secondValue"}}""") + val localStatePreferences = context.getSharedPreferences(LOCAL_STATE_FILE_NAME, Context.MODE_PRIVATE) + assertEquals("firstValue", localStatePreferences.getString("firstKey", null)) + assertEquals("secondValue", localStatePreferences.getString("secondKey", null)) + assertFalse(localStatePreferences.contains("local_state_data_json")) + } + + private fun String.toJsonObject(): JSONObject = JSONObject(this) +} From c4329765e6b6761675a43050c259c2748110cced Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 12 Mar 2026 12:17:51 +0300 Subject: [PATCH 41/59] MOBILEWEBVIEW-94: Add test. Add local state version to ready response --- WebViewLocalStateStorageTests.swift | 279 ++++++++++++++++++ .../inapp/presentation/view/DataCollector.kt | 2 + .../view/WebViewInappViewHolder.kt | 12 +- .../view/WebViewLocalStateStore.kt | 4 +- .../presentation/view/DataCollectorTest.kt | 7 + .../view/WebViewLocalStateStoreTest.kt | 115 +++++++- 6 files changed, 404 insertions(+), 15 deletions(-) create mode 100644 WebViewLocalStateStorageTests.swift diff --git a/WebViewLocalStateStorageTests.swift b/WebViewLocalStateStorageTests.swift new file mode 100644 index 000000000..26b0b44c6 --- /dev/null +++ b/WebViewLocalStateStorageTests.swift @@ -0,0 +1,279 @@ +// +// WebViewLocalStateStorageTests.swift +// MindboxTests +// +// Created by Sergei Semko on 3/11/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@testable import Mindbox + +@Suite("WebViewLocalStateStorage", .tags(.webView)) +struct WebViewLocalStateStorageTests { + + private let testSuiteName = "cloud.Mindbox.test.webview.localState" + private let keyPrefix = Constants.WebViewLocalState.keyPrefix + + private func makeSUT() -> (sut: WebViewLocalStateStorage, defaults: UserDefaults, persistence: MockPersistenceStorage) { + let persistence = MockPersistenceStorage() + let defaults = UserDefaults(suiteName: testSuiteName)! + defaults.removePersistentDomain(forName: testSuiteName) + let sut = WebViewLocalStateStorage(dataDefaults: defaults, persistenceStorage: persistence) + return (sut, defaults, persistence) + } + + // MARK: - get + + @Test("get returns default version and empty data when storage is empty") + func getEmptyStorage() { + let (sut, _, _) = makeSUT() + + let state = sut.get(keys: []) + + #expect(state.version == Constants.WebViewLocalState.defaultVersion) + #expect(state.data.isEmpty) + } + + @Test("get returns all stored keys when keys array is empty") + func getAllKeys() { + let (sut, defaults, _) = makeSUT() + defaults.set("value1", forKey: "\(keyPrefix)key1") + defaults.set("value2", forKey: "\(keyPrefix)key2") + + let state = sut.get(keys: []) + + #expect(state.data.count == 2) + #expect(state.data["key1"] == "value1") + #expect(state.data["key2"] == "value2") + } + + @Test("get returns only requested keys") + func getSpecificKeys() { + let (sut, defaults, _) = makeSUT() + defaults.set("value1", forKey: "\(keyPrefix)key1") + defaults.set("value2", forKey: "\(keyPrefix)key2") + defaults.set("value3", forKey: "\(keyPrefix)key3") + + let state = sut.get(keys: ["key1", "key3"]) + + #expect(state.data.count == 2) + #expect(state.data["key1"] == "value1") + #expect(state.data["key3"] == "value3") + } + + @Test("get omits missing keys from data") + func getMissingKeys() { + let (sut, defaults, _) = makeSUT() + defaults.set("value1", forKey: "\(keyPrefix)key1") + + let state = sut.get(keys: ["key1", "missing"]) + + #expect(state.data.count == 1) + #expect(state.data["key1"] == "value1") + #expect(state.data["missing"] == nil) + } + + @Test("get returns current version from persistence") + func getCurrentVersion() { + let (sut, _, persistence) = makeSUT() + persistence.webViewLocalStateVersion = 5 + + let state = sut.get(keys: []) + + #expect(state.version == 5) + } + + @Test("get returns default version when persistence version is nil") + func getDefaultVersion() { + let (sut, _, persistence) = makeSUT() + persistence.webViewLocalStateVersion = nil + + let state = sut.get(keys: []) + + #expect(state.version == Constants.WebViewLocalState.defaultVersion) + } + + // MARK: - set + + @Test("set stores values in UserDefaults") + func setStoresValues() { + let (sut, defaults, _) = makeSUT() + + _ = sut.set(data: ["key1": "value1", "key2": "value2"]) + + #expect(defaults.string(forKey: "\(keyPrefix)key1") == "value1") + #expect(defaults.string(forKey: "\(keyPrefix)key2") == "value2") + } + + @Test("set removes key when value is nil") + func setRemovesNilKey() { + let (sut, defaults, _) = makeSUT() + defaults.set("value1", forKey: "\(keyPrefix)key1") + + _ = sut.set(data: ["key1": nil]) + + #expect(defaults.string(forKey: "\(keyPrefix)key1") == nil) + } + + @Test("set updates existing values") + func setUpdatesValues() { + let (sut, defaults, _) = makeSUT() + defaults.set("old", forKey: "\(keyPrefix)key1") + + let state = sut.set(data: ["key1": "new"]) + + #expect(defaults.string(forKey: "\(keyPrefix)key1") == "new") + #expect(state.data["key1"] == "new") + } + + @Test("set returns only affected keys") + func setReturnsAffectedKeys() { + let (sut, defaults, _) = makeSUT() + defaults.set("existing", forKey: "\(keyPrefix)existing") + + let state = sut.set(data: ["key1": "value1"]) + + #expect(state.data.count == 1) + #expect(state.data["key1"] == "value1") + #expect(state.data["existing"] == nil) + } + + @Test("set does not change version") + func setPreservesVersion() { + let (sut, _, persistence) = makeSUT() + persistence.webViewLocalStateVersion = 3 + + let state = sut.set(data: ["key1": "value1"]) + + #expect(state.version == 3) + #expect(persistence.webViewLocalStateVersion == 3) + } + + @Test("set stores each key as separate UserDefaults entry") + func setSeparateEntries() { + let (sut, defaults, _) = makeSUT() + + _ = sut.set(data: ["firstKey": "firstValue", "secondKey": "secondValue"]) + + #expect(defaults.string(forKey: "\(keyPrefix)firstKey") == "firstValue") + #expect(defaults.string(forKey: "\(keyPrefix)secondKey") == "secondValue") + } + + // MARK: - initialize + + @Test("initialize stores version in PersistenceStorage") + func initStoresVersion() { + let (sut, _, persistence) = makeSUT() + + _ = sut.initialize(version: 7, data: ["key": "value"]) + + #expect(persistence.webViewLocalStateVersion == 7) + } + + @Test("initialize stores data and returns it") + func initStoresAndReturnsData() throws { + let (sut, defaults, _) = makeSUT() + + let state = try #require(sut.initialize(version: 2, data: ["key1": "value1", "key2": "value2"])) + + #expect(state.version == 2) + #expect(state.data["key1"] == "value1") + #expect(state.data["key2"] == "value2") + #expect(defaults.string(forKey: "\(keyPrefix)key1") == "value1") + #expect(defaults.string(forKey: "\(keyPrefix)key2") == "value2") + } + + @Test("initialize rejects zero version") + func initRejectsZero() { + let (sut, _, _) = makeSUT() + + #expect(sut.initialize(version: 0, data: ["key": "value"]) == nil) + } + + @Test("initialize rejects negative version") + func initRejectsNegative() { + let (sut, _, _) = makeSUT() + + #expect(sut.initialize(version: -1, data: ["key": "value"]) == nil) + } + + @Test("initialize removes keys with nil values") + func initRemovesNilKeys() { + let (sut, defaults, _) = makeSUT() + defaults.set("value1", forKey: "\(keyPrefix)key1") + + let state = sut.initialize(version: 2, data: ["key1": nil]) + + #expect(state != nil) + #expect(defaults.string(forKey: "\(keyPrefix)key1") == nil) + } + + @Test("initialize merges with existing data") + func initMergesData() { + let (sut, defaults, _) = makeSUT() + defaults.set("existing", forKey: "\(keyPrefix)old") + + let state = sut.initialize(version: 3, data: ["new": "value"]) + + #expect(state != nil) + #expect(defaults.string(forKey: "\(keyPrefix)old") == "existing") + #expect(defaults.string(forKey: "\(keyPrefix)new") == "value") + } + + @Test("initialize does not store version on rejection") + func initPreservesVersionOnReject() { + let (sut, _, persistence) = makeSUT() + persistence.webViewLocalStateVersion = 5 + + _ = sut.initialize(version: 0, data: ["key": "value"]) + + #expect(persistence.webViewLocalStateVersion == 5) + } + + // MARK: - Integration + + @Test("full flow: init → set → get") + func fullFlow() throws { + let (sut, _, _) = makeSUT() + + let initState = try #require(sut.initialize(version: 2, data: ["key1": "value1", "key2": "value2"])) + #expect(initState.version == 2) + + let setState = sut.set(data: ["key1": "updated", "key2": nil, "key3": "value3"]) + #expect(setState.version == 2) + + let getState = sut.get(keys: []) + #expect(getState.version == 2) + #expect(getState.data["key1"] == "updated") + #expect(getState.data["key2"] == nil) + #expect(getState.data["key3"] == "value3") + } + + @Test("get after set with null returns empty for deleted key") + func setNullThenGet() { + let (sut, _, _) = makeSUT() + + _ = sut.set(data: ["key1": "value1"]) + _ = sut.set(data: ["key1": nil]) + + let state = sut.get(keys: ["key1"]) + #expect(state.data.isEmpty) + } + + @Test("prefix isolation: non-prefixed keys and Apple system keys are filtered out") + func prefixIsolation() { + let (sut, defaults, _) = makeSUT() + defaults.set("foreign", forKey: "foreignKey") + defaults.set("value", forKey: "\(keyPrefix)myKey") + + let state = sut.get(keys: []) + + #expect(state.data.count == 1) + #expect(state.data["myKey"] == "value") + #expect(state.data["foreignKey"] == nil) + #expect(state.data["AKLastLocale"] == nil) + #expect(state.data["AppleLocale"] == nil) + #expect(state.data["NSInterfaceStyle"] == nil) + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt index 33df3acd3..83a0fb840 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt @@ -31,6 +31,7 @@ internal class DataCollector( private val providers: MutableMap by lazy { mutableMapOf( KEY_DEVICE_UUID to Provider.string(MindboxPreferences.deviceUuid), + KEY_LOCAL_STATE_VERSION to Provider.number(MindboxPreferences.localStateVersion), KEY_ENDPOINT_ID to Provider.string(configuration.endpointId), KEY_IN_APP_ID to Provider.string(inAppId), KEY_INSETS to createInsetsPayload(inAppInsets), @@ -76,6 +77,7 @@ internal class DataCollector( private const val KEY_TRACK_VISIT_REQUEST_URL = "trackVisitRequestUrl" private const val KEY_USER_VISIT_COUNT = "userVisitCount" private const val KEY_VERSION = "version" + private const val KEY_LOCAL_STATE_VERSION = "localStateVersion" private const val VALUE_PLATFORM = "android" private const val VALUE_THEME_DARK = "dark" private const val VALUE_THEME_LIGHT = "light" diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index e04e1efeb..7132f8913 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -135,15 +135,9 @@ internal class WebViewInAppViewHolder( register(WebViewAction.ASYNC_OPERATION, ::handleAsyncOperationAction) register(WebViewAction.OPEN_LINK, ::handleOpenLinkAction) registerSuspend(WebViewAction.SYNC_OPERATION, ::handleSyncOperationAction) - register(WebViewAction.LOCAL_STATE_GET) { message -> - handleLocalStateGetAction(message) - } - register(WebViewAction.LOCAL_STATE_SET) { message -> - handleLocalStateSetAction(message) - } - register(WebViewAction.LOCAL_STATE_INIT) { message -> - handleLocalStateInitAction(message) - } + registerSuspend(WebViewAction.LOCAL_STATE_GET, ::handleLocalStateGetAction) + registerSuspend(WebViewAction.LOCAL_STATE_SET, ::handleLocalStateSetAction) + registerSuspend(WebViewAction.LOCAL_STATE_INIT, ::handleLocalStateInitAction) register(WebViewAction.READY) { handleReadyAction( configuration = configuration, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt index 551dc69fd..afdc3a044 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt @@ -46,9 +46,7 @@ internal class WebViewLocalStateStore( } fun initState(payload: String): String { - val payloadObject: JSONObject = JSONObject(payload) - val jsonData: JSONObject = payloadObject.getJSONObject(FIELD_DATA) - val version: Int = payloadObject.getInt(FIELD_VERSION) + val version: Int = JSONObject(payload).getInt(FIELD_VERSION) require(version > 0) { "Version must be greater than 0" } MindboxPreferences.localStateVersion = version diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt index 2962012e6..9c09a6da3 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt @@ -48,6 +48,7 @@ class DataCollectorTest { every { resources.configuration } returns uiConfiguration every { resources.displayMetrics } returns displayMetrics mockkObject(MindboxPreferences) + every { MindboxPreferences.localStateVersion } returns 1 } @After @@ -61,6 +62,7 @@ class DataCollectorTest { Locale.setDefault(Locale.forLanguageTag("en-US")) uiConfiguration.uiMode = UiConfiguration.UI_MODE_NIGHT_NO every { MindboxPreferences.deviceUuid } returns "device-uuid" + every { MindboxPreferences.localStateVersion } returns 12 every { MindboxPreferences.userVisitCount } returns 7 every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.GRANTED every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.DENIED @@ -97,6 +99,7 @@ class DataCollectorTest { assertEquals("{\"screen\":\"home\"}", actualJson.get("operationBody").asString) assertEquals("android", actualJson.get("platform").asString) assertEquals("light", actualJson.get("theme").asString) + assertEquals(12, actualJson.get("localStateVersion").asInt) assertEquals("link", actualJson.get("trackVisitSource").asString) assertEquals("https://mindbox.cloud/path", actualJson.get("trackVisitRequestUrl").asString) assertEquals("7", actualJson.get("userVisitCount").asString) @@ -121,6 +124,7 @@ class DataCollectorTest { Locale.setDefault(Locale.forLanguageTag("ru-RU")) uiConfiguration.uiMode = UiConfiguration.UI_MODE_NIGHT_YES every { MindboxPreferences.deviceUuid } returns "" + every { MindboxPreferences.localStateVersion } returns 3 every { MindboxPreferences.userVisitCount } returns 3 every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.GRANTED every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.GRANTED @@ -152,6 +156,7 @@ class DataCollectorTest { assertFalse(actualJson.has("operationBody")) assertFalse(actualJson.has("trackVisitSource")) assertFalse(actualJson.has("trackVisitRequestUrl")) + assertEquals(3, actualJson.get("localStateVersion").asInt) assertEquals("overridden-endpoint", actualJson.get("endpointId").asString) assertEquals("dark", actualJson.get("theme").asString) assertEquals("ru_RU", actualJson.get("locale").asString) @@ -170,6 +175,7 @@ class DataCollectorTest { val displayMetrics = DisplayMetrics().apply { this.density = density } every { resources.displayMetrics } returns displayMetrics every { MindboxPreferences.deviceUuid } returns "device-uuid" + every { MindboxPreferences.localStateVersion } returns 5 every { MindboxPreferences.userVisitCount } returns 0 every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.DENIED every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.DENIED @@ -197,6 +203,7 @@ class DataCollectorTest { assertEquals(4, insetsJson.get("top").asInt) assertEquals(6, insetsJson.get("right").asInt) assertEquals(8, insetsJson.get("bottom").asInt) + assertEquals(5, actualJson.get("localStateVersion").asInt) } private fun getPermissionStatus(payload: JsonObject, permissionKey: String): String { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt index 20b304e31..9ece5c5f0 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt @@ -39,13 +39,30 @@ internal class WebViewLocalStateStoreTest { } @Test - fun `initState stores values and getState returns requested keys with null for missing`() { + fun `get with specific keys returns only requested keys`() { store.initState("""{"data":{"key1":"value1","key2":"value2"},"version":2}""") - val actualResponse: JSONObject = store.getState("""{"data":["key1","missing"]}""").toJsonObject() + val actualResponse: JSONObject = store.getState("""{"data":["key1"]}""").toJsonObject() assertEquals(2, actualResponse.getInt("version")) val actualData: JSONObject = actualResponse.getJSONObject("data") assertEquals("value1", actualData.getString("key1")) - assertTrue(actualData.isNull("missing")) + assertFalse(actualData.has("key2")) + } + + @Test + fun `get with empty keys returns all stored keys`() { + store.setState("""{"data":{"key1":"value1","key2":"value2"}}""") + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertEquals(2, actualData.length()) + assertEquals("value1", actualData.getString("key1")) + assertEquals("value2", actualData.getString("key2")) + } + + @Test + fun `get returns current version from preferences`() { + MindboxPreferences.localStateVersion = 5 + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + assertEquals(5, actualResponse.getInt("version")) } @Test @@ -92,5 +109,97 @@ internal class WebViewLocalStateStoreTest { assertFalse(localStatePreferences.contains("local_state_data_json")) } + @Test + fun `get missing keys excludes absent keys from response`() { + store.initState("""{"data":{"existing":"value"},"version":2}""") + val actualResponse: JSONObject = store.getState("""{"data":["existing","missing"]}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertTrue(actualData.has("existing")) + assertTrue(actualData.has("missing")) + assertEquals(2, actualData.length()) + } + + @Test + fun `setState returns only affected keys`() { + store.initState("""{"data":{"oldKey":"oldValue"},"version":4}""") + val actualResponse: JSONObject = store.setState("""{"data":{"newKey":"newValue"}}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertTrue(actualData.has("newKey")) + assertFalse(actualData.has("oldKey")) + } + + @Test + fun `setState does not change version`() { + store.initState("""{"data":{"key":"value"},"version":8}""") + val actualResponse: JSONObject = store.setState("""{"data":{"key":"updated"}}""").toJsonObject() + assertEquals(8, actualResponse.getInt("version")) + assertEquals(8, MindboxPreferences.localStateVersion) + } + + @Test + fun `initState merges with existing data`() { + store.setState("""{"data":{"base":"base-value","keep":"keep-value"}}""") + store.initState("""{"data":{"base":"updated-base","added":"added-value"},"version":3}""") + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertEquals("updated-base", actualData.getString("base")) + assertEquals("keep-value", actualData.getString("keep")) + assertEquals("added-value", actualData.getString("added")) + } + + @Test + fun `initState rejects negative version`() { + val actualError: IllegalArgumentException = assertThrows(IllegalArgumentException::class.java) { + store.initState("""{"data":{"key":"value"},"version":-1}""") + } + assertTrue(actualError.message?.contains("Version must be greater than 0") == true) + } + + @Test + fun `initState rejects zero version`() { + val actualError: IllegalArgumentException = assertThrows(IllegalArgumentException::class.java) { + store.initState("""{"data":{"key":"value"},"version":0}""") + } + assertTrue(actualError.message?.contains("Version must be greater than 0") == true) + } + + @Test + fun `initState does not write version when rejected`() { + store.initState("""{"data":{"key":"value"},"version":6}""") + assertThrows(IllegalArgumentException::class.java) { + store.initState("""{"data":{"key":"next"},"version":-10}""") + } + assertEquals(6, MindboxPreferences.localStateVersion) + } + + @Test + fun `full flow init set get works correctly`() { + store.initState("""{"data":{"k1":"v1"},"version":5}""") + store.setState("""{"data":{"k2":"v2","k1":"v1-updated"}}""") + val actualResponse: JSONObject = store.getState("""{"data":["k1","k2"]}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertEquals("v1-updated", actualData.getString("k1")) + assertEquals("v2", actualData.getString("k2")) + assertEquals(5, actualResponse.getInt("version")) + } + + @Test + fun `set null then get returns removed key as empty`() { + store.setState("""{"data":{"keyToDelete":"value"}}""") + store.setState("""{"data":{"keyToDelete":null}}""") + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertFalse(actualData.has("keyToDelete")) + } + + @Test + fun `initState removes key when value is null`() { + store.setState("""{"data":{"keyToDelete":"value"}}""") + store.initState("""{"data":{"keyToDelete":null},"version":2}""") + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertFalse(actualData.has("keyToDelete")) + } + private fun String.toJsonObject(): JSONObject = JSONObject(this) } From 331229e0126b124aaa7b8bec0b4d0b4979904836 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 12 Mar 2026 14:51:31 +0300 Subject: [PATCH 42/59] MOBILEWEBVIEW-94: Follow code review --- .../presentation/view/WebViewLocalStateStore.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt index afdc3a044..cc9f179db 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt @@ -55,18 +55,12 @@ internal class WebViewLocalStateStore( } private fun JSONObject.toMap(): Map { - val keysIterator: Iterator = this.keys() - val resultMap: MutableMap = mutableMapOf() - while (keysIterator.hasNext()) { - val key: String = keysIterator.next() - val value: Any? = this.opt(key) - if (value == null || value == JSONObject.NULL) { - resultMap[key] = null - } else { - resultMap[key] = value.toString() + return buildMap(capacity = this.length()) { + keys().forEach { key -> + val value: Any? = opt(key) + put(key, if (value == null || value == JSONObject.NULL) null else value.toString()) } } - return resultMap } private fun buildResponse(data: Map): String { From b1f8eca75802fc917b30b8d6c61b10ae0387efb0 Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:25:31 +0300 Subject: [PATCH 43/59] MOBILEWEBVIEW-97: add firstInitializationDateTime --- .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 6 ++ .../inapp/presentation/view/DataCollector.kt | 2 + .../mindbox/mobile_sdk/models/Timestamp.kt | 10 +++ .../repository/MindboxPreferences.kt | 12 +++ .../mindbox/mobile_sdk/utils/Constants.kt | 2 +- .../mobile_sdk/utils/MigrationManager.kt | 30 ++++++- .../mindbox/mobile_sdk/ExtensionsTest.kt | 15 ++++ .../cloud/mindbox/mobile_sdk/MindboxTest.kt | 27 +++++++ .../presentation/view/DataCollectorTest.kt | 9 ++- .../mobile_sdk/utils/MigrationManagerTest.kt | 80 ++++++++++++++++++- 10 files changed, 188 insertions(+), 5 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index 237bf66ec..9ad7a86e2 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -94,6 +94,7 @@ public object Mindbox : MindboxLog { private lateinit var lifecycleManager: LifecycleManager private val userVisitManager: UserVisitManager by mindboxInject { userVisitManager } + private val timeProvider by mindboxInject { timeProvider } internal var pushServiceHandlers: List = listOf() @@ -1244,6 +1245,11 @@ public object Mindbox : MindboxLog { MindboxPreferences.isNotificationEnabled = isNotificationEnabled MindboxPreferences.instanceId = instanceId + if (MindboxPreferences.firstInitializationTime == null) { + MindboxPreferences.firstInitializationTime = timeProvider.currentTimestamp() + .convertToIso8601String() + } + MindboxEventManager.appInstalled(context, initData, configuration.shouldCreateCustomer) deliverDeviceUuid(deviceUuid) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt index 83a0fb840..239593bdf 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt @@ -33,6 +33,7 @@ internal class DataCollector( KEY_DEVICE_UUID to Provider.string(MindboxPreferences.deviceUuid), KEY_LOCAL_STATE_VERSION to Provider.number(MindboxPreferences.localStateVersion), KEY_ENDPOINT_ID to Provider.string(configuration.endpointId), + KEY_FIRST_INITIALIZATION_TIME to Provider.string(MindboxPreferences.firstInitializationTime), KEY_IN_APP_ID to Provider.string(inAppId), KEY_INSETS to createInsetsPayload(inAppInsets), KEY_LOCALE to Provider.string(resolveLocale()), @@ -57,6 +58,7 @@ internal class DataCollector( companion object Companion { private const val KEY_DEVICE_UUID = "deviceUUID" private const val KEY_ENDPOINT_ID = "endpointId" + private const val KEY_FIRST_INITIALIZATION_TIME = "firstInitializationDateTime" private const val KEY_IN_APP_ID = "inAppId" private const val KEY_INSETS = "insets" private const val KEY_LOCALE = "locale" diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt index 6f87e3186..feb993283 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt @@ -1,5 +1,9 @@ package cloud.mindbox.mobile_sdk.models +import cloud.mindbox.mobile_sdk.convertToString +import cloud.mindbox.mobile_sdk.convertToZonedDateTimeAtUTC +import org.threeten.bp.Instant + /** * Represents a specific point in time as milliseconds since the Unix epoch (January 1, 1970, 00:00:00 UTC) */ @@ -11,3 +15,9 @@ internal value class Timestamp(val ms: Long) { } internal fun Long.toTimestamp(): Timestamp = Timestamp(this) + +internal fun Timestamp.convertToIso8601String(): String { + return Instant.ofEpochMilli(ms) + .convertToZonedDateTimeAtUTC() + .convertToString() +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt index f3725130e..cf325fdf5 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt @@ -38,6 +38,7 @@ internal object MindboxPreferences { private const val KEY_LAST_INAPP_CHANGE_STATE_TIME = "key_last_inapp_change_state_time" private const val KEY_LOCAL_STATE_VERSION = "local_state_version" private const val DEFAULT_LOCAL_STATE_VERSION = 1 + private const val KEY_FIRST_INITIALIZATION_TIME = "key_first_initialization_time" private val prefScope = CoroutineScope(Dispatchers.Default) @@ -235,6 +236,17 @@ internal object MindboxPreferences { } } + var firstInitializationTime: String? + get() = loggingRunCatching(defaultValue = null) { + SharedPreferencesManager.getString(KEY_FIRST_INITIALIZATION_TIME) + ?.takeIf { value -> value.isNotBlank() } + } + set(value) { + loggingRunCatching { + SharedPreferencesManager.put(KEY_FIRST_INITIALIZATION_TIME, value) + } + } + var lastInfoUpdateTime: Long? get() = loggingRunCatching(defaultValue = null) { SharedPreferencesManager.getLong(KEY_LAST_INFO_UPDATE_TIME) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt index 983bfe155..73d9c1ebe 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt @@ -8,5 +8,5 @@ internal object Constants { internal const val APP_PACKAGE_NAME = "app_package" internal const val APP_UID_NAME = "app_uid" internal const val SCHEME_PACKAGE = "package" - internal const val SDK_VERSION_CODE = 3 + internal const val SDK_VERSION_CODE = 4 } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/MigrationManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/MigrationManager.kt index a4e490f4f..74559e7b9 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/MigrationManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/MigrationManager.kt @@ -8,6 +8,8 @@ import cloud.mindbox.mobile_sdk.fromJsonTyped import cloud.mindbox.mobile_sdk.logger.MindboxLog import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.managers.SharedPreferencesManager +import cloud.mindbox.mobile_sdk.models.convertToIso8601String +import cloud.mindbox.mobile_sdk.models.toTimestamp import cloud.mindbox.mobile_sdk.pushes.PrefPushToken import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.toJsonTyped @@ -23,6 +25,7 @@ internal class MigrationManager(val context: Context) : MindboxLog { private var isMigrating = false private val gson by mindboxInject { gson } + private val timeProvider by mindboxInject { timeProvider } suspend fun migrateAll() { if (isMigrating) return @@ -36,7 +39,8 @@ internal class MigrationManager(val context: Context) : MindboxLog { listOf( version290(), version2120(), - version2140() + version2140(), + version2150() ).filter { it.isNeeded } .onEach { migration -> val job = Mindbox.mindboxScope.launch { @@ -144,4 +148,28 @@ internal class MigrationManager(val context: Context) : MindboxLog { MindboxPreferences.versionCode = VERSION_CODE } } + + private fun version2150() = object : Migration { + val VERSION_CODE = 4 + + override val description: String + get() = "Stores the first SDK initialization time" + override val isNeeded: Boolean + get() = (MindboxPreferences.versionCode ?: 0) < VERSION_CODE + + override suspend fun run() { + if (MindboxPreferences.firstInitializationTime == null) { + val firstInitTimestamp = MindboxPreferences.pushTokens.values + .map { token -> token.updateDate } + .filter { timestamp -> timestamp > 0L } + .minOrNull() + ?: timeProvider.currentTimestamp().ms + MindboxPreferences.firstInitializationTime = + firstInitTimestamp + .toTimestamp() + .convertToIso8601String() + } + MindboxPreferences.versionCode = VERSION_CODE + } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsTest.kt index 7ac4dae67..b5d18b595 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsTest.kt @@ -3,6 +3,8 @@ package cloud.mindbox.mobile_sdk import android.content.Context import android.content.Intent import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.models.convertToIso8601String import com.android.volley.NetworkResponse import com.android.volley.VolleyError import com.jakewharton.threetenabp.AndroidThreeTen @@ -13,6 +15,7 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.threeten.bp.Instant import org.robolectric.RobolectricTestRunner import org.threeten.bp.LocalDateTime import org.threeten.bp.ZoneId @@ -50,6 +53,18 @@ internal class ExtensionsTest { assertEquals(expectedResult, actualResult) } + @Test + fun `converting timestamp to ISO 8601 string`() { + val time = Timestamp(1_736_501_200_000L) + val expectedResult: String = ZonedDateTime.ofInstant( + Instant.ofEpochMilli(time.ms), + ZoneOffset.UTC + ).convertToString() + val actualResult: String = time.convertToIso8601String() + + assertEquals(expectedResult, actualResult) + } + private val testPackageName = "com.test.app" private val customProcessName = "com.test.app:myprocess" private val context = mockk { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/MindboxTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/MindboxTest.kt index 382f91dab..9be1cb9d9 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/MindboxTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/MindboxTest.kt @@ -2,6 +2,7 @@ package cloud.mindbox.mobile_sdk import android.app.Application import android.content.Context +import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.InitData import cloud.mindbox.mobile_sdk.models.TokenData @@ -43,6 +44,8 @@ class MindboxTest { @Before fun setUp() { + mockkObject(MindboxDI) + every { MindboxDI.appModule } returns mockk(relaxed = true) mockkObject(MindboxPreferences) mockkObject(PushNotificationManager) mockkObject(MindboxEventManager) @@ -55,6 +58,8 @@ class MindboxTest { every { MindboxPreferences.isNotificationEnabled } returns true every { MindboxPreferences.instanceId } returns "instanceId" every { MindboxPreferences.deviceUuid } returns "deviceUUID" + every { MindboxPreferences.firstInitializationTime } returns null + every { MindboxPreferences.firstInitializationTime = any() } just runs every { MindboxPreferences.infoUpdatedVersion } returns 1 Mindbox.pushServiceHandlers = listOf(firstProvider, secondProvider, thirdProvider) @@ -242,6 +247,28 @@ class MindboxTest { } } + @Test + fun `firstInitialization does not override saved first initialization time`() = runTest { + every { MindboxPreferences.firstInitializationTime } returns "2025-01-10T07:40:00Z" + + Mindbox.firstInitialization(context, mockk(relaxed = true)) + + verify(exactly = 0) { + MindboxPreferences.firstInitializationTime = any() + } + } + + @Test + fun `firstInitializationDateTime saved when first initialization time`() = runTest { + every { MindboxPreferences.firstInitializationTime } returns null + + Mindbox.firstInitialization(context, mockk(relaxed = true)) + + verify(exactly = 1) { + MindboxPreferences.firstInitializationTime = any() + } + } + @Test fun `getPushTokensSaveDate returns correctly map`() { val tokensDate = Mindbox.getPushTokensSaveDate() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt index 9c09a6da3..53c0eea7d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt @@ -63,6 +63,7 @@ class DataCollectorTest { uiConfiguration.uiMode = UiConfiguration.UI_MODE_NIGHT_NO every { MindboxPreferences.deviceUuid } returns "device-uuid" every { MindboxPreferences.localStateVersion } returns 12 + every { MindboxPreferences.firstInitializationTime } returns "2025-01-10T07:40:00Z" every { MindboxPreferences.userVisitCount } returns 7 every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.GRANTED every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.DENIED @@ -80,7 +81,7 @@ class DataCollectorTest { eventType = EventType.AsyncOperation("OpenScreen"), body = "{\"screen\":\"home\"}", ) - val dataCollector: DataCollector = DataCollector( + val dataCollector = DataCollector( appContext = appContext, sessionStorageManager = sessionStorageManager, permissionManager = permissionManager, @@ -94,6 +95,7 @@ class DataCollectorTest { val actualJson: JsonObject = JsonParser.parseString(actualPayload).asJsonObject assertEquals("device-uuid", actualJson.get("deviceUUID").asString) assertEquals("endpoint-id", actualJson.get("endpointId").asString) + assertEquals("2025-01-10T07:40:00Z", actualJson.get("firstInitializationDateTime").asString) assertEquals("en_US", actualJson.get("locale").asString) assertEquals("OpenScreen", actualJson.get("operationName").asString) assertEquals("{\"screen\":\"home\"}", actualJson.get("operationBody").asString) @@ -125,6 +127,7 @@ class DataCollectorTest { uiConfiguration.uiMode = UiConfiguration.UI_MODE_NIGHT_YES every { MindboxPreferences.deviceUuid } returns "" every { MindboxPreferences.localStateVersion } returns 3 + every { MindboxPreferences.firstInitializationTime } returns null every { MindboxPreferences.userVisitCount } returns 3 every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.GRANTED every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.GRANTED @@ -139,7 +142,7 @@ class DataCollectorTest { requestUrl = " ", sdkVersionNumeric = Constants.SDK_VERSION_NUMERIC, ) - val dataCollector: DataCollector = DataCollector( + val dataCollector = DataCollector( appContext = appContext, sessionStorageManager = sessionStorageManager, permissionManager = permissionManager, @@ -152,6 +155,7 @@ class DataCollectorTest { val actualPayload: String = dataCollector.get() val actualJson: JsonObject = JsonParser.parseString(actualPayload).asJsonObject assertFalse(actualJson.has("deviceUUID")) + assertFalse(actualJson.has("firstInitializationDateTime")) assertFalse(actualJson.has("operationName")) assertFalse(actualJson.has("operationBody")) assertFalse(actualJson.has("trackVisitSource")) @@ -176,6 +180,7 @@ class DataCollectorTest { every { resources.displayMetrics } returns displayMetrics every { MindboxPreferences.deviceUuid } returns "device-uuid" every { MindboxPreferences.localStateVersion } returns 5 + every { MindboxPreferences.firstInitializationTime } returns null every { MindboxPreferences.userVisitCount } returns 0 every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.DENIED every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.DENIED diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt index 03b9ccf3c..5b14e98b7 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt @@ -1,5 +1,9 @@ package cloud.mindbox.mobile_sdk.utils +import cloud.mindbox.mobile_sdk.di.MindboxDI +import cloud.mindbox.mobile_sdk.models.convertToIso8601String +import cloud.mindbox.mobile_sdk.models.toTimestamp +import cloud.mindbox.mobile_sdk.pushes.PrefPushToken import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import com.google.gson.Gson import com.google.gson.reflect.TypeToken @@ -13,6 +17,10 @@ class MigrationManagerTest { @Before fun setUp() { mockkObject(MindboxPreferences) + mockkObject(MindboxDI) + every { MindboxDI.appModule } returns mockk(relaxed = true) { + every { gson } returns Gson() + } } @Test @@ -43,7 +51,7 @@ class MigrationManagerTest { } returns expectedNewMapString val mm = MigrationManager(mockk()) - every { MindboxPreferences.versionCode } returns Constants.SDK_VERSION_CODE - 1 + every { MindboxPreferences.versionCode } returns 2 mm.migrateAll() coVerify(exactly = 1) { MindboxPreferences.shownInApps = expectedNewMapString @@ -52,4 +60,74 @@ class MigrationManagerTest { MindboxPreferences.shownInApps = expectedShownInappsWithListShowString } } + + @Test + fun `version2150 saves minimum push token timestamp as first initialization time`() = runTest { + val expectedTimestamp = 1000L + every { MindboxPreferences.versionCode } returns 3 + every { MindboxPreferences.firstInitializationTime } returns null + every { MindboxPreferences.pushTokens } returns mapOf( + "FCM" to PrefPushToken("tokenFCM", expectedTimestamp), + "HMS" to PrefPushToken("tokenHMS", 2000L), + ) + every { MindboxPreferences.firstInitializationTime = any() } just runs + every { MindboxPreferences.versionCode = any() } just runs + + MigrationManager(mockk()).migrateAll() + + verify(exactly = 1) { + MindboxPreferences.firstInitializationTime = expectedTimestamp + .toTimestamp() + .convertToIso8601String() + } + } + + @Test + fun `version2150 does not override existing first initialization time`() = runTest { + every { MindboxPreferences.versionCode } returns 3 + every { MindboxPreferences.firstInitializationTime } returns "2025-01-10T07:40:00Z" + every { MindboxPreferences.versionCode = any() } just runs + + MigrationManager(mockk()).migrateAll() + + verify(exactly = 0) { + MindboxPreferences.firstInitializationTime = any() + } + } + + @Test + fun `version2150 uses current time when no push tokens available`() = runTest { + every { MindboxPreferences.versionCode } returns 3 + every { MindboxPreferences.firstInitializationTime } returns null + every { MindboxPreferences.pushTokens } returns emptyMap() + every { MindboxPreferences.firstInitializationTime = any() } just runs + every { MindboxPreferences.versionCode = any() } just runs + + MigrationManager(mockk()).migrateAll() + + verify(exactly = 1) { + MindboxPreferences.firstInitializationTime = any() + } + } + + @Test + fun `version2150 filters out zero push token timestamps`() = runTest { + val expectedTimestamp = 5000L + every { MindboxPreferences.versionCode } returns 3 + every { MindboxPreferences.firstInitializationTime } returns null + every { MindboxPreferences.pushTokens } returns mapOf( + "FCM" to PrefPushToken("tokenFCM", 0L), + "HMS" to PrefPushToken("tokenHMS", expectedTimestamp), + ) + every { MindboxPreferences.firstInitializationTime = any() } just runs + every { MindboxPreferences.versionCode = any() } just runs + + MigrationManager(mockk()).migrateAll() + + verify(exactly = 1) { + MindboxPreferences.firstInitializationTime = expectedTimestamp + .toTimestamp() + .convertToIso8601String() + } + } } From 2ad0d873fb3d04984922e059e447c52aafe75539 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 13 Mar 2026 17:38:59 +0300 Subject: [PATCH 44/59] MOBILEWEBVIEW-34: Fix remove paused viewholder --- .../inapp/presentation/InAppMessageViewDisplayerImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index b9c2e4412..0595841d1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -120,6 +120,7 @@ internal class InAppMessageViewDisplayerImpl( if (currentActivity == activity) { currentActivity = null } + pausedHolder?.onClose() pausedHolder = currentHolder currentHolder = null } @@ -189,7 +190,6 @@ internal class InAppMessageViewDisplayerImpl( isFirstShow = !isRestored ) } - pausedHolder = null currentActivity?.root?.let { root -> inAppFailureTracker.executeWithFailureTracking( From 37927c3df3e8f8071f644832484c0f40e557a028 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 16 Mar 2026 11:56:39 +0300 Subject: [PATCH 45/59] MOBILEWEBVIEW-34: Fix remove inapp for part activity --- .../inapp/presentation/InAppMessageViewDisplayerImpl.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 0595841d1..73bf60d2a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -120,8 +120,9 @@ internal class InAppMessageViewDisplayerImpl( if (currentActivity == activity) { currentActivity = null } + val holderToPause = currentHolder ?: return pausedHolder?.onClose() - pausedHolder = currentHolder + pausedHolder = holderToPause currentHolder = null } From 092138315fe8815b6c252933533198090983327b Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 17 Mar 2026 09:28:20 +0300 Subject: [PATCH 46/59] MOBILEWEBVIEW-34: Fix paused viewholder inapp --- .../inapp/presentation/InAppMessageViewDisplayerImpl.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 73bf60d2a..289198799 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -163,6 +163,10 @@ internal class InAppMessageViewDisplayerImpl( isActionExecuted = false } if (isRestored && tryReattachRestoredInApp(wrapper.inAppType.inAppId)) return + if (isRestored) { + pausedHolder?.onClose() + pausedHolder = null + } val callbackWrapper = InAppCallbackWrapper(inAppCallback) { wrapper.inAppActionCallbacks.onInAppDismiss.onDismiss() From 5db51ac039065a6fc32b6df8e3f462cad3829cd6 Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:12:15 +0300 Subject: [PATCH 47/59] MOBILEWEBVIEW-98: support vibration --- sdk/src/main/AndroidManifest.xml | 1 + .../data/validators/HapticRequestValidator.kt | 68 +++++ .../view/HapticFeedbackExecutor.kt | 224 +++++++++++++++++ .../inapp/presentation/view/WebViewAction.kt | 3 + .../view/WebViewInappViewHolder.kt | 14 ++ .../validators/HapticRequestValidatorTest.kt | 238 ++++++++++++++++++ 6 files changed, 548 insertions(+) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidator.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/HapticFeedbackExecutor.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidatorTest.kt diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml index 822fd693c..e50bafe2f 100644 --- a/sdk/src/main/AndroidManifest.xml +++ b/sdk/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidator.kt new file mode 100644 index 000000000..c25bc6780 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidator.kt @@ -0,0 +1,68 @@ +package cloud.mindbox.mobile_sdk.inapp.data.validators + +import cloud.mindbox.mobile_sdk.inapp.presentation.view.HapticConstants +import cloud.mindbox.mobile_sdk.inapp.presentation.view.HapticPatternEvent +import cloud.mindbox.mobile_sdk.inapp.presentation.view.HapticRequest +import cloud.mindbox.mobile_sdk.logger.mindboxLogW + +internal class HapticRequestValidator : Validator { + + override fun isValid(item: HapticRequest): Boolean = when (item) { + is HapticRequest.Selection -> true + is HapticRequest.Impact -> true + is HapticRequest.Pattern -> isValidPattern(item.events) + } + + private fun isValidPattern(events: List): Boolean { + if (events.isEmpty()) return logAndFail("pattern is empty") + if (events.size > MAX_EVENTS) return logAndFail("too many events: ${events.size}") + if (!events.all { isValidEvent(it) }) return false + return isValidPatternOrder(events.sortedBy { it.time }) + } + + private fun isValidEvent(event: HapticPatternEvent): Boolean { + if (event.time !in 0L..MAX_TOTAL_DURATION_MS) { + return logAndFail("event time out of range: ${event.time}") + } + if (event.duration !in 0L..MAX_SINGLE_EVENT_DURATION_MS) { + return logAndFail("event duration out of range: ${event.duration}") + } + if (event.intensity !in 0f..1f) { + return logAndFail("event intensity out of range: ${event.intensity}") + } + if (event.sharpness !in 0f..1f) { + return logAndFail("event sharpness out of range: ${event.sharpness}") + } + val effectiveDuration: Long = effectiveDurationOf(event) + if (event.time + effectiveDuration > MAX_TOTAL_DURATION_MS) { + return logAndFail("event time + effectiveDuration exceeds max: ${event.time + effectiveDuration}") + } + return true + } + + private fun isValidPatternOrder(sortedEvents: List): Boolean { + for (i in 1 until sortedEvents.size) { + val previous: HapticPatternEvent = sortedEvents[i - 1] + val next: HapticPatternEvent = sortedEvents[i] + val previousEnd: Long = previous.time + effectiveDurationOf(previous) + if (next.time < previousEnd) { + return logAndFail("event at time=${next.time} overlaps previous event ending at $previousEnd") + } + } + return true + } + + private fun effectiveDurationOf(event: HapticPatternEvent): Long = + if (event.duration > 0) event.duration else HapticConstants.TRANSIENT_DURATION_MS + + private fun logAndFail(reason: String): Boolean { + mindboxLogW("[Haptic] invalid pattern: $reason") + return false + } + + private companion object { + const val MAX_EVENTS = 128 + const val MAX_TOTAL_DURATION_MS = 30_000L + const val MAX_SINGLE_EVENT_DURATION_MS = 5_000L + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/HapticFeedbackExecutor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/HapticFeedbackExecutor.kt new file mode 100644 index 000000000..f771b0110 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/HapticFeedbackExecutor.kt @@ -0,0 +1,224 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import org.json.JSONObject + +internal enum class HapticImpactStyle { Light, Medium, Heavy } + +internal sealed class HapticRequest { + object Selection : HapticRequest() + + data class Impact(val style: HapticImpactStyle) : HapticRequest() + + data class Pattern(val events: List) : HapticRequest() +} + +/** + * Represents a single haptic pattern event. + * + * @param time Start time of the event relative to the beginning of the pattern, in milliseconds. + * @param duration Duration of the vibration, in milliseconds. + * @param intensity Normalized intensity in range [0.0, 1.0]. + * @param sharpness Normalized sharpness in range [0.0, 1.0]. + * + * Note: On Android, [sharpness] is currently parsed for compatibility with the + * cross‑platform schema but is not applied when generating vibration effects. + * Changes to this parameter will not affect the resulting haptic feedback on Android. + */ +internal data class HapticPatternEvent( + val time: Long, + val duration: Long, + val intensity: Float, + val sharpness: Float, +) + +internal object HapticConstants { + const val KEY_TYPE = "type" + const val KEY_STYLE = "style" + const val KEY_PATTERN = "pattern" + const val KEY_TIME = "time" + const val KEY_DURATION = "duration" + const val KEY_INTENSITY = "intensity" + const val KEY_SHARPNESS = "sharpness" + + const val TYPE_SELECTION = "selection" + const val TYPE_IMPACT = "impact" + const val TYPE_PATTERN = "pattern" + + const val STYLE_LIGHT = "light" + const val STYLE_MEDIUM = "medium" + const val STYLE_HEAVY = "heavy" + const val STYLE_SOFT = "soft" + const val STYLE_RIGID = "rigid" + + const val SELECTION_FALLBACK_DURATION_MS = 20L + const val TRANSIENT_DURATION_MS = 10L +} + +@OptIn(InternalMindboxApi::class) +internal fun parseHapticRequest(payload: String?): HapticRequest { + if (payload.isNullOrBlank() || payload == BridgeMessage.EMPTY_PAYLOAD) { + return HapticRequest.Selection + } + return loggingRunCatching(defaultValue = HapticRequest.Selection) { + val json = JSONObject(payload) + when (json.optString(HapticConstants.KEY_TYPE, HapticConstants.TYPE_SELECTION)) { + HapticConstants.TYPE_IMPACT -> { + val styleStr: String = json.optString(HapticConstants.KEY_STYLE) + HapticRequest.Impact(style = parseImpactStyle(styleStr)) + } + HapticConstants.TYPE_PATTERN -> HapticRequest.Pattern(events = parsePatternEvents(json)) + else -> HapticRequest.Selection + } + } +} + +private fun parseImpactStyle(style: String): HapticImpactStyle = when (style) { + HapticConstants.STYLE_LIGHT, HapticConstants.STYLE_SOFT -> HapticImpactStyle.Light + HapticConstants.STYLE_HEAVY, HapticConstants.STYLE_RIGID -> HapticImpactStyle.Heavy + else -> HapticImpactStyle.Medium +} + +private fun parsePatternEvents(json: JSONObject): List { + val array = json.optJSONArray(HapticConstants.KEY_PATTERN) ?: return emptyList() + return (0 until array.length()).mapNotNull { index -> + loggingRunCatching(defaultValue = null) { + val item = array.getJSONObject(index) + HapticPatternEvent( + time = item.optLong(HapticConstants.KEY_TIME, 0L), + duration = item.optLong(HapticConstants.KEY_DURATION, 0L), + intensity = item.optDouble(HapticConstants.KEY_INTENSITY, 1.0).toFloat(), + sharpness = item.optDouble(HapticConstants.KEY_SHARPNESS, 0.0).toFloat(), + ) + } + } +} + +internal interface HapticFeedbackExecutor { + fun execute(request: HapticRequest) + + fun cancel() +} + +internal class HapticFeedbackExecutorImpl( + private val context: Context, +) : HapticFeedbackExecutor { + + override fun execute(request: HapticRequest) { + loggingRunCatching { + when (request) { + is HapticRequest.Selection -> executeSelection() + is HapticRequest.Impact -> executeImpact(request.style) + is HapticRequest.Pattern -> executePattern(request.events) + } + } + } + + override fun cancel() { + loggingRunCatching { + resolveVibrator()?.cancel() + } + } + + @Suppress("DEPRECATION") + private fun executeSelection() { + val vibrator: Vibrator = resolveVibrator() ?: return + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> + vibrator.vibrate(VibrationEffect.createOneShot(HapticConstants.SELECTION_FALLBACK_DURATION_MS, 85)) + else -> + vibrator.vibrate(HapticConstants.SELECTION_FALLBACK_DURATION_MS) + } + } + + @Suppress("DEPRECATION") + private fun executeImpact(style: HapticImpactStyle) { + val vibrator: Vibrator = resolveVibrator() ?: return + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> { + val effectId: Int = when (style) { + HapticImpactStyle.Light -> VibrationEffect.EFFECT_TICK + HapticImpactStyle.Medium -> VibrationEffect.EFFECT_CLICK + HapticImpactStyle.Heavy -> VibrationEffect.EFFECT_HEAVY_CLICK + } + vibrator.vibrate(VibrationEffect.createPredefined(effectId)) + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { + val (durationMs, amplitude) = impactParams(style) + vibrator.vibrate(VibrationEffect.createOneShot(durationMs, amplitude)) + } + else -> + vibrator.vibrate(impactLegacyDuration(style)) + } + } + + @Suppress("DEPRECATION") + private fun executePattern(events: List) { + if (events.isEmpty()) return + val vibrator: Vibrator = resolveVibrator() ?: return + mindboxLogI("[Haptic] pattern events=${events.size}") + val (timings, amplitudes) = buildWaveform(events) + if (timings.isEmpty()) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createWaveform(timings.toLongArray(), amplitudes.toIntArray(), -1)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(timings.toLongArray(), -1) + } + } + + private fun buildWaveform(events: List): Pair, List> { + val sorted: List = events.sortedBy { it.time } + val timings: MutableList = mutableListOf() + val amplitudes: MutableList = mutableListOf() + var currentTime = 0L + for (event in sorted) { + val effectiveDuration: Long = + if (event.duration > 0) event.duration else HapticConstants.TRANSIENT_DURATION_MS + val amplitude: Int = (event.intensity * 255).toInt().coerceIn(0, 255) + val gap: Long = event.time - currentTime + if (gap > 0) { + timings.add(gap) + amplitudes.add(0) + } else if (timings.isEmpty()) { + timings.add(0) + amplitudes.add(0) + } + timings.add(effectiveDuration) + amplitudes.add(amplitude) + currentTime = event.time + effectiveDuration + } + return timings to amplitudes + } + + @Suppress("DEPRECATION") + private fun resolveVibrator(): Vibrator? { + val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + (context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator + } else { + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + return vibrator?.takeIf { it.hasVibrator() } + } + + private fun impactParams(style: HapticImpactStyle): Pair = when (style) { + HapticImpactStyle.Light -> 20L to 85 + HapticImpactStyle.Medium -> 40L to 180 + HapticImpactStyle.Heavy -> 60L to 255 + } + + private fun impactLegacyDuration(style: HapticImpactStyle): Long = when (style) { + HapticImpactStyle.Light -> 20L + HapticImpactStyle.Medium -> 40L + HapticImpactStyle.Heavy -> 60L + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index e7be54989..c97db72b8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -54,6 +54,9 @@ public enum class WebViewAction { @SerializedName("localState.init") LOCAL_STATE_INIT, + + @SerializedName("haptic") + HAPTIC, } @InternalMindboxApi diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 7132f8913..886d33c67 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -16,6 +16,7 @@ import cloud.mindbox.mobile_sdk.fromJson import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.validators.BridgeMessageValidator +import cloud.mindbox.mobile_sdk.inapp.data.validators.HapticRequestValidator import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendFailureWithContext import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager @@ -74,6 +75,7 @@ internal class WebViewInAppViewHolder( private val gson: Gson by mindboxInject { this.gson } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } + private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } private val sessionStorageManager: SessionStorageManager by mindboxInject { sessionStorageManager } private val permissionManager: PermissionManager by mindboxInject { permissionManager } @@ -87,6 +89,9 @@ internal class WebViewInAppViewHolder( private val localStateStore: WebViewLocalStateStore by lazy { WebViewLocalStateStore(appContext) } + private val hapticFeedbackExecutor: HapticFeedbackExecutor by lazy { + HapticFeedbackExecutorImpl(appContext) + } override fun bind() {} @@ -152,9 +157,17 @@ internal class WebViewInAppViewHolder( register(WebViewAction.HIDE) { handleHideAction(controller) } + register(WebViewAction.HAPTIC, ::handleHapticAction) } } + private fun handleHapticAction(message: BridgeMessage.Request): String { + val request = parseHapticRequest(message.payload) + if (!hapticRequestValidator.isValid(request)) return BridgeMessage.EMPTY_PAYLOAD + hapticFeedbackExecutor.execute(request = request) + return BridgeMessage.EMPTY_PAYLOAD + } + private fun handleReadyAction( configuration: Configuration, insets: InAppInsets, @@ -630,6 +643,7 @@ internal class WebViewInAppViewHolder( } override fun onClose() { + hapticFeedbackExecutor.cancel() stopTimer() cancelPendingResponses("WebView In-App is closed") clearBackPressedCallback() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidatorTest.kt new file mode 100644 index 000000000..c8766ad6e --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidatorTest.kt @@ -0,0 +1,238 @@ +package cloud.mindbox.mobile_sdk.inapp.data.validators + +import cloud.mindbox.mobile_sdk.inapp.presentation.view.HapticImpactStyle +import cloud.mindbox.mobile_sdk.inapp.presentation.view.HapticPatternEvent +import cloud.mindbox.mobile_sdk.inapp.presentation.view.HapticRequest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HapticRequestValidatorTest { + + private val validator: HapticRequestValidator = HapticRequestValidator() + + @Test + fun `isValid returns true for Selection`() { + val actualResult: Boolean = validator.isValid(HapticRequest.Selection) + assertTrue(actualResult) + } + + @Test + fun `isValid returns true for Impact with any style`() { + assertTrue(validator.isValid(HapticRequest.Impact(HapticImpactStyle.Light))) + assertTrue(validator.isValid(HapticRequest.Impact(HapticImpactStyle.Medium))) + assertTrue(validator.isValid(HapticRequest.Impact(HapticImpactStyle.Heavy))) + } + + @Test + fun `isValid returns false for Pattern with empty events`() { + val request: HapticRequest = HapticRequest.Pattern(events = emptyList()) + val actualResult: Boolean = validator.isValid(request) + assertFalse(actualResult) + } + + @Test + fun `isValid returns false for Pattern with more than 128 events`() { + val events: List = List(129) { validEvent(time = it * 100L) } + val request: HapticRequest = HapticRequest.Pattern(events = events) + val actualResult: Boolean = validator.isValid(request) + assertFalse(actualResult) + } + + @Test + fun `isValid returns true for Pattern with exactly 128 valid events`() { + val events: List = List(128) { validEvent(time = it * 200L) } + val request: HapticRequest = HapticRequest.Pattern(events = events) + val actualResult: Boolean = validator.isValid(request) + assertTrue(actualResult) + } + + @Test + fun `isValid returns false when event time is negative`() { + val events: List = listOf(validEvent(time = -1L)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns false when event time exceeds 30000`() { + val events: List = listOf(validEvent(time = 30_001L)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns true when event time is 0`() { + assertTrue(validator.isValid(HapticRequest.Pattern(listOf(validEvent(time = 0L))))) + } + + @Test + fun `isValid returns false when transient event starts at 30000 because effective duration exceeds limit`() { + val events: List = listOf(validEvent(time = 30_000L, duration = 0L)) + assertFalse(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns false when event duration is negative`() { + val events: List = listOf(validEvent(duration = -1L)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns false when event duration exceeds 5000`() { + val events: List = listOf(validEvent(duration = 5_001L)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns true when event duration is at boundary 0 and 5000`() { + assertTrue(validator.isValid(HapticRequest.Pattern(listOf(validEvent(duration = 0L))))) + assertTrue(validator.isValid(HapticRequest.Pattern(listOf(validEvent(duration = 5_000L))))) + } + + @Test + fun `isValid returns false when event intensity is below 0`() { + val events: List = listOf(validEvent(intensity = -0.1f)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns false when event intensity exceeds 1`() { + val events: List = listOf(validEvent(intensity = 1.1f)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns true when event intensity is at boundary 0 and 1`() { + assertTrue(validator.isValid(HapticRequest.Pattern(listOf(validEvent(intensity = 0f))))) + assertTrue(validator.isValid(HapticRequest.Pattern(listOf(validEvent(intensity = 1f))))) + } + + @Test + fun `isValid returns false when event sharpness is below 0`() { + val events: List = listOf(validEvent(sharpness = -0.1f)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns false when event sharpness exceeds 1`() { + val events: List = listOf(validEvent(sharpness = 1.1f)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns false when event time plus duration exceeds 30000`() { + val events: List = listOf( + validEvent(time = 28_000L, duration = 2_001L), + ) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns true when event time plus duration equals 30000`() { + val events: List = listOf( + validEvent(time = 25_000L, duration = 5_000L), + ) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertTrue(validator.isValid(request)) + } + + @Test + fun `isValid returns true for single valid pattern event`() { + val events: List = listOf(validEvent()) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertTrue(validator.isValid(request)) + } + + @Test + fun `isValid returns false when any event in pattern is invalid`() { + val events: List = listOf( + validEvent(time = 0L), + validEvent(time = 1000L, duration = 10_000L), + ) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns false when events overlap`() { + val events: List = listOf( + validEvent(time = 0L, duration = 200L), + validEvent(time = 100L, duration = 100L), + ) + assertFalse(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns false when events have same time`() { + val events: List = listOf( + validEvent(time = 0L, duration = 100L), + validEvent(time = 0L, duration = 100L), + ) + assertFalse(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns true when events are adjacent without overlap`() { + val events: List = listOf( + validEvent(time = 0L, duration = 100L), + validEvent(time = 100L, duration = 100L), + ) + assertTrue(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns true when events have gap between them`() { + val events: List = listOf( + validEvent(time = 0L, duration = 100L), + validEvent(time = 300L, duration = 100L), + ) + assertTrue(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns false when unsorted events overlap after sorting`() { + val events: List = listOf( + validEvent(time = 100L, duration = 100L), + validEvent(time = 0L, duration = 200L), + ) + assertFalse(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns false when transient event overlaps next event`() { + val events: List = listOf( + validEvent(time = 0L, duration = 0L), + validEvent(time = 5L, duration = 100L), + ) + assertFalse(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns true when transient event ends exactly when next event starts`() { + val events: List = listOf( + validEvent(time = 0L, duration = 0L), + validEvent(time = 10L, duration = 100L), + ) + assertTrue(validator.isValid(HapticRequest.Pattern(events))) + } + + private fun validEvent( + time: Long = 0L, + duration: Long = 100L, + intensity: Float = 1f, + sharpness: Float = 0f, + ): HapticPatternEvent = HapticPatternEvent( + time = time, + duration = duration, + intensity = intensity, + sharpness = sharpness, + ) +} From bf38a8ee82462871b059b65b8b1a9a27804db48c Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 18 Mar 2026 11:58:50 +0300 Subject: [PATCH 48/59] MOBILEWEBVIEW-100: Add permission request for jsbridge --- sdk/src/main/AndroidManifest.xml | 9 + .../RuntimePermissionRequestActivity.kt | 72 +++++++ .../actions/RuntimePermissionRequestBridge.kt | 29 +++ .../inapp/presentation/view/WebViewAction.kt | 3 + .../view/WebViewInappViewHolder.kt | 30 ++- .../view/WebViewPermissionRequester.kt | 201 ++++++++++++++++++ sdk/src/main/res/values/strings.xml | 2 + ...ebViewPermissionBridgeSerializationTest.kt | 39 ++++ .../view/WebViewPermissionRequesterTest.kt | 174 +++++++++++++++ 9 files changed, 555 insertions(+), 4 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestActivity.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionBridgeSerializationTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml index e50bafe2f..e1b866fc4 100644 --- a/sdk/src/main/AndroidManifest.xml +++ b/sdk/src/main/AndroidManifest.xml @@ -18,5 +18,14 @@ android:noHistory="true" android:theme="@style/Theme.MindboxTransparent"> + + diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestActivity.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestActivity.kt new file mode 100644 index 000000000..bfeebcf14 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestActivity.kt @@ -0,0 +1,72 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.actions + +import android.app.Activity +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.view.ViewGroup +import androidx.annotation.RequiresApi +import cloud.mindbox.mobile_sdk.logger.mindboxLogW + +internal class RuntimePermissionRequestActivity : Activity() { + + companion object { + private const val REQUEST_CODE: Int = 125130 + internal const val EXTRA_REQUEST_ID: String = "runtime_permission_request_id" + internal const val EXTRA_PERMISSIONS: String = "runtime_permission_permissions" + } + + private var requestId: String? = null + private var isResultSent: Boolean = false + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + window.decorView.setBackgroundColor(Color.TRANSPARENT) + window.decorView.isClickable = false + window.setDimAmount(0f) + val actualRequestId: String = intent?.getStringExtra(EXTRA_REQUEST_ID).orEmpty() + val permissions: Array = intent?.getStringArrayExtra(EXTRA_PERMISSIONS) + ?.map { permission: String -> permission } + ?.toTypedArray() + ?: emptyArray() + if (actualRequestId.isBlank() || permissions.isEmpty()) { + finish() + return + } + requestId = actualRequestId + requestPermissions(permissions, REQUEST_CODE) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode != REQUEST_CODE) { + return + } + val isGranted: Boolean = grantResults.isNotEmpty() && grantResults.all { result: Int -> + result == android.content.pm.PackageManager.PERMISSION_GRANTED + } + val actualRequestId: String = requestId.orEmpty() + if (actualRequestId.isNotBlank()) { + RuntimePermissionRequestBridge.resolve(actualRequestId, isGranted) + isResultSent = true + } + finish() + } + + override fun onDestroy() { + if (!isResultSent) { + val actualRequestId: String = requestId.orEmpty() + if (actualRequestId.isNotBlank()) { + mindboxLogW("Permission request activity closed before result for id=$actualRequestId") + RuntimePermissionRequestBridge.resolve(actualRequestId, false) + } + } + super.onDestroy() + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt new file mode 100644 index 000000000..5ed6da872 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt @@ -0,0 +1,29 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.actions + +import kotlinx.coroutines.CompletableDeferred +import java.util.concurrent.ConcurrentHashMap + +internal object RuntimePermissionRequestBridge { + + private val pendingRequestsById: MutableMap> = ConcurrentHashMap() + + fun register(requestId: String): CompletableDeferred { + val deferred: CompletableDeferred = CompletableDeferred() + pendingRequestsById[requestId] = deferred + return deferred + } + + fun resolve(requestId: String, isGranted: Boolean) { + val deferred: CompletableDeferred = pendingRequestsById.remove(requestId) ?: return + if (!deferred.isCompleted) { + deferred.complete(isGranted) + } + } + + fun reject(requestId: String, error: Throwable) { + val deferred: CompletableDeferred = pendingRequestsById.remove(requestId) ?: return + if (!deferred.isCompleted) { + deferred.completeExceptionally(error) + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index c97db72b8..b7471402a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -57,6 +57,9 @@ public enum class WebViewAction { @SerializedName("haptic") HAPTIC, + + @SerializedName(value = "permission.request") + PERMISSION_REQUEST, } @InternalMindboxApi diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 886d33c67..ecb0c758a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view +import android.app.Activity import android.app.Application import android.net.Uri import android.view.ViewGroup @@ -8,11 +9,9 @@ import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri -import cloud.mindbox.mobile_sdk.BuildConfig -import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.* import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.di.mindboxInject -import cloud.mindbox.mobile_sdk.fromJson import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.validators.BridgeMessageValidator @@ -35,7 +34,6 @@ import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.getShortUserAgent import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason -import cloud.mindbox.mobile_sdk.safeAs import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch import com.google.gson.Gson import kotlinx.coroutines.CancellationException @@ -93,6 +91,13 @@ internal class WebViewInAppViewHolder( HapticFeedbackExecutorImpl(appContext) } + private val webViewPermissionRequester: WebViewPermissionRequester by lazy { + WebViewPermissionRequesterImpl( + context = appContext, + permissionManager = permissionManager + ) + } + override fun bind() {} suspend fun sendActionAndAwaitResponse( @@ -143,6 +148,7 @@ internal class WebViewInAppViewHolder( registerSuspend(WebViewAction.LOCAL_STATE_GET, ::handleLocalStateGetAction) registerSuspend(WebViewAction.LOCAL_STATE_SET, ::handleLocalStateSetAction) registerSuspend(WebViewAction.LOCAL_STATE_INIT, ::handleLocalStateInitAction) + registerSuspend(WebViewAction.PERMISSION_REQUEST, ::handlePermissionAction) register(WebViewAction.READY) { handleReadyAction( configuration = configuration, @@ -283,6 +289,22 @@ internal class WebViewInAppViewHolder( return localStateStore.initState(payload) } + private suspend fun handlePermissionAction(message: BridgeMessage.Request): String { + val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD + val typeString: String? = JSONObject(payload).getString(BridgeMessage.TYPE_FIELD_NAME) + val type: PermissionType? = runCatching { typeString.enumValue() }.getOrNull() + requireNotNull(type) { "Unknown permission type: $typeString" } + + val activity: Activity? = webViewController?.view?.context?.safeAs() + checkNotNull(activity) { "Not found activity for permission request" } + + val permissionRequestResult: PermissionActionResponse = webViewPermissionRequester.requestPermission( + activity, + type + ) + return gson.toJson(permissionRequestResult) + } + private fun createWebViewController(layer: Layer.WebViewLayer): WebViewController { mindboxLogI("Creating WebView for In-App: ${wrapper.inAppType.inAppId} with layer ${layer.type}") val controller: WebViewController = WebViewController.create(currentDialog.context, BuildConfig.DEBUG) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt new file mode 100644 index 000000000..948e0b210 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt @@ -0,0 +1,201 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import cloud.mindbox.mobile_sdk.inapp.data.managers.PermissionManagerImpl +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus +import cloud.mindbox.mobile_sdk.inapp.presentation.actions.RuntimePermissionRequestActivity +import cloud.mindbox.mobile_sdk.inapp.presentation.actions.RuntimePermissionRequestBridge +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.UUID + +internal interface WebViewPermissionRequester { + suspend fun requestPermission(activity: Activity, permissionType: PermissionType): PermissionActionResponse +} + +internal enum class PermissionType(val value: String) { + PUSH_NOTIFICATIONS("pushNotifications"), + LOCATION("location"), + CAMERA("camera"), + MICROPHONE("microphone"), + PHOTO_LIBRARY("photoLibrary") +} + +internal data class PermissionActionResponse( + @SerializedName("result") + val result: PermissionRequestStatus, + @SerializedName("dialogShown") + val dialogShown: Boolean, +) + +internal enum class PermissionRequestStatus(val value: String) { + @SerializedName("granted") + GRANTED("granted"), + + @SerializedName("denied") + DENIED("denied") +} + +@SuppressLint("InlinedApi") +internal class WebViewPermissionRequesterImpl( + private val context: Context, + private val runtimePermissionLauncher: RuntimePermissionLauncher = RuntimePermissionLauncherImpl(), + private val manifestPermissionChecker: PermissionManifestChecker = ManifestPermissionChecker(context), + private val permissionManager: PermissionManager = PermissionManagerImpl(context), + private val sdkIntProvider: () -> Int = { Build.VERSION.SDK_INT }, +) : WebViewPermissionRequester { + + override suspend fun requestPermission( + activity: Activity, + permissionType: PermissionType + ): PermissionActionResponse { + val currentStatus: PermissionStatus = getPermissionStatus(permissionType) + if (isGrantedStatus(currentStatus)) { + return PermissionActionResponse( + result = PermissionRequestStatus.GRANTED, + dialogShown = false + ) + } + val permissionsToRequest: List = resolveRequestPermissions(permissionType) + if (permissionsToRequest.isEmpty()) { + return PermissionActionResponse( + result = PermissionRequestStatus.DENIED, + dialogShown = false + ) + } + val declaredPermissions: List = permissionsToRequest.filter { permission: String -> + manifestPermissionChecker.isPermissionDeclared(permission) + } + if (declaredPermissions.isEmpty()) { + throw IllegalStateException("Permission is not declared in AndroidManifest for type: ${permissionType.value}") + } + declaredPermissions.forEach { permission: String -> + val status: PermissionRequestStatus = runtimePermissionLauncher.requestPermission( + activity = activity, + permissions = arrayOf(permission) + ) + if (status == PermissionRequestStatus.GRANTED) { + return PermissionActionResponse( + result = status, + dialogShown = true + ) + } + } + return PermissionActionResponse( + result = PermissionRequestStatus.DENIED, + dialogShown = true + ) + } + + private fun getPermissionStatus(permissionType: PermissionType): PermissionStatus { + return when (permissionType) { + PermissionType.PUSH_NOTIFICATIONS -> permissionManager.getNotificationPermissionStatus() + PermissionType.LOCATION -> permissionManager.getLocationPermissionStatus() + PermissionType.CAMERA -> permissionManager.getCameraPermissionStatus() + PermissionType.MICROPHONE -> permissionManager.getMicrophonePermissionStatus() + PermissionType.PHOTO_LIBRARY -> permissionManager.getPhotoLibraryPermissionStatus() + } + } + + private fun isGrantedStatus(permissionStatus: PermissionStatus): Boolean { + return permissionStatus == PermissionStatus.GRANTED || permissionStatus == PermissionStatus.LIMITED + } + + private fun resolveRequestPermissions(permissionType: PermissionType): List { + return when (permissionType) { + PermissionType.PUSH_NOTIFICATIONS -> { + if (sdkIntProvider() >= Build.VERSION_CODES.TIRAMISU) { + listOf(Manifest.permission.POST_NOTIFICATIONS) + } else { + emptyList() + } + } + PermissionType.LOCATION -> listOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + PermissionType.CAMERA -> listOf(Manifest.permission.CAMERA) + PermissionType.MICROPHONE -> listOf(Manifest.permission.RECORD_AUDIO) + PermissionType.PHOTO_LIBRARY -> resolveLibraryPermissions() + } + } + + private fun resolveLibraryPermissions(): List { + if (sdkIntProvider() >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return listOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + ) + } + if (sdkIntProvider() >= Build.VERSION_CODES.TIRAMISU) { + return listOf(Manifest.permission.READ_MEDIA_IMAGES) + } + return listOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } +} + +internal interface RuntimePermissionLauncher { + suspend fun requestPermission(activity: Activity, permissions: Array): PermissionRequestStatus +} + +internal class RuntimePermissionLauncherImpl : RuntimePermissionLauncher { + override suspend fun requestPermission( + activity: Activity, + permissions: Array + ): PermissionRequestStatus { + val requestId: String = UUID.randomUUID().toString() + val deferredResult = RuntimePermissionRequestBridge.register(requestId) + withContext(Dispatchers.Main.immediate) { + activity.startActivity( + Intent(activity, RuntimePermissionRequestActivity::class.java).apply { + putExtra(RuntimePermissionRequestActivity.EXTRA_REQUEST_ID, requestId) + putExtra(RuntimePermissionRequestActivity.EXTRA_PERMISSIONS, permissions) + } + ) + } + val isGranted: Boolean = deferredResult.await() + return if (isGranted) { + PermissionRequestStatus.GRANTED + } else { + PermissionRequestStatus.DENIED + } + } +} + +internal interface PermissionManifestChecker { + fun isPermissionDeclared(permission: String): Boolean +} + +internal class ManifestPermissionChecker( + private val context: Context +) : PermissionManifestChecker { + private val declaredPermissions: Set by lazy { + readDeclaredPermissions() + } + + override fun isPermissionDeclared(permission: String): Boolean { + return declaredPermissions.contains(permission) + } + + private fun readDeclaredPermissions(): Set { + val packageInfo: PackageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo( + context.packageName, + PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()) + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + } + return packageInfo.requestedPermissions?.toSet().orEmpty() + } +} diff --git a/sdk/src/main/res/values/strings.xml b/sdk/src/main/res/values/strings.xml index ba29a08a9..acb6bfed1 100644 --- a/sdk/src/main/res/values/strings.xml +++ b/sdk/src/main/res/values/strings.xml @@ -13,5 +13,7 @@ true @null @android:color/transparent + false + false \ No newline at end of file diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionBridgeSerializationTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionBridgeSerializationTest.kt new file mode 100644 index 000000000..a8761a1fe --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionBridgeSerializationTest.kt @@ -0,0 +1,39 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(InternalMindboxApi::class) +class WebViewPermissionBridgeSerializationTest { + + private val gson: Gson = Gson() + + @Test + fun `toJson serializes denied result correctly`() { + val payload = PermissionActionResponse( + result = PermissionRequestStatus.DENIED, + dialogShown = true + ) + val json: String = gson.toJson(payload) + val parsedPayload: PermissionResponseTestPayload = gson.fromJson(json, PermissionResponseTestPayload::class.java) + assertEquals("denied", parsedPayload.result) + assertEquals(true, parsedPayload.dialogShown) + } + + @Test + fun `fromJson maps permission request action to enum`() { + val message: ActionWrapper = gson.fromJson("""{"action":"permission.request"}""", ActionWrapper::class.java) + assertEquals(WebViewAction.PERMISSION_REQUEST, message.action) + } + + private data class PermissionResponseTestPayload( + val result: String, + val dialogShown: Boolean + ) + + private data class ActionWrapper( + val action: WebViewAction + ) +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt new file mode 100644 index 000000000..f38ad5efb --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt @@ -0,0 +1,174 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.app.Activity +import android.os.Build +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WebViewPermissionRequesterTest { + + @Test + fun `requestPermission returns granted when camera permission already granted`() = runTest { + val runtimePermissionLauncher: RuntimePermissionLauncher = FakeRuntimePermissionLauncher(PermissionRequestStatus.DENIED) + val permissionManifestChecker: PermissionManifestChecker = FakePermissionManifestChecker( + declaredPermissions = setOf(android.Manifest.permission.CAMERA) + ) + val permissionManager: PermissionManager = FakePermissionManager( + cameraStatus = PermissionStatus.GRANTED + ) + val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( + context = mockk(relaxed = true), + runtimePermissionLauncher = runtimePermissionLauncher, + manifestPermissionChecker = permissionManifestChecker, + permissionManager = permissionManager, + sdkIntProvider = { Build.VERSION_CODES.TIRAMISU } + ) + val actualResult: PermissionActionResponse = requester.requestPermission( + activity = mockk(relaxed = true), + permissionType = PermissionType.CAMERA + ) + assertEquals(PermissionRequestStatus.GRANTED, actualResult.result) + assertEquals(false, actualResult.dialogShown) + } + + @Test + fun `requestPermission returns denied when camera permission request is denied`() = runTest { + val runtimePermissionLauncher: RuntimePermissionLauncher = FakeRuntimePermissionLauncher(PermissionRequestStatus.DENIED) + val permissionManifestChecker: PermissionManifestChecker = FakePermissionManifestChecker( + declaredPermissions = setOf(android.Manifest.permission.CAMERA) + ) + val permissionManager: PermissionManager = FakePermissionManager( + cameraStatus = PermissionStatus.DENIED + ) + val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( + context = mockk(relaxed = true), + runtimePermissionLauncher = runtimePermissionLauncher, + manifestPermissionChecker = permissionManifestChecker, + permissionManager = permissionManager, + sdkIntProvider = { Build.VERSION_CODES.TIRAMISU } + ) + val actualResult: PermissionActionResponse = requester.requestPermission( + activity = mockk(relaxed = true), + permissionType = PermissionType.CAMERA + ) + assertEquals(PermissionRequestStatus.DENIED, actualResult.result) + assertEquals(true, actualResult.dialogShown) + } + + @Test + fun `requestPermission throws error when permission missing in AndroidManifest`() = runTest { + val runtimePermissionLauncher: RuntimePermissionLauncher = FakeRuntimePermissionLauncher(PermissionRequestStatus.GRANTED) + val permissionManifestChecker: PermissionManifestChecker = FakePermissionManifestChecker( + declaredPermissions = emptySet() + ) + val permissionManager: PermissionManager = FakePermissionManager( + cameraStatus = PermissionStatus.DENIED + ) + val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( + context = mockk(relaxed = true), + runtimePermissionLauncher = runtimePermissionLauncher, + manifestPermissionChecker = permissionManifestChecker, + permissionManager = permissionManager, + sdkIntProvider = { Build.VERSION_CODES.TIRAMISU } + ) + val error: Throwable = runCatching { + requester.requestPermission( + activity = mockk(relaxed = true), + permissionType = PermissionType.CAMERA + ) + }.exceptionOrNull() ?: error("Expected exception for missing manifest permission") + assertTrue(error is IllegalStateException) + } + + @Test + fun `requestPermission returns denied without dialog for push on sdk lower than tiramisu`() = runTest { + val runtimePermissionLauncher: RuntimePermissionLauncher = FakeRuntimePermissionLauncher(PermissionRequestStatus.GRANTED) + val permissionManifestChecker: PermissionManifestChecker = FakePermissionManifestChecker( + declaredPermissions = setOf(android.Manifest.permission.POST_NOTIFICATIONS) + ) + val permissionManager: PermissionManager = FakePermissionManager( + pushStatus = PermissionStatus.DENIED + ) + val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( + context = mockk(relaxed = true), + runtimePermissionLauncher = runtimePermissionLauncher, + manifestPermissionChecker = permissionManifestChecker, + permissionManager = permissionManager, + sdkIntProvider = { Build.VERSION_CODES.S } + ) + val actualResult: PermissionActionResponse = requester.requestPermission( + activity = mockk(relaxed = true), + permissionType = PermissionType.entries.first { permissionType: PermissionType -> + permissionType.value == "pushNotifications" + } + ) + assertEquals(PermissionRequestStatus.DENIED, actualResult.result) + assertEquals(false, actualResult.dialogShown) + } + + @Test + fun `requestPermission returns granted without dialog when status is limited`() = runTest { + val runtimePermissionLauncher: RuntimePermissionLauncher = FakeRuntimePermissionLauncher(PermissionRequestStatus.DENIED) + val permissionManifestChecker: PermissionManifestChecker = FakePermissionManifestChecker( + declaredPermissions = setOf(android.Manifest.permission.READ_MEDIA_IMAGES) + ) + val permissionManager: PermissionManager = FakePermissionManager( + libraryStatus = PermissionStatus.LIMITED + ) + val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( + context = mockk(relaxed = true), + runtimePermissionLauncher = runtimePermissionLauncher, + manifestPermissionChecker = permissionManifestChecker, + permissionManager = permissionManager, + sdkIntProvider = { Build.VERSION_CODES.UPSIDE_DOWN_CAKE } + ) + val actualResult: PermissionActionResponse = requester.requestPermission( + activity = mockk(relaxed = true), + permissionType = PermissionType.entries.first { permissionType: PermissionType -> + permissionType.value == "photoLibrary" + } + ) + assertEquals(PermissionRequestStatus.GRANTED, actualResult.result) + assertEquals(false, actualResult.dialogShown) + } + + private class FakeRuntimePermissionLauncher( + private val result: PermissionRequestStatus + ) : RuntimePermissionLauncher { + override suspend fun requestPermission(activity: Activity, permissions: Array): PermissionRequestStatus { + return result + } + } + + private class FakePermissionManifestChecker( + private val declaredPermissions: Set + ) : PermissionManifestChecker { + override fun isPermissionDeclared(permission: String): Boolean { + return declaredPermissions.contains(permission) + } + } + + private class FakePermissionManager( + private val cameraStatus: PermissionStatus = PermissionStatus.DENIED, + private val geoStatus: PermissionStatus = PermissionStatus.DENIED, + private val microphoneStatus: PermissionStatus = PermissionStatus.DENIED, + private val pushStatus: PermissionStatus = PermissionStatus.DENIED, + private val libraryStatus: PermissionStatus = PermissionStatus.DENIED, + ) : PermissionManager { + + override fun getCameraPermissionStatus(): PermissionStatus = cameraStatus + + override fun getLocationPermissionStatus(): PermissionStatus = geoStatus + + override fun getMicrophonePermissionStatus(): PermissionStatus = microphoneStatus + + override fun getNotificationPermissionStatus(): PermissionStatus = pushStatus + + override fun getPhotoLibraryPermissionStatus(): PermissionStatus = libraryStatus + } +} From 2f7e27e1a85034df5bb9322f8f47d682f2deffe6 Mon Sep 17 00:00:00 2001 From: sozinov Date: Wed, 18 Mar 2026 13:00:13 +0300 Subject: [PATCH 49/59] MOBILEWEBVIEW-98: fix unit test stubbing --- .../cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt index 5b14e98b7..c8a3410ab 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.utils import cloud.mindbox.mobile_sdk.di.MindboxDI +import cloud.mindbox.mobile_sdk.di.modules.AppModule import cloud.mindbox.mobile_sdk.models.convertToIso8601String import cloud.mindbox.mobile_sdk.models.toTimestamp import cloud.mindbox.mobile_sdk.pushes.PrefPushToken @@ -18,9 +19,9 @@ class MigrationManagerTest { fun setUp() { mockkObject(MindboxPreferences) mockkObject(MindboxDI) - every { MindboxDI.appModule } returns mockk(relaxed = true) { - every { gson } returns Gson() - } + val appModule = mockk(relaxed = true) + every { appModule.gson } returns Gson() + every { MindboxDI.appModule } returns appModule } @Test From fa41cebcbf499ae01bb83bab5a624bedf49614a1 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 18 Mar 2026 16:08:16 +0300 Subject: [PATCH 50/59] MOBILEWEBVIEW-100: Follow code review --- .../actions/RuntimePermissionRequestActivity.kt | 12 ++++++++---- .../presentation/view/WebViewInappViewHolder.kt | 2 +- .../presentation/view/WebViewPermissionRequester.kt | 2 ++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestActivity.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestActivity.kt index bfeebcf14..f701bffb8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestActivity.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestActivity.kt @@ -5,7 +5,6 @@ import android.graphics.Color import android.os.Build import android.os.Bundle import android.view.ViewGroup -import androidx.annotation.RequiresApi import cloud.mindbox.mobile_sdk.logger.mindboxLogW internal class RuntimePermissionRequestActivity : Activity() { @@ -19,7 +18,6 @@ internal class RuntimePermissionRequestActivity : Activity() { private var requestId: String? = null private var isResultSent: Boolean = false - @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) @@ -36,7 +34,13 @@ internal class RuntimePermissionRequestActivity : Activity() { return } requestId = actualRequestId - requestPermissions(permissions, REQUEST_CODE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + requestPermissions(permissions, REQUEST_CODE) + } else { + RuntimePermissionRequestBridge.resolve(actualRequestId, false) + isResultSent = true + finish() + } } override fun onRequestPermissionsResult( @@ -60,7 +64,7 @@ internal class RuntimePermissionRequestActivity : Activity() { } override fun onDestroy() { - if (!isResultSent) { + if (!isResultSent && isFinishing && !isChangingConfigurations) { val actualRequestId: String = requestId.orEmpty() if (actualRequestId.isNotBlank()) { mindboxLogW("Permission request activity closed before result for id=$actualRequestId") diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index ecb0c758a..cd9a354e9 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -291,7 +291,7 @@ internal class WebViewInAppViewHolder( private suspend fun handlePermissionAction(message: BridgeMessage.Request): String { val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD - val typeString: String? = JSONObject(payload).getString(BridgeMessage.TYPE_FIELD_NAME) + val typeString: String? = JSONObject(payload).getString(PERMISSION_PAYLOAD_TYPE_FIELD_NAME) val type: PermissionType? = runCatching { typeString.enumValue() }.getOrNull() requireNotNull(type) { "Unknown permission type: $typeString" } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt index 948e0b210..e386b7c59 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt @@ -18,6 +18,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.util.UUID +internal const val PERMISSION_PAYLOAD_TYPE_FIELD_NAME = "type" + internal interface WebViewPermissionRequester { suspend fun requestPermission(activity: Activity, permissionType: PermissionType): PermissionActionResponse } From 717826d00f66c1d9908e0201116eb20425019f00 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 18 Mar 2026 17:13:02 +0300 Subject: [PATCH 51/59] MOBILEWEBVIEW-100: Follow code review --- .../presentation/actions/RuntimePermissionRequestBridge.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt index 5ed6da872..68020156b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt @@ -19,11 +19,4 @@ internal object RuntimePermissionRequestBridge { deferred.complete(isGranted) } } - - fun reject(requestId: String, error: Throwable) { - val deferred: CompletableDeferred = pendingRequestsById.remove(requestId) ?: return - if (!deferred.isCompleted) { - deferred.completeExceptionally(error) - } - } } From 8d58f66499c4e5392c69c049e1097cfdba124aed Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 20 Mar 2026 13:51:39 +0300 Subject: [PATCH 52/59] MOBILEWEBVIEW-133: Add push permission request for jsbridge --- .../actions/PushActivationActivity.kt | 29 ++- .../view/WebViewPermissionRequester.kt | 178 +++++++----------- ...ebViewPermissionBridgeSerializationTest.kt | 16 +- .../view/WebViewPermissionRequesterTest.kt | 123 ++++-------- 4 files changed, 141 insertions(+), 205 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt index 7ad5f78f9..121fcdd28 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt @@ -19,10 +19,13 @@ internal class PushActivationActivity : Activity() { private val requestPermissionManager by mindboxInject { requestPermissionManager } private var shouldCheckDialogShowing = false private val resumeTimes = mutableListOf() + private var requestId: String? = null + private var isResultSent: Boolean = false companion object { private const val PERMISSION_REQUEST_CODE = 125129 private const val TIME_BETWEEN_RESUME = 700 + internal const val EXTRA_REQUEST_ID: String = "runtime_permission_request_id" } @RequiresApi(Build.VERSION_CODES.M) @@ -43,7 +46,7 @@ internal class PushActivationActivity : Activity() { granted -> { mindboxLogI("User clicked 'allow' in request permission") Mindbox.updateNotificationPermissionStatus(this) - finish() + finishWithResult(isGranted = true) } permissionDenied && !shouldShowRationale -> { @@ -51,20 +54,20 @@ internal class PushActivationActivity : Activity() { if (requestPermissionManager.getRequestCount() > 1) { mindboxLogI("User already rejected permission two times, try open settings") mindboxNotificationManager.openNotificationSettings(this) - finish() + finishWithResult(isGranted = false) } else { mindboxLogI("Awaiting show dialog") shouldCheckDialogShowing = true } } else { mindboxNotificationManager.shouldOpenSettings = true - finish() + finishWithResult(isGranted = false) } } permissionDenied && shouldShowRationale -> { mindboxLogI("User rejected first permission request") - finish() + finishWithResult(isGranted = false) } } } @@ -77,6 +80,7 @@ internal class PushActivationActivity : Activity() { ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) + requestId = intent?.getStringExtra(EXTRA_REQUEST_ID) mindboxLogI("Call permission laucher") requestPermissions(arrayOf(Constants.POST_NOTIFICATION), PERMISSION_REQUEST_CODE) } @@ -94,16 +98,29 @@ internal class PushActivationActivity : Activity() { requestPermissionManager.decreaseRequestCounter() } shouldCheckDialogShowing = false - finish() + finishWithResult(isGranted = false) } super.onResume() } override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_DOWN) { - finish() + finishWithResult(isGranted = false) return true } return super.onTouchEvent(event) } + + override fun onDestroy() { + if (!isResultSent && isFinishing && !isChangingConfigurations) { + RuntimePermissionRequestBridge.resolve(requestId.orEmpty(), false) + } + super.onDestroy() + } + + private fun finishWithResult(isGranted: Boolean) { + RuntimePermissionRequestBridge.resolve(requestId.orEmpty(), isGranted) + isResultSent = true + finish() + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt index e386b7c59..47573b676 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt @@ -5,18 +5,16 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Intent -import android.content.pm.PackageInfo -import android.content.pm.PackageManager import android.os.Build +import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.inapp.data.managers.PermissionManagerImpl import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus -import cloud.mindbox.mobile_sdk.inapp.presentation.actions.RuntimePermissionRequestActivity +import cloud.mindbox.mobile_sdk.inapp.presentation.actions.PushActivationActivity import cloud.mindbox.mobile_sdk.inapp.presentation.actions.RuntimePermissionRequestBridge import com.google.gson.annotations.SerializedName import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.util.UUID internal const val PERMISSION_PAYLOAD_TYPE_FIELD_NAME = "type" @@ -25,11 +23,7 @@ internal interface WebViewPermissionRequester { } internal enum class PermissionType(val value: String) { - PUSH_NOTIFICATIONS("pushNotifications"), - LOCATION("location"), - CAMERA("camera"), - MICROPHONE("microphone"), - PHOTO_LIBRARY("photoLibrary") + PUSH_NOTIFICATIONS("pushNotifications") } internal data class PermissionActionResponse( @@ -37,6 +31,15 @@ internal data class PermissionActionResponse( val result: PermissionRequestStatus, @SerializedName("dialogShown") val dialogShown: Boolean, + @SerializedName("details") + val details: PermissionActionDetails, +) + +internal data class PermissionActionDetails( + @SerializedName("required") + val required: Boolean, + @SerializedName("shouldShowRequestPermissionRationale") + val shouldShowRequestPermissionRationale: Boolean? = null, ) internal enum class PermissionRequestStatus(val value: String) { @@ -50,8 +53,7 @@ internal enum class PermissionRequestStatus(val value: String) { @SuppressLint("InlinedApi") internal class WebViewPermissionRequesterImpl( private val context: Context, - private val runtimePermissionLauncher: RuntimePermissionLauncher = RuntimePermissionLauncherImpl(), - private val manifestPermissionChecker: PermissionManifestChecker = ManifestPermissionChecker(context), + private val pushPermissionLauncher: PushPermissionLauncher = PushPermissionLauncherImpl(), private val permissionManager: PermissionManager = PermissionManagerImpl(context), private val sdkIntProvider: () -> Int = { Build.VERSION.SDK_INT }, ) : WebViewPermissionRequester { @@ -62,49 +64,29 @@ internal class WebViewPermissionRequesterImpl( ): PermissionActionResponse { val currentStatus: PermissionStatus = getPermissionStatus(permissionType) if (isGrantedStatus(currentStatus)) { - return PermissionActionResponse( + return createPermissionActionResponse( result = PermissionRequestStatus.GRANTED, dialogShown = false ) } - val permissionsToRequest: List = resolveRequestPermissions(permissionType) - if (permissionsToRequest.isEmpty()) { - return PermissionActionResponse( + if (!isPermissionRequired()) { + return createPermissionActionResponse( result = PermissionRequestStatus.DENIED, dialogShown = false ) } - val declaredPermissions: List = permissionsToRequest.filter { permission: String -> - manifestPermissionChecker.isPermissionDeclared(permission) - } - if (declaredPermissions.isEmpty()) { - throw IllegalStateException("Permission is not declared in AndroidManifest for type: ${permissionType.value}") - } - declaredPermissions.forEach { permission: String -> - val status: PermissionRequestStatus = runtimePermissionLauncher.requestPermission( - activity = activity, - permissions = arrayOf(permission) - ) - if (status == PermissionRequestStatus.GRANTED) { - return PermissionActionResponse( - result = status, - dialogShown = true - ) - } - } - return PermissionActionResponse( - result = PermissionRequestStatus.DENIED, - dialogShown = true + + val permissionResult: PushPermissionRequestResult = pushPermissionLauncher.requestPermission(activity = activity) + return createPermissionActionResponse( + result = permissionResult.status, + dialogShown = true, + shouldShowRequestPermissionRationale = permissionResult.shouldShowRequestPermissionRationale ) } private fun getPermissionStatus(permissionType: PermissionType): PermissionStatus { return when (permissionType) { PermissionType.PUSH_NOTIFICATIONS -> permissionManager.getNotificationPermissionStatus() - PermissionType.LOCATION -> permissionManager.getLocationPermissionStatus() - PermissionType.CAMERA -> permissionManager.getCameraPermissionStatus() - PermissionType.MICROPHONE -> permissionManager.getMicrophonePermissionStatus() - PermissionType.PHOTO_LIBRARY -> permissionManager.getPhotoLibraryPermissionStatus() } } @@ -112,92 +94,70 @@ internal class WebViewPermissionRequesterImpl( return permissionStatus == PermissionStatus.GRANTED || permissionStatus == PermissionStatus.LIMITED } - private fun resolveRequestPermissions(permissionType: PermissionType): List { - return when (permissionType) { - PermissionType.PUSH_NOTIFICATIONS -> { - if (sdkIntProvider() >= Build.VERSION_CODES.TIRAMISU) { - listOf(Manifest.permission.POST_NOTIFICATIONS) - } else { - emptyList() - } - } - PermissionType.LOCATION -> listOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - ) - PermissionType.CAMERA -> listOf(Manifest.permission.CAMERA) - PermissionType.MICROPHONE -> listOf(Manifest.permission.RECORD_AUDIO) - PermissionType.PHOTO_LIBRARY -> resolveLibraryPermissions() - } - } + private fun isPermissionRequired(): Boolean = sdkIntProvider() >= Build.VERSION_CODES.TIRAMISU - private fun resolveLibraryPermissions(): List { - if (sdkIntProvider() >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - return listOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + private fun createPermissionActionResponse( + result: PermissionRequestStatus, + dialogShown: Boolean, + shouldShowRequestPermissionRationale: Boolean? = null + ): PermissionActionResponse { + val isPermissionRequired: Boolean = isPermissionRequired() + return PermissionActionResponse( + result = result, + dialogShown = dialogShown, + details = PermissionActionDetails( + required = isPermissionRequired, + shouldShowRequestPermissionRationale = shouldShowRequestPermissionRationale ) - } - if (sdkIntProvider() >= Build.VERSION_CODES.TIRAMISU) { - return listOf(Manifest.permission.READ_MEDIA_IMAGES) - } - return listOf(Manifest.permission.READ_EXTERNAL_STORAGE) + ) } } -internal interface RuntimePermissionLauncher { - suspend fun requestPermission(activity: Activity, permissions: Array): PermissionRequestStatus +internal interface PushPermissionLauncher { + suspend fun requestPermission(activity: Activity): PushPermissionRequestResult } -internal class RuntimePermissionLauncherImpl : RuntimePermissionLauncher { - override suspend fun requestPermission( - activity: Activity, - permissions: Array - ): PermissionRequestStatus { - val requestId: String = UUID.randomUUID().toString() +internal data class PushPermissionRequestResult( + val status: PermissionRequestStatus, + val shouldShowRequestPermissionRationale: Boolean, +) + +@SuppressLint("InlinedApi") +internal class PushPermissionLauncherImpl( + private val sdkIntProvider: () -> Int = { Build.VERSION.SDK_INT } +) : PushPermissionLauncher { + private val notificationPermission: String = Manifest.permission.POST_NOTIFICATIONS + + override suspend fun requestPermission(activity: Activity): PushPermissionRequestResult { + if (sdkIntProvider() < Build.VERSION_CODES.TIRAMISU) { + return PushPermissionRequestResult( + status = PermissionRequestStatus.DENIED, + shouldShowRequestPermissionRationale = false + ) + } + val requestId: String = Mindbox.generateRandomUuid() val deferredResult = RuntimePermissionRequestBridge.register(requestId) withContext(Dispatchers.Main.immediate) { activity.startActivity( - Intent(activity, RuntimePermissionRequestActivity::class.java).apply { - putExtra(RuntimePermissionRequestActivity.EXTRA_REQUEST_ID, requestId) - putExtra(RuntimePermissionRequestActivity.EXTRA_PERMISSIONS, permissions) + Intent(activity, PushActivationActivity::class.java).apply { + putExtra(PushActivationActivity.EXTRA_REQUEST_ID, requestId) } ) } val isGranted: Boolean = deferredResult.await() - return if (isGranted) { - PermissionRequestStatus.GRANTED - } else { - PermissionRequestStatus.DENIED + val shouldShowRationale: Boolean = withContext(Dispatchers.Main.immediate) { + activity.shouldShowRequestPermissionRationale(notificationPermission) } - } -} - -internal interface PermissionManifestChecker { - fun isPermissionDeclared(permission: String): Boolean -} - -internal class ManifestPermissionChecker( - private val context: Context -) : PermissionManifestChecker { - private val declaredPermissions: Set by lazy { - readDeclaredPermissions() - } - - override fun isPermissionDeclared(permission: String): Boolean { - return declaredPermissions.contains(permission) - } - - private fun readDeclaredPermissions(): Set { - val packageInfo: PackageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.packageManager.getPackageInfo( - context.packageName, - PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()) + return if (isGranted) { + PushPermissionRequestResult( + status = PermissionRequestStatus.GRANTED, + shouldShowRequestPermissionRationale = shouldShowRationale ) } else { - @Suppress("DEPRECATION") - context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + PushPermissionRequestResult( + status = PermissionRequestStatus.DENIED, + shouldShowRequestPermissionRationale = shouldShowRationale + ) } - return packageInfo.requestedPermissions?.toSet().orEmpty() } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionBridgeSerializationTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionBridgeSerializationTest.kt index a8761a1fe..2fef5238b 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionBridgeSerializationTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionBridgeSerializationTest.kt @@ -14,12 +14,18 @@ class WebViewPermissionBridgeSerializationTest { fun `toJson serializes denied result correctly`() { val payload = PermissionActionResponse( result = PermissionRequestStatus.DENIED, - dialogShown = true + dialogShown = true, + details = PermissionActionDetails( + required = true, + shouldShowRequestPermissionRationale = false + ) ) val json: String = gson.toJson(payload) val parsedPayload: PermissionResponseTestPayload = gson.fromJson(json, PermissionResponseTestPayload::class.java) assertEquals("denied", parsedPayload.result) assertEquals(true, parsedPayload.dialogShown) + assertEquals(true, parsedPayload.details.required) + assertEquals(false, parsedPayload.details.shouldShowRequestPermissionRationale) } @Test @@ -30,7 +36,13 @@ class WebViewPermissionBridgeSerializationTest { private data class PermissionResponseTestPayload( val result: String, - val dialogShown: Boolean + val dialogShown: Boolean, + val details: PermissionDetailsTestPayload + ) + + private data class PermissionDetailsTestPayload( + val required: Boolean, + val shouldShowRequestPermissionRationale: Boolean? ) private data class ActionWrapper( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt index f38ad5efb..eaa993af1 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt @@ -7,152 +7,99 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test class WebViewPermissionRequesterTest { @Test - fun `requestPermission returns granted when camera permission already granted`() = runTest { - val runtimePermissionLauncher: RuntimePermissionLauncher = FakeRuntimePermissionLauncher(PermissionRequestStatus.DENIED) - val permissionManifestChecker: PermissionManifestChecker = FakePermissionManifestChecker( - declaredPermissions = setOf(android.Manifest.permission.CAMERA) + fun `requestPermission returns granted when push permission already granted`() = runTest { + val pushPermissionLauncher: PushPermissionLauncher = FakePushPermissionLauncher( + PushPermissionRequestResult( + status = PermissionRequestStatus.DENIED, + shouldShowRequestPermissionRationale = false + ) ) val permissionManager: PermissionManager = FakePermissionManager( - cameraStatus = PermissionStatus.GRANTED + pushStatus = PermissionStatus.GRANTED ) val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( context = mockk(relaxed = true), - runtimePermissionLauncher = runtimePermissionLauncher, - manifestPermissionChecker = permissionManifestChecker, + pushPermissionLauncher = pushPermissionLauncher, permissionManager = permissionManager, sdkIntProvider = { Build.VERSION_CODES.TIRAMISU } ) val actualResult: PermissionActionResponse = requester.requestPermission( activity = mockk(relaxed = true), - permissionType = PermissionType.CAMERA + permissionType = PermissionType.PUSH_NOTIFICATIONS ) assertEquals(PermissionRequestStatus.GRANTED, actualResult.result) assertEquals(false, actualResult.dialogShown) + assertEquals(true, actualResult.details.required) + assertEquals(null, actualResult.details.shouldShowRequestPermissionRationale) } @Test - fun `requestPermission returns denied when camera permission request is denied`() = runTest { - val runtimePermissionLauncher: RuntimePermissionLauncher = FakeRuntimePermissionLauncher(PermissionRequestStatus.DENIED) - val permissionManifestChecker: PermissionManifestChecker = FakePermissionManifestChecker( - declaredPermissions = setOf(android.Manifest.permission.CAMERA) + fun `requestPermission returns denied when push permission request is denied`() = runTest { + val pushPermissionLauncher: PushPermissionLauncher = FakePushPermissionLauncher( + PushPermissionRequestResult( + status = PermissionRequestStatus.DENIED, + shouldShowRequestPermissionRationale = true + ) ) val permissionManager: PermissionManager = FakePermissionManager( - cameraStatus = PermissionStatus.DENIED + pushStatus = PermissionStatus.DENIED ) val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( context = mockk(relaxed = true), - runtimePermissionLauncher = runtimePermissionLauncher, - manifestPermissionChecker = permissionManifestChecker, + pushPermissionLauncher = pushPermissionLauncher, permissionManager = permissionManager, sdkIntProvider = { Build.VERSION_CODES.TIRAMISU } ) val actualResult: PermissionActionResponse = requester.requestPermission( activity = mockk(relaxed = true), - permissionType = PermissionType.CAMERA + permissionType = PermissionType.PUSH_NOTIFICATIONS ) assertEquals(PermissionRequestStatus.DENIED, actualResult.result) assertEquals(true, actualResult.dialogShown) - } - - @Test - fun `requestPermission throws error when permission missing in AndroidManifest`() = runTest { - val runtimePermissionLauncher: RuntimePermissionLauncher = FakeRuntimePermissionLauncher(PermissionRequestStatus.GRANTED) - val permissionManifestChecker: PermissionManifestChecker = FakePermissionManifestChecker( - declaredPermissions = emptySet() - ) - val permissionManager: PermissionManager = FakePermissionManager( - cameraStatus = PermissionStatus.DENIED - ) - val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( - context = mockk(relaxed = true), - runtimePermissionLauncher = runtimePermissionLauncher, - manifestPermissionChecker = permissionManifestChecker, - permissionManager = permissionManager, - sdkIntProvider = { Build.VERSION_CODES.TIRAMISU } - ) - val error: Throwable = runCatching { - requester.requestPermission( - activity = mockk(relaxed = true), - permissionType = PermissionType.CAMERA - ) - }.exceptionOrNull() ?: error("Expected exception for missing manifest permission") - assertTrue(error is IllegalStateException) + assertEquals(true, actualResult.details.required) + assertEquals(true, actualResult.details.shouldShowRequestPermissionRationale) } @Test fun `requestPermission returns denied without dialog for push on sdk lower than tiramisu`() = runTest { - val runtimePermissionLauncher: RuntimePermissionLauncher = FakeRuntimePermissionLauncher(PermissionRequestStatus.GRANTED) - val permissionManifestChecker: PermissionManifestChecker = FakePermissionManifestChecker( - declaredPermissions = setOf(android.Manifest.permission.POST_NOTIFICATIONS) + val pushPermissionLauncher: PushPermissionLauncher = FakePushPermissionLauncher( + PushPermissionRequestResult( + status = PermissionRequestStatus.GRANTED, + shouldShowRequestPermissionRationale = false + ) ) val permissionManager: PermissionManager = FakePermissionManager( pushStatus = PermissionStatus.DENIED ) val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( context = mockk(relaxed = true), - runtimePermissionLauncher = runtimePermissionLauncher, - manifestPermissionChecker = permissionManifestChecker, + pushPermissionLauncher = pushPermissionLauncher, permissionManager = permissionManager, sdkIntProvider = { Build.VERSION_CODES.S } ) val actualResult: PermissionActionResponse = requester.requestPermission( activity = mockk(relaxed = true), - permissionType = PermissionType.entries.first { permissionType: PermissionType -> - permissionType.value == "pushNotifications" - } + permissionType = PermissionType.PUSH_NOTIFICATIONS ) assertEquals(PermissionRequestStatus.DENIED, actualResult.result) assertEquals(false, actualResult.dialogShown) + assertEquals(false, actualResult.details.required) + assertEquals(null, actualResult.details.shouldShowRequestPermissionRationale) } - @Test - fun `requestPermission returns granted without dialog when status is limited`() = runTest { - val runtimePermissionLauncher: RuntimePermissionLauncher = FakeRuntimePermissionLauncher(PermissionRequestStatus.DENIED) - val permissionManifestChecker: PermissionManifestChecker = FakePermissionManifestChecker( - declaredPermissions = setOf(android.Manifest.permission.READ_MEDIA_IMAGES) - ) - val permissionManager: PermissionManager = FakePermissionManager( - libraryStatus = PermissionStatus.LIMITED - ) - val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( - context = mockk(relaxed = true), - runtimePermissionLauncher = runtimePermissionLauncher, - manifestPermissionChecker = permissionManifestChecker, - permissionManager = permissionManager, - sdkIntProvider = { Build.VERSION_CODES.UPSIDE_DOWN_CAKE } - ) - val actualResult: PermissionActionResponse = requester.requestPermission( - activity = mockk(relaxed = true), - permissionType = PermissionType.entries.first { permissionType: PermissionType -> - permissionType.value == "photoLibrary" - } - ) - assertEquals(PermissionRequestStatus.GRANTED, actualResult.result) - assertEquals(false, actualResult.dialogShown) - } - - private class FakeRuntimePermissionLauncher( - private val result: PermissionRequestStatus - ) : RuntimePermissionLauncher { - override suspend fun requestPermission(activity: Activity, permissions: Array): PermissionRequestStatus { + private class FakePushPermissionLauncher( + private val result: PushPermissionRequestResult + ) : PushPermissionLauncher { + override suspend fun requestPermission(activity: Activity): PushPermissionRequestResult { return result } } - private class FakePermissionManifestChecker( - private val declaredPermissions: Set - ) : PermissionManifestChecker { - override fun isPermissionDeclared(permission: String): Boolean { - return declaredPermissions.contains(permission) - } - } - private class FakePermissionManager( private val cameraStatus: PermissionStatus = PermissionStatus.DENIED, private val geoStatus: PermissionStatus = PermissionStatus.DENIED, From 357905a505f58446403ddda19826b9c83a5dc3e9 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 20 Mar 2026 14:00:46 +0300 Subject: [PATCH 53/59] MOBILEWEBVIEW-133: Remove activity for repmissions --- sdk/src/main/AndroidManifest.xml | 9 --- .../RuntimePermissionRequestActivity.kt | 76 ------------------- 2 files changed, 85 deletions(-) delete mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestActivity.kt diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml index e1b866fc4..e50bafe2f 100644 --- a/sdk/src/main/AndroidManifest.xml +++ b/sdk/src/main/AndroidManifest.xml @@ -18,14 +18,5 @@ android:noHistory="true" android:theme="@style/Theme.MindboxTransparent"> - - diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestActivity.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestActivity.kt deleted file mode 100644 index f701bffb8..000000000 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestActivity.kt +++ /dev/null @@ -1,76 +0,0 @@ -package cloud.mindbox.mobile_sdk.inapp.presentation.actions - -import android.app.Activity -import android.graphics.Color -import android.os.Build -import android.os.Bundle -import android.view.ViewGroup -import cloud.mindbox.mobile_sdk.logger.mindboxLogW - -internal class RuntimePermissionRequestActivity : Activity() { - - companion object { - private const val REQUEST_CODE: Int = 125130 - internal const val EXTRA_REQUEST_ID: String = "runtime_permission_request_id" - internal const val EXTRA_PERMISSIONS: String = "runtime_permission_permissions" - } - - private var requestId: String? = null - private var isResultSent: Boolean = false - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - window.decorView.setBackgroundColor(Color.TRANSPARENT) - window.decorView.isClickable = false - window.setDimAmount(0f) - val actualRequestId: String = intent?.getStringExtra(EXTRA_REQUEST_ID).orEmpty() - val permissions: Array = intent?.getStringArrayExtra(EXTRA_PERMISSIONS) - ?.map { permission: String -> permission } - ?.toTypedArray() - ?: emptyArray() - if (actualRequestId.isBlank() || permissions.isEmpty()) { - finish() - return - } - requestId = actualRequestId - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - requestPermissions(permissions, REQUEST_CODE) - } else { - RuntimePermissionRequestBridge.resolve(actualRequestId, false) - isResultSent = true - finish() - } - } - - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode != REQUEST_CODE) { - return - } - val isGranted: Boolean = grantResults.isNotEmpty() && grantResults.all { result: Int -> - result == android.content.pm.PackageManager.PERMISSION_GRANTED - } - val actualRequestId: String = requestId.orEmpty() - if (actualRequestId.isNotBlank()) { - RuntimePermissionRequestBridge.resolve(actualRequestId, isGranted) - isResultSent = true - } - finish() - } - - override fun onDestroy() { - if (!isResultSent && isFinishing && !isChangingConfigurations) { - val actualRequestId: String = requestId.orEmpty() - if (actualRequestId.isNotBlank()) { - mindboxLogW("Permission request activity closed before result for id=$actualRequestId") - RuntimePermissionRequestBridge.resolve(actualRequestId, false) - } - } - super.onDestroy() - } -} From 4ffe116d09a62f367a8b1778bc09d47ee32bd25e Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 20 Mar 2026 18:15:28 +0300 Subject: [PATCH 54/59] MOBILEWEBVIEW-133: Remove route to settings --- .../inapp/presentation/actions/PushActivationActivity.kt | 5 ++++- .../inapp/presentation/view/WebViewPermissionRequester.kt | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt index 121fcdd28..f57580262 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt @@ -21,11 +21,13 @@ internal class PushActivationActivity : Activity() { private val resumeTimes = mutableListOf() private var requestId: String? = null private var isResultSent: Boolean = false + private var isNeedToRouteSettings: Boolean = true companion object { private const val PERMISSION_REQUEST_CODE = 125129 private const val TIME_BETWEEN_RESUME = 700 internal const val EXTRA_REQUEST_ID: String = "runtime_permission_request_id" + internal const val EXTRA_ROUTE_TO_SETTINGS: String = "runtime_permission_route_to_settings" } @RequiresApi(Build.VERSION_CODES.M) @@ -81,6 +83,7 @@ internal class PushActivationActivity : Activity() { ViewGroup.LayoutParams.MATCH_PARENT ) requestId = intent?.getStringExtra(EXTRA_REQUEST_ID) + isNeedToRouteSettings = intent?.getBooleanExtra(EXTRA_ROUTE_TO_SETTINGS, true) ?: true mindboxLogI("Call permission laucher") requestPermissions(arrayOf(Constants.POST_NOTIFICATION), PERMISSION_REQUEST_CODE) } @@ -89,7 +92,7 @@ internal class PushActivationActivity : Activity() { resumeTimes.add(SystemClock.elapsedRealtime()) if (shouldCheckDialogShowing) { val duration = resumeTimes.last() - resumeTimes.first() - if (duration < TIME_BETWEEN_RESUME) { + if (duration < TIME_BETWEEN_RESUME && isNeedToRouteSettings) { resumeTimes.clear() mindboxLogI("System dialog not shown because timeout=$duration -> open settings") mindboxNotificationManager.openNotificationSettings(this) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt index 47573b676..82d5504f7 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt @@ -141,6 +141,7 @@ internal class PushPermissionLauncherImpl( activity.startActivity( Intent(activity, PushActivationActivity::class.java).apply { putExtra(PushActivationActivity.EXTRA_REQUEST_ID, requestId) + putExtra(PushActivationActivity.EXTRA_ROUTE_TO_SETTINGS, false) } ) } From bbc58c5d1e8ea7029d13d15aa7750e2735b94241 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 24 Mar 2026 17:27:22 +0300 Subject: [PATCH 55/59] MOBILE-53: Add settings.open action for js bridge --- .../MindboxNotificationManager.kt | 4 ++- .../MindboxNotificationManagerImpl.kt | 24 ++++++++++---- .../inapp/presentation/view/WebViewAction.kt | 3 ++ .../view/WebViewInappViewHolder.kt | 32 +++++++++++++++++++ 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManager.kt index ac1992668..6e9002131 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManager.kt @@ -6,7 +6,9 @@ internal interface MindboxNotificationManager { fun isNotificationEnabled(): Boolean - fun openNotificationSettings(activity: Activity) + fun openNotificationSettings(activity: Activity, channelId: String? = null) + + fun openApplicationSettings(activity: Activity) fun requestPermission(activity: Activity) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManagerImpl.kt index d2d5553fb..e089e139d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManagerImpl.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import android.app.Activity import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build import android.provider.Settings import androidx.core.app.NotificationManagerCompat @@ -11,7 +12,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.managers.RequestPermissionManager import cloud.mindbox.mobile_sdk.utils.Constants -import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching internal class MindboxNotificationManagerImpl( private val context: Context, @@ -29,15 +30,15 @@ internal class MindboxNotificationManagerImpl( } } - override fun openNotificationSettings(activity: Activity) { - LoggingExceptionHandler.runCatching { + override fun openNotificationSettings(activity: Activity, channelId: String?) { + loggingRunCatching { val intent = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, channelId) } } - else -> { Intent(Constants.NOTIFICATION_SETTINGS).apply { putExtra(Constants.APP_PACKAGE_NAME, activity.packageName) @@ -45,16 +46,25 @@ internal class MindboxNotificationManagerImpl( } } } - mindboxLogI("Opening notification settings.") + mindboxLogI("Opening notification settings") + activity.startActivity(intent) + } + } + + override fun openApplicationSettings(activity: Activity) { + loggingRunCatching { + val packageUri: Uri = Uri.fromParts("package", activity.packageName, null) + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, packageUri) + mindboxLogI("Opening application settings") activity.startActivity(intent) } } override fun requestPermission(activity: Activity) { - LoggingExceptionHandler.runCatching { + loggingRunCatching { if (NotificationManagerCompat.from(context).areNotificationsEnabled()) { mindboxLogI("Notification is enabled now, don't try request permission") - return@runCatching + return@loggingRunCatching } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index b7471402a..f85515780 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -60,6 +60,9 @@ public enum class WebViewAction { @SerializedName(value = "permission.request") PERMISSION_REQUEST, + + @SerializedName(value = "settings.open") + SETTINGS_OPEN, } @InternalMindboxApi diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index cd9a354e9..4e5ebd5b3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -23,6 +23,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback +import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import cloud.mindbox.mobile_sdk.inapp.webview.* import cloud.mindbox.mobile_sdk.logger.mindboxLogD @@ -36,6 +37,7 @@ import cloud.mindbox.mobile_sdk.models.getShortUserAgent import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch import com.google.gson.Gson +import com.google.gson.annotations.SerializedName import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.cancel @@ -77,6 +79,7 @@ internal class WebViewInAppViewHolder( private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } private val sessionStorageManager: SessionStorageManager by mindboxInject { sessionStorageManager } private val permissionManager: PermissionManager by mindboxInject { permissionManager } + private val mindboxNotificationManager: MindboxNotificationManager by mindboxInject { mindboxNotificationManager } private val appContext: Application by mindboxInject { appContext } private val operationExecutor: WebViewOperationExecutor by lazy { MindboxWebViewOperationExecutor() @@ -149,6 +152,7 @@ internal class WebViewInAppViewHolder( registerSuspend(WebViewAction.LOCAL_STATE_SET, ::handleLocalStateSetAction) registerSuspend(WebViewAction.LOCAL_STATE_INIT, ::handleLocalStateInitAction) registerSuspend(WebViewAction.PERMISSION_REQUEST, ::handlePermissionAction) + register(WebViewAction.SETTINGS_OPEN, ::handleSettingsOpenAction) register(WebViewAction.READY) { handleReadyAction( configuration = configuration, @@ -305,6 +309,22 @@ internal class WebViewInAppViewHolder( return gson.toJson(permissionRequestResult) } + private fun handleSettingsOpenAction(message: BridgeMessage.Request): String { + val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD + val settingsOpenRequest: SettingsOpenRequest? = gson.fromJson(payload).getOrNull() + requireNotNull(settingsOpenRequest) + + val targetType = settingsOpenRequest.target.enumValue() + val activity: Activity? = webViewController?.view?.context?.safeAs() + checkNotNull(activity) { "Not found activity for open settings" } + + when (targetType) { + SettingsOpenTargetType.NOTIFICATIONS -> mindboxNotificationManager.openNotificationSettings(activity, settingsOpenRequest.channelId) + SettingsOpenTargetType.APPLICATION -> mindboxNotificationManager.openApplicationSettings(activity) + } + return BridgeMessage.SUCCESS_PAYLOAD + } + private fun createWebViewController(layer: Layer.WebViewLayer): WebViewController { mindboxLogI("Creating WebView for In-App: ${wrapper.inAppType.inAppId} with layer ${layer.type}") val controller: WebViewController = WebViewController.create(currentDialog.context, BuildConfig.DEBUG) @@ -687,4 +707,16 @@ internal class WebViewInAppViewHolder( private data class ErrorPayload( val error: String ) + + private data class SettingsOpenRequest( + @SerializedName("target") + val target: String, + @SerializedName("channelId") + val channelId: String? + ) + + private enum class SettingsOpenTargetType { + NOTIFICATIONS, + APPLICATION + } } From 700808d68013742207efef3c7553d9787d62db1d Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:00:06 +0300 Subject: [PATCH 56/59] MOBILEWEBVIEW-126: change back action handler --- .../InAppMessageViewDisplayerImpl.kt | 24 ++--- .../view/AbstractInAppViewHolder.kt | 59 +++++++++--- .../presentation/view/BackButtonHandler.kt | 20 ++++ .../presentation/view/BackPressRegistrar.kt | 47 +++++++++ .../view/InAppConstraintLayout.kt | 26 ++++- .../view/ModalWindowInAppViewHolder.kt | 32 ++----- .../view/WebViewInappViewHolder.kt | 60 ++++++------ .../view/BackButtonHandlerTest.kt | 95 +++++++++++++++++++ 8 files changed, 277 insertions(+), 86 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandler.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackPressRegistrar.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandlerTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 289198799..29bcc769a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -2,8 +2,6 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import android.app.Activity import android.view.ViewGroup -import androidx.activity.OnBackPressedCallback -import androidx.activity.OnBackPressedDispatcherOwner import cloud.mindbox.mobile_sdk.addUnique import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking @@ -14,6 +12,8 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTra import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.* +import cloud.mindbox.mobile_sdk.inapp.presentation.view.ActivityBackPressRegistrar +import cloud.mindbox.mobile_sdk.inapp.presentation.view.BackPressRegistrar import cloud.mindbox.mobile_sdk.inapp.presentation.view.InAppViewHolder import cloud.mindbox.mobile_sdk.inapp.presentation.view.ModalWindowInAppViewHolder import cloud.mindbox.mobile_sdk.inapp.presentation.view.SnackbarInAppViewHolder @@ -31,9 +31,9 @@ internal interface MindboxView { val container: ViewGroup - fun requestPermission() + val backPressRegistrar: BackPressRegistrar - fun registerBack(onBack: OnBackPressedCallback) + fun requestPermission() } internal class InAppMessageViewDisplayerImpl( @@ -237,22 +237,16 @@ internal class InAppMessageViewDisplayerImpl( return true } - private fun createMindboxView(root: ViewGroup): MindboxView { - return object : MindboxView { + private fun createMindboxView(root: ViewGroup): MindboxView = + object : MindboxView { override val container: ViewGroup = root + override val backPressRegistrar: BackPressRegistrar = + ActivityBackPressRegistrar(activityProvider = { currentActivity }) override fun requestPermission() { - currentActivity?.let { activity -> - mindboxNotificationManager.requestPermission(activity = activity) - } - } - - override fun registerBack(onBack: OnBackPressedCallback) { - val backOwner = currentActivity as? OnBackPressedDispatcherOwner - backOwner?.onBackPressedDispatcher?.addCallback(onBack) + currentActivity?.let { mindboxNotificationManager.requestPermission(activity = it) } } } - } override fun dismissCurrentInApp() { loggingRunCatching { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt index b43233e9f..b3a94c480 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt @@ -8,6 +8,8 @@ import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.ImageView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import cloud.mindbox.mobile_sdk.R import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure @@ -51,25 +53,35 @@ internal abstract class AbstractInAppViewHolder( } private var typingView: View? = null + private var shouldRestoreKeyboard: Boolean = false protected val preparedImages: MutableMap = mutableMapOf() internal val inAppFailureTracker: InAppFailureTracker by mindboxInject { inAppFailureTracker } private var inAppActionHandler = InAppActionHandler() + private var backRegistration: BackRegistration? = null - private fun hideKeyboard(currentRoot: ViewGroup) { - val context = currentRoot.context - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? - if (imm?.isAcceptingText == true) { - typingView = currentRoot.findFocus() - imm.hideSoftInputFromWindow( + private fun isKeyboardVisible(root: View): Boolean = + ViewCompat.getRootWindowInsets(root)?.isVisible(WindowInsetsCompat.Type.ime()) == true + + protected fun hideKeyboard(currentRoot: ViewGroup) { + typingView = currentRoot.rootView.findFocus() + if (isKeyboardVisible(currentRoot)) { + shouldRestoreKeyboard = true + val context = currentRoot.context + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.hideSoftInputFromWindow( currentRoot.windowToken, 0 ) } } + protected open fun onBeforeShow(currentRoot: MindboxView) { + hideKeyboard(currentRoot.container) + } + abstract fun bind() protected open fun addUrlSource(layer: Layer.ImageLayer, inAppCallback: InAppCallback) { @@ -178,6 +190,16 @@ internal abstract class AbstractInAppViewHolder( inAppLayout.prepareLayoutForInApp(wrapper.inAppType) } + protected fun bindBackAction(currentRoot: MindboxView, onBackPress: () -> Unit) { + clearBackRegistration() + backRegistration = currentRoot.backPressRegistrar.register(inAppLayout, onBackPress) + } + + protected fun clearBackRegistration() { + backRegistration?.unregister() + backRegistration = null + } + private fun attachToRoot(currentRoot: ViewGroup) { if (_currentDialog == null) { initView(currentRoot) @@ -197,15 +219,21 @@ internal abstract class AbstractInAppViewHolder( } } - private fun restoreKeyboard() { - typingView?.let { view -> + protected fun restoreKeyboard() { + val view: View = typingView ?: return + val shouldShowKeyboard: Boolean = shouldRestoreKeyboard + typingView = null + shouldRestoreKeyboard = false + view.post { view.requestFocus() - val imm = - (view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?) - imm?.showSoftInput( - view, - InputMethodManager.SHOW_IMPLICIT - ) + if (shouldShowKeyboard) { + val imm = + (view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?) + imm?.showSoftInput( + view, + InputMethodManager.SHOW_IMPLICIT + ) + } } } @@ -213,7 +241,7 @@ internal abstract class AbstractInAppViewHolder( isInAppMessageActive = true attachToRoot(currentRoot.container) startPositionController(currentRoot.container) - hideKeyboard(currentRoot.container) + onBeforeShow(currentRoot) inAppActionHandler.mindboxView = currentRoot } @@ -226,6 +254,7 @@ internal abstract class AbstractInAppViewHolder( } override fun onClose() { + clearBackRegistration() positionController?.stop() positionController = null currentDialog.parent.safeAs()?.removeView(_currentDialog) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandler.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandler.kt new file mode 100644 index 000000000..7ac07e555 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandler.kt @@ -0,0 +1,20 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.view.KeyEvent +import cloud.mindbox.mobile_sdk.logger.mindboxLogI + +internal class BackButtonHandler( + private val listener: () -> Unit, +) { + /** + * Returns true if the event was consumed, null if it was not a back key event. + */ + fun dispatchKeyEvent(event: KeyEvent?): Boolean? { + if (event?.keyCode != KeyEvent.KEYCODE_BACK || event.action != KeyEvent.ACTION_UP || event.isCanceled) { + return null + } + mindboxLogI("BackButtonHandler: KEYCODE_BACK ACTION_UP") + listener() + return true + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackPressRegistrar.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackPressRegistrar.kt new file mode 100644 index 000000000..6646f94c7 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackPressRegistrar.kt @@ -0,0 +1,47 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.app.Activity +import android.os.Build +import android.window.OnBackInvokedCallback +import android.window.OnBackInvokedDispatcher +import cloud.mindbox.mobile_sdk.logger.mindboxLogI + +internal fun interface BackRegistration { + fun unregister() +} + +internal interface BackPressRegistrar { + fun register(layout: BackButtonLayout, onBackPress: () -> Unit): BackRegistration +} + +internal class ActivityBackPressRegistrar( + private val activityProvider: () -> Activity?, +) : BackPressRegistrar { + + override fun register(layout: BackButtonLayout, onBackPress: () -> Unit): BackRegistration { + layout.setBackListener(onBackPress) + val systemBackRegistration: BackRegistration = registerSystemBackCallback(onBackPress) + return BackRegistration { + layout.setBackListener(null) + systemBackRegistration.unregister() + } + } + + private fun registerSystemBackCallback(onBackPress: () -> Unit): BackRegistration { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return BackRegistration {} + } + val activity: Activity = activityProvider() ?: return BackRegistration {} + val callback = OnBackInvokedCallback { + mindboxLogI("OnBackInvokedCallback fired") + onBackPress() + } + activity.onBackInvokedDispatcher.registerOnBackInvokedCallback( + OnBackInvokedDispatcher.PRIORITY_DEFAULT, + callback + ) + return BackRegistration { + activity.onBackInvokedDispatcher.unregisterOnBackInvokedCallback(callback) + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt index 8313f3a3f..aa4367465 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt @@ -15,13 +15,33 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.px import kotlin.math.abs -internal class InAppConstraintLayout : ConstraintLayout { +internal class InAppConstraintLayout : ConstraintLayout, BackButtonLayout { fun setSwipeToDismissCallback(callback: () -> Unit) { swipeToDismissCallback = callback } + override fun setBackListener(listener: (() -> Unit)?) { + backButtonHandler = listener?.let { BackButtonHandler(it) } + } + private var swipeToDismissCallback: (() -> Unit)? = null + private var backButtonHandler: BackButtonHandler? = null + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = + if (keyCode == KeyEvent.KEYCODE_BACK && backButtonHandler != null) { + true + } else { + super.onKeyDown(keyCode, event) + } + + override fun dispatchKeyEvent(event: KeyEvent?): Boolean = + if (backButtonHandler?.dispatchKeyEvent(event) == true) { + true + } else { + super.dispatchKeyEvent(event) + } + internal var webViewInsets: InAppInsets = InAppInsets() constructor(context: Context) : super(context) @@ -268,3 +288,7 @@ internal data class InAppInsets( const val BOTTOM = "bottom" } } + +internal fun interface BackButtonLayout { + fun setBackListener(listener: (() -> Unit)?) +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt index 42a3101bf..baebe0328 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt @@ -4,7 +4,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import androidx.activity.OnBackPressedCallback import androidx.core.view.isVisible import cloud.mindbox.mobile_sdk.R import cloud.mindbox.mobile_sdk.inapp.domain.models.Element @@ -23,25 +22,6 @@ internal class ModalWindowInAppViewHolder( ) : AbstractInAppViewHolder(wrapper, controller, inAppCallback) { private var currentBackground: ViewGroup? = null - private var backPressedCallback: OnBackPressedCallback? = null - - private fun registerBackPressedCallback(): OnBackPressedCallback { - clearBackPressedCallback() - val callback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) - mindboxLogI("In-app dismissed by back press") - inAppController.close() - } - } - backPressedCallback = callback - return callback - } - - private fun clearBackPressedCallback() { - backPressedCallback?.remove() - backPressedCallback = null - } override fun bind() { wrapper.inAppType.elements.forEach { element -> @@ -101,12 +81,12 @@ internal class ModalWindowInAppViewHolder( } mindboxLogI("Show ${wrapper.inAppType.inAppId} on ${this.hashCode()}") currentDialog.requestFocus() - currentRoot.registerBack(registerBackPressedCallback()) - } - - override fun onClose() { - clearBackPressedCallback() - super.onClose() + val backAction = { + inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) + mindboxLogI("In-app dismissed by back press") + inAppController.close() + } + bindBackAction(currentRoot, backAction) } override fun initView(currentRoot: ViewGroup) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 4e5ebd5b3..1341ab5bb 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -6,7 +6,6 @@ import android.net.Uri import android.view.ViewGroup import android.widget.RelativeLayout import android.widget.Toast -import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import cloud.mindbox.mobile_sdk.* @@ -68,8 +67,12 @@ internal class WebViewInAppViewHolder( private var closeInappTimer: Timer? = null private var webViewController: WebViewController? = null - private var backPressedCallback: OnBackPressedCallback? = null private var currentWebViewOrigin: String? = null + + private fun bindWebViewBackAction(currentRoot: MindboxView, controller: WebViewController) { + bindBackAction(currentRoot) { sendBackAction(controller) } + } + private val pendingResponsesById: MutableMap> = ConcurrentHashMap() @@ -100,6 +103,9 @@ internal class WebViewInAppViewHolder( permissionManager = permissionManager ) } + private var currentMindboxView: MindboxView? = null + + override fun onBeforeShow(currentRoot: MindboxView) = Unit override fun bind() {} @@ -196,11 +202,28 @@ internal class WebViewInAppViewHolder( ).get() } + private fun activateFirstShowPresentation( + mindboxView: MindboxView, + controller: WebViewController, + ) { + hideKeyboard(inAppLayout) + inAppLayout.requestFocus() + bindWebViewBackAction(mindboxView, controller) + controller.setVisibility(true) + } + private fun handleInitAction(controller: WebViewController): String { stopTimer() wrapper.inAppActionCallbacks.onInAppShown.onShown() - controller.setVisibility(true) - backPressedCallback?.isEnabled = true + val mindboxView = currentMindboxView ?: run { + mindboxLogW("MindboxView is null when activating WebView In-App") + inAppController.close() + return BridgeMessage.UNKNOWN_ERROR_PAYLOAD + } + activateFirstShowPresentation( + mindboxView = mindboxView, + controller = controller, + ) return BridgeMessage.EMPTY_PAYLOAD } @@ -413,11 +436,6 @@ internal class WebViewInAppViewHolder( return "$scheme://$host$normalizedPort" } - private fun clearBackPressedCallback() { - backPressedCallback?.remove() - backPressedCallback = null - } - private fun sendBackAction(controller: WebViewController) { val message: BridgeMessage.Request = BridgeMessage.createAction( WebViewAction.BACK, @@ -637,6 +655,7 @@ internal class WebViewInAppViewHolder( } override fun show(currentRoot: MindboxView) { + currentMindboxView = currentRoot super.show(currentRoot) mindboxLogI("Try to show in-app with id ${wrapper.inAppType.inAppId}") wrapper.inAppType.layers.forEach { layer -> @@ -646,25 +665,10 @@ internal class WebViewInAppViewHolder( } } mindboxLogI("Show In-App ${wrapper.inAppType.inAppId} in holder ${this.hashCode()}") - inAppLayout.requestFocus() - webViewController?.let { controller -> - currentRoot.registerBack(registerBackPressedCallback(controller)) - } - } - - private fun registerBackPressedCallback(controller: WebViewController): OnBackPressedCallback { - val isBackCallbackEnabled = backPressedCallback?.isEnabled ?: false - clearBackPressedCallback() - val callback = object : OnBackPressedCallback(isBackCallbackEnabled) { - override fun handleOnBackPressed() { - sendBackAction(controller) - } - } - backPressedCallback = callback - return callback } override fun reattach(currentRoot: MindboxView) { + currentMindboxView = currentRoot super.reattach(currentRoot) wrapper.inAppType.layers.forEach { layer -> when (layer) { @@ -673,9 +677,7 @@ internal class WebViewInAppViewHolder( } } inAppLayout.requestFocus() - webViewController?.let { controller -> - currentRoot.registerBack(registerBackPressedCallback(controller)) - } + webViewController?.let { controller -> bindWebViewBackAction(currentRoot, controller) } } override fun canReuseOnRestore(inAppId: String): Boolean = wrapper.inAppType.inAppId == inAppId @@ -688,7 +690,6 @@ internal class WebViewInAppViewHolder( hapticFeedbackExecutor.cancel() stopTimer() cancelPendingResponses("WebView In-App is closed") - clearBackPressedCallback() webViewController?.let { controller -> val view: WebViewPlatformView = controller.view view.parent.safeAs()?.removeView(view) @@ -697,6 +698,7 @@ internal class WebViewInAppViewHolder( currentWebViewOrigin = null webViewController?.destroy() webViewController = null + currentMindboxView = null super.onClose() } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandlerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandlerTest.kt new file mode 100644 index 000000000..8217688ef --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandlerTest.kt @@ -0,0 +1,95 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.view.KeyEvent +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BackButtonHandlerTest { + + @Test + fun `dispatchKeyEvent returns true and invokes listener for non canceled back action up event`() { + var hasInvokedListener = false + val backButtonHandler = BackButtonHandler { + hasInvokedListener = true + } + val event: KeyEvent = createKeyEvent( + eventAction = KeyEvent.ACTION_UP, + eventKeyCode = KeyEvent.KEYCODE_BACK, + isEventCanceled = false, + ) + val actualResult: Boolean? = backButtonHandler.dispatchKeyEvent(event) + assertTrue(actualResult == true) + assertTrue(hasInvokedListener) + } + + @Test + fun `dispatchKeyEvent returns null and does not invoke listener for back action down event`() { + var hasInvokedListener = false + val backButtonHandler = BackButtonHandler { + hasInvokedListener = true + } + val event: KeyEvent = createKeyEvent( + eventAction = KeyEvent.ACTION_DOWN, + eventKeyCode = KeyEvent.KEYCODE_BACK, + isEventCanceled = false, + ) + val actualResult: Boolean? = backButtonHandler.dispatchKeyEvent(event) + assertNull(actualResult) + assertFalse(hasInvokedListener) + } + + @Test + fun `dispatchKeyEvent returns null and does not invoke listener for canceled back action up event`() { + var hasInvokedListener = false + val backButtonHandler = BackButtonHandler { + hasInvokedListener = true + } + val event: KeyEvent = createKeyEvent( + eventAction = KeyEvent.ACTION_UP, + eventKeyCode = KeyEvent.KEYCODE_BACK, + isEventCanceled = true, + ) + val actualResult: Boolean? = backButtonHandler.dispatchKeyEvent(event) + assertNull(actualResult) + assertFalse(hasInvokedListener) + } + + @Test + fun `dispatchKeyEvent returns null and does not invoke listener for non back action up event`() { + var hasInvokedListener = false + val backButtonHandler = BackButtonHandler { + hasInvokedListener = true + } + val event: KeyEvent = createKeyEvent( + eventAction = KeyEvent.ACTION_UP, + eventKeyCode = KeyEvent.KEYCODE_ENTER, + isEventCanceled = false, + ) + val actualResult: Boolean? = backButtonHandler.dispatchKeyEvent(event) + assertNull(actualResult) + assertFalse(hasInvokedListener) + } + + @Test + fun `dispatchKeyEvent returns null and does not invoke listener for null event`() { + var hasInvokedListener = false + val backButtonHandler = BackButtonHandler { + hasInvokedListener = true + } + val actualResult: Boolean? = backButtonHandler.dispatchKeyEvent(event = null) + assertNull(actualResult) + assertFalse(hasInvokedListener) + } + + private fun createKeyEvent(eventAction: Int, eventKeyCode: Int, isEventCanceled: Boolean): KeyEvent { + return mockk { + every { action } returns eventAction + every { keyCode } returns eventKeyCode + every { isCanceled } returns isEventCanceled + } + } +} From d2e4bcddfef125d66494c912468c4e8082e64a0d Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 25 Mar 2026 17:40:00 +0300 Subject: [PATCH 57/59] MOBILEWEBVIEW-133: Fix dialogShown --- .../actions/PushActivationActivity.kt | 15 ++++---- .../actions/RuntimePermissionRequestBridge.kt | 17 ++++++--- .../view/WebViewPermissionRequester.kt | 24 +++++------- .../view/WebViewPermissionRequesterTest.kt | 37 +++++++++++++++++-- 4 files changed, 63 insertions(+), 30 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt index f57580262..f4239796f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt @@ -56,7 +56,7 @@ internal class PushActivationActivity : Activity() { if (requestPermissionManager.getRequestCount() > 1) { mindboxLogI("User already rejected permission two times, try open settings") mindboxNotificationManager.openNotificationSettings(this) - finishWithResult(isGranted = false) + finishWithResult(isGranted = false, dialogShown = false) } else { mindboxLogI("Awaiting show dialog") shouldCheckDialogShowing = true @@ -84,7 +84,7 @@ internal class PushActivationActivity : Activity() { ) requestId = intent?.getStringExtra(EXTRA_REQUEST_ID) isNeedToRouteSettings = intent?.getBooleanExtra(EXTRA_ROUTE_TO_SETTINGS, true) ?: true - mindboxLogI("Call permission laucher") + mindboxLogI("Call permission launcher") requestPermissions(arrayOf(Constants.POST_NOTIFICATION), PERMISSION_REQUEST_CODE) } @@ -92,7 +92,8 @@ internal class PushActivationActivity : Activity() { resumeTimes.add(SystemClock.elapsedRealtime()) if (shouldCheckDialogShowing) { val duration = resumeTimes.last() - resumeTimes.first() - if (duration < TIME_BETWEEN_RESUME && isNeedToRouteSettings) { + val dialogShown = duration >= TIME_BETWEEN_RESUME + if (!dialogShown && isNeedToRouteSettings) { resumeTimes.clear() mindboxLogI("System dialog not shown because timeout=$duration -> open settings") mindboxNotificationManager.openNotificationSettings(this) @@ -101,7 +102,7 @@ internal class PushActivationActivity : Activity() { requestPermissionManager.decreaseRequestCounter() } shouldCheckDialogShowing = false - finishWithResult(isGranted = false) + finishWithResult(isGranted = false, dialogShown = dialogShown) } super.onResume() } @@ -116,13 +117,13 @@ internal class PushActivationActivity : Activity() { override fun onDestroy() { if (!isResultSent && isFinishing && !isChangingConfigurations) { - RuntimePermissionRequestBridge.resolve(requestId.orEmpty(), false) + finishWithResult(false) } super.onDestroy() } - private fun finishWithResult(isGranted: Boolean) { - RuntimePermissionRequestBridge.resolve(requestId.orEmpty(), isGranted) + private fun finishWithResult(isGranted: Boolean, dialogShown: Boolean = true) { + RuntimePermissionRequestBridge.resolve(requestId.orEmpty(), isGranted, dialogShown) isResultSent = true finish() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt index 68020156b..1a85caeb5 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt @@ -5,18 +5,23 @@ import java.util.concurrent.ConcurrentHashMap internal object RuntimePermissionRequestBridge { - private val pendingRequestsById: MutableMap> = ConcurrentHashMap() + private val pendingRequestsById: MutableMap> = ConcurrentHashMap() - fun register(requestId: String): CompletableDeferred { - val deferred: CompletableDeferred = CompletableDeferred() + fun register(requestId: String): CompletableDeferred { + val deferred: CompletableDeferred = CompletableDeferred() pendingRequestsById[requestId] = deferred return deferred } - fun resolve(requestId: String, isGranted: Boolean) { - val deferred: CompletableDeferred = pendingRequestsById.remove(requestId) ?: return + fun resolve(requestId: String, isGranted: Boolean, isDialogShown: Boolean) { + val deferred: CompletableDeferred = pendingRequestsById.remove(requestId) ?: return if (!deferred.isCompleted) { - deferred.complete(isGranted) + deferred.complete(PermissionRequest(isGranted, isDialogShown)) } } + + data class PermissionRequest( + val isGranted: Boolean, + val dialogShown: Boolean, + ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt index 82d5504f7..9a5f49d06 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt @@ -79,7 +79,7 @@ internal class WebViewPermissionRequesterImpl( val permissionResult: PushPermissionRequestResult = pushPermissionLauncher.requestPermission(activity = activity) return createPermissionActionResponse( result = permissionResult.status, - dialogShown = true, + dialogShown = permissionResult.dialogShown, shouldShowRequestPermissionRationale = permissionResult.shouldShowRequestPermissionRationale ) } @@ -120,6 +120,7 @@ internal interface PushPermissionLauncher { internal data class PushPermissionRequestResult( val status: PermissionRequestStatus, val shouldShowRequestPermissionRationale: Boolean, + val dialogShown: Boolean, ) @SuppressLint("InlinedApi") @@ -132,7 +133,8 @@ internal class PushPermissionLauncherImpl( if (sdkIntProvider() < Build.VERSION_CODES.TIRAMISU) { return PushPermissionRequestResult( status = PermissionRequestStatus.DENIED, - shouldShowRequestPermissionRationale = false + shouldShowRequestPermissionRationale = false, + dialogShown = false ) } val requestId: String = Mindbox.generateRandomUuid() @@ -145,20 +147,14 @@ internal class PushPermissionLauncherImpl( } ) } - val isGranted: Boolean = deferredResult.await() + val (isGranted, dialogShown) = deferredResult.await() val shouldShowRationale: Boolean = withContext(Dispatchers.Main.immediate) { activity.shouldShowRequestPermissionRationale(notificationPermission) } - return if (isGranted) { - PushPermissionRequestResult( - status = PermissionRequestStatus.GRANTED, - shouldShowRequestPermissionRationale = shouldShowRationale - ) - } else { - PushPermissionRequestResult( - status = PermissionRequestStatus.DENIED, - shouldShowRequestPermissionRationale = shouldShowRationale - ) - } + return PushPermissionRequestResult( + status = if (isGranted) PermissionRequestStatus.GRANTED else PermissionRequestStatus.DENIED, + shouldShowRequestPermissionRationale = shouldShowRationale, + dialogShown = dialogShown + ) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt index eaa993af1..31b2d66b8 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt @@ -16,7 +16,8 @@ class WebViewPermissionRequesterTest { val pushPermissionLauncher: PushPermissionLauncher = FakePushPermissionLauncher( PushPermissionRequestResult( status = PermissionRequestStatus.DENIED, - shouldShowRequestPermissionRationale = false + shouldShowRequestPermissionRationale = false, + dialogShown = false ) ) val permissionManager: PermissionManager = FakePermissionManager( @@ -38,12 +39,41 @@ class WebViewPermissionRequesterTest { assertEquals(null, actualResult.details.shouldShowRequestPermissionRationale) } + @Test + fun `requestPermission returns granted when push permission request is granted without dialog`() = runTest { + val pushPermissionLauncher: PushPermissionLauncher = FakePushPermissionLauncher( + PushPermissionRequestResult( + status = PermissionRequestStatus.GRANTED, + shouldShowRequestPermissionRationale = false, + dialogShown = false + ) + ) + val permissionManager: PermissionManager = FakePermissionManager( + pushStatus = PermissionStatus.DENIED + ) + val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( + context = mockk(relaxed = true), + pushPermissionLauncher = pushPermissionLauncher, + permissionManager = permissionManager, + sdkIntProvider = { Build.VERSION_CODES.TIRAMISU } + ) + val actualResult: PermissionActionResponse = requester.requestPermission( + activity = mockk(relaxed = true), + permissionType = PermissionType.PUSH_NOTIFICATIONS + ) + assertEquals(PermissionRequestStatus.GRANTED, actualResult.result) + assertEquals(false, actualResult.dialogShown) + assertEquals(true, actualResult.details.required) + assertEquals(false, actualResult.details.shouldShowRequestPermissionRationale) + } + @Test fun `requestPermission returns denied when push permission request is denied`() = runTest { val pushPermissionLauncher: PushPermissionLauncher = FakePushPermissionLauncher( PushPermissionRequestResult( status = PermissionRequestStatus.DENIED, - shouldShowRequestPermissionRationale = true + shouldShowRequestPermissionRationale = true, + dialogShown = true ) ) val permissionManager: PermissionManager = FakePermissionManager( @@ -70,7 +100,8 @@ class WebViewPermissionRequesterTest { val pushPermissionLauncher: PushPermissionLauncher = FakePushPermissionLauncher( PushPermissionRequestResult( status = PermissionRequestStatus.GRANTED, - shouldShowRequestPermissionRationale = false + shouldShowRequestPermissionRationale = false, + dialogShown = false ) ) val permissionManager: PermissionManager = FakePermissionManager( From 2ca8821f7b3e5212e8b877d6c280da42a979f121 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 27 Mar 2026 12:02:34 +0300 Subject: [PATCH 58/59] MOBILEWEBVIEW-133: Fix open settings after request permission --- .../inapp/data/repositories/InAppRepositoryImpl.kt | 2 +- .../inapp/presentation/actions/PushActivationActivity.kt | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt index 91cc66bf1..1e0ad72a3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt @@ -134,7 +134,7 @@ internal class InAppRepositoryImpl( override fun sendInAppShowFailure(failures: List) { failures .takeIf { it.isNotEmpty() } - ?.let { failures -> + ?.let { inAppSerializationManager.serializeToInAppShowFailuresString(failures) .takeIf { it.isNotBlank() } ?.let { operationBody -> diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt index f4239796f..d78d6e113 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt @@ -54,8 +54,10 @@ internal class PushActivationActivity : Activity() { permissionDenied && !shouldShowRationale -> { if (mindboxNotificationManager.shouldOpenSettings) { if (requestPermissionManager.getRequestCount() > 1) { - mindboxLogI("User already rejected permission two times, try open settings") - mindboxNotificationManager.openNotificationSettings(this) + if (isNeedToRouteSettings) { + mindboxLogI("User already rejected permission two times, try open settings") + mindboxNotificationManager.openNotificationSettings(this) + } finishWithResult(isGranted = false, dialogShown = false) } else { mindboxLogI("Awaiting show dialog") From 71611120119f40318e9f349073f6099fc3324aee Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:47:58 +0300 Subject: [PATCH 59/59] MOBILE-39: support shake and flip --- .../inapp/presentation/view/WebViewAction.kt | 9 + .../view/WebViewInappViewHolder.kt | 86 ++++++ .../presentation/view/motion/MotionService.kt | 271 ++++++++++++++++++ .../mindbox/mobile_sdk/models/Timestamp.kt | 4 + .../view/MotionServiceBehaviorTest.kt | 219 ++++++++++++++ .../view/MotionServiceResolvePositionTest.kt | 249 ++++++++++++++++ 6 files changed, 838 insertions(+) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/motion/MotionService.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceResolvePositionTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index f85515780..ce5397664 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -63,6 +63,15 @@ public enum class WebViewAction { @SerializedName(value = "settings.open") SETTINGS_OPEN, + + @SerializedName("motion.start") + MOTION_START, + + @SerializedName("motion.stop") + MOTION_STOP, + + @SerializedName("motion.event") + MOTION_EVENT, } @InternalMindboxApi diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 1341ab5bb..41d8ba2ea 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -24,6 +24,12 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView +import androidx.lifecycle.ProcessLifecycleOwner +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionGesture +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionService +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionServiceProtocol +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionStartResult import cloud.mindbox.mobile_sdk.inapp.webview.* import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE @@ -35,6 +41,7 @@ import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.getShortUserAgent import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching import com.google.gson.Gson import com.google.gson.annotations.SerializedName import kotlinx.coroutines.CancellationException @@ -63,12 +70,16 @@ internal class WebViewInAppViewHolder( private const val JS_BRIDGE = "$JS_BRIDGE_CLASS.emit" private const val JS_CALL_BRIDGE = "(()=>{try{$JS_BRIDGE(%s);return!0}catch(_){return!1}})()" private const val JS_CHECK_BRIDGE = "(() => typeof $JS_BRIDGE_CLASS !== 'undefined' && typeof $JS_BRIDGE === 'function')()" + private const val MOTION_GESTURE_KEY = "gesture" + private const val MOTION_GESTURES_KEY = "gestures" } private var closeInappTimer: Timer? = null private var webViewController: WebViewController? = null private var currentWebViewOrigin: String? = null + private var motionService: MotionServiceProtocol? = null + private fun bindWebViewBackAction(currentRoot: MindboxView, controller: WebViewController) { bindBackAction(currentRoot) { sendBackAction(controller) } } @@ -77,6 +88,7 @@ internal class WebViewInAppViewHolder( ConcurrentHashMap() private val gson: Gson by mindboxInject { this.gson } + private val timeProvider: TimeProvider by mindboxInject { timeProvider } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } @@ -174,6 +186,8 @@ internal class WebViewInAppViewHolder( handleHideAction(controller) } register(WebViewAction.HAPTIC, ::handleHapticAction) + register(WebViewAction.MOTION_START, ::handleMotionStartAction) + register(WebViewAction.MOTION_STOP) { handleMotionStopAction() } } } @@ -184,6 +198,57 @@ internal class WebViewInAppViewHolder( return BridgeMessage.EMPTY_PAYLOAD } + private fun handleMotionStartAction(message: BridgeMessage.Request): String { + val payload = requireNotNull(message.payload) { "Missing payload" } + val gestures = parseMotionGestures(payload) + require(gestures.isNotEmpty()) { "No valid gestures provided. Available: shake, flip" } + val result = getOrCreateMotionService().startMonitoring(gestures) + require(!result.allUnavailable) { + "No sensors available for: ${result.unavailable.joinToString { it.value }}" + } + return buildMotionStartPayload(result) + } + + private fun buildMotionStartPayload(result: MotionStartResult): String { + if (result.unavailable.isEmpty()) return BridgeMessage.SUCCESS_PAYLOAD + return gson.toJson( + MotionStartPayload(unavailable = result.unavailable.map { it.value }) + ) + } + + private fun handleMotionStopAction(): String { + motionService?.stopMonitoring() + return BridgeMessage.SUCCESS_PAYLOAD + } + + private fun sendMotionEvent(gesture: MotionGesture, data: Map) { + val controller: WebViewController = webViewController ?: return + val payload = JSONObject() + .apply { + put(MOTION_GESTURE_KEY, gesture.value) + data.forEach { (key, value) -> put(key, value) } + } + .toString() + val message: BridgeMessage.Request = BridgeMessage.createAction( + action = WebViewAction.MOTION_EVENT, + payload = payload, + ) + sendActionInternal(controller, message) { error -> + mindboxLogW("[WebView] Motion: failed to send motion.event to JS: $error") + motionService?.stopMonitoring() + } + } + + private fun parseMotionGestures(payload: String): Set { + return loggingRunCatching(defaultValue = emptySet()) { + val array = JSONObject(payload).optJSONArray(MOTION_GESTURES_KEY) + ?: return@loggingRunCatching emptySet() + (0 until array.length()) + .mapNotNull { i -> array.optString(i).enumValue() } + .toSet() + } + } + private fun handleReadyAction( configuration: Configuration, insets: InAppInsets, @@ -251,6 +316,7 @@ internal class WebViewInAppViewHolder( } private fun handleCloseAction(message: BridgeMessage): String { + motionService?.stopMonitoring() inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") inAppController.close() @@ -688,6 +754,7 @@ internal class WebViewInAppViewHolder( override fun onClose() { hapticFeedbackExecutor.cancel() + motionService?.stopMonitoring() stopTimer() cancelPendingResponses("WebView In-App is closed") webViewController?.let { controller -> @@ -702,6 +769,18 @@ internal class WebViewInAppViewHolder( super.onClose() } + private fun getOrCreateMotionService(): MotionServiceProtocol = + motionService ?: MotionService( + context = appContext, + lifecycle = ProcessLifecycleOwner.get().lifecycle, + timeProvider = timeProvider, + ).also { service -> + service.onGestureDetected = { gesture, data -> + sendMotionEvent(gesture = gesture, data = data) + } + motionService = service + } + private data class NavigationInterceptedPayload( val url: String ) @@ -710,6 +789,13 @@ internal class WebViewInAppViewHolder( val error: String ) + private data class MotionStartPayload( + @SerializedName("success") + val success: Boolean = true, + @SerializedName("unavailable") + val unavailable: List? = null, + ) + private data class SettingsOpenRequest( @SerializedName("target") val target: String, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/motion/MotionService.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/motion/MotionService.kt new file mode 100644 index 000000000..757520fbf --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/motion/MotionService.kt @@ -0,0 +1,271 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view.motion + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import kotlin.math.abs +import kotlin.math.hypot + +internal enum class MotionGesture(val value: String) { + SHAKE("shake"), + FLIP("flip"), +} + +internal enum class DevicePosition(val value: String) { + FACE_UP("faceUp"), + FACE_DOWN("faceDown"), + PORTRAIT("portrait"), + PORTRAIT_UPSIDE_DOWN("portraitUpsideDown"), + LANDSCAPE_LEFT("landscapeLeft"), + LANDSCAPE_RIGHT("landscapeRight"), +} + +internal data class MotionVector(val x: Float, val y: Float, val z: Float) { + companion object { + val ZERO: MotionVector = MotionVector(0f, 0f, 0f) + } + + operator fun minus(other: MotionVector): MotionVector = MotionVector(x - other.x, y - other.y, z - other.z) + + fun magnitude(): Float = hypot(hypot(x, y), z) +} + +internal data class MotionStartResult( + val started: Set, + val unavailable: Set, +) { + val allUnavailable: Boolean get() = started.isEmpty() && unavailable.isNotEmpty() +} + +internal interface MotionServiceProtocol { + var onGestureDetected: ((gesture: MotionGesture, data: Map) -> Unit)? + + fun startMonitoring(gestures: Set): MotionStartResult + + fun stopMonitoring() +} + +internal class MotionService( + private val context: Context, + private val lifecycle: Lifecycle, + private val timeProvider: TimeProvider, +) : MotionServiceProtocol { + + private companion object { + const val SMOOTHING_FACTOR = 0.7f + val SHAKE_COOLDOWN = Milliseconds(800L) + const val TABLET_MIN_WIDTH_DP = 600 + const val PHONE_THRESHOLD_G = 3.0f + const val TABLET_THRESHOLD_G = 1.5f + const val FLIP_ENTER_THRESHOLD_G = 0.8f + const val FLIP_EXIT_THRESHOLD_G = 0.6f + } + + override var onGestureDetected: ((gesture: MotionGesture, data: Map) -> Unit)? = null + + private val sensorManager: SensorManager? = + context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager + + private val shakeAccelerationThreshold: Float by lazy { + val isTablet = context.resources.configuration.smallestScreenWidthDp >= TABLET_MIN_WIDTH_DP + val thresholdG = if (isTablet) TABLET_THRESHOLD_G else PHONE_THRESHOLD_G + thresholdG * SensorManager.GRAVITY_EARTH + } + + private var activeGestures: Set = emptySet() + private var suspendedGestures: Set? = null + + private var lastShakeVector: MotionVector = MotionVector.ZERO + private var accumulateShake = 0f + private var lastShakeTimestamp: Timestamp = Timestamp.ZERO + + private var currentFlipPosition: DevicePosition? = null + + private val shakeListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + processShake(MotionVector(event.values[0], event.values[1], event.values[2])) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit + } + + private val flipListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + processFlip(MotionVector(-event.values[0], -event.values[1], -event.values[2])) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit + } + + private val lifecycleObserver = object : DefaultLifecycleObserver { + override fun onStop(owner: LifecycleOwner) = suspend() + + override fun onStart(owner: LifecycleOwner) = resume() + } + + override fun startMonitoring(gestures: Set): MotionStartResult { + if (activeGestures.isNotEmpty()) stopMonitoring() + val unavailable = buildSet { + if (gestures.contains(MotionGesture.SHAKE) && !isShakeAvailable()) { + add(MotionGesture.SHAKE) + } + if (gestures.contains(MotionGesture.FLIP) && !isFlipAvailable()) { + add(MotionGesture.FLIP) + } + } + + activeGestures = gestures - unavailable + val result = MotionStartResult(started = activeGestures, unavailable = unavailable) + if (activeGestures.isEmpty()) return result + addLifecycleObserver() + startSensors() + + mindboxLogI("Motion: monitoring started for ${activeGestures.map { it.value }}") + if (unavailable.isNotEmpty()) { + mindboxLogI("Motion: unavailable gestures: ${unavailable.map { it.value }}") + } + return result + } + + override fun stopMonitoring() { + if (activeGestures.isEmpty() && suspendedGestures == null) return + removeLifecycleObserver() + stopSensors() + activeGestures = emptySet() + suspendedGestures = null + mindboxLogI("Motion: monitoring stopped") + } + + private fun addLifecycleObserver() { + lifecycle.addObserver(lifecycleObserver) + } + + private fun removeLifecycleObserver() { + lifecycle.removeObserver(lifecycleObserver) + } + + internal fun suspend() { + if (activeGestures.isEmpty()) return + suspendedGestures = activeGestures + stopSensors() + mindboxLogI("Motion: suspended (app in background)") + } + + internal fun resume() { + val gestures = suspendedGestures ?: return + suspendedGestures = null + activeGestures = gestures + startSensors() + mindboxLogI("Motion: resumed (app in foreground)") + } + + private fun startSensors() { + if (activeGestures.contains(MotionGesture.SHAKE)) { + sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let { sensor -> + sensorManager.registerListener(shakeListener, sensor, SensorManager.SENSOR_DELAY_NORMAL) + } + } + if (activeGestures.contains(MotionGesture.FLIP)) { + sensorManager?.getDefaultSensor(Sensor.TYPE_GRAVITY)?.let { sensor -> + sensorManager.registerListener(flipListener, sensor, SensorManager.SENSOR_DELAY_NORMAL) + } + } + } + + private fun stopSensors() { + sensorManager?.unregisterListener(shakeListener) + sensorManager?.unregisterListener(flipListener) + resetShakeState() + currentFlipPosition = null + } + + private fun resetShakeState() { + lastShakeVector = MotionVector.ZERO + accumulateShake = 0f + lastShakeTimestamp = Timestamp.ZERO + } + + private fun isShakeAvailable(): Boolean = + sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null + + private fun isFlipAvailable(): Boolean = + sensorManager?.getDefaultSensor(Sensor.TYPE_GRAVITY) != null + + internal fun processShake(vector: MotionVector) { + val delta = (vector - lastShakeVector).magnitude() + accumulateShake = accumulateShake * SMOOTHING_FACTOR + delta + val now: Timestamp = timeProvider.currentTimestamp() + val elapsed: Milliseconds = timeProvider.elapsedSince(lastShakeTimestamp) + if (accumulateShake > shakeAccelerationThreshold && elapsed.interval > SHAKE_COOLDOWN.interval) { + accumulateShake = 0f + lastShakeTimestamp = now + loggingRunCatching { onGestureDetected?.invoke(MotionGesture.SHAKE, emptyMap()) } + } + lastShakeVector = vector + } + + private fun processFlip(vector: MotionVector) { + val newPosition = resolvePosition(vector = vector, current = currentFlipPosition) + if (newPosition == null || newPosition == currentFlipPosition) return + + val from = currentFlipPosition + currentFlipPosition = newPosition + + if (from == null) return + + loggingRunCatching { + onGestureDetected?.invoke( + MotionGesture.FLIP, + mapOf("from" to from.value, "to" to newPosition.value), + ) + } + } + + internal fun resolvePosition( + vector: MotionVector, + current: DevicePosition?, + ): DevicePosition? { + data class Axis( + val value: Float, + val negative: DevicePosition, + val positive: DevicePosition, + ) + + val axes = listOf( + Axis(vector.z, DevicePosition.FACE_UP, DevicePosition.FACE_DOWN), + Axis(vector.y, DevicePosition.PORTRAIT, DevicePosition.PORTRAIT_UPSIDE_DOWN), + Axis(vector.x, DevicePosition.LANDSCAPE_LEFT, DevicePosition.LANDSCAPE_RIGHT), + ) + + if (current != null) { + axes.forEach { axis -> + val position = if (axis.value > 0f) axis.positive else axis.negative + if (position == current && abs(axis.value) > FLIP_EXIT_THRESHOLD_G * SensorManager.GRAVITY_EARTH) { + return current + } + } + } + + var dominantPosition: DevicePosition? = null + var maxMagnitude = FLIP_ENTER_THRESHOLD_G * SensorManager.GRAVITY_EARTH + + for (axis in axes) { + val magnitude = abs(axis.value) + if (magnitude > maxMagnitude) { + maxMagnitude = magnitude + dominantPosition = if (axis.value > 0f) axis.positive else axis.negative + } + } + return dominantPosition + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt index feb993283..65deadc5a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt @@ -12,6 +12,10 @@ internal value class Timestamp(val ms: Long) { operator fun plus(milliseconds: Long): Timestamp = Timestamp(ms + milliseconds) operator fun minus(timestamp: Timestamp): Timestamp = Timestamp(ms - timestamp.ms) + + companion object { + val ZERO: Timestamp = Timestamp(0L) + } } internal fun Long.toTimestamp(): Timestamp = Timestamp(this) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt new file mode 100644 index 000000000..b2371cbc5 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt @@ -0,0 +1,219 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import androidx.lifecycle.Lifecycle +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionGesture +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionService +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionVector +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +private class FakeTimeProvider(private var nowMs: Long = 0L) : TimeProvider { + override fun currentTimeMillis(): Long = nowMs + + override fun currentTimestamp(): Timestamp = Timestamp(nowMs) + + override fun elapsedSince(startTimeMillis: Timestamp): Milliseconds = + Milliseconds(nowMs - startTimeMillis.ms) + + fun advanceBy(ms: Long) { + nowMs += ms + } +} + +class MotionServiceShakeTest { + + private val phoneThresholdG = 3.0f * SensorManager.GRAVITY_EARTH + + private lateinit var fakeTimeProvider: FakeTimeProvider + private lateinit var motionService: MotionService + + @Before + fun setUp() { + val mockContext: Context = mockk(relaxed = true) + every { mockContext.getSystemService(Context.SENSOR_SERVICE) } returns mockk(relaxed = true) + every { mockContext.resources } returns mockk(relaxed = true) + fakeTimeProvider = FakeTimeProvider(nowMs = 10_000L) + motionService = MotionService( + context = mockContext, + lifecycle = mockk(relaxed = true), + timeProvider = fakeTimeProvider, + ) + } + + @Test + fun `processShake fires callback when accumulated force exceeds threshold`() { + var isDetected = false + motionService.onGestureDetected = { gesture, _ -> isDetected = gesture == MotionGesture.SHAKE } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + + assertTrue(isDetected) + } + + @Test + fun `processShake does not fire callback when force is below threshold`() { + var isDetected = false + motionService.onGestureDetected = { _, _ -> isDetected = true } + + motionService.processShake(MotionVector(x = phoneThresholdG - 1f, y = 0f, z = 0f)) + + assertFalse(isDetected) + } + + @Test + fun `processShake does not fire callback during cooldown`() { + var detectedCount = 0 + motionService.onGestureDetected = { _, _ -> detectedCount++ } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) + + assertEquals(1, detectedCount) + } + + @Test + fun `processShake fires again after cooldown expires`() { + var detectedCount = 0 + motionService.onGestureDetected = { _, _ -> detectedCount++ } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + fakeTimeProvider.advanceBy(900L) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) + + assertEquals(2, detectedCount) + } + + @Test + fun `processShake does not fire after exactly cooldown boundary`() { + var detectedCount = 0 + motionService.onGestureDetected = { _, _ -> detectedCount++ } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + fakeTimeProvider.advanceBy(800L) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) + + assertEquals(1, detectedCount) + } + + @Test + fun `processShake sends empty data map for shake gesture`() { + var capturedData: Map? = null + motionService.onGestureDetected = { _, data -> capturedData = data } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + + assertTrue(capturedData != null && capturedData?.isEmpty() == true) + } + + @Test + fun `processShake accumulates force across multiple frames`() { + var isDetected = false + motionService.onGestureDetected = { _, _ -> isDetected = true } + + val halfThreshold = phoneThresholdG / 2f + motionService.processShake(MotionVector(x = halfThreshold, y = 0f, z = 0f)) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) + motionService.processShake(MotionVector(x = halfThreshold, y = 0f, z = 0f)) + + assertTrue(isDetected) + } +} + +class MotionServiceLifecycleTest { + + private lateinit var mockSensorManager: SensorManager + private lateinit var mockContext: Context + private lateinit var motionService: MotionService + + @Before + fun setUp() { + val mockSensor = mockk(relaxed = true) + mockSensorManager = mockk(relaxed = true) + every { mockSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) } returns mockSensor + every { mockSensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) } returns mockSensor + + mockContext = mockk(relaxed = true) + every { mockContext.getSystemService(Context.SENSOR_SERVICE) } returns mockSensorManager + every { mockContext.resources } returns mockk(relaxed = true) + + motionService = MotionService( + context = mockContext, + lifecycle = mockk(relaxed = true), + timeProvider = SystemTimeProvider(), + ) + } + + @Test + fun `startMonitoring registers sensor listener`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE)) + + verify(exactly = 1) { mockSensorManager.registerListener(any(), any(), any()) } + } + + @Test + fun `suspend stops sensors when monitoring is active`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE)) + + motionService.suspend() + + verify { mockSensorManager.unregisterListener(any()) } + } + + @Test + fun `suspend does nothing when monitoring is not active`() { + motionService.suspend() + + verify(exactly = 0) { mockSensorManager.unregisterListener(any()) } + } + + @Test + fun `resume restarts sensors after suspend`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE)) + motionService.suspend() + + motionService.resume() + + verify(exactly = 2) { mockSensorManager.registerListener(any(), any(), any()) } + } + + @Test + fun `resume does nothing without prior suspend`() { + motionService.resume() + + verify(exactly = 0) { mockSensorManager.registerListener(any(), any(), any()) } + } + + @Test + fun `stopMonitoring after suspend prevents resume from restarting sensors`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE)) + motionService.suspend() + motionService.stopMonitoring() + + motionService.resume() + + verify(exactly = 1) { mockSensorManager.registerListener(any(), any(), any()) } + } + + @Test + fun `stopMonitoring unregisters all sensors`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE, MotionGesture.FLIP)) + + motionService.stopMonitoring() + + verify(atLeast = 1) { mockSensorManager.unregisterListener(any()) } + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceResolvePositionTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceResolvePositionTest.kt new file mode 100644 index 000000000..cefd974c9 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceResolvePositionTest.kt @@ -0,0 +1,249 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.hardware.SensorManager +import androidx.lifecycle.Lifecycle +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.DevicePosition +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionService +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionVector +import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class MotionServiceResolvePositionTest { + + private lateinit var motionService: MotionService + + private val enterThreshold = 0.8f * SensorManager.GRAVITY_EARTH + private val exitThreshold = 0.6f * SensorManager.GRAVITY_EARTH + + @Before + fun setUp() { + val mockContext: Context = mockk(relaxed = true) + every { mockContext.getSystemService(Context.SENSOR_SERVICE) } returns mockk(relaxed = true) + every { mockContext.resources } returns mockk(relaxed = true) + motionService = MotionService( + context = mockContext, + lifecycle = mockk(relaxed = true), + timeProvider = SystemTimeProvider(), + ) + } + + @Test + fun `resolvePosition returns faceUp when z is strongly negative and no current position`() { + val inputZ = -enterThreshold - 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = null, + ) + assertEquals(DevicePosition.FACE_UP, actualPosition) + } + + @Test + fun `resolvePosition returns faceDown when z is strongly positive and no current position`() { + val inputZ = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = null, + ) + assertEquals(DevicePosition.FACE_DOWN, actualPosition) + } + + @Test + fun `resolvePosition returns portrait when y is strongly negative and no current position`() { + val inputY = -enterThreshold - 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = 0f), + current = null, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + @Test + fun `resolvePosition returns portraitUpsideDown when y is strongly positive and no current position`() { + val inputY = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = 0f), + current = null, + ) + assertEquals(DevicePosition.PORTRAIT_UPSIDE_DOWN, actualPosition) + } + + @Test + fun `resolvePosition returns landscapeLeft when x is strongly negative and no current position`() { + val inputX = -enterThreshold - 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = inputX, y = 0f, z = 0f), + current = null, + ) + assertEquals(DevicePosition.LANDSCAPE_LEFT, actualPosition) + } + + @Test + fun `resolvePosition returns landscapeRight when x is strongly positive and no current position`() { + val inputX = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = inputX, y = 0f, z = 0f), + current = null, + ) + assertEquals(DevicePosition.LANDSCAPE_RIGHT, actualPosition) + } + + @Test + fun `resolvePosition returns null when all axes are below enter threshold and no current position`() { + val inputValue = enterThreshold - 0.1f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = -inputValue), + current = null, + ) + assertNull(actualPosition) + } + + @Test + fun `resolvePosition returns null when all axes are zero and no current position`() { + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = 0f), + current = null, + ) + assertNull(actualPosition) + } + + @Test + fun `resolvePosition retains current faceUp when z is above exit threshold`() { + val inputZ = -(exitThreshold + 0.1f) + val inputCurrentPosition = DevicePosition.FACE_UP + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.FACE_UP, actualPosition) + } + + @Test + fun `resolvePosition retains current portrait when y is above exit threshold`() { + val inputY = -(exitThreshold + 0.1f) + val inputCurrentPosition = DevicePosition.PORTRAIT + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = 0f), + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + @Test + fun `resolvePosition retains current landscapeLeft when x is above exit threshold`() { + val inputX = -(exitThreshold + 0.1f) + val inputCurrentPosition = DevicePosition.LANDSCAPE_LEFT + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = inputX, y = 0f, z = 0f), + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.LANDSCAPE_LEFT, actualPosition) + } + + @Test + fun `resolvePosition drops current faceUp when z falls below exit threshold and switches to portrait`() { + val inputZ = -(exitThreshold - 0.1f) + val inputY = -(enterThreshold + 0.5f) + val inputCurrentPosition = DevicePosition.FACE_UP + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = inputZ), + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + @Test + fun `resolvePosition returns null when current position is lost and no axis exceeds enter threshold`() { + val inputZ = -(exitThreshold - 0.1f) + val inputCurrentPosition = DevicePosition.FACE_UP + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = inputCurrentPosition, + ) + assertNull(actualPosition) + } + + @Test + fun `resolvePosition picks dominant axis when multiple axes exceed enter threshold`() { + val inputZ = -(enterThreshold + 0.1f) + val inputY = -(enterThreshold + 2.0f) + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = inputZ), + current = null, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + @Test + fun `resolvePosition picks z axis when z magnitude exceeds y magnitude`() { + val inputZ = -(enterThreshold + 1.0f) + val inputY = -(enterThreshold + 0.1f) + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = inputZ), + current = null, + ) + assertEquals(DevicePosition.FACE_UP, actualPosition) + } + + @Test + fun `resolvePosition returns null when z is exactly at enter threshold`() { + val inputZ = -enterThreshold + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = null, + ) + assertNull(actualPosition) + } + + @Test + fun `resolvePosition transitions from faceUp to faceDown when z flips to positive`() { + val inputZ = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = DevicePosition.FACE_UP, + ) + assertEquals(DevicePosition.FACE_DOWN, actualPosition) + } + + @Test + fun `resolvePosition transitions from portrait to portraitUpsideDown when y flips to positive`() { + val inputY = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = 0f), + current = DevicePosition.PORTRAIT, + ) + assertEquals(DevicePosition.PORTRAIT_UPSIDE_DOWN, actualPosition) + } + + @Test + fun `resolvePosition handles multi-step transition from portrait through faceUp to faceDown`() { + val inputStrongZ = -(enterThreshold + 0.5f) + val step1ActualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputStrongZ), + current = DevicePosition.PORTRAIT, + ) + assertEquals(DevicePosition.FACE_UP, step1ActualPosition) + + val step2ActualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = enterThreshold + 0.5f), + current = DevicePosition.FACE_UP, + ) + assertEquals(DevicePosition.FACE_DOWN, step2ActualPosition) + } + + @Test + fun `resolvePosition retains portrait when y is above exit threshold even though z is below enter threshold`() { + val inputY = -(exitThreshold + 0.5f) + val inputZ = -(enterThreshold - 1.0f) + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = inputZ), + current = DevicePosition.PORTRAIT, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } +}