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..e277b856 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt @@ -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() +} + +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") +} 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"}"""