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..22d08179ab 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.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
@@ -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 = RelayLimitsTracker(client)
+
// Resource-usage ledger: relay traffic/reconnect + connection-time,
// foreground-time, process-CPU, and signature-verification collectors.
init {
@@ -1100,11 +1104,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..3f17440e3b 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,36 @@ 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-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
+ 结构
+ 把这个群置于父级下方以构建一个层次结构。
+ 父群
+ 顶层群
+ 选择父群
+ 搜索群
+ 没有父级(顶层)
+ 此群位于顶层
+ 此中继上尚没有其他群
权限
私密
只有成员可以阅读消息。
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
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.
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/kotlinSerialization/LimitsKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt
new file mode 100644
index 0000000000..85d6c5c9a4
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt
@@ -0,0 +1,109 @@
+/*
+ * 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.JsonPrimitive
+import kotlinx.serialization.json.add
+import kotlinx.serialization.json.addJsonArray
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.buildJsonObject
+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 `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.
+ 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.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 800420e49c..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
@@ -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
@@ -38,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
@@ -97,6 +99,11 @@ object MessageKSerializer : KSerializer {
add(JsonPrimitive(value.subId))
}
+ is LimitsMessage -> {
+ // LIMITS wire format: ["LIMITS", { }]
+ add(LimitsKSerializer.serializeToElement(value))
+ }
+
is NegMsgMessage -> {
add(JsonPrimitive(value.subId))
add(JsonPrimitive(value.message))
@@ -160,6 +167,12 @@ object MessageKSerializer : KSerializer {
CountMessage(queryId, result)
}
+ LimitsMessage.LABEL -> {
+ // 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 -> {
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/client/limits/RelayLimitsTracker.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt
new file mode 100644
index 0000000000..a8ffa37d63
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.
+ *
+ * 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 RelayLimitsTracker(
+ 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("RelayLimitsTracker", "Init, Subscribe")
+ client.addConnectionListener(clientListener)
+ }
+
+ fun destroy() {
+ // makes sure to run
+ Log.d("RelayLimitsTracker", "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
new file mode 100644
index 0000000000..1ecd2f20f7
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt
@@ -0,0 +1,81 @@
+/*
+ * 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 androidx.compose.runtime.Immutable
+
+/**
+ * `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.
+ */
+@Immutable
+data 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/client/limits/RelayLimitsTrackerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt
new file mode 100644
index 0000000000..6547aeb9bb
--- /dev/null
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.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 RelayLimitsTrackerTest {
+ 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 = RelayLimitsTracker(client)
+ val listener = client.captured ?: error("RelayLimitsTracker 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())
+ }
+}
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..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
@@ -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,112 @@ 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)
+ }
+
+ @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
new file mode 100644
index 0000000000..33b6a439bd
--- /dev/null
+++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt
@@ -0,0 +1,66 @@
+/*
+ * 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 {
+ // 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 ->
+ if (tag.isArray) tag.mapNotNull { it.takeIf { n -> n.isValueNode }?.asText() } else emptyList()
+ }
+
+ fun fromJson(jsonObject: JsonNode): LimitsMessage =
+ LimitsMessage(
+ 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.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..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
@@ -103,6 +103,16 @@ class MessageDeserializer : StdDeserializer(Message::class.java) {
)
}
+ LimitsMessage.LABEL -> {
+ // 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 -> {
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..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
@@ -80,6 +80,47 @@ class MessageSerializer : StdSerializer(Message::class.java) {
gen.writeString(msg.subId)
}
+ is LimitsMessage -> {
+ // 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) }
+ 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..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
@@ -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,96 @@ 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 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 =
+ 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 =