Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ internal class MindboxWebViewOperationExecutor(
onError = { error: MindboxError ->
if (continuation.isActive) {
continuation.resumeWithException(
IllegalStateException(error.toJson())
WebViewSyncOperationException(error.toWebViewDataJson(gson))
)
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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.
*
* 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()
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()

@justSmK justSmK Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gson.toJson(data) wouldn't produce the same payload: the injected Gson is built without disableHtmlEscaping(), so it would emit \u003c-style escapes for <, &, ' etc. in errorMessage. This method's contract is byte-parity with iOS JSONEncoder, which doesn't HTML-escape, so JsonElement.toString() (htmlSafe off) is intentional.

}

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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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"}"""
Expand Down