mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
Merge remote-tracking branch 'origin/main' into claude/amethyst-mobile-colors-kgdfhn
This commit is contained in:
@@ -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 =
|
||||
|
||||
+71
-29
@@ -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() {
|
||||
|
||||
+11
-5
@@ -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
|
||||
}
|
||||
|
||||
+141
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-7
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
-113
@@ -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>>?,
|
||||
)
|
||||
+109
@@ -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"),
|
||||
)
|
||||
}
|
||||
+13
@@ -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,
|
||||
|
||||
+2
@@ -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}" }
|
||||
}
|
||||
}
|
||||
|
||||
+104
@@ -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)
|
||||
}
|
||||
}
|
||||
+81
@@ -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"
|
||||
}
|
||||
}
|
||||
+136
@@ -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())
|
||||
}
|
||||
}
|
||||
+109
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+66
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+10
@@ -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(),
|
||||
|
||||
+41
@@ -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)
|
||||
|
||||
+91
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user