Merge remote-tracking branch 'origin/main' into claude/amethyst-mobile-colors-kgdfhn

This commit is contained in:
Claude
2026-07-16 22:21:55 +00:00
23 changed files with 1243 additions and 202 deletions
+11
View File
@@ -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." />
</service>
<service
android:name=".service.notifications.NotificationServiceTileService"
android:icon="@drawable/amethyst_service"
android:label="@string/always_on_notif_tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<receiver
android:name=".service.notifications.BootCompletedReceiver"
android:exported="false">
@@ -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()
}
@@ -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<String, AccountSettings?> = 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<Boolean> by lazy {
MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true))
}
fun notificationServiceEnabledFlow(): StateFlow<Boolean> = 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 =
@@ -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() {
@@ -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
}
@@ -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 (<service android:label/android:icon>),
// 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
}
}
}
@@ -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<List<Pair<AccountInfo, AccountSettings>>>(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
@@ -296,7 +296,7 @@
<string name="concord_redeeming_invite">兑换邀请中…</string>
<string name="concord_invite_failed">无法获取此邀请。链接可能已过期或其中继不可访问。</string>
<string name="concord_invite_failed_invalid">此邀请链接无效或无法用此账户打开。</string>
<string name="concord_invite_failed_incompatible">此邀请是用新版本的应用创建,尚无法在此处打开。 询问已更新的链接,或在更新后重试。</string>
<string name="concord_invite_failed_incompatible">此邀请是用新版本的应用创建,尚无法在此处打开。 请求已更新的链接,或在更新后重试。</string>
<string name="concord_invite_failed_revoked">此邀请链接已被撤销,不能再使用。请求新链接。</string>
<string name="concord_home_title">Concord 频道</string>
<string name="concord_home_empty">您尚未加入任何Concord频道。创建一个频道,或者打开一个邀请链接。</string>
@@ -1898,7 +1898,7 @@
<string name="new_conversation_relay_group_con_1">绑定单一中继</string>
<string name="new_conversation_ephemeral_title">临时聊天</string>
<string name="new_conversation_ephemeral_tagline">不论谁在线,立马聊天</string>
<string name="new_conversation_ephemeral_chip">现在在线</string>
<string name="new_conversation_ephemeral_chip">当前在线</string>
<string name="new_conversation_ephemeral_best">与当前在线的任何人进行即时聊天。</string>
<string name="new_conversation_ephemeral_cta">开始聊天</string>
<string name="new_conversation_ephemeral_pro_1">与当前在线的人交谈</string>
@@ -1937,6 +1937,7 @@
<string name="relay_group_members_title">成员</string>
<string name="relay_group_make_admin">设为管理员</string>
<string name="relay_group_make_moderator">设为协管</string>
<string name="relay_group_assign_role">分配角色: %1$s</string>
<string name="relay_group_demote_member">删除职位</string>
<string name="relay_group_remove_user">从群组中删除</string>
<string name="relay_group_remove_user_confirm">从此群中移除 %1$s ?在重新添加或重新邀请之前这些人将失去访问权限。</string>
@@ -1972,6 +1973,15 @@
<string name="relay_group_field_topics_hint">比特币,nostr,艺术品</string>
<string name="relay_group_field_geohash">位置 (gehash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_section_structure">结构</string>
<string name="relay_group_parent_desc">把这个群置于父级下方以构建一个层次结构。</string>
<string name="relay_group_parent_label">父群</string>
<string name="relay_group_parent_none">顶层群</string>
<string name="relay_group_parent_pick_title">选择父群</string>
<string name="relay_group_parent_search">搜索群</string>
<string name="relay_group_parent_top_level_option">没有父级(顶层)</string>
<string name="relay_group_parent_none_desc">此群位于顶层</string>
<string name="relay_group_parent_empty">此中继上尚没有其他群</string>
<string name="relay_group_section_permissions">权限</string>
<string name="relay_group_flag_private">私密</string>
<string name="relay_group_flag_private_desc">只有成员可以阅读消息。</string>
+9
View File
@@ -1702,6 +1702,15 @@
<string name="always_on_notif_setting_title">Always-on notification service</string>
<string name="always_on_notif_setting_description">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.</string>
<string name="always_on_notif_tile_label">Notification service</string>
<string name="always_on_notif_tile_subtitle_on">On</string>
<string name="always_on_notif_tile_subtitle_off">Off</string>
<string name="notification_service_master_title">Background notification service</string>
<string name="notification_service_master_description">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.</string>
<string name="notification_service_accounts_title">Accounts active in the background</string>
<string name="notification_service_participation_title">Keep this account active in the background</string>
<string name="split_notifications_setting_title">Split notifications by Follows</string>
<string name="split_notifications_setting_description">Show two notification tabs — Following (people you follow) and Everyone. The unread indicator glows only for activity from people you follow.</string>
<string name="show_messages_in_notifications_setting_title">Show Messages</string>
+113
View File
@@ -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<PersistentMap<url, LimitsMessage>>`. 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.
@@ -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<Array<String>>,
): 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
}
}
}
@@ -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<Int>?,
@SerialName("blocked_event_kinds") val blockedEventKinds: Set<Int>?,
@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<Array<String>>?,
)
@@ -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<Int>? = (this[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.intOrNull }
private fun JsonObject.tagList(key: String): List<List<String>>? =
(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"),
)
}
@@ -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<Message> {
add(JsonPrimitive(value.subId))
}
is LimitsMessage -> {
// LIMITS wire format: ["LIMITS", { <limit_properties> }]
add(LimitsKSerializer.serializeToElement(value))
}
is NegMsgMessage -> {
add(JsonPrimitive(value.subId))
add(JsonPrimitive(value.message))
@@ -160,6 +167,12 @@ object MessageKSerializer : KSerializer<Message> {
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,
@@ -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}" }
}
}
@@ -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<PersistentMap<NormalizedRelayUrl, LimitsMessage>>(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<PersistentMap<NormalizedRelayUrl, LimitsMessage>> = _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<NormalizedRelayUrl, LimitsMessage> = _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)
}
}
@@ -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", { <limit_properties> }]`.
*
* 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<Int>? = null,
/** Denylist of event kinds the relay rejects for publishing. */
val blockedEventKinds: List<Int>? = 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<List<String>>? = null,
) : Message {
override fun label() = LABEL
companion object {
const val LABEL = "LIMITS"
}
}
@@ -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<RelayLimitsTracker, RelayConnectionListener> {
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())
}
}
@@ -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)),
)
}
}
@@ -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<Int>? = get(field)?.takeIf { it.isArray }?.mapNotNull { it.takeIf { n -> n.isNumber }?.intValue() }
private fun JsonNode.requiredTags(field: String): List<List<String>>? =
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"),
)
}
}
@@ -103,6 +103,16 @@ class MessageDeserializer : StdDeserializer<Message>(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(),
@@ -80,6 +80,47 @@ class MessageSerializer : StdSerializer<Message>(Message::class.java) {
gen.writeString(msg.subId)
}
is LimitsMessage -> {
// LIMITS wire format: ["LIMITS", { <limit_properties> }]. 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)
@@ -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 =