From 26432840beb8d2a5744a69262258fd5179346c16 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 20 Jan 2026 10:58:22 +0300 Subject: [PATCH 01/64] 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/64] 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/64] 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/64] 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/64] 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 b8f34b4b1628dbb7ca6719587e1cff562411b102 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 21 Jan 2026 10:18:31 +0300 Subject: [PATCH 06/64] MOBILEWEBVIEW-3: Fix InAppPositionController for BottomSheet --- kmp-common-sdk | 2 +- .../view/InAppPositionController.kt | 30 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/kmp-common-sdk b/kmp-common-sdk index eb48474d7..1ceae5aa2 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit eb48474d74edb4ea1e75197adb1d36c48cef8d3e +Subproject commit 1ceae5aa2f46903b3cd1d0eb16b06222ca0ca2ad 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 a6e7858abacffaf18a4c8e10ebcd8c905f873e15 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:43:03 +0300 Subject: [PATCH 07/64] WMSDK-608: Support app distribution from all branches (#670) Solution without GItLab API token With manual distribution, you can specify the branch name for the app. If there is no such branch, the workflow will fail and the logs will show an error stating that the branch could not be found. In all other cases, I always try to trigger a build on GitLab with the exact same branch as the one on which the distribution workflow runs. If there is no such branch, it triggers a build in GitLab on develop. --- .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 3a50ab05c11d4e39a94e3389c3cac270f2fbfe28 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 2 Feb 2026 10:24:20 +0300 Subject: [PATCH 08/64] 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 09/64] 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 10/64] 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 11/64] 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 12/64] 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 13/64] 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 14/64] 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 15/64] 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 16/64] 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 17/64] 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 18/64] 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 19/64] 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 20/64] 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 21/64] 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 22/64] 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 23/64] 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 24/64] 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 25/64] 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 26/64] 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 27/64] 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 28/64] 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 29/64] 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 30/64] 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 31/64] 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 32/64] 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 33/64] 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 34/64] 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 35/64] 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 36/64] 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 37/64] 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 38/64] 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 39/64] 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 40/64] 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 41/64] 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 42ae50327fc0394e7fc8c284022ca393504e042d Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 10 Mar 2026 14:27:29 +0300 Subject: [PATCH 42/64] Change version badge image url --- README.md | 2 +- kmp-common-sdk | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b3995622f..8ec488b21 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/cloud.mindbox/mobile-sdk/badge.svg)](https://central.sonatype.com/artifact/cloud.mindbox/mobile-sdk) +[![Maven Central](https://img.shields.io/maven-central/v/cloud.mindbox/mobile-sdk?label=Maven%20Central)](https://central.sonatype.com/artifact/cloud.mindbox/mobile-sdk) # Mindbox SDK for Android diff --git a/kmp-common-sdk b/kmp-common-sdk index 1ceae5aa2..80e199ff1 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 1ceae5aa2f46903b3cd1d0eb16b06222ca0ca2ad +Subproject commit 80e199ff1d25f1bed6283287b4a89be6e3e65e10 From 55f1f0587fb98a42d6587578183773118c0dad6d Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 11 Mar 2026 17:43:57 +0300 Subject: [PATCH 43/64] 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 44/64] 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 45/64] 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 46/64] 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 47/64] 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 48/64] 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 49/64] 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 50/64] 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 51/64] 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 52/64] 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 53/64] 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 54/64] 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 55/64] 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 56/64] 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 57/64] 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 58/64] 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 59/64] 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 60/64] 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 61/64] 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 62/64] 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) + } +} From 1ba61e0f039d6aeadbc5bf512e3df125f36a3c7e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:13:53 +0000 Subject: [PATCH 63/64] Bump SDK version to 2.15.0 --- 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 1fbb71f6d..9e21cd213 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.15.0-rc' + implementation 'cloud.mindbox:mobile-sdk:2.15.0' implementation 'cloud.mindbox:mindbox-firebase' implementation 'cloud.mindbox:mindbox-huawei' implementation 'cloud.mindbox:mindbox-rustore' diff --git a/gradle.properties b/gradle.properties index c9ffc8477..16c42c7bd 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.15.0-rc +SDK_VERSION_NAME=2.15.0 USE_LOCAL_MINDBOX_COMMON=true android.nonTransitiveRClass=false kotlin.mpp.androidGradlePluginCompatibility.nowarn=true From 6fbc622bf18e4b004a71d4ebc62c7e9d06bf568b Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:14:56 +0300 Subject: [PATCH 64/64] MOBILE-97: delete ios tests (cherry picked from commit 7dc8f0bd0d82c629895a5604b84f498f20c0089f) --- WebViewLocalStateStorageTests.swift | 279 ---------------------------- 1 file changed, 279 deletions(-) delete mode 100644 WebViewLocalStateStorageTests.swift diff --git a/WebViewLocalStateStorageTests.swift b/WebViewLocalStateStorageTests.swift deleted file mode 100644 index 26b0b44c6..000000000 --- a/WebViewLocalStateStorageTests.swift +++ /dev/null @@ -1,279 +0,0 @@ -// -// 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) - } -}