From 94c9dc7f77528e572aa3889abb283a03b0c9e50a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 15:23:25 +0000 Subject: [PATCH 1/8] feat: parse relay LIMITS message (NIP relay limits) Adds support for the relay-to-client LIMITS frame, which advertises the connection's current rights and limits (can_read/can_write, auth requirements, max_message_length, max_subscriptions, max_filters, max_limit, POW, rate limits, required tags, etc.). Relays such as wss://pipe.imwald.eu/ send it on connect and whenever rights change, and Amethyst previously logged it as an unsupported message. - New LimitsMessage model in quartz commonMain with every optional field. - Jackson decode/encode (LimitsDeserializer + MessageSerializer) and the kotlinx-serialization path (LimitsKSerializer + MessageKSerializer). - Logs a summary line in RelayLogger. - Tests: production payload parse, array/tag fields, empty object, and round-trip parity between the Jackson and kotlinx codecs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ --- .../kotlinSerialization/LimitsKSerializer.kt | 96 +++++++++++++++++++ .../kotlinSerialization/MessageKSerializer.kt | 10 ++ .../relay/client/accessories/RelayLogger.kt | 2 + .../relay/commands/toClient/LimitsMessage.kt | 78 +++++++++++++++ .../relay/commands/RelayWireErgonomicsTest.kt | 74 ++++++++++++++ .../commands/toClient/LimitsDeserializer.kt | 56 +++++++++++ .../commands/toClient/MessageDeserializer.kt | 7 ++ .../commands/toClient/MessageSerializer.kt | 41 ++++++++ .../KotlinSerializationMapperTest.kt | 71 ++++++++++++++ 9 files changed, 435 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt new file mode 100644 index 0000000000..d4bc50164a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt @@ -0,0 +1,96 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonArray +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray + +/** kotlinx-serialization codec for the NIP-22 `LIMITS` object payload. */ +object LimitsKSerializer { + fun serializeToElement(value: LimitsMessage): JsonObject = + buildJsonObject { + // Only emit the fields the relay actually set; absent limits stay absent. + value.canWrite?.let { put("can_write", it) } + value.canRead?.let { put("can_read", it) } + value.authForRead?.let { put("auth_for_read", it) } + value.authForWrite?.let { put("auth_for_write", it) } + value.acceptedEventKinds?.let { kinds -> + putJsonArray("accepted_event_kinds") { kinds.forEach { add(it) } } + } + value.blockedEventKinds?.let { kinds -> + putJsonArray("blocked_event_kinds") { kinds.forEach { add(it) } } + } + value.minPowDifficulty?.let { put("min_pow_difficulty", it) } + value.maxMessageLength?.let { put("max_message_length", it) } + value.maxSubscriptions?.let { put("max_subscriptions", it) } + value.maxFilters?.let { put("max_filters", it) } + value.maxLimit?.let { put("max_limit", it) } + value.maxEventTags?.let { put("max_event_tags", it) } + value.maxContentLength?.let { put("max_content_length", it) } + value.createdAtMsecsAgo?.let { put("created_at_msecs_ago", it) } + value.createdAtMsecsAhead?.let { put("created_at_msecs_ahead", it) } + value.filterRateLimit?.let { put("filter_rate_limit", it) } + value.publishingRateLimit?.let { put("publishing_rate_limit", it) } + value.requiredTags?.let { tags -> + putJsonArray("required_tags") { + tags.forEach { tag -> + addJsonArray { tag.forEach { part -> add(part) } } + } + } + } + } + + fun deserializeFromElement(jsonObject: JsonObject): LimitsMessage = + LimitsMessage( + canWrite = jsonObject["can_write"]?.jsonPrimitive?.boolean, + canRead = jsonObject["can_read"]?.jsonPrimitive?.boolean, + authForRead = jsonObject["auth_for_read"]?.jsonPrimitive?.boolean, + authForWrite = jsonObject["auth_for_write"]?.jsonPrimitive?.boolean, + acceptedEventKinds = jsonObject["accepted_event_kinds"]?.jsonArray?.map { it.jsonPrimitive.int }, + blockedEventKinds = jsonObject["blocked_event_kinds"]?.jsonArray?.map { it.jsonPrimitive.int }, + minPowDifficulty = jsonObject["min_pow_difficulty"]?.jsonPrimitive?.int, + maxMessageLength = jsonObject["max_message_length"]?.jsonPrimitive?.int, + maxSubscriptions = jsonObject["max_subscriptions"]?.jsonPrimitive?.int, + maxFilters = jsonObject["max_filters"]?.jsonPrimitive?.int, + maxLimit = jsonObject["max_limit"]?.jsonPrimitive?.int, + maxEventTags = jsonObject["max_event_tags"]?.jsonPrimitive?.int, + maxContentLength = jsonObject["max_content_length"]?.jsonPrimitive?.int, + createdAtMsecsAgo = jsonObject["created_at_msecs_ago"]?.jsonPrimitive?.long, + createdAtMsecsAhead = jsonObject["created_at_msecs_ahead"]?.jsonPrimitive?.long, + filterRateLimit = jsonObject["filter_rate_limit"]?.jsonPrimitive?.long, + publishingRateLimit = jsonObject["publishing_rate_limit"]?.jsonPrimitive?.long, + requiredTags = + jsonObject["required_tags"]?.jsonArray?.map { tag -> + (tag as JsonArray).map { it.jsonPrimitive.content } + }, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 800420e49c..0892e776e6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage @@ -97,6 +98,11 @@ object MessageKSerializer : KSerializer { add(JsonPrimitive(value.subId)) } + is LimitsMessage -> { + // NIP-22 wire format: ["LIMITS", { }] + add(LimitsKSerializer.serializeToElement(value)) + } + is NegMsgMessage -> { add(JsonPrimitive(value.subId)) add(JsonPrimitive(value.message)) @@ -160,6 +166,10 @@ object MessageKSerializer : KSerializer { CountMessage(queryId, result) } + LimitsMessage.LABEL -> { + LimitsKSerializer.deserializeFromElement(array[1].jsonObject) + } + NegMsgMessage.LABEL -> { NegMsgMessage( subId = array[1].jsonPrimitive.content, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index ba3c1e5a34..f1b95de2f2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage @@ -64,6 +65,7 @@ class RelayLogger( is AuthMessage -> if (debugReceiving) Log.d(logTag) { "Auth: ${msg.challenge}" } is NotifyMessage -> if (debugReceiving) Log.d(logTag) { "Notify: ${msg.message}" } is CountMessage -> if (debugReceiving) Log.d(logTag) { "Count: ${msg.result.count} approx: ${msg.result.approximate} hll: ${msg.result.hll != null}" } + is LimitsMessage -> if (debugReceiving) Log.d(logTag) { "Limits: canRead=${msg.canRead} canWrite=${msg.canWrite} maxLimit=${msg.maxLimit} maxSubscriptions=${msg.maxSubscriptions}" } is ClosedMessage -> Log.w(logTag) { "Closed: ${msg.subId} ${msg.message}" } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt new file mode 100644 index 0000000000..cb2f68f2f6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt @@ -0,0 +1,78 @@ +/* + * 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.quartz.nip01Core.relay.commands.toClient + +/** + * NIP-22 `LIMITS` message: the relay advertises the current rights and limits + * for this connection. Sent upon connection and again at any point during the + * connection to reflect the client's changing rights (e.g. after a NIP-42 AUTH). + * + * Wire format: `["LIMITS", { }]`. + * + * Every field is optional: a relay only sends the limits it enforces, and a + * missing field means "unspecified / no advertised limit" — clients should keep + * any previously cached value and not assume a default. Clients cache this + * payload on the relay connection and apply it when sending `EVENT`s and `REQ`s. + */ +class LimitsMessage( + /** Whether clients may publish events to this relay. */ + val canWrite: Boolean? = null, + /** Whether clients may send `REQ` commands to this relay. */ + val canRead: Boolean? = null, + /** Whether the client must authenticate (NIP-42) before reading. */ + val authForRead: Boolean? = null, + /** Whether the client must authenticate (NIP-42) before writing. */ + val authForWrite: Boolean? = null, + /** Allowlist of event kinds the relay accepts for publishing. */ + val acceptedEventKinds: List? = null, + /** Denylist of event kinds the relay rejects for publishing. */ + val blockedEventKinds: List? = null, + /** Minimum proof-of-work (NIP-13) difficulty in bits required to publish. */ + val minPowDifficulty: Int? = null, + /** Maximum byte length for published events and `REQ` filters. */ + val maxMessageLength: Int? = null, + /** Maximum number of concurrent subscriptions allowed. */ + val maxSubscriptions: Int? = null, + /** Maximum number of filters allowed per `REQ` command. */ + val maxFilters: Int? = null, + /** Maximum value the relay honors for a filter's `limit` (use pagination for more). */ + val maxLimit: Int? = null, + /** Maximum number of tags allowed in a published event. */ + val maxEventTags: Int? = null, + /** Maximum length of the `content` field of a published event. */ + val maxContentLength: Int? = null, + /** Minimum event `created_at` recency, in milliseconds before now. */ + val createdAtMsecsAgo: Long? = null, + /** Maximum event `created_at` in the future, in milliseconds ahead of now. */ + val createdAtMsecsAhead: Long? = null, + /** Minimum debounce interval, in milliseconds, between filter changes. */ + val filterRateLimit: Long? = null, + /** Minimum debounce interval, in milliseconds, between publishes. */ + val publishingRateLimit: Long? = null, + /** Tags that must be present on published events, as `[key, optional value]` pairs. */ + val requiredTags: List>? = null, +) : Message { + override fun label() = LABEL + + companion object { + const val LABEL = "LIMITS" + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt index 2eb20459b0..d2c4499727 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage @@ -98,4 +99,77 @@ class RelayWireErgonomicsTest { assertEquals("sub1", closed.subId) assertEquals("restricted: nope", closed.message) } + + @Test + fun limitsMessageParsesProductionPayload() { + // Real payload from wss://pipe.imwald.eu/ (NIP-22 LIMITS). + val json = + """["LIMITS",{"can_read":true,"can_write":true,"auth_for_read":true,"auth_for_write":false,""" + + """"max_message_length":262144,"max_subscriptions":10,"max_filters":10,"max_limit":200,""" + + """"max_event_tags":2000,"max_content_length":131072}]""" + val parsed = Message.fromJson(json) + assertTrue(parsed is LimitsMessage) + assertEquals(true, parsed.canRead) + assertEquals(true, parsed.canWrite) + assertEquals(true, parsed.authForRead) + assertEquals(false, parsed.authForWrite) + assertEquals(262144, parsed.maxMessageLength) + assertEquals(10, parsed.maxSubscriptions) + assertEquals(10, parsed.maxFilters) + assertEquals(200, parsed.maxLimit) + assertEquals(2000, parsed.maxEventTags) + assertEquals(131072, parsed.maxContentLength) + // Fields the relay didn't send stay null. + assertNull(parsed.minPowDifficulty) + assertNull(parsed.acceptedEventKinds) + assertNull(parsed.requiredTags) + } + + @Test + fun limitsMessageParsesArrayFields() { + val json = + """["LIMITS",{"accepted_event_kinds":[0,1,3],"blocked_event_kinds":[4],""" + + """"min_pow_difficulty":16,"created_at_msecs_ago":3600000,"created_at_msecs_ahead":60000,""" + + """"required_tags":[["t","nostr"],["p"]]}]""" + val parsed = Message.fromJson(json) + assertTrue(parsed is LimitsMessage) + assertEquals(listOf(0, 1, 3), parsed.acceptedEventKinds) + assertEquals(listOf(4), parsed.blockedEventKinds) + assertEquals(16, parsed.minPowDifficulty) + assertEquals(3600000L, parsed.createdAtMsecsAgo) + assertEquals(60000L, parsed.createdAtMsecsAhead) + assertEquals(listOf(listOf("t", "nostr"), listOf("p")), parsed.requiredTags) + } + + @Test + fun limitsMessageRoundTrips() { + val original = + LimitsMessage( + canRead = true, + canWrite = false, + authForWrite = true, + maxLimit = 500, + acceptedEventKinds = listOf(1, 30023), + requiredTags = listOf(listOf("t", "nostr")), + ) + val json = original.toJson() + assertTrue(json.startsWith("""["LIMITS",{""")) + val parsed = Message.fromJson(json) + assertTrue(parsed is LimitsMessage) + assertEquals(true, parsed.canRead) + assertEquals(false, parsed.canWrite) + assertEquals(true, parsed.authForWrite) + assertNull(parsed.authForRead) + assertEquals(500, parsed.maxLimit) + assertEquals(listOf(1, 30023), parsed.acceptedEventKinds) + assertEquals(listOf(listOf("t", "nostr")), parsed.requiredTags) + } + + @Test + fun limitsMessageParsesEmptyObject() { + val parsed = Message.fromJson("""["LIMITS",{}]""") + assertTrue(parsed is LimitsMessage) + assertNull(parsed.canRead) + assertNull(parsed.maxLimit) + } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt new file mode 100644 index 0000000000..fcd8e345f4 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt @@ -0,0 +1,56 @@ +/* + * 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.quartz.nip01Core.relay.commands.toClient + +import com.fasterxml.jackson.databind.JsonNode + +class LimitsDeserializer { + companion object { + private fun JsonNode.intList(field: String): List? = get(field)?.takeIf { it.isArray }?.map { it.asInt() } + + private fun JsonNode.requiredTags(field: String): List>? = + get(field)?.takeIf { it.isArray }?.map { tag -> + tag.map { it.asText() } + } + + fun fromJson(jsonObject: JsonNode): LimitsMessage = + LimitsMessage( + canWrite = jsonObject.get("can_write")?.asBoolean(), + canRead = jsonObject.get("can_read")?.asBoolean(), + authForRead = jsonObject.get("auth_for_read")?.asBoolean(), + authForWrite = jsonObject.get("auth_for_write")?.asBoolean(), + acceptedEventKinds = jsonObject.intList("accepted_event_kinds"), + blockedEventKinds = jsonObject.intList("blocked_event_kinds"), + minPowDifficulty = jsonObject.get("min_pow_difficulty")?.asInt(), + maxMessageLength = jsonObject.get("max_message_length")?.asInt(), + maxSubscriptions = jsonObject.get("max_subscriptions")?.asInt(), + maxFilters = jsonObject.get("max_filters")?.asInt(), + maxLimit = jsonObject.get("max_limit")?.asInt(), + maxEventTags = jsonObject.get("max_event_tags")?.asInt(), + maxContentLength = jsonObject.get("max_content_length")?.asInt(), + createdAtMsecsAgo = jsonObject.get("created_at_msecs_ago")?.asLong(), + createdAtMsecsAhead = jsonObject.get("created_at_msecs_ahead")?.asLong(), + filterRateLimit = jsonObject.get("filter_rate_limit")?.asLong(), + publishingRateLimit = jsonObject.get("publishing_rate_limit")?.asLong(), + requiredTags = jsonObject.requiredTags("required_tags"), + ) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt index 09e31365e0..52badf4dbe 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt @@ -103,6 +103,13 @@ class MessageDeserializer : StdDeserializer(Message::class.java) { ) } + LimitsMessage.LABEL -> { + jp.nextToken() + val result: JsonNode = jp.codec.readTree(jp) + + LimitsDeserializer.fromJson(result) + } + NegMsgMessage.LABEL -> { NegMsgMessage( subId = jp.nextTextValue(), diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index 9048fe774c..b7a144bb15 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -80,6 +80,47 @@ class MessageSerializer : StdSerializer(Message::class.java) { gen.writeString(msg.subId) } + is LimitsMessage -> { + // NIP-22 wire format: ["LIMITS", { }]. Only + // the fields the relay set are emitted; absent limits stay absent. + gen.writeStartObject() + msg.canWrite?.let { gen.writeBooleanField("can_write", it) } + msg.canRead?.let { gen.writeBooleanField("can_read", it) } + msg.authForRead?.let { gen.writeBooleanField("auth_for_read", it) } + msg.authForWrite?.let { gen.writeBooleanField("auth_for_write", it) } + msg.acceptedEventKinds?.let { + gen.writeArrayFieldStart("accepted_event_kinds") + it.forEach { kind -> gen.writeNumber(kind) } + gen.writeEndArray() + } + msg.blockedEventKinds?.let { + gen.writeArrayFieldStart("blocked_event_kinds") + it.forEach { kind -> gen.writeNumber(kind) } + gen.writeEndArray() + } + msg.minPowDifficulty?.let { gen.writeNumberField("min_pow_difficulty", it) } + msg.maxMessageLength?.let { gen.writeNumberField("max_message_length", it) } + msg.maxSubscriptions?.let { gen.writeNumberField("max_subscriptions", it) } + msg.maxFilters?.let { gen.writeNumberField("max_filters", it) } + msg.maxLimit?.let { gen.writeNumberField("max_limit", it) } + msg.maxEventTags?.let { gen.writeNumberField("max_event_tags", it) } + msg.maxContentLength?.let { gen.writeNumberField("max_content_length", it) } + msg.createdAtMsecsAgo?.let { gen.writeNumberField("created_at_msecs_ago", it) } + msg.createdAtMsecsAhead?.let { gen.writeNumberField("created_at_msecs_ahead", it) } + msg.filterRateLimit?.let { gen.writeNumberField("filter_rate_limit", it) } + msg.publishingRateLimit?.let { gen.writeNumberField("publishing_rate_limit", it) } + msg.requiredTags?.let { + gen.writeArrayFieldStart("required_tags") + it.forEach { tag -> + gen.writeStartArray() + tag.forEach { part -> gen.writeString(part) } + gen.writeEndArray() + } + gen.writeEndArray() + } + gen.writeEndObject() + } + is NegMsgMessage -> { gen.writeString(msg.subId) gen.writeString(msg.message) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt index 959b4de530..22c00ec147 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage @@ -498,6 +499,76 @@ class KotlinSerializationMapperTest { assertEquals(jacksonJson, kotlinJson) } + @Test + fun serializeLimitsMessage_matchesJackson() { + val msg = + LimitsMessage( + canWrite = true, + canRead = true, + authForRead = true, + authForWrite = false, + acceptedEventKinds = listOf(0, 1, 3), + blockedEventKinds = listOf(4), + minPowDifficulty = 16, + maxMessageLength = 262144, + maxSubscriptions = 10, + maxFilters = 10, + maxLimit = 200, + maxEventTags = 2000, + maxContentLength = 131072, + createdAtMsecsAgo = 3600000L, + createdAtMsecsAhead = 60000L, + filterRateLimit = 100L, + publishingRateLimit = 500L, + requiredTags = listOf(listOf("t", "nostr"), listOf("p")), + ) + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeLimitsMessage() { + val json = + """["LIMITS",{"can_read":true,"can_write":true,"auth_for_read":true,"auth_for_write":false,""" + + """"max_subscriptions":10,"max_limit":200}]""" + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + assertTrue(deserialized is LimitsMessage) + assertEquals(true, deserialized.canRead) + assertEquals(true, deserialized.canWrite) + assertEquals(true, deserialized.authForRead) + assertEquals(false, deserialized.authForWrite) + assertEquals(10, deserialized.maxSubscriptions) + assertEquals(200, deserialized.maxLimit) + assertNull(deserialized.maxFilters) + } + + @Test + fun crossDeserializationLimitsMessage() { + val msg = + LimitsMessage( + canRead = true, + canWrite = false, + maxLimit = 500, + acceptedEventKinds = listOf(1, 30023), + requiredTags = listOf(listOf("t", "nostr")), + ) + + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(jacksonJson) + assertTrue(kotlinDeserialized is LimitsMessage) + assertEquals(msg.maxLimit, kotlinDeserialized.maxLimit) + assertEquals(msg.acceptedEventKinds, kotlinDeserialized.acceptedEventKinds) + assertEquals(msg.requiredTags, kotlinDeserialized.requiredTags) + + val kotlinJson = KotlinSerializationMapper.toJson(msg) + val jacksonDeserialized = JacksonMapper.fromJsonToMessage(kotlinJson) + assertTrue(jacksonDeserialized is LimitsMessage) + assertEquals(msg.maxLimit, jacksonDeserialized.maxLimit) + assertEquals(msg.acceptedEventKinds, jacksonDeserialized.acceptedEventKinds) + assertEquals(msg.requiredTags, jacksonDeserialized.requiredTags) + } + @Test fun crossDeserializationMessages() { val messages = From 9daa0b3e48f3447c160df1550a4d46ab5fa76e63 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 15:39:04 +0000 Subject: [PATCH 2/8] feat: cache and expose relay LIMITS per relay Adds RelayLimits, a passive connection-listener accessory (modeled on RelayAuthenticator) that caches the latest LIMITS each relay advertises and publishes it as a Compose-stable StateFlow, so consumers can read a relay's current rights/limits instead of only observing the raw message. - RelayLimits: caches LimitsMessage per NormalizedRelayUrl, exposes limitsFlow (StateFlow), get(url) and snapshot(); connection-scoped (entry dropped on disconnect so stale limits don't leak). - LimitsMessage marked @Immutable for Compose stability in the flow map. - Wired into AppModules next to relayStats (Amethyst.instance.relayLimits). - Tests: per-relay caching, later-replaces-earlier, independent relays, drop-on-disconnect, and ignore non-LIMITS messages. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 + .../relay/client/limits/RelayLimits.kt | 100 +++++++++++++ .../relay/commands/toClient/LimitsMessage.kt | 5 +- .../relay/client/limits/RelayLimitsTest.kt | 136 ++++++++++++++++++ 4 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 393e9b64d8..5cbb2cc366 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -122,6 +122,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker +import com.vitorpamplona.quartz.nip01Core.relay.client.limits.RelayLimits import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder @@ -710,6 +711,9 @@ class AppModules( // Captures statistics about relays val relayStats = RelayStats(client) + // Caches the latest LIMITS (rights + limits) each relay advertises. + val relayLimits = RelayLimits(client) + // Resource-usage ledger: relay traffic/reconnect + connection-time, // foreground-time, process-CPU, and signature-verification collectors. init { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt new file mode 100644 index 0000000000..36acfaf475 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt @@ -0,0 +1,100 @@ +/* + * 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.quartz.nip01Core.relay.client.limits + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Caches the latest `LIMITS` (relay rights + limits) advertised by each relay. + * + * A relay sends `LIMITS` on connect and again whenever the connection's rights + * change (e.g. after a successful NIP-42 AUTH flips `can_write` on). Like + * [com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator], + * this is an accessory that registers a [RelayConnectionListener], keeps + * per-relay state, and publishes it as a Compose-stable [StateFlow] — but it is + * purely passive: it never replies to the relay. + * + * The cache is connection-scoped: a relay's entry is dropped on disconnect, so a + * stale limit from a previous session never leaks into a new one. A fresh + * connection re-advertises `LIMITS` on connect. + */ +class RelayLimits( + val client: INostrClient, +) { + // onIncomingMessage / onDisconnected fire on the per-relay socket dispatcher + // thread, so this is mutated concurrently. MutableStateFlow.update is an + // atomic compare-and-set loop over an immutable PersistentMap, so concurrent + // writers from different relays never corrupt the map or lose an update. + private val _limitsFlow = MutableStateFlow>(persistentMapOf()) + + /** + * Per-relay `LIMITS` as an immutable, Compose-stable snapshot map. The map + * identity changes on every mutation, so downstream + * [kotlinx.coroutines.flow.distinctUntilChanged] and Compose `@Immutable` + * skipping both work correctly. + */ + val limitsFlow: StateFlow> = _limitsFlow.asStateFlow() + + /** The most recent `LIMITS` the relay advertised, or null if none seen on the current connection. */ + fun get(url: NormalizedRelayUrl): LimitsMessage? = _limitsFlow.value[url] + + fun snapshot(): Map = _limitsFlow.value + + private val clientListener = + object : RelayConnectionListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is LimitsMessage) { + _limitsFlow.update { it.putting(relay.url, msg) } + } + } + + override fun onDisconnected(relay: IRelayClient) { + _limitsFlow.update { it.removing(relay.url) } + } + } + + init { + Log.d("RelayLimits", "Init, Subscribe") + client.addConnectionListener(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("RelayLimits", "Destroy, Unsubscribe") + client.removeConnectionListener(clientListener) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt index cb2f68f2f6..38b692825b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient +import androidx.compose.runtime.Immutable + /** - * NIP-22 `LIMITS` message: the relay advertises the current rights and limits + * `LIMITS` message: the relay advertises the current rights and limits * for this connection. Sent upon connection and again at any point during the * connection to reflect the client's changing rights (e.g. after a NIP-42 AUTH). * @@ -32,6 +34,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient * any previously cached value and not assume a default. Clients cache this * payload on the relay connection and apply it when sending `EVENT`s and `REQ`s. */ +@Immutable class LimitsMessage( /** Whether clients may publish events to this relay. */ val canWrite: Boolean? = null, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt new file mode 100644 index 0000000000..b88f2e2dfc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt @@ -0,0 +1,136 @@ +/* + * 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.quartz.nip01Core.relay.client.limits + +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RelayLimitsTest { + private class CapturingClient( + private val delegate: INostrClient = EmptyNostrClient(), + ) : INostrClient by delegate { + var captured: RelayConnectionListener? = null + + override fun addConnectionListener(listener: RelayConnectionListener) { + captured = listener + } + } + + private class FakeRelayClient( + override val url: NormalizedRelayUrl, + ) : IRelayClient { + override fun connect() = Unit + + override fun needsToReconnect() = false + + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit + + override fun isConnected() = true + + override fun sendOrConnectAndSync(cmd: Command) = Unit + + override fun sendIfConnected(cmd: Command) = Unit + + override fun disconnect() = Unit + } + + private fun setup(): Pair { + val client = CapturingClient() + val limits = RelayLimits(client) + val listener = client.captured ?: error("RelayLimits did not register a listener") + return limits to listener + } + + @Test + fun cachesLimitsPerRelay() { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + assertNull(limits.get(relay.url), "No limits before any LIMITS message") + + listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = true, maxLimit = 200)) + + assertEquals(true, limits.get(relay.url)?.canWrite) + assertEquals(200, limits.get(relay.url)?.maxLimit) + assertEquals(limits.get(relay.url), limits.limitsFlow.value[relay.url]) + } + + @Test + fun laterLimitsReplaceEarlierOnes() { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = false, maxLimit = 200)) + listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = true, maxLimit = 500)) + + // A relay re-advertises LIMITS when rights change (e.g. after AUTH flips can_write). + assertEquals(true, limits.get(relay.url)?.canWrite) + assertEquals(500, limits.get(relay.url)?.maxLimit) + } + + @Test + fun tracksLimitsForDistinctRelaysIndependently() { + val (limits, listener) = setup() + val relayA = FakeRelayClient(NormalizedRelayUrl("wss://a.example/")) + val relayB = FakeRelayClient(NormalizedRelayUrl("wss://b.example/")) + + listener.onIncomingMessage(relayA, "", LimitsMessage(maxLimit = 100)) + listener.onIncomingMessage(relayB, "", LimitsMessage(maxLimit = 999)) + + assertEquals(100, limits.get(relayA.url)?.maxLimit) + assertEquals(999, limits.get(relayB.url)?.maxLimit) + assertEquals(2, limits.snapshot().size) + } + + @Test + fun dropsCachedLimitsOnDisconnect() { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onIncomingMessage(relay, "", LimitsMessage(canRead = true)) + assertTrue(limits.get(relay.url) != null) + + listener.onDisconnected(relay) + assertNull(limits.get(relay.url), "Limits are connection-scoped and cleared on disconnect") + assertTrue(limits.snapshot().isEmpty()) + } + + @Test + fun ignoresNonLimitsMessages() { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onIncomingMessage(relay, "", EoseMessage("sub1")) + + assertNull(limits.get(relay.url)) + assertTrue(limits.snapshot().isEmpty()) + } +} From 041abf9e37b8354d43408cbdb2720a1e7daeaeaa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 17:21:00 +0000 Subject: [PATCH 3/8] refactor: reconcile duplicate LIMITS code and rename client accessory Cleanup after adding LimitsMessage + the client-side cache: - Remove the unused experimental LIMITS prototype (experimental/limits/Limits.kt + LimitProcessor.kt). Its @Serializable model duplicated LimitsMessage (minus auth_for_read/auth_for_write and the Message wiring); the processor's clamp/reject logic is superseded by the server-side LimitsPolicy and will be reincarnated as pure helpers on LimitsMessage. Both were prototypes with no references (preserved in git history). - Rename the client accessory RelayLimits -> RelayLimitsTracker so it no longer collides on simple name with the relay-server-side nip01Core.relay.server.policies.RelayLimits (the operator-configured limits a relay enforces and advertises). Updates AppModules and the test. No behavior change. quartz:jvmTest (RelayLimitsTrackerTest 5/5) and amethyst play-flavor compile are green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 +- .../experimental/limits/LimitProcessor.kt | 113 ------------------ .../quartz/experimental/limits/Limits.kt | 44 ------- .../{RelayLimits.kt => RelayLimitsTracker.kt} | 10 +- ...imitsTest.kt => RelayLimitsTrackerTest.kt} | 8 +- 5 files changed, 13 insertions(+), 166 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/Limits.kt rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/{RelayLimits.kt => RelayLimitsTracker.kt} (92%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/{RelayLimitsTest.kt => RelayLimitsTrackerTest.kt} (95%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 5cbb2cc366..92a2454665 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -122,7 +122,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker -import com.vitorpamplona.quartz.nip01Core.relay.client.limits.RelayLimits +import com.vitorpamplona.quartz.nip01Core.relay.client.limits.RelayLimitsTracker import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder @@ -712,7 +712,7 @@ class AppModules( val relayStats = RelayStats(client) // Caches the latest LIMITS (rights + limits) each relay advertises. - val relayLimits = RelayLimits(client) + val relayLimits = RelayLimitsTracker(client) // Resource-usage ledger: relay traffic/reconnect + connection-time, // foreground-time, process-CPU, and signature-verification collectors. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt deleted file mode 100644 index 3bd5129456..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt +++ /dev/null @@ -1,113 +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.quartz.experimental.limits - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip13Pow.pow -import com.vitorpamplona.quartz.utils.TimeUtils - -class LimitProcessor { - fun wrapFilterToLimits( - filter: Filter, - sendingStr: String, - limits: Limits, - ): Filter? { - var newFilter: Filter? = filter - - if (limits.canRead != null && !limits.canRead) { - newFilter = null - } - - if (limits.maxLimit != null && filter.limit != null && filter.limit > limits.maxLimit) { - newFilter = filter.copy(limit = limits.maxLimit) - } - - if (!limits.acceptedEventKinds.isNullOrEmpty() && !filter.kinds.isNullOrEmpty()) { - val intersect = filter.kinds.filter { it in limits.acceptedEventKinds } - if (intersect.isNotEmpty()) { - newFilter = filter.copy(kinds = intersect) - } else { - newFilter = null - } - } - - if (!limits.blockedEventKinds.isNullOrEmpty() && !filter.kinds.isNullOrEmpty()) { - val intersect = filter.kinds.filter { it !in limits.blockedEventKinds } - if (intersect.isNotEmpty()) { - newFilter = filter.copy(kinds = intersect) - } else { - newFilter = null - } - } - - if (limits.maxMessageLength != null && sendingStr.length > limits.maxMessageLength) { - // TODO: figure out how to dynamically reduce filter size - newFilter = null - } - - return newFilter - } - - fun canSendEvent( - ev: Event, - sendingStr: String, - limits: Limits, - ): Boolean { - if (limits.canWrite != null && !limits.canWrite) return false - - if (!limits.acceptedEventKinds.isNullOrEmpty() && ev.kind !in limits.acceptedEventKinds) return false - if (!limits.blockedEventKinds.isNullOrEmpty() && ev.kind in limits.blockedEventKinds) return false - - if (limits.minPoW != null && ev.pow() < limits.minPoW) return false - if (limits.maxEventTags != null && ev.tags.size > limits.maxEventTags) return false - if (limits.maxContentLength != null && ev.content.length > limits.maxContentLength) return false - - if (limits.createdAtMillisecsAgo != null && ev.createdAt < TimeUtils.now() - limits.createdAtMillisecsAgo) return false - if (limits.createdAtMillisecsAhead != null && ev.createdAt > TimeUtils.now() + limits.createdAtMillisecsAhead) return false - - if (limits.requiredTags != null && !matchAll(ev, limits.requiredTags)) return false - - if (limits.maxMessageLength != null && sendingStr.length > limits.maxMessageLength) return false - - return true - } - - private fun matchAll( - ev: Event, - requiredTags: Array>, - ): Boolean = - requiredTags.all { requiredTag -> - if (requiredTag.isNotEmpty()) { - if (requiredTag.getOrNull(1) == null) { - ev.tags.any { eventTag -> - eventTag.getOrNull(0) == requiredTag[0] - } - } else { - ev.tags.any { eventTag -> - eventTag.getOrNull(0) == requiredTag[0] && eventTag.getOrNull(1) == requiredTag[1] - } - } - } else { - true - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/Limits.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/Limits.kt deleted file mode 100644 index 2526e377f9..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/Limits.kt +++ /dev/null @@ -1,44 +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.quartz.experimental.limits - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -class Limits( - @SerialName("can_write") val canWrite: Boolean?, - @SerialName("can_read") val canRead: Boolean?, - @SerialName("accepted_event_kinds") val acceptedEventKinds: Set?, - @SerialName("blocked_event_kinds") val blockedEventKinds: Set?, - @SerialName("min_pow_difficulty") val minPoW: Int?, - @SerialName("max_message_length") val maxMessageLength: Int?, - @SerialName("max_subscriptions") val maxSubscriptions: Int?, - @SerialName("max_filters") val maxFilters: Int?, - @SerialName("max_limit") val maxLimit: Int?, - @SerialName("max_event_tags") val maxEventTags: Int?, - @SerialName("max_content_length") val maxContentLength: Int?, - @SerialName("created_at_msecs_ago") val createdAtMillisecsAgo: Long?, - @SerialName("created_at_msecs_ahead") val createdAtMillisecsAhead: Long?, - @SerialName("filter_rate_limit") val filterRateLimit: Long?, - @SerialName("publishing_rate_limit") val publishingRateLimit: Long?, - @SerialName("required_tags") val requiredTags: Array>?, -) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt similarity index 92% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt index 36acfaf475..a8ffa37d63 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt @@ -47,8 +47,12 @@ import kotlinx.coroutines.flow.update * The cache is connection-scoped: a relay's entry is dropped on disconnect, so a * stale limit from a previous session never leaks into a new one. A fresh * connection re-advertises `LIMITS` on connect. + * + * Named `…Tracker` to avoid colliding with the relay-*server* side's + * [com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits], which + * is the operator-configured source of truth a relay enforces and advertises. */ -class RelayLimits( +class RelayLimitsTracker( val client: INostrClient, ) { // onIncomingMessage / onDisconnected fire on the per-relay socket dispatcher @@ -88,13 +92,13 @@ class RelayLimits( } init { - Log.d("RelayLimits", "Init, Subscribe") + Log.d("RelayLimitsTracker", "Init, Subscribe") client.addConnectionListener(clientListener) } fun destroy() { // makes sure to run - Log.d("RelayLimits", "Destroy, Unsubscribe") + Log.d("RelayLimitsTracker", "Destroy, Unsubscribe") client.removeConnectionListener(clientListener) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt similarity index 95% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt index b88f2e2dfc..6547aeb9bb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt @@ -33,7 +33,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue -class RelayLimitsTest { +class RelayLimitsTrackerTest { private class CapturingClient( private val delegate: INostrClient = EmptyNostrClient(), ) : INostrClient by delegate { @@ -62,10 +62,10 @@ class RelayLimitsTest { override fun disconnect() = Unit } - private fun setup(): Pair { + private fun setup(): Pair { val client = CapturingClient() - val limits = RelayLimits(client) - val listener = client.captured ?: error("RelayLimits did not register a listener") + val limits = RelayLimitsTracker(client) + val listener = client.captured ?: error("RelayLimitsTracker did not register a listener") return limits to listener } From 9b7aca77a6b3d6eb3e40def2df710dd45c698e6e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 18:28:27 +0000 Subject: [PATCH 4/8] docs: add relay LIMITS status + roadmap plan Captures what's done (parse, RelayLimitsTracker, cleanup), the audit fixes to fold in (kotlinx parse leniency to match Jackson on iOS, drop stale NIP-22 labels, make LimitsMessage a data class), and the remaining roadmap (client apply helpers, server-side emit, NIP-11 bridge, amy tooling). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ --- quartz/plans/2026-07-16-relay-limits.md | 113 ++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 quartz/plans/2026-07-16-relay-limits.md diff --git a/quartz/plans/2026-07-16-relay-limits.md b/quartz/plans/2026-07-16-relay-limits.md new file mode 100644 index 0000000000..b0426776b2 --- /dev/null +++ b/quartz/plans/2026-07-16-relay-limits.md @@ -0,0 +1,113 @@ +# Relay `LIMITS` message — status & roadmap + +Support for the relay-to-client `LIMITS` frame (from +[nostr-protocol/nips#1434](https://github.com/nostr-protocol/nips/pull/1434)), +where a relay advertises the current connection's rights and limits — on +connect and again whenever they change (e.g. after a NIP-42 AUTH flips +`can_write`). Live-verified against `wss://pipe.imwald.eu/` (which sends `AUTH` +then `LIMITS`). + +Note: `LIMITS` is **not** NIP-22 (that's Comment / kind 1111); it's the #1434 +proposal and is label-based on the wire, so the code keys off the `"LIMITS"` +label rather than a NIP number. + +## Done (branch `claude/limits-message-support-g5plrd`, PR #3597) + +- **Parse** — `LimitsMessage` model (`nip01Core/relay/commands/toClient/`) with + every #1434 field plus the two non-spec extensions this relay sends + (`auth_for_read` / `auth_for_write`). Wired into both codecs: + Jackson (`LimitsDeserializer` + `MessageSerializer`, jvmAndroid) and + kotlinx (`LimitsKSerializer` + `MessageKSerializer`, iOS/native). Unknown + fields are ignored, so a future field can't re-trigger the original + "Message LIMITS is not supported" crash. +- **Cache/expose** — `RelayLimitsTracker` (`nip01Core/relay/client/limits/`), + a passive `RelayConnectionListener` accessory (modeled on + `RelayAuthenticator`) that caches the latest `LimitsMessage` per + `NormalizedRelayUrl` and publishes a Compose-stable + `StateFlow>`. Connection-scoped (dropped on + disconnect). Wired in `AppModules` as `Amethyst.instance.relayLimits`. +- **Cleanup** — removed the unused experimental prototype + (`experimental/limits/Limits.kt` + `LimitProcessor.kt`); renamed the client + accessory to `…Tracker` to avoid colliding with the server-side + `relay/server/policies/RelayLimits`. + +## Audit fixes to fold in (small, do first) + +From the 2026-07-16 review of the branch: + +1. **kotlinx parse should be as lenient as Jackson.** `MessageKSerializer` is + the *iOS* incoming path; today `LimitsKSerializer.deserializeFromElement` + throws on a mistyped field (`(tag as JsonArray)`, `.jsonPrimitive.int`) or a + payload-less `["LIMITS"]` (`array[1].jsonObject`), where Jackson coerces. + Same frame → parsed on Android, dropped on iOS. Use `intOrNull` / + `booleanOrNull` / `longOrNull`, guard the array cast, and default a missing + payload object to an empty `LimitsMessage`. +2. **Drop the stale "NIP-22" labels** in `LimitsKSerializer` KDoc and the + `// NIP-22 wire format` comments in `MessageKSerializer` / `MessageSerializer` + (already removed from `LimitsMessage.kt`). +3. **Make `LimitsMessage` a `data class`** so `StateFlow.distinctUntilChanged` + suppresses no-op emissions when a relay re-advertises identical limits, and + tests get value equality. +4. Fold the explicit-`null`→`false`/`0` coercion nuance into (1) via the + `…OrNull` accessors on both sides. + +## Roadmap + +### 1. Client-side apply/enforcement (highest user-facing value) + +#1434 says clients MUST apply limits when sending. Provide **pure, testable +helpers on `LimitsMessage`** (mirror the server's `LimitsPolicy` logic; the +deleted `LimitProcessor` in git history is a starting sketch): + +- `clamp(filter)` / `clamp(filters)` → cap each filter `limit` to `max_limit`. +- `rejectionForPublish(event): String?` → `can_write`, `accepted`/`blocked + _event_kinds`, `max_content_length`, `max_event_tags`, `min_pow_difficulty`, + `created_at_msecs_ago`/`ahead` window, `required_tags`. +- `canRead()` / `canWrite()` convenience. + +Keep the helpers in quartz (pure, no side effects). Wire them into the send +path at the app layer (subscription/filter-assembly + publish), reading from +`RelayLimitsTracker`. Decide the UX for a rejected publish (surface vs +silently drop) with the maintainer — this is the one opinionated call. + +### 2. Server-side emit (`geode`) — the symmetric half + +The relay side already **enforces** (`LimitsPolicy` + `RelaySession`) and +**advertises via NIP-11** (`RelayLimits.toNip11Limitation()`), but never sends +the dynamic `LIMITS` frame. Add: + +- `RelayLimits.toLimitsMessage(canRead, canWrite, authForRead, authForWrite)` + next to `toNip11Limitation()`, so one source of truth drives NIP-11 *and* the + frame. +- `RelaySession` / `EventSourceServer` sends `LIMITS` on connect and **re-sends + after AUTH** when effective rights change. +- Then geode ↔ our own client becomes interop-testable (relayBench / the amy + serve path). + +### 3. NIP-11 ↔ LIMITS bridge + +The two overlap ~80% (`RelayInformationLimitation` vs `LimitsMessage`). Add +`LimitsMessage.toNip11Limitation()` / `RelayInformationLimitation +.toLimitsMessage()`, and let `RelayLimitsTracker` **seed** from the NIP-11 doc +before the socket connects, then override with the live frame — one "limits of +relay X" model regardless of source. + +### 4. Tooling, tests, docs + +- `amy relay info` already prints the NIP-11 doc; add the live `LIMITS` frame + (the interop tool the repo already leans on). +- Catalog `RelayLimitsTracker` in + `nip01Core/relay/client/accessories/README.md`. +- Test the **AUTH → re-LIMITS** transition (`auth_for_write` flipping after a + successful AUTH) — the one dynamic behavior not yet exercised live. + +### 5. Spec follow-up + +`auth_for_read` / `auth_for_write` are not in #1434. Worth a note on the PR; +we tolerate them as extras either way (they pair with the `AUTH` this relay +also sends). + +## Suggested sequencing + +audit fixes → (1) client apply helpers → (2) server emit → (3) NIP-11 bridge → +(4) tooling. From ae81c93c04f5966a46e074237b6b385c024e32cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 03:04:27 +0000 Subject: [PATCH 5/8] feat: global background-notification-service master + per-account participation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesigns the always-on notification control into two switches: - A global master ("Background notification service") — a persisted, battery-saver "airplane mode" stored in global preferences that overrides every account. The Quick Settings tile toggles this; it survives restarts and crashes until re-enabled. - Per-account participation ("Keep this account active in the background") — the existing per-account setting, now surfaced as a per-write-account list under the master in the Notification Settings screen. The always-on service runs while the master is on AND at least one writable account participates; turning the master off tears down every layer for all accounts. The manager reacts to both the master flow and each loaded account's participation flag, and the restart layers (watchdog/boot/worker) honor the same rule. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TKLqvcC1vobUYs3PxL2zWU --- amethyst/src/main/AndroidManifest.xml | 11 ++ .../com/vitorpamplona/amethyst/AppModules.kt | 6 +- .../amethyst/LocalPreferences.kt | 37 +++++ .../AlwaysOnNotificationServiceManager.kt | 100 +++++++++---- .../notifications/NotificationRelayService.kt | 16 +- .../NotificationServiceTileService.kt | 141 ++++++++++++++++++ .../settings/NotificationSettingsScreen.kt | 76 +++++++++- amethyst/src/main/res/values/strings.xml | 9 ++ 8 files changed, 353 insertions(+), 43 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 5a7960c004..42e59a2bb1 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -384,6 +384,17 @@ android:value="Persistent real-time messaging relay connection for Nostr protocol. Maintains WebSocket connections to user-configured inbox relays for immediate notification delivery of direct messages, zaps, and mentions." /> + + + + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 393e9b64d8..fce9d8206a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -1100,11 +1100,13 @@ class AppModules( } } - // Watch for account login and start/stop always-on notification service + // Watch for account login and start/stop always-on notification service. + // The manager gates on the global master switch + each account's participation + // (not the active account), so it only needs to run while someone is logged in. applicationIOScope.launch { sessionManager.accountContent.collectLatest { state -> if (state is AccountState.LoggedIn) { - alwaysOnNotificationServiceManager.watchAccount(state.account) + alwaysOnNotificationServiceManager.start() } else { alwaysOnNotificationServiceManager.stop() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index b437a6e838..a6783c51da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -71,6 +71,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -94,6 +95,11 @@ data class AccountInfo( private object PrefKeys { const val CURRENT_ACCOUNT = "currently_logged_in_account" + + // Global (non-account) master switch for the always-on notification service. + // When off, the service is suppressed for every account regardless of each + // account's own participation flag. Persisted so it survives restarts/crashes. + const val NOTIFICATION_SERVICE_ENABLED = "notification_service_enabled" const val SAVED_ACCOUNTS = "all_saved_accounts" const val NOSTR_PRIVKEY = "nostr_privkey" const val NOSTR_PUBKEY = "nostr_pubkey" @@ -205,6 +211,37 @@ object LocalPreferences { private val savedAccountsMutex = Mutex() private val cachedAccounts: MutableMap = mutableMapOf() + // Global master switch for the always-on notification service ("Background + // notification service"). Default ON: existing users keep current behavior, and + // per-account participation decides who actually stays active. + // + // Stored in PLAIN (non-encrypted) SharedPreferences on purpose. It is a non-sensitive + // global boolean, and — unlike encryptedPreferences(), which asserts non-main — plain + // prefs can be read synchronously on ANY thread. The restart-layer gate + // (NotificationRelayService.isEnabled) is synchronous and runs in fresh processes (boot + // receiver, WorkManager), so it MUST read the persisted value without a suspend hop; + // otherwise a saved OFF would be missed on cold boot and the service would resurrect. + // The flow is lazily seeded from disk once (synchronous, main-safe) and is thereafter + // the source of truth, so there is no async hydrate that could clobber a user toggle. + private fun globalSettingsPrefs(): SharedPreferences = + Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE) + + private val notificationServiceEnabled: MutableStateFlow by lazy { + MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)) + } + + fun notificationServiceEnabledFlow(): StateFlow = notificationServiceEnabled + + fun isNotificationServiceEnabled(): Boolean = notificationServiceEnabled.value + + fun setNotificationServiceEnabled(enabled: Boolean) { + // In-memory update is the source of truth (main-safe); plain-prefs edit{} persists + // asynchronously via apply(), also main-safe. No suspend/hydrate hop, so no window + // where a late disk read can clobber this write. + notificationServiceEnabled.value = enabled + globalSettingsPrefs().edit { putBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, enabled) } + } + suspend fun currentAccount(): String? { if (currentAccount == null) { currentAccount = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt index b124ba8ca7..58c11797f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -22,14 +22,18 @@ package com.vitorpamplona.amethyst.service.notifications import android.content.Context import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch /** @@ -41,16 +45,23 @@ import kotlinx.coroutines.launch * L4 - BootCompletedReceiver (restart on boot) * L5 - ServiceWatchdogManager (AlarmManager, 5-min health check) * - * When enabled, all layers activate. When disabled, all layers deactivate. - * The manager watches the account's alwaysOnNotificationService setting - * and reacts to changes in real time. + * Two switches gate the system: * - * While enabled, every saved writable account is kept loaded in - * [AccountCacheState] so GiftWraps addressed to any of them (delivered via - * open relay subscriptions) get unwrapped by the owning account's - * `newNotesPreProcessor`. Without this, wraps for non-active accounts would - * sit in [com.vitorpamplona.amethyst.model.LocalCache] with no subscriber - * able to decrypt them. + * - The **global master** ([LocalPreferences.notificationServiceEnabledFlow], the + * "Background notification service" toggle / Quick Settings tile). When off, every + * layer is torn down and nothing restarts, regardless of any account's setting — + * this is the battery-saver "airplane mode". Persisted, so an explicit off survives + * restarts and crashes. + * - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService], + * "Keep this account active in the background"). While the master is on, the service + * runs as long as **at least one** writable account participates. + * + * While the master is on, every saved writable account is kept loaded in + * [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps + * addressed to any of them (delivered via open relay subscriptions) get unwrapped by + * the owning account's `newNotesPreProcessor`. Without this, wraps for non-active + * accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no + * subscriber able to decrypt them. */ class AlwaysOnNotificationServiceManager( private val context: Context, @@ -68,22 +79,52 @@ class AlwaysOnNotificationServiceManager( private var wasEnabled = false /** - * Starts watching the given account's always-on setting. - * When the setting changes, all layers are started or stopped accordingly. - * On initial load with false, nothing happens (no-op for users who never enabled it). + * Starts watching the global master switch and the participation flags of every + * loaded writable account. The service layers run while the master is on AND at + * least one account participates; the master overrides everything when off. + * + * Idempotent: safe to call again on account switch/login — it restarts the watch. */ - fun watchAccount(account: Account) { + @OptIn(ExperimentalCoroutinesApi::class) + fun start() { watchJob?.cancel() wasEnabled = false watchJob = scope.launch { - account.settings.alwaysOnNotificationService.collectLatest { enabled -> - if (enabled) { - wasEnabled = true - enableAllLayers() - } else if (wasEnabled) { - disableAllLayers() + localPreferences.notificationServiceEnabledFlow().collectLatest { masterEnabled -> + if (!masterEnabled) { + // Global airplane mode: suppress every layer regardless of + // per-account participation, and stop keeping accounts loaded. + if (wasEnabled) { + disableServiceLayers() + wasEnabled = false + } + stopMultiAccountPreload() + return@collectLatest } + + // Master on: keep every writable account loaded so its participation + // flag is observable and its gift wraps can decrypt, then run the + // service only while at least one account is participating. + startMultiAccountPreload() + accountsCache.accounts + .flatMapLatest { accounts -> + val flags = accounts.values.map { it.settings.alwaysOnNotificationService } + if (flags.isEmpty()) { + flowOf(false) + } else { + combine(flags) { values -> values.any { it } } + } + }.distinctUntilChanged() + .collectLatest { anyParticipating -> + if (anyParticipating) { + wasEnabled = true + enableServiceLayers() + } else if (wasEnabled) { + disableServiceLayers() + wasEnabled = false + } + } } } } @@ -93,10 +134,15 @@ class AlwaysOnNotificationServiceManager( watchJob = null preloadJob?.cancel() preloadJob = null + // Logout/terminate: tear the layers down explicitly. Otherwise the watchdog alarm + // and periodic worker stay scheduled and would resurrect the service for a + // logged-out user (nobody participating). + disableServiceLayers() + wasEnabled = false } - private fun enableAllLayers() { - Log.d(TAG, "Enabling all notification service layers") + private fun enableServiceLayers() { + Log.d(TAG, "Enabling notification service layers") // L1: Start foreground service NotificationRelayService.start(context) @@ -108,12 +154,10 @@ class AlwaysOnNotificationServiceManager( ServiceWatchdogManager.schedule(context) // L2 (FCM) and L4 (BOOT_COMPLETED) are always active via manifest - - startMultiAccountPreload() } - private fun disableAllLayers() { - Log.d(TAG, "Disabling all notification service layers") + private fun disableServiceLayers() { + Log.d(TAG, "Disabling notification service layers") // L1: Stop foreground service NotificationRelayService.stop(context) @@ -123,8 +167,6 @@ class AlwaysOnNotificationServiceManager( // L5: Cancel watchdog alarm ServiceWatchdogManager.cancel(context) - - stopMultiAccountPreload() } /** @@ -154,7 +196,7 @@ class AlwaysOnNotificationServiceManager( /** * Cancels the preload collector and releases every cached account except the - * currently active one, so users with the setting off return to single-account + * currently active one, so users with the master off return to single-account * memory/battery footprint. */ private fun stopMultiAccountPreload() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index c255ea4b36..2f48e5466b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -37,6 +37,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.pluralStringRes @@ -117,13 +118,18 @@ class NotificationRelayService : Service() { context.stopService(Intent(context, NotificationRelayService::class.java)) } + // Gate for the restart layers (watchdog, boot, catch-up worker, onResume): the + // global master must be on, and — matching the manager's rule — at least one loaded + // writable account must be participating. An empty cache (logged out, or accounts + // not yet loaded) gates OFF, so the layers never resurrect the service for a + // logged-out user; on cold boot the account loads headlessly and the manager starts + // the service itself once a participant exists. fun isEnabled(context: Context): Boolean = try { - Amethyst.instance.sessionManager - .loggedInAccount() - ?.settings - ?.alwaysOnNotificationService - ?.value == true + LocalPreferences.isNotificationServiceEnabled() && + Amethyst.instance.accountsCache.accounts.value.values.any { + it.settings.alwaysOnNotificationService.value + } } catch (e: Exception) { false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt new file mode 100644 index 0000000000..7c9b4ea907 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt @@ -0,0 +1,141 @@ +/* + * 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.service.notifications + +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Quick Settings tile that temporarily enables or disables the always-on background + * notification service — a battery-saver "airplane mode" — without digging through + * Settings. + * + * The tile toggles the **global master** switch ([LocalPreferences.setNotificationServiceEnabled]), + * not any per-account setting: when off, [AlwaysOnNotificationServiceManager] tears down + * every layer for all accounts; when on, each account's own "keep active in the + * background" flag decides who participates. The master is persisted, so an explicit off + * survives restarts and crashes. + * + * The switch is global, so the tile does not depend on a logged-in account. It only + * needs `Amethyst.instance` (main process) to reach [LocalPreferences]; during the brief + * cold-start window before that is built — or in the `:napplet` process, which never + * hosts this tile — it renders as unavailable and the next `onStartListening` recovers. + */ +class NotificationServiceTileService : TileService() { + companion object { + private const val TAG = "NotifServiceTile" + } + + private var scope: CoroutineScope? = null + private var watchJob: Job? = null + + /** True once `Amethyst.instance` (and therefore [LocalPreferences]) is reachable. */ + private fun preferencesReady(): Boolean = + try { + Amethyst.instance + true + } catch (e: Exception) { + false + } + + override fun onStartListening() { + super.onStartListening() + // onStartListening/onStopListening are balanced by the framework, but cancel + // any stale collector defensively so a re-entrant start can't leak a scope. + watchJob?.cancel() + scope?.cancel() + + refreshTile() + if (!preferencesReady()) return + + val newScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + scope = newScope + // Keep the tile in sync when the master is flipped elsewhere (the Settings + // toggle) while the shade is open. + watchJob = + newScope.launch { + LocalPreferences.notificationServiceEnabledFlow().collectLatest { + refreshTile() + } + } + } + + override fun onStopListening() { + watchJob?.cancel() + watchJob = null + scope?.cancel() + scope = null + super.onStopListening() + } + + override fun onClick() { + super.onClick() + if (!preferencesReady()) { + Log.w(TAG, "Preferences not ready; ignoring tile click") + refreshTile() + return + } + LocalPreferences.setNotificationServiceEnabled(!LocalPreferences.isNotificationServiceEnabled()) + refreshTile() + } + + private fun refreshTile() { + val tile = qsTile ?: return + + if (!preferencesReady()) { + tile.state = Tile.STATE_UNAVAILABLE + } else { + val enabled = LocalPreferences.isNotificationServiceEnabled() + tile.state = if (enabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + tile.subtitleCompat( + getString( + if (enabled) { + R.string.always_on_notif_tile_subtitle_on + } else { + R.string.always_on_notif_tile_subtitle_off + }, + ), + ) + } + + // Label and icon come from the manifest (), + // which is the tile's default — no need to reset them on every refresh. + tile.updateTile() + } + + private fun Tile.subtitleCompat(text: CharSequence) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + subtitle = text + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt index d0b82a09e6..7f10209e3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt @@ -34,10 +34,12 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -50,8 +52,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.AccountInfo +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper import com.vitorpamplona.amethyst.service.notifications.NotificationChannels import com.vitorpamplona.amethyst.ui.components.PushNotificationProviderTile @@ -59,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.components.hasPushNotificationProvider import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -89,8 +95,13 @@ fun NotificationSettingsScreen( @Composable private fun DeliverySection(accountViewModel: AccountViewModel) { - val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService - .collectAsStateWithLifecycle() + // Global master switch (persisted, all accounts). produceState + runCatching keeps + // the @Preview safe when Amethyst.instance / LocalPreferences aren't available. + val master by produceState(initialValue = true) { + runCatching { + LocalPreferences.notificationServiceEnabledFlow().collect { value = it } + } + } SettingsSection(R.string.notification_settings_section_delivery) { if (hasPushNotificationProvider()) { @@ -99,18 +110,69 @@ private fun DeliverySection(accountViewModel: AccountViewModel) { } SettingsSwitchTile( icon = MaterialSymbols.Notifications, - title = R.string.always_on_notif_setting_title, - description = R.string.always_on_notif_setting_description, - checked = alwaysOn, - onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() }, + title = R.string.notification_service_master_title, + description = R.string.notification_service_master_description, + checked = master, + onCheckedChange = { LocalPreferences.setNotificationServiceEnabled(it) }, ) } - if (alwaysOn) { + if (master) { + BackgroundAccountsSection() BatteryOptimizationBanner() } } +/** + * Per-account participation list, shown under the master switch: one "keep active in the + * background" toggle per write-enabled account. Each row toggles that account's own + * [AccountSettings.alwaysOnNotificationService]; because LocalPreferences caches one + * AccountSettings per npub, the toggle reaches the same instance the always-on manager + * observes, so participation changes take effect live. + */ +@Composable +private fun BackgroundAccountsSection() { + val accounts by produceState>>(emptyList()) { + value = + runCatching { + LocalPreferences + .allSavedAccounts() + .filter { it.hasPrivKey || it.loggedInWithExternalSigner } + .mapNotNull { info -> + LocalPreferences.loadAccountConfigFromEncryptedStorage(info.npub)?.let { info to it } + } + }.getOrDefault(emptyList()) + } + + if (accounts.isEmpty()) return + + SettingsSection(R.string.notification_service_accounts_title) { + accounts.forEachIndexed { index, (info, settings) -> + if (index > 0) SettingsDivider() + AccountParticipationRow(info, settings) + } + } +} + +@Composable +private fun AccountParticipationRow( + info: AccountInfo, + settings: AccountSettings, +) { + val participates by settings.alwaysOnNotificationService.collectAsStateWithLifecycle() + SettingsControlRow( + icon = MaterialSymbols.AccountCircle, + title = info.npub.toShortDisplay(), + description = stringRes(R.string.notification_service_participation_title), + onClick = { settings.toggleAlwaysOnNotificationService() }, + ) { + Switch( + checked = participates, + onCheckedChange = { settings.toggleAlwaysOnNotificationService() }, + ) + } +} + @Composable private fun DisplaySection(accountViewModel: AccountViewModel) { val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 97004239da..513e12f364 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1702,6 +1702,15 @@ Always-on notification service Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message. + Notification service + On + Off + + Background notification service + Master switch for real-time background notifications. Turn off to save battery — like an airplane mode. When off, no account stays connected in the background, and it stays off (across restarts) until you turn it back on. + Accounts active in the background + Keep this account active in the background + Split notifications by Follows Show two notification tabs — Following (people you follow) and Everyone. The unread indicator glows only for activity from people you follow. Show Messages From 452a1c8186d8f6a601146fb715f8944e01b1e8ea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 18:37:35 +0000 Subject: [PATCH 6/8] fix: harden LIMITS parsing and dedup, per audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the four findings from the branch audit: - Make the kotlinx codec (LimitsKSerializer, the iOS/native incoming path) as lenient as the Jackson path: mistyped fields degrade to null, non-int array elements are skipped, and a payload-less ["LIMITS"] frame yields an empty message instead of throwing. Guard the payload access in MessageKSerializer and MessageDeserializer likewise. - Make the Jackson reads (LimitsDeserializer) type-checked so an explicit JSON null or wrong-typed value stays null ("unspecified — keep previous") instead of coercing to false/0. Both codecs now behave identically. - LimitsMessage -> data class, so StateFlow.distinctUntilChanged in RelayLimitsTracker suppresses no-op emissions when a relay re-advertises identical limits, and tests get value equality. - Drop the stale "NIP-22" labels (LIMITS is nostr-protocol/nips#1434, not NIP-22) from LimitsKSerializer, MessageKSerializer and MessageSerializer. Tests: added mistyped/null-field and payload-less coverage on both the Jackson and kotlinx paths, plus a value-equality check. quartz:jvmTest (RelayWireErgonomicsTest 11, KotlinSerializationMapperTest 57, RelayLimitsTrackerTest 5) and amethyst play compile green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ --- .../kotlinSerialization/LimitsKSerializer.kt | 67 +++++++++++-------- .../kotlinSerialization/MessageKSerializer.kt | 7 +- .../relay/commands/toClient/LimitsMessage.kt | 2 +- .../relay/commands/RelayWireErgonomicsTest.kt | 35 ++++++++++ .../commands/toClient/LimitsDeserializer.kt | 44 +++++++----- .../commands/toClient/MessageDeserializer.kt | 11 +-- .../commands/toClient/MessageSerializer.kt | 2 +- .../KotlinSerializationMapperTest.kt | 20 ++++++ 8 files changed, 136 insertions(+), 52 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt index d4bc50164a..85d6c5c9a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt @@ -23,19 +23,35 @@ package com.vitorpamplona.quartz.nip01Core.kotlinSerialization import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.add import kotlinx.serialization.json.addJsonArray -import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray -/** kotlinx-serialization codec for the NIP-22 `LIMITS` object payload. */ +/** kotlinx-serialization codec for the `LIMITS` object payload. */ object LimitsKSerializer { + // Tolerant readers so a malformed/mistyped field degrades to null instead of + // throwing: this is the iOS/native incoming-parse path, and it must match the + // leniency of the Jackson path (LimitsDeserializer) used on jvmAndroid. + 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.intList(key: String): List? = (this[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.intOrNull } + + private fun JsonObject.tagList(key: String): List>? = + (this[key] as? JsonArray)?.map { tag -> + (tag as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList() + } + fun serializeToElement(value: LimitsMessage): JsonObject = buildJsonObject { // Only emit the fields the relay actually set; absent limits stay absent. @@ -71,26 +87,23 @@ object LimitsKSerializer { fun deserializeFromElement(jsonObject: JsonObject): LimitsMessage = LimitsMessage( - canWrite = jsonObject["can_write"]?.jsonPrimitive?.boolean, - canRead = jsonObject["can_read"]?.jsonPrimitive?.boolean, - authForRead = jsonObject["auth_for_read"]?.jsonPrimitive?.boolean, - authForWrite = jsonObject["auth_for_write"]?.jsonPrimitive?.boolean, - acceptedEventKinds = jsonObject["accepted_event_kinds"]?.jsonArray?.map { it.jsonPrimitive.int }, - blockedEventKinds = jsonObject["blocked_event_kinds"]?.jsonArray?.map { it.jsonPrimitive.int }, - minPowDifficulty = jsonObject["min_pow_difficulty"]?.jsonPrimitive?.int, - maxMessageLength = jsonObject["max_message_length"]?.jsonPrimitive?.int, - maxSubscriptions = jsonObject["max_subscriptions"]?.jsonPrimitive?.int, - maxFilters = jsonObject["max_filters"]?.jsonPrimitive?.int, - maxLimit = jsonObject["max_limit"]?.jsonPrimitive?.int, - maxEventTags = jsonObject["max_event_tags"]?.jsonPrimitive?.int, - maxContentLength = jsonObject["max_content_length"]?.jsonPrimitive?.int, - createdAtMsecsAgo = jsonObject["created_at_msecs_ago"]?.jsonPrimitive?.long, - createdAtMsecsAhead = jsonObject["created_at_msecs_ahead"]?.jsonPrimitive?.long, - filterRateLimit = jsonObject["filter_rate_limit"]?.jsonPrimitive?.long, - publishingRateLimit = jsonObject["publishing_rate_limit"]?.jsonPrimitive?.long, - requiredTags = - jsonObject["required_tags"]?.jsonArray?.map { tag -> - (tag as JsonArray).map { it.jsonPrimitive.content } - }, + canWrite = jsonObject.bool("can_write"), + canRead = jsonObject.bool("can_read"), + authForRead = jsonObject.bool("auth_for_read"), + authForWrite = jsonObject.bool("auth_for_write"), + acceptedEventKinds = jsonObject.intList("accepted_event_kinds"), + blockedEventKinds = jsonObject.intList("blocked_event_kinds"), + minPowDifficulty = jsonObject.int("min_pow_difficulty"), + maxMessageLength = jsonObject.int("max_message_length"), + maxSubscriptions = jsonObject.int("max_subscriptions"), + maxFilters = jsonObject.int("max_filters"), + maxLimit = jsonObject.int("max_limit"), + maxEventTags = jsonObject.int("max_event_tags"), + maxContentLength = jsonObject.int("max_content_length"), + createdAtMsecsAgo = jsonObject.long("created_at_msecs_ago"), + createdAtMsecsAhead = jsonObject.long("created_at_msecs_ahead"), + filterRateLimit = jsonObject.long("filter_rate_limit"), + publishingRateLimit = jsonObject.long("publishing_rate_limit"), + requiredTags = jsonObject.tagList("required_tags"), ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 0892e776e6..adba985212 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -39,6 +39,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonArray @@ -99,7 +100,7 @@ object MessageKSerializer : KSerializer { } is LimitsMessage -> { - // NIP-22 wire format: ["LIMITS", { }] + // LIMITS wire format: ["LIMITS", { }] add(LimitsKSerializer.serializeToElement(value)) } @@ -167,7 +168,9 @@ object MessageKSerializer : KSerializer { } LimitsMessage.LABEL -> { - LimitsKSerializer.deserializeFromElement(array[1].jsonObject) + // Tolerate a payload-less or malformed ["LIMITS"] frame instead of throwing. + val payload = array.getOrNull(1) as? JsonObject ?: JsonObject(emptyMap()) + LimitsKSerializer.deserializeFromElement(payload) } NegMsgMessage.LABEL -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt index 38b692825b..1ecd2f20f7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt @@ -35,7 +35,7 @@ import androidx.compose.runtime.Immutable * payload on the relay connection and apply it when sending `EVENT`s and `REQ`s. */ @Immutable -class LimitsMessage( +data class LimitsMessage( /** Whether clients may publish events to this relay. */ val canWrite: Boolean? = null, /** Whether clients may send `REQ` commands to this relay. */ diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt index d2c4499727..3b7e726019 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt @@ -172,4 +172,39 @@ class RelayWireErgonomicsTest { assertNull(parsed.canRead) assertNull(parsed.maxLimit) } + + @Test + fun limitsMessageToleratesPayloadlessFrame() { + // A malformed ["LIMITS"] with no object must not throw; it yields an empty message. + val parsed = Message.fromJson("""["LIMITS"]""") + assertTrue(parsed is LimitsMessage) + assertNull(parsed.canRead) + assertNull(parsed.maxLimit) + } + + @Test + fun limitsMessageToleratesMistypedAndNullFields() { + // Wrong-typed and explicitly-null fields degrade to null ("unspecified"), + // never to false/0, and never throw. + val json = + """["LIMITS",{"can_write":null,"max_limit":"lots","max_filters":true,""" + + """"accepted_event_kinds":[1,"x",3],"required_tags":["oops",["t","nostr"]]}]""" + val parsed = Message.fromJson(json) + assertTrue(parsed is LimitsMessage) + assertNull(parsed.canWrite, "explicit null stays null, not false") + assertNull(parsed.maxLimit, "a string is not an int -> null, not 0") + assertNull(parsed.maxFilters, "a boolean is not an int -> null") + assertEquals(listOf(1, 3), parsed.acceptedEventKinds, "non-int array elements are skipped") + // The bare "oops" string is not a tag array -> empty; the real pair survives. + assertEquals(listOf(emptyList(), listOf("t", "nostr")), parsed.requiredTags) + } + + @Test + fun limitsMessageHasValueEquality() { + // data class equality lets StateFlow.distinctUntilChanged suppress no-op re-advertisements. + assertEquals( + LimitsMessage(canWrite = true, maxLimit = 200, acceptedEventKinds = listOf(1, 2)), + LimitsMessage(canWrite = true, maxLimit = 200, acceptedEventKinds = listOf(1, 2)), + ) + } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt index fcd8e345f4..33b6a439bd 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt @@ -24,32 +24,42 @@ import com.fasterxml.jackson.databind.JsonNode class LimitsDeserializer { companion object { - private fun JsonNode.intList(field: String): List? = get(field)?.takeIf { it.isArray }?.map { it.asInt() } + // Type-checked readers: a missing field, an explicit JSON null, or a + // wrong-typed value all yield null ("unspecified — keep the previous + // value") rather than coercing to false/0. Keeps this in step with the + // kotlinx path (LimitsKSerializer) used on iOS/native. + private fun JsonNode.bool(field: String): Boolean? = get(field)?.takeIf { it.isBoolean }?.booleanValue() + + private fun JsonNode.int(field: String): Int? = get(field)?.takeIf { it.isNumber }?.intValue() + + private fun JsonNode.long(field: String): Long? = get(field)?.takeIf { it.isNumber }?.longValue() + + private fun JsonNode.intList(field: String): List? = get(field)?.takeIf { it.isArray }?.mapNotNull { it.takeIf { n -> n.isNumber }?.intValue() } private fun JsonNode.requiredTags(field: String): List>? = get(field)?.takeIf { it.isArray }?.map { tag -> - tag.map { it.asText() } + if (tag.isArray) tag.mapNotNull { it.takeIf { n -> n.isValueNode }?.asText() } else emptyList() } fun fromJson(jsonObject: JsonNode): LimitsMessage = LimitsMessage( - canWrite = jsonObject.get("can_write")?.asBoolean(), - canRead = jsonObject.get("can_read")?.asBoolean(), - authForRead = jsonObject.get("auth_for_read")?.asBoolean(), - authForWrite = jsonObject.get("auth_for_write")?.asBoolean(), + canWrite = jsonObject.bool("can_write"), + canRead = jsonObject.bool("can_read"), + authForRead = jsonObject.bool("auth_for_read"), + authForWrite = jsonObject.bool("auth_for_write"), acceptedEventKinds = jsonObject.intList("accepted_event_kinds"), blockedEventKinds = jsonObject.intList("blocked_event_kinds"), - minPowDifficulty = jsonObject.get("min_pow_difficulty")?.asInt(), - maxMessageLength = jsonObject.get("max_message_length")?.asInt(), - maxSubscriptions = jsonObject.get("max_subscriptions")?.asInt(), - maxFilters = jsonObject.get("max_filters")?.asInt(), - maxLimit = jsonObject.get("max_limit")?.asInt(), - maxEventTags = jsonObject.get("max_event_tags")?.asInt(), - maxContentLength = jsonObject.get("max_content_length")?.asInt(), - createdAtMsecsAgo = jsonObject.get("created_at_msecs_ago")?.asLong(), - createdAtMsecsAhead = jsonObject.get("created_at_msecs_ahead")?.asLong(), - filterRateLimit = jsonObject.get("filter_rate_limit")?.asLong(), - publishingRateLimit = jsonObject.get("publishing_rate_limit")?.asLong(), + minPowDifficulty = jsonObject.int("min_pow_difficulty"), + maxMessageLength = jsonObject.int("max_message_length"), + maxSubscriptions = jsonObject.int("max_subscriptions"), + maxFilters = jsonObject.int("max_filters"), + maxLimit = jsonObject.int("max_limit"), + maxEventTags = jsonObject.int("max_event_tags"), + maxContentLength = jsonObject.int("max_content_length"), + createdAtMsecsAgo = jsonObject.long("created_at_msecs_ago"), + createdAtMsecsAhead = jsonObject.long("created_at_msecs_ahead"), + filterRateLimit = jsonObject.long("filter_rate_limit"), + publishingRateLimit = jsonObject.long("publishing_rate_limit"), requiredTags = jsonObject.requiredTags("required_tags"), ) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt index 52badf4dbe..7e45082421 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt @@ -104,10 +104,13 @@ class MessageDeserializer : StdDeserializer(Message::class.java) { } LimitsMessage.LABEL -> { - jp.nextToken() - val result: JsonNode = jp.codec.readTree(jp) - - LimitsDeserializer.fromJson(result) + // Tolerate a payload-less ["LIMITS"] frame instead of throwing. + if (jp.nextToken() == JsonToken.START_OBJECT) { + val result: JsonNode = jp.codec.readTree(jp) + LimitsDeserializer.fromJson(result) + } else { + LimitsMessage() + } } NegMsgMessage.LABEL -> { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index b7a144bb15..86274bf1e6 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -81,7 +81,7 @@ class MessageSerializer : StdSerializer(Message::class.java) { } is LimitsMessage -> { - // NIP-22 wire format: ["LIMITS", { }]. Only + // LIMITS wire format: ["LIMITS", { }]. Only // the fields the relay set are emitted; absent limits stay absent. gen.writeStartObject() msg.canWrite?.let { gen.writeBooleanField("can_write", it) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt index 22c00ec147..40e7aa5884 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt @@ -543,6 +543,26 @@ class KotlinSerializationMapperTest { assertNull(deserialized.maxFilters) } + @Test + fun deserializeLimitsMessageToleratesMistypedAndMissingPayload() { + // The kotlinx path is the iOS/native incoming parser; a mistyped field or a + // payload-less ["LIMITS"] must degrade to null / empty, matching Jackson, + // rather than throwing. + val mistyped = + KotlinSerializationMapper.fromJsonToMessage( + """["LIMITS",{"can_write":null,"max_limit":"lots","accepted_event_kinds":[1,"x",3],"required_tags":["oops",["t","nostr"]]}]""", + ) + assertTrue(mistyped is LimitsMessage) + assertNull(mistyped.canWrite) + assertNull(mistyped.maxLimit) + assertEquals(listOf(1, 3), mistyped.acceptedEventKinds) + assertEquals(listOf(emptyList(), listOf("t", "nostr")), mistyped.requiredTags) + + val payloadless = KotlinSerializationMapper.fromJsonToMessage("""["LIMITS"]""") + assertTrue(payloadless is LimitsMessage) + assertNull(payloadless.maxLimit) + } + @Test fun crossDeserializationLimitsMessage() { val msg = From ccecc5edd4ac56de781f31f81bb2b01760a70adb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 18:49:12 +0000 Subject: [PATCH 7/8] style: spotlessApply on LocalPreferences after merging main Merged origin/main into the branch; a formatting violation in LocalPreferences.kt (globalSettingsPrefs, added on main) was surfacing in the PR merge-ref lint check. spotlessApply collapses it to one line (whitespace only, no behavior change) so CI's lint job passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ --- .../main/java/com/vitorpamplona/amethyst/LocalPreferences.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index a6783c51da..3f17440e3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -223,8 +223,7 @@ object LocalPreferences { // otherwise a saved OFF would be missed on cold boot and the service would resurrect. // The flow is lazily seeded from disk once (synchronous, main-safe) and is thereafter // the source of truth, so there is no async hydrate that could clobber a user toggle. - private fun globalSettingsPrefs(): SharedPreferences = - Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE) + private fun globalSettingsPrefs(): SharedPreferences = Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE) private val notificationServiceEnabled: MutableStateFlow by lazy { MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)) From 5c19017ef654b85adbd0a37b64b1ae0204c5a65e Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:27:09 +0000 Subject: [PATCH 8/8] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-zh-rCN/strings.xml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 102a38a788..ec088ef1ea 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -296,7 +296,7 @@ 兑换邀请中… 无法获取此邀请。链接可能已过期或其中继不可访问。 此邀请链接无效或无法用此账户打开。 - 此邀请是用新版本的应用创建,尚无法在此处打开。 询问已更新的链接,或在更新后重试。 + 此邀请是用新版本的应用创建,尚无法在此处打开。 请求已更新的链接,或在更新后重试。 此邀请链接已被撤销,不能再使用。请求新链接。 Concord 频道 您尚未加入任何Concord频道。创建一个频道,或者打开一个邀请链接。 @@ -1898,7 +1898,7 @@ 绑定单一中继 临时聊天 不论谁在线,立马聊天 - 现在在线 + 当前在线 与当前在线的任何人进行即时聊天。 开始聊天 与当前在线的人交谈 @@ -1937,6 +1937,7 @@ 成员 设为管理员 设为协管 + 分配角色: %1$s 删除职位 从群组中删除 从此群中移除 %1$s ?在重新添加或重新邀请之前这些人将失去访问权限。 @@ -1972,6 +1973,15 @@ 比特币,nostr,艺术品 位置 (gehash) u0nd + 结构 + 把这个群置于父级下方以构建一个层次结构。 + 父群 + 顶层群 + 选择父群 + 搜索群 + 没有父级(顶层) + 此群位于顶层 + 此中继上尚没有其他群 权限 私密 只有成员可以阅读消息。