From e66c27e3757fc63dc9dc24ea6d919474e9c16b5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 22:23:47 +0000 Subject: [PATCH 1/8] refactor(commons): move CLI-safe util extensions out of amethyst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First low-friction slice of the amethyst→commons migration (commons/plans/2026-05-30-amethyst-to-commons-migration.md): the model nipNN state holders are all blocked by the LocalCache/Note/Account keystone (Phase A), so start with the genuinely Android-free utilities. - Delete amethyst service/IterableExt.kt — exact duplicate of the existing commons util/IterableUtils.kt (Iterable.replace); re-point 4 callers. - Move retryIfException (CoroutinesExt.kt) into commons util/CoroutinesUtils.kt. - Move togglePresenceInSet (SetExt.kt) into commons util/SetUtils.kt. All commonMain-safe (verifyKmpPurity passes). amethyst play + fdroid both compile against the relocated helpers. --- .../notifications/PushNotificationUtils.kt | 2 +- .../amethyst/service/IterableExt.kt | 26 ------------------- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 2 +- .../rooms/dal/ChatroomListNewFeedFilter.kt | 2 +- .../relays/common/BasicRelaySetupInfoModel.kt | 2 +- .../relays/nip65/Nip65RelayListViewModel.kt | 2 +- .../notifications/PushNotificationUtils.kt | 2 +- .../amethyst/commons/util/CoroutinesUtils.kt | 2 +- .../amethyst/commons/util/SetUtils.kt | 2 +- 9 files changed, 8 insertions(+), 34 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/IterableExt.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/CoroutinesExt.kt => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CoroutinesUtils.kt (97%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/SetExt.kt => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/SetUtils.kt (96%) 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/service/IterableExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/IterableExt.kt deleted file mode 100644 index 7df5fa41e2..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/IterableExt.kt +++ /dev/null @@ -1,26 +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 - -fun Iterable.replace( - old: T, - new: T, -): List = map { if (it == old) new else it } 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/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/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) From 5f1514a15231d3fbcef9085ac62738cdf7ff459c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 22:42:05 +0000 Subject: [PATCH 2/8] refactor(commons): move relay broadcast tracker to commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of the amethyst→commons migration. BroadcastTracker + BroadcastEvent/RelayResult/BroadcastStatus are platform-agnostic relay event-broadcast logic (no keystone coupling, no Android) that Desktop and the CLI can reuse. - Move service/broadcast/{BroadcastModels,BroadcastTracker} into commons commonMain service/broadcast. - Replace the two commonMain purity-gate violations: System.currentTimeMillis() -> TimeUtils.now() (startedAt is only used to sort the active-broadcast list) and java.util.UUID.randomUUID() -> RandomInstance.randomChars(16) for the tracking id. - Re-point the 4 Android callers (AccountViewModel + broadcast UI). verifyKmpPurity passes; amethyst play + fdroid both compile. --- .../vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt | 6 +++--- .../amethyst/ui/broadcast/BroadcastDetailsSheet.kt | 6 +++--- .../amethyst/ui/broadcast/DisplayBroadcastProgress.kt | 2 +- .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../amethyst/commons}/service/broadcast/BroadcastModels.kt | 5 +++-- .../amethyst/commons}/service/broadcast/BroadcastTracker.kt | 6 +++--- 6 files changed, 14 insertions(+), 13 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/service/broadcast/BroadcastModels.kt (96%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/service/broadcast/BroadcastTracker.kt (98%) 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/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/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( From 58ad87c900b0ec4fac6d4068c9867ce12272a12c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 22:57:37 +0000 Subject: [PATCH 3/8] refactor(commons): move link-preview fetcher to commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice of the amethyst→commons migration. UrlPreview (OpenGraph link-preview fetcher) and HtmlParser already wrapped the extracted commons preview parsers (MetaTagsParser/OpenGraphParser/HtmlCharsetParser); this consolidates the whole link-preview concern in commons. - Move service/previews/{UrlPreview,HtmlParser} into commons jvmAndroid service preview package. They land in jvmAndroid (not commonMain) because UrlPreview uses OkHttp and HtmlParser uses java.nio.charset — both JVM-only. No Android-framework or keystone coupling: the caller injects the OkHttpClient as a lambda. - Add explicit okhttp + okhttp-coroutines deps to commons jvmAndroid (previously only present transitively via coil-okhttp). - Re-point the single caller (model/UrlCachedPreviewer). commons JVM compile + verifyKmpPurity pass; amethyst play + fdroid compile. --- .../com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt | 2 +- commons/build.gradle.kts | 6 ++++++ .../vitorpamplona/amethyst/commons/preview}/HtmlParser.kt | 5 +---- .../vitorpamplona/amethyst/commons/preview}/UrlPreview.kt | 4 +--- 4 files changed, 9 insertions(+), 8 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview}/HtmlParser.kt (92%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview}/UrlPreview.kt (95%) 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/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/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt index d9116c56e1..c6d1d95f61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt @@ -18,11 +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.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 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 95% 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..18f5086ba4 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 From f26c00add0e61bb2abf50d3412f089ab9cfaeb94 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 23:34:05 +0000 Subject: [PATCH 4/8] =?UTF-8?q?refactor(commons):=20make=20HtmlParser=20KM?= =?UTF-8?q?P=20=E2=80=94=20drop=20java=20Charset=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the link-preview move: HtmlParser + HtmlCharsetParser were stuck in jvmAndroid only because they spoke java.nio.charset.Charset. There is no common Charset type in the Kotlin stdlib, so this reshapes the API to speak IANA charset *names* (String) and pushes the single genuinely-platform operation — byte->String decode — behind expect/actual. - Move HtmlParser + HtmlCharsetParser to commonMain. Charset detection (meta-tag sniff + BOM sniff) is pure string/byte work; BOM detection no longer needs okio (manual leading-byte compare). - Add `expect fun decodeBytes(bytes, charsetName)`: * jvmAndroid actual -> java.nio.charset (all JRE charsets, UTF-8 fallback) * iosMain actual -> NSStringEncoding for the common web charsets (UTF-8/16/32, Latin-1, CP1252, ASCII), UTF-8 fallback for the rest. - UrlPreview (stays jvmAndroid; needs OkHttp) now reads response.body.bytes() and passes mimeType.charset()?.name(). Verified: commons compiles for JVM AND iosSimulatorArm64, verifyKmpPurity passes, commons jvmTest passes, amethyst play + fdroid compile. --- .../commons/preview/CharsetDecoder.kt | 38 +++++++++ .../commons/preview/HtmlCharsetParser.kt | 22 ++--- .../amethyst/commons/preview/HtmlParser.kt | 69 +++++++++++++++ .../commons/preview/CharsetDecoder.kt | 72 ++++++++++++++++ .../commons/preview/CharsetDecoder.kt | 34 ++++++++ .../amethyst/commons/preview/HtmlParser.kt | 84 ------------------- .../amethyst/commons/preview/UrlPreview.kt | 2 +- 7 files changed, 225 insertions(+), 96 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt (80%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt delete mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt 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/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/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt new file mode 100644 index 0000000000..7af77373ae --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt @@ -0,0 +1,34 @@ +/* + * 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 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/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt deleted file mode 100644 index c6d1d95f61..0000000000 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt +++ /dev/null @@ -1,84 +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.commons.preview - -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/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt index 18f5086ba4..63f2d00d83 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt @@ -63,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()) } From 92989566cda72f594c1a64e440844e722bae06e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:34:23 +0000 Subject: [PATCH 5/8] chore(actions): bump the actions group with 2 updates Bumps the actions group with 2 updates: [gradle/actions](https://github.com/gradle/actions) and [actions/cache](https://github.com/actions/cache). Updates `gradle/actions` from 4 to 6 - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/v4...v6) Updates `actions/cache` from 4 to 5 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: gradle/actions dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 10 +++++----- .github/workflows/smoke-test-desktop.yml | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) 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') }} From 1c2775c6b5d6ee5ed89d10778f61205cc20cffce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 19:15:03 +0000 Subject: [PATCH 6/8] fix(quartz): emit `q` tags again (inverted guard dropped all quotes) TagArrayBuilder.addUniqueValueIfNew had an inverted guard: if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this Since has(index) == size > index, `tag.has(1)` is true for every well-formed tag with a value, so the function returned early and never added it. addUniqueValueIfNew / addAllUniqueValueIfNew are used only by the quote() / quotes() builders, so every `q` tag (naddr, nevent, note, nembed, npub, nprofile) has been silently dropped since this file was introduced. Restore the missing `!` and add a regression test covering an addressable (naddr) quote plus the guard's accept/skip/dedupe semantics. https://claude.ai/code/session_01NMavNzJ7VRLhoD3hboCCC7 --- .../quartz/nip01Core/core/TagArrayBuilder.kt | 2 +- .../nip18Reposts/QuoteTagBuilderTest.kt | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip18Reposts/QuoteTagBuilderTest.kt 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] }) + } +} From ae271d0ba7705b1a139708cd1863b258df8c4d30 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 1 Jun 2026 21:34:18 +0200 Subject: [PATCH 7/8] refactor(sonar): extract NOT_STARTED_MESSAGE constant in CashuWalletState Replace the literal "CashuWalletState.start() not called" duplicated across 9 call sites (8 check guards + the publish default lambda) with a single private companion constant. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../model/nip60Cashu/CashuWalletState.kt | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) 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" } } From e8a50bfa1105bc03ff5709a76d8411a17af76358 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 22:33:42 +0000 Subject: [PATCH 8/8] fix: use ephemeral signer for media uploads in anonymous posts When composing an anonymous post (tap pfp to go anon on the short-note or comment screens), media uploads still authorized against the Blossom / NIP-96 server with the real account's signer. The server echoes that pubkey back in the returned media URL (e.g. Blossom's `as=`), linking the real identity to the supposedly anonymous post. Thread an optional `forcedSigner` through the upload chain (MultiOrchestrator -> UploadOrchestrator -> NIP-96/Blossom auth). Both ShortNotePostViewModel and CommentPostViewModel now hold a single ephemeral signer per compose session, reused for every photo/voice upload and for the final anonymous broadcast, so the upload auth event and the post share one throwaway key. signAnonymouslyAndBroadcast accepts that signer so the media author matches the post author. Non-anonymous callers are unaffected (forcedSigner defaults to null). The signer is reset in cancel() so each new compose session gets a fresh anonymous identity. --- .../vitorpamplona/amethyst/model/Account.kt | 2 +- .../service/uploads/MultiOrchestrator.kt | 5 ++++ .../service/uploads/UploadOrchestrator.kt | 29 +++++++++++++++---- .../nip22Comments/CommentPostViewModel.kt | 15 +++++++++- .../loggedIn/home/ShortNotePostViewModel.kt | 16 +++++++++- 5 files changed, 58 insertions(+), 9 deletions(-) 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/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/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/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) {