Merge remote-tracking branch 'origin/main' into claude/benchrelay-1m-events-test-v6vtp0

# Conflicts:
#	gradle/libs.versions.toml
This commit is contained in:
Claude
2026-07-05 12:13:37 +00:00
47 changed files with 970 additions and 94 deletions
@@ -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)
}
@@ -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") }
}
}
@@ -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)
}
@@ -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?,
)
@@ -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()) ||
@@ -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)
@@ -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()) ||
@@ -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 =
@@ -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
@@ -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 ||
@@ -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 ||
@@ -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) {
@@ -958,6 +958,118 @@
<string name="route_podcasts">पुटप्रसार</string>
<string name="podcast_view_episodes">कडियाँ देखें</string>
<string name="podcast_no_episodes">कोई कडी प्राप्त नहीं अब तक</string>
<string name="podcast_trailer">पूर्वावलोकन</string>
<string name="podcast_trailer_season">वर्ष %1$d</string>
<string name="podcast_explicit">अभद्र</string>
<string name="podcast_completed">समाप्त</string>
<string name="podcast_premium">अधिमूल्य</string>
<string name="podcast_support_show">कार्यक्रम का समर्थन</string>
<string name="podcast_by_author">इनके द्वारा %1$s</string>
<string name="podcast_season_episode">वर्ष %1$d । कडी %2$d</string>
<string name="podcast_episode_number">कडी %1$d</string>
<string name="podcast_season">वर्ष %1$d</string>
<string name="podcast_video">चलचित्र</string>
<string name="podcast_transcript">प्रतिलिपि</string>
<string name="podcast_chapters">अध्याय</string>
<string name="podcast_value_for_value">मूल्य के लिए मूल्य</string>
<string name="podcast_value_split_percent">%1$d प्रतिशतप्रतिशत</string>
<string name="podcast_value_zap_split_hint">इसको ज्साप इनमें विभाजित :</string>
<string name="podcast_hosts_and_guests">निमन्त्रक तथा अभ्यागत</string>
<string name="podcast_play_soundbite">प्रमुखाम्श चलाएँ</string>
<string name="podcast_top_supporters">ऊर्ध्वतम समर्थक</string>
<plurals name="podcast_chapters_count">
<item quantity="one">%1$d अध्याय</item>
<item quantity="other">%1$d अध्याय</item>
</plurals>
<string name="podcast_role_host">निमन्त्रक</string>
<string name="podcast_role_cohost">सह निमन्त्रक</string>
<string name="podcast_role_editor">सम्पादक</string>
<string name="podcast_author_verified">सत्यापित कर्ता</string>
<string name="podcast_value_error_title">मूल्य के लिए मूल्य अपक्रम</string>
<string name="podcast_value_no_recipients">इस पुटप्रसार का कोई भुगतान सम्भाव्य मूल्य प्राप्तकर्ता नहीं।</string>
<string name="podcast_value_keysend_requires_nwc">एक नोस्टर धनकोष संयोजन जोडें कुंचिकाप्रेषण (कडी) प्राप्तकर्ताओं को भेजने के लिए।</string>
<string name="podcast_value_stream">साट्स प्रवाह</string>
<string name="podcast_value_stream_rate">%1$d साट्स प्रति मिनुट</string>
<string name="podcast_value_stream_hint">स्वचालित रूप से मूल्य भेजता है जब आप सुन रहे हैं।</string>
<string name="podcast_value_streamed_total">%1$d साट्स प्रवाहित इस सत्र में</string>
<string name="podcast_value_stream_requires_wallet">एक नोस्टर धनकोष संयोजन अथवा ऋणांकन धनकोष जोडें सुनते समय साट्स प्रवाह चलाने के लिए।</string>
<string name="podcast_new_episode">नयी कडी</string>
<string name="podcast_edit_episode">कडी सम्पादन</string>
<string name="podcast_publishing_banner">प्रकाशन…</string>
<string name="podcast_cover_upload_cta">बाहरी कलाकृति जोडें</string>
<string name="podcast_cover_upload_hint">वर्गाकार चित्र जो दिखेगा कडी के लिए</string>
<string name="podcast_episode_title_label">शीर्षक</string>
<string name="podcast_episode_title_placeholder">कडी शीर्षक</string>
<string name="podcast_episode_summary_label">साराम्श</string>
<string name="podcast_episode_duration_label">अवधि (सेकण्ड)</string>
<string name="podcast_episode_more_details">अधिक विवरण</string>
<string name="podcast_episode_season_label">वर्ष</string>
<string name="podcast_episode_number_label">कडी अंक</string>
<string name="podcast_episode_video_label">चलचित्र जालपता</string>
<string name="podcast_episode_transcript_label">प्रतिलिपि जालपता</string>
<string name="podcast_episode_chapters_label">अध्याय जालपता</string>
<string name="podcast_episode_topics_label">विषय सूची</string>
<string name="podcast_episode_topics_placeholder">अल्पविराम पृथक्कृत विषयसूचक</string>
<string name="podcast_episode_audio_url_label">ध्वनि जालपता</string>
<string name="podcast_episode_audio_url_placeholder">https://…/episode.mp3</string>
<string name="podcast_episode_audio_picked">ध्वनि अभिलेख आरोहणार्थ तत्पर</string>
<string name="podcast_episode_audio_upload_cta">ध्वनि अभिलेख जोडें</string>
<string name="podcast_episode_audio_upload_hint">एमपी३॰ अथवा एम४ए॰ अथवा अन्य ध्वनि</string>
<string name="podcast_episode_delete">कडी मिटाएँ</string>
<string name="podcast_episode_delete_confirm">क्या इस कडी को मिटा दें। इसे पूर्ववत नहीं किया जा सकता।</string>
<string name="podcast_edit_show">आपका पुटप्रसार</string>
<string name="podcast_show_cover_cta">बाहरी कलाकृति जोडें</string>
<string name="podcast_show_cover_hint">वर्गाकार कलाकृति आपके कार्यक्रम के लिए</string>
<string name="podcast_show_title_label">कार्यक्रम शीर्षक</string>
<string name="podcast_show_title_placeholder">मेरा पुटप्रसार</string>
<string name="podcast_show_description_label">विवरण</string>
<string name="podcast_show_author_label">कर्ता</string>
<string name="podcast_show_email_label">जालसम्पर्क</string>
<string name="podcast_show_website_label">जालस्थान</string>
<string name="podcast_show_categories_label">श्रेणियाँ</string>
<string name="podcast_show_categories_placeholder">तन्त्रज्ञान समाचार इत्यादि</string>
<string name="podcast_show_funding_label">वित्तपोषण योजक</string>
<string name="podcast_show_funding_placeholder">https://…</string>
<string name="podcast_show_language_label">भाषा</string>
<string name="podcast_show_language_placeholder">आंग्ल</string>
<string name="podcast_show_copyright_label">प्रतिलिप्यधिकार</string>
<string name="podcast_show_type_label">कार्यक्रम प्रकार</string>
<string name="podcast_show_type_episodic">कड्यात्मक</string>
<string name="podcast_show_type_serial">धारावाहिक</string>
<string name="podcast_show_explicit">अभद्र विषयवस्तु</string>
<string name="podcast_show_complete">कार्यक्रम समाप्त (कोई अन्य कडी नहीं)</string>
<string name="podcast_show_locked">तालाबद्ध (अधिमूल्य)</string>
<string name="podcast_new_trailer">नया पूर्वावलोकन</string>
<string name="podcast_trailer_upload_cta">पूर्वावलोकन अम्श जोडें</string>
<string name="podcast_trailer_upload_hint">लघु ध्वनि अथवा दृश्य पूर्वीक्षण</string>
<string name="podcast_trailer_title_placeholder">पूर्वावलोकन शीर्षक</string>
<string name="podcast_trailer_url_label">अभिलेख जालपता</string>
<string name="podcast_your_podcast">आपका पुटप्रसार</string>
<string name="podcast_untitled">शीर्षकरहित</string>
<string name="podcast_no_episodes_yet">कोई कडी नहीं अब तक। नयी कडी दबाएँ अपने प्रथम कडी प्रकाशित करने के लिए।</string>
<string name="podcast_create_your_show">अपना पुटप्रसार बनाएँ</string>
<string name="podcast_tap_to_edit_show">दबाएँ कार्यक्रम विवरण सम्पादन करने के लिए</string>
<string name="podcast_create_show_hint">कार्यक्रम शीर्षक कलाकृति तथा विवरण की स्थापना करें</string>
<plurals name="podcast_trailer_count">
<item quantity="one">%1$d पूर्वावलोकन</item>
<item quantity="other">%1$d पूर्वावलोकन</item>
</plurals>
<string name="podcast_value_editor_hint">प्राप्तकर्ता जोडें आगत साट्स को भार अनुसार विभाजित करने के लिए। श्रोतागण उद्धृत करेंगे अथवा मूल्य प्रवाहित करेंगे इन गन्तव्यों को।</string>
<string name="podcast_value_add_recipient">प्राप्तकर्ता जोडें</string>
<string name="podcast_value_add_address">पता जोडें स्वयम हाथ से</string>
<string name="podcast_value_search_user">नोस्टर प्रयोक्ता जोडें</string>
<string name="podcast_value_search_user_hint">नाम अथवा @विभेदक द्वारा ढूँढें</string>
<string name="podcast_value_user_no_lnaddress">इस प्रयोक्ता का कोई लैटनिंग॰ पता नहीं</string>
<string name="podcast_value_remove_recipient">प्राप्तकर्ता हटाएँ</string>
<string name="podcast_value_recipient_name">नाम (विकल्पात्मक)</string>
<string name="podcast_value_type_lnaddress">लैटनिंग॰ पता</string>
<string name="podcast_value_type_node">कडी (कुंचिकाप्रेषण)</string>
<string name="podcast_value_lnaddress">लैटनिंग॰ पता</string>
<string name="podcast_value_lnaddress_hint">name@example.com</string>
<string name="podcast_value_node_pubkey">कडी ख्याप्यकुंचिका</string>
<string name="podcast_value_node_pubkey_hint">02abc… (३३ अष्टक षोडशांक)</string>
<string name="podcast_value_weight">भार</string>
<string name="podcast_value_fee">शुल्क</string>
<plurals name="podcast_episode_count">
<item quantity="one">%1$d कडी</item>
<item quantity="other">%1$d कडियाँ</item>
@@ -988,6 +1100,8 @@
<string name="public_bookmarks">सार्वजनिक स्मर्तव्य</string>
<string name="repository_bookmarks">क्रमलेखकोश</string>
<string name="repository_bookmarks_explainer">आपके स्मर्त्तव्यचिह्नित गिट क्रमलेखकोश</string>
<string name="podcast_bookmarks">पुटप्रसार</string>
<string name="podcast_bookmarks_explainer">आपके स्मर्त्तव्यचिह्नित पुटप्रसार तथा कडियाँ</string>
<string name="add_to_private_bookmarks">निजी स्मर्तव्य सूची में जोडें</string>
<string name="add_to_public_bookmarks">सार्वजनिक स्मर्तव्य सूची में जोडें</string>
<string name="remove_from_private_bookmarks">निजी स्मर्तव्य सूची से हटाएँ</string>
@@ -3177,6 +3291,9 @@
<string name="compose_settings">सम्पादन स्थापना विकल्प</string>
<string name="auto_create_drafts_setting_title">पाण्डुलिपियाँ स्वतः बनाएँ</string>
<string name="auto_create_drafts_setting_description">स्वतः पाण्डुलिपि घटना का अभिलेखन करता है जब आप टंकण करते है अथवा सम्पादक छोडते हैं अप्रेषित लेख के साथ तथा आपके निजी निर्गतपेटिका पुनःप्रसारकों को भेजता है।</string>
<string name="compose_signature_setting_title">हस्ताक्षर</string>
<string name="compose_signature_setting_description">सन्देश के अन्त में जोडा जाएगा नए पत्र प्रत्युत्तर उद्धरण अथवा निबन्ध खोलते समय। रिक्त छोड दें अक्षम करने के लिए।</string>
<string name="compose_signature_setting_hint">आपका हस्ताक्षर</string>
<string name="ai_writing_use_this">इसका प्रयोग करें</string>
<string name="ai_writing_dismiss">हटाएँ</string>
<string name="ai_tone_correct">सम्यक</string>
@@ -2608,6 +2608,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<item quantity="many">%1$d elementów</item>
<item quantity="other">%1$d elementy</item>
</plurals>
<plurals name="git_diff_files_changed">
<item quantity="one">Plik %1$d zmieniony</item>
<item quantity="few">Plików %1$d zmienionych</item>
<item quantity="many">Plików %1$d zmienionych</item>
<item quantity="other">%1$d pliki zostały zmienione</item>
</plurals>
<string name="git_diff_binary">Plik binarny nie jest wyświetlany.</string>
<string name="git_new_issue_button">Nowy</string>
<string name="git_new_issue_title">Nowy problem</string>
+1
View File
@@ -3485,6 +3485,7 @@
<string name="goal_progress">%1$s funded of %2$s sats goal</string>
<string name="fundraiser_ends">Ends %1$s</string>
<string name="fundraiser_onchain_donation">On-chain donation</string>
<string name="bird_detection_title">Bird detection</string>
<plurals name="birdex_species_count">
<item quantity="one">Birdex · %1$d species</item>
<item quantity="other">Birdex · %1$d species</item>
@@ -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
}
@@ -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")
}
}
@@ -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")
}
}
}
@@ -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)
}
}
@@ -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<File>()
@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())
}
}
@@ -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<File>()
@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")
}
}
@@ -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<AccountState.LoggedIn> =
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 =
@@ -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
@@ -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")
}
}
}
@@ -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")
}
}
@@ -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)
@@ -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"),
@@ -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,
@@ -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<PreviewItem>) {
items.forEach { item ->
if (item is PreviewItem.Reencoded) {
item.compressedFile.delete()
item.compressedFile.deleteOrWarn("CompressionPreview", "compressed temp")
}
}
}
@@ -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
@@ -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
}
}
}
}
@@ -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<Array<String>>,
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: <Common name> (<Scientific name>)` (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:"
}
}
@@ -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
@@ -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"),
@@ -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))
}
}
@@ -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<VerifyResult> {
@@ -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
}
@@ -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()
@@ -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)
@@ -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,
@@ -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()
@@ -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()
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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<BirdDetectionEvent>(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<BirdDetectionEvent>(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<BirdDetectionEvent>(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<BirdDetectionEvent>(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<BirdDetectionEvent>(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<BirdDetectionEvent>(event)
assertEquals("Purple Gallinule", event.commonName())
}
@Test
fun parsesBirdDetectionFields() {
val event = sampleEvent()
assertIs<BirdDetectionEvent>(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())
}
}
@@ -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<Op, Timestamp>): 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<Op, String>(OpKECCAK256() to "branch")
assertEquals("branch", map[OpKECCAK256()])
}
@Test
fun cryptoOpsWithDifferentTagsAreNotEqual() {
// The shared OpCrypto equals is tag-keyed — distinct ops must not conflate.
assertNotEquals<Op>(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<Op>(OpAppend(arg), OpPrepend(arg))
}
@Test
fun binaryOpsWorkAsMapKeys() {
val map = mutableMapOf<Op, String>()
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)
}
}