From 3a50ab05c11d4e39a94e3389c3cac270f2fbfe28 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 2 Feb 2026 10:24:20 +0300 Subject: [PATCH 1/5] 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 1ceae5aa..6720b2a1 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 65e570ff..c103c98b 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 00000000..0bfdfd39 --- /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 68629986..6f2a8772 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 2/5] 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 00000000..015584b2 --- /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 0bfdfd39..668a27b2 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 6f2a8772..d05a82bc 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 3/5] 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 00000000..02fb37d0 --- /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 00000000..5cf91ba7 --- /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 4/5] 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 6720b2a1..c76665d1 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 5/5] 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 c76665d1..15032dfa 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 c103c98b..41f7ec0d 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 015584b2..60c24b51 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 668a27b2..d3a10aeb 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 d05a82bc..d06dec20 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 02fb37d0..5e50eb8f 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 5cf91ba7..c4f2084f 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