diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt index bb2673f7bb..838c3c4815 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt @@ -20,25 +20,45 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed -import org.json.JSONObject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.put + +// The `ime.*` envelopes are ad-hoc JSON from the page shim; every field is optional, so each +// accessor degrades to null rather than throwing on an absent or mistyped value. +private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull + +private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull + +private fun JsonObject.double(key: String): Double? = (this[key] as? JsonPrimitive)?.doubleOrNull + +private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull + +private fun JsonObject.obj(key: String): JsonObject? = this[key] as? JsonObject /** Parses the `geom` object of an `ime.pagesel` payload into a [SelectionGeometry], or null if absent. */ -fun parseSelectionGeometry(o: JSONObject?): SelectionGeometry? { +fun parseSelectionGeometry(o: JsonObject?): SelectionGeometry? { if (o == null) return null return SelectionGeometry( - left = o.optDouble("l", 0.0).toFloat(), - top = o.optDouble("t", 0.0).toFloat(), - right = o.optDouble("r", 0.0).toFloat(), - bottom = o.optDouble("b", 0.0).toFloat(), - startX = o.optDouble("sx", 0.0).toFloat(), - startBottom = o.optDouble("sb", 0.0).toFloat(), - endX = o.optDouble("ex", 0.0).toFloat(), - endBottom = o.optDouble("eb", 0.0).toFloat(), - viewportWidth = o.optDouble("vw", 0.0).toFloat(), - caretX = if (o.has("cx")) o.optDouble("cx").toFloat() else null, - caretTop = if (o.has("ct")) o.optDouble("ct").toFloat() else null, - caretBottom = if (o.has("cb")) o.optDouble("cb").toFloat() else null, - isRange = o.optBoolean("rng", false), + left = (o.double("l") ?: 0.0).toFloat(), + top = (o.double("t") ?: 0.0).toFloat(), + right = (o.double("r") ?: 0.0).toFloat(), + bottom = (o.double("b") ?: 0.0).toFloat(), + startX = (o.double("sx") ?: 0.0).toFloat(), + startBottom = (o.double("sb") ?: 0.0).toFloat(), + endX = (o.double("ex") ?: 0.0).toFloat(), + endBottom = (o.double("eb") ?: 0.0).toFloat(), + viewportWidth = (o.double("vw") ?: 0.0).toFloat(), + caretX = o.double("cx")?.toFloat(), + caretTop = o.double("ct")?.toFloat(), + caretBottom = o.double("cb")?.toFloat(), + isRange = o.bool("rng") ?: false, ) } @@ -62,45 +82,45 @@ interface EmbeddedImeBridge { * learns there is a field to put the keyboard back on. Sent when a tab becomes the active one again, and when * an [ImeEvent.WantKeyboard] tap arrives for a field this host no longer mirrors. */ -fun EmbeddedImeBridge.requestImeResync() = sendImeOp(JSONObject().put("type", "ime.resync").toString()) +fun EmbeddedImeBridge.requestImeResync() = sendImeOp(buildJsonObject { put("type", "ime.resync") }.toString()) /** Parses one page → host `ime.*` envelope into an [ImeEvent], or null for anything unrecognized. */ fun parseImeEvent(payload: String): ImeEvent? { - val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null - return when (o.optString("type")) { + val o = runCatching { Json.parseToJsonElement(payload) as? JsonObject }.getOrNull() ?: return null + return when (o.string("type")) { "ime.focus" -> parseFocus(o) "ime.wantkb" -> ImeEvent.WantKeyboard "ime.refocus" -> ImeEvent.ReFocus(parseFocus(o)) "ime.blur" -> ImeEvent.Blur "ime.state" -> ImeEvent.State( - text = o.optString("text", ""), - selStart = o.optInt("selStart", 0), - selEnd = o.optInt("selEnd", 0), - geometry = parseSelectionGeometry(o.optJSONObject("geom")), + text = o.string("text") ?: "", + selStart = o.int("selStart") ?: 0, + selEnd = o.int("selEnd") ?: 0, + geometry = parseSelectionGeometry(o.obj("geom")), ) "ime.pagesel" -> ImeEvent.PageSelection( - active = o.optBoolean("active", false), - text = o.optString("text", ""), - geometry = parseSelectionGeometry(o.optJSONObject("geom")), + active = o.bool("active") ?: false, + text = o.string("text") ?: "", + geometry = parseSelectionGeometry(o.obj("geom")), ) - "ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false)) - "ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom"))) + "ime.scroll" -> ImeEvent.Scroll(active = o.bool("active") ?: false) + "ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.obj("geom"))) else -> null } } -private fun parseFocus(o: JSONObject) = +private fun parseFocus(o: JsonObject) = ImeEvent.Focus( - inputType = o.optString("inputType", "text"), - enterKeyHint = o.optString("enterKeyHint", ""), - multiline = o.optBoolean("multiline", false), - readOnly = o.optBoolean("readOnly", false), - text = o.optString("text", ""), - selStart = o.optInt("selStart", 0), - selEnd = o.optInt("selEnd", 0), - geometry = parseSelectionGeometry(o.optJSONObject("geom")), + inputType = o.string("inputType") ?: "text", + enterKeyHint = o.string("enterKeyHint") ?: "", + multiline = o.bool("multiline") ?: false, + readOnly = o.bool("readOnly") ?: false, + text = o.string("text") ?: "", + selStart = o.int("selStart") ?: 0, + selEnd = o.int("selEnd") ?: 0, + geometry = parseSelectionGeometry(o.obj("geom")), ) /** What the focused page field reports up to the host keyboard. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index 73472ede01..8405e741b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -83,7 +83,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.privacysandbox.ui.client.view.SandboxedSdkView import kotlinx.coroutines.delay -import org.json.JSONObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import kotlin.math.roundToInt // How far off-screen a parked (inactive) warm tab is shifted — well past any real screen width. @@ -106,12 +107,12 @@ private fun EmbeddedImeBridge.sendFieldOp( cssX: Float, cssY: Float, ) = sendImeOp( - JSONObject() - .put("type", type) - .apply { if (edge != null) put("edge", edge) } - .put("x", cssX.toDouble()) - .put("y", cssY.toDouble()) - .toString(), + buildJsonObject { + put("type", type) + if (edge != null) put("edge", edge) + put("x", cssX.toDouble()) + put("y", cssY.toDouble()) + }.toString(), ) /** @@ -508,7 +509,14 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { fingerPx.y > oy + bounds.height - edgeZonePx -> AUTOSCROLL_STEP_CSS else -> 0.0 } - if (dy != 0.0) imeBridge?.sendImeOp(JSONObject().put("type", "ime.autoscroll").put("dy", dy).toString()) + if (dy != 0.0) { + imeBridge?.sendImeOp( + buildJsonObject { + put("type", "ime.autoscroll") + put("dy", dy) + }.toString(), + ) + } } if (!active || magProbe == null) { magnifier.hide() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt index 5d2aec6b4d..047fedb698 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt @@ -32,7 +32,9 @@ import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnectionWrapper import android.view.inputmethod.InputMethodManager import android.widget.EditText -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put /** * The main-app-window home for the soft keyboard when a field is focused inside an embedded WebView. The @@ -124,7 +126,7 @@ class RemoteImeView( ) // The IME's "Go/Search/Send/Done" — the page submits/handles it (single-line has no newline). setOnEditorActionListener { _, _, _ -> - bridge?.sendImeOp(JSONObject().put("type", "ime.action").toString()) + bridge?.sendImeOp(buildJsonObject { put("type", "ime.action") }.toString()) true } } @@ -365,12 +367,12 @@ class RemoteImeView( schedule() } - private fun stateJson(): JSONObject { + private fun stateJson(): JsonObject { val editable = text val composingStart = if (editable != null) BaseInputConnection.getComposingSpanStart(editable) else -1 val composingEnd = if (editable != null) BaseInputConnection.getComposingSpanEnd(editable) else -1 - return JSONObject() - .put("type", "ime.set") + return buildJsonObject { + put("type", "ime.set") // A readonly field's text must never travel back to the page. TYPE_NULL keeps the *user* from // typing into the mirror, but the mirror still flushes on selection changes — a long-press // select-all, then Chrome's collapse-to-endpoint, both emit one — and that flush is delivered @@ -381,11 +383,12 @@ class RemoteImeView( // Omitting the key (rather than sending the current text) makes the shim treat the message as // selection-only — `var next = (msg.text != null) ? String(msg.text) : prev` — so the // host-drawn handles and Copy keep working off a synced selection while nothing can be written. - .apply { if (!fieldReadOnly) put("text", editable?.toString() ?: "") } - .put("selStart", selectionStart) - .put("selEnd", selectionEnd) - .put("composingStart", composingStart) - .put("composingEnd", composingEnd) + if (!fieldReadOnly) put("text", editable?.toString() ?: "") + put("selStart", selectionStart) + put("selEnd", selectionEnd) + put("composingStart", composingStart) + put("composingEnd", composingEnd) + } } private fun flushState() { diff --git a/nappletHost/build.gradle.kts b/nappletHost/build.gradle.kts index 3d6b307c1b..cec65e1ef4 100644 --- a/nappletHost/build.gradle.kts +++ b/nappletHost/build.gradle.kts @@ -44,6 +44,9 @@ dependencies { implementation(libs.androidx.webkit) implementation(libs.okhttp) + // Tree-level JSON for the bridge/broker envelopes (no @Serializable codegen, so no plugin needed). + implementation(libs.kotlinx.serialization.json) + // Provider side of the cross-process UI embedding: hosts the browser WebView in this keyless // `:napplet` process and ships its rendered surface to the main app via SurfaceControlViewHost. implementation(libs.androidx.privacysandbox.ui.core) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/JsonEnvelope.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/JsonEnvelope.kt new file mode 100644 index 0000000000..dba3febff0 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/JsonEnvelope.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplethost + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull + +// Tree-level JSON helpers (kotlinx.serialization) for the ad-hoc bridge/broker envelopes this +// process relays ({type, id, ...}). The envelopes come from untrusted pages, so every accessor +// is total: absent/mistyped fields degrade to the empty/false default instead of throwing. + +/** Parses [raw] as a JSON object, or null when it is malformed or not an object. */ +internal fun parseJsonObject(raw: String): JsonObject? = runCatching { Json.parseToJsonElement(raw) as? JsonObject }.getOrNull() + +/** The value at [key] rendered as a string, or "" when absent, null, or not a primitive. */ +internal fun JsonObject.stringOrEmpty(key: String): String = (this[key] as? JsonPrimitive)?.contentOrNull ?: "" + +/** The value at [key] as a boolean, or false when absent or not a boolean. */ +internal fun JsonObject.booleanOrFalse(key: String): Boolean = (this[key] as? JsonPrimitive)?.booleanOrNull ?: false + +/** A copy of this envelope with [key] set to [value] ([JsonObject] is immutable). */ +internal fun JsonObject.withString( + key: String, + value: String, +): JsonObject = JsonObject(this + (key to JsonPrimitive(value))) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 56d2856887..4ae16ef661 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -64,7 +64,7 @@ import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.quartz.utils.Log -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject import java.io.ByteArrayOutputStream import java.lang.ref.WeakReference import java.util.concurrent.Executor @@ -616,13 +616,13 @@ class NappletBrowserActivity : ComponentActivity() { if (!isMainFrame) return bridgeReplyProxy = replyProxy val raw = message.data ?: return - val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return + val envelope = parseJsonObject(raw) ?: return val scheme = sourceOrigin.scheme ?: return val host = sourceOrigin.host ?: return val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" - val id = envelope.optString("id").ifEmpty { "fire-${fireSeq++}" } + val id = envelope.stringOrEmpty("id").ifEmpty { "fire-${fireSeq++}" } val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { replyTo = replyMessenger @@ -667,8 +667,7 @@ class NappletBrowserActivity : ComponentActivity() { NappletIpc.MSG_RESPONSE -> { val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() - result.put("id", id) + val result = (parseJsonObject(payload) ?: JsonObject(emptyMap())).withString("id", id) bridgeReplyProxy?.postMessage(result.toString()) } NappletIpc.MSG_PUSH -> { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 72eaf08f17..f165fe8c39 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -56,7 +56,7 @@ import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.quartz.utils.Log -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject import java.io.ByteArrayOutputStream /** @@ -591,10 +591,10 @@ class NappletBrowserService : Service() { if (!isMainFrame) return tab.bridgeReplyProxy = replyProxy val raw = message.data ?: return - val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return + val envelope = parseJsonObject(raw) ?: return // IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client. - if (envelope.optString("type").startsWith("ime.")) { + if (envelope.stringOrEmpty("type").startsWith("ime.")) { val reply = Message.obtain(null, NappletBrowserContract.MSG_IME_EVENT).apply { data = Bundle().apply { putString(NappletBrowserContract.KEY_IME_PAYLOAD, raw) } @@ -607,7 +607,7 @@ class NappletBrowserService : Service() { val host = sourceOrigin.host ?: return val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" - val id = envelope.optString("id").ifEmpty { "fire-${tab.fireSeq++}" } + val id = envelope.stringOrEmpty("id").ifEmpty { "fire-${tab.fireSeq++}" } val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { replyTo = tab.replyMessenger @@ -716,8 +716,7 @@ class NappletBrowserService : Service() { NappletIpc.MSG_RESPONSE -> { val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() - result.put("id", id) + val result = (parseJsonObject(payload) ?: JsonObject(emptyMap())).withString("id", id) runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) } } NappletIpc.MSG_PUSH -> { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFaviconSniffer.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFaviconSniffer.kt index 926ddd7e86..b5348fdf4d 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFaviconSniffer.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFaviconSniffer.kt @@ -26,7 +26,6 @@ import android.util.Base64 import android.webkit.WebView import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.quartz.utils.Log -import org.json.JSONObject /** * Declared-favicon capture for the sandboxed browser WebViews, complementing @@ -111,9 +110,9 @@ internal object NappletFaviconSniffer { /** `state` to base64 payload, or null when the page has not produced a result for this seq yet. */ private fun parse(raw: String?): Pair? { if (raw == null || raw == "null") return null - val json = runCatching { JSONObject(raw) }.getOrNull() ?: return null - val state = json.optString("state").ifBlank { return null } - return state to json.optString("data") + val json = parseJsonObject(raw) ?: return null + val state = json.stringOrEmpty("state").ifBlank { return null } + return state to json.stringOrEmpty("data") } /** diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index df6c826a07..7d9e68a53a 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -78,7 +78,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject import java.lang.ref.WeakReference import java.util.concurrent.Executor import com.vitorpamplona.amethyst.commons.R as CommonsR @@ -744,17 +744,17 @@ class NappletHostActivity : ComponentActivity() { val raw = message.data ?: return // The applet sends a full upstream envelope {type, id, ...}; we forward it verbatim and // correlate on its id. The broker reads `type` to decode and to build the .result reply. - val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return + val envelope = parseJsonObject(raw) ?: return // Unbind a keyboard action as soon as the applet drops it (the broker's Done reply carries no // actionId, so the binding is removed here from the envelope itself). - if (envelope.optString("type") == "keys.unregisterAction") { - envelope.optString("actionId").takeIf { it.isNotEmpty() }?.let { keyActions.unregister(it) } + if (envelope.stringOrEmpty("type") == "keys.unregisterAction") { + envelope.stringOrEmpty("actionId").takeIf { it.isNotEmpty() }?.let { keyActions.unregister(it) } } // Fire-and-forget messages (inc.emit, keys.unregisterAction) have no id; synthesize one so // they still reach the broker. Any reply is harmless — the applet has nothing to correlate. - val id = envelope.optString("id").ifEmpty { "fire-${fireSeq++}" } + val id = envelope.stringOrEmpty("id").ifEmpty { "fire-${fireSeq++}" } val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { @@ -791,13 +791,12 @@ class NappletHostActivity : ComponentActivity() { val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true // payload is the broker's {type:"...result", ok, ...}; inject the correlation id for the shim. - val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() - result.put("id", id) + val result = (parseJsonObject(payload) ?: JsonObject(emptyMap())).withString("id", id) // The broker authorized a keyboard action: bind the honored key combo so dispatchKeyEvent // can fire it. Only ok'd registrations bind (a denied KEYS request never reaches here). - if (result.optString("type") == "keys.registerAction.result" && result.optBoolean("ok")) { - val actionId = result.optString("actionId") - if (actionId.isNotEmpty()) keyActions.register(actionId, result.optString("binding").ifEmpty { null }) + if (result.stringOrEmpty("type") == "keys.registerAction.result" && result.booleanOrFalse("ok")) { + val actionId = result.stringOrEmpty("actionId") + if (actionId.isNotEmpty()) keyActions.register(actionId, result.stringOrEmpty("binding").ifEmpty { null }) } notifyIfSensitive(result) bridgeReplyProxy?.postMessage(result.toString()) @@ -989,10 +988,10 @@ class NappletHostActivity : ComponentActivity() { * Surfaces an "allow always" capability acting on the user's behalf, so a granted RELAY/UPLOAD/VALUE * op can never run completely silently. Read-only ops (identity/storage/resource) don't toast. */ - private fun notifyIfSensitive(result: JSONObject) { - if (!result.optBoolean("ok")) return + private fun notifyIfSensitive(result: JsonObject) { + if (!result.booleanOrFalse("ok")) return val message = - when (result.optString("type")) { + when (result.stringOrEmpty("type")) { "relay.publish.result", "relay.publishEncrypted.result" -> getString(R.string.napplet_action_published, barTitle()) "upload.upload.result" -> getString(R.string.napplet_action_uploaded, barTitle()) "value.payInvoice.result" -> getString(R.string.napplet_action_paid, barTitle()) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index 62c44a0dfa..c1a7996d08 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -62,7 +62,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject import java.io.ByteArrayOutputStream import java.util.concurrent.Executor @@ -583,10 +583,10 @@ class NappletHostService : Service() { tab.bridgeReplyProxy = replyProxy val raw = message.data ?: return - val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return + val envelope = parseJsonObject(raw) ?: return // IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client. - if (envelope.optString("type").startsWith("ime.")) { + if (envelope.stringOrEmpty("type").startsWith("ime.")) { val reply = Message.obtain(null, NappletEmbedContract.MSG_IME_EVENT).apply { data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, raw) } @@ -595,7 +595,7 @@ class NappletHostService : Service() { return } - val id = envelope.optString("id").ifEmpty { "fire-${tab.fireSeq++}" } + val id = envelope.stringOrEmpty("id").ifEmpty { "fire-${tab.fireSeq++}" } val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { replyTo = tab.replyMessenger @@ -630,8 +630,7 @@ class NappletHostService : Service() { NappletIpc.MSG_RESPONSE -> { val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() - result.put("id", id) + val result = (parseJsonObject(payload) ?: JsonObject(emptyMap())).withString("id", id) notifyIfSensitive(tab, result) runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) } } @@ -647,11 +646,11 @@ class NappletHostService : Service() { /** Pushes a notice to the main process for a granted "allow always" sensitive op, so it can toast. */ private fun notifyIfSensitive( tab: NappletTab, - result: JSONObject, + result: JsonObject, ) { - if (!result.optBoolean("ok")) return + if (!result.booleanOrFalse("ok")) return val notice = - when (result.optString("type")) { + when (result.stringOrEmpty("type")) { "relay.publish.result", "relay.publishEncrypted.result" -> NappletEmbedContract.NOTICE_PUBLISHED "upload.upload.result" -> NappletEmbedContract.NOTICE_UPLOADED "value.payInvoice.result" -> NappletEmbedContract.NOTICE_PAID diff --git a/tools/ime-test/README.md b/tools/ime-test/README.md index 50c6af04fa..458b9ff8a6 100644 --- a/tools/ime-test/README.md +++ b/tools/ime-test/README.md @@ -73,12 +73,11 @@ node shim-events.mjs /path/to/other/shim.js # diff a candidate against it Set `CHROMIUM_PATH` if your Chromium lives somewhere other than `/opt/pw-browsers/chromium-1194/chrome-linux/chrome`. -**Why this and not a JVM unit test.** The host-side parser -(`parseImeEvent`) runs on Android's `org.json`, which the unit tests stub out -(`unitTests.isReturnDefaultValues = true` in `amethyst/build.gradle.kts`, and -there is no Robolectric); a Kotlin test would "pass" without parsing anything. -The half worth protecting is the page↔host contract, and that only exists in a -browser. +**Why this and not a JVM unit test.** The half worth protecting is the +page↔host contract — real browser focus/gesture behavior and the envelopes the +shim emits for it — and that only exists in a browser. A JVM test of the +host-side parser (`parseImeEvent`, kotlinx.serialization) would only re-parse +envelopes the test itself fabricated. ## `perf.html` — why does the embed feel slower than the full-screen browser? diff --git a/tools/ime-test/shim-events.mjs b/tools/ime-test/shim-events.mjs index 14882505a7..acb5a62dc1 100644 --- a/tools/ime-test/shim-events.mjs +++ b/tools/ime-test/shim-events.mjs @@ -2,9 +2,9 @@ // // Loads the REAL shim (commons/src/commonMain/composeResources/files/napplet/shim.js) into real Chromium // with the embedded-surface flags set, drives genuine focus/tap/blur gestures, and asserts the `ime.*` -// envelopes it emits. This is the only honest automated coverage for this code: the host-side parser runs -// on Android's `org.json`, which the JVM unit tests stub out (`unitTests.isReturnDefaultValues = true`), so -// a Kotlin test of it would pass without parsing anything. +// envelopes it emits. This is the only honest automated coverage for this code: the half worth protecting +// is the page↔host contract (real browser focus/gesture behavior), which no JVM unit test of the host-side +// parser (`parseImeEvent`, kotlinx.serialization) can exercise. // // cd tools/ime-test && npm i playwright-core && node shim-events.mjs //