mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge remote-tracking branch 'origin/main' into claude/benchrelay-1m-events-test-v6vtp0
# Conflicts: # gradle/libs.versions.toml
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+4
-7
@@ -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?,
|
||||
)
|
||||
|
||||
+2
@@ -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()) ||
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
+2
@@ -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()) ||
|
||||
|
||||
+4
@@ -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 =
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+2
@@ -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 ||
|
||||
|
||||
+2
@@ -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 ||
|
||||
|
||||
+4
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+4
-5
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
+74
@@ -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())
|
||||
}
|
||||
}
|
||||
+83
@@ -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")
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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 =
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -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)
|
||||
|
||||
+3
-7
@@ -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"),
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
+13
-8
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+94
@@ -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:"
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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"),
|
||||
|
||||
+19
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+6
-8
@@ -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
|
||||
}
|
||||
|
||||
-10
@@ -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()
|
||||
|
||||
|
||||
+7
-1
@@ -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)
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
-2
@@ -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()
|
||||
|
||||
|
||||
-10
@@ -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()
|
||||
|
||||
|
||||
-4
@@ -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
|
||||
|
||||
|
||||
-4
@@ -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)
|
||||
|
||||
+149
@@ -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())
|
||||
}
|
||||
}
|
||||
+135
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user