From 613bf03b59c35159b4c4b1b886ed1e3bfc7a373e Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:56:38 +0300 Subject: [PATCH 1/3] MOBILE-197: Send data-only payload in WebView sync onError Drop both wrappers: the {type, data} envelope from toJson and the {"error": ...} double serialization in sendErrorResponse. The payload format is shared with iOS: string httpStatusCode, no transport statusCode. toJson() is unchanged - public API used by wrapper SDKs. --- .../view/WebViewInappViewHolder.kt | 11 ++-- .../view/WebViewOperationExecutor.kt | 2 +- .../view/WebViewSyncOperationError.kt | 54 +++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt 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 be2d52ce..7ef1db80 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 @@ -644,10 +644,13 @@ internal class WebViewInAppViewHolder( error: Throwable, controller: WebViewController, ) { - val json: String = runCatching { - val payload = ErrorPayload(error = requireNotNull(error.message)) - gson.toJson(payload) - }.getOrDefault(BridgeMessage.UNKNOWN_ERROR_PAYLOAD) + val json: String = when (error) { + is WebViewSyncOperationException -> error.payloadJson + else -> 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}") 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 a7f9f54f..24ca375a 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 @@ -52,7 +52,7 @@ internal class MindboxWebViewOperationExecutor( onError = { error: MindboxError -> if (continuation.isActive) { continuation.resumeWithException( - IllegalStateException(error.toJson()) + WebViewSyncOperationException(error.toWebViewDataJson(gson)) ) } }, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt new file mode 100644 index 00000000..7ca0a88d --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt @@ -0,0 +1,54 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import cloud.mindbox.mobile_sdk.models.MindboxError +import com.google.gson.Gson +import com.google.gson.JsonObject + +internal class WebViewSyncOperationException(val payloadJson: String) : Exception(payloadJson) + +/** + * Data-only JSON without the `{type, data}` envelope — the WebView JS-bridge `onError` + * contract shared with iOS: string `httpStatusCode`, no transport `statusCode`. + * `toJson()` must keep the envelope: RN/Flutter wrappers dispatch on it. + */ +internal fun MindboxError.toWebViewDataJson(gson: Gson): String { + val data = JsonObject() + when (this) { + is MindboxError.Validation -> { + data.addProperty("status", status) + data.add("validationMessages", gson.toJsonTree(validationMessages)) + } + + is MindboxError.Protocol -> + data.addServerErrorFields(status, errorMessage, errorId, httpStatusCode) + + is MindboxError.InternalServer -> + data.addServerErrorFields(status, errorMessage, errorId, httpStatusCode) + + is MindboxError.UnknownServer -> { + status?.let { data.addProperty("status", it) } + errorMessage?.let { data.addProperty("errorMessage", it) } + errorId?.let { data.addProperty("errorId", it) } + data.addProperty("httpStatusCode", httpStatusCode?.toString() ?: "null") + } + + is MindboxError.Unknown -> { + data.addProperty("errorKey", "unknown") + data.addProperty("errorName", throwable?.javaClass?.canonicalName ?: "") + data.addProperty("errorMessage", throwable?.localizedMessage ?: "") + } + } + return data.toString() +} + +private fun JsonObject.addServerErrorFields( + status: String, + errorMessage: String?, + errorId: String?, + httpStatusCode: Int?, +) { + addProperty("status", status) + errorMessage?.let { addProperty("errorMessage", it) } + addProperty("errorId", errorId ?: "") + addProperty("httpStatusCode", httpStatusCode?.toString() ?: "null") +} From db63c5fae52cc8a1a39dd65789e0b3df888daf87 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:56:42 +0300 Subject: [PATCH 2/3] MOBILE-197: Pin iOS-format sync error payloads in executor tests --- .../view/WebViewOperationExecutorTest.kt | 83 +++++++++++++++++-- 1 file changed, 76 insertions(+), 7 deletions(-) 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 4138f21f..e03426d7 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,8 +3,10 @@ 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 cloud.mindbox.mobile_sdk.models.ValidationMessage import com.google.gson.Gson import io.mockk.* +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -290,10 +292,8 @@ class WebViewOperationExecutorTest { } } - @Test - fun `executeSyncOperation throws IllegalStateException when event manager returns error`() = runTest { + private fun executeSyncOperationExpectingError(error: MindboxError): WebViewSyncOperationException = runBlocking { val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" - val expectedError: MindboxError = MindboxError.Unknown(Throwable("network failure")) every { MindboxEventManager.syncOperation( name = any(), @@ -303,16 +303,85 @@ class WebViewOperationExecutorTest { ) } answers { val onError: (MindboxError) -> Unit = arg(3) - onError(expectedError) + onError(error) } try { executor.executeSyncOperation(payload, tags = null) - fail("Expected IllegalStateException") - } catch (exception: IllegalStateException) { - assertEquals(expectedError.toJson(), exception.message) + throw AssertionError("Expected WebViewSyncOperationException") + } catch (exception: WebViewSyncOperationException) { + exception } } + @Test + fun `executeSyncOperation protocol error payload is the data contents in iOS format`() { + val exception = executeSyncOperationExpectingError( + MindboxError.Protocol( + statusCode = 400, + status = "ProtocolError", + errorMessage = "Operation OpenScreen not found", + errorId = "error-id-1", + httpStatusCode = 400, + ) + ) + assertEquals( + """{"status":"ProtocolError","errorMessage":"Operation OpenScreen not found","errorId":"error-id-1","httpStatusCode":"400"}""", + exception.payloadJson, + ) + } + + @Test + fun `executeSyncOperation internal server error payload is the data contents in iOS format`() { + val exception = executeSyncOperationExpectingError( + MindboxError.InternalServer( + statusCode = 500, + status = "InternalServerError", + errorMessage = "Something went wrong", + errorId = null, + httpStatusCode = 500, + ) + ) + assertEquals( + """{"status":"InternalServerError","errorMessage":"Something went wrong","errorId":"","httpStatusCode":"500"}""", + exception.payloadJson, + ) + } + + @Test + fun `executeSyncOperation validation error payload is the data contents with validationMessages`() { + val exception = executeSyncOperationExpectingError( + MindboxError.Validation( + statusCode = 200, + status = "ValidationError", + validationMessages = listOf( + ValidationMessage(message = "Invalid email", location = "/customer/email") + ), + ) + ) + assertEquals( + """{"status":"ValidationError","validationMessages":[{"message":"Invalid email","location":"/customer/email"}]}""", + exception.payloadJson, + ) + } + + @Test + fun `executeSyncOperation network error payload is the data contents without envelope`() { + val exception = executeSyncOperationExpectingError(MindboxError.UnknownServer()) + assertEquals( + """{"errorMessage":"Cannot reach server","httpStatusCode":"null"}""", + exception.payloadJson, + ) + } + + @Test + fun `executeSyncOperation unknown error payload is the data contents without envelope`() { + val exception = executeSyncOperationExpectingError(MindboxError.Unknown(Throwable("network failure"))) + assertEquals( + """{"errorKey":"unknown","errorName":"java.lang.Throwable","errorMessage":"network failure"}""", + exception.payloadJson, + ) + } + @Test fun `executeSyncOperation throws when payload misses body`() = runTest { val payload: String = """{"operation":"OpenScreen"}""" From 73b4e7bb5359175a19e9c3869404c57ebd76b721 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:40:38 +0300 Subject: [PATCH 3/3] MOBILE-197: Pin why sync error payload avoids gson.toJson The injected gson has htmlSafe enabled and would emit \u003c-style escapes, diverging from iOS JSONEncoder output. --- .../inapp/presentation/view/WebViewSyncOperationError.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt index 7ca0a88d..e277b856 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt @@ -10,6 +10,9 @@ internal class WebViewSyncOperationException(val payloadJson: String) : Exceptio * Data-only JSON without the `{type, data}` envelope — the WebView JS-bridge `onError` * contract shared with iOS: string `httpStatusCode`, no transport `statusCode`. * `toJson()` must keep the envelope: RN/Flutter wrappers dispatch on it. + * + * Serialized via `JsonElement.toString()`, not `gson.toJson`: the SDK gson has + * htmlSafe enabled and would escape `<`/`&`/`'`, while iOS `JSONEncoder` does not. */ internal fun MindboxError.toWebViewDataJson(gson: Gson): String { val data = JsonObject()