diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5997aaaed0..092ea9e37d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,7 +34,7 @@ jobs: # :amethyst alone). Replaces the narrower `cache: gradle` previously on # actions/setup-java, which only cached `modules-2`. - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -75,7 +75,7 @@ jobs: java-version: 21 - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -87,7 +87,7 @@ jobs: # we fall back to fetching, which is what the in-build retry budget # exists for. - name: Cache vlc-setup downloads - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.gradle/vlcSetup key: vlcsetup-${{ runner.os }}-${{ hashFiles('desktopApp/build.gradle.kts') }} @@ -207,7 +207,7 @@ jobs: java-version: 21 - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -260,7 +260,7 @@ jobs: java-version: 21 - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index b5eb3a1355..540d8466b2 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -34,7 +34,7 @@ jobs: java-version: 21 - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: true @@ -64,12 +64,12 @@ jobs: java-version: 21 - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: true - name: Cache vlc-setup downloads - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.gradle/vlcSetup key: vlcsetup-Linux-${{ hashFiles('desktopApp/build.gradle.kts') }} diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index 310ced70b8..a3d52c6c8d 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.notifications import com.vitorpamplona.amethyst.AccountInfo -import com.vitorpamplona.amethyst.service.retryIfException +import com.vitorpamplona.amethyst.commons.util.retryIfException import kotlinx.coroutines.Dispatchers import okhttp3.OkHttpClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1c91b076c5..e3dea0f4d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1888,8 +1888,8 @@ class Account( suspend fun signAnonymouslyAndBroadcast( template: EventTemplate, broadcast: List = emptyList(), + anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()), ): T { - val anonymousSigner = NostrSignerInternal(KeyPair()) val event = anonymousSigner.sign(template) cache.justConsumeMyOwnEvent(event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt index 8640804783..2544c32776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.model import android.util.LruCache import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.service.previews.UrlPreview +import com.vitorpamplona.amethyst.commons.preview.UrlPreview import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import okhttp3.OkHttpClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 08270487a7..d380314df2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -879,7 +879,7 @@ class CashuWalletState( * is loaded. */ suspend fun restoreFromMint(mintUrl: String): RestoreOutcome? { - check(started) { "CashuWalletState.start() not called" } + check(started) { NOT_STARTED_MESSAGE } val seed = ensureSeed() ?: return null // Heal any prior-Resync duplicates BEFORE running the new // restore. Without this the existingSecrets set below would @@ -941,7 +941,7 @@ class CashuWalletState( * excludes the ghosts. */ suspend fun cleanupDuplicateProofs() { - check(started) { "CashuWalletState.start() not called" } + check(started) { NOT_STARTED_MESSAGE } val entries = _tokenEntries.value if (entries.size < 2) return @@ -1019,7 +1019,7 @@ class CashuWalletState( * caller would still pick the ghosts. */ suspend fun scrubLocallyStaleProofs(mintUrlFilter: String? = null) { - check(started) { "CashuWalletState.start() not called" } + check(started) { NOT_STARTED_MESSAGE } val byMint = _tokenEntries.value .groupBy { it.content.mint } @@ -1075,7 +1075,7 @@ class CashuWalletState( * migration (e.g. a future "compact wallet" action). */ suspend fun migrateStaleKeysets() { - check(started) { "CashuWalletState.start() not called" } + check(started) { NOT_STARTED_MESSAGE } // Group held tokens by mint URL — each mint has its own keysets. val byMint = _tokenEntries.value.groupBy { it.content.mint } for ((mintUrl, entries) in byMint) { @@ -1107,7 +1107,7 @@ class CashuWalletState( preferredMintUrl: String? = null, onProgress: ((Float) -> Unit)? = null, ): NutzapSent { - check(started) { "CashuWalletState.start() not called" } + check(started) { NOT_STARTED_MESSAGE } val resolved = peekNutzapTarget(recipientPubKey) ?: throw IllegalStateException("Recipient does not accept nutzaps from any of our mints") @@ -1160,7 +1160,7 @@ class CashuWalletState( amountSats: Long, memo: String? = null, ): SendTokenCompleted { - check(started) { "CashuWalletState.start() not called" } + check(started) { NOT_STARTED_MESSAGE } if (amountSats <= 0) throw IllegalArgumentException("Amount must be positive") if (mintUrl.isBlank()) throw IllegalArgumentException("Pick a mint") @@ -1184,7 +1184,7 @@ class CashuWalletState( quote: MeltQuoteBolt11ResponseDto, skipScrub: Boolean = false, ): MeltCompleted { - check(started) { "CashuWalletState.start() not called" } + check(started) { NOT_STARTED_MESSAGE } if (mintUrl.isBlank()) throw IllegalArgumentException("Pick a mint") // [rebalance] already scrubbed this mint to compute its coverage check, so @@ -1220,7 +1220,7 @@ class CashuWalletState( onProgress: ((Float) -> Unit)? = null, onFundsMoved: () -> Unit = {}, ): RebalanceCompleted { - check(started) { "CashuWalletState.start() not called" } + check(started) { NOT_STARTED_MESSAGE } require(sats > 0) { "Amount must be positive" } require(sourceMintUrl != targetMintUrl) { "Source and target mints must differ" } @@ -1297,7 +1297,7 @@ class CashuWalletState( * call [publishEvent] is gated behind `started` so the no-op default is * never observed by produced events. */ - private var publish: suspend (Event) -> Unit = { error("CashuWalletState.start() not called") } + private var publish: suspend (Event) -> Unit = { error(NOT_STARTED_MESSAGE) } private suspend fun publishEvent(event: Event) { publish(event) @@ -1311,6 +1311,8 @@ class CashuWalletState( * wallet-less users don't stare at a spinner. */ const val DISCOVERY_TIMEOUT_MS = 8_000L + + private const val NOT_STARTED_MESSAGE = "CashuWalletState.start() not called" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt deleted file mode 100644 index d9116c56e1..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service.previews - -import com.vitorpamplona.amethyst.commons.preview.HtmlCharsetParser -import com.vitorpamplona.amethyst.commons.preview.MetaTag -import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okio.BufferedSource -import okio.ByteString.Companion.decodeHex -import okio.Options -import java.nio.charset.Charset - -class HtmlParser { - companion object { - // taken from okhttp - private val UNICODE_BOMS = - Options.of( - // UTF-8 - "efbbbf".decodeHex(), - // UTF-16BE - "feff".decodeHex(), - // UTF-16LE - "fffe".decodeHex(), - // UTF-32BE - "0000ffff".decodeHex(), - // UTF-32LE - "ffff0000".decodeHex(), - ) - } - - suspend fun parseHtml( - source: BufferedSource, - type: Charset?, - ): Sequence = - parseHtml( - source.readByteArray(), - type ?: source.readBomAsCharset(), - ) - - suspend fun parseHtml( - bodyBytes: ByteArray, - type: Charset?, - ): Sequence = - withContext(Dispatchers.IO) { - // sniff charset from Content-Type header or BOM - if (type != null) { - val content = bodyBytes.toString(type) - return@withContext MetaTagsParser.parse(content) - } - - // if sniffing was failed, detect charset from content - val charset = HtmlCharsetParser.detectCharset(bodyBytes) - val content = bodyBytes.toString(charset) - return@withContext MetaTagsParser.parse(content) - } - - private fun BufferedSource.readBomAsCharset(): Charset? = - when (select(UNICODE_BOMS)) { - 0 -> Charsets.UTF_8 - 1 -> Charsets.UTF_16BE - 2 -> Charsets.UTF_16LE - 3 -> Charsets.UTF_32BE - 4 -> Charsets.UTF_32LE - -1 -> null - else -> throw AssertionError() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index 12e44faade..4f9af36bd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.ciphers.NostrCipher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope @@ -66,6 +67,7 @@ class MultiOrchestrator( stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, convertGifToMp4: Boolean = false, + forcedSigner: NostrSigner? = null, ): Result { coroutineScope { val jobs = @@ -84,6 +86,7 @@ class MultiOrchestrator( stripMetadata, onStrippingFailed, convertGifToMp4 = convertGifToMp4, + forcedSigner = forcedSigner, ) } } @@ -106,6 +109,7 @@ class MultiOrchestrator( stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, convertGifToMp4: Boolean = false, + forcedSigner: NostrSigner? = null, ): Result { coroutineScope { val jobs = @@ -125,6 +129,7 @@ class MultiOrchestrator( stripMetadata, onStrippingFailed, convertGifToMp4 = convertGifToMp4, + forcedSigner = forcedSigner, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index a230b37cf9..ed5b3e7b84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -30,7 +30,10 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.ciphers.NostrCipher import kotlinx.coroutines.flow.MutableStateFlow @@ -142,6 +145,7 @@ class UploadOrchestrator { contentTypeForResult: String?, originalHash: String?, account: Account, + forcedSigner: NostrSigner?, context: Context, ): UploadingFinalState { updateState(0.2, UploadingState.Uploading) @@ -158,7 +162,12 @@ class UploadOrchestrator { onProgress = { percent: Float -> updateState(0.2 + (0.2 * percent), UploadingState.Uploading) }, - httpAuth = account::createHTTPAuthorization, + httpAuth = + if (forcedSigner != null) { + { url, method, body -> forcedSigner.sign(HTTPAuthorizationEvent.build(url, method, body)) } + } else { + account::createHTTPAuthorization + }, context = context, ) @@ -187,6 +196,7 @@ class UploadOrchestrator { contentTypeForResult: String?, originalHash: String?, account: Account, + forcedSigner: NostrSigner?, context: Context, ): UploadingFinalState { updateState(0.2, UploadingState.Uploading) @@ -201,7 +211,12 @@ class UploadOrchestrator { sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, - httpAuth = account::createBlossomUploadAuth, + httpAuth = + if (forcedSigner != null) { + { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) } + } else { + account::createBlossomUploadAuth + }, context = context, ) @@ -360,6 +375,7 @@ class UploadOrchestrator { stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, convertGifToMp4: Boolean = false, + forcedSigner: NostrSigner? = null, ): UploadingFinalState { val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4) @@ -379,8 +395,8 @@ class UploadOrchestrator { try { return when (server.type) { ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) - ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) - ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) + ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) + ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) } } finally { deleteTempUri(finalUri, uri) @@ -401,6 +417,7 @@ class UploadOrchestrator { stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, convertGifToMp4: Boolean = false, + forcedSigner: NostrSigner? = null, ): UploadingFinalState { val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4) @@ -423,8 +440,8 @@ class UploadOrchestrator { try { return when (server.type) { ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) - ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) - ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) + ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) + ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) } } finally { deleteTempUri(encrypted.uri, uri) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt index 26ee588d3c..2fab6dfd26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -56,9 +56,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent -import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus -import com.vitorpamplona.amethyst.service.broadcast.RelayResult +import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent +import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus +import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt index a7cef85c45..2c9a3afbe5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt @@ -72,9 +72,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent -import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus -import com.vitorpamplona.amethyst.service.broadcast.RelayResult +import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent +import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus +import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt index 58d8ac4b60..2a6bb6c98d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt @@ -37,8 +37,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.model.BooleanType -import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.collections.immutable.ImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 4f0758c6b8..70c44937c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -71,8 +71,11 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes @@ -215,6 +218,14 @@ open class CommentPostViewModel : var wantsAnonymousPost by mutableStateOf(false) + // A single ephemeral signer reused for the whole compose session so that media + // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed + // by the same throwaway key, instead of leaking the real account's pubkey into the + // upload authorization (and therefore into the returned media URL). + private var anonymousSignerCache: NostrSigner? = null + + fun anonymousSigner(): NostrSigner = anonymousSignerCache ?: NostrSignerInternal(KeyPair()).also { anonymousSignerCache = it } + fun lnAddress(): String? = account.userProfile().lnAddress() fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null @@ -452,7 +463,7 @@ open class CommentPostViewModel : cancel() if (anonymous) { - accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast) + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner()) } else { accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) } @@ -619,6 +630,7 @@ open class CommentPostViewModel : context, stripMetadata = stripMetadata, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null, ) if (results.allGood) { @@ -711,6 +723,7 @@ open class CommentPostViewModel : wantsToAddGeoHash = false wantsSecretEmoji = false wantsAnonymousPost = false + anonymousSignerCache = null forwardZapTo.value = SplitBuilder() forwardZapToEditting.clearText() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 4bbe6869ee..956d8e8f97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActiviti import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager +import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState @@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuild import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler -import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index c11283c657..eeabb81cc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal +import com.vitorpamplona.amethyst.commons.util.replace import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index 9f859dd14b..7cf22d65e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal +import com.vitorpamplona.amethyst.commons.util.replace import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 990b02a7c5..150353320c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -91,7 +91,10 @@ import com.vitorpamplona.quartz.experimental.zapPolls.minAmount import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash @@ -306,6 +309,14 @@ open class ShortNotePostViewModel : // Anonymous Reply var wantsAnonymousPost by mutableStateOf(false) + // A single ephemeral signer reused for the whole compose session so that media + // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed + // by the same throwaway key, instead of leaking the real account's pubkey into the + // upload authorization (and therefore into the returned media URL). + private var anonymousSignerCache: NostrSigner? = null + + fun anonymousSigner(): NostrSigner = anonymousSignerCache ?: NostrSignerInternal(KeyPair()).also { anonymousSignerCache = it } + // Scheduled posting: epoch seconds (UTC) when the post should be published. // Null = post immediately on Send (existing behavior). var scheduledForSec by mutableStateOf(null) @@ -870,7 +881,7 @@ open class ShortNotePostViewModel : } if (anonymous) { - accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast) + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner()) } else if (accountViewModel.settings.useTrackedBroadcasts()) { // Tracked broadcasting with progress feedback (non-blocking) val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) @@ -1138,6 +1149,7 @@ open class ShortNotePostViewModel : stripMetadata, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, convertGifToMp4 = convertGifToMp4, + forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null, ) if (results.allGood) { @@ -1235,6 +1247,7 @@ open class ShortNotePostViewModel : wantsExclusiveGeoPost = false wantsSecretEmoji = false wantsAnonymousPost = false + anonymousSignerCache = null scheduledForSec = null forwardZapTo.value = SplitBuilder() @@ -1467,6 +1480,7 @@ open class ShortNotePostViewModel : account = account, context = appContext, useH265 = false, + forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null, ) when (result) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index 6b3bd54272..0b51be87c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.util.replace import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 4534896968..05b76dbaf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -25,8 +25,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.util.replace import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index 8216ca889c..fb355c45cf 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.notifications import com.google.firebase.messaging.FirebaseMessaging import com.vitorpamplona.amethyst.AccountInfo -import com.vitorpamplona.amethyst.service.retryIfException +import com.vitorpamplona.amethyst.commons.util.retryIfException import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index cb8d7dc1a8..942cbd65e4 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -114,6 +114,12 @@ kotlin { // coil-ktor when the iOS Compose UI ships. implementation(libs.coil.okhttp) + // OkHttp (+ coroutines bridge) for the link-preview fetcher + // (service/preview/UrlPreview). JVM-only; iOS will swap to + // Ktor when its UI ships. + implementation(libs.okhttp) + implementation(libs.okhttpCoroutines) + // Markdown rendering (richtext-commonmark). The single // consumer (RenderMarkdown.kt) already lives in jvmAndroid. // iOS support pending Phase 3 markdown decision. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt new file mode 100644 index 0000000000..82b0140470 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt @@ -0,0 +1,38 @@ +/* + * 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.preview + +/** + * Decodes [bytes] into a String using the charset named [charsetName]. + * + * [charsetName] is an IANA charset name (e.g. "UTF-8", "ISO-8859-1", + * "windows-1252"). When it is null or cannot be resolved on the current + * platform, the implementation falls back to UTF-8. + * + * The decode is the only platform-specific step of link-preview HTML parsing: + * the JVM actual delegates to `java.nio.charset`, which supports every charset + * the JRE ships; the iOS actual maps the common web charsets to + * `NSStringEncoding` and falls back to UTF-8 for anything exotic. + */ +expect fun decodeBytes( + bytes: ByteArray, + charsetName: String?, +): String diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt similarity index 80% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt index e497d7da40..670808ea62 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt @@ -20,37 +20,37 @@ */ package com.vitorpamplona.amethyst.commons.preview -import java.nio.charset.Charset - object HtmlCharsetParser { val ATTRIBUTE_VALUE_CHARSET = "charset" val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv" val CONTENT = "content" + private const val DEFAULT_CHARSET = "UTF-8" + private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""") - fun detectCharset(bodyBytes: ByteArray): Charset { + /** + * Sniffs the charset declared in the document's `` tags, returning its + * IANA name. Returns [DEFAULT_CHARSET] when no usable declaration is found. + */ + fun detectCharset(bodyBytes: ByteArray): String { // try to detect charset from meta tags parsed from first 1024 bytes of body - val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8")) + val firstPart = bodyBytes.decodeToString(0, minOf(1024, bodyBytes.size)) val metaTags = MetaTagsParser.parse(firstPart) metaTags.forEach { meta -> val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET) if (charsetAttr.isNotEmpty()) { - runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let { - return it - } + return charsetAttr } if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") { RE_CONTENT_TYPE_CHARSET .find(meta.attr(CONTENT)) ?.let { - runCatching { Charset.forName(it.groupValues[1]) }.getOrNull() - }?.let { - return it + return it.groupValues[1] } } } // defaults to UTF-8 - return Charset.forName("utf-8") + return DEFAULT_CHARSET } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt new file mode 100644 index 0000000000..823862bf31 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt @@ -0,0 +1,69 @@ +/* + * 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.preview + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class HtmlParser { + companion object { + // Byte-order marks mapped to their IANA charset names, longest first so + // a 4-byte BOM is matched before a 2-byte one. (Patterns taken from okhttp.) + private val UNICODE_BOMS = + listOf( + byteArrayOf(0x00, 0x00, 0xFF.toByte(), 0xFF.toByte()) to "UTF-32BE", + byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0x00, 0x00) to "UTF-32LE", + byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) to "UTF-8", + byteArrayOf(0xFE.toByte(), 0xFF.toByte()) to "UTF-16BE", + byteArrayOf(0xFF.toByte(), 0xFE.toByte()) to "UTF-16LE", + ) + } + + suspend fun parseHtml( + bodyBytes: ByteArray, + charsetName: String?, + ): Sequence = + withContext(Dispatchers.Default) { + // Precedence: explicit charset (e.g. from Content-Type) > BOM > + // charset sniffed from tags (defaults to UTF-8). + val name = + charsetName + ?: bodyBytes.bomCharsetName() + ?: HtmlCharsetParser.detectCharset(bodyBytes) + val content = decodeBytes(bodyBytes, name) + MetaTagsParser.parse(content) + } + + private fun ByteArray.bomCharsetName(): String? { + for ((bom, name) in UNICODE_BOMS) { + if (startsWith(bom)) return name + } + return null + } + + private fun ByteArray.startsWith(prefix: ByteArray): Boolean { + if (size < prefix.size) return false + for (i in prefix.indices) { + if (this[i] != prefix[i]) return false + } + return true + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/broadcast/BroadcastModels.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/broadcast/BroadcastModels.kt index 1424ded754..6dd5807f7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/broadcast/BroadcastModels.kt @@ -18,11 +18,12 @@ * 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.service.broadcast +package com.vitorpamplona.amethyst.commons.service.broadcast import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils /** * Result of a relay's response to an event publish. @@ -72,7 +73,7 @@ data class BroadcastEvent( val id: String, val event: Event, val targetRelays: List, - val startedAt: Long = System.currentTimeMillis(), + val startedAt: Long = TimeUtils.now(), val results: Map = emptyMap(), val status: BroadcastStatus = BroadcastStatus.IN_PROGRESS, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/broadcast/BroadcastTracker.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/broadcast/BroadcastTracker.kt index bfede508e2..e9f8166cc6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/broadcast/BroadcastTracker.kt @@ -18,7 +18,7 @@ * 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.service.broadcast +package com.vitorpamplona.amethyst.commons.service.broadcast import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -41,7 +42,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.withTimeoutOrNull -import java.util.UUID /** * Tracks event broadcasts to relays with live progress updates. @@ -73,7 +73,7 @@ class BroadcastTracker { relays: Set, client: INostrClient, ) { - val trackingId = UUID.randomUUID().toString() + val trackingId = RandomInstance.randomChars(16) val broadcast = BroadcastEvent( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CoroutinesExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CoroutinesUtils.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/CoroutinesExt.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CoroutinesUtils.kt index 2d5bfaf9d9..9462a3b696 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CoroutinesExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CoroutinesUtils.kt @@ -18,7 +18,7 @@ * 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.service +package com.vitorpamplona.amethyst.commons.util import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/SetExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/SetUtils.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/SetExt.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/SetUtils.kt index 48f52e791d..7f46393d6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/SetExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/SetUtils.kt @@ -18,6 +18,6 @@ * 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.service +package com.vitorpamplona.amethyst.commons.util fun Set.togglePresenceInSet(item: T): Set = if (contains(item)) minus(item) else plus(item) diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt new file mode 100644 index 0000000000..cf70fa5533 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt @@ -0,0 +1,72 @@ +/* + * 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.preview + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.NSASCIIStringEncoding +import platform.Foundation.NSData +import platform.Foundation.NSISOLatin1StringEncoding +import platform.Foundation.NSString +import platform.Foundation.NSStringEncoding +import platform.Foundation.NSUTF16BigEndianStringEncoding +import platform.Foundation.NSUTF16LittleEndianStringEncoding +import platform.Foundation.NSUTF32BigEndianStringEncoding +import platform.Foundation.NSUTF32LittleEndianStringEncoding +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.NSWindowsCP1252StringEncoding +import platform.Foundation.create + +/** + * iOS decode of HTML bytes by charset name. The common web charsets are mapped + * to their `NSStringEncoding`; anything else falls back to UTF-8 (matching the + * "defaults to UTF-8" behaviour of the charset sniffer). + */ +@OptIn(ExperimentalForeignApi::class) +actual fun decodeBytes( + bytes: ByteArray, + charsetName: String?, +): String { + if (bytes.isEmpty()) return "" + + val encoding = encodingFor(charsetName) + + val data = + bytes.usePinned { pinned -> + NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) + } + + return (NSString.create(data, encoding) as String?) + ?: bytes.decodeToString() +} + +private fun encodingFor(charsetName: String?): NSStringEncoding = + when (charsetName?.trim()?.uppercase()) { + "UTF-16", "UTF-16BE", "UTF16" -> NSUTF16BigEndianStringEncoding + "UTF-16LE" -> NSUTF16LittleEndianStringEncoding + "UTF-32", "UTF-32BE", "UTF32" -> NSUTF32BigEndianStringEncoding + "UTF-32LE" -> NSUTF32LittleEndianStringEncoding + "ISO-8859-1", "ISO8859-1", "ISO_8859-1", "LATIN1", "L1", "CP819" -> NSISOLatin1StringEncoding + "WINDOWS-1252", "CP1252" -> NSWindowsCP1252StringEncoding + "US-ASCII", "ASCII", "ANSI_X3.4-1968" -> NSASCIIStringEncoding + else -> NSUTF8StringEncoding + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/IterableExt.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt similarity index 76% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/IterableExt.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt index 7df5fa41e2..7af77373ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/IterableExt.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt @@ -18,9 +18,17 @@ * 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.service +package com.vitorpamplona.amethyst.commons.preview -fun Iterable.replace( - old: T, - new: T, -): List = map { if (it == old) new else it } +import java.nio.charset.Charset + +actual fun decodeBytes( + bytes: ByteArray, + charsetName: String?, +): String { + val charset = + charsetName + ?.let { runCatching { Charset.forName(it) }.getOrNull() } + ?: Charsets.UTF_8 + return bytes.toString(charset) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt index f339cf8792..63f2d00d83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt @@ -18,10 +18,8 @@ * 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.service.previews +package com.vitorpamplona.amethyst.commons.preview -import com.vitorpamplona.amethyst.commons.preview.OpenGraphParser -import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -65,7 +63,7 @@ class UrlPreview { ?: throw IllegalArgumentException("Website returned unknown mimetype: ${response.headers["Content-Type"]}") when { mimeType.type == "text" && mimeType.subtype == "html" -> { - val metaTags = HtmlParser().parseHtml(response.body.source(), mimeType.charset()) + val metaTags = HtmlParser().parseHtml(response.body.bytes(), mimeType.charset()?.name()) val data = OpenGraphParser().extractUrlInfo(metaTags) UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString()) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt index 9241a884d1..3ad9a6b93a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt @@ -73,7 +73,7 @@ class TagArrayBuilder { } fun addUniqueValueIfNew(tag: Array): TagArrayBuilder { - if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this + if (!tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this val list = tagList.getOrPut(tag[0], ::mutableListOf) if (list.none { it.valueOrNull() == tag[1] }) { list.add(tag) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip18Reposts/QuoteTagBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip18Reposts/QuoteTagBuilderTest.kt new file mode 100644 index 0000000000..2fce1de5ad --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip18Reposts/QuoteTagBuilderTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip18Reposts + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Regression test for the inverted guard in TagArrayBuilder.addUniqueValueIfNew that + * silently dropped every `q` (quote) tag from events built with the TagArrayBuilder DSL. + */ +class QuoteTagBuilderTest { + @Test + fun addressableQuoteAddsQTag() { + // naddr for a kind-36787 music track (see investigated event 759e543b...). + val content = + "nostr:naddr1qq8hgunpvd4j6at0v5mkcv35x9hqzxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctczyrt5unwa5r40e3uv8z2h3e6rvkpfj8cfxrsv9fcj93w2hl9d06lz5qcyqqqglvc3w67s7" + + val entities = findNostrUris(content) + assertEquals(1, entities.size, "the naddr should be parsed into a single entity") + + val template = + TextNoteEvent.build(content) { + quotes(findNostrUris(content)) + } + + val qTag = template.tags.firstOrNull { it.isNotEmpty() && it[0] == "q" } + assertNotNull(qTag, "a `q` tag must be added for the quoted naddr") + assertEquals( + "36787:d74e4ddda0eafcc78c389578e7436582991f0930e0c2a7122c5cabfcad7ebe2a:track-uoe7l241n", + qTag[1], + ) + } + + @Test + fun addUniqueValueIfNewGuardSemantics() { + val builder = TagArrayBuilder() + + // well-formed tag is kept + builder.addUniqueValueIfNew(arrayOf("q", "value1")) + // a name-only tag is ignored (no value at index 1) + builder.addUniqueValueIfNew(arrayOf("q")) + // a duplicate value is not added twice + builder.addUniqueValueIfNew(arrayOf("q", "value1")) + // a distinct value is kept + builder.addUniqueValueIfNew(arrayOf("q", "value2")) + + val qTags = builder.build().filter { it.isNotEmpty() && it[0] == "q" } + assertEquals(listOf("value1", "value2"), qTags.map { it[1] }) + } +}