diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt index 838c3c4815..8d68c8eaaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt @@ -20,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. */ diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridgeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridgeTest.kt new file mode 100644 index 0000000000..1724252f94 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridgeTest.kt @@ -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) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt index 918cab2458..a47ad9aeb9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt @@ -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): 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() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/JsonTreeUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/JsonTreeUtils.kt new file mode 100644 index 0000000000..0bb787bad9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/JsonTreeUtils.kt @@ -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))) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/JsonEnvelope.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/JsonEnvelope.kt deleted file mode 100644 index dba3febff0..0000000000 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/JsonEnvelope.kt +++ /dev/null @@ -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))) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 4ae16ef661..8780d82a73 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -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 -> { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index f165fe8c39..474de861fd 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -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 -> { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFaviconSniffer.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFaviconSniffer.kt index b5348fdf4d..3030056d5a 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFaviconSniffer.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFaviconSniffer.kt @@ -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? { 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() } /** diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index 7d9e68a53a..27a71f864b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -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()) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index c1a7996d08..5c73f25c81 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -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