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/service/images/ThumbnailDiskCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt index 1a8cf64c42..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 @@ -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 @@ -68,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 } } @@ -155,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 } @@ -193,7 +190,7 @@ class ThumbnailDiskCache( files .sortedBy { it.lastModified() } .take(files.size - maxEntries) - .forEach { it.delete() } + .forEach { it.deleteOrWarn("ThumbnailDiskCache", "thumbnail") } } } 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..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 @@ -30,17 +30,24 @@ 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. */ 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). * @@ -61,7 +68,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 = BIRD_PREFIX + pluralStringResource(R.plurals.birdex_species_count, names.size, names.size), style = MaterialTheme.typography.titleMedium, ) @@ -82,3 +89,64 @@ 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 (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 detection = + remember(noteEvent) { + BirdDetectionInfo( + commonName = noteEvent.commonName(), + species = noteEvent.speciesName(), + reference = noteEvent.speciesReference(), + ) + } + + Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { + Text( + text = BIRD_PREFIX + (detection.commonName ?: stringResource(R.string.bird_detection_title)), + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + if (detection.species != null) { + Spacer(Modifier.height(6.dp)) + if (detection.reference != null) { + val typography = MaterialTheme.typography + val speciesStyle = remember(typography) { typography.bodyMedium.copy(fontStyle = FontStyle.Italic) } + ClickableUrl( + urlText = detection.species, + url = detection.reference, + style = speciesStyle, + ) + } else { + Text( + text = detection.species, + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** 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/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/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 39c0e39194..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 @@ -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 @@ -155,9 +156,8 @@ class VoiceReplyViewModel : ViewModel() { private fun deleteVoiceLocalFile() { voiceLocalFile?.let { file -> try { - if (file.exists()) { - file.delete() - 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/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-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 @@ सम्पादन स्थापना विकल्प पाण्डुलिपियाँ स्वतः बनाएँ स्वतः पाण्डुलिपि घटना का अभिलेखन करता है जब आप टंकण करते है अथवा सम्पादक छोडते हैं अप्रेषित लेख के साथ तथा आपके निजी निर्गतपेटिका पुनःप्रसारकों को भेजता है। + हस्ताक्षर + सन्देश के अन्त में जोडा जाएगा नए पत्र प्रत्युत्तर उद्धरण अथवा निबन्ध खोलते समय। रिक्त छोड दें अक्षम करने के लिए। + आपका हस्ताक्षर इसका प्रयोग करें हटाएँ सम्यक 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 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/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/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..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 @@ -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 @@ -228,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) } @@ -275,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.delete() - } + // Clean up any leftover temp file + 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/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/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") + } +} 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..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 @@ -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 @@ -260,10 +261,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() + // 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/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..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 @@ -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() + 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..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 @@ -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() + 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..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 @@ -190,13 +191,8 @@ 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) + // Owner-only (700) — Tor state includes onion keys. + appDir.restrictToOwner("DesktopTorManager") return TorRuntime.Environment.Builder( workDirectory = appDir.resolve("work"), 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") } } } 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 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..3439f537dc 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt @@ -20,8 +20,8 @@ */ 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 @@ -55,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") } } } @@ -64,13 +64,18 @@ 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 { Triple(it, it.length(), it.lastModified()) } ?: return + var total = files.sumOf { it.second } if (total <= maxBytes) return - files.sortedBy { it.lastModified() }.forEach { f -> + files.sortedBy { it.third }.forEach { (f, length, _) -> if (total <= maxBytes) return - total -= f.length() - f.delete() + if (f.deleteOrWarn("NappletBlobCache", "blob")) { + total -= length + } } } } 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..a45eef32cd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEvent.kt @@ -0,0 +1,94 @@ +/* + * 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.nip31Alts.alt +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. + * 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 NIP-31 `alt` tag (may be null). */ + fun summary() = tags.alt() + + /** + * Common (vernacular) species name, parsed out of the `alt` tag. Birdstar + * 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(" (") + .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/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/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/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)) } } 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..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 @@ -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 { @@ -40,12 +40,10 @@ 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 - - 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..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,13 @@ abstract class OpBinary( return this.tag() - other.tag() } - override fun hashCode(): Int = TAG.toInt() xor this.arg.contentHashCode() + // 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() companion object { @Throws(DeserializationException::class) 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 79598322c3..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,8 +49,6 @@ class OpKECCAK256 : OpCrypto() { return hash } - override fun equals(other: Any?): Boolean = (other is OpKECCAK256) - 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/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/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..a65fb84f29 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEventTest.kt @@ -0,0 +1,149 @@ +/* + * 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() { + 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 + 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 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 = + 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()) + } +} 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..d50cb2a553 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsEqualsContractTest.kt @@ -0,0 +1,135 @@ +/* + * 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 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 + +/** + * 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 { + @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 + 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()]) + } + + @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 + 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() + assertEquals(VerifyResult(null, 100), result) + } +}