mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-09-19 09:05:46 +08:00
fix: route a-tags by their own coordinate, and revive quartz's dead androidUnitTest
Three related fixes. 1. EventBroadcaster now reads the addressed author straight off the `a` coordinate. The whole a-tag branch was nested inside `getAddressableNoteIfExists(addressId)`, so an event answering an addressable this device had never cached contributed no relays at all - an RSVP to an appointment seen only as a bare reference went to the sender's own outbox and never to the host it was answering. A coordinate is `kind:pubkey:dTag`, so the author is in the tag and needs no cache entry. Adds a pure `addressedAuthors(AddressHintProvider)` (EventBroadcaster is not unit-constructible, so the logic is extracted the way this repo extracts routing decisions) and feeds each result through computeRelayListForLinkedUser, which already falls back to stored relay hints for an unknown user. 2. RenderCalendarRSVPEvent loads and subscribes to the appointment behind the RSVP. LoadAddressableNote puts the AddressableNote in LocalCache and EventFinderFilterAssemblerSubscription asks relays for it - filterMissingAddressables selects exactly the addressables whose event is null. This is what gives the broadcaster's a-tag walk something to walk: fix 1 reaches the host from the coordinate alone, but the other invitees are read off the appointment, which has to be in the cache. Composition-scoped like every other per-note subscription. 3. quartz/src/androidUnitTest/ was a dead source set. The module declares androidHostTest, so nothing under androidUnitTest/ was ever compiled and its two test classes had never run. IntentResultSerializerTest, HintIndexerMemoryTest and their RuntimeExt helper move to androidHostTest, where both now execute and pass. The directory's other two files were stale pre-migration duplicates and are deleted: relayDB.txt is byte-identical to the androidHostTest copy, and its TestResourceLoader.kt predates the loadDecompressString actual, so it would no longer compile against the current expect. `./gradlew test`: 11,694 tests, 0 failures. The 4 pre-existing testAndroidHostTest failures are untouched and still unrelated to this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nxd2uiYajDGuymQK93txzG
This commit is contained in:
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.filter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
@@ -52,6 +53,19 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
|
||||
/**
|
||||
* The pubkeys an `a`-tagging event addresses, read straight from the coordinates
|
||||
* (`kind:pubkey:dTag`) rather than from whatever the local cache happens to hold.
|
||||
*
|
||||
* This is what lets [EventBroadcaster] route to an addressed author's inbox relays when the
|
||||
* addressable itself was never cached on this device - a NIP-52 RSVP being the motivating case,
|
||||
* since its `a` tag is the only thing tying it to the appointment's host.
|
||||
*
|
||||
* Unparseable coordinates are dropped; the result is deduplicated because an event may address
|
||||
* several addressables by the same author (a calendar listing its own appointments).
|
||||
*/
|
||||
fun addressedAuthors(event: AddressHintProvider): Set<HexKey> = event.linkedAddressIds().mapNotNullTo(mutableSetOf()) { Address.parse(it)?.pubKeyHex }
|
||||
|
||||
/**
|
||||
* The sign-and-publish choke point for an [Account]: computes the relay set an
|
||||
* event should be broadcast to (NIP-65 outbox model, relay hints, channel home
|
||||
@@ -232,6 +246,16 @@ class EventBroadcaster(
|
||||
event.addressHints().forEach {
|
||||
relayList.add(it.relay)
|
||||
}
|
||||
|
||||
// An `a` coordinate names its own author, so the addressed user's inbox is reachable
|
||||
// straight from the tag. Everything in the loop below is nested inside a cache
|
||||
// lookup, so without this an event aimed at an addressable this device never cached
|
||||
// - an RSVP to a calendar appointment that arrived as a bare reference, say - went
|
||||
// only to the sender's own outbox and never to the author it was answering.
|
||||
addressedAuthors(event).forEach { authorPubKey ->
|
||||
relayList.addAll(computeRelayListForLinkedUser(authorPubKey))
|
||||
}
|
||||
|
||||
event.linkedAddressIds().forEach { addressId ->
|
||||
account.cache.getAddressableNoteIfExists(addressId)?.let { linkedNote ->
|
||||
val linkedNoteAuthor = linkedNote.author
|
||||
|
||||
+40
-3
@@ -21,16 +21,18 @@
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
|
||||
/**
|
||||
* Entry for a NIP-52 calendar RSVP: decodes the [Note] and renders the shared commons
|
||||
* [CalendarRsvpCard]. Draws only from the event's own tags, so the entry keeps the
|
||||
* dispatcher signature without touching the account or nav.
|
||||
* Entry for a NIP-52 calendar RSVP: decodes the [Note], makes sure the appointment it answers is
|
||||
* in the cache, and renders the shared commons [CalendarRsvpCard].
|
||||
*/
|
||||
@Composable
|
||||
fun RenderCalendarRSVPEvent(
|
||||
@@ -40,5 +42,40 @@ fun RenderCalendarRSVPEvent(
|
||||
) {
|
||||
val event = note.event as? CalendarRSVPEvent ?: return
|
||||
|
||||
LoadAppointmentBehind(event, accountViewModel)
|
||||
|
||||
CalendarRsvpCard(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the appointment this RSVP answers, from the `a` tag that is the only thing tying the
|
||||
* two together.
|
||||
*
|
||||
* [LoadAddressableNote] creates the [com.vitorpamplona.amethyst.commons.model.AddressableNote] in
|
||||
* `LocalCache` — a shell with a null event if we have never seen the appointment — and
|
||||
* [EventFinderFilterAssemblerSubscription] then asks relays for it: `filterMissingAddressables`
|
||||
* picks up exactly those addressables whose `event == null` and queries the address author's
|
||||
* outbox relays plus any stored hints.
|
||||
*
|
||||
* Both halves matter beyond drawing this card. `EventBroadcaster` routes an RSVP by following its
|
||||
* `a` tag into the appointment and reading the participants off it, and every step of that walk
|
||||
* is a `LocalCache` lookup. An RSVP seen in a feed for an appointment that was never cached would
|
||||
* otherwise leave the cache with no entry to walk, so answering it from here would reach the host
|
||||
* (whose pubkey the coordinate carries) but none of the other invitees.
|
||||
*
|
||||
* Composition-scoped like every other per-note subscription: the row unsubscribes ~30s after it
|
||||
* scrolls away or the app backgrounds.
|
||||
*/
|
||||
@Composable
|
||||
private fun LoadAppointmentBehind(
|
||||
event: CalendarRSVPEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val address = remember(event) { event.calendarEventAddress() } ?: return
|
||||
|
||||
LoadAddressableNote(address, accountViewModel) { appointment ->
|
||||
if (appointment != null) {
|
||||
EventFinderFilterAssemblerSubscription(appointment, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [addressedAuthors] is what lets [EventBroadcaster] reach an addressed author's inbox relays
|
||||
* without a cache hit. The rest of the a-tag branch is nested inside
|
||||
* `getAddressableNoteIfExists(addressId)`, so before this existed an RSVP answering an
|
||||
* appointment this device had never cached went only to the sender's own outbox — the host it
|
||||
* was replying to never received it.
|
||||
*/
|
||||
class AddressedAuthorRoutingTest {
|
||||
private val host = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
private val other = "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64"
|
||||
private val relay = "wss://relay.damus.io/"
|
||||
|
||||
private fun rsvp(vararg tags: Array<String>) =
|
||||
CalendarRSVPEvent(
|
||||
id = "00".repeat(32),
|
||||
pubKey = "11".repeat(32),
|
||||
createdAt = 1700000000,
|
||||
tags = arrayOf(*tags),
|
||||
content = "",
|
||||
sig = "00".repeat(64),
|
||||
)
|
||||
|
||||
private fun calendar(vararg tags: Array<String>) =
|
||||
CalendarEvent(
|
||||
id = "22".repeat(32),
|
||||
pubKey = host,
|
||||
createdAt = 1700000000,
|
||||
tags = arrayOf(*tags),
|
||||
content = "",
|
||||
sig = "00".repeat(64),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an RSVP addresses the appointment's host`() {
|
||||
val event = rsvp(arrayOf("a", "31923:$host:party", relay), arrayOf("status", "accepted"))
|
||||
|
||||
assertEquals(setOf(host), addressedAuthors(event))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the host is found without a relay hint on the tag`() {
|
||||
// The hint is optional; the coordinate alone must still identify who to deliver to.
|
||||
val event = rsvp(arrayOf("a", "31923:$host:party"), arrayOf("status", "declined"))
|
||||
|
||||
assertEquals(setOf(host), addressedAuthors(event))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a day-slot appointment resolves the same way`() {
|
||||
val event = rsvp(arrayOf("a", "31922:$host:all-day"), arrayOf("status", "tentative"))
|
||||
|
||||
assertEquals(setOf(host), addressedAuthors(event))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar addresses every appointment author it lists`() {
|
||||
val event =
|
||||
calendar(
|
||||
arrayOf("d", "my-calendar"),
|
||||
arrayOf("a", "31923:$host:party", relay),
|
||||
arrayOf("a", "31922:$other:standup"),
|
||||
)
|
||||
|
||||
assertEquals(setOf(host, other), addressedAuthors(event))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated authors collapse to one delivery target`() {
|
||||
// A calendar usually lists many appointments by the same host; their inbox is one target.
|
||||
val event =
|
||||
calendar(
|
||||
arrayOf("d", "my-calendar"),
|
||||
arrayOf("a", "31923:$host:party"),
|
||||
arrayOf("a", "31923:$host:standup"),
|
||||
arrayOf("a", "31922:$host:all-day"),
|
||||
)
|
||||
|
||||
assertEquals(setOf(host), addressedAuthors(event))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed coordinate is dropped rather than throwing`() {
|
||||
val event =
|
||||
rsvp(
|
||||
arrayOf("a", "not-a-coordinate"),
|
||||
arrayOf("a", "31923:$host:party"),
|
||||
arrayOf("status", "accepted"),
|
||||
)
|
||||
|
||||
assertEquals(setOf(host), addressedAuthors(event))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event with no a tags addresses nobody`() {
|
||||
assertTrue(addressedAuthors(rsvp(arrayOf("status", "accepted"))).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz
|
||||
|
||||
actual class TestResourceLoader {
|
||||
actual fun loadString(file: String): String =
|
||||
this@TestResourceLoader
|
||||
.javaClass.classLoader!!
|
||||
.getResourceAsStream(file)
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user