refactor: replace org.json with kotlinx.serialization in source files

Sweeps the last org.json usages out of the Kotlin sources and moves them
to kotlinx.serialization's JSON tree API (already the project standard):

- nappletHost: bridge/broker envelope handling in NappletHostActivity,
  NappletHostService, NappletBrowserActivity, NappletBrowserService and
  NappletFaviconSniffer now parses with Json.parseToJsonElement via new
  total helpers in JsonEnvelope.kt (absent/mistyped fields degrade to
  empty/false instead of throwing, matching the old opt* semantics).
  Adds the kotlinx-serialization-json runtime to the module (tree API
  only, so no serialization plugin needed).
- amethyst embed IME relay: EmbeddedImeBridge parses ime.* envelopes
  with JsonObject accessors; RemoteImeView and EmbeddedTabLayer build
  their outgoing envelopes with buildJsonObject.
- tools/ime-test: drops the now-stale "org.json is stubbed in JVM unit
  tests" rationale from the README and shim-events.mjs header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVcZwp65oybotmq5o66foW
This commit is contained in:
Claude
2026-08-30 05:52:29 +00:00
parent a370b1d8c5
commit 2415565ebf
12 changed files with 174 additions and 100 deletions
@@ -20,25 +20,45 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed 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. */ /** 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 if (o == null) return null
return SelectionGeometry( return SelectionGeometry(
left = o.optDouble("l", 0.0).toFloat(), left = (o.double("l") ?: 0.0).toFloat(),
top = o.optDouble("t", 0.0).toFloat(), top = (o.double("t") ?: 0.0).toFloat(),
right = o.optDouble("r", 0.0).toFloat(), right = (o.double("r") ?: 0.0).toFloat(),
bottom = o.optDouble("b", 0.0).toFloat(), bottom = (o.double("b") ?: 0.0).toFloat(),
startX = o.optDouble("sx", 0.0).toFloat(), startX = (o.double("sx") ?: 0.0).toFloat(),
startBottom = o.optDouble("sb", 0.0).toFloat(), startBottom = (o.double("sb") ?: 0.0).toFloat(),
endX = o.optDouble("ex", 0.0).toFloat(), endX = (o.double("ex") ?: 0.0).toFloat(),
endBottom = o.optDouble("eb", 0.0).toFloat(), endBottom = (o.double("eb") ?: 0.0).toFloat(),
viewportWidth = o.optDouble("vw", 0.0).toFloat(), viewportWidth = (o.double("vw") ?: 0.0).toFloat(),
caretX = if (o.has("cx")) o.optDouble("cx").toFloat() else null, caretX = o.double("cx")?.toFloat(),
caretTop = if (o.has("ct")) o.optDouble("ct").toFloat() else null, caretTop = o.double("ct")?.toFloat(),
caretBottom = if (o.has("cb")) o.optDouble("cb").toFloat() else null, caretBottom = o.double("cb")?.toFloat(),
isRange = o.optBoolean("rng", false), 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 * 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. * 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. */ /** Parses one page → host `ime.*` envelope into an [ImeEvent], or null for anything unrecognized. */
fun parseImeEvent(payload: String): ImeEvent? { fun parseImeEvent(payload: String): ImeEvent? {
val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null val o = runCatching { Json.parseToJsonElement(payload) as? JsonObject }.getOrNull() ?: return null
return when (o.optString("type")) { return when (o.string("type")) {
"ime.focus" -> parseFocus(o) "ime.focus" -> parseFocus(o)
"ime.wantkb" -> ImeEvent.WantKeyboard "ime.wantkb" -> ImeEvent.WantKeyboard
"ime.refocus" -> ImeEvent.ReFocus(parseFocus(o)) "ime.refocus" -> ImeEvent.ReFocus(parseFocus(o))
"ime.blur" -> ImeEvent.Blur "ime.blur" -> ImeEvent.Blur
"ime.state" -> "ime.state" ->
ImeEvent.State( ImeEvent.State(
text = o.optString("text", ""), text = o.string("text") ?: "",
selStart = o.optInt("selStart", 0), selStart = o.int("selStart") ?: 0,
selEnd = o.optInt("selEnd", 0), selEnd = o.int("selEnd") ?: 0,
geometry = parseSelectionGeometry(o.optJSONObject("geom")), geometry = parseSelectionGeometry(o.obj("geom")),
) )
"ime.pagesel" -> "ime.pagesel" ->
ImeEvent.PageSelection( ImeEvent.PageSelection(
active = o.optBoolean("active", false), active = o.bool("active") ?: false,
text = o.optString("text", ""), text = o.string("text") ?: "",
geometry = parseSelectionGeometry(o.optJSONObject("geom")), geometry = parseSelectionGeometry(o.obj("geom")),
) )
"ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false)) "ime.scroll" -> ImeEvent.Scroll(active = o.bool("active") ?: false)
"ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom"))) "ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.obj("geom")))
else -> null else -> null
} }
} }
private fun parseFocus(o: JSONObject) = private fun parseFocus(o: JsonObject) =
ImeEvent.Focus( ImeEvent.Focus(
inputType = o.optString("inputType", "text"), inputType = o.string("inputType") ?: "text",
enterKeyHint = o.optString("enterKeyHint", ""), enterKeyHint = o.string("enterKeyHint") ?: "",
multiline = o.optBoolean("multiline", false), multiline = o.bool("multiline") ?: false,
readOnly = o.optBoolean("readOnly", false), readOnly = o.bool("readOnly") ?: false,
text = o.optString("text", ""), text = o.string("text") ?: "",
selStart = o.optInt("selStart", 0), selStart = o.int("selStart") ?: 0,
selEnd = o.optInt("selEnd", 0), selEnd = o.int("selEnd") ?: 0,
geometry = parseSelectionGeometry(o.optJSONObject("geom")), geometry = parseSelectionGeometry(o.obj("geom")),
) )
/** What the focused page field reports up to the host keyboard. */ /** What the focused page field reports up to the host keyboard. */
@@ -83,7 +83,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.privacysandbox.ui.client.view.SandboxedSdkView import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.json.JSONObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.math.roundToInt import kotlin.math.roundToInt
// How far off-screen a parked (inactive) warm tab is shifted — well past any real screen width. // 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, cssX: Float,
cssY: Float, cssY: Float,
) = sendImeOp( ) = sendImeOp(
JSONObject() buildJsonObject {
.put("type", type) put("type", type)
.apply { if (edge != null) put("edge", edge) } if (edge != null) put("edge", edge)
.put("x", cssX.toDouble()) put("x", cssX.toDouble())
.put("y", cssY.toDouble()) put("y", cssY.toDouble())
.toString(), }.toString(),
) )
/** /**
@@ -508,7 +509,14 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
fingerPx.y > oy + bounds.height - edgeZonePx -> AUTOSCROLL_STEP_CSS fingerPx.y > oy + bounds.height - edgeZonePx -> AUTOSCROLL_STEP_CSS
else -> 0.0 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) { if (!active || magProbe == null) {
magnifier.hide() magnifier.hide()
@@ -32,7 +32,9 @@ import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputConnectionWrapper import android.view.inputmethod.InputConnectionWrapper
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.EditText 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 * 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). // The IME's "Go/Search/Send/Done" — the page submits/handles it (single-line has no newline).
setOnEditorActionListener { _, _, _ -> setOnEditorActionListener { _, _, _ ->
bridge?.sendImeOp(JSONObject().put("type", "ime.action").toString()) bridge?.sendImeOp(buildJsonObject { put("type", "ime.action") }.toString())
true true
} }
} }
@@ -365,12 +367,12 @@ class RemoteImeView(
schedule() schedule()
} }
private fun stateJson(): JSONObject { private fun stateJson(): JsonObject {
val editable = text val editable = text
val composingStart = if (editable != null) BaseInputConnection.getComposingSpanStart(editable) else -1 val composingStart = if (editable != null) BaseInputConnection.getComposingSpanStart(editable) else -1
val composingEnd = if (editable != null) BaseInputConnection.getComposingSpanEnd(editable) else -1 val composingEnd = if (editable != null) BaseInputConnection.getComposingSpanEnd(editable) else -1
return JSONObject() return buildJsonObject {
.put("type", "ime.set") put("type", "ime.set")
// A readonly field's text must never travel back to the page. TYPE_NULL keeps the *user* from // 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 // 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 // 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 // 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 // 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. // host-drawn handles and Copy keep working off a synced selection while nothing can be written.
.apply { if (!fieldReadOnly) put("text", editable?.toString() ?: "") } if (!fieldReadOnly) put("text", editable?.toString() ?: "")
.put("selStart", selectionStart) put("selStart", selectionStart)
.put("selEnd", selectionEnd) put("selEnd", selectionEnd)
.put("composingStart", composingStart) put("composingStart", composingStart)
.put("composingEnd", composingEnd) put("composingEnd", composingEnd)
}
} }
private fun flushState() { private fun flushState() {
+3
View File
@@ -44,6 +44,9 @@ dependencies {
implementation(libs.androidx.webkit) implementation(libs.androidx.webkit)
implementation(libs.okhttp) 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 // 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. // `:napplet` process and ships its rendered surface to the main app via SurfaceControlViewHost.
implementation(libs.androidx.privacysandbox.ui.core) implementation(libs.androidx.privacysandbox.ui.core)
@@ -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)))
@@ -64,7 +64,7 @@ import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import org.json.JSONObject import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.concurrent.Executor import java.util.concurrent.Executor
@@ -616,13 +616,13 @@ class NappletBrowserActivity : ComponentActivity() {
if (!isMainFrame) return if (!isMainFrame) return
bridgeReplyProxy = replyProxy bridgeReplyProxy = replyProxy
val raw = message.data ?: return val raw = message.data ?: return
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return val envelope = parseJsonObject(raw) ?: return
val scheme = sourceOrigin.scheme ?: return val scheme = sourceOrigin.scheme ?: return
val host = sourceOrigin.host ?: return val host = sourceOrigin.host ?: return
val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" 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 = val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply { Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = replyMessenger replyTo = replyMessenger
@@ -667,8 +667,7 @@ class NappletBrowserActivity : ComponentActivity() {
NappletIpc.MSG_RESPONSE -> { NappletIpc.MSG_RESPONSE -> {
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() val result = (parseJsonObject(payload) ?: JsonObject(emptyMap())).withString("id", id)
result.put("id", id)
bridgeReplyProxy?.postMessage(result.toString()) bridgeReplyProxy?.postMessage(result.toString())
} }
NappletIpc.MSG_PUSH -> { NappletIpc.MSG_PUSH -> {
@@ -56,7 +56,7 @@ import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import org.json.JSONObject import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
/** /**
@@ -591,10 +591,10 @@ class NappletBrowserService : Service() {
if (!isMainFrame) return if (!isMainFrame) return
tab.bridgeReplyProxy = replyProxy tab.bridgeReplyProxy = replyProxy
val raw = message.data ?: return 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. // 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 = val reply =
Message.obtain(null, NappletBrowserContract.MSG_IME_EVENT).apply { Message.obtain(null, NappletBrowserContract.MSG_IME_EVENT).apply {
data = Bundle().apply { putString(NappletBrowserContract.KEY_IME_PAYLOAD, raw) } data = Bundle().apply { putString(NappletBrowserContract.KEY_IME_PAYLOAD, raw) }
@@ -607,7 +607,7 @@ class NappletBrowserService : Service() {
val host = sourceOrigin.host ?: return val host = sourceOrigin.host ?: return
val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" 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 = val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply { Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = tab.replyMessenger replyTo = tab.replyMessenger
@@ -716,8 +716,7 @@ class NappletBrowserService : Service() {
NappletIpc.MSG_RESPONSE -> { NappletIpc.MSG_RESPONSE -> {
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() val result = (parseJsonObject(payload) ?: JsonObject(emptyMap())).withString("id", id)
result.put("id", id)
runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) } runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) }
} }
NappletIpc.MSG_PUSH -> { NappletIpc.MSG_PUSH -> {
@@ -26,7 +26,6 @@ import android.util.Base64
import android.webkit.WebView import android.webkit.WebView
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import org.json.JSONObject
/** /**
* Declared-favicon capture for the sandboxed browser WebViews, complementing * 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. */ /** `state` to base64 payload, or null when the page has not produced a result for this seq yet. */
private fun parse(raw: String?): Pair<String, String>? { private fun parse(raw: String?): Pair<String, String>? {
if (raw == null || raw == "null") return null if (raw == null || raw == "null") return null
val json = runCatching { JSONObject(raw) }.getOrNull() ?: return null val json = parseJsonObject(raw) ?: return null
val state = json.optString("state").ifBlank { return null } val state = json.stringOrEmpty("state").ifBlank { return null }
return state to json.optString("data") return state to json.stringOrEmpty("data")
} }
/** /**
@@ -78,7 +78,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject import kotlinx.serialization.json.JsonObject
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.concurrent.Executor import java.util.concurrent.Executor
import com.vitorpamplona.amethyst.commons.R as CommonsR import com.vitorpamplona.amethyst.commons.R as CommonsR
@@ -744,17 +744,17 @@ class NappletHostActivity : ComponentActivity() {
val raw = message.data ?: return val raw = message.data ?: return
// The applet sends a full upstream envelope {type, id, ...}; we forward it verbatim and // 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. // 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 // 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). // actionId, so the binding is removed here from the envelope itself).
if (envelope.optString("type") == "keys.unregisterAction") { if (envelope.stringOrEmpty("type") == "keys.unregisterAction") {
envelope.optString("actionId").takeIf { it.isNotEmpty() }?.let { keyActions.unregister(it) } envelope.stringOrEmpty("actionId").takeIf { it.isNotEmpty() }?.let { keyActions.unregister(it) }
} }
// Fire-and-forget messages (inc.emit, keys.unregisterAction) have no id; synthesize one so // 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. // 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 = val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply { Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
@@ -791,13 +791,12 @@ class NappletHostActivity : ComponentActivity() {
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
// payload is the broker's {type:"...result", ok, ...}; inject the correlation id for the shim. // payload is the broker's {type:"...result", ok, ...}; inject the correlation id for the shim.
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() val result = (parseJsonObject(payload) ?: JsonObject(emptyMap())).withString("id", id)
result.put("id", id)
// The broker authorized a keyboard action: bind the honored key combo so dispatchKeyEvent // 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). // 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")) { if (result.stringOrEmpty("type") == "keys.registerAction.result" && result.booleanOrFalse("ok")) {
val actionId = result.optString("actionId") val actionId = result.stringOrEmpty("actionId")
if (actionId.isNotEmpty()) keyActions.register(actionId, result.optString("binding").ifEmpty { null }) if (actionId.isNotEmpty()) keyActions.register(actionId, result.stringOrEmpty("binding").ifEmpty { null })
} }
notifyIfSensitive(result) notifyIfSensitive(result)
bridgeReplyProxy?.postMessage(result.toString()) 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 * 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. * op can never run completely silently. Read-only ops (identity/storage/resource) don't toast.
*/ */
private fun notifyIfSensitive(result: JSONObject) { private fun notifyIfSensitive(result: JsonObject) {
if (!result.optBoolean("ok")) return if (!result.booleanOrFalse("ok")) return
val message = val message =
when (result.optString("type")) { when (result.stringOrEmpty("type")) {
"relay.publish.result", "relay.publishEncrypted.result" -> getString(R.string.napplet_action_published, barTitle()) "relay.publish.result", "relay.publishEncrypted.result" -> getString(R.string.napplet_action_published, barTitle())
"upload.upload.result" -> getString(R.string.napplet_action_uploaded, barTitle()) "upload.upload.result" -> getString(R.string.napplet_action_uploaded, barTitle())
"value.payInvoice.result" -> getString(R.string.napplet_action_paid, barTitle()) "value.payInvoice.result" -> getString(R.string.napplet_action_paid, barTitle())
@@ -62,7 +62,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.sha256.sha256 import com.vitorpamplona.quartz.utils.sha256.sha256
import org.json.JSONObject import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.util.concurrent.Executor import java.util.concurrent.Executor
@@ -583,10 +583,10 @@ class NappletHostService : Service() {
tab.bridgeReplyProxy = replyProxy tab.bridgeReplyProxy = replyProxy
val raw = message.data ?: return 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. // 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 = val reply =
Message.obtain(null, NappletEmbedContract.MSG_IME_EVENT).apply { Message.obtain(null, NappletEmbedContract.MSG_IME_EVENT).apply {
data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, raw) } data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, raw) }
@@ -595,7 +595,7 @@ class NappletHostService : Service() {
return return
} }
val id = envelope.optString("id").ifEmpty { "fire-${tab.fireSeq++}" } val id = envelope.stringOrEmpty("id").ifEmpty { "fire-${tab.fireSeq++}" }
val msg = val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply { Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = tab.replyMessenger replyTo = tab.replyMessenger
@@ -630,8 +630,7 @@ class NappletHostService : Service() {
NappletIpc.MSG_RESPONSE -> { NappletIpc.MSG_RESPONSE -> {
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() val result = (parseJsonObject(payload) ?: JsonObject(emptyMap())).withString("id", id)
result.put("id", id)
notifyIfSensitive(tab, result) notifyIfSensitive(tab, result)
runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) } 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. */ /** Pushes a notice to the main process for a granted "allow always" sensitive op, so it can toast. */
private fun notifyIfSensitive( private fun notifyIfSensitive(
tab: NappletTab, tab: NappletTab,
result: JSONObject, result: JsonObject,
) { ) {
if (!result.optBoolean("ok")) return if (!result.booleanOrFalse("ok")) return
val notice = val notice =
when (result.optString("type")) { when (result.stringOrEmpty("type")) {
"relay.publish.result", "relay.publishEncrypted.result" -> NappletEmbedContract.NOTICE_PUBLISHED "relay.publish.result", "relay.publishEncrypted.result" -> NappletEmbedContract.NOTICE_PUBLISHED
"upload.upload.result" -> NappletEmbedContract.NOTICE_UPLOADED "upload.upload.result" -> NappletEmbedContract.NOTICE_UPLOADED
"value.payInvoice.result" -> NappletEmbedContract.NOTICE_PAID "value.payInvoice.result" -> NappletEmbedContract.NOTICE_PAID
+5 -6
View File
@@ -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 Set `CHROMIUM_PATH` if your Chromium lives somewhere other than
`/opt/pw-browsers/chromium-1194/chrome-linux/chrome`. `/opt/pw-browsers/chromium-1194/chrome-linux/chrome`.
**Why this and not a JVM unit test.** The host-side parser **Why this and not a JVM unit test.** The half worth protecting is the
(`parseImeEvent`) runs on Android's `org.json`, which the unit tests stub out page↔host contract — real browser focus/gesture behavior and the envelopes the
(`unitTests.isReturnDefaultValues = true` in `amethyst/build.gradle.kts`, and shim emits for it — and that only exists in a browser. A JVM test of the
there is no Robolectric); a Kotlin test would "pass" without parsing anything. host-side parser (`parseImeEvent`, kotlinx.serialization) would only re-parse
The half worth protecting is the page↔host contract, and that only exists in a envelopes the test itself fabricated.
browser.
## `perf.html` — why does the embed feel slower than the full-screen browser? ## `perf.html` — why does the embed feel slower than the full-screen browser?
+3 -3
View File
@@ -2,9 +2,9 @@
// //
// Loads the REAL shim (commons/src/commonMain/composeResources/files/napplet/shim.js) into real Chromium // 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.*` // 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 // envelopes it emits. This is the only honest automated coverage for this code: the half worth protecting
// on Android's `org.json`, which the JVM unit tests stub out (`unitTests.isReturnDefaultValues = true`), so // is the page↔host contract (real browser focus/gesture behavior), which no JVM unit test of the host-side
// a Kotlin test of it would pass without parsing anything. // parser (`parseImeEvent`, kotlinx.serialization) can exercise.
// //
// cd tools/ime-test && npm i playwright-core && node shim-events.mjs // cd tools/ime-test && npm i playwright-core && node shim-events.mjs
// //