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
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. */
@@ -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<String>) {
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()
@@ -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() {
+3
View File
@@ -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)
@@ -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.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 -> {
@@ -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 -> {
@@ -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<String, String>? {
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")
}
/**
@@ -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())
@@ -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
+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
`/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?
+3 -3
View File
@@ -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
//