mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
Merge remote-tracking branch 'origin/main' into claude/armada-nip29-integration-lwqard
# Conflicts: # cli/tests/.gitignore
This commit is contained in:
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.defaults
|
||||
|
||||
/**
|
||||
* Curated indexer relays for resolving NIP-17 inbox lookups (kind:10050).
|
||||
*
|
||||
* Used by [com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver]
|
||||
* via a SEPARATE unauthenticated NostrClient — these queries MUST NOT carry an
|
||||
* AUTH event back to the user's identity key (security review F-01: an
|
||||
* authenticated indexer fan-out turns "indexer learns we queried for pubkey X"
|
||||
* into "indexer learns Amethyst user U queried for pubkey X").
|
||||
*
|
||||
* Set selected for known kind:10050 indexing coverage; `purplepag.es` is
|
||||
* deliberately excluded (metadata indexer, weak kind:10050 coverage).
|
||||
*/
|
||||
object DefaultDmIndexerRelays {
|
||||
val RELAYS: List<String> =
|
||||
listOf(
|
||||
"wss://relay.nos.social",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://purplerelay.com",
|
||||
)
|
||||
}
|
||||
@@ -114,6 +114,24 @@ class User(
|
||||
|
||||
fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays()
|
||||
|
||||
/**
|
||||
* Strict variant of [dmInboxRelays] that returns ONLY the user's NIP-17
|
||||
* inbox relays (kind:10050) and never falls back to the NIP-65 read
|
||||
* marker (kind:10002).
|
||||
*
|
||||
* Per NIP-17 §Publishing, gift wraps MUST land on relays advertised in
|
||||
* the recipient's kind:10050 — the NIP-65 read fallback in
|
||||
* [dmInboxRelays] is a UI-convenience heuristic that leaks the DM
|
||||
* metadata to relays the recipient did not designate for DMs. Any code
|
||||
* that decides "can I actually deliver a NIP-17 wrap to this user"
|
||||
* should call this strict variant; UI hints and probe-time bootstrap
|
||||
* paths may continue to use the lenient one.
|
||||
*
|
||||
* Returns `null` when the recipient has no published kind:10050 (or an
|
||||
* empty one) — callers treat this as "unreachable via NIP-17".
|
||||
*/
|
||||
fun dmInboxRelaysStrict() = dmInboxRelayList()?.relays()?.ifEmpty { null }
|
||||
|
||||
fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: mostUsedNonLocalRelay()
|
||||
|
||||
fun allUsedRelaysOrNull() = relays?.allOrNull()
|
||||
|
||||
+1
@@ -185,6 +185,7 @@ class NostrSignerPermissionLedger(
|
||||
* Deliberately conservative: when a kind's blast radius is unclear, it is left out so the user
|
||||
* is asked rather than surprised.
|
||||
*/
|
||||
@Suppress("DEPRECATION") // TorrentCommentEvent is deprecated (NIP-22) but still a reasonable sign kind
|
||||
val REASONABLE_SIGN_KINDS: Set<Int> =
|
||||
setOf(
|
||||
TextNoteEvent.KIND, // 1 — short text notes & replies
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacylock
|
||||
|
||||
/**
|
||||
* Routes gated by the privacy lock.
|
||||
*
|
||||
* A single master `PrivacyLockSettings.lockEnabled` flag protects all scopes
|
||||
* together, but each scope keeps its own [PrivacyLockState] so that unlock,
|
||||
* idle-timer, and leave-route transitions apply independently per route.
|
||||
*/
|
||||
enum class LockScope { Messages, Wallet }
|
||||
+2
-2
@@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
interface PrivacyLockSettings {
|
||||
val lockEnabled: StateFlow<Boolean>
|
||||
val inactivityTimer: StateFlow<InactivityTimer>
|
||||
val redactionLevel: StateFlow<DmRedactionLevel>
|
||||
val dmRedactionLevel: StateFlow<DmRedactionLevel>
|
||||
val firstRunCardSeen: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
@@ -68,7 +68,7 @@ interface PrivacyLockSettings {
|
||||
|
||||
fun setInactivityTimer(timer: InactivityTimer)
|
||||
|
||||
fun setRedactionLevel(level: DmRedactionLevel)
|
||||
fun setDmRedactionLevel(level: DmRedactionLevel)
|
||||
|
||||
fun setFirstRunCardSeen(seen: Boolean)
|
||||
|
||||
|
||||
+41
-14
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacylock
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -33,19 +35,25 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* App-global state holder for the Messages privacy lock.
|
||||
* App-global state holder for a single privacy-lock [scope].
|
||||
*
|
||||
* One instance per gated route (Messages, Wallet, …) is provided via
|
||||
* [LocalPrivacyLockState] at the App composition root. All instances share
|
||||
* the same [PrivacyLockSettings] — one master `lockEnabled` flag enables
|
||||
* every scope together — but each scope keeps its own [LockState] and its
|
||||
* own idle-timer [Job] so unlock, leave-route, and inactivity transitions
|
||||
* apply independently per route.
|
||||
*
|
||||
* - Single instance per app, provided via [LocalMessagesLockState] at the
|
||||
* App composition root.
|
||||
* - Initial value is seeded synchronously from [settings.lockEnabled.value]
|
||||
* so the first composition sees [LockState.Locked] without flashing
|
||||
* content (deep-link race fix, plan §Security Hardening H1).
|
||||
* - The underlying StateFlow is hot (`MutableStateFlow`); notification path
|
||||
* can read `state.value` synchronously without subscribing.
|
||||
*/
|
||||
class MessagesLockState(
|
||||
class PrivacyLockState(
|
||||
val scope: LockScope,
|
||||
private val settings: PrivacyLockSettings,
|
||||
private val scope: CoroutineScope,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) {
|
||||
private val seed: LockState =
|
||||
if (settings.lockEnabled.value) LockState.Locked else LockState.Disabled
|
||||
@@ -64,12 +72,12 @@ class MessagesLockState(
|
||||
} else if (mutableState.value is LockState.Disabled) {
|
||||
mutableState.value = LockState.Locked
|
||||
}
|
||||
}.launchIn(scope)
|
||||
}.launchIn(coroutineScope)
|
||||
|
||||
combine(settings.lockEnabled, settings.inactivityTimer) { enabled, timer -> enabled to timer }
|
||||
.onEach { _ ->
|
||||
if (mutableState.value is LockState.Unlocked) restartIdleTimer()
|
||||
}.launchIn(scope)
|
||||
}.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
/** Resets the inactivity timer. No-op unless currently Unlocked. */
|
||||
@@ -90,7 +98,7 @@ class MessagesLockState(
|
||||
* Mark the session as authenticated. Transitions from either
|
||||
* [LockState.Locked] (normal unlock path) or [LockState.Disabled]
|
||||
* (first-run banner path — enabling the lock while the user is
|
||||
* actively in Messages should NOT flash the lock screen).
|
||||
* actively in a gated route should NOT flash the lock screen).
|
||||
* No-op if already [LockState.Unlocked]. Starts the idle timer.
|
||||
*/
|
||||
fun onUnlockSuccess() {
|
||||
@@ -105,7 +113,8 @@ class MessagesLockState(
|
||||
|
||||
/**
|
||||
* Triggered when biometric / OS credential is permanently unavailable.
|
||||
* Auto-disables the lock so the user can keep accessing Messages.
|
||||
* Auto-disables the lock (flips every scope to [LockState.Disabled]
|
||||
* via the shared setting) so the user can keep accessing gated routes.
|
||||
*/
|
||||
fun onCredentialUnavailable() {
|
||||
cancelIdleTimer()
|
||||
@@ -118,6 +127,10 @@ class MessagesLockState(
|
||||
* [PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES] failures: base 30 s,
|
||||
* doubling each further failure, capped at 5 min.
|
||||
*
|
||||
* Backoff state is shared across scopes — a mistyped password on the
|
||||
* Wallet gate locks out the Messages gate too (and vice versa). This is
|
||||
* intentional anti-brute-force behaviour.
|
||||
*
|
||||
* @param nowMs current epoch millis (injected for testability).
|
||||
* @return the new [PrivacyLockSettings.lockedUntilEpochMs] value, or
|
||||
* null when no lockout yet applies.
|
||||
@@ -148,7 +161,7 @@ class MessagesLockState(
|
||||
cancelIdleTimer()
|
||||
val millis = settings.inactivityTimer.value.millis ?: return
|
||||
idleTimerJob =
|
||||
scope.launch {
|
||||
coroutineScope.launch {
|
||||
delay(millis)
|
||||
if (mutableState.value is LockState.Unlocked) {
|
||||
mutableState.value = LockState.Locked
|
||||
@@ -162,8 +175,22 @@ class MessagesLockState(
|
||||
}
|
||||
}
|
||||
|
||||
/** Provided once at the App composition root. */
|
||||
val LocalMessagesLockState =
|
||||
compositionLocalOf<MessagesLockState> {
|
||||
error("LocalMessagesLockState not provided — wrap App() with CompositionLocalProvider")
|
||||
/**
|
||||
* Provided once at the App composition root. Map keyed by [LockScope]; every
|
||||
* scope must have an entry (see [lockStateFor] which throws when missing).
|
||||
*/
|
||||
val LocalPrivacyLockState =
|
||||
compositionLocalOf<Map<LockScope, PrivacyLockState>> {
|
||||
error("LocalPrivacyLockState not provided — wrap App() with CompositionLocalProvider")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience accessor used inside gate composables. Reads the map from the
|
||||
* ambient [LocalPrivacyLockState] and returns the state holder for [scope].
|
||||
* Throws if the scope was not registered at the App root.
|
||||
*/
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun lockStateFor(scope: LockScope): PrivacyLockState =
|
||||
LocalPrivacyLockState.current[scope]
|
||||
?: error("PrivacyLockState for $scope not registered at App root")
|
||||
+158
-9
@@ -33,13 +33,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Coordinates metadata and reactions loading for feed items.
|
||||
@@ -73,6 +76,15 @@ class FeedMetadataCoordinator(
|
||||
private val queuedPubkeys = mutableSetOf<HexKey>()
|
||||
private val queuedNoteIds = mutableSetOf<HexKey>()
|
||||
private val queuedBoostedIds = mutableSetOf<HexKey>()
|
||||
private val queuedKind3Pubkeys = mutableSetOf<HexKey>()
|
||||
|
||||
// Batched paths only — pubkeys currently in-flight in a batched REQ.
|
||||
// Prevents rapid re-fire of the same batch. Distinct from queuedPubkeys
|
||||
// and queuedKind3Pubkeys (which record "asked and at least one relay
|
||||
// returned EOSE") so a batch that times out with zero events can be
|
||||
// retried on the next call — see PR #3483 review finding 5.
|
||||
private val inFlightBatchedMetadata = mutableSetOf<HexKey>()
|
||||
private val inFlightBatchedKind3 = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Start processing the subscription queue.
|
||||
@@ -251,14 +263,24 @@ class FeedMetadataCoordinator(
|
||||
/**
|
||||
* Fast-path: batched metadata subscription for visible-viewport authors.
|
||||
* Bypasses rate limiter. Single filter with all authors. Closes after EOSE.
|
||||
*
|
||||
* Pubkeys are moved into [queuedPubkeys] (dedup) only after at least one
|
||||
* relay EOSE'd. On timeout with zero EOSE (index relays all unreachable)
|
||||
* they roll out of [inFlightBatchedMetadata] so a subsequent call can
|
||||
* retry — see PR #3483 review finding 5.
|
||||
*/
|
||||
fun loadMetadataBatched(
|
||||
pubkeys: List<HexKey>,
|
||||
timeoutMs: Long = 5_000L,
|
||||
) {
|
||||
val newPubkeys = pubkeys.filter { it !in queuedPubkeys }.distinct()
|
||||
val newPubkeys =
|
||||
pubkeys
|
||||
.asSequence()
|
||||
.filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata }
|
||||
.distinct()
|
||||
.toList()
|
||||
if (newPubkeys.isEmpty()) return
|
||||
queuedPubkeys.addAll(newPubkeys)
|
||||
inFlightBatchedMetadata.addAll(newPubkeys)
|
||||
|
||||
scope.launch {
|
||||
val filter =
|
||||
@@ -269,8 +291,7 @@ class FeedMetadataCoordinator(
|
||||
)
|
||||
val filterMap = indexRelays.associateWith { listOf(filter) }
|
||||
val subId = newSubId()
|
||||
val eoseReceived = mutableSetOf<NormalizedRelayUrl>()
|
||||
val allEose = CompletableDeferred<Unit>()
|
||||
val gate = BatchEoseGate(scope, target = indexRelays.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
@@ -287,16 +308,96 @@ class FeedMetadataCoordinator(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eoseReceived.add(relay)
|
||||
if (eoseReceived.size >= indexRelays.size) {
|
||||
allEose.complete(Unit)
|
||||
}
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
withTimeoutOrNull(timeoutMs) { allEose.await() }
|
||||
val eosedRelays = gate.awaitAll(timeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
if (eosedRelays > 0) {
|
||||
queuedPubkeys.addAll(newPubkeys)
|
||||
}
|
||||
inFlightBatchedMetadata.removeAll(newPubkeys.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched kind-3 (follow list) subscription. Used by the WoT service
|
||||
* to fetch the follow lists of every account the active user follows,
|
||||
* so friends-of-friends counts can be computed.
|
||||
*
|
||||
* Chunks authors into ≤100 per Filter within a single subscription
|
||||
* so relays with per-filter author caps (nostr-rs-relay defaults to
|
||||
* ~100) don't silently truncate the batch. Aggregates EOSE across
|
||||
* chunks and calls [onEose] once (or after [timeoutMs]).
|
||||
*
|
||||
* Pubkeys are moved into [queuedKind3Pubkeys] (dedup) only after at
|
||||
* least one relay EOSE'd. On timeout with zero EOSE (index relays all
|
||||
* unreachable — common on flaky mobile networks) they roll out of
|
||||
* [inFlightBatchedKind3] so the next `loadKind3Batched` call retries
|
||||
* — see PR #3483 review finding 5.
|
||||
*/
|
||||
fun loadKind3Batched(
|
||||
pubkeys: Collection<HexKey>,
|
||||
timeoutMs: Long = 5_000L,
|
||||
onEose: () -> Unit = {},
|
||||
) {
|
||||
val newPubkeys =
|
||||
pubkeys
|
||||
.asSequence()
|
||||
.filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 }
|
||||
.distinct()
|
||||
.toList()
|
||||
if (newPubkeys.isEmpty()) {
|
||||
onEose()
|
||||
return
|
||||
}
|
||||
inFlightBatchedKind3.addAll(newPubkeys)
|
||||
|
||||
scope.launch {
|
||||
val filters =
|
||||
newPubkeys.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
authors = chunk,
|
||||
limit = chunk.size,
|
||||
)
|
||||
}
|
||||
val filterMap = indexRelays.associateWith { filters }
|
||||
val subId = newSubId()
|
||||
val gate = BatchEoseGate(scope, target = indexRelays.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
this@FeedMetadataCoordinator.onEvent?.invoke(event, relay)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
val eosedRelays = gate.awaitAll(timeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
if (eosedRelays > 0) {
|
||||
queuedKind3Pubkeys.addAll(newPubkeys)
|
||||
}
|
||||
inFlightBatchedKind3.removeAll(newPubkeys.toSet())
|
||||
|
||||
onEose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,5 +408,53 @@ class FeedMetadataCoordinator(
|
||||
priorityQueue.clear()
|
||||
queuedPubkeys.clear()
|
||||
queuedNoteIds.clear()
|
||||
queuedKind3Pubkeys.clear()
|
||||
inFlightBatchedMetadata.clear()
|
||||
inFlightBatchedKind3.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates EOSE notifications from per-relay `onEose` callbacks
|
||||
* (which the client may dispatch on `Dispatchers.IO`) via a
|
||||
* [Channel]. The consumer coroutine is the sole reader/writer of the
|
||||
* `seen` set, eliminating the race the previous `mutableSetOf` +
|
||||
* shared-state check had — see PR #3483 review finding 6.
|
||||
*
|
||||
* [awaitAll] blocks up to [timeoutMs] and returns the number of
|
||||
* relays that EOSE'd (may be less than [target] on timeout). The
|
||||
* count feeds the retry decision in the batched loaders.
|
||||
*/
|
||||
private class BatchEoseGate(
|
||||
private val scope: CoroutineScope,
|
||||
private val target: Int,
|
||||
) {
|
||||
private val incoming = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
|
||||
private val done = CompletableDeferred<Unit>()
|
||||
|
||||
@Volatile private var lastCount = 0
|
||||
|
||||
fun notifyEose(relay: NormalizedRelayUrl) {
|
||||
incoming.trySend(relay)
|
||||
}
|
||||
|
||||
suspend fun awaitAll(timeoutMs: Long): Int {
|
||||
if (target <= 0) return 0
|
||||
val consumer =
|
||||
scope.launch {
|
||||
val seen = mutableSetOf<NormalizedRelayUrl>()
|
||||
for (relay in incoming) {
|
||||
if (seen.add(relay)) {
|
||||
lastCount = seen.size
|
||||
if (seen.size >= target && !done.isCompleted) {
|
||||
done.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
withTimeoutOrNull(timeoutMs) { done.await() }
|
||||
incoming.close()
|
||||
consumer.join()
|
||||
return lastCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.auth
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
|
||||
/**
|
||||
* Inline AUTH approval banner.
|
||||
*
|
||||
* Renders one row per pending tier-2 NIP-42 AUTH challenge with three
|
||||
* actions: `[Once]` `[Always]` `[Never]`. Each press calls [onResolve]
|
||||
* with the user's choice, which the parent (typically a coordinator)
|
||||
* uses to complete the underlying [PendingAuthApproval.decision]
|
||||
* deferred and persist the scope.
|
||||
*
|
||||
* Stacks up to 3 entries inline; the rest collapse into a `+N more` row
|
||||
* (a future iteration may expand them on click — keep simple for now).
|
||||
*
|
||||
* The component is platform-agnostic and lives in `commons` so Android
|
||||
* and Desktop can render the same UX once the wire-up is built on each
|
||||
* platform.
|
||||
*/
|
||||
@Composable
|
||||
fun AuthApprovalBanner(
|
||||
pending: List<PendingAuthApproval>,
|
||||
onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = pending.isNotEmpty(),
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
val visible = pending.take(3)
|
||||
val hidden = pending.size - visible.size
|
||||
|
||||
visible.forEach { approval ->
|
||||
AuthApprovalRow(approval = approval, onResolve = onResolve)
|
||||
}
|
||||
|
||||
if (hidden > 0) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "+$hidden more relay${if (hidden == 1) "" else "s"} pending approval",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthApprovalRow(
|
||||
approval: PendingAuthApproval,
|
||||
onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f),
|
||||
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = approval.relayUrl.displayUrl(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (approval.pendingCount > 1) {
|
||||
"requires authentication for ${approval.pendingCount} messages"
|
||||
} else {
|
||||
"requires authentication to deliver this message"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ONCE) }) {
|
||||
Text("Once", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ALWAYS) }) {
|
||||
Text("Always", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.BLOCKED) }) {
|
||||
Text("Never", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.auth
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Persisted scope for an AUTH approval decision.
|
||||
*
|
||||
* `ONCE` is in-memory only — never written to disk. `ALWAYS` and `BLOCKED`
|
||||
* persist via [AuthApprovalStore].
|
||||
*/
|
||||
enum class AuthApprovalScope {
|
||||
/** Approve this session; don't persist. */
|
||||
ONCE,
|
||||
|
||||
/** Approve indefinitely (or until the store's TTL expires the row). */
|
||||
ALWAYS,
|
||||
|
||||
/** Reject indefinitely. Future AUTH challenges from this relay are silently dropped. */
|
||||
BLOCKED,
|
||||
}
|
||||
|
||||
/**
|
||||
* The classifier verdict for a single AUTH challenge.
|
||||
*
|
||||
* `Allow` and `Block` are immediate. `Pending` means the user needs to decide;
|
||||
* the policy hands back a [CompletableDeferred] that the UI banner completes
|
||||
* once the user picks `[Once] [Always] [Never]`.
|
||||
*/
|
||||
sealed interface AuthApprovalDecision {
|
||||
/** Auto-sign the AUTH event for this relay. */
|
||||
data object Allow : AuthApprovalDecision
|
||||
|
||||
/** Silently drop the AUTH challenge. */
|
||||
data object Block : AuthApprovalDecision
|
||||
|
||||
/**
|
||||
* Suspend the signer until the user resolves the prompt.
|
||||
*
|
||||
* @property pending populated with the user's choice when the banner is
|
||||
* actioned. The signer awaits this deferred; if it resolves to
|
||||
* [AuthApprovalScope.BLOCKED] the AUTH is dropped, otherwise signed.
|
||||
*/
|
||||
data class Pending(
|
||||
val pending: CompletableDeferred<AuthApprovalScope>,
|
||||
) : AuthApprovalDecision
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending tier-2 AUTH approval surfaced to the user.
|
||||
*
|
||||
* Created when the policy decides a challenge needs user consent. Subscribers
|
||||
* (an `AccountAuthApprovals` ViewModel — wired in P2.5) render a banner with
|
||||
* `[Once] [Always] [Never]` buttons that resolve [decision] via `complete()`.
|
||||
*
|
||||
* `pendingCount` lets the banner coalesce multiple challenges from the same
|
||||
* relay into one row (`"<relay-url> requires authentication for 3 messages"`)
|
||||
* rather than stacking duplicate banners.
|
||||
*/
|
||||
data class PendingAuthApproval(
|
||||
val relayUrl: NormalizedRelayUrl,
|
||||
val decision: CompletableDeferred<AuthApprovalScope>,
|
||||
val pendingCount: Int = 1,
|
||||
)
|
||||
|
||||
/**
|
||||
* Per-account approval store. Implementations persist `ALWAYS` / `BLOCKED`
|
||||
* grants (typically to a SQLite `auth_approvals` table, wired in P2.4).
|
||||
*
|
||||
* `getScope` returns `null` if no decision is recorded for the relay.
|
||||
*/
|
||||
interface AuthApprovalStore {
|
||||
/** Returns the persisted decision for `relayUrl`, or `null` if unknown. */
|
||||
suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope?
|
||||
|
||||
/**
|
||||
* Record a user decision. `ONCE` decisions are NOT persisted by contract —
|
||||
* the policy caches them in-memory for the current session only.
|
||||
*/
|
||||
suspend fun setScope(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
)
|
||||
|
||||
/** Wipe all persisted approvals. Called on account delete / logout. */
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory [AuthApprovalStore] used as a development scaffold and as the
|
||||
* `ONCE` cache layer on top of a persistent store. Tier-2 banner approvals
|
||||
* with `ONCE` scope live here for the session and are dropped on logout.
|
||||
*/
|
||||
class InMemoryAuthApprovalStore : AuthApprovalStore {
|
||||
private val scopes = mutableMapOf<NormalizedRelayUrl, AuthApprovalScope>()
|
||||
private val lock = Mutex()
|
||||
|
||||
override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? = lock.withLock { scopes[relayUrl] }
|
||||
|
||||
override suspend fun setScope(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
) {
|
||||
lock.withLock { scopes[relayUrl] = scope }
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
lock.withLock { scopes.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The classifier between the relay client's `signWithAllLoggedInUsers` lambda
|
||||
* and the actual signer.
|
||||
*
|
||||
* Two tiers:
|
||||
*
|
||||
* - **Tier 1 (auto-allow):** the relay is in the user's own outbox or
|
||||
* NIP-17 DM-inbox set, or has a persisted `ALWAYS` grant. Sign immediately,
|
||||
* no prompt. These are relays the user has already declared they trust.
|
||||
* - **Tier 2 (prompt):** anything else, with the exception of relays that
|
||||
* carry a persisted `BLOCKED` grant. Surface a [PendingAuthApproval] via
|
||||
* [onPromptRequired] and suspend until the user resolves the
|
||||
* [CompletableDeferred]. If `ONCE`, cache for this session; if `ALWAYS` or
|
||||
* `BLOCKED`, persist via the store.
|
||||
*
|
||||
* No tier-3: every challenge is either auto-allowed, blocked by a persisted
|
||||
* decision, or surfaced to the user. There is no silent third path.
|
||||
*
|
||||
* @property selfApprovedRelays the union of own outbox + DM-inbox + any
|
||||
* account-level pre-approval. Recomputed by the caller on Account state
|
||||
* changes. Tier 1 if the challenger is in this set.
|
||||
* @property store persistence layer (SQLite-backed in production, in-memory in
|
||||
* tests).
|
||||
* @property onPromptRequired called when a [PendingAuthApproval] needs to be
|
||||
* surfaced to the UI. The UI subscribes to this side-channel and completes
|
||||
* the contained [CompletableDeferred] with the user's pick.
|
||||
*/
|
||||
class AuthApprovalPolicy(
|
||||
val selfApprovedRelays: () -> Set<NormalizedRelayUrl>,
|
||||
val store: AuthApprovalStore,
|
||||
val onPromptRequired: (PendingAuthApproval) -> Unit,
|
||||
) {
|
||||
/**
|
||||
* Decide what to do with an AUTH challenge from `relayUrl`.
|
||||
*
|
||||
* @return [AuthApprovalDecision.Allow] for tier-1 / persisted-ALWAYS,
|
||||
* [AuthApprovalDecision.Block] for persisted-BLOCKED,
|
||||
* [AuthApprovalDecision.Pending] (and emits to [onPromptRequired]) for
|
||||
* unknown relays.
|
||||
*/
|
||||
suspend fun classify(relayUrl: NormalizedRelayUrl): AuthApprovalDecision {
|
||||
// Persisted decision wins over tier-1: if user explicitly blocked a
|
||||
// relay that happens to also be in their outbox, respect the block.
|
||||
when (store.getScope(relayUrl)) {
|
||||
AuthApprovalScope.ALWAYS -> return AuthApprovalDecision.Allow
|
||||
AuthApprovalScope.BLOCKED -> return AuthApprovalDecision.Block
|
||||
AuthApprovalScope.ONCE -> return AuthApprovalDecision.Allow
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
if (relayUrl in selfApprovedRelays()) {
|
||||
return AuthApprovalDecision.Allow
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<AuthApprovalScope>()
|
||||
onPromptRequired(PendingAuthApproval(relayUrl, deferred))
|
||||
return AuthApprovalDecision.Pending(deferred)
|
||||
}
|
||||
|
||||
/** Persist (or cache) the user's choice from a [PendingAuthApproval] resolution. */
|
||||
suspend fun recordDecision(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
) {
|
||||
// `ONCE` lives in-memory only (the InMemoryAuthApprovalStore handles
|
||||
// this transparently). `ALWAYS` and `BLOCKED` persist via the store.
|
||||
store.setScope(relayUrl, scope)
|
||||
}
|
||||
}
|
||||
+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.amethyst.commons.relayClient.nip17Dm
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Resolves a recipient's NIP-17 inbox relays (kind:10050) for DM delivery.
|
||||
*
|
||||
* Three-layer lookup, in order:
|
||||
*
|
||||
* 1. **LocalCache hit** — the caller has already seen the user's kind:10050
|
||||
* via the normal feed subscription pipeline. Cheapest; no I/O.
|
||||
* 2. **In-memory LRU cache** — a prior resolve() succeeded for this pubkey
|
||||
* within the TTL. Avoids re-querying indexers when the user opens a
|
||||
* conversation list and clicks several recipients in sequence.
|
||||
* 3. **Indexer fan-out** — query a curated set of indexer relays for the
|
||||
* user's kind:10050 via [RecipientRelayFetcher]. The client passed in
|
||||
* here MUST be an **unauthenticated** instance (no [RelayAuthenticator]
|
||||
* attached) — otherwise an indexer's AUTH challenge would extract an
|
||||
* identity-key signature from the user, turning the metadata leak
|
||||
* "indexer learns who we want to DM" into "indexer learns user U wants
|
||||
* to DM pubkey X".
|
||||
*
|
||||
* Filters to **kind:10050 only**. Per NIP-17 §Publishing, gift wraps MUST
|
||||
* land on relays in the recipient's kind:10050; this resolver never
|
||||
* substitutes the NIP-65 read marker as a fallback, because doing so leaks
|
||||
* DMs to relays the recipient did not explicitly designate for DMs.
|
||||
*
|
||||
* Empty result is the canonical "we don't know where to send" signal — the
|
||||
* caller should refuse to publish rather than fall back to its own relays
|
||||
* (see [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount.resolveDmInboxRelaysStrict]).
|
||||
*
|
||||
* @property unauthenticatedClient NostrClient WITHOUT a RelayAuthenticator
|
||||
* attached. Use a dedicated instance — do NOT pass the app's primary
|
||||
* client.
|
||||
* @property indexerRelays Curated indexer set. Typically
|
||||
* [com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays].
|
||||
* @property localLookup Callback the resolver invokes first to check the
|
||||
* LocalCache — returns the user's current kind:10050 list or null if
|
||||
* unknown. Allows commons/headless callers to plug in a CLI-safe lookup.
|
||||
* @property cacheTtlMs LRU cache TTL. 1h matches the brainstorm's open
|
||||
* question; configurable here for tests.
|
||||
* @property cacheSize LRU bound. 100 entries × ~200 bytes each is trivial
|
||||
* memory; matches typical active-conversation count for power users.
|
||||
*/
|
||||
class DmInboxRelayResolver(
|
||||
private val unauthenticatedClient: INostrClient,
|
||||
private val indexerRelays: Set<NormalizedRelayUrl>,
|
||||
private val localLookup: (HexKey) -> List<NormalizedRelayUrl>?,
|
||||
private val cacheTtlMs: Long = 60 * 60 * 1_000L,
|
||||
private val cacheSize: Int = 100,
|
||||
private val nowMs: () -> Long = {
|
||||
kotlin.time.Clock.System
|
||||
.now()
|
||||
.toEpochMilliseconds()
|
||||
},
|
||||
) {
|
||||
private data class Entry(
|
||||
val relays: List<NormalizedRelayUrl>,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
private val cache = linkedMapOf<HexKey, Entry>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Resolve `pubkey`'s NIP-17 inbox relays. Returns empty list if neither
|
||||
* the LocalCache nor the indexer fan-out yielded a kind:10050.
|
||||
*/
|
||||
suspend fun resolve(pubkey: HexKey): List<NormalizedRelayUrl> {
|
||||
localLookup(pubkey)?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
|
||||
val now = nowMs()
|
||||
mutex.withLock {
|
||||
cache[pubkey]?.let { entry ->
|
||||
if (entry.expiresAtMs > now) {
|
||||
// Refresh LRU order on hit
|
||||
cache.remove(pubkey)
|
||||
cache[pubkey] = entry
|
||||
return entry.relays
|
||||
} else {
|
||||
cache.remove(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (indexerRelays.isEmpty()) return emptyList()
|
||||
|
||||
val lists = RecipientRelayFetcher.fetchRelayLists(unauthenticatedClient, pubkey, indexerRelays)
|
||||
// Strict: kind:10050 ONLY. No NIP-65 fallback. Empty = canonical
|
||||
// "unreachable" signal; caller refuses to publish.
|
||||
val relays = lists.dmInbox
|
||||
|
||||
mutex.withLock {
|
||||
cache[pubkey] = Entry(relays, now + cacheTtlMs)
|
||||
while (cache.size > cacheSize) {
|
||||
cache.remove(cache.keys.iterator().next())
|
||||
}
|
||||
}
|
||||
return relays
|
||||
}
|
||||
|
||||
/** Evict a specific entry — e.g. when LocalCache observes a fresh kind:10050. */
|
||||
suspend fun invalidate(pubkey: HexKey) {
|
||||
mutex.withLock { cache.remove(pubkey) }
|
||||
}
|
||||
|
||||
/** Wipe the entire cache — e.g. on account switch. */
|
||||
suspend fun clear() {
|
||||
mutex.withLock { cache.clear() }
|
||||
}
|
||||
}
|
||||
+25
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.commons.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -62,6 +64,10 @@ data class ProfilePictureUrl(
|
||||
* @param loadProfilePicture Whether to load the profile picture (false = show robohash only)
|
||||
* @param loadRobohash Whether to generate robohash (false = show generic icon)
|
||||
* @param useThumbnailCache Whether to use the thumbnail disk cache for faster repeated loads
|
||||
* @param badge Optional overlay drawn on top of the avatar (bottom-right by
|
||||
* convention). Used by Desktop for the WoT trust-score chip; Android call
|
||||
* sites leave it null. When null the avatar renders as before (no extra
|
||||
* `Box` wrapper).
|
||||
*/
|
||||
@Composable
|
||||
fun UserAvatar(
|
||||
@@ -73,7 +79,26 @@ fun UserAvatar(
|
||||
loadProfilePicture: Boolean = true,
|
||||
loadRobohash: Boolean = true,
|
||||
useThumbnailCache: Boolean = false,
|
||||
badge: @Composable (BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
if (badge != null) {
|
||||
Box(modifier = modifier.size(size)) {
|
||||
UserAvatar(
|
||||
userHex = userHex,
|
||||
pictureUrl = pictureUrl,
|
||||
size = size,
|
||||
modifier = Modifier,
|
||||
contentDescription = contentDescription,
|
||||
loadProfilePicture = loadProfilePicture,
|
||||
loadRobohash = loadRobohash,
|
||||
useThumbnailCache = useThumbnailCache,
|
||||
badge = null,
|
||||
)
|
||||
badge()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val avatarModifier =
|
||||
remember(size, modifier) {
|
||||
modifier
|
||||
|
||||
+7
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -46,12 +47,17 @@ import org.jetbrains.compose.resources.stringResource
|
||||
/**
|
||||
* A card displaying user search result with avatar, name, and nip05/pubkey.
|
||||
* Shared between Android and Desktop search screens.
|
||||
*
|
||||
* @param badge Optional overlay drawn on top of the avatar (bottom-right
|
||||
* by convention). Used by Desktop for the WoT trust-score chip; Android
|
||||
* call sites leave it null. Forwarded to [UserAvatar].
|
||||
*/
|
||||
@Composable
|
||||
fun UserSearchCard(
|
||||
user: User,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
badge: @Composable (BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
@@ -73,6 +79,7 @@ fun UserSearchCard(
|
||||
pictureUrl = user.profilePicture(),
|
||||
size = 40.dp,
|
||||
contentDescription = stringResource(Res.string.accessibility_user_avatar),
|
||||
badge = badge,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ enum class PromptResult {
|
||||
|
||||
/**
|
||||
* Credential surface permanently unavailable on this device — caller
|
||||
* should invoke [com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState.onCredentialUnavailable].
|
||||
* should invoke [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onCredentialUnavailable].
|
||||
*/
|
||||
Unavailable,
|
||||
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.ui.privacylock
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState
|
||||
|
||||
/**
|
||||
* Observes pointer events on the Initial pass — does NOT consume them, so
|
||||
@@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState
|
||||
* since they're not user input — preserves the "walked-away-from-desk"
|
||||
* protection per brainstorm resolved Q.
|
||||
*/
|
||||
fun Modifier.resetIdleOnInteraction(state: MessagesLockState): Modifier =
|
||||
fun Modifier.resetIdleOnInteraction(state: PrivacyLockState): Modifier =
|
||||
this.pointerInput(state) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.privacylock
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Shared lock-screen surface used by [MessagesLockGate] and [WalletLockGate].
|
||||
* Runs the async [CredentialPrompter] path (biometric / OS credential on
|
||||
* Android + iOS). Desktop platforms use a password-input inline lock screen
|
||||
* instead — see `DesktopMessagesLockGate` / `DesktopWalletLockGate`.
|
||||
*
|
||||
* Kept `internal` so the only public entry points are the per-scope Gates.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LockScreen(
|
||||
scope: LockScope,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
unlockLabel: String,
|
||||
) {
|
||||
val lockState = lockStateFor(scope)
|
||||
val prompter = LocalCredentialPrompter.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(prompter) {
|
||||
if (!prompter.available) {
|
||||
lockState.onCredentialUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Box(modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Box(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = 320.dp),
|
||||
)
|
||||
Box(modifier = Modifier.size(32.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
when (prompter.prompt()) {
|
||||
PromptResult.Success -> lockState.onUnlockSuccess()
|
||||
PromptResult.Unavailable -> lockState.onCredentialUnavailable()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = prompter.available,
|
||||
) {
|
||||
Text(text = unlockLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-91
@@ -20,40 +20,21 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.privacylock
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockState
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
|
||||
/**
|
||||
* Wraps the Messages route and gates entry behind the credential prompt.
|
||||
*
|
||||
* Branch selection happens SYNCHRONOUSLY in composition — no
|
||||
* [LaunchedEffect] guard — so the chat content composable never enters
|
||||
* composition while [LockState.Locked]. Closes the deep-link race
|
||||
* (plan §Security Hardening H1).
|
||||
* [androidx.compose.runtime.LaunchedEffect] guard — so the chat content
|
||||
* composable never enters composition while [LockState.Locked]. Closes the
|
||||
* deep-link race (plan §Security Hardening H1).
|
||||
*
|
||||
* The gate is an overlay, NOT a wrapper that disposes content. While
|
||||
* locked, the [content] lambda is not invoked at all; on unlock, the
|
||||
@@ -61,12 +42,14 @@ import kotlinx.coroutines.launch
|
||||
* `rememberSaveable` survive a lock cycle (SavedStateRegistry-backed).
|
||||
* For plain `remember` state, drafts are cleared — accept this trade-off.
|
||||
*
|
||||
* The gate also fires [MessagesLockState.onLeaveRoute] from its
|
||||
* [DisposableEffect.onDispose] block, so navigating away locks immediately.
|
||||
* The gate also fires
|
||||
* [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onLeaveRoute]
|
||||
* from its [DisposableEffect.onDispose] block, so navigating away locks
|
||||
* immediately.
|
||||
*/
|
||||
@Composable
|
||||
fun MessagesLockGate(content: @Composable () -> Unit) {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val lockState = lockStateFor(LockScope.Messages)
|
||||
val current by lockState.state.collectAsState()
|
||||
|
||||
DisposableEffect(lockState) {
|
||||
@@ -74,70 +57,13 @@ fun MessagesLockGate(content: @Composable () -> Unit) {
|
||||
}
|
||||
|
||||
when (current) {
|
||||
is LockState.Locked -> LockScreen()
|
||||
is LockState.Locked ->
|
||||
LockScreen(
|
||||
scope = LockScope.Messages,
|
||||
title = "Messages locked",
|
||||
subtitle = "Unlock to read or send messages.",
|
||||
unlockLabel = "Unlock",
|
||||
)
|
||||
else -> content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockScreen() {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val prompter = LocalCredentialPrompter.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(prompter) {
|
||||
if (!prompter.available) {
|
||||
lockState.onCredentialUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Box(modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = "Messages locked",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Box(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "Unlock to read or send messages",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = 320.dp),
|
||||
)
|
||||
Box(modifier = Modifier.size(32.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
when (prompter.prompt()) {
|
||||
PromptResult.Success -> lockState.onUnlockSuccess()
|
||||
PromptResult.Unavailable -> lockState.onCredentialUnavailable()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = prompter.available,
|
||||
) {
|
||||
Text(text = "Unlock")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.privacylock
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
|
||||
/**
|
||||
* Wraps the Wallet route and gates entry behind the credential prompt.
|
||||
*
|
||||
* Behaviour mirrors [MessagesLockGate] — see that composable's KDoc for the
|
||||
* deep-link race, draft persistence, and leave-route semantics. Only the
|
||||
* [LockScope] and the lock-screen copy differ.
|
||||
*
|
||||
* Desktop apps use the platform-specific `DesktopWalletLockGate` (password
|
||||
* input inline, no async CredentialPrompter round-trip); Android + iOS
|
||||
* front ends use this composable directly.
|
||||
*/
|
||||
@Composable
|
||||
fun WalletLockGate(content: @Composable () -> Unit) {
|
||||
val lockState = lockStateFor(LockScope.Wallet)
|
||||
val current by lockState.state.collectAsState()
|
||||
|
||||
DisposableEffect(lockState) {
|
||||
onDispose { lockState.onLeaveRoute() }
|
||||
}
|
||||
|
||||
when (current) {
|
||||
is LockState.Locked ->
|
||||
LockScreen(
|
||||
scope = LockScope.Wallet,
|
||||
title = "Wallet locked",
|
||||
subtitle = "Unlock to see your balance and send or receive sats.",
|
||||
unlockLabel = "Unlock",
|
||||
)
|
||||
else -> content()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ fun SigningAwareButton(
|
||||
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
when (signingState.state) {
|
||||
is SigningOpState.Pending -> {
|
||||
is SigningOpState.Pending, is SigningOpState.Progress -> {
|
||||
Box(modifier = modifier.size(32.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
|
||||
+36
-1
@@ -37,11 +37,29 @@ sealed class SigningOpState {
|
||||
|
||||
data object Pending : SigningOpState()
|
||||
|
||||
/**
|
||||
* Signing is in flight AND has a known step count — typically a NIP-17
|
||||
* group send via a remote signer (bunker), where the UI can usefully show
|
||||
* "Encrypting via remote signer ([current] of [total])".
|
||||
*
|
||||
* Treated as Pending for all is-pending checks via [isPending] below;
|
||||
* existing callers that branch on `is Pending` keep working unchanged.
|
||||
* New callers can render the counter when [SigningOpState] is `Progress`.
|
||||
*/
|
||||
data class Progress(
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val label: String? = null,
|
||||
) : SigningOpState()
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : SigningOpState()
|
||||
}
|
||||
|
||||
/** True when signing is in flight, regardless of whether step counts are known. */
|
||||
fun SigningOpState.isPending(): Boolean = this is SigningOpState.Pending || this is SigningOpState.Progress
|
||||
|
||||
/**
|
||||
* Global signing status — any [SigningState] instance updates this when signing starts/ends.
|
||||
* Observe [globalState] from a screen-level composable to show a persistent status bar.
|
||||
@@ -86,7 +104,7 @@ class SigningState {
|
||||
private set
|
||||
|
||||
suspend fun <T> execute(block: suspend () -> T): T? {
|
||||
if (state is SigningOpState.Pending) return null
|
||||
if (state.isPending()) return null
|
||||
state = SigningOpState.Pending
|
||||
GlobalSigningStatus.onPending()
|
||||
errorMessage = null
|
||||
@@ -114,6 +132,23 @@ class SigningState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the in-flight signing state with a progress counter. Use during
|
||||
* multi-step operations (NIP-17 group send via bunker, batch zaps) to
|
||||
* show the user how far the in-flight op has progressed.
|
||||
*
|
||||
* Only takes effect while [state] is Pending or Progress — no-op
|
||||
* otherwise so callers don't have to gate on Idle/Error themselves.
|
||||
*/
|
||||
fun updateProgress(
|
||||
current: Int,
|
||||
total: Int,
|
||||
label: String? = null,
|
||||
) {
|
||||
if (!state.isPending()) return
|
||||
state = SigningOpState.Progress(current, total, label)
|
||||
}
|
||||
|
||||
private fun setError(message: String) {
|
||||
errorMessage = message
|
||||
state = SigningOpState.Error(message)
|
||||
|
||||
+15
@@ -87,6 +87,21 @@ fun SigningStatusBar(
|
||||
}
|
||||
}
|
||||
|
||||
is SigningOpState.Progress -> {
|
||||
Snackbar(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
containerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
val label = opState.label ?: "Signing"
|
||||
Text(
|
||||
text = "$label (${opState.current} of ${opState.total})",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is SigningOpState.Error -> {
|
||||
Snackbar(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
|
||||
+72
-10
@@ -25,6 +25,8 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
@@ -41,6 +43,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Slim shared state for DM message composition.
|
||||
@@ -56,6 +59,19 @@ class ChatNewMessageState(
|
||||
val account: IAccount,
|
||||
val cache: ICacheProvider,
|
||||
val scope: CoroutineScope,
|
||||
/**
|
||||
* Optional resolver for probing kind:10050 via curated indexer relays
|
||||
* when a peer's DM inbox isn't in the local cache. When provided,
|
||||
* [updateRecipientRelayStatus] falls through to the resolver on a cache
|
||||
* miss so the pre-send UI stops falsely reporting "recipient has no DM
|
||||
* relay list" for accounts whose 10050 sits on an indexer we haven't
|
||||
* subscribed to yet.
|
||||
*
|
||||
* When null (default, e.g. Android's ChatNewMessageViewModel which
|
||||
* hasn't been wired to a resolver yet), behaviour matches the
|
||||
* cache-only strict check.
|
||||
*/
|
||||
private val dmInboxResolver: (suspend (HexKey) -> List<NormalizedRelayUrl>?)? = null,
|
||||
) {
|
||||
private val _message = MutableStateFlow(TextFieldValue(""))
|
||||
val message: StateFlow<TextFieldValue> = _message.asStateFlow()
|
||||
@@ -86,20 +102,66 @@ class ChatNewMessageState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all recipients have DM relay lists.
|
||||
* Messages can only be sent via NIP-17, so recipients must have
|
||||
* either a DM inbox relay list (kind 10050) or NIP-65 inbox relays.
|
||||
* Check whether every participant in the current room is reachable via
|
||||
* NIP-17 — i.e. has a published kind:10050 that lists at least one
|
||||
* relay.
|
||||
*
|
||||
* Uses the **strict** check ([com.vitorpamplona.amethyst.commons.model.User.dmInboxRelaysStrict],
|
||||
* kind:10050 only) instead of the lenient [dmInboxRelays] that falls
|
||||
* back to the NIP-65 read marker — the send path also uses strict
|
||||
* resolution (see `DesktopIAccount.resolveDmInboxRelaysStrict`), and
|
||||
* disagreement here caused the pre-send UI to green-light sends that
|
||||
* would then fail at send time.
|
||||
*
|
||||
* Two-phase check:
|
||||
*
|
||||
* 1. Synchronous cache check — every peer's kind:10050 sits in
|
||||
* [cache]. If all present with at least one relay, unblock
|
||||
* immediately.
|
||||
* 2. Async resolver probe (only when [dmInboxResolver] is provided) —
|
||||
* for peers whose 10050 isn't cached, kick off a curated-indexer
|
||||
* fan-out. If any peer's relays turn up, update the flag to
|
||||
* unblock the composer without requiring the user to restart the
|
||||
* conversation view.
|
||||
*
|
||||
* The blocking flag is set optimistically during the probe so the
|
||||
* user still sees the warning until we've confirmed the peer is
|
||||
* genuinely unreachable via NIP-17. This preserves the "don't allow
|
||||
* silent-fail sends" invariant.
|
||||
*/
|
||||
fun updateRecipientRelayStatus() {
|
||||
val currentRoom = _room.value
|
||||
if (currentRoom != null) {
|
||||
_recipientsMissingDmRelays.value =
|
||||
currentRoom.users.any { hexKey ->
|
||||
val user = cache.getOrCreateUser(hexKey)
|
||||
user?.dmInboxRelays().isNullOrEmpty()
|
||||
}
|
||||
} else {
|
||||
if (currentRoom == null) {
|
||||
_recipientsMissingDmRelays.value = false
|
||||
return
|
||||
}
|
||||
|
||||
val missing =
|
||||
currentRoom.users.filter { hexKey ->
|
||||
val user = cache.getOrCreateUser(hexKey)
|
||||
user?.dmInboxRelaysStrict().isNullOrEmpty()
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
_recipientsMissingDmRelays.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Cache miss → block optimistically, then probe indexers for the
|
||||
// missing peers. If any of them turn up a kind:10050, unblock.
|
||||
_recipientsMissingDmRelays.value = true
|
||||
val resolver = dmInboxResolver ?: return
|
||||
|
||||
scope.launch {
|
||||
val stillMissing =
|
||||
missing.any { hexKey ->
|
||||
val fanOut = resolver(hexKey)
|
||||
fanOut.isNullOrEmpty()
|
||||
}
|
||||
// The room may have changed while we were probing; only apply
|
||||
// the result if we're still looking at the same conversation.
|
||||
if (_room.value == currentRoom) {
|
||||
_recipientsMissingDmRelays.value = stillMissing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.wot
|
||||
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
|
||||
/**
|
||||
* Compose-observable score service. Provided by the Desktop app at App
|
||||
* root when a user is logged in. Left null on Android and while logged
|
||||
* out — leaf composables branch on `LocalWoTService.current == null` to
|
||||
* skip the WoT rendering path.
|
||||
*
|
||||
* The badge-hide predicates (self, already-followed) are read from
|
||||
* `commons.moderation.LocalSpamExemptKeys` — the same set already
|
||||
* provided by the hashtag-spam filter.
|
||||
*/
|
||||
val LocalWoTService: ProvidableCompositionLocal<WoTService?> =
|
||||
compositionLocalOf { null }
|
||||
|
||||
/**
|
||||
* Whether the WoT service has finished its initial batch fetch (or the
|
||||
* 2s startup timeout has elapsed). Read once at the App root via
|
||||
* [WoTService.isReady] and provided down as a scalar so leaf composables
|
||||
* don't each spawn a Flow collector.
|
||||
*/
|
||||
val LocalWoTReady: ProvidableCompositionLocal<Boolean> =
|
||||
compositionLocalOf { false }
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.wot
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
|
||||
/**
|
||||
* Platform-agnostic interface between [OutboxDispatcher] and the platform's
|
||||
* event cache. Desktop and `amy` each provide their own implementation —
|
||||
* DesktopLocalCache on the app side, a minimal in-memory adapter over the
|
||||
* amy local store on the CLI side.
|
||||
*
|
||||
* The dispatcher only needs three capabilities:
|
||||
*
|
||||
* 1. Peek at what kind-10002 events are already stored so it can skip
|
||||
* Phase-1 discovery for authors whose write-relay list is already
|
||||
* known (from hydration or a previous session's fetch).
|
||||
* 2. Ingest a kind-10002 that just came back from an index relay so
|
||||
* subsequent lookups don't re-fetch it.
|
||||
* 3. Ingest a kind-0 or kind-3 that just came back from an outbox
|
||||
* relay so the platform cache/UI can pick it up through the usual
|
||||
* consume path.
|
||||
*
|
||||
* Every method must be idempotent — the dispatcher may re-fire the same
|
||||
* event through the gateway if two relays happen to return the same
|
||||
* addressable event.
|
||||
*/
|
||||
interface OutboxCacheGateway {
|
||||
/**
|
||||
* Returns the currently-cached kind-10002 event for [pubkey], or null
|
||||
* if the platform cache doesn't have one yet.
|
||||
*/
|
||||
fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent?
|
||||
|
||||
/**
|
||||
* Called for every kind-10002 the dispatcher receives during Phase 1.
|
||||
* The gateway should route it through its normal consume path so the
|
||||
* event is stored, deduped by createdAt, and picked up by any state
|
||||
* holders observing the addressable-notes cache.
|
||||
*/
|
||||
fun onOutboxDiscovered(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
)
|
||||
|
||||
/**
|
||||
* Called for every kind-0 (metadata) or kind-3 (contact list) the
|
||||
* dispatcher receives during Phase 2 or Phase 3. The gateway should
|
||||
* route it through its normal consume path — this is how new profile
|
||||
* metadata and follow lists reach downstream consumers like the WoT
|
||||
* service and the UI.
|
||||
*/
|
||||
fun onDiscoveredEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl,
|
||||
)
|
||||
}
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.wot
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.RelayListRecommendationProcessor
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Fetches kind-0 (profile metadata) and kind-3 (contact list) events for a
|
||||
* set of authors using the NIP-65 **outbox model**:
|
||||
*
|
||||
* 1. **Phase 1 — discover.** Ask the configured index relays (Purple Pages,
|
||||
* Coracle, nos.lol, …) for the kind-10002 of each author. Merge with
|
||||
* already-cached 10002s from [OutboxCacheGateway].
|
||||
*
|
||||
* 2. **Phase 2 — pick + fetch.** Feed the author → write-relays map into
|
||||
* [RelayListRecommendationProcessor.reliableRelaySetFor] to get a
|
||||
* minimal, popularity-based set of relays that covers every author.
|
||||
* Open one subscription per recommended relay, filtered to that
|
||||
* relay's authors, for kind-0 and/or kind-3.
|
||||
*
|
||||
* 3. **Phase 3 — fallback.** For any author whose kind-10002 the network
|
||||
* never returned, fall back to the index-relay REQ (preserves the
|
||||
* current behaviour so a coldish account doesn't lose signal).
|
||||
*
|
||||
* The dispatcher is single-scoped (one instance per account) so its dedup
|
||||
* set survives across follow-set diffs. Call [clear] on account switch.
|
||||
*
|
||||
* @param client shared [INostrClient] used for every subscription
|
||||
* @param scope account-lifetime scope; cancelling it cancels in-flight REQs
|
||||
* @param indexRelays lazy accessor so a change through the settings UI
|
||||
* takes effect on next fetch without recreating the
|
||||
* dispatcher
|
||||
* @param gateway platform-specific cache adapter (see [OutboxCacheGateway])
|
||||
* @param perRelayTimeoutMs how long each REQ waits for its EOSE. Under
|
||||
* the plan (2026-07-06): 4 s.
|
||||
* @param overallTimeoutMs cap on the whole two-phase fetch. Belt against
|
||||
* a phase getting stuck. Under the plan: 8 s.
|
||||
* @param maxOutboxRelaysPerAuthor bound author → write-relays to the first N
|
||||
* relays after the [RelayListRecommendationProcessor]
|
||||
* chooses them, to keep fan-out predictable
|
||||
*/
|
||||
class OutboxDispatcher(
|
||||
private val client: INostrClient,
|
||||
private val scope: CoroutineScope,
|
||||
private val indexRelays: () -> Set<NormalizedRelayUrl>,
|
||||
private val gateway: OutboxCacheGateway,
|
||||
private val perRelayTimeoutMs: Long = 4_000L,
|
||||
private val overallTimeoutMs: Long = 20_000L,
|
||||
@Suppress("UNUSED_PARAMETER") maxOutboxRelaysPerAuthor: Int = 5,
|
||||
) {
|
||||
/**
|
||||
* Pubkeys we've already successfully fetched kind-3 for this session
|
||||
* (Phase 1 or Phase 2 returned events for them). Skipping a second
|
||||
* fetch is safe because a churn event from a subsequent kind-3
|
||||
* republication still reaches [OutboxCacheGateway.onDiscoveredEvent]
|
||||
* via other subscriptions (feed, notifications).
|
||||
*/
|
||||
private val kind3Succeeded = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Pubkeys we've already successfully fetched kind-0 for this session.
|
||||
*/
|
||||
private val kind0Succeeded = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Currently-in-flight authors — prevents rapid re-fire of the same
|
||||
* fetch. Distinct from [kind3Succeeded]/[kind0Succeeded]: a zero-EOSE
|
||||
* timeout rolls out of this set (allowing retry) instead of
|
||||
* permanently marking the pubkey as done.
|
||||
*/
|
||||
private val kind3InFlight = mutableSetOf<HexKey>()
|
||||
private val kind0InFlight = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Outcome counters. All values are aggregated across every phase of
|
||||
* one [fetchKind3Only] / [fetchKind0And3] call. Callers log them for
|
||||
* observability; `amy wot sync --json` also emits them so a caller
|
||||
* can measure whether the outbox path is doing the work vs the
|
||||
* fallback path.
|
||||
*/
|
||||
data class Result(
|
||||
val authorsRequested: Int,
|
||||
val kind10002Received: Int,
|
||||
val kind3Received: Int,
|
||||
val kind0Received: Int,
|
||||
val outboxCoveredAuthors: Int,
|
||||
val fallbackAuthors: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetch kind-3 for every pubkey in [authors] via each author's outbox
|
||||
* relay when known, falling back to index relays otherwise. Suspends
|
||||
* until every phase EOSEs or times out.
|
||||
*/
|
||||
suspend fun fetchKind3Only(authors: Set<HexKey>): Result = run(authors, includeKind0 = false, includeKind3 = true)
|
||||
|
||||
/**
|
||||
* Fetch kind-3 AND kind-0 for every pubkey in [authors]. Same phase
|
||||
* pipeline; a single per-outbox-relay subscription pulls both kinds
|
||||
* so we don't double the connection count.
|
||||
*/
|
||||
suspend fun fetchKind0And3(authors: Set<HexKey>): Result = run(authors, includeKind0 = true, includeKind3 = true)
|
||||
|
||||
/**
|
||||
* Fetch kind-0 only. Used by the metadata preloader when it decides
|
||||
* to bypass the index-relay batch for a specific author (e.g. a
|
||||
* profile screen visit where the author's outbox is already cached).
|
||||
*/
|
||||
suspend fun fetchKind0Only(authors: Set<HexKey>): Result = run(authors, includeKind0 = true, includeKind3 = false)
|
||||
|
||||
/**
|
||||
* Drop every dedup marker. Call on account switch so a fresh account
|
||||
* doesn't inherit the previous account's "already fetched" state.
|
||||
*/
|
||||
fun clear() {
|
||||
kind3Succeeded.clear()
|
||||
kind0Succeeded.clear()
|
||||
kind3InFlight.clear()
|
||||
kind0InFlight.clear()
|
||||
}
|
||||
|
||||
private suspend fun run(
|
||||
authors: Set<HexKey>,
|
||||
includeKind0: Boolean,
|
||||
includeKind3: Boolean,
|
||||
): Result {
|
||||
if (authors.isEmpty()) return zeroResult(0)
|
||||
|
||||
val newForKind3 =
|
||||
if (includeKind3) authors.filter { it !in kind3Succeeded && it !in kind3InFlight }.toSet() else emptySet()
|
||||
val newForKind0 =
|
||||
if (includeKind0) authors.filter { it !in kind0Succeeded && it !in kind0InFlight }.toSet() else emptySet()
|
||||
|
||||
if (newForKind3.isEmpty() && newForKind0.isEmpty()) {
|
||||
Log.d("OutboxDispatcher") { "skip: all authors deduped (succeeded or in-flight)" }
|
||||
return zeroResult(authors.size)
|
||||
}
|
||||
|
||||
kind3InFlight.addAll(newForKind3)
|
||||
kind0InFlight.addAll(newForKind0)
|
||||
|
||||
return try {
|
||||
val result =
|
||||
withTimeoutOrNull(overallTimeoutMs) {
|
||||
doRun(authors, newForKind3, newForKind0, includeKind0, includeKind3)
|
||||
}
|
||||
if (result == null) {
|
||||
Log.w("OutboxDispatcher") { "overall timeout ${overallTimeoutMs}ms exceeded — returning zero result" }
|
||||
zeroResult(authors.size)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
} finally {
|
||||
kind3InFlight.removeAll(newForKind3)
|
||||
kind0InFlight.removeAll(newForKind0)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doRun(
|
||||
allAuthors: Set<HexKey>,
|
||||
newForKind3: Set<HexKey>,
|
||||
newForKind0: Set<HexKey>,
|
||||
includeKind0: Boolean,
|
||||
includeKind3: Boolean,
|
||||
): Result {
|
||||
val relayCounts = FetchCounters()
|
||||
val relaysConfigured = indexRelays()
|
||||
val newTargets = (newForKind3 + newForKind0)
|
||||
|
||||
// Split into "have cached 10002" vs "need Phase 1".
|
||||
val cachedOutbox = mutableMapOf<HexKey, Set<NormalizedRelayUrl>>()
|
||||
val toDiscover = mutableSetOf<HexKey>()
|
||||
for (author in newTargets) {
|
||||
val write =
|
||||
gateway
|
||||
.cachedOutbox(author)
|
||||
?.writeRelaysNorm()
|
||||
.orEmpty()
|
||||
.toSet()
|
||||
if (write.isNotEmpty()) cachedOutbox[author] = write else toDiscover.add(author)
|
||||
}
|
||||
|
||||
Log.d("OutboxDispatcher") {
|
||||
"start authors=${allAuthors.size} newKind3=${newForKind3.size} newKind0=${newForKind0.size} " +
|
||||
"cachedOutbox=${cachedOutbox.size} toDiscover=${toDiscover.size} " +
|
||||
"indexRelays=${relaysConfigured.size}"
|
||||
}
|
||||
|
||||
// Phase 1 — discover kind-10002 on the index relays. runPhase1
|
||||
// returns pubkey → list of (event, relay) so we can pick the
|
||||
// newest event (some relays return outdated 10002s).
|
||||
val discovered = mutableMapOf<HexKey, Set<NormalizedRelayUrl>>()
|
||||
if (toDiscover.isNotEmpty() && relaysConfigured.isNotEmpty()) {
|
||||
val (phase1Events, phase1EosedCount) = runPhase1(toDiscover, relaysConfigured)
|
||||
phase1Events.forEach { (pubkey, results) ->
|
||||
val newest = results.maxByOrNull { it.first.createdAt } ?: return@forEach
|
||||
gateway.onOutboxDiscovered(newest.first, newest.second)
|
||||
val write =
|
||||
newest.first
|
||||
.writeRelaysNorm()
|
||||
.orEmpty()
|
||||
.toSet()
|
||||
if (write.isNotEmpty()) discovered[pubkey] = write
|
||||
}
|
||||
relayCounts.kind10002 += phase1Events.values.sumOf { it.size }
|
||||
Log.d("OutboxDispatcher") {
|
||||
"phase1 done eosed=$phase1EosedCount/${relaysConfigured.size} " +
|
||||
"10002-events=${relayCounts.kind10002} discovered=${discovered.size}"
|
||||
}
|
||||
}
|
||||
|
||||
val outboxMap = cachedOutbox + discovered
|
||||
val authorsWithOutbox = outboxMap.keys
|
||||
val fallbackAuthors = newTargets - authorsWithOutbox
|
||||
|
||||
// Phase 2 — per-outbox-relay REQ, kind-3 and/or kind-0. All
|
||||
// recommended relays are subscribed in a single call so the pool
|
||||
// fans out in parallel; a per-relay 4 s timeout bounds the wait
|
||||
// regardless of how many relays the recommendation set contains.
|
||||
val kind3BeforePhase2 = relayCounts.kind3
|
||||
val kind0BeforePhase2 = relayCounts.kind0
|
||||
if (outboxMap.isNotEmpty() && (includeKind0 || includeKind3)) {
|
||||
val recommendations = RelayListRecommendationProcessor.reliableRelaySetFor(outboxMap)
|
||||
val phase2FilterMap =
|
||||
recommendations
|
||||
.mapNotNull { rec ->
|
||||
val authorsForThisRelay =
|
||||
rec.users.intersect(
|
||||
if (includeKind0 && includeKind3) {
|
||||
newTargets
|
||||
} else if (includeKind3) {
|
||||
newForKind3
|
||||
} else {
|
||||
newForKind0
|
||||
},
|
||||
)
|
||||
if (authorsForThisRelay.isEmpty()) return@mapNotNull null
|
||||
val kinds =
|
||||
buildList {
|
||||
if (includeKind0 && authorsForThisRelay.any { it in newForKind0 }) add(MetadataEvent.KIND)
|
||||
if (includeKind3 && authorsForThisRelay.any { it in newForKind3 }) add(ContactListEvent.KIND)
|
||||
}
|
||||
if (kinds.isEmpty()) return@mapNotNull null
|
||||
rec.relay to
|
||||
authorsForThisRelay.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
authors = chunk,
|
||||
limit = chunk.size * kinds.size,
|
||||
)
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
Log.d("OutboxDispatcher") { "phase2 recommendations=${recommendations.size} relays-with-work=${phase2FilterMap.size}" }
|
||||
if (phase2FilterMap.isNotEmpty()) {
|
||||
runPhase2Or3(phase2FilterMap, counters = relayCounts)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("OutboxDispatcher") {
|
||||
"phase2 done kind3=${relayCounts.kind3 - kind3BeforePhase2} kind0=${relayCounts.kind0 - kind0BeforePhase2}"
|
||||
}
|
||||
|
||||
// Phase 3 — index-relay fallback for authors with no 10002.
|
||||
val kind3BeforePhase3 = relayCounts.kind3
|
||||
val kind0BeforePhase3 = relayCounts.kind0
|
||||
if (fallbackAuthors.isNotEmpty() && relaysConfigured.isNotEmpty()) {
|
||||
val kinds =
|
||||
buildList {
|
||||
if (includeKind0 && fallbackAuthors.any { it in newForKind0 }) add(MetadataEvent.KIND)
|
||||
if (includeKind3 && fallbackAuthors.any { it in newForKind3 }) add(ContactListEvent.KIND)
|
||||
}
|
||||
if (kinds.isNotEmpty()) {
|
||||
Log.d("OutboxDispatcher") { "phase3 fallback authors=${fallbackAuthors.size} kinds=$kinds relays=${relaysConfigured.size}" }
|
||||
val filters =
|
||||
fallbackAuthors.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
authors = chunk,
|
||||
limit = chunk.size * kinds.size,
|
||||
)
|
||||
}
|
||||
val phase3FilterMap = relaysConfigured.associateWith { filters }
|
||||
runPhase2Or3(phase3FilterMap, counters = relayCounts)
|
||||
Log.d("OutboxDispatcher") {
|
||||
"phase3 done kind3=${relayCounts.kind3 - kind3BeforePhase3} kind0=${relayCounts.kind0 - kind0BeforePhase3}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Promote to succeeded — a completed run means we've asked; even if
|
||||
// an author had no publishable data we don't need to keep pounding
|
||||
// relays every follow-set change.
|
||||
kind3Succeeded.addAll(newForKind3)
|
||||
kind0Succeeded.addAll(newForKind0)
|
||||
|
||||
return Result(
|
||||
authorsRequested = allAuthors.size,
|
||||
kind10002Received = relayCounts.kind10002,
|
||||
kind3Received = relayCounts.kind3,
|
||||
kind0Received = relayCounts.kind0,
|
||||
outboxCoveredAuthors = authorsWithOutbox.size,
|
||||
fallbackAuthors = fallbackAuthors.size,
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private class FetchCounters {
|
||||
var kind10002 = 0
|
||||
var kind3 = 0
|
||||
var kind0 = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 helper. Returns a map of pubkey → list of (event, relay) so
|
||||
* caller can pick the newest, plus a boolean-per-relay EOSE indicator
|
||||
* (currently ignored but recorded for future retry telemetry).
|
||||
*/
|
||||
private suspend fun runPhase1(
|
||||
pubkeys: Set<HexKey>,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
): Pair<Map<HexKey, List<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>>, Int> {
|
||||
val filters =
|
||||
pubkeys.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = listOf(AdvertisedRelayListEvent.KIND),
|
||||
authors = chunk,
|
||||
limit = chunk.size,
|
||||
)
|
||||
}
|
||||
val filterMap = relays.associateWith { filters }
|
||||
|
||||
val received = mutableMapOf<HexKey, MutableList<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>>()
|
||||
val gate = BatchEoseGate(scope, target = relays.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (event is AdvertisedRelayListEvent && event.pubKey in pubkeys) {
|
||||
received
|
||||
.getOrPut(event.pubKey) { mutableListOf() }
|
||||
.add(event to relay)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
val subId = newSubId()
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
val eosedCount = gate.awaitAll(perRelayTimeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
return received to eosedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 or Phase 3 helper. Opens a single subscription that
|
||||
* fans out to every relay in [filterMap] (Phase 2 uses per-outbox-
|
||||
* relay filters; Phase 3 uses the index-relay set with a shared
|
||||
* fallback filter). All relays are subscribed in parallel — the
|
||||
* per-relay timeout bounds the total wait regardless of relay count.
|
||||
*/
|
||||
private suspend fun runPhase2Or3(
|
||||
filterMap: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
counters: FetchCounters,
|
||||
) {
|
||||
val gate = BatchEoseGate(scope, target = filterMap.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
when (event.kind) {
|
||||
MetadataEvent.KIND -> counters.kind0++
|
||||
ContactListEvent.KIND -> counters.kind3++
|
||||
}
|
||||
gateway.onDiscoveredEvent(event, relay)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
val subId = newSubId()
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
gate.awaitAll(perRelayTimeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
}
|
||||
|
||||
private fun zeroResult(requested: Int) =
|
||||
Result(
|
||||
authorsRequested = requested,
|
||||
kind10002Received = 0,
|
||||
kind3Received = 0,
|
||||
kind0Received = 0,
|
||||
outboxCoveredAuthors = 0,
|
||||
fallbackAuthors = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* KMP-safe EOSE aggregator (same as FeedMetadataCoordinator's local
|
||||
* one — duplicated locally instead of exported to keep the fix scope
|
||||
* minimal). Per-relay `onEose` callbacks may run on any dispatcher
|
||||
* (typically `Dispatchers.IO`) so we funnel them through a Channel
|
||||
* and let a single consumer coroutine own the `seen` set.
|
||||
*/
|
||||
private class BatchEoseGate(
|
||||
private val scope: CoroutineScope,
|
||||
private val target: Int,
|
||||
) {
|
||||
private val incoming = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
|
||||
private val done = CompletableDeferred<Unit>()
|
||||
|
||||
@Volatile private var lastCount = 0
|
||||
|
||||
fun notifyEose(relay: NormalizedRelayUrl) {
|
||||
incoming.trySend(relay)
|
||||
}
|
||||
|
||||
suspend fun awaitAll(timeoutMs: Long): Int {
|
||||
if (target <= 0) return 0
|
||||
val consumer =
|
||||
scope.launch {
|
||||
val seen = mutableSetOf<NormalizedRelayUrl>()
|
||||
for (relay in incoming) {
|
||||
if (seen.add(relay)) {
|
||||
lastCount = seen.size
|
||||
if (seen.size >= target && !done.isCompleted) {
|
||||
done.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
withTimeoutOrNull(timeoutMs) { done.await() }
|
||||
incoming.close()
|
||||
consumer.join()
|
||||
return lastCount
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.wot
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Friends-of-friends trust score computed from the active user's follow
|
||||
* graph. For every pubkey X the score is the count of accounts in the
|
||||
* active user's follow set who also follow X.
|
||||
*
|
||||
* ## Reactivity model
|
||||
*
|
||||
* Scores are exposed via a Compose-observable [SnapshotStateMap]. Consumers
|
||||
* that read a **single key** (`scores[pubkey]`) recompose only when that
|
||||
* key changes — this is `SnapshotStateMap`'s built-in per-key observation
|
||||
* and applies whether or not the writer wraps in a snapshot block.
|
||||
* Consumers that iterate the map or read `size` recompose on **any**
|
||||
* mutation.
|
||||
*
|
||||
* The writer wraps each op in [Snapshot.withMutableSnapshot] to *coalesce*
|
||||
* an op's writes into a single Compose commit — so a Kind3 op that
|
||||
* touches N reverse-index targets emits one invalidation, not N. It does
|
||||
* not confer additional per-key isolation on top of `SnapshotStateMap`'s
|
||||
* own semantics.
|
||||
*
|
||||
* ## Concurrency
|
||||
*
|
||||
* All internal state is mutated from a single writer coroutine
|
||||
* ([writerLoop]) on [writerDispatcher] (default [Dispatchers.Default]), so
|
||||
* concurrent [applyKind3] / [onFollowSetChange] / [markReadyOnce] calls
|
||||
* from different threads are serialized without extra locking.
|
||||
*
|
||||
* ## Lifecycle
|
||||
*
|
||||
* Call [close] on account switch / logout so the writer coroutine exits
|
||||
* and the ops channel is released. Post-close ops are silently dropped.
|
||||
*/
|
||||
@Stable
|
||||
class WoTService(
|
||||
private val scope: CoroutineScope,
|
||||
/** Dispatcher for the internal writer coroutine. Tests override with `Dispatchers.Unconfined` for synchronous behavior. */
|
||||
private val writerDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : AutoCloseable {
|
||||
/**
|
||||
* Sparse per-pubkey score map. Entries with count 0 are removed
|
||||
* (not stored as 0) to keep the Compose subscriber tracking tight.
|
||||
* Callers should read as `scores[pubkey] ?: 0`.
|
||||
*/
|
||||
private val _scores: SnapshotStateMap<HexKey, Int> = mutableStateMapOf()
|
||||
val scores: SnapshotStateMap<HexKey, Int> get() = _scores
|
||||
|
||||
// Reverse index: target pubkey → set of my-follows who follow them.
|
||||
private val reverseIndex = HashMap<HexKey, MutableSet<HexKey>>()
|
||||
|
||||
// Per-follower cached follow set (excluding self / follower itself).
|
||||
// Enables diff-based updates when a follower republishes their kind-3.
|
||||
private val perFollowerSnapshot = HashMap<HexKey, Set<HexKey>>()
|
||||
|
||||
private var myFollows: Set<HexKey> = emptySet()
|
||||
private var selfPubkey: HexKey? = null
|
||||
private var readyMarked = false
|
||||
private var disabled = false
|
||||
|
||||
private val _isReady = MutableStateFlow(false)
|
||||
val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
|
||||
|
||||
private val _isDisabled = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* True when the active user's follow set exceeds [MAX_FOLLOWS] and WoT
|
||||
* scoring has been shut off. Callers that dispatch the batch kind-3
|
||||
* REQ must gate on this — a disabled service silently accepts and
|
||||
* ignores all subsequent [applyKind3] calls, so a caller that keeps
|
||||
* flooding kind-3s wastes bandwidth for nothing.
|
||||
*/
|
||||
val isDisabled: StateFlow<Boolean> = _isDisabled.asStateFlow()
|
||||
|
||||
private val ops = Channel<Op>(capacity = Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
scope.launch(writerDispatcher) { writerLoop() }
|
||||
}
|
||||
|
||||
/** Update the active user's follow set (and self pubkey). */
|
||||
fun onFollowSetChange(
|
||||
newFollows: Set<HexKey>,
|
||||
newSelf: HexKey?,
|
||||
) {
|
||||
ops.trySend(Op.FollowSet(newFollows, newSelf))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest a kind-3 event for a followed pubkey. Ignored when the
|
||||
* event's author isn't in the current follow set. Follow lists are
|
||||
* capped at [MAX_FOLLOWS_PER_EVENT] to bound CPU cost against a
|
||||
* hostile publisher.
|
||||
*/
|
||||
fun applyKind3(
|
||||
follower: HexKey,
|
||||
follows: Set<HexKey>,
|
||||
) {
|
||||
val bounded =
|
||||
if (follows.size > MAX_FOLLOWS_PER_EVENT) {
|
||||
follows.take(MAX_FOLLOWS_PER_EVENT).toSet()
|
||||
} else {
|
||||
follows
|
||||
}
|
||||
ops.trySend(Op.Kind3(follower, bounded))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the service as ready to render badges. Idempotent — subsequent
|
||||
* calls are no-ops. Trigger from the first EOSE on the batch kind-3
|
||||
* REQ, or from a startup-timeout fallback, whichever fires first.
|
||||
*/
|
||||
fun markReadyOnce() {
|
||||
ops.trySend(Op.MarkReady)
|
||||
}
|
||||
|
||||
/** Clear all state. Used on logout / account switch. */
|
||||
fun clear() {
|
||||
ops.trySend(Op.Clear)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain [Map] snapshot of current scores for headless
|
||||
* callers (e.g. the amy CLI) that don't run inside a Compose
|
||||
* composition. O(N) copy from the underlying [SnapshotStateMap].
|
||||
*/
|
||||
fun scoresSnapshot(): Map<HexKey, Int> = HashMap(_scores)
|
||||
|
||||
private sealed interface Op {
|
||||
data class FollowSet(
|
||||
val newFollows: Set<HexKey>,
|
||||
val newSelf: HexKey?,
|
||||
) : Op
|
||||
|
||||
data class Kind3(
|
||||
val follower: HexKey,
|
||||
val follows: Set<HexKey>,
|
||||
) : Op
|
||||
|
||||
data object MarkReady : Op
|
||||
|
||||
data object Clear : Op
|
||||
}
|
||||
|
||||
private suspend fun writerLoop() {
|
||||
for (op in ops) {
|
||||
Snapshot.withMutableSnapshot {
|
||||
when (op) {
|
||||
is Op.FollowSet -> handleFollowSet(op.newFollows, op.newSelf)
|
||||
is Op.Kind3 -> handleKind3(op.follower, op.follows)
|
||||
Op.MarkReady -> handleMarkReady()
|
||||
Op.Clear -> handleClear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFollowSet(
|
||||
newFollows: Set<HexKey>,
|
||||
newSelf: HexKey?,
|
||||
) {
|
||||
// Guardrail — massive follow lists don't produce a useful WoT signal.
|
||||
// Do this BEFORE assigning myFollows so applyKind3's `follower in
|
||||
// myFollows` gate doesn't accidentally credit anyone once the
|
||||
// caller keeps pumping kind-3s in (a caller that fails to gate on
|
||||
// isDisabled would otherwise fully repopulate reverseIndex/_scores
|
||||
// and defeat the guardrail — see PR #3483 review finding 2).
|
||||
if (newFollows.size > MAX_FOLLOWS) {
|
||||
reverseIndex.clear()
|
||||
perFollowerSnapshot.clear()
|
||||
_scores.clear()
|
||||
myFollows = emptySet()
|
||||
selfPubkey = newSelf
|
||||
disabled = true
|
||||
_isDisabled.value = true
|
||||
handleMarkReady()
|
||||
return
|
||||
}
|
||||
|
||||
val removed = myFollows - newFollows
|
||||
myFollows = newFollows
|
||||
selfPubkey = newSelf
|
||||
// Follow set is back within limits (or was already) — re-enable if
|
||||
// we had previously flipped disabled=true.
|
||||
if (disabled) {
|
||||
disabled = false
|
||||
_isDisabled.value = false
|
||||
}
|
||||
|
||||
// Uncredit any follower we're no longer following.
|
||||
removed.forEach { follower ->
|
||||
val prevFollows = perFollowerSnapshot.remove(follower) ?: return@forEach
|
||||
prevFollows.forEach { target ->
|
||||
val set = reverseIndex[target] ?: return@forEach
|
||||
set.remove(follower)
|
||||
if (set.isEmpty()) reverseIndex.remove(target)
|
||||
updateScore(target)
|
||||
}
|
||||
}
|
||||
// Added followers will be credited when their kind-3 arrives via applyKind3.
|
||||
}
|
||||
|
||||
private fun handleKind3(
|
||||
follower: HexKey,
|
||||
follows: Set<HexKey>,
|
||||
) {
|
||||
if (disabled) return
|
||||
if (follower !in myFollows) return
|
||||
|
||||
val old = perFollowerSnapshot[follower] ?: emptySet()
|
||||
val excluded = setOfNotNull(follower, selfPubkey)
|
||||
val effective = follows - excluded
|
||||
val added = effective - old
|
||||
val removed = old - effective
|
||||
perFollowerSnapshot[follower] = effective
|
||||
|
||||
added.forEach { target ->
|
||||
reverseIndex.getOrPut(target) { hashSetOf() }.add(follower)
|
||||
updateScore(target)
|
||||
}
|
||||
removed.forEach { target ->
|
||||
val set = reverseIndex[target] ?: return@forEach
|
||||
set.remove(follower)
|
||||
if (set.isEmpty()) reverseIndex.remove(target)
|
||||
updateScore(target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMarkReady() {
|
||||
if (!readyMarked) {
|
||||
readyMarked = true
|
||||
_isReady.value = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClear() {
|
||||
reverseIndex.clear()
|
||||
perFollowerSnapshot.clear()
|
||||
_scores.clear()
|
||||
myFollows = emptySet()
|
||||
selfPubkey = null
|
||||
readyMarked = false
|
||||
_isReady.value = false
|
||||
disabled = false
|
||||
_isDisabled.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the writer coroutine and release the ops channel. Call from
|
||||
* account-switch / logout paths. Post-close [applyKind3] / [onFollowSetChange]
|
||||
* / [markReadyOnce] / [clear] calls are silently dropped (the `trySend`
|
||||
* on a closed [Channel] fails without throwing).
|
||||
*
|
||||
* Idempotent; safe to call multiple times.
|
||||
*/
|
||||
override fun close() {
|
||||
ops.close()
|
||||
}
|
||||
|
||||
private fun updateScore(target: HexKey) {
|
||||
val n = reverseIndex[target]?.size ?: 0
|
||||
if (n > 0) _scores[target] = n else _scores.remove(target)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Skip WoT entirely for accounts following more than this many pubkeys. */
|
||||
const val MAX_FOLLOWS = 2000
|
||||
|
||||
/** Cap follows per kind-3 event to bound CPU cost against a hostile publisher. */
|
||||
const val MAX_FOLLOWS_PER_EVENT = 5000
|
||||
}
|
||||
}
|
||||
+83
-19
@@ -29,25 +29,27 @@ import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MessagesLockStateTest {
|
||||
class PrivacyLockStateTest {
|
||||
private class FakeSettings(
|
||||
lockEnabled: Boolean = false,
|
||||
timer: InactivityTimer = InactivityTimer.OneMin,
|
||||
password: String? = null,
|
||||
) : PrivacyLockSettings {
|
||||
private val mutableLockEnabled = MutableStateFlow(lockEnabled)
|
||||
private val mutableTimer = MutableStateFlow(timer)
|
||||
private val mutableRedaction = MutableStateFlow(DmRedactionLevel.DEFAULT)
|
||||
private val mutableFirstRunSeen = MutableStateFlow(false)
|
||||
private val mutablePasswordHashed = MutableStateFlow<String?>(null)
|
||||
private val mutablePasswordHashed = MutableStateFlow<String?>(password)
|
||||
private val mutableFailedAttempts = MutableStateFlow(0)
|
||||
private val mutableLockedUntil = MutableStateFlow<Long?>(null)
|
||||
|
||||
override val lockEnabled: StateFlow<Boolean> = mutableLockEnabled.asStateFlow()
|
||||
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
|
||||
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
|
||||
override val dmRedactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
|
||||
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
|
||||
override val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
|
||||
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
|
||||
@@ -61,7 +63,7 @@ class MessagesLockStateTest {
|
||||
mutableTimer.value = timer
|
||||
}
|
||||
|
||||
override fun setRedactionLevel(level: DmRedactionLevel) {
|
||||
override fun setDmRedactionLevel(level: DmRedactionLevel) {
|
||||
mutableRedaction.value = level
|
||||
}
|
||||
|
||||
@@ -71,6 +73,8 @@ class MessagesLockStateTest {
|
||||
|
||||
override fun setPasswordHashed(saltAndHash: String?) {
|
||||
mutablePasswordHashed.value = saltAndHash
|
||||
// Mirror the production cascade — no credential means no gate.
|
||||
if (saltAndHash == null && mutableLockEnabled.value) mutableLockEnabled.value = false
|
||||
}
|
||||
|
||||
override fun setFailedUnlockAttempts(count: Int) {
|
||||
@@ -86,7 +90,7 @@ class MessagesLockStateTest {
|
||||
fun cold_start_with_lock_enabled_seeds_to_locked() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
assertEquals(LockState.Locked, state.state.value)
|
||||
}
|
||||
|
||||
@@ -94,7 +98,7 @@ class MessagesLockStateTest {
|
||||
fun cold_start_with_lock_disabled_seeds_to_disabled() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = false)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
assertEquals(LockState.Disabled, state.state.value)
|
||||
}
|
||||
|
||||
@@ -102,7 +106,7 @@ class MessagesLockStateTest {
|
||||
fun unlock_success_transitions_to_unlocked_and_idle_timer_fires() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
advanceTimeBy(InactivityTimer.OneMin.millis!! + 1_000L)
|
||||
@@ -113,7 +117,7 @@ class MessagesLockStateTest {
|
||||
fun leave_route_locks_immediately() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneHour)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
state.onLeaveRoute()
|
||||
@@ -124,7 +128,7 @@ class MessagesLockStateTest {
|
||||
fun toggling_lock_off_transitions_to_disabled() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
settings.setLockEnabled(false)
|
||||
@@ -135,7 +139,7 @@ class MessagesLockStateTest {
|
||||
fun never_timer_does_not_fire() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.Never)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
advanceTimeBy(InactivityTimer.OneHour.millis!! * 2)
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
@@ -145,7 +149,7 @@ class MessagesLockStateTest {
|
||||
fun user_interaction_resets_idle_timer() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
advanceTimeBy(InactivityTimer.OneMin.millis!! - 1_000L)
|
||||
state.onUserInteraction()
|
||||
@@ -159,7 +163,7 @@ class MessagesLockStateTest {
|
||||
fun credential_unavailable_disables_lock() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onCredentialUnavailable()
|
||||
assertEquals(LockState.Disabled, state.state.value)
|
||||
assertEquals(false, settings.lockEnabled.value)
|
||||
@@ -169,11 +173,11 @@ class MessagesLockStateTest {
|
||||
fun unlock_success_from_disabled_transitions_to_unlocked() =
|
||||
runTest {
|
||||
// First-run banner path: user enables lock + sets password while
|
||||
// already viewing Messages. State is Disabled at that moment, and
|
||||
// we want to stay Unlocked so the user isn't kicked to the lock
|
||||
// already viewing a gated route. State is Disabled at that moment,
|
||||
// and we want to stay Unlocked so the user isn't kicked to the lock
|
||||
// screen right after enabling.
|
||||
val settings = FakeSettings(lockEnabled = false)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
assertEquals(LockState.Disabled, state.state.value)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
@@ -183,7 +187,7 @@ class MessagesLockStateTest {
|
||||
fun failed_attempts_below_threshold_do_not_trip_lockout() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES - 1) {
|
||||
assertEquals(null, state.onFailedUnlockAttempt(now))
|
||||
@@ -199,7 +203,7 @@ class MessagesLockStateTest {
|
||||
fun fifth_failure_trips_base_lockout() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) {
|
||||
state.onFailedUnlockAttempt(now)
|
||||
@@ -212,7 +216,7 @@ class MessagesLockStateTest {
|
||||
fun lockout_doubles_and_caps_at_maximum() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
// 5th failure → base (30s)
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) }
|
||||
@@ -230,7 +234,7 @@ class MessagesLockStateTest {
|
||||
fun unlock_success_clears_backoff_state() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) }
|
||||
assertTrue(settings.lockedUntilEpochMs.value != null)
|
||||
@@ -239,4 +243,64 @@ class MessagesLockStateTest {
|
||||
assertEquals(null, settings.lockedUntilEpochMs.value)
|
||||
assertEquals(0, settings.failedUnlockAttempts.value)
|
||||
}
|
||||
|
||||
// ---- Wallet-lock reuse additions ----
|
||||
|
||||
@Test
|
||||
fun two_scopes_have_independent_lock_state() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope)
|
||||
assertEquals(LockState.Locked, messages.state.value)
|
||||
assertEquals(LockState.Locked, wallet.state.value)
|
||||
|
||||
messages.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, messages.state.value)
|
||||
assertEquals(LockState.Locked, wallet.state.value)
|
||||
|
||||
messages.onLeaveRoute()
|
||||
assertEquals(LockState.Locked, messages.state.value)
|
||||
assertEquals(LockState.Locked, wallet.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failed_unlock_counter_is_shared_across_scopes() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
// Three failures on Messages, two on Wallet → shared counter hits 5
|
||||
repeat(3) { messages.onFailedUnlockAttempt(now) }
|
||||
repeat(2) { wallet.onFailedUnlockAttempt(now) }
|
||||
assertEquals(
|
||||
PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES,
|
||||
settings.failedUnlockAttempts.value,
|
||||
)
|
||||
// The 5th failure trips the base lockout regardless of which scope
|
||||
// it came from — either scope now sees the countdown.
|
||||
assertEquals(
|
||||
now + PrivacyLockSettings.LOCKOUT_BASE_MS,
|
||||
settings.lockedUntilEpochMs.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearing_password_cascades_to_disable_the_master_lock() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val settings = FakeSettings(lockEnabled = true, password = "salt\$hash")
|
||||
val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope)
|
||||
assertEquals(LockState.Locked, messages.state.value)
|
||||
assertEquals(LockState.Locked, wallet.state.value)
|
||||
|
||||
// User clears the password from Settings → cascade fires
|
||||
settings.setPasswordHashed(null)
|
||||
|
||||
assertEquals(false, settings.lockEnabled.value)
|
||||
assertEquals(LockState.Disabled, messages.state.value)
|
||||
assertEquals(LockState.Disabled, wallet.state.value)
|
||||
assertNull(settings.passwordHashed.value)
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.auth
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* End-to-end exercise of the AUTH stack: policy classification +
|
||||
* RelayAuthEvent.build template + real NostrSignerInternal signing.
|
||||
*
|
||||
* This is the lambda-level shape that [DesktopAuthCoordinator]'s
|
||||
* `signWithAllLoggedInUsers` calls into. It isolates the policy/signer
|
||||
* round-trip from the live websocket layer (which has its own coverage
|
||||
* in `geode/.../KtorRelayTest.kt` against a Ktor mock relay).
|
||||
*
|
||||
* Together with the existing AuthApprovalPolicyTest (classifier),
|
||||
* PoolEventOutboxStateTest (auth-required carve-out), and
|
||||
* GiftWrapRelayHintTest (NIP-17 hint placement), this covers the AUTH
|
||||
* pipeline at unit granularity — the geode Ktor tests handle the
|
||||
* websocket-level round-trip.
|
||||
*/
|
||||
class AuthApprovalEndToEndTest {
|
||||
private val signer = NostrSignerInternal(KeyPair())
|
||||
private val ownInbox = NormalizedRelayUrl("wss://own.inbox/")
|
||||
private val unknown = NormalizedRelayUrl("wss://unknown.relay/")
|
||||
private val challenge = "test-challenge-abc123"
|
||||
|
||||
private fun newPolicy(
|
||||
ownSet: Set<NormalizedRelayUrl> = setOf(ownInbox),
|
||||
onPrompt: (PendingAuthApproval) -> Unit = {},
|
||||
): Pair<AuthApprovalPolicy, AuthApprovalStore> {
|
||||
val store = InMemoryAuthApprovalStore()
|
||||
return AuthApprovalPolicy(
|
||||
selfApprovedRelays = { ownSet },
|
||||
store = store,
|
||||
onPromptRequired = onPrompt,
|
||||
) to store
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator's lambda shape, distilled. Returns the signed AUTH event
|
||||
* (or null on Block / not-signed-by-policy).
|
||||
*/
|
||||
private suspend fun signWithPolicy(
|
||||
relay: NormalizedRelayUrl,
|
||||
policy: AuthApprovalPolicy,
|
||||
): RelayAuthEvent? {
|
||||
val template = RelayAuthEvent.build(relay, challenge)
|
||||
val relayFromTemplate = template.tags.firstNotNullOfOrNull(RelayTag::parse)
|
||||
assertEquals(relay, relayFromTemplate, "RelayAuthEvent.build must round-trip via RelayTag.parse")
|
||||
return when (val decision = policy.classify(relay)) {
|
||||
AuthApprovalDecision.Allow -> signer.sign(template)
|
||||
AuthApprovalDecision.Block -> null
|
||||
is AuthApprovalDecision.Pending -> {
|
||||
val resolved = decision.pending.await()
|
||||
if (resolved != AuthApprovalScope.ONCE) policy.recordDecision(relay, resolved)
|
||||
if (resolved == AuthApprovalScope.BLOCKED) null else signer.sign(template)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tier1OwnInboxAutoSignsValidAuthEvent() =
|
||||
runTest {
|
||||
val (policy, _) = newPolicy()
|
||||
val signed = signWithPolicy(ownInbox, policy)
|
||||
assertNotNull(signed)
|
||||
assertEquals(RelayAuthEvent.KIND, signed.kind)
|
||||
assertEquals(signer.pubKey, signed.pubKey)
|
||||
assertEquals(challenge, signed.challenge())
|
||||
assertEquals(ownInbox, signed.relay())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tier2UnknownPromptsAndOnceResolutionSigns() =
|
||||
runTest {
|
||||
var prompted: PendingAuthApproval? = null
|
||||
val (policy, _) = newPolicy(onPrompt = { prompted = it })
|
||||
|
||||
coroutineScope {
|
||||
// Concurrent: lambda suspends inside policy.classify; we
|
||||
// resolve the deferred from outside as the banner UI would.
|
||||
val deferred = async { signWithPolicy(unknown, policy) }
|
||||
yieldUntilNotNull { prompted }
|
||||
prompted!!.decision.complete(AuthApprovalScope.ONCE)
|
||||
|
||||
val signed = deferred.await()
|
||||
assertNotNull(signed)
|
||||
assertEquals(unknown, signed.relay())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tier2BlockedResolutionReturnsNullAndPersists() =
|
||||
runTest {
|
||||
var prompted: PendingAuthApproval? = null
|
||||
val (policy, store) = newPolicy(onPrompt = { prompted = it })
|
||||
|
||||
coroutineScope {
|
||||
val deferred = async { signWithPolicy(unknown, policy) }
|
||||
yieldUntilNotNull { prompted }
|
||||
prompted!!.decision.complete(AuthApprovalScope.BLOCKED)
|
||||
|
||||
val signed = deferred.await()
|
||||
assertNull(signed)
|
||||
assertEquals(AuthApprovalScope.BLOCKED, store.getScope(unknown))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tier2AlwaysPersistsAndSkipsPromptNextTime() =
|
||||
runTest {
|
||||
var promptCount = 0
|
||||
val (policy, store) =
|
||||
newPolicy(onPrompt = {
|
||||
it.decision.complete(AuthApprovalScope.ALWAYS)
|
||||
promptCount++
|
||||
})
|
||||
|
||||
// First call: prompts and resolves to ALWAYS.
|
||||
val first = signWithPolicy(unknown, policy)
|
||||
assertNotNull(first)
|
||||
assertEquals(1, promptCount)
|
||||
assertEquals(AuthApprovalScope.ALWAYS, store.getScope(unknown))
|
||||
|
||||
// Second call: should NOT prompt again.
|
||||
val second = signWithPolicy(unknown, policy)
|
||||
assertNotNull(second)
|
||||
assertEquals(1, promptCount, "ALWAYS persisted — no second prompt")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <T> yieldUntilNotNull(crossinline supplier: () -> T?): T {
|
||||
repeat(100) {
|
||||
supplier()?.let { return it }
|
||||
yield()
|
||||
}
|
||||
error("supplier never produced a value within 100 yields")
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.auth
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AuthApprovalPolicyTest {
|
||||
private val ownOutbox = NormalizedRelayUrl("wss://own.outbox/")
|
||||
private val unknown = NormalizedRelayUrl("wss://unknown.relay/")
|
||||
private val blockedRelay = NormalizedRelayUrl("wss://blocked.relay/")
|
||||
|
||||
private fun newPolicy(
|
||||
ownSet: Set<NormalizedRelayUrl> = setOf(ownOutbox),
|
||||
onPrompt: (PendingAuthApproval) -> Unit = {},
|
||||
): Pair<AuthApprovalPolicy, AuthApprovalStore> {
|
||||
val store = InMemoryAuthApprovalStore()
|
||||
val policy =
|
||||
AuthApprovalPolicy(
|
||||
selfApprovedRelays = { ownSet },
|
||||
store = store,
|
||||
onPromptRequired = onPrompt,
|
||||
)
|
||||
return policy to store
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tier1OwnOutboxRelayIsAutoAllowed() =
|
||||
runTest {
|
||||
val (policy, _) = newPolicy()
|
||||
val decision = policy.classify(ownOutbox)
|
||||
assertSame(AuthApprovalDecision.Allow, decision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownRelayPromptsAndReturnsPending() =
|
||||
runTest {
|
||||
val prompts = mutableListOf<PendingAuthApproval>()
|
||||
val (policy, _) = newPolicy(onPrompt = { prompts += it })
|
||||
|
||||
val decision = policy.classify(unknown)
|
||||
|
||||
assertIs<AuthApprovalDecision.Pending>(decision)
|
||||
assertEquals(1, prompts.size)
|
||||
assertEquals(unknown, prompts.first().relayUrl)
|
||||
assertSame(decision.pending, prompts.first().decision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistedAlwaysIsAutoAllowed() =
|
||||
runTest {
|
||||
val (policy, store) = newPolicy()
|
||||
store.setScope(unknown, AuthApprovalScope.ALWAYS)
|
||||
val decision = policy.classify(unknown)
|
||||
assertSame(AuthApprovalDecision.Allow, decision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistedBlockedIsAutoBlockedEvenForOwnOutbox() =
|
||||
runTest {
|
||||
// Explicit user `[Never]` overrides tier-1 — if the user blocked a relay
|
||||
// that happens to be in their outbox, respect that.
|
||||
val (policy, store) = newPolicy()
|
||||
store.setScope(ownOutbox, AuthApprovalScope.BLOCKED)
|
||||
val decision = policy.classify(ownOutbox)
|
||||
assertSame(AuthApprovalDecision.Block, decision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordDecisionPersistsAndChangesSubsequentClassification() =
|
||||
runTest {
|
||||
var promptCount = 0
|
||||
val (policy, _) = newPolicy(onPrompt = { promptCount++ })
|
||||
|
||||
// First call prompts.
|
||||
val first = policy.classify(unknown)
|
||||
assertIs<AuthApprovalDecision.Pending>(first)
|
||||
assertEquals(1, promptCount)
|
||||
|
||||
// User picks `[Always]`.
|
||||
policy.recordDecision(unknown, AuthApprovalScope.ALWAYS)
|
||||
|
||||
// Subsequent calls return Allow without prompting.
|
||||
val second = policy.classify(unknown)
|
||||
assertSame(AuthApprovalDecision.Allow, second)
|
||||
assertEquals(1, promptCount, "should not prompt again after Always grant")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockedDecisionPersistsAndStaysBlocked() =
|
||||
runTest {
|
||||
var promptCount = 0
|
||||
val (policy, _) = newPolicy(onPrompt = { promptCount++ })
|
||||
|
||||
// First call prompts.
|
||||
policy.classify(blockedRelay)
|
||||
assertEquals(1, promptCount)
|
||||
|
||||
// User picks `[Never]`.
|
||||
policy.recordDecision(blockedRelay, AuthApprovalScope.BLOCKED)
|
||||
|
||||
// Subsequent classify returns Block without prompting.
|
||||
val decision = policy.classify(blockedRelay)
|
||||
assertSame(AuthApprovalDecision.Block, decision)
|
||||
assertEquals(1, promptCount, "should not prompt again after Never")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selfApprovedRelaysIsReevaluatedPerCall() =
|
||||
runTest {
|
||||
// Account state changes (user adds a relay to their outbox) must take
|
||||
// effect immediately — the policy reads the supplier per classify.
|
||||
var ownSet = setOf<NormalizedRelayUrl>()
|
||||
val policy =
|
||||
AuthApprovalPolicy(
|
||||
selfApprovedRelays = { ownSet },
|
||||
store = InMemoryAuthApprovalStore(),
|
||||
onPromptRequired = {},
|
||||
)
|
||||
|
||||
assertIs<AuthApprovalDecision.Pending>(policy.classify(ownOutbox))
|
||||
|
||||
ownSet = setOf(ownOutbox)
|
||||
assertSame(AuthApprovalDecision.Allow, policy.classify(ownOutbox))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storeClearWipesAllApprovals() =
|
||||
runTest {
|
||||
val store = InMemoryAuthApprovalStore()
|
||||
store.setScope(unknown, AuthApprovalScope.ALWAYS)
|
||||
store.setScope(blockedRelay, AuthApprovalScope.BLOCKED)
|
||||
|
||||
store.clear()
|
||||
|
||||
// Both relays now unknown → fresh classification prompts.
|
||||
assertTrue(store.getScope(unknown) == null)
|
||||
assertTrue(store.getScope(blockedRelay) == null)
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.nip17Dm
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DmInboxRelayResolverTest {
|
||||
private val peer: HexKey = "0".repeat(64)
|
||||
private val cachedRelay = NormalizedRelayUrl("wss://cached.relay/")
|
||||
private val indexer = NormalizedRelayUrl("wss://indexer.example/")
|
||||
|
||||
private fun newResolver(
|
||||
localLookup: (HexKey) -> List<NormalizedRelayUrl>?,
|
||||
indexers: Set<NormalizedRelayUrl> = setOf(indexer),
|
||||
now: () -> Long = { 0L },
|
||||
ttlMs: Long = 60_000L,
|
||||
) = DmInboxRelayResolver(
|
||||
unauthenticatedClient = EmptyNostrClient(),
|
||||
indexerRelays = indexers,
|
||||
localLookup = localLookup,
|
||||
cacheTtlMs = ttlMs,
|
||||
cacheSize = 4,
|
||||
nowMs = now,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun localLookupHitShortCircuitsIndexerFanOut() =
|
||||
runTest {
|
||||
var indexerCalled = false
|
||||
// The indexer would only run if RecipientRelayFetcher.fetchRelayLists ran. EmptyNostrClient returns no events,
|
||||
// so even if it did, we'd get an empty list — but assert indirectly via the result.
|
||||
val resolver = newResolver(localLookup = { listOf(cachedRelay) })
|
||||
val result = resolver.resolve(peer)
|
||||
assertEquals(listOf(cachedRelay), result)
|
||||
assertTrue(!indexerCalled) // we never set this; LocalLookup returns first
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyIndexerSetReturnsEmpty() =
|
||||
runTest {
|
||||
val resolver = newResolver(localLookup = { null }, indexers = emptySet())
|
||||
val result = resolver.resolve(peer)
|
||||
assertEquals(emptyList(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyLocalAndEmptyIndexerYieldsEmpty() =
|
||||
runTest {
|
||||
// EmptyNostrClient.fetchAll returns no events → resolver yields empty.
|
||||
val resolver = newResolver(localLookup = { null })
|
||||
val result = resolver.resolve(peer)
|
||||
assertEquals(emptyList(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cacheHitWithinTtlSkipsIndexer() =
|
||||
runTest {
|
||||
// First call: localLookup returns null, indexer empty → caches [] for peer.
|
||||
// Second call: same peer within TTL → returns cached [], no new indexer call.
|
||||
var localLookupCalls = 0
|
||||
val resolver =
|
||||
newResolver(
|
||||
localLookup = {
|
||||
localLookupCalls++
|
||||
null
|
||||
},
|
||||
)
|
||||
resolver.resolve(peer)
|
||||
resolver.resolve(peer)
|
||||
// localLookup is invoked on every resolve (cheap), but the indexer
|
||||
// fan-out + cache write only happens once. Hard to assert directly
|
||||
// on RecipientRelayFetcher without a mock client; cache TTL behaviour
|
||||
// is exercised below.
|
||||
assertEquals(2, localLookupCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cacheExpiryTriggersFreshIndexerCall() =
|
||||
runTest {
|
||||
var nowMs = 0L
|
||||
val ttl = 1_000L
|
||||
val resolver = newResolver(localLookup = { null }, now = { nowMs }, ttlMs = ttl)
|
||||
|
||||
resolver.resolve(peer) // caches [] with expiresAt = ttl
|
||||
nowMs = ttl + 1 // past expiry
|
||||
val second = resolver.resolve(peer)
|
||||
assertEquals(emptyList(), second) // still empty from EmptyNostrClient — but went through the indexer path again
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearWipesAllEntries() =
|
||||
runTest {
|
||||
val resolver = newResolver(localLookup = { null })
|
||||
resolver.resolve(peer)
|
||||
resolver.clear()
|
||||
// No way to introspect cache directly; assert through the resolve API
|
||||
// continuing to work (would NPE if internal state were corrupt).
|
||||
val result = resolver.resolve(peer)
|
||||
assertEquals(emptyList(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidateRemovesNamedEntry() =
|
||||
runTest {
|
||||
val resolver = newResolver(localLookup = { null })
|
||||
resolver.resolve(peer)
|
||||
resolver.invalidate(peer)
|
||||
val result = resolver.resolve(peer)
|
||||
assertEquals(emptyList(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun localLookupReturningEmptyListFallsThroughToCacheAndIndexer() =
|
||||
runTest {
|
||||
// Subtle: localLookup must return null OR a non-empty list. An EMPTY
|
||||
// list from localLookup means "I know this user has no 10050" — but
|
||||
// we want "I don't know" to fall through. The resolver guards with
|
||||
// `takeIf { it.isNotEmpty() }`.
|
||||
var localLookupCalls = 0
|
||||
val resolver =
|
||||
newResolver(
|
||||
localLookup = {
|
||||
localLookupCalls++
|
||||
emptyList()
|
||||
},
|
||||
)
|
||||
val result = resolver.resolve(peer)
|
||||
assertEquals(emptyList(), result)
|
||||
assertEquals(1, localLookupCalls)
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -63,7 +63,7 @@ class PreferencesPrivacyLockSettings(
|
||||
|
||||
override val lockEnabled: StateFlow<Boolean> = mutableEnabled.asStateFlow()
|
||||
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
|
||||
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
|
||||
override val dmRedactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
|
||||
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
|
||||
override val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
|
||||
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
|
||||
@@ -77,7 +77,7 @@ class PreferencesPrivacyLockSettings(
|
||||
// "locked UI / leaking notifications" anti-pattern).
|
||||
if (enabled && mutableRedaction.value == DmRedactionLevel.Full) {
|
||||
val userPickedFull = prefs.getBoolean("redaction_user_set", false)
|
||||
if (!userPickedFull) setRedactionLevel(DmRedactionLevel.Generic)
|
||||
if (!userPickedFull) setDmRedactionLevel(DmRedactionLevel.Generic)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ class PreferencesPrivacyLockSettings(
|
||||
prefs.putInt(KEY_INACTIVITY_TIMER, timer.ordinal)
|
||||
}
|
||||
|
||||
override fun setRedactionLevel(level: DmRedactionLevel) {
|
||||
override fun setDmRedactionLevel(level: DmRedactionLevel) {
|
||||
mutableRedaction.value = level
|
||||
prefs.putInt(KEY_REDACTION_LEVEL, level.ordinal)
|
||||
prefs.putBoolean("redaction_user_set", true)
|
||||
@@ -99,7 +99,15 @@ class PreferencesPrivacyLockSettings(
|
||||
|
||||
override fun setPasswordHashed(saltAndHash: String?) {
|
||||
mutablePasswordHashed.value = saltAndHash
|
||||
if (saltAndHash == null) prefs.remove(KEY_PASSWORD_HASHED) else prefs.put(KEY_PASSWORD_HASHED, saltAndHash)
|
||||
if (saltAndHash == null) {
|
||||
prefs.remove(KEY_PASSWORD_HASHED)
|
||||
// A lock without a credential is not a valid state — cascade so the
|
||||
// toggle can't stay on with nothing to verify against. Every gated
|
||||
// scope transitions to Disabled via the shared `lockEnabled` flag.
|
||||
if (mutableEnabled.value) setLockEnabled(false)
|
||||
} else {
|
||||
prefs.put(KEY_PASSWORD_HASHED, saltAndHash)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setFailedUnlockAttempts(count: Int) {
|
||||
|
||||
+12
@@ -164,6 +164,18 @@ class RelayLatencyTracker(
|
||||
/**
|
||||
* Expires pending entries older than the configured TTLs and records the TTL value as the
|
||||
* sample (per the brainstorm: "punish silent relays"). Idempotent and cheap.
|
||||
*
|
||||
* The per-relay pending maps are `Collections.synchronizedMap(LinkedHashMap)` — their
|
||||
* individual reads and writes are thread-safe, but iteration is NOT: per
|
||||
* `Collections.synchronizedMap` javadoc, the caller MUST hold the returned map's
|
||||
* monitor while iterating. Directly iterating triggers a
|
||||
* `ConcurrentModificationException` when a producer thread (network dispatcher)
|
||||
* mutates the map while the sweep is walking it — reliably reproduced on macOS
|
||||
* during any relay-add on Amethyst Desktop as of 2026-07-06.
|
||||
*
|
||||
* Fix: iterate under `synchronized(pending)` blocks so the network dispatcher
|
||||
* waits until sweep releases the monitor. The sweep is O(pending), typically
|
||||
* ~single-digit entries per relay, so the hold time is negligible.
|
||||
*/
|
||||
override fun sweep(nowMs: Long) {
|
||||
// Per-relay pending maps are `Collections.synchronizedMap(LinkedHashMap)` — the
|
||||
|
||||
+1
-1
@@ -324,7 +324,7 @@ actual class SecureKeyStorage private actual constructor() {
|
||||
} else {
|
||||
// Fallback for non-interactive environments (testing, etc.)
|
||||
print("Enter master password: ")
|
||||
readLine() ?: throw SecureStorageException("Password required for fallback storage")
|
||||
readlnOrNull() ?: throw SecureStorageException("Password required for fallback storage")
|
||||
}
|
||||
}
|
||||
return fallbackPassword!!
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relays.index
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
/**
|
||||
* User-configurable set of relays used to fetch profile metadata
|
||||
* (kind 0) and follow lists (kind 3) — the "index relays" set passed
|
||||
* to `FeedMetadataCoordinator` in the Desktop app and to `wot sync`
|
||||
* in `amy`.
|
||||
*
|
||||
* Backed by [java.util.prefs.Preferences] at a fixed node
|
||||
* `com/vitorpamplona/amethyst/relays/index` (JVM-user-scoped). The
|
||||
* shared node means Desktop and `amy` running as the same OS user
|
||||
* observe the same setting without extra plumbing — the same trick
|
||||
* `PreferencesHashtagSpamSettings` uses for the hashtag-spam filter.
|
||||
*
|
||||
* Not per-account: users typically have a single preferred set of
|
||||
* index relays regardless of which account is currently logged in.
|
||||
* If per-account overrides become necessary later, layer a per-user
|
||||
* key on top; this class stays the base.
|
||||
*
|
||||
* CSV serialisation for the persisted value matches what
|
||||
* `DesktopAccountRelays` uses for its categories — no JSON dep, no
|
||||
* `Serializable` contract. URLs are normalised via
|
||||
* [RelayUrlNormalizer.normalizeOrNull] at both write and read time so
|
||||
* malformed entries never enter the effective set.
|
||||
*/
|
||||
class PreferencesIndexRelays(
|
||||
private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME),
|
||||
) {
|
||||
private val mutableRelays: MutableStateFlow<Set<NormalizedRelayUrl>> =
|
||||
MutableStateFlow(parse(prefs.get(KEY_URLS, "")))
|
||||
|
||||
/**
|
||||
* Current user override. Empty when the user has not configured
|
||||
* anything — callers should route through [effective] to get the
|
||||
* defaults-fallback resolved set.
|
||||
*/
|
||||
val relays: StateFlow<Set<NormalizedRelayUrl>> = mutableRelays.asStateFlow()
|
||||
|
||||
fun setRelays(new: Set<NormalizedRelayUrl>) {
|
||||
mutableRelays.value = new
|
||||
prefs.put(KEY_URLS, new.joinToString(",") { it.url })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the set the relay client should actually use — the user
|
||||
* override when non-empty, otherwise [DEFAULT_INDEX_RELAYS]. Never
|
||||
* returns empty (unless the caller has explicitly reset both the
|
||||
* override and the defaults to empty, which would require a code
|
||||
* change here).
|
||||
*/
|
||||
fun effective(): Set<NormalizedRelayUrl> = mutableRelays.value.ifEmpty { DEFAULT_INDEX_RELAYS }
|
||||
|
||||
companion object {
|
||||
const val NODE_NAME = "com/vitorpamplona/amethyst/relays/index"
|
||||
const val KEY_URLS = "urls"
|
||||
|
||||
/**
|
||||
* Byte-for-byte identical to `DefaultRelays.RELAYS` at
|
||||
* `desktopApp/.../network/RelayStatus.kt`. Preserves current
|
||||
* behaviour for users who never open the settings UI.
|
||||
*
|
||||
* Note: `commons/AmethystDefaults.kt` also has
|
||||
* `DefaultIndexerRelayList` (Purple Pages, Coracle …) which is
|
||||
* more purpose-built for indexing. Adopting it is a separate
|
||||
* ticket — see the plan's "Out of Scope" section.
|
||||
*/
|
||||
val DEFAULT_INDEX_RELAYS: Set<NormalizedRelayUrl> =
|
||||
listOf(
|
||||
"wss://nos.lol",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.noswhere.com",
|
||||
"wss://relay.primal.net",
|
||||
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.toSet()
|
||||
|
||||
internal fun parse(csv: String): Set<NormalizedRelayUrl> =
|
||||
csv
|
||||
.split(",")
|
||||
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
|
||||
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.assemblers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Regression tests for PR #3483 review findings on FeedMetadataCoordinator:
|
||||
*
|
||||
* - Finding 5: `queuedKind3Pubkeys` was marked-on-send, so if every index
|
||||
* relay timed out the pubkeys were permanently marked and subsequent
|
||||
* calls short-circuited — WoT stayed empty for the whole session.
|
||||
* Fix: pubkeys land in `queuedKind3Pubkeys` only after ≥1 EOSE; on
|
||||
* zero-EOSE timeout they roll out of `inFlightBatchedKind3` for retry.
|
||||
*
|
||||
* - Finding 6: `eoseReceived: MutableSet` was mutated from per-relay
|
||||
* `onEose` callbacks running on `Dispatchers.IO` with no sync. Fix:
|
||||
* `BatchEoseGate` funnels EOSE notifications through a `Channel` so a
|
||||
* single consumer coroutine is the sole reader/writer of the `seen`
|
||||
* set.
|
||||
*/
|
||||
class FeedMetadataCoordinatorTest {
|
||||
private lateinit var scope: CoroutineScope
|
||||
private val relay1 = NormalizedRelayUrl("wss://relay1.test/")
|
||||
private val relay2 = NormalizedRelayUrl("wss://relay2.test/")
|
||||
private val relay3 = NormalizedRelayUrl("wss://relay3.test/")
|
||||
private val indexRelays = setOf(relay1, relay2, relay3)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun pubkey(seed: Int): HexKey = seed.toString(16).padStart(64, '0')
|
||||
|
||||
/**
|
||||
* Fake client that captures subscribe/unsubscribe and lets the test
|
||||
* drive EOSE notifications on any dispatcher we choose.
|
||||
*/
|
||||
private class ControllableClient(
|
||||
private val delegate: INostrClient = EmptyNostrClient(),
|
||||
) : INostrClient by delegate {
|
||||
val subscriptions = mutableMapOf<String, SubscriptionListener?>()
|
||||
val subscribeCalls = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
|
||||
var unsubscribeCallCount = 0
|
||||
private set
|
||||
|
||||
override fun subscribe(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
) {
|
||||
subscriptions[subId] = listener
|
||||
subscribeCalls.add(filters)
|
||||
}
|
||||
|
||||
override fun unsubscribe(subId: String) {
|
||||
subscriptions.remove(subId)
|
||||
unsubscribeCallCount++
|
||||
}
|
||||
|
||||
fun fireEose(relay: NormalizedRelayUrl) {
|
||||
subscriptions.values.filterNotNull().forEach { it.onEose(relay, forFilters = null) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadKind3Batched retries after zero-EOSE timeout`() =
|
||||
runBlocking {
|
||||
val client = ControllableClient()
|
||||
val coordinator =
|
||||
FeedMetadataCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = indexRelays,
|
||||
)
|
||||
|
||||
val pubkeys = listOf(pubkey(1), pubkey(2), pubkey(3))
|
||||
|
||||
// Call 1 — no relay EOSEs; must time out.
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
|
||||
delay(350) // exceed the timeout
|
||||
|
||||
// Call 2 — the same pubkeys must be re-subscribed since call 1
|
||||
// never got a successful EOSE. The old code would silently
|
||||
// short-circuit here.
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
|
||||
delay(50) // let the launcher run
|
||||
|
||||
assertEquals(
|
||||
"Zero-EOSE timeout must not permanently dedup pubkeys",
|
||||
2,
|
||||
client.subscribeCalls.size,
|
||||
)
|
||||
assertEquals(
|
||||
"Second call must re-request the same author set",
|
||||
pubkeys.size,
|
||||
client.subscribeCalls[1]
|
||||
.values
|
||||
.first()
|
||||
.first()
|
||||
.authors!!
|
||||
.size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadKind3Batched short-circuits after successful EOSE`() =
|
||||
runBlocking {
|
||||
val client = ControllableClient()
|
||||
val coordinator =
|
||||
FeedMetadataCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = indexRelays,
|
||||
)
|
||||
|
||||
val pubkeys = listOf(pubkey(1), pubkey(2))
|
||||
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 1_000)
|
||||
// Give the launcher time to register the listener before we fire.
|
||||
delay(50)
|
||||
indexRelays.forEach(client::fireEose)
|
||||
delay(200) // let the coordinator finish + promote to queued
|
||||
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
|
||||
delay(50)
|
||||
|
||||
assertEquals(
|
||||
"Successful call must dedup subsequent identical calls",
|
||||
1,
|
||||
client.subscribeCalls.size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadKind3Batched promotes even when only some relays EOSE`() =
|
||||
runBlocking {
|
||||
val client = ControllableClient()
|
||||
val coordinator =
|
||||
FeedMetadataCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = indexRelays,
|
||||
)
|
||||
|
||||
val pubkeys = listOf(pubkey(1))
|
||||
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 300)
|
||||
delay(30)
|
||||
// Only 1 of 3 EOSEs — timeout still fires but we made progress.
|
||||
client.fireEose(relay1)
|
||||
delay(400)
|
||||
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
|
||||
delay(50)
|
||||
|
||||
assertEquals(
|
||||
"≥1 EOSE = progress = promote to queued (avoid re-asking)",
|
||||
1,
|
||||
client.subscribeCalls.size,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for finding 6 — pumps EOSE from many dispatchers in
|
||||
* parallel. The old MutableSet-based code could drop entries or throw
|
||||
* ConcurrentModificationException on the internal HashSet iterator.
|
||||
* BatchEoseGate must aggregate every distinct relay exactly once.
|
||||
*/
|
||||
@Test
|
||||
fun `EOSE aggregator is safe under concurrent per-relay callbacks`() =
|
||||
runBlocking {
|
||||
val bigIndexSet =
|
||||
(0..19).map { NormalizedRelayUrl("wss://relay$it.test/") }.toSet()
|
||||
val client = ControllableClient()
|
||||
val coordinator =
|
||||
FeedMetadataCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = bigIndexSet,
|
||||
)
|
||||
|
||||
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 2_000)
|
||||
delay(50) // wait for subscription
|
||||
|
||||
// Fire EOSEs concurrently from many dispatchers.
|
||||
val jobs =
|
||||
bigIndexSet.map { relay ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
client.fireEose(relay)
|
||||
}
|
||||
}
|
||||
jobs.forEach { it.join() }
|
||||
|
||||
// The 2nd call must short-circuit — every relay EOSE'd, so
|
||||
// pubkey(1) is now in queuedKind3Pubkeys.
|
||||
delay(100)
|
||||
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
|
||||
delay(50)
|
||||
|
||||
assertEquals(
|
||||
"Under concurrent EOSE from all relays, aggregator must reach target",
|
||||
1,
|
||||
client.subscribeCalls.size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadMetadataBatched follows the same retry semantics`() =
|
||||
runBlocking {
|
||||
val client = ControllableClient()
|
||||
val coordinator =
|
||||
FeedMetadataCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = indexRelays,
|
||||
)
|
||||
|
||||
val pubkeys = listOf(pubkey(1), pubkey(2))
|
||||
|
||||
// Call 1 — zero EOSE, timeout.
|
||||
coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200)
|
||||
delay(350)
|
||||
// Call 2 — must re-subscribe.
|
||||
coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200)
|
||||
delay(50)
|
||||
|
||||
assertTrue(
|
||||
"Metadata batch also retries on zero-EOSE timeout",
|
||||
client.subscribeCalls.size >= 2,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear releases in-flight dedup so a fresh call always fires`() =
|
||||
runBlocking {
|
||||
val client = ControllableClient()
|
||||
val coordinator =
|
||||
FeedMetadataCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = indexRelays,
|
||||
)
|
||||
|
||||
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
|
||||
delay(50)
|
||||
// clear() must drop the in-flight tracker even mid-request.
|
||||
coordinator.clear()
|
||||
delay(300) // let call 1 finish + roll back
|
||||
|
||||
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
|
||||
delay(50)
|
||||
|
||||
assertTrue(client.subscribeCalls.size >= 2)
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relays.index
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
class PreferencesIndexRelaysTest {
|
||||
private val testNode = "com/vitorpamplona/amethyst/test/relays/index_${System.currentTimeMillis()}"
|
||||
|
||||
private fun prefs(): Preferences = Preferences.userRoot().node(testNode)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
prefs().clear()
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
prefs().removeNode()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsWhenPreferencesUnset() {
|
||||
val store = PreferencesIndexRelays(prefs())
|
||||
assertTrue(store.relays.value.isEmpty())
|
||||
assertEquals(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS, store.effective())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setRelaysPersistsAcrossInstances() {
|
||||
val store = PreferencesIndexRelays(prefs())
|
||||
val urls =
|
||||
listOf("wss://relay.example", "wss://index.example")
|
||||
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.toSet()
|
||||
store.setRelays(urls)
|
||||
assertEquals(urls, store.relays.value)
|
||||
|
||||
val reloaded = PreferencesIndexRelays(prefs())
|
||||
assertEquals(urls, reloaded.relays.value)
|
||||
assertEquals(urls, reloaded.effective())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun effectiveFallsBackWhenOverrideCleared() {
|
||||
val store = PreferencesIndexRelays(prefs())
|
||||
val urls =
|
||||
listOf("wss://relay.example")
|
||||
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.toSet()
|
||||
store.setRelays(urls)
|
||||
store.setRelays(emptySet())
|
||||
assertEquals(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS, store.effective())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyEntriesInCsvAreSkipped() {
|
||||
// Plant a URL list with empty tokens (extra commas). The
|
||||
// parser should skip blanks silently.
|
||||
prefs().put(PreferencesIndexRelays.KEY_URLS, "wss://good.example,,wss://also-good.example,")
|
||||
val store = PreferencesIndexRelays(prefs())
|
||||
// Both good URLs should be present; no blank / empty entry.
|
||||
assertEquals(2, store.relays.value.size)
|
||||
assertTrue(store.relays.value.none { it.url.isBlank() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultSetIsNotEmpty() {
|
||||
// Guardrail against a future refactor accidentally clearing the constant.
|
||||
assertTrue(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS.isNotEmpty())
|
||||
}
|
||||
}
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.wot
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Coverage for the outbox pipeline defined in
|
||||
* `commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md`.
|
||||
* Scenarios:
|
||||
*
|
||||
* 1. Author has a cached kind-10002 → Phase 1 skipped, Phase 2 REQs
|
||||
* the author's write relay directly.
|
||||
* 2. Author has no cached 10002 → Phase 1 discovers, Phase 2 uses the
|
||||
* discovered write relays.
|
||||
* 3. Author with no 10002 anywhere → Phase 3 fallback to index relays.
|
||||
* 4. Per-relay timeout on Phase 1 doesn't cancel Phase 2 for authors
|
||||
* that already had a cached outbox.
|
||||
* 5. clear() releases dedup so a fresh call always re-runs.
|
||||
*/
|
||||
class OutboxDispatcherTest {
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private val indexRelay1 = NormalizedRelayUrl("wss://index1.test/")
|
||||
private val indexRelay2 = NormalizedRelayUrl("wss://index2.test/")
|
||||
private val indexRelays = setOf(indexRelay1, indexRelay2)
|
||||
|
||||
private val outboxAlice = NormalizedRelayUrl("wss://alice-outbox.test/")
|
||||
private val outboxBob = NormalizedRelayUrl("wss://bob-outbox.test/")
|
||||
|
||||
private val alice = pubkey(1)
|
||||
private val bob = pubkey(2)
|
||||
private val charlie = pubkey(3)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun pubkey(seed: Int): HexKey = seed.toString(16).padStart(64, '0')
|
||||
|
||||
private fun dummySig() = "0".repeat(128)
|
||||
|
||||
private fun outboxEventFor(
|
||||
author: HexKey,
|
||||
writeRelays: List<NormalizedRelayUrl>,
|
||||
createdAt: Long = 1_700_000_000,
|
||||
): AdvertisedRelayListEvent {
|
||||
val tags = writeRelays.map { arrayOf("r", it.url, "write") }.toTypedArray()
|
||||
return AdvertisedRelayListEvent(
|
||||
id = "out-$author".take(64).padEnd(64, '0'),
|
||||
pubKey = author,
|
||||
createdAt = createdAt,
|
||||
tags = tags,
|
||||
content = "",
|
||||
sig = dummySig(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun kind3For(
|
||||
author: HexKey,
|
||||
follows: List<HexKey>,
|
||||
) = ContactListEvent(
|
||||
id = "k3-$author".take(64).padEnd(64, '0'),
|
||||
pubKey = author,
|
||||
createdAt = 1_700_000_100,
|
||||
tags = follows.map { arrayOf("p", it) }.toTypedArray(),
|
||||
content = "",
|
||||
sig = dummySig(),
|
||||
)
|
||||
|
||||
private class RecordingGateway : OutboxCacheGateway {
|
||||
val cache = mutableMapOf<HexKey, AdvertisedRelayListEvent>()
|
||||
val discoveredOutbox = mutableListOf<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>()
|
||||
val discoveredEvents = mutableListOf<Pair<Event, NormalizedRelayUrl>>()
|
||||
|
||||
override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? = cache[pubkey]
|
||||
|
||||
override fun onOutboxDiscovered(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
cache[event.pubKey] = event
|
||||
discoveredOutbox.add(event to relay)
|
||||
}
|
||||
|
||||
override fun onDiscoveredEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
discoveredEvents.add(event to relay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake INostrClient that replays a scripted set of events + auto-EOSEs
|
||||
* per relay when [subscribe] is called. The script is keyed by the
|
||||
* REQ's `(kinds, relay)` pair so tests can seed different responses
|
||||
* for Phase-1 and Phase-2 subs.
|
||||
*/
|
||||
private class ScriptedClient(
|
||||
private val delegate: INostrClient = EmptyNostrClient(),
|
||||
) : INostrClient by delegate {
|
||||
// (kind, relay) → list of events to return
|
||||
private val script = mutableMapOf<Pair<Int, NormalizedRelayUrl>, List<Event>>()
|
||||
private val eoseNever = mutableSetOf<NormalizedRelayUrl>()
|
||||
val allSubscribeCalls = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
|
||||
|
||||
fun scriptEvent(
|
||||
kind: Int,
|
||||
relay: NormalizedRelayUrl,
|
||||
events: List<Event>,
|
||||
) {
|
||||
script[kind to relay] = events
|
||||
}
|
||||
|
||||
fun neverEose(relay: NormalizedRelayUrl) {
|
||||
eoseNever.add(relay)
|
||||
}
|
||||
|
||||
override fun subscribe(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
) {
|
||||
allSubscribeCalls.add(filters)
|
||||
filters.forEach { (relay, filterList) ->
|
||||
filterList.forEach { filter ->
|
||||
filter.kinds?.forEach { kind ->
|
||||
script[kind to relay]?.forEach { event ->
|
||||
listener?.onEvent(event, isLive = false, relay = relay, forFilters = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (relay !in eoseNever) {
|
||||
listener?.onEose(relay, forFilters = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unsubscribe(subId: String) { /* no-op */ }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cached outbox skips Phase 1 and fetches directly from write relay`() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
val gateway = RecordingGateway()
|
||||
gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice))
|
||||
client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob))))
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { indexRelays },
|
||||
gateway = gateway,
|
||||
perRelayTimeoutMs = 400,
|
||||
overallTimeoutMs = 2_000,
|
||||
)
|
||||
|
||||
val result = dispatcher.fetchKind3Only(setOf(alice))
|
||||
|
||||
assertEquals(1, result.kind3Received)
|
||||
assertEquals(1, result.outboxCoveredAuthors)
|
||||
assertEquals(0, result.fallbackAuthors)
|
||||
assertTrue(
|
||||
"Phase 2 must REQ from Alice's own outbox relay",
|
||||
client.allSubscribeCalls.any { call -> outboxAlice in call.keys },
|
||||
)
|
||||
assertTrue(
|
||||
"No Phase 1 REQ should be sent to index relays when 10002 is cached",
|
||||
client.allSubscribeCalls.none { call -> indexRelays.any { it in call.keys } },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Phase 1 discovers 10002 then Phase 2 fetches from the discovered write relay`() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
val gateway = RecordingGateway()
|
||||
val bobOutbox = outboxEventFor(bob, listOf(outboxBob))
|
||||
|
||||
indexRelays.forEach { rel ->
|
||||
client.scriptEvent(AdvertisedRelayListEvent.KIND, rel, listOf(bobOutbox))
|
||||
}
|
||||
client.scriptEvent(ContactListEvent.KIND, outboxBob, listOf(kind3For(bob, listOf(alice))))
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { indexRelays },
|
||||
gateway = gateway,
|
||||
perRelayTimeoutMs = 400,
|
||||
overallTimeoutMs = 2_000,
|
||||
)
|
||||
|
||||
val result = dispatcher.fetchKind3Only(setOf(bob))
|
||||
|
||||
assertTrue("Discovered 10002 count > 0", result.kind10002Received > 0)
|
||||
assertEquals(1, result.kind3Received)
|
||||
assertEquals(1, result.outboxCoveredAuthors)
|
||||
assertEquals(0, result.fallbackAuthors)
|
||||
assertTrue(
|
||||
"Gateway was told about the discovered 10002",
|
||||
gateway.discoveredOutbox.any { it.first.pubKey == bob },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `author with no 10002 falls back to index-relay REQ`() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
val gateway = RecordingGateway()
|
||||
|
||||
// No 10002 anywhere. Charlie's kind-3 sits only on the index relays.
|
||||
indexRelays.forEach { rel ->
|
||||
client.scriptEvent(ContactListEvent.KIND, rel, listOf(kind3For(charlie, listOf(alice))))
|
||||
}
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { indexRelays },
|
||||
gateway = gateway,
|
||||
perRelayTimeoutMs = 400,
|
||||
overallTimeoutMs = 2_000,
|
||||
)
|
||||
|
||||
val result = dispatcher.fetchKind3Only(setOf(charlie))
|
||||
|
||||
assertEquals(1, result.fallbackAuthors)
|
||||
assertEquals(0, result.outboxCoveredAuthors)
|
||||
assertTrue(
|
||||
"Fallback path receives the kind-3",
|
||||
result.kind3Received >= 1,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cached-outbox author still fetched when Phase 1 for other authors times out`() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
val gateway = RecordingGateway()
|
||||
|
||||
// Alice has cached outbox — Phase 2 must fetch from her write relay.
|
||||
gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice))
|
||||
client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob))))
|
||||
|
||||
// Bob has no cached outbox and index relays never EOSE for Phase 1.
|
||||
indexRelays.forEach(client::neverEose)
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { indexRelays },
|
||||
gateway = gateway,
|
||||
perRelayTimeoutMs = 200,
|
||||
overallTimeoutMs = 2_000,
|
||||
)
|
||||
|
||||
val result = dispatcher.fetchKind3Only(setOf(alice, bob))
|
||||
|
||||
// Alice was covered by cached outbox; Bob wasn't but Phase 1 timed
|
||||
// out, so he became a fallback candidate.
|
||||
assertEquals(
|
||||
"Alice always covered by cached outbox",
|
||||
1,
|
||||
result.outboxCoveredAuthors,
|
||||
)
|
||||
assertTrue(result.kind3Received >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear releases dedup so a subsequent identical call refetches`() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
val gateway = RecordingGateway()
|
||||
gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice))
|
||||
client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob))))
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { indexRelays },
|
||||
gateway = gateway,
|
||||
perRelayTimeoutMs = 400,
|
||||
overallTimeoutMs = 2_000,
|
||||
)
|
||||
|
||||
dispatcher.fetchKind3Only(setOf(alice))
|
||||
val subCountAfterFirst = client.allSubscribeCalls.size
|
||||
|
||||
// Second call without clear() — should short-circuit.
|
||||
dispatcher.fetchKind3Only(setOf(alice))
|
||||
assertEquals(subCountAfterFirst, client.allSubscribeCalls.size)
|
||||
|
||||
// After clear(), the same call re-runs Phase 2.
|
||||
dispatcher.clear()
|
||||
dispatcher.fetchKind3Only(setOf(alice))
|
||||
assertTrue(client.allSubscribeCalls.size > subCountAfterFirst)
|
||||
}
|
||||
|
||||
/**
|
||||
* BatchEoseGate stress — inside OutboxDispatcher this is a private
|
||||
* class but the observable effect (Phase 1 completes when all index
|
||||
* relays EOSE, and stays within the timeout budget) is what matters.
|
||||
*/
|
||||
@Test
|
||||
fun `EOSE aggregation is safe with many concurrent index-relay callbacks`() =
|
||||
runBlocking {
|
||||
val bigIndexSet = (0..15).map { NormalizedRelayUrl("wss://index$it.test/") }.toSet()
|
||||
val client = ScriptedClient()
|
||||
val gateway = RecordingGateway()
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { bigIndexSet },
|
||||
gateway = gateway,
|
||||
perRelayTimeoutMs = 1_000,
|
||||
overallTimeoutMs = 3_000,
|
||||
)
|
||||
|
||||
// Kick off a fetch and race the subscribe call. ScriptedClient
|
||||
// fires EOSE inline; we simulate concurrent per-relay EOSE by
|
||||
// launching multiple dispatchers as a smoke test.
|
||||
val fetchJob = scope.launch { dispatcher.fetchKind3Only(setOf(alice, bob, charlie)) }
|
||||
|
||||
// Give the launcher a moment to enter Phase 1's subscribe.
|
||||
delay(50)
|
||||
fetchJob.join()
|
||||
// No CME thrown, no hang past the timeout budget.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.wot
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class WoTServiceTest {
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var svc: WoTService
|
||||
|
||||
// Fixed test pubkeys for readability.
|
||||
private val me = "self".padEnd(64, '0')
|
||||
private val a = "aaaa".padEnd(64, '0')
|
||||
private val b = "bbbb".padEnd(64, '0')
|
||||
private val c = "cccc".padEnd(64, '0')
|
||||
private val d = "dddd".padEnd(64, '0')
|
||||
private val e = "eeee".padEnd(64, '0')
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
svc = WoTService(scope, writerDispatcher = Dispatchers.Unconfined)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* With `Dispatchers.Unconfined` + `Channel.UNLIMITED`, `trySend` from the
|
||||
* test thread synchronously resumes the writer coroutine — so no explicit
|
||||
* wait is needed. This helper is a no-op we keep for future scheduler
|
||||
* changes.
|
||||
*/
|
||||
private fun drain() = Unit
|
||||
|
||||
@Test
|
||||
fun emptyGraphYieldsEmptyScores() {
|
||||
svc.onFollowSetChange(emptySet(), me)
|
||||
drain()
|
||||
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleFollowerCreditsTargets() {
|
||||
svc.onFollowSetChange(setOf(a), me)
|
||||
svc.applyKind3(a, setOf(c, d))
|
||||
drain()
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
assertEquals(1, svc.scoresSnapshot()[d])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlappingFollowersSumScores() {
|
||||
svc.onFollowSetChange(setOf(a, b), me)
|
||||
svc.applyKind3(a, setOf(c, d))
|
||||
svc.applyKind3(b, setOf(c, e))
|
||||
drain()
|
||||
assertEquals(2, svc.scoresSnapshot()[c])
|
||||
assertEquals(1, svc.scoresSnapshot()[d])
|
||||
assertEquals(1, svc.scoresSnapshot()[e])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingFollowerDecrementsAllContributions() {
|
||||
svc.onFollowSetChange(setOf(a, b), me)
|
||||
svc.applyKind3(a, setOf(c, d))
|
||||
svc.applyKind3(b, setOf(c, e))
|
||||
drain()
|
||||
// A drops out.
|
||||
svc.onFollowSetChange(setOf(b), me)
|
||||
drain()
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
// d had only A crediting it — should be gone.
|
||||
assertFalse(c in svc.scoresSnapshot() && d in svc.scoresSnapshot() && svc.scoresSnapshot()[d] == null)
|
||||
assertEquals(null, svc.scoresSnapshot()[d])
|
||||
assertEquals(1, svc.scoresSnapshot()[e])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kind3ChurnAppliesDiff() {
|
||||
svc.onFollowSetChange(setOf(a), me)
|
||||
svc.applyKind3(a, setOf(c, d))
|
||||
drain()
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
assertEquals(1, svc.scoresSnapshot()[d])
|
||||
// A republishes with a different set — d removed, e added.
|
||||
svc.applyKind3(a, setOf(c, e))
|
||||
drain()
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
assertEquals(null, svc.scoresSnapshot()[d])
|
||||
assertEquals(1, svc.scoresSnapshot()[e])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selfInclusionInKind3IsExcluded() {
|
||||
svc.onFollowSetChange(setOf(a), me)
|
||||
// A's kind-3 includes self (me) — must not inflate self's score.
|
||||
svc.applyKind3(a, setOf(c, me))
|
||||
drain()
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
assertEquals(null, svc.scoresSnapshot()[me])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun followerSelfInclusionIsExcluded() {
|
||||
svc.onFollowSetChange(setOf(a), me)
|
||||
// A's kind-3 includes A itself — must not inflate A's own score.
|
||||
svc.applyKind3(a, setOf(c, a))
|
||||
drain()
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
assertEquals(null, svc.scoresSnapshot()[a])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kind3FromNonFollowerIsIgnored() {
|
||||
svc.onFollowSetChange(setOf(a), me)
|
||||
// e is NOT in my follow set — their kind-3 shouldn't credit anyone.
|
||||
svc.applyKind3(e, setOf(c, d))
|
||||
drain()
|
||||
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sparseMapDropsZeroCounts() {
|
||||
svc.onFollowSetChange(setOf(a), me)
|
||||
svc.applyKind3(a, setOf(c))
|
||||
drain()
|
||||
assertTrue(c in svc.scoresSnapshot())
|
||||
// A republishes with an empty follow set.
|
||||
svc.applyKind3(a, emptySet())
|
||||
drain()
|
||||
// c dropped to 0 → removed from map, not stored as 0.
|
||||
assertFalse(c in svc.scoresSnapshot())
|
||||
}
|
||||
|
||||
private fun fakePubkey(seed: Int): String = seed.toString(16).padStart(64, '0')
|
||||
|
||||
@Test
|
||||
fun guardrailSkipsHugeFollowSets() {
|
||||
val hugeFollows = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet()
|
||||
svc.onFollowSetChange(hugeFollows, me)
|
||||
drain()
|
||||
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
|
||||
assertTrue(runBlocking { svc.isReady.first() })
|
||||
assertTrue(runBlocking { svc.isDisabled.first() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for PR #3483 review finding 2: even after the guardrail
|
||||
* trips, applyKind3 for a follower in the huge follow set used to
|
||||
* repopulate reverseIndex/_scores because myFollows had already been
|
||||
* assigned. Fix clears myFollows AND sets a disabled flag; both gate
|
||||
* handleKind3 so the guardrail actually holds under sustained pump.
|
||||
*/
|
||||
@Test
|
||||
fun guardrailIgnoresApplyKind3AfterTrip() {
|
||||
val huge = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet()
|
||||
svc.onFollowSetChange(huge, me)
|
||||
drain()
|
||||
|
||||
val anyFollower = huge.first()
|
||||
svc.applyKind3(anyFollower, setOf(c, d, e))
|
||||
drain()
|
||||
|
||||
assertEquals(
|
||||
"Guardrail must block score repopulation via applyKind3",
|
||||
emptyMap<String, Int>(),
|
||||
svc.scoresSnapshot(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun guardrailReleasesWhenFollowSetShrinksBack() {
|
||||
val huge = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet()
|
||||
svc.onFollowSetChange(huge, me)
|
||||
drain()
|
||||
assertTrue(runBlocking { svc.isDisabled.first() })
|
||||
|
||||
// User trims their follow list — dispatcher should re-engage.
|
||||
svc.onFollowSetChange(setOf(a, b), me)
|
||||
drain()
|
||||
assertFalse(runBlocking { svc.isDisabled.first() })
|
||||
|
||||
// And WoT scoring resumes normally.
|
||||
svc.applyKind3(a, setOf(c, d))
|
||||
drain()
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeStopsAcceptingOps() {
|
||||
svc.onFollowSetChange(setOf(a), me)
|
||||
svc.applyKind3(a, setOf(c))
|
||||
drain()
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
|
||||
svc.close()
|
||||
drain()
|
||||
|
||||
// Post-close writes are dropped silently.
|
||||
svc.applyKind3(a, setOf(d))
|
||||
drain()
|
||||
assertEquals(null, svc.scoresSnapshot()[d])
|
||||
// State observed before close remains readable.
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeIsIdempotent() {
|
||||
svc.close()
|
||||
svc.close() // should not throw
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maxFollowsPerEventCap() {
|
||||
svc.onFollowSetChange(setOf(a), me)
|
||||
val huge = (0..WoTService.MAX_FOLLOWS_PER_EVENT + 100).map { fakePubkey(it) }.toSet()
|
||||
svc.applyKind3(a, huge)
|
||||
drain()
|
||||
// Cap kicks in after MAX_FOLLOWS_PER_EVENT — no crash, score map bounded.
|
||||
assertTrue(svc.scoresSnapshot().size <= WoTService.MAX_FOLLOWS_PER_EVENT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markReadyOnceFiresReady() {
|
||||
assertFalse(runBlocking { svc.isReady.first() })
|
||||
svc.markReadyOnce()
|
||||
drain()
|
||||
assertTrue(runBlocking { svc.isReady.first() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearResetsEverything() {
|
||||
svc.onFollowSetChange(setOf(a), me)
|
||||
svc.applyKind3(a, setOf(c, d))
|
||||
svc.markReadyOnce()
|
||||
drain()
|
||||
svc.clear()
|
||||
drain()
|
||||
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
|
||||
assertFalse(runBlocking { svc.isReady.first() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scoresSnapshotIsHashMapCopy() {
|
||||
svc.onFollowSetChange(setOf(a), me)
|
||||
svc.applyKind3(a, setOf(c))
|
||||
drain()
|
||||
val snap = svc.scoresSnapshot()
|
||||
assertEquals(1, snap[c])
|
||||
// Modifying the snapshot must not affect the service.
|
||||
(snap as MutableMap<String, Int>).clear()
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user