mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+56
-36
@@ -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. */
|
||||||
|
|||||||
+16
-8
@@ -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()
|
||||||
|
|||||||
+13
-10
@@ -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() {
|
||||||
|
|||||||
@@ -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)))
|
||||||
+4
-5
@@ -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 -> {
|
||||||
|
|||||||
+5
-6
@@ -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 -> {
|
||||||
|
|||||||
+3
-4
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+12
-13
@@ -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())
|
||||||
|
|||||||
+8
-9
@@ -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
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user