From f64b2e6f1cf489d172b426185949a36097736656 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 05:42:46 +0000 Subject: [PATCH 01/14] fix(quartz): record REQ state before send in PoolRequests.syncState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On connect, syncFilters re-sends every desired REQ through PoolRequests.syncState. It previously sent the frame and only recorded the subscription as SENT afterward, in the post-send onSent callback. A relay that answers faster than that callback runs — the in-process transport used by the desktop launch-optimization tests, or any relay on a fast path — can deliver the EOSE while the per-sub state still reads "nothing in flight" (onConnecting cleared it, onSent hasn't recorded it). The EOSE handler then sees empty filters, concludes it never sent a REQ, and fires a duplicate, replaying the whole page a second time. Pre-mark the sub as SENT under its lock before the frame leaves, mirroring the decideCommandLocked pre-mark already used by sendToRelayIfChanged, so a response can never race ahead of the record. The send stays unconditional: this is a fresh-connection sync (onConnecting always cleared the per-relay state first), so there is no in-flight REQ on the new socket to dedupe against. Fixes the flaky SubscribeBeforeConnectTest, which asserted a pre-connect subscription delivers exactly its events once and intermittently saw them doubled. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W4kmtZoNUSXxwG23wD2JwP --- .../relay/client/pool/PoolRequests.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 289d62300e..7dfa5a5333 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -295,9 +295,26 @@ class PoolRequests { relay: NormalizedRelayUrl, sync: (Command) -> Unit, ) { - desiredSubs.forEach { subId, filters -> - val filters = filters[relay] + // Pre-mark the subscription as SENT (recording the filters) *before* + // the frame leaves, so a response can never race ahead of the record. + // A fast relay — the in-process transport, or any relay that answers + // before the sender's post-send onSent callback runs — can deliver the + // EOSE for this REQ while the state still reads "nothing in flight" + // (onConnecting cleared it and onSent hasn't recorded it yet). The EOSE + // handler would then see empty filters, conclude it never sent a REQ, + // and fire a duplicate — replaying the whole page a second time. + // Recording under the lock up front closes that window. + // + // This is a fresh-connection sync (onConnected → onConnecting always + // cleared the per-relay state first), so every desired filter is + // (re)sent unconditionally: unlike the change-driven path there is no + // in-flight REQ on this brand-new socket to dedupe against. + desiredSubs.forEach { subId, perRelayFilters -> + val filters = perRelayFilters[relay] if (!filters.isNullOrEmpty()) { + subState(subId).let { state -> + state.withLock { state.onOpenReq(relay, filters) } + } sync(ReqCmd(subId, filters)) } } From 3d4c2f1963647f3468f1718045be825843081bbc Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sat, 4 Jul 2026 07:31:05 +0000 Subject: [PATCH 02/14] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 14c17b1cb0..c4570c676d 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -958,6 +958,118 @@ पुटप्रसार कडियाँ देखें कोई कडी प्राप्त नहीं अब तक + पूर्वावलोकन + वर्ष %1$d + अभद्र + समाप्त + अधिमूल्य + कार्यक्रम का समर्थन + इनके द्वारा %1$s + वर्ष %1$d । कडी %2$d + कडी %1$d + वर्ष %1$d + चलचित्र + प्रतिलिपि + अध्याय + मूल्य के लिए मूल्य + %1$d प्रतिशतप्रतिशत + इसको ज्साप इनमें विभाजित : + निमन्त्रक तथा अभ्यागत + प्रमुखाम्श चलाएँ + ऊर्ध्वतम समर्थक + + %1$d अध्याय + %1$d अध्याय + + निमन्त्रक + सह निमन्त्रक + सम्पादक + सत्यापित कर्ता + मूल्य के लिए मूल्य अपक्रम + इस पुटप्रसार का कोई भुगतान सम्भाव्य मूल्य प्राप्तकर्ता नहीं। + एक नोस्टर धनकोष संयोजन जोडें कुंचिकाप्रेषण (कडी) प्राप्तकर्ताओं को भेजने के लिए। + साट्स प्रवाह + %1$d साट्स प्रति मिनुट + स्वचालित रूप से मूल्य भेजता है जब आप सुन रहे हैं। + %1$d साट्स प्रवाहित इस सत्र में + एक नोस्टर धनकोष संयोजन अथवा ऋणांकन धनकोष जोडें सुनते समय साट्स प्रवाह चलाने के लिए। + नयी कडी + कडी सम्पादन + प्रकाशन… + बाहरी कलाकृति जोडें + वर्गाकार चित्र जो दिखेगा कडी के लिए + शीर्षक + कडी शीर्षक + साराम्श + अवधि (सेकण्ड) + अधिक विवरण + वर्ष + कडी अंक + चलचित्र जालपता + प्रतिलिपि जालपता + अध्याय जालपता + विषय सूची + अल्पविराम पृथक्कृत विषयसूचक + ध्वनि जालपता + https://…/episode.mp3 + ध्वनि अभिलेख आरोहणार्थ तत्पर + ध्वनि अभिलेख जोडें + एमपी३॰ अथवा एम४ए॰ अथवा अन्य ध्वनि + कडी मिटाएँ + क्या इस कडी को मिटा दें। इसे पूर्ववत नहीं किया जा सकता। + आपका पुटप्रसार + बाहरी कलाकृति जोडें + वर्गाकार कलाकृति आपके कार्यक्रम के लिए + कार्यक्रम शीर्षक + मेरा पुटप्रसार + विवरण + कर्ता + जालसम्पर्क + जालस्थान + श्रेणियाँ + तन्त्रज्ञान समाचार इत्यादि + वित्तपोषण योजक + https://… + भाषा + आंग्ल + प्रतिलिप्यधिकार + कार्यक्रम प्रकार + कड्यात्मक + धारावाहिक + अभद्र विषयवस्तु + कार्यक्रम समाप्त (कोई अन्य कडी नहीं) + तालाबद्ध (अधिमूल्य) + नया पूर्वावलोकन + पूर्वावलोकन अम्श जोडें + लघु ध्वनि अथवा दृश्य पूर्वीक्षण + पूर्वावलोकन शीर्षक + अभिलेख जालपता + आपका पुटप्रसार + शीर्षकरहित + कोई कडी नहीं अब तक। नयी कडी दबाएँ अपने प्रथम कडी प्रकाशित करने के लिए। + अपना पुटप्रसार बनाएँ + दबाएँ कार्यक्रम विवरण सम्पादन करने के लिए + कार्यक्रम शीर्षक कलाकृति तथा विवरण की स्थापना करें + + %1$d पूर्वावलोकन + %1$d पूर्वावलोकन + + प्राप्तकर्ता जोडें आगत साट्स को भार अनुसार विभाजित करने के लिए। श्रोतागण उद्धृत करेंगे अथवा मूल्य प्रवाहित करेंगे इन गन्तव्यों को। + प्राप्तकर्ता जोडें + पता जोडें स्वयम हाथ से + नोस्टर प्रयोक्ता जोडें + नाम अथवा @विभेदक द्वारा ढूँढें + इस प्रयोक्ता का कोई लैटनिंग॰ पता नहीं + प्राप्तकर्ता हटाएँ + नाम (विकल्पात्मक) + लैटनिंग॰ पता + कडी (कुंचिकाप्रेषण) + लैटनिंग॰ पता + name@example.com + कडी ख्याप्यकुंचिका + 02abc… (३३ अष्टक षोडशांक) + भार + शुल्क %1$d कडी %1$d कडियाँ @@ -988,6 +1100,8 @@ सार्वजनिक स्मर्तव्य क्रमलेखकोश आपके स्मर्त्तव्यचिह्नित गिट क्रमलेखकोश + पुटप्रसार + आपके स्मर्त्तव्यचिह्नित पुटप्रसार तथा कडियाँ निजी स्मर्तव्य सूची में जोडें सार्वजनिक स्मर्तव्य सूची में जोडें निजी स्मर्तव्य सूची से हटाएँ @@ -3177,6 +3291,9 @@ सम्पादन स्थापना विकल्प पाण्डुलिपियाँ स्वतः बनाएँ स्वतः पाण्डुलिपि घटना का अभिलेखन करता है जब आप टंकण करते है अथवा सम्पादक छोडते हैं अप्रेषित लेख के साथ तथा आपके निजी निर्गतपेटिका पुनःप्रसारकों को भेजता है। + हस्ताक्षर + सन्देश के अन्त में जोडा जाएगा नए पत्र प्रत्युत्तर उद्धरण अथवा निबन्ध खोलते समय। रिक्त छोड दें अक्षम करने के लिए। + आपका हस्ताक्षर इसका प्रयोग करें हटाएँ सम्यक From a83b8e4064d5d4fd4831f5a86e32c63405c10270 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 4 Jul 2026 09:38:56 +0200 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20support=20Birdstar=20bird=20detec?= =?UTF-8?q?tion=20events=20(kind=202473)=20-=20fetch=20Birdex=20life=20lis?= =?UTF-8?q?ts=20in=20home=20and=20profile=20relay=20REQs=20-=20richer=20Bi?= =?UTF-8?q?rdstar=20cards=20=E2=80=94=20common=20name=20title,=20Wikidata?= =?UTF-8?q?=20link,=20bird=20emoji=20-=20surface=20Birdstar=20bird=20detec?= =?UTF-8?q?tions=20in=20home=20and=20profile=20feeds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../amethyst/model/LocalCache.kt | 5 + .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/types/Birdex.kt | 54 +++++++- .../dal/FollowPackFeedNewThreadFeedFilter.kt | 2 + .../home/dal/HomeNewThreadFeedFilter.kt | 2 + .../nip65Follows/FilterHomePostsByAuthors.kt | 4 + .../datasource/FilterUserProfilePosts.kt | 4 + .../mutual/dal/UserProfileMutualFeedFilter.kt | 2 + .../dal/UserProfileNewThreadFeedFilter.kt | 2 + .../loggedIn/threadview/ThreadFeedView.kt | 4 + amethyst/src/main/res/values/strings.xml | 1 + .../birdstar/BirdDetectionEvent.kt | 87 +++++++++++++ .../vitorpamplona/quartz/kinds/KindNames.kt | 2 + .../quartz/utils/EventFactory.kt | 2 + .../birdstar/BirdDetectionEventTest.kt | 119 ++++++++++++++++++ 15 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEvent.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEventTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 59fc275990..101451360d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -54,6 +54,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -3489,6 +3490,10 @@ object LocalCache : ILocalCache, ICacheProvider { consumeBaseReplaceable(event, relay, wasVerified) } + is BirdDetectionEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + is CommentEvent -> { consumeRegularEvent(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index d7cd351841..748e848c32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -118,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward +import com.vitorpamplona.amethyst.ui.note.types.RenderBirdDetection import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent @@ -229,6 +230,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent @@ -1381,6 +1383,10 @@ private fun RenderNoteRow( RenderBirdex(baseNote) } + is BirdDetectionEvent -> { + RenderBirdDetection(baseNote) + } + is RoadEventReportEvent -> { RenderRoadEventReport(baseNote) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Birdex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Birdex.kt index 5237134276..8f9de9df14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Birdex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Birdex.kt @@ -30,12 +30,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent /** How many species names to list before collapsing into a "+N more" suffix. */ @@ -61,7 +65,7 @@ fun RenderBirdex(baseNote: Note) { Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { Text( - text = pluralStringResource(R.plurals.birdex_species_count, names.size, names.size), + text = "\uD83D\uDC26 " + pluralStringResource(R.plurals.birdex_species_count, names.size, names.size), style = MaterialTheme.typography.titleMedium, ) @@ -82,3 +86,51 @@ fun RenderBirdex(baseNote: Note) { } } } + +/** + * Minimal card for a single Birdstar bird detection (kind 2473). + * + * The event has no body and no images. The title is the common name parsed from + * the publisher's `alt` tag (falling back to the raw `alt`, then a generic + * label), and the scientific name renders as an italic link to the Wikidata + * species entry from the `i` tag when one is present. The `g` geohash is + * surfaced by the generic note-location UI, not here. + */ +@Composable +fun RenderBirdDetection(baseNote: Note) { + val noteEvent = baseNote.event as? BirdDetectionEvent ?: return + + val commonName = remember(noteEvent) { noteEvent.commonName() } + val summary = remember(noteEvent) { noteEvent.summary() } + val species = remember(noteEvent) { noteEvent.speciesName() } + val reference = remember(noteEvent) { noteEvent.speciesReference() } + + Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { + Text( + text = "\uD83D\uDC26 " + (commonName ?: summary ?: stringResource(R.string.bird_detection_title)), + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + if (species != null) { + Spacer(Modifier.height(6.dp)) + val speciesStyle = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic) + if (reference != null) { + ClickableUrl( + urlText = species, + url = reference, + style = speciesStyle, + ) + } else { + Text( + text = species, + style = speciesStyle, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt index 988d6b12fc..f4821e3315 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent @@ -140,6 +141,7 @@ class FollowPackFeedNewThreadFeedFilter( noteEvent is ClassifiedsEvent || noteEvent is FundraiserEvent || noteEvent is BirdexEvent || + noteEvent is BirdDetectionEvent || noteEvent.isRenderableRepost() || (noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) || (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index 7e68087aca..5e3597e2cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent @@ -129,6 +130,7 @@ class HomeNewThreadFeedFilter( noteEvent is ClassifiedsEvent || noteEvent is FundraiserEvent || noteEvent is BirdexEvent || + noteEvent is BirdDetectionEvent || noteEvent.isRenderableRepost() || (noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) || (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index cd65087039..2979f555f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopN import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent @@ -72,6 +74,8 @@ val HomePostsNewThreadKinds2 = ChessGameEvent.KIND, LiveChessGameChallengeEvent.KIND, LiveChessGameEndEvent.KIND, + BirdDetectionEvent.KIND, + BirdexEvent.KIND, ) val HomePostsConversationKinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index 1db6f91b4b..5ecb3cd102 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent @@ -74,6 +76,8 @@ val UserProfilePostKinds2 = ZapPollEvent.KIND, PinListEvent.KIND, AttestationEvent.KIND, + BirdDetectionEvent.KIND, + BirdexEvent.KIND, ) // NIP-5A nsites (15128/35128) and NIP-5D napplets (15129/35129) the user publishes, surfaced in diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt index ac353129c5..fbddcc4a63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent @@ -80,6 +81,7 @@ class UserProfileMutualFeedFilter( it.event is ClassifiedsEvent || it.event is FundraiserEvent || it.event is BirdexEvent || + it.event is BirdDetectionEvent || it.event.isRenderableRepost() || it.event is LongTextNoteEvent || it.event is WikiNoteEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index 4e65e5bc6a..bc874d4425 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent @@ -86,6 +87,7 @@ class UserProfileNewThreadFeedFilter( it.event is ClassifiedsEvent || it.event is FundraiserEvent || it.event is BirdexEvent || + it.event is BirdDetectionEvent || it.event.isRenderableRepost() || it.event is LongTextNoteEvent || it.event is WikiNoteEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 85c84cfa69..e452ea6032 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -155,6 +155,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation +import com.vitorpamplona.amethyst.ui.note.types.RenderBirdDetection import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent @@ -249,6 +250,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent @@ -972,6 +974,8 @@ private fun FullBleedNoteCompose( RenderFundraiser(baseNote, makeItShort = false, accountViewModel, nav) } else if (noteEvent is BirdexEvent) { RenderBirdex(baseNote) + } else if (noteEvent is BirdDetectionEvent) { + RenderBirdDetection(baseNote) } else if (noteEvent is RoadEventReportEvent) { RenderRoadEventReport(baseNote) } else if (noteEvent is RoadEventConfirmationEvent) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 38c36cf6ab..96d1287e7d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3485,6 +3485,7 @@ %1$s funded of %2$s sats goal Ends %1$s On-chain donation + Bird detection Birdex · %1$d species Birdex · %1$d species diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEvent.kt new file mode 100644 index 0000000000..fd1450d676 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEvent.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.birdstar + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip50Search.SearchableEvent + +/** + * Birdstar bird detection (kind 2473). + * + * An app-specific **regular** kind published by the Birdstar app + * (`birdstar.app`) — a single bird sighting/detection. It is **not** defined by + * any NIP; the schema below is derived from events seen in the wild. It is the + * per-sighting companion of the replaceable [BirdexEvent] (kind 12473) life list. + * + * The event carries no body — its payload is in the tags: + * + * - `n` — the detected species' scientific name (e.g. `Porphyrio martinica`). + * - `i` — an external identity reference for the species, a Wikidata entity URL + * (NIP-73 style, e.g. `https://www.wikidata.org/entity/Q27074644`). + * - `g` — a geohash of the detection location (standard NIP-01 `g` tag; use + * `Event.geohashes()` to read it). + * - `alt` — a human-readable summary written by the publisher + * (e.g. `Bird detection: Purple Gallinule (Porphyrio martinica)`). + */ +@Immutable +class BirdDetectionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(summary(), speciesName()).joinToString("\n") + + /** Scientific name of the detected species, from the `n` tag (may be null). */ + fun speciesName() = tags.firstTagValue("n") + + /** External species reference (Wikidata entity URL), from the `i` tag (may be null). */ + fun speciesReference() = tags.firstTagValue("i") + + /** Publisher-provided human-readable summary, from the `alt` tag (may be null). */ + fun summary() = tags.firstTagValue("alt") + + /** + * Common (vernacular) species name, parsed out of the `alt` tag. Birdstar + * writes `Bird detection: ()`; this strips the + * fixed prefix and the trailing parenthetical. Null when the `alt` tag is + * missing or nothing is left after stripping. + */ + fun commonName(): String? { + val alt = summary() ?: return null + return alt + .removePrefix(ALT_PREFIX) + .substringBeforeLast(" (") + .trim() + .ifBlank { null } + } + + companion object { + const val KIND = 2473 + private const val ALT_PREFIX = "Bird detection:" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 5e522ade47..dd58d1936a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent @@ -558,6 +559,7 @@ object KindNames { GoodWikiRelayListEvent.KIND to KindName("Wiki Relays", "51"), UserGraspListEvent.KIND to KindName("GRASP Servers", "34"), BirdexEvent.KIND to KindName("Birdex", null), + BirdDetectionEvent.KIND to KindName("Bird Detection", null), NwcInfoEvent.KIND to KindName("NWC Info", "47"), RelayMembershipListEvent.KIND to KindName("Relay Memberships", "43"), RootSiteEvent.KIND to KindName("Website Root", "5A"), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 3e3a3081a0..cce8601fa1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent @@ -354,6 +355,7 @@ class EventFactory { AudioTrackEvent.KIND -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig) BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + BirdDetectionEvent.KIND -> BirdDetectionEvent(id, pubKey, createdAt, tags, content, sig) BirdexEvent.KIND -> BirdexEvent(id, pubKey, createdAt, tags, content, sig) BidEvent.KIND -> BidEvent(id, pubKey, createdAt, tags, content, sig) BidConfirmationEvent.KIND -> BidConfirmationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEventTest.kt new file mode 100644 index 0000000000..1ce1037473 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEventTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.birdstar + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class BirdDetectionEventTest { + // Real event from the wild (relay.ditto.pub), published by birdstar.app + private fun sampleEvent(): Event = + EventFactory.create( + id = "2b1450b8886c997ff14ab57d46fc5a60929ab8bba3dc0c7f1ebfd5916e41a0cc", + pubKey = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd", + createdAt = 1_783_089_908L, + kind = BirdDetectionEvent.KIND, + tags = + arrayOf( + arrayOf("alt", "Bird detection: Purple Gallinule (Porphyrio martinica)"), + arrayOf("i", "https://www.wikidata.org/entity/Q27074644"), + arrayOf("n", "Porphyrio martinica"), + arrayOf("g", "9vk"), + arrayOf("client", "birdstar.app"), + ), + content = "", + sig = + "7a30bea2cb4b5b2a826448294bfae072d2948f02e3d259f824c7706fbc1e2a32" + + "5661efef9a63d5127e7fc26abbd15df55b98df9f44017f19de91616e97b5cad7", + ) + + @Test + fun factoryBuildsBirdDetectionForKind2473() { + val event = sampleEvent() + assertTrue( + event is BirdDetectionEvent, + "Expected a BirdDetectionEvent but got ${event::class.simpleName}", + ) + } + + @Test + fun kind2473IsNowKnown() { + assertTrue(EventFactory.isKnownKind(BirdDetectionEvent.KIND), "kind 2473 should be a known kind") + } + + @Test + fun parsesCommonNameFromAltTag() { + val event = sampleEvent() + assertIs(event) + + assertEquals("Purple Gallinule", event.commonName()) + } + + @Test + fun commonNameIsNullWithoutAltTag() { + val event: Event = + EventFactory.create( + id = "00".repeat(32), + pubKey = "00".repeat(32), + createdAt = 1_783_089_908L, + kind = BirdDetectionEvent.KIND, + tags = arrayOf(arrayOf("n", "Porphyrio martinica")), + content = "", + sig = "00".repeat(64), + ) + assertIs(event) + + assertEquals(null, event.commonName()) + } + + @Test + fun commonNameHandlesAltWithoutParenthetical() { + val event: Event = + EventFactory.create( + id = "00".repeat(32), + pubKey = "00".repeat(32), + createdAt = 1_783_089_908L, + kind = BirdDetectionEvent.KIND, + tags = arrayOf(arrayOf("alt", "Bird detection: Purple Gallinule")), + content = "", + sig = "00".repeat(64), + ) + assertIs(event) + + assertEquals("Purple Gallinule", event.commonName()) + } + + @Test + fun parsesBirdDetectionFields() { + val event = sampleEvent() + assertIs(event) + + assertEquals("Porphyrio martinica", event.speciesName()) + assertEquals("https://www.wikidata.org/entity/Q27074644", event.speciesReference()) + assertEquals("Bird detection: Purple Gallinule (Porphyrio martinica)", event.summary()) + assertEquals(listOf("9vk"), event.geohashes()) + } +} From b8d3791ea397ead6d11ddc3e59fc2cc79ebf4934 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 4 Jul 2026 10:53:20 +0200 Subject: [PATCH 04/14] Code review: - only parse commonName when the alt tag has the Birdstar prefix - summary() on both Birdstar events now uses the canonical NIP-31 tags.alt() helper instead of a raw firstTagValue("alt") lookup - speciesReference() only returns http(s) URLs since UIs render it as a clickable link (rejects e.g. javascript: schemes), with a test - Detection card: parse tags once into a single remember slot, drop the near-dead '?: summary' title fallback, stop rebuilding the italic TextStyle every recomposition - Hoist the duplicated bird-emoji literal into a shared BIRD_PREFIX - Trim the redundant factory test to the assertIs idiom --- .../amethyst/ui/note/types/Birdex.kt | 50 ++++++++++++------- .../birdstar/BirdDetectionEvent.kt | 21 +++++--- .../experimental/birdstar/BirdexEvent.kt | 6 +-- .../birdstar/BirdDetectionEventTest.kt | 40 +++++++++++++-- 4 files changed, 85 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Birdex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Birdex.kt index 8f9de9df14..a73f3a2532 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Birdex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Birdex.kt @@ -45,6 +45,9 @@ import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent /** How many species names to list before collapsing into a "+N more" suffix. */ private const val SPECIES_PREVIEW_LIMIT = 6 +/** Bird emoji prefix shared by both Birdstar card titles. */ +private const val BIRD_PREFIX = "\uD83D\uDC26 " + /** * Minimal, fixed-size summary card for a Birdstar "Birdex" (kind 12473). * @@ -65,7 +68,7 @@ fun RenderBirdex(baseNote: Note) { Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { Text( - text = "\uD83D\uDC26 " + pluralStringResource(R.plurals.birdex_species_count, names.size, names.size), + text = BIRD_PREFIX + pluralStringResource(R.plurals.birdex_species_count, names.size, names.size), style = MaterialTheme.typography.titleMedium, ) @@ -91,41 +94,47 @@ fun RenderBirdex(baseNote: Note) { * Minimal card for a single Birdstar bird detection (kind 2473). * * The event has no body and no images. The title is the common name parsed from - * the publisher's `alt` tag (falling back to the raw `alt`, then a generic - * label), and the scientific name renders as an italic link to the Wikidata - * species entry from the `i` tag when one is present. The `g` geohash is - * surfaced by the generic note-location UI, not here. + * the publisher's `alt` tag (a generic label when absent), and the scientific + * name renders as an italic link to the Wikidata species entry from the `i` tag + * when one is present. The `g` geohash is surfaced by the generic note-location + * UI, not here. */ @Composable fun RenderBirdDetection(baseNote: Note) { val noteEvent = baseNote.event as? BirdDetectionEvent ?: return - val commonName = remember(noteEvent) { noteEvent.commonName() } - val summary = remember(noteEvent) { noteEvent.summary() } - val species = remember(noteEvent) { noteEvent.speciesName() } - val reference = remember(noteEvent) { noteEvent.speciesReference() } + val detection = + remember(noteEvent) { + BirdDetectionInfo( + commonName = noteEvent.commonName(), + species = noteEvent.speciesName(), + reference = noteEvent.speciesReference(), + ) + } Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { Text( - text = "\uD83D\uDC26 " + (commonName ?: summary ?: stringResource(R.string.bird_detection_title)), + text = BIRD_PREFIX + (detection.commonName ?: stringResource(R.string.bird_detection_title)), style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, ) - if (species != null) { + if (detection.species != null) { Spacer(Modifier.height(6.dp)) - val speciesStyle = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic) - if (reference != null) { + if (detection.reference != null) { + val typography = MaterialTheme.typography + val speciesStyle = remember(typography) { typography.bodyMedium.copy(fontStyle = FontStyle.Italic) } ClickableUrl( - urlText = species, - url = reference, + urlText = detection.species, + url = detection.reference, style = speciesStyle, ) } else { Text( - text = species, - style = speciesStyle, + text = detection.species, + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -134,3 +143,10 @@ fun RenderBirdDetection(baseNote: Note) { } } } + +/** Tag values parsed once per event for the detection card. */ +private class BirdDetectionInfo( + val commonName: String?, + val species: String?, + val reference: String?, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEvent.kt index fd1450d676..a45eef32cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip50Search.SearchableEvent /** @@ -59,20 +60,26 @@ class BirdDetectionEvent( /** Scientific name of the detected species, from the `n` tag (may be null). */ fun speciesName() = tags.firstTagValue("n") - /** External species reference (Wikidata entity URL), from the `i` tag (may be null). */ - fun speciesReference() = tags.firstTagValue("i") + /** + * External species reference (Wikidata entity URL), from the `i` tag. + * Only http(s) URLs are returned — UIs render this as a clickable link, + * so other schemes are rejected here rather than at every call site. + */ + fun speciesReference() = tags.firstTagValue("i")?.takeIf { it.startsWith("https://") || it.startsWith("http://") } - /** Publisher-provided human-readable summary, from the `alt` tag (may be null). */ - fun summary() = tags.firstTagValue("alt") + /** Publisher-provided human-readable summary, from the NIP-31 `alt` tag (may be null). */ + fun summary() = tags.alt() /** * Common (vernacular) species name, parsed out of the `alt` tag. Birdstar - * writes `Bird detection: ()`; this strips the - * fixed prefix and the trailing parenthetical. Null when the `alt` tag is - * missing or nothing is left after stripping. + * currently writes `Bird detection: ()` (in + * English); this strips the fixed prefix and the trailing parenthetical. + * Null when the `alt` tag is missing or nothing is left after stripping — + * including if a future Birdstar release rewords the alt text. */ fun commonName(): String? { val alt = summary() ?: return null + if (!alt.startsWith(ALT_PREFIX)) return null return alt .removePrefix(ALT_PREFIX) .substringBeforeLast(" (") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdexEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdexEvent.kt index 254635bfdc..5d15bb0896 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdexEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdexEvent.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.quartz.experimental.birdstar import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip50Search.SearchableEvent /** @@ -65,8 +65,8 @@ class BirdexEvent( /** Number of collected species (one `n` tag per species). */ fun speciesCount() = speciesNames().size - /** Publisher-provided human-readable summary, from the `alt` tag (may be null). */ - fun summary() = tags.firstTagValue("alt") + /** Publisher-provided human-readable summary, from the NIP-31 `alt` tag (may be null). */ + fun summary() = tags.alt() companion object { const val KIND = 12473 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEventTest.kt index 1ce1037473..a65fb84f29 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEventTest.kt @@ -52,11 +52,24 @@ class BirdDetectionEventTest { @Test fun factoryBuildsBirdDetectionForKind2473() { - val event = sampleEvent() - assertTrue( - event is BirdDetectionEvent, - "Expected a BirdDetectionEvent but got ${event::class.simpleName}", - ) + assertIs(sampleEvent()) + } + + @Test + fun speciesReferenceRejectsNonHttpValues() { + val event: Event = + EventFactory.create( + id = "00".repeat(32), + pubKey = "00".repeat(32), + createdAt = 1_783_089_908L, + kind = BirdDetectionEvent.KIND, + tags = arrayOf(arrayOf("i", "javascript:alert(1)")), + content = "", + sig = "00".repeat(64), + ) + assertIs(event) + + assertEquals(null, event.speciesReference()) } @Test @@ -89,6 +102,23 @@ class BirdDetectionEventTest { assertEquals(null, event.commonName()) } + @Test + fun commonNameIsNullWhenAltLacksTheBirdstarPrefix() { + val event: Event = + EventFactory.create( + id = "00".repeat(32), + pubKey = "00".repeat(32), + createdAt = 1_783_089_908L, + kind = BirdDetectionEvent.KIND, + tags = arrayOf(arrayOf("alt", "Arbitrary attacker-controlled title (spoof)")), + content = "", + sig = "00".repeat(64), + ) + assertIs(event) + + assertEquals(null, event.commonName()) + } + @Test fun commonNameHandlesAltWithoutParenthetical() { val event: Event = From 370a56ab690cfbdc280ca26357618b0ffd988863 Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:46:51 +0000 Subject: [PATCH 05/14] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-pl-rPL/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 967f605ae6..4e450cd7a7 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -2608,6 +2608,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest %1$d elementów %1$d elementy + + Plik %1$d zmieniony + Plików %1$d zmienionych + Plików %1$d zmienionych + %1$d pliki zostały zmienione + Plik binarny nie jest wyświetlany. Nowy Nowy problem From c3d6c054821a404c5aff6652f85bd417fda58c3a Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 3 Jul 2026 22:33:38 +0200 Subject: [PATCH 06/14] fix: handle File.delete() return values in cache eviction and voice cleanup --- .../amethyst/service/images/ThumbnailDiskCache.kt | 6 +++++- .../ui/screen/loggedIn/home/VoiceReplyViewModel.kt | 7 +++++-- .../amethyst/napplethost/NappletBlobCache.kt | 8 ++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt index 1a8cf64c42..208150293b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt @@ -193,7 +193,11 @@ class ThumbnailDiskCache( files .sortedBy { it.lastModified() } .take(files.size - maxEntries) - .forEach { it.delete() } + .forEach { + if (!it.delete()) { + Log.w("ThumbnailDiskCache") { "Failed to evict thumbnail ${it.absolutePath}" } + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 39c0e39194..4113ec5a08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -156,8 +156,11 @@ class VoiceReplyViewModel : ViewModel() { voiceLocalFile?.let { file -> try { if (file.exists()) { - file.delete() - Log.d("VoiceReplyViewModel") { "Deleted voice file: ${file.absolutePath}" } + if (file.delete()) { + Log.d("VoiceReplyViewModel") { "Deleted voice file: ${file.absolutePath}" } + } else { + Log.w("VoiceReplyViewModel") { "Failed to delete voice file: ${file.absolutePath}" } + } } } catch (e: Exception) { Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt index a0b2636a65..ecd61878c8 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt @@ -69,8 +69,12 @@ class NappletBlobCache( if (total <= maxBytes) return files.sortedBy { it.lastModified() }.forEach { f -> if (total <= maxBytes) return - total -= f.length() - f.delete() + val length = f.length() + if (f.delete()) { + total -= length + } else { + Log.w("NappletBlobCache") { "Failed to evict blob ${f.absolutePath}" } + } } } } From 2b9ffb4849c422b03bf149b8e6c1c15480810d03 Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 3 Jul 2026 22:44:20 +0200 Subject: [PATCH 07/14] Code review: - extract shared File.deleteOrWarn helper for cache eviction --- .../service/images/ThumbnailDiskCache.kt | 7 +-- .../loggedIn/home/VoiceReplyViewModel.kt | 5 +-- .../amethyst/commons/util/FileDeletion.kt | 44 +++++++++++++++++++ .../amethyst/napplethost/NappletBlobCache.kt | 16 ++++--- 4 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletion.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt index 208150293b..32b1c1fe70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.images import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -193,11 +194,7 @@ class ThumbnailDiskCache( files .sortedBy { it.lastModified() } .take(files.size - maxEntries) - .forEach { - if (!it.delete()) { - Log.w("ThumbnailDiskCache") { "Failed to evict thumbnail ${it.absolutePath}" } - } - } + .forEach { it.deleteOrWarn("ThumbnailDiskCache", "thumbnail") } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 4113ec5a08..ce3e28411b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator @@ -156,10 +157,8 @@ class VoiceReplyViewModel : ViewModel() { voiceLocalFile?.let { file -> try { if (file.exists()) { - if (file.delete()) { + if (file.deleteOrWarn("VoiceReplyViewModel", "voice file")) { Log.d("VoiceReplyViewModel") { "Deleted voice file: ${file.absolutePath}" } - } else { - Log.w("VoiceReplyViewModel") { "Failed to delete voice file: ${file.absolutePath}" } } } } catch (e: Exception) { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletion.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletion.kt new file mode 100644 index 0000000000..6ca8b99870 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletion.kt @@ -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.util + +import com.vitorpamplona.quartz.utils.Log +import java.io.File + +/** + * Deletes this file, warning on real failures only. [File.delete] returns false + * both when deletion genuinely fails and when the file is already gone (e.g. + * removed by a concurrent eviction in another thread or process); only the + * former deserves a warning. + * + * @param tag the log tag of the calling component + * @param what a short noun for the log message, e.g. "thumbnail" or "blob" + * @return true when the file no longer exists, whether this call deleted it or + * it was already gone; false when it still exists and could not be deleted. + */ +fun File.deleteOrWarn( + tag: String, + what: String, +): Boolean { + if (delete() || !exists()) return true + Log.w(tag) { "Failed to delete $what $absolutePath" } + return false +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt index ecd61878c8..baf1109759 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.napplethost +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -64,16 +65,17 @@ class NappletBlobCache( /** Best-effort eviction: if the store exceeds [maxBytes], delete oldest blobs until under it. */ fun trimToSize(maxBytes: Long) { runCatching { - val files = dir.listFiles()?.filter { it.isFile && !it.name.contains(".tmp.") } ?: return - var total = files.sumOf { it.length() } + val files = + dir + .listFiles() + ?.filter { it.isFile && !it.name.contains(".tmp.") } + ?.map { it to it.length() } ?: return + var total = files.sumOf { it.second } if (total <= maxBytes) return - files.sortedBy { it.lastModified() }.forEach { f -> + files.sortedBy { it.first.lastModified() }.forEach { (f, length) -> if (total <= maxBytes) return - val length = f.length() - if (f.delete()) { + if (f.deleteOrWarn("NappletBlobCache", "blob")) { total -= length - } else { - Log.w("NappletBlobCache") { "Failed to evict blob ${f.absolutePath}" } } } } From 27650f2f770fec3f5332cbcbf535b70d252498e7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 4 Jul 2026 14:09:29 +0200 Subject: [PATCH 08/14] fix: handle remaining unchecked File.delete() and Tor dir permission results --- .../commons/keystorage/SecureKeyStorage.kt | 3 +- .../service/upload/UploadOrchestrator.kt | 5 ++-- .../desktop/account/AccountManager.kt | 7 +++-- .../desktop/account/DesktopAccountStorage.kt | 3 +- .../amethyst/desktop/relay/LocalRelayStore.kt | 3 +- .../service/drafts/DesktopDraftStore.kt | 5 ++-- .../service/media/VideoThumbnailCache.kt | 7 +++-- .../amethyst/desktop/tor/DesktopTorManager.kt | 28 ++++++++++++++----- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 3 +- .../desktop/ui/media/CompressionPreview.kt | 3 +- 10 files changed, 45 insertions(+), 22 deletions(-) diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index 76620aeea7..bcfe4a3509 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.keystorage import com.github.javakeyring.BackendNotSupportedException import com.github.javakeyring.Keyring import com.github.javakeyring.PasswordAccessException +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -277,7 +278,7 @@ actual class SecureKeyStorage private actual constructor() { } finally { // Clean up temp file if it still exists if (tempFile.exists()) { - tempFile.delete() + tempFile.deleteOrWarn("SecureKeyStorage", "temp key file") } } } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt index 1c214a4a94..058dc558d7 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.service.upload import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.ReencodeResult +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult @@ -144,8 +145,8 @@ class UploadOrchestrator( // Eager cleanup of every intermediate. NonCancellable so a // user-cancelled upload still cleans up its temps. withContext(NonCancellable) { - strippedTemp?.delete() - reencodedTemp?.delete() + strippedTemp?.deleteOrWarn("UploadOrchestrator", "stripped temp") + reencodedTemp?.deleteOrWarn("UploadOrchestrator", "reencoded temp") } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index b5c4e5598c..66b0924a06 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.model.account.SignerType +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -261,9 +262,9 @@ class AccountManager internal constructor( suspend fun loadSavedAccount(): Result = try { // Clean up legacy files (one-time, silent) - File(amethystDir, "last_account.txt").delete() - File(amethystDir, "bunker_uri.txt").delete() - File(amethystDir, "nwc_connection.txt").delete() + File(amethystDir, "last_account.txt").deleteOrWarn("AccountManager", "legacy file") + File(amethystDir, "bunker_uri.txt").deleteOrWarn("AccountManager", "legacy file") + File(amethystDir, "nwc_connection.txt").deleteOrWarn("AccountManager", "legacy file") // Single source of truth: accounts.json.enc val activeNpub = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt index 40c32e2ad4..3f6ff53ec2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.model.account.AccountStorage import com.vitorpamplona.amethyst.commons.model.account.SignerType +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.utils.Log import java.io.File import java.nio.file.Files @@ -166,7 +167,7 @@ class DesktopAccountStorage( val backup = File(file.parent, "accounts.json.enc.corrupt.${System.currentTimeMillis()}") java.nio.file.Files .copy(file.toPath(), backup.toPath()) - file.delete() + file.deleteOrWarn("DesktopAccountStorage", "corrupt accounts file") backup.absolutePath } catch (_: Exception) { null diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt index 2dea705f9c..566df9e006 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.desktop.relay import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -276,7 +277,7 @@ class LocalRelayStore( private fun deleteDbFiles(path: String) { listOf("", "-wal", "-shm", "-journal").forEach { suffix -> - File(path + suffix).delete() + File(path + suffix).deleteOrWarn("LocalRelayStore", "relay db file") } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt index ac0dbaade0..8271970177 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.service.drafts import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -174,7 +175,7 @@ class DesktopDraftStore( suspend fun deleteDraft(slug: String) { val safeSlug = sanitizeSlug(slug) mutex.withLock { - File(draftsDir, "$safeSlug.md").delete() + File(draftsDir, "$safeSlug.md").deleteOrWarn("DesktopDraftStore", "draft file") val index = loadIndexMap() index.remove(safeSlug) @@ -249,7 +250,7 @@ class DesktopDraftStore( StandardCopyOption.REPLACE_EXISTING, ) } finally { - if (tempFile.exists()) tempFile.delete() + if (tempFile.exists()) tempFile.deleteOrWarn("DesktopDraftStore", "temp draft file") } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt index 09cc733d95..be0e10bb3a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.service.media import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -137,7 +138,7 @@ object VideoThumbnailCache { // a persistent cache hit (decoders may fail on every // retry against a half-MP4). Discard so the next request // re-downloads from scratch. - if (!downloaded.persistable) downloaded.file.delete() + if (!downloaded.persistable) downloaded.file.deleteOrWarn("VideoThumbnailCache", "truncated video chunk") } } } @@ -166,7 +167,7 @@ object VideoThumbnailCache { val hash = sha1Hex(url) val cached = File(downloadCacheDir, "$hash.mp4") if (cached.length() > 0L) return Download(cached, persistable = true) - if (cached.exists()) cached.delete() + if (cached.exists()) cached.deleteOrWarn("VideoThumbnailCache", "empty cached chunk") var wrote = false var rangeHonored = false @@ -181,7 +182,7 @@ object VideoThumbnailCache { } } if (!wrote || cached.length() == 0L) { - cached.delete() + cached.deleteOrWarn("VideoThumbnailCache", "empty cached chunk") return null } return Download(cached, persistable = rangeHonored) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt index 210d2f2777..e8881b6375 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt @@ -46,6 +46,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission /** * Desktop Tor daemon manager using kmp-tor. @@ -190,13 +192,7 @@ class DesktopTorManager( private fun desktopEnvironment(): TorRuntime.Environment { val appDir = torDataDirectory() appDir.mkdirs() - // Restrict permissions to owner only (700) - appDir.setReadable(false, false) - appDir.setReadable(true, true) - appDir.setWritable(false, false) - appDir.setWritable(true, true) - appDir.setExecutable(false, false) - appDir.setExecutable(true, true) + restrictToOwner(appDir) return TorRuntime.Environment.Builder( workDirectory = appDir.resolve("work"), @@ -205,6 +201,24 @@ class DesktopTorManager( ) {} } + /** Restricts [dir] to owner only (700) — Tor state includes onion keys. */ + private fun restrictToOwner(dir: File) { + try { + Files.setPosixFilePermissions( + dir.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ), + ) + } catch (e: UnsupportedOperationException) { + // Windows: no POSIX permissions; the user profile's NTFS ACLs apply instead. + } catch (e: Exception) { + Log.w("DesktopTorManager", "Could not restrict permissions on ${dir.absolutePath}", e) + } + } + /** OS-specific data directory for Tor. */ internal fun torDataDirectory(): File { val osName = System.getProperty("os.name", "").lowercase() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index fedaa0a6c5..3c36d63e95 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadResult import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.ImageCompressionStore import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -282,7 +283,7 @@ fun ComposeNoteDialog( // User opted out of the compressed // version — drop the temp before we // ship the original. - item.compressedFile.delete() + item.compressedFile.deleteOrWarn("ComposeNoteDialog", "compressed temp") orchestrator.upload( file = file, alt = null, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt index 66698f4b92..75064dd991 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.ImageFormatSniffer import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.PassReason import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.ReencodeResult +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import java.io.File import javax.imageio.ImageIO @@ -163,7 +164,7 @@ suspend fun buildPreview( fun cleanupPreviewTemps(items: List) { items.forEach { item -> if (item is PreviewItem.Reencoded) { - item.compressedFile.delete() + item.compressedFile.deleteOrWarn("CompressionPreview", "compressed temp") } } } From 206c0979b1cf56c3d553517ca9850dab4ecb126d Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 4 Jul 2026 14:34:05 +0200 Subject: [PATCH 09/14] =?UTF-8?q?Code=20review:=20-=20align=20voice-file?= =?UTF-8?q?=20debug=20log=20with=20deleteOrWarn's=20is-gone=20contract=20-?= =?UTF-8?q?=20Convert=20the=20delete-then-warn=20sites=20the=20sweep=20lef?= =?UTF-8?q?t=20hand-rolled=20in=20already=20=20=20touched=20files:=20Thumb?= =?UTF-8?q?nailDiskCache=20corrupt-file=20and=20temp-thumbnail=20cleanup,?= =?UTF-8?q?=20=20=20NappletBlobCache.put=20leftover=20temp,=20and=20Secure?= =?UTF-8?q?KeyStorage's=20bare=20delete=20of=20=20=20the=20fallback=20key?= =?UTF-8?q?=20file=20(the=20highest-stakes=20delete=20in=20that=20file).?= =?UTF-8?q?=20-=20Drop=20the=20exists()=20guards=20left=20layered=20over?= =?UTF-8?q?=20deleteOrWarn=20=E2=80=94=20the=20helper=20=20=20already=20tr?= =?UTF-8?q?eats=20an=20absent=20file=20as=20silent=20success.=20-=20Collap?= =?UTF-8?q?se=20AccountManager's=20legacy-file=20triple=20into=20a=20loop?= =?UTF-8?q?=20and=20drop=20the=20=20=20stale=20"silent"=20from=20its=20com?= =?UTF-8?q?ment.=20-=20Snapshot=20lastModified=20alongside=20length=20in?= =?UTF-8?q?=20NappletBlobCache.trimToSize=20so=20=20=20sortedBy=20compares?= =?UTF-8?q?=20in-memory=20values=20instead=20of=20stat-ing=20per=20compari?= =?UTF-8?q?son.=20-=20Promote=20DesktopTorManager's=20private=20restrictTo?= =?UTF-8?q?Owner=20into=20a=20shared=20=20=20File.restrictToOwner(tag)=20i?= =?UTF-8?q?n=20commons=20(600=20files=20/=20700=20dirs)=20=E2=80=94=20the?= =?UTF-8?q?=20repo's=20=20=20sixth=20private=20copy=20of=20this=20pattern?= =?UTF-8?q?=20was=20one=20too=20many;=20the=20remaining=20copies=20=20=20c?= =?UTF-8?q?an=20migrate=20incrementally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/images/ThumbnailDiskCache.kt | 8 +-- .../loggedIn/home/VoiceReplyViewModel.kt | 6 +- .../commons/keystorage/SecureKeyStorage.kt | 8 +-- .../amethyst/commons/util/FilePermissions.kt | 58 +++++++++++++++++++ .../desktop/account/AccountManager.kt | 7 +-- .../service/drafts/DesktopDraftStore.kt | 2 +- .../service/media/VideoThumbnailCache.kt | 2 +- .../amethyst/desktop/tor/DesktopTorManager.kt | 24 +------- .../amethyst/napplethost/NappletBlobCache.kt | 9 ++- 9 files changed, 77 insertions(+), 47 deletions(-) create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt index 32b1c1fe70..67b86c2824 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt @@ -69,9 +69,7 @@ class ThumbnailDiskCache( BitmapFactory.decodeFile(file.absolutePath) } catch (e: Exception) { Log.w("ThumbnailDiskCache", "Failed to decode cached thumbnail, deleting: ${file.absolutePath}", e) - if (!file.delete()) { - Log.w("ThumbnailDiskCache") { "Failed to delete corrupt cache file: ${file.absolutePath}" } - } + file.deleteOrWarn("ThumbnailDiskCache", "corrupt cache file") null } } @@ -156,9 +154,7 @@ class ThumbnailDiskCache( scaled.recycle() if (!tempFile.renameTo(finalFile)) { Log.w("ThumbnailDiskCache") { "Failed to rename temp thumbnail to final: ${tempFile.absolutePath}" } - if (!tempFile.delete()) { - Log.w("ThumbnailDiskCache") { "Failed to delete temp thumbnail: ${tempFile.absolutePath}" } - } + tempFile.deleteOrWarn("ThumbnailDiskCache", "temp thumbnail") return false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index ce3e28411b..40c5b3919a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -156,10 +156,8 @@ class VoiceReplyViewModel : ViewModel() { private fun deleteVoiceLocalFile() { voiceLocalFile?.let { file -> try { - if (file.exists()) { - if (file.deleteOrWarn("VoiceReplyViewModel", "voice file")) { - Log.d("VoiceReplyViewModel") { "Deleted voice file: ${file.absolutePath}" } - } + if (file.deleteOrWarn("VoiceReplyViewModel", "voice file")) { + Log.d("VoiceReplyViewModel") { "Voice file removed or already gone: ${file.absolutePath}" } } } catch (e: Exception) { Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e) diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index bcfe4a3509..00772efcec 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -229,7 +229,7 @@ actual class SecureKeyStorage private actual constructor() { if (existed) { if (data.isEmpty()) { - fallbackFile.delete() + fallbackFile.deleteOrWarn("SecureKeyStorage", "fallback key file") } else { atomicWriteFallbackData(fallbackFile, data) } @@ -276,10 +276,8 @@ actual class SecureKeyStorage private actual constructor() { StandardCopyOption.REPLACE_EXISTING, ) } finally { - // Clean up temp file if it still exists - if (tempFile.exists()) { - tempFile.deleteOrWarn("SecureKeyStorage", "temp key file") - } + // Clean up any leftover temp file + tempFile.deleteOrWarn("SecureKeyStorage", "temp key file") } } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissions.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissions.kt new file mode 100644 index 0000000000..0ebbd1861b --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissions.kt @@ -0,0 +1,58 @@ +/* + * 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.util + +import com.vitorpamplona.quartz.utils.Log +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +/** + * Restricts this file or directory to owner-only access (600 for files, + * 700 for directories), best-effort. + * + * Silent on filesystems without POSIX permissions (Windows), where the user + * profile's NTFS ACLs apply instead; warns when a POSIX filesystem refuses. + * + * @param tag the log tag of the calling component + */ +fun File.restrictToOwner(tag: String) { + try { + val permissions = + if (isDirectory) { + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ) + } else { + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ) + } + Files.setPosixFilePermissions(toPath(), permissions) + } catch (_: UnsupportedOperationException) { + // Windows: no POSIX permissions; the user profile's NTFS ACLs apply instead. + } catch (e: Exception) { + Log.w(tag, "Could not restrict permissions on $absolutePath", e) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 66b0924a06..068b5d1770 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -261,10 +261,9 @@ class AccountManager internal constructor( suspend fun loadSavedAccount(): Result = try { - // Clean up legacy files (one-time, silent) - File(amethystDir, "last_account.txt").deleteOrWarn("AccountManager", "legacy file") - File(amethystDir, "bunker_uri.txt").deleteOrWarn("AccountManager", "legacy file") - File(amethystDir, "nwc_connection.txt").deleteOrWarn("AccountManager", "legacy file") + // Clean up legacy files (one-time) + listOf("last_account.txt", "bunker_uri.txt", "nwc_connection.txt") + .forEach { File(amethystDir, it).deleteOrWarn("AccountManager", "legacy file") } // Single source of truth: accounts.json.enc val activeNpub = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt index 8271970177..536802c6be 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt @@ -250,7 +250,7 @@ class DesktopDraftStore( StandardCopyOption.REPLACE_EXISTING, ) } finally { - if (tempFile.exists()) tempFile.deleteOrWarn("DesktopDraftStore", "temp draft file") + tempFile.deleteOrWarn("DesktopDraftStore", "temp draft file") } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt index be0e10bb3a..a7d28d7b9d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt @@ -167,7 +167,7 @@ object VideoThumbnailCache { val hash = sha1Hex(url) val cached = File(downloadCacheDir, "$hash.mp4") if (cached.length() > 0L) return Download(cached, persistable = true) - if (cached.exists()) cached.deleteOrWarn("VideoThumbnailCache", "empty cached chunk") + cached.deleteOrWarn("VideoThumbnailCache", "empty cached chunk") var wrote = false var rangeHonored = false diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt index e8881b6375..2742782a25 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.tor import com.vitorpamplona.amethyst.commons.tor.ITorManager import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.util.restrictToOwner import com.vitorpamplona.quartz.utils.Log import io.matthewnelson.kmp.tor.resource.exec.tor.ResourceLoaderTorExec import io.matthewnelson.kmp.tor.runtime.Action.Companion.startDaemonAsync @@ -46,8 +47,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.io.File -import java.nio.file.Files -import java.nio.file.attribute.PosixFilePermission /** * Desktop Tor daemon manager using kmp-tor. @@ -192,7 +191,8 @@ class DesktopTorManager( private fun desktopEnvironment(): TorRuntime.Environment { val appDir = torDataDirectory() appDir.mkdirs() - restrictToOwner(appDir) + // Owner-only (700) — Tor state includes onion keys. + appDir.restrictToOwner("DesktopTorManager") return TorRuntime.Environment.Builder( workDirectory = appDir.resolve("work"), @@ -201,24 +201,6 @@ class DesktopTorManager( ) {} } - /** Restricts [dir] to owner only (700) — Tor state includes onion keys. */ - private fun restrictToOwner(dir: File) { - try { - Files.setPosixFilePermissions( - dir.toPath(), - setOf( - PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE, - PosixFilePermission.OWNER_EXECUTE, - ), - ) - } catch (e: UnsupportedOperationException) { - // Windows: no POSIX permissions; the user profile's NTFS ACLs apply instead. - } catch (e: Exception) { - Log.w("DesktopTorManager", "Could not restrict permissions on ${dir.absolutePath}", e) - } - } - /** OS-specific data directory for Tor. */ internal fun torDataDirectory(): File { val osName = System.getProperty("os.name", "").lowercase() diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt index baf1109759..3439f537dc 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.napplethost import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 import java.io.File @@ -56,8 +55,8 @@ class NappletBlobCache( dir.mkdirs() val tmp = File(dir, "$sha256.tmp.${System.nanoTime()}") tmp.writeBytes(bytes) - if (!tmp.renameTo(target) && !tmp.delete()) { - Log.w("NappletBlobCache") { "Failed to delete leftover temp file ${tmp.absolutePath} after a failed rename" } + if (!tmp.renameTo(target)) { + tmp.deleteOrWarn("NappletBlobCache", "leftover temp file") } } } @@ -69,10 +68,10 @@ class NappletBlobCache( dir .listFiles() ?.filter { it.isFile && !it.name.contains(".tmp.") } - ?.map { it to it.length() } ?: return + ?.map { Triple(it, it.length(), it.lastModified()) } ?: return var total = files.sumOf { it.second } if (total <= maxBytes) return - files.sortedBy { it.first.lastModified() }.forEach { (f, length) -> + files.sortedBy { it.third }.forEach { (f, length, _) -> if (total <= maxBytes) return if (f.deleteOrWarn("NappletBlobCache", "blob")) { total -= length From afe8c783c07aeef34e76af2f1b55d725fbdff6b2 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 4 Jul 2026 17:47:53 +0200 Subject: [PATCH 10/14] test(commons): cover deleteOrWarn and restrictToOwner helpers --- .../amethyst/commons/util/FileDeletionTest.kt | 74 +++++++++++++++++ .../commons/util/FilePermissionsTest.kt | 83 +++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletionTest.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissionsTest.kt diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletionTest.kt new file mode 100644 index 0000000000..b9f2ed652d --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletionTest.kt @@ -0,0 +1,74 @@ +/* + * 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.util + +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FileDeletionTest { + private val createdFiles = mutableListOf() + + @AfterTest + fun cleanup() { + createdFiles.asReversed().forEach { it.delete() } + createdFiles.clear() + } + + private fun tempFile(): File = + File.createTempFile("file-deletion-test", ".tmp").also { + createdFiles.add(it) + } + + @Test + fun deletesExistingFileAndReturnsTrue() { + val file = tempFile() + assertTrue(file.exists()) + + assertTrue(file.deleteOrWarn("FileDeletionTest", "temp file")) + assertFalse(file.exists()) + } + + @Test + fun alreadyGoneFileIsSuccess() { + val file = tempFile() + assertTrue(file.delete()) + + // Simulates a concurrent eviction: the file vanished before our delete. + assertTrue(file.deleteOrWarn("FileDeletionTest", "temp file")) + } + + @Test + fun undeletableFileReturnsFalseAndKeepsFile() { + // A non-empty directory cannot be deleted, and still exists afterwards — + // the genuine-failure branch. + val dir = Files.createTempDirectory("file-deletion-test").toFile() + val child = File(dir, "child.txt").apply { writeText("keeps dir non-empty") } + createdFiles.add(dir) + createdFiles.add(child) + + assertFalse(dir.deleteOrWarn("FileDeletionTest", "non-empty dir")) + assertTrue(dir.exists()) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissionsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissionsTest.kt new file mode 100644 index 0000000000..6640ffa76f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissionsTest.kt @@ -0,0 +1,83 @@ +/* + * 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.util + +import java.io.File +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class FilePermissionsTest { + private val createdFiles = mutableListOf() + + @AfterTest + fun cleanup() { + createdFiles.asReversed().forEach { it.delete() } + createdFiles.clear() + } + + private fun isPosix() = FileSystems.getDefault().supportedFileAttributeViews().contains("posix") + + @Test + fun restrictsFileToOwnerReadWrite() { + if (!isPosix()) return // Windows: restrictToOwner is documented as a silent no-op + + val file = File.createTempFile("file-permissions-test", ".tmp") + createdFiles.add(file) + + file.restrictToOwner("FilePermissionsTest") + + assertEquals( + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + Files.getPosixFilePermissions(file.toPath()), + ) + } + + @Test + fun restrictsDirectoryToOwnerReadWriteExecute() { + if (!isPosix()) return + + val dir = Files.createTempDirectory("file-permissions-test").toFile() + createdFiles.add(dir) + + dir.restrictToOwner("FilePermissionsTest") + + assertEquals( + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ), + Files.getPosixFilePermissions(dir.toPath()), + ) + } + + @Test + fun missingFileDoesNotThrow() { + val ghost = File(System.getProperty("java.io.tmpdir"), "file-permissions-test-missing-${System.nanoTime()}") + + // Best-effort contract: failures are logged, never thrown. + ghost.restrictToOwner("FilePermissionsTest") + } +} From bb6bf99586d4cc124c3320865ffd9d519de90113 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 16:21:55 +0000 Subject: [PATCH 11/14] chore: update negentropy-kmp to v1.1.1 Bumps the negentropy-kmp dependency from v1.0.2 to v1.1.1. The release is backward compatible for consumers (StorageVector still provided; the codebase only consumes IStorage and never implements it). Quartz compiles and all NIP-77 negentropy tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01M3659Rs6ayVm4XmxvbtpWE --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index adc03d031d..e121e7fa6d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,7 +52,7 @@ material3 = "1.9.0" media3 = "1.10.1" mockk = "1.14.11" kotlinx-coroutines-test = "1.11.0" -negentropyKmp = "v1.0.2" +negentropyKmp = "v1.1.1" netUrlencoderLibVersion = "1.6.0" navigationCompose = "2.9.8" okhttp = "5.4.0" From 9bb1d3aaf29bc6c75bc4b6913434ac97ecf645f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:48:18 +0000 Subject: [PATCH 12/14] fix: stabilize flaky LocalRelayStoreHydrationTest against GC eviction DesktopLocalCache stores Users in a WeakReference-backed LargeSoftCache. The followee User in kind3IsHydratedBeforeKind0SoMetadataLoadsForFollowedAuthors is created only during hydrate's kind:0 phase and has no Note referencing it, so it is only weakly reachable once hydrate returns. A GC landing between hydrate() and the assertions evicted it, flaking the test (reproduced deterministically by forcing System.gc()). Pin a strong reference to the followee's User for the duration of the test so the cache cannot evict it, mirroring how followed users stay reachable via live account/UI state in the running app. The ordering invariant the test asserts is unaffected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NHQ3g7wD9WbDvj7NspiAWW --- .../desktop/relay/LocalRelayStoreHydrationTest.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt index eadaf362e9..3bd92f908a 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt @@ -167,6 +167,16 @@ class LocalRelayStoreHydrationTest { val cache = DesktopLocalCache() val store = newStore() + + // Pin a strong reference to the followee's User for the duration of the + // test. DesktopLocalCache stores Users in a WeakReference-backed cache + // (LargeSoftCache), and a metadata-only followed user has no Note pointing + // at it, so nothing else keeps it strongly reachable. Without this pin a GC + // landing between hydrate() and the assertions below could evict the user + // and flake the test. In the running app followed users stay reachable via + // live account/UI state. + val pinnedFollowee = cache.getOrCreateUser(followee.pubKey.toHexKey()) + try { store.hydrate(cache) } finally { @@ -184,6 +194,9 @@ class LocalRelayStoreHydrationTest { actual = followeeUser.toBestDisplayName(), message = "Phase 2 (kind:0) must run after phase 1 so metadata is applied to followed users", ) + // Keep the pin alive past the assertions and confirm hydrate updated the + // same instance rather than a second copy. + assertEquals(pinnedFollowee, followeeUser, "hydrate must update the cached User in place") } @Test From 772b4ea8ed30486e3eea03f14873940d37aeb629 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 4 Jul 2026 16:14:50 +0200 Subject: [PATCH 13/14] fix(quartz): repair equals/hashCode contracts in OTS ops and VerifyResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Op instances key Timestamp.ops (MutableMap), so contract violations corrupt hash-map behavior: - OpKECCAK256 defined equals without hashCode, so equal instances hashed by identity — two equal keys could land in different buckets, producing duplicate branches or failed lookups in keccak256 timestamp trees. Add hashCode = TAG, mirroring OpSHA1/OpSHA256/OpRIPEMD160. - OpBinary defined hashCode without equals — and its TAG referenced Op.TAG (0x00), a no-op XOR. Define the equals/hashCode pair once on OpBinary using tag() and drop the duplicated overrides from OpAppend/OpPrepend (behavior unchanged: same tag + same arg content). - VerifyResult.equals cast without a type test (ClassCastException on foreign types instead of false) and hashCode force-cast the nullable timestamp (NPE for null-timestamp results). Convert to a data class; the custom toString and compareTo stay. --- .../quartz/nip03Timestamp/ots/VerifyResult.kt | 9 +- .../quartz/nip03Timestamp/ots/op/OpAppend.kt | 10 -- .../quartz/nip03Timestamp/ots/op/OpBinary.kt | 4 +- .../nip03Timestamp/ots/op/OpKECCAK256.kt | 2 + .../quartz/nip03Timestamp/ots/op/OpPrepend.kt | 10 -- .../ots/OtsEqualsContractTest.kt | 110 ++++++++++++++++++ 6 files changed, 116 insertions(+), 29 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt index 8827e0aa17..db8aceb159 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip03Timestamp.ots /** * Class that lets us compare, sort, store and print timestamps. */ -class VerifyResult( +data class VerifyResult( val timestamp: Long?, val height: Int, ) : Comparable { @@ -41,11 +41,4 @@ class VerifyResult( } override fun compareTo(other: VerifyResult): Int = this.height - other.height - - override fun equals(other: Any?): Boolean { - val vr = other as VerifyResult - return this.timestamp == vr.timestamp && this.height == vr.height - } - - override fun hashCode(): Int = (((this.timestamp) as Long).toInt()) xor this.height } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpAppend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpAppend.kt index e725b19877..9c67e26eae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpAppend.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpAppend.kt @@ -37,16 +37,6 @@ class OpAppend( public override fun call(msg: ByteArray): ByteArray = msg + this.arg - override fun equals(other: Any?): Boolean { - if (other !is OpAppend) { - return false - } - - return this.arg.contentEquals(other.arg) - } - - override fun hashCode(): Int = TAG.toInt() xor this.arg.contentHashCode() - companion object { val TAG: Byte = 0xf0.toByte() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.kt index 40551ac87c..4f7be1ad51 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.kt @@ -53,7 +53,9 @@ abstract class OpBinary( return this.tag() - other.tag() } - override fun hashCode(): Int = TAG.toInt() xor this.arg.contentHashCode() + override fun equals(other: Any?): Boolean = other is OpBinary && this.tag() == other.tag() && this.arg.contentEquals(other.arg) + + override fun hashCode(): Int = this.tag().toInt() xor this.arg.contentHashCode() companion object { @Throws(DeserializationException::class) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpKECCAK256.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpKECCAK256.kt index 79598322c3..51cf4d94e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpKECCAK256.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpKECCAK256.kt @@ -51,6 +51,8 @@ class OpKECCAK256 : OpCrypto() { override fun equals(other: Any?): Boolean = (other is OpKECCAK256) + override fun hashCode(): Int = TAG.toInt() + companion object { val TAG: Byte = 103.toByte() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpPrepend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpPrepend.kt index a32189e1f3..bcd4f85a86 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpPrepend.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpPrepend.kt @@ -37,16 +37,6 @@ class OpPrepend( public override fun call(msg: ByteArray): ByteArray = this.arg + msg - override fun equals(other: Any?): Boolean { - if (other !is OpPrepend) { - return false - } - - return this.arg.contentEquals(other.arg) - } - - public override fun hashCode(): Int = TAG.toInt() xor this.arg.contentHashCode() - companion object { val TAG: Byte = 0xf1.toByte() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt new file mode 100644 index 0000000000..2f7a0b8b79 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt @@ -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.quartz.nip03Timestamp.ots + +import com.vitorpamplona.quartz.nip03Timestamp.ots.op.Op +import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpAppend +import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpKECCAK256 +import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpPrepend +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Equals/hashCode contract tests for the types keying [Timestamp.ops] + * (a MutableMap): equal ops MUST hash equally or map + * lookups produce duplicate branches and failed upgrades. + */ +class OtsEqualsContractTest { + // --- OpKECCAK256: equals existed without hashCode; equal instances hashed by identity --- + + @Test + fun keccakInstancesAreEqualAndHashEqually() { + val a = OpKECCAK256() + val b = OpKECCAK256() + + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun keccakWorksAsMapKey() { + val map = mutableMapOf(OpKECCAK256() to "branch") + + assertEquals("branch", map[OpKECCAK256()]) + } + + // --- OpBinary: equals is defined once on the superclass, keyed on tag() + arg content --- + + @Test + fun binaryOpsWithSameArgAndClassAreEqual() { + val arg = byteArrayOf(1, 2, 3) + + assertEquals(OpAppend(arg), OpAppend(byteArrayOf(1, 2, 3))) + assertEquals(OpAppend(arg).hashCode(), OpAppend(byteArrayOf(1, 2, 3)).hashCode()) + } + + @Test + fun binaryOpsWithDifferentArgAreNotEqual() { + assertNotEquals(OpAppend(byteArrayOf(1, 2, 3)), OpAppend(byteArrayOf(9))) + } + + @Test + fun appendAndPrependWithSameArgAreNotEqual() { + // Same arg content, different tag — the superclass equals must not conflate them. + val arg = byteArrayOf(1, 2, 3) + + assertNotEquals(OpAppend(arg), OpPrepend(arg)) + } + + @Test + fun binaryOpsWorkAsMapKeys() { + val map = mutableMapOf() + map[OpAppend(byteArrayOf(1, 2, 3))] = "append" + map[OpPrepend(byteArrayOf(1, 2, 3))] = "prepend" + + assertEquals(2, map.size) + assertEquals("append", map[OpAppend(byteArrayOf(1, 2, 3))]) + assertEquals("prepend", map[OpPrepend(byteArrayOf(1, 2, 3))]) + } + + // --- VerifyResult: equals used to throw on null/foreign types; hashCode NPEd on null timestamp --- + + @Test + fun verifyResultEqualsNullIsFalse() { + assertFalse(VerifyResult(1234L, 100).equals(null)) + } + + @Test + fun verifyResultEqualsForeignTypeIsFalse() { + assertFalse(VerifyResult(1234L, 100).equals("not a VerifyResult")) + } + + @Test + fun verifyResultWithNullTimestampHashesWithoutThrowing() { + val result = VerifyResult(null, 100) + + result.hashCode() + assertTrue(result == VerifyResult(null, 100)) + } +} From 489e8ae50b863565bfe8930480cb4cfae2bca01e Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 4 Jul 2026 18:30:19 +0200 Subject: [PATCH 14/14] Code review: - enforce op equality by class and pin the tag-uniqueness invariant - hoist crypto op equals/hashCode onto OpCrypto --- .../quartz/nip03Timestamp/ots/VerifyResult.kt | 5 ++++ .../quartz/nip03Timestamp/ots/op/OpBinary.kt | 6 +++- .../quartz/nip03Timestamp/ots/op/OpCrypto.kt | 8 +++++ .../nip03Timestamp/ots/op/OpKECCAK256.kt | 4 --- .../nip03Timestamp/ots/op/OpRIPEMD160.kt | 4 --- .../quartz/nip03Timestamp/ots/op/OpSHA1.kt | 4 --- .../quartz/nip03Timestamp/ots/op/OpSHA256.kt | 4 --- .../ots/OtsEqualsContractTest.kt | 29 +++++++++++++++++-- 8 files changed, 45 insertions(+), 19 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt index db8aceb159..9e6763380d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt @@ -40,5 +40,10 @@ data class VerifyResult( return "block $height attests data existed as of unix timestamp of $timestamp" } + /** + * Orders by [height] only, intentionally ignoring [timestamp] — NOT + * consistent with [equals], which compares both fields. Fine for picking + * the earliest attestation; do not rely on it for sorted-set dedup. + */ override fun compareTo(other: VerifyResult): Int = this.height - other.height } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.kt index 4f7be1ad51..1d8bf092b5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.kt @@ -53,7 +53,11 @@ abstract class OpBinary( return this.tag() - other.tag() } - override fun equals(other: Any?): Boolean = other is OpBinary && this.tag() == other.tag() && this.arg.contentEquals(other.arg) + // Class-scoped equality: never conflates two ops as Timestamp.ops map keys + // even if a subclass reused a tag by mistake. hashCode stays tag-based + // (collisions are legal). Tag uniqueness itself is a protocol invariant + // (serialization dispatches on it), pinned by OtsEqualsContractTest. + override fun equals(other: Any?): Boolean = other is OpBinary && this::class == other::class && this.arg.contentEquals(other.arg) override fun hashCode(): Int = this.tag().toInt() xor this.arg.contentHashCode() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpCrypto.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpCrypto.kt index 4dafd75ff5..f98f38b9b7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpCrypto.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpCrypto.kt @@ -64,6 +64,14 @@ abstract class OpCrypto internal constructor() : OpUnary() { return hashFd(ctx) } + // Crypto ops carry no identity state — class-scoped equality with a + // tag-based hashCode (never conflates ops even if a subclass reused a + // tag by mistake). Tag uniqueness itself is a protocol invariant + // (serialization dispatches on it), pinned by OtsEqualsContractTest. + override fun equals(other: Any?): Boolean = other is OpCrypto && this::class == other::class + + override fun hashCode(): Int = this.tag().toInt() + companion object { fun deserializeFromTag( ctx: StreamDeserializationContext, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpKECCAK256.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpKECCAK256.kt index 51cf4d94e7..67663af466 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpKECCAK256.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpKECCAK256.kt @@ -49,10 +49,6 @@ class OpKECCAK256 : OpCrypto() { return hash } - override fun equals(other: Any?): Boolean = (other is OpKECCAK256) - - override fun hashCode(): Int = TAG.toInt() - companion object { val TAG: Byte = 103.toByte() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpRIPEMD160.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpRIPEMD160.kt index 430e57bca5..eafea4ea29 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpRIPEMD160.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpRIPEMD160.kt @@ -48,10 +48,6 @@ class OpRIPEMD160 : OpCrypto() { return hash } - override fun equals(other: Any?): Boolean = (other is OpRIPEMD160) - - override fun hashCode(): Int = TAG.toInt() - companion object { val TAG: Byte = 0x03 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpSHA1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpSHA1.kt index df0a90155e..fb1078f201 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpSHA1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpSHA1.kt @@ -46,10 +46,6 @@ class OpSHA1 : OpCrypto() { override fun call(msg: ByteArray): ByteArray = super.call(msg) - override fun equals(other: Any?): Boolean = (other is OpSHA1) - - override fun hashCode(): Int = TAG.toInt() - companion object { val TAG: Byte = 0x02 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpSHA256.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpSHA256.kt index bbc24a9e92..7ce810eed2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpSHA256.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpSHA256.kt @@ -40,10 +40,6 @@ class OpSHA256 : OpCrypto() { override fun call(msg: ByteArray): ByteArray = super.call(msg) - override fun equals(other: Any?): Boolean = (other is OpSHA256) - - override fun hashCode(): Int = TAG.toInt() - companion object { val TAG: Byte = 0x08 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt index 2f7a0b8b79..d50cb2a553 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt @@ -24,11 +24,13 @@ import com.vitorpamplona.quartz.nip03Timestamp.ots.op.Op import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpAppend import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpKECCAK256 import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpPrepend +import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpRIPEMD160 +import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpSHA1 +import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpSHA256 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals -import kotlin.test.assertTrue /** * Equals/hashCode contract tests for the types keying [Timestamp.ops] @@ -36,6 +38,23 @@ import kotlin.test.assertTrue * lookups produce duplicate branches and failed upgrades. */ class OtsEqualsContractTest { + @Test + fun allOpTagsAreUnique() { + // The protocol invariant everything leans on: serialization dispatches + // on the tag byte and hashCode is tag-based. A new op MUST use a fresh tag. + val tags = + listOf( + OpAppend.TAG, + OpPrepend.TAG, + OpSHA1.TAG, + OpSHA256.TAG, + OpRIPEMD160.TAG, + OpKECCAK256.TAG, + ) + + assertEquals(tags.size, tags.toSet().size) + } + // --- OpKECCAK256: equals existed without hashCode; equal instances hashed by identity --- @Test @@ -54,6 +73,12 @@ class OtsEqualsContractTest { assertEquals("branch", map[OpKECCAK256()]) } + @Test + fun cryptoOpsWithDifferentTagsAreNotEqual() { + // The shared OpCrypto equals is tag-keyed — distinct ops must not conflate. + assertNotEquals(OpSHA256(), OpKECCAK256()) + } + // --- OpBinary: equals is defined once on the superclass, keyed on tag() + arg content --- @Test @@ -105,6 +130,6 @@ class OtsEqualsContractTest { val result = VerifyResult(null, 100) result.hashCode() - assertTrue(result == VerifyResult(null, 100)) + assertEquals(VerifyResult(null, 100), result) } }