refactor: consolidate JsonObject tree accessors and pin ime envelope parsing

Follow-ups from the branch audit:

- Adds commons/util/JsonTreeUtils.kt: one shared set of total, null-safe
  JsonObject accessors (parseJsonObjectOrNull, stringOrNull, intOrNull,
  longOrNull, doubleOrNull, booleanOrNull, objectOrNull, withString) for
  ad-hoc JSON trees. Replaces the two near-identical private sets this
  branch had introduced (nappletHost's JsonEnvelope.kt, now deleted, and
  EmbeddedImeBridge's file-local helpers) and FeedDefinitionSerializer's
  identical bool/int/long copies. FeedDefinitionSerializer keeps its
  deliberately stricter isString-guarded string(), now documented, and
  NappletProtocolJson keeps its throwing accessors (rejecting malformed
  input at the trust boundary is its job). Quartz's copies stay: quartz
  cannot depend on commons.
- Adds EmbeddedImeBridgeTest (16 JVM tests) pinning parseImeEvent /
  parseSelectionGeometry: per-event parsing, defaulting of absent fields,
  the total-accessor behavior for mistyped fields, and the ime.resync
  envelope. This parser became JVM-testable when it moved off Android's
  org.json; the browser suite in tools/ime-test still owns the page side.

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 14:29:28 +00:00
parent 2415565ebf
commit 2aa4a43337
10 changed files with 377 additions and 138 deletions
@@ -20,45 +20,33 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import kotlinx.serialization.json.Json
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
import com.vitorpamplona.amethyst.commons.util.doubleOrNull
import com.vitorpamplona.amethyst.commons.util.intOrNull
import com.vitorpamplona.amethyst.commons.util.objectOrNull
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
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? {
if (o == null) return null
return SelectionGeometry(
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,
left = (o.doubleOrNull("l") ?: 0.0).toFloat(),
top = (o.doubleOrNull("t") ?: 0.0).toFloat(),
right = (o.doubleOrNull("r") ?: 0.0).toFloat(),
bottom = (o.doubleOrNull("b") ?: 0.0).toFloat(),
startX = (o.doubleOrNull("sx") ?: 0.0).toFloat(),
startBottom = (o.doubleOrNull("sb") ?: 0.0).toFloat(),
endX = (o.doubleOrNull("ex") ?: 0.0).toFloat(),
endBottom = (o.doubleOrNull("eb") ?: 0.0).toFloat(),
viewportWidth = (o.doubleOrNull("vw") ?: 0.0).toFloat(),
caretX = o.doubleOrNull("cx")?.toFloat(),
caretTop = o.doubleOrNull("ct")?.toFloat(),
caretBottom = o.doubleOrNull("cb")?.toFloat(),
isRange = o.booleanOrNull("rng") ?: false,
)
}
@@ -86,41 +74,41 @@ fun EmbeddedImeBridge.requestImeResync() = sendImeOp(buildJsonObject { put("type
/** Parses one page → host `ime.*` envelope into an [ImeEvent], or null for anything unrecognized. */
fun parseImeEvent(payload: String): ImeEvent? {
val o = runCatching { Json.parseToJsonElement(payload) as? JsonObject }.getOrNull() ?: return null
return when (o.string("type")) {
val o = parseJsonObjectOrNull(payload) ?: return null
return when (o.stringOrNull("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.string("text") ?: "",
selStart = o.int("selStart") ?: 0,
selEnd = o.int("selEnd") ?: 0,
geometry = parseSelectionGeometry(o.obj("geom")),
text = o.stringOrNull("text") ?: "",
selStart = o.intOrNull("selStart") ?: 0,
selEnd = o.intOrNull("selEnd") ?: 0,
geometry = parseSelectionGeometry(o.objectOrNull("geom")),
)
"ime.pagesel" ->
ImeEvent.PageSelection(
active = o.bool("active") ?: false,
text = o.string("text") ?: "",
geometry = parseSelectionGeometry(o.obj("geom")),
active = o.booleanOrNull("active") ?: false,
text = o.stringOrNull("text") ?: "",
geometry = parseSelectionGeometry(o.objectOrNull("geom")),
)
"ime.scroll" -> ImeEvent.Scroll(active = o.bool("active") ?: false)
"ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.obj("geom")))
"ime.scroll" -> ImeEvent.Scroll(active = o.booleanOrNull("active") ?: false)
"ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.objectOrNull("geom")))
else -> null
}
}
private fun parseFocus(o: JsonObject) =
ImeEvent.Focus(
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")),
inputType = o.stringOrNull("inputType") ?: "text",
enterKeyHint = o.stringOrNull("enterKeyHint") ?: "",
multiline = o.booleanOrNull("multiline") ?: false,
readOnly = o.booleanOrNull("readOnly") ?: false,
text = o.stringOrNull("text") ?: "",
selStart = o.intOrNull("selStart") ?: 0,
selEnd = o.intOrNull("selEnd") ?: 0,
geometry = parseSelectionGeometry(o.objectOrNull("geom")),
)
/** What the focused page field reports up to the host keyboard. */
@@ -0,0 +1,217 @@
/*
* 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.ui.screen.loggedIn.embed
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the host-side half of the page↔host IME contract: how [parseImeEvent] and
* [parseSelectionGeometry] read the shim's `ime.*` envelopes, including the defaulting
* behavior for absent/mistyped fields the kotlinx.serialization migration settled on
* (total accessors — a field the shim never sent, or sent malformed, degrades to the
* documented default instead of throwing). The page-side half (which envelopes real
* browser gestures produce) lives in `tools/ime-test/shim-events.mjs`.
*/
class EmbeddedImeBridgeTest {
private fun geom(raw: String) = parseSelectionGeometry(Json.parseToJsonElement(raw).jsonObject)
// ---- malformed / unrecognized payloads ----
@Test
fun rejectsPayloadsThatAreNotJsonObjects() {
assertNull(parseImeEvent("not json"))
assertNull(parseImeEvent(""))
assertNull(parseImeEvent("[1,2]"))
assertNull(parseImeEvent("\"ime.blur\""))
assertNull(parseImeEvent("42"))
assertNull(parseImeEvent("null"))
}
@Test
fun rejectsUnknownOrMissingType() {
assertNull(parseImeEvent("""{"type":"ime.unknown"}"""))
assertNull(parseImeEvent("""{"id":"1"}"""))
assertNull(parseImeEvent("""{"type":7}""")) // coerces to "7", which matches nothing
}
// ---- payload-free events ----
@Test
fun parsesPayloadFreeEvents() {
assertEquals(ImeEvent.WantKeyboard, parseImeEvent("""{"type":"ime.wantkb"}"""))
assertEquals(ImeEvent.Blur, parseImeEvent("""{"type":"ime.blur"}"""))
}
// ---- focus / refocus ----
@Test
fun parsesFocusWithAllFields() {
val event =
parseImeEvent(
"""{"type":"ime.focus","inputType":"email","enterKeyHint":"send","multiline":true,
"readOnly":true,"text":"gm","selStart":1,"selEnd":2,
"geom":{"l":1,"t":2,"r":3,"b":4,"sx":5,"sb":6,"ex":7,"eb":8,"vw":360}}""",
) as ImeEvent.Focus
assertEquals("email", event.inputType)
assertEquals("send", event.enterKeyHint)
assertTrue(event.multiline)
assertTrue(event.readOnly)
assertEquals("gm", event.text)
assertEquals(1, event.selStart)
assertEquals(2, event.selEnd)
assertEquals(360f, event.geometry!!.viewportWidth)
}
@Test
fun focusDefaultsEveryAbsentField() {
val event = parseImeEvent("""{"type":"ime.focus"}""") as ImeEvent.Focus
assertEquals("text", event.inputType)
assertEquals("", event.enterKeyHint)
assertFalse(event.multiline)
assertFalse(event.readOnly)
assertEquals("", event.text)
assertEquals(0, event.selStart)
assertEquals(0, event.selEnd)
assertNull(event.geometry)
}
@Test
fun refocusWrapsAFocus() {
val event = parseImeEvent("""{"type":"ime.refocus","inputType":"url","text":"a"}""") as ImeEvent.ReFocus
assertEquals("url", event.focus.inputType)
assertEquals("a", event.focus.text)
}
// ---- state / pagesel / scroll / carettap ----
@Test
fun parsesState() {
val event = parseImeEvent("""{"type":"ime.state","text":"abc","selStart":1,"selEnd":3}""") as ImeEvent.State
assertEquals("abc", event.text)
assertEquals(1, event.selStart)
assertEquals(3, event.selEnd)
assertNull(event.geometry)
}
@Test
fun stateToleratesExplicitNullGeom() {
val event = parseImeEvent("""{"type":"ime.state","text":"a","selStart":0,"selEnd":0,"geom":null}""") as ImeEvent.State
assertNull(event.geometry)
}
@Test
fun parsesPageSelection() {
val event =
parseImeEvent(
"""{"type":"ime.pagesel","active":true,"text":"copied",
"geom":{"l":0,"t":0,"r":10,"b":10,"sx":0,"sb":10,"ex":10,"eb":10,"vw":360}}""",
) as ImeEvent.PageSelection
assertTrue(event.active)
assertEquals("copied", event.text)
assertEquals(10f, event.geometry!!.right)
}
@Test
fun parsesScroll() {
assertTrue((parseImeEvent("""{"type":"ime.scroll","active":true}""") as ImeEvent.Scroll).active)
assertFalse((parseImeEvent("""{"type":"ime.scroll"}""") as ImeEvent.Scroll).active)
}
@Test
fun parsesCaretTap() {
val event =
parseImeEvent(
"""{"type":"ime.carettap","geom":{"l":1,"t":2,"r":3,"b":4,"sx":1,"sb":4,"ex":3,"eb":4,"vw":360}}""",
) as ImeEvent.CaretTap
assertEquals(1f, event.geometry!!.left)
assertNull((parseImeEvent("""{"type":"ime.carettap"}""") as ImeEvent.CaretTap).geometry)
}
// ---- geometry ----
@Test
fun geometryNullForAbsentObject() {
assertNull(parseSelectionGeometry(null))
}
@Test
fun geometryDefaultsAbsentFieldsToZero() {
val g = geom("""{}""")!!
assertEquals(0f, g.left)
assertEquals(0f, g.viewportWidth)
assertFalse(g.isRange)
}
@Test
fun geometryCaretFieldsAreNullOnlyWhenAbsent() {
val without = geom("""{"l":1}""")!!
assertNull(without.caretX)
assertNull(without.caretTop)
assertNull(without.caretBottom)
val with = geom("""{"cx":10.5,"ct":20,"cb":30,"rng":true}""")!!
assertEquals(10.5f, with.caretX)
assertEquals(20f, with.caretTop)
assertEquals(30f, with.caretBottom)
assertTrue(with.isRange)
}
// ---- coercion behavior for mistyped fields (deliberate: total accessors, no throwing) ----
@Test
fun mistypedFieldsDegradeToDefaultsInsteadOfThrowing() {
val event =
parseImeEvent(
// selStart as a numeric string parses; selEnd as a fraction does NOT truncate
// (unlike org.json's optInt) — it falls back to 0; text as a number coerces to
// its literal text; readOnly as the string "true" parses as a boolean.
"""{"type":"ime.focus","text":7,"selStart":"5","selEnd":5.7,"readOnly":"true","geom":[1,2]}""",
) as ImeEvent.Focus
assertEquals("7", event.text)
assertEquals(5, event.selStart)
assertEquals(0, event.selEnd)
assertTrue(event.readOnly)
assertNull(event.geometry) // an array where an object belongs is treated as absent
}
// ---- host → page envelopes ----
@Test
fun resyncRequestSendsTheBareEnvelope() {
var sent: String? = null
val bridge =
object : EmbeddedImeBridge {
override var onImeEvent: ((ImeEvent) -> Unit)? = null
override fun sendImeOp(json: String) {
sent = json
}
}
bridge.requestImeResync()
assertEquals("""{"type":"ime.resync"}""", sent)
}
}
@@ -20,17 +20,18 @@
*/
package com.vitorpamplona.amethyst.commons.feeds.custom
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
import com.vitorpamplona.amethyst.commons.util.intOrNull
import com.vitorpamplona.amethyst.commons.util.longOrNull
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.intOrNull as primitiveIntOrNull
object FeedDefinitionSerializer {
private val json = Json { ignoreUnknownKeys = true }
@@ -65,8 +66,8 @@ object FeedDefinitionSerializer {
val id = node.string("id") ?: return null
val name = node.string("name") ?: return null
val emoji = node.string("emoji") ?: ""
val pinned = node.bool("pinned") ?: false
val pinOrder = node.int("pinOrder") ?: Int.MAX_VALUE
val pinned = node.booleanOrNull("pinned") ?: false
val pinOrder = node.intOrNull("pinOrder") ?: Int.MAX_VALUE
val refreshMode =
node.string("refreshMode")?.let {
try {
@@ -75,7 +76,7 @@ object FeedDefinitionSerializer {
RefreshMode.LIVE_STREAM
}
} ?: RefreshMode.LIVE_STREAM
val createdAt = node.long("createdAt") ?: 0L
val createdAt = node.longOrNull("createdAt") ?: 0L
val source = (node["source"] as? JsonObject)?.let { deserializeSource(it) } ?: return null
return FeedDefinition(
@@ -158,7 +159,7 @@ object FeedDefinitionSerializer {
"people_list" -> {
FeedSource.PeopleList(
kind = node.int("kind") ?: 30000,
kind = node.intOrNull("kind") ?: 30000,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
@@ -166,7 +167,7 @@ object FeedDefinitionSerializer {
"interest_set" -> {
FeedSource.InterestSet(
kind = node.int("kind") ?: 30015,
kind = node.intOrNull("kind") ?: 30015,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
@@ -174,7 +175,7 @@ object FeedDefinitionSerializer {
"dvm" -> {
FeedSource.DVM(
kind = node.int("kind") ?: 31990,
kind = node.intOrNull("kind") ?: 31990,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
@@ -194,14 +195,10 @@ object FeedDefinitionSerializer {
private fun stringArray(values: Iterable<String>): JsonArray = buildJsonArray { values.forEach { add(JsonPrimitive(it)) } }
// Deliberately stricter than the shared stringOrNull: a persisted feed field must be an actual
// quoted string — a number or boolean here is a corrupt record, not a value to coerce.
private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull
private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull
private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
private fun JsonObject.stringList(key: String) =
(this[key] as? JsonArray)
?.map { (it as? JsonPrimitive)?.content.orEmpty() }
@@ -209,6 +206,6 @@ object FeedDefinitionSerializer {
private fun JsonObject.intList(key: String) =
(this[key] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.intOrNull }
?.mapNotNull { (it as? JsonPrimitive)?.primitiveIntOrNull }
?.toImmutableList()
}
@@ -0,0 +1,67 @@
/*
* 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.commons.util
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
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
// Total accessors for ad-hoc JSON trees (kotlinx.serialization) — bridge envelopes, persisted
// blobs, evaluateJavascript results. Every field is optional and possibly attacker-controlled,
// so each accessor degrades to null instead of throwing on an absent, JSON-null, or mistyped
// value. A codec that WANTS to reject malformed input (e.g. NappletProtocolJson) should keep
// throwing accessors instead of these.
/** Parses [raw] as a JSON object, or null when it is malformed or not an object. */
fun parseJsonObjectOrNull(raw: String): JsonObject? = runCatching { Json.parseToJsonElement(raw) as? JsonObject }.getOrNull()
/**
* The primitive at [key] rendered as a string, or null when absent, JSON-null, or not a
* primitive. Numbers and booleans coerce to their literal text (org.json `optString` style);
* use a `isString`-guarded read instead where a quoted string must be told apart from them.
*/
fun JsonObject.stringOrNull(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull
/** The primitive at [key] as an Int, or null when absent or not parseable as one. */
fun JsonObject.intOrNull(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull
/** The primitive at [key] as a Long, or null when absent or not parseable as one. */
fun JsonObject.longOrNull(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
/** The primitive at [key] as a Double, or null when absent or not parseable as one. */
fun JsonObject.doubleOrNull(key: String): Double? = (this[key] as? JsonPrimitive)?.doubleOrNull
/** The primitive at [key] as a Boolean, or null when absent or not parseable as one. */
fun JsonObject.booleanOrNull(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull
/** The nested object at [key], or null when absent or not an object. */
fun JsonObject.objectOrNull(key: String): JsonObject? = this[key] as? JsonObject
/** A copy of this object with [key] set to [value] ([JsonObject] is immutable). */
fun JsonObject.withString(
key: String,
value: String,
): JsonObject = JsonObject(this + (key to JsonPrimitive(value)))
@@ -1,46 +0,0 @@
/*
* 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)))
@@ -63,6 +63,9 @@ import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import com.vitorpamplona.amethyst.commons.util.withString
import com.vitorpamplona.quartz.utils.Log
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
@@ -616,13 +619,13 @@ class NappletBrowserActivity : ComponentActivity() {
if (!isMainFrame) return
bridgeReplyProxy = replyProxy
val raw = message.data ?: return
val envelope = parseJsonObject(raw) ?: return
val envelope = parseJsonObjectOrNull(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.stringOrEmpty("id").ifEmpty { "fire-${fireSeq++}" }
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = replyMessenger
@@ -667,7 +670,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 = (parseJsonObject(payload) ?: JsonObject(emptyMap())).withString("id", id)
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
bridgeReplyProxy?.postMessage(result.toString())
}
NappletIpc.MSG_PUSH -> {
@@ -55,6 +55,9 @@ import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import com.vitorpamplona.amethyst.commons.util.withString
import com.vitorpamplona.quartz.utils.Log
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
@@ -591,10 +594,10 @@ class NappletBrowserService : Service() {
if (!isMainFrame) return
tab.bridgeReplyProxy = replyProxy
val raw = message.data ?: return
val envelope = parseJsonObject(raw) ?: return
val envelope = parseJsonObjectOrNull(raw) ?: return
// IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client.
if (envelope.stringOrEmpty("type").startsWith("ime.")) {
if (envelope.stringOrNull("type").orEmpty().startsWith("ime.")) {
val reply =
Message.obtain(null, NappletBrowserContract.MSG_IME_EVENT).apply {
data = Bundle().apply { putString(NappletBrowserContract.KEY_IME_PAYLOAD, raw) }
@@ -607,7 +610,7 @@ class NappletBrowserService : Service() {
val host = sourceOrigin.host ?: return
val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else ""
val id = envelope.stringOrEmpty("id").ifEmpty { "fire-${tab.fireSeq++}" }
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = tab.replyMessenger
@@ -716,7 +719,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 = (parseJsonObject(payload) ?: JsonObject(emptyMap())).withString("id", id)
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) }
}
NappletIpc.MSG_PUSH -> {
@@ -25,6 +25,8 @@ import android.os.Looper
import android.util.Base64
import android.webkit.WebView
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import com.vitorpamplona.quartz.utils.Log
/**
@@ -110,9 +112,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 = parseJsonObject(raw) ?: return null
val state = json.stringOrEmpty("state").ifBlank { return null }
return state to json.stringOrEmpty("data")
val json = parseJsonObjectOrNull(raw) ?: return null
val state = json.stringOrNull("state")?.ifBlank { null } ?: return null
return state to json.stringOrNull("data").orEmpty()
}
/**
@@ -64,6 +64,10 @@ import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import com.vitorpamplona.amethyst.commons.util.withString
import com.vitorpamplona.amethyst.napplethost.R
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
@@ -744,17 +748,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 = parseJsonObject(raw) ?: return
val envelope = parseJsonObjectOrNull(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.stringOrEmpty("type") == "keys.unregisterAction") {
envelope.stringOrEmpty("actionId").takeIf { it.isNotEmpty() }?.let { keyActions.unregister(it) }
if (envelope.stringOrNull("type") == "keys.unregisterAction") {
envelope.stringOrNull("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.stringOrEmpty("id").ifEmpty { "fire-${fireSeq++}" }
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
@@ -791,12 +795,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 = (parseJsonObject(payload) ?: JsonObject(emptyMap())).withString("id", id)
val result = (parseJsonObjectOrNull(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.stringOrEmpty("type") == "keys.registerAction.result" && result.booleanOrFalse("ok")) {
val actionId = result.stringOrEmpty("actionId")
if (actionId.isNotEmpty()) keyActions.register(actionId, result.stringOrEmpty("binding").ifEmpty { null })
if (result.stringOrNull("type") == "keys.registerAction.result" && result.booleanOrNull("ok") == true) {
val actionId = result.stringOrNull("actionId").orEmpty()
if (actionId.isNotEmpty()) keyActions.register(actionId, result.stringOrNull("binding")?.ifEmpty { null })
}
notifyIfSensitive(result)
bridgeReplyProxy?.postMessage(result.toString())
@@ -989,9 +993,9 @@ class NappletHostActivity : ComponentActivity() {
* op can never run completely silently. Read-only ops (identity/storage/resource) don't toast.
*/
private fun notifyIfSensitive(result: JsonObject) {
if (!result.booleanOrFalse("ok")) return
if (result.booleanOrNull("ok") != true) return
val message =
when (result.stringOrEmpty("type")) {
when (result.stringOrNull("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())
@@ -58,6 +58,10 @@ import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import com.vitorpamplona.amethyst.commons.util.withString
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.Log
@@ -583,10 +587,10 @@ class NappletHostService : Service() {
tab.bridgeReplyProxy = replyProxy
val raw = message.data ?: return
val envelope = parseJsonObject(raw) ?: return
val envelope = parseJsonObjectOrNull(raw) ?: return
// IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client.
if (envelope.stringOrEmpty("type").startsWith("ime.")) {
if (envelope.stringOrNull("type").orEmpty().startsWith("ime.")) {
val reply =
Message.obtain(null, NappletEmbedContract.MSG_IME_EVENT).apply {
data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, raw) }
@@ -595,7 +599,7 @@ class NappletHostService : Service() {
return
}
val id = envelope.stringOrEmpty("id").ifEmpty { "fire-${tab.fireSeq++}" }
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = tab.replyMessenger
@@ -630,7 +634,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 = (parseJsonObject(payload) ?: JsonObject(emptyMap())).withString("id", id)
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
notifyIfSensitive(tab, result)
runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) }
}
@@ -648,9 +652,9 @@ class NappletHostService : Service() {
tab: NappletTab,
result: JsonObject,
) {
if (!result.booleanOrFalse("ok")) return
if (result.booleanOrNull("ok") != true) return
val notice =
when (result.stringOrEmpty("type")) {
when (result.stringOrNull("type")) {
"relay.publish.result", "relay.publishEncrypted.result" -> NappletEmbedContract.NOTICE_PUBLISHED
"upload.upload.result" -> NappletEmbedContract.NOTICE_UPLOADED
"value.payInvoice.result" -> NappletEmbedContract.NOTICE_PAID