diff --git a/kmp-common-sdk b/kmp-common-sdk index 1ceae5aa2..15032dfa4 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 1ceae5aa2f46903b3cd1d0eb16b06222ca0ca2ad +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 65e570ff2..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 @@ -25,6 +26,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 @@ -37,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 @@ -270,6 +273,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/data/validators/BridgeMessageValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt new file mode 100644 index 000000000..60c24b513 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt @@ -0,0 +1,49 @@ +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 + + 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.name.isEmpty()) { + mindboxLogW("BridgeMessage action is empty") + return false + } + + if (item.timestamp <= 0L) { + mindboxLogW("BridgeMessage timestamp must be positive") + 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 new file mode 100644 index 000000000..d3a10aeb9 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -0,0 +1,151 @@ +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 + +@InternalMindboxApi +public enum class WebViewAction { + @SerializedName("init") + INIT, + + @SerializedName("ready") + READY, + + @SerializedName("click") + CLICK, + + @SerializedName("close") + CLOSE, + + @SerializedName("hide") + HIDE, + + @SerializedName("log") + LOG, + + @SerializedName("alert") + ALERT, + + @SerializedName("toast") + TOAST, +} + +@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 + + public 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() + + public 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() + + public 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() + + 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" + + public fun createAction(action: WebViewAction, payload: String): Request = + Request( + id = UUID.randomUUID().toString(), + version = VERSION, + action = action, + payload = payload, + timestamp = System.currentTimeMillis(), + ) + + public fun createResponseAction(message: Request, payload: String?): Response = + Response( + id = message.id, + version = message.version, + action = message.action, + payload = payload, + timestamp = System.currentTimeMillis(), + ) + + public fun createErrorAction(message: Request, payload: String?): Error = + Error( + id = message.id, + version = message.version, + action = message.action, + payload = payload, + timestamp = System.currentTimeMillis(), + ) + } +} + +@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() + + 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: BridgeSuspendMessageHandler) { + 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..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 @@ -2,12 +2,15 @@ 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 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 @@ -31,12 +34,14 @@ 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.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel 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,12 +53,19 @@ 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 } + private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } override val isActive: Boolean get() = isInAppMessageActive @@ -66,8 +78,59 @@ 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, + configuration: Configuration + ): WebViewActionHandlers { + return WebViewActionHandlers().apply { + 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) { + handleInitAction(controller) + } + register(WebViewAction.HIDE) { + handleHideAction(controller) + } + } + } + + 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) @@ -75,62 +138,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 handleInitAction(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 handleClickAction(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 handleCloseAction(message: BridgeMessage): String { + inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) + mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") + 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 handleHideAction(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 handleLogAction(message: BridgeMessage.Request): String { + mindboxLogI("JS: ${message.payload}") + return BridgeMessage.EMPTY_PAYLOAD + } - override fun onHide() { - controller.setVisibility(false) - } + 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 + } - override fun onLog(message: String) { - mindboxLogI("JS: $message") - } - }) - } + private fun handleAlertAction(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 { @@ -144,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() @@ -158,42 +235,122 @@ 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) { + 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) + mindboxLogE("WebView send error response for ${message.action} with payload ${errorMessage.payload}") + sendActionInternal(controller, errorMessage) + } + + private fun handleResponse(message: BridgeMessage.Response) { + val responseDeferred: CompletableDeferred? = pendingResponsesById.remove(message.id) + if (responseDeferred == null) { + mindboxLogW("No pending response for id $message.id") + return + } + if (!responseDeferred.isCompleted) { + responseDeferred.complete(message) + } + } + + private fun handleError(message: BridgeMessage.Error) { + mindboxLogW("WebView error: ${message.payload}") + val responseDeferred: CompletableDeferred? = pendingResponsesById.remove(message.id) + 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 renderLayer(layer: Layer.WebViewLayer) { if (webViewController == null) { val controller: WebViewController = createWebViewController(layer) - controller.setVisibility(false) webViewController = controller + Mindbox.mindboxScope.launch { val configuration: Configuration = DbManager.listenConfigurations().first() - withContext(Dispatchers.Main) { - addJavascriptInterface(layer, configuration) - controller.setUserAgentSuffix(configuration.getShortUserAgent()) - } + 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 + } + + 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, 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 +360,7 @@ internal class WebViewInAppViewHolder( requestQueue.add(stringRequest) } } + webViewController?.let { controller -> val view: WebViewPlatformView = controller.view if (view.parent !== inAppLayout) { @@ -212,13 +370,37 @@ 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)}.") + hide() + 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 -> { - addUrlSource(layer) + renderLayer(layer) } else -> { @@ -234,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") } } @@ -247,6 +429,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 +442,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) - } - } - } - } } 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..5e50eb8f0 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidatorTest.kt @@ -0,0 +1,87 @@ +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() + + @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..c4f2084fd --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewActionHandlersTest.kt @@ -0,0 +1,134 @@ +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 +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.* +import org.junit.Assert.* +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class, InternalMindboxApi::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, + ) + } +}