mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+38
-50
@@ -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. */
|
||||
|
||||
+217
@@ -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)
|
||||
}
|
||||
}
|
||||
+13
-16
@@ -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()
|
||||
}
|
||||
|
||||
+67
@@ -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)))
|
||||
+6
-3
@@ -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 -> {
|
||||
|
||||
+7
-4
@@ -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 -> {
|
||||
|
||||
+5
-3
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+14
-10
@@ -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())
|
||||
|
||||
+10
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user