From e33fef2ebd269ad45deece4986e5052c9526ea0c Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sat, 23 May 2026 15:46:46 +0300 Subject: [PATCH 01/33] feat(desktop): rich text migration, copy raw JSON, and profile metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace desktop's 3-segment custom parser with commons' 23-segment RichTextParser. Add DesktopRichTextViewer rendering all segment types: hashtags, invoices (with NWC pay), cashu tokens, custom emoji, nowhere links, emails, relay URLs, markdown, image galleries, and more. Add 'Copy Raw JSON' to note overflow menu. Add nip05, website, and lightning address to profile card with right-click copy support. đŸ€– Generated with Claude Code Co-Authored-By: Claude --- .../service/DesktopCachedRichTextParser.kt | 56 ++ .../amethyst/desktop/ui/EventExtensions.kt | 2 + .../amethyst/desktop/ui/NoteActions.kt | 7 + .../amethyst/desktop/ui/UserProfileScreen.kt | 130 ++++ .../desktop/ui/note/DesktopRichTextViewer.kt | 690 ++++++++++++++++++ .../amethyst/desktop/ui/note/NoteCard.kt | 197 +---- 6 files changed, 907 insertions(+), 175 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt new file mode 100644 index 0000000000..c478f7f6ad --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt @@ -0,0 +1,56 @@ +/* + * 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.desktop.service + +import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState + +object DesktopCachedRichTextParser { + private const val MAX_CACHE_SIZE = 50 + + private val cache = + java.util.Collections.synchronizedMap( + object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry) = size > MAX_CACHE_SIZE + }, + ) + + fun parseText( + content: String, + tags: ImmutableListOfLists, + callbackUri: String? = null, + ): RichTextViewerState { + cache[content]?.let { return it } + val state = RichTextParser().parseText(content, tags, callbackUri) + cache[content] = state + return state + } + + fun isMarkdown(content: String): Boolean = + content.startsWith("> ") || + content.startsWith("# ") || + content.contains("##") || + content.contains("__") || + content.contains("**") || + content.contains("```") || + content.contains("](") +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt index 26650f5140..0367228efa 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData import com.vitorpamplona.quartz.nip01Core.core.Event @@ -49,5 +50,6 @@ fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData { profilePictureUrl = pictureUrl, content = content, createdAt = createdAt, + tags = ImmutableListOfLists(tags), ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 490125e088..80877ac126 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -763,6 +763,13 @@ fun NoteActionsRow( showOverflowMenu = false }, ) + DropdownMenuItem( + text = { Text("Copy Raw JSON") }, + onClick = { + copyToClipboard(event.toJson()) + showOverflowMenu = false + }, + ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 5f8661a258..2aa85742a2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -43,6 +44,8 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -63,7 +66,10 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -138,6 +144,25 @@ fun UserProfileScreen( ) } var picture by remember { mutableStateOf(cachedMetadata?.profilePicture()) } + var nip05 by remember { + mutableStateOf( + cachedMetadata + ?.flow + ?.value + ?.info + ?.nip05, + ) + } + var website by remember { + mutableStateOf( + cachedMetadata + ?.flow + ?.value + ?.info + ?.website, + ) + } + var lnAddress by remember { mutableStateOf(cachedMetadata?.lnAddress()) } var followersCount by remember { mutableStateOf(localCache.getCachedFollowerCount(pubKeyHex)) } var followingCount by remember { mutableStateOf(localCache.getCachedFollowingCount(pubKeyHex)) } @@ -258,6 +283,9 @@ fun UserProfileScreen( displayName = metadata.displayName ?: metadata.name about = metadata.about picture = metadata.picture + nip05 = metadata.nip05 + website = metadata.website + lnAddress = metadata.lnAddress() } // Store MetadataEvent for editing (only for own profile) @@ -678,6 +706,47 @@ fun UserProfileScreen( ) } + // Profile metadata fields + nip05?.takeIf { it.isNotBlank() }?.let { addr -> + Spacer(Modifier.height(8.dp)) + ProfileMetadataField( + text = addr, + icon = MaterialSymbols.CheckCircle, + onClick = { + runCatching { + java.awt.Desktop.getDesktop().browse( + java.net.URI("https://${addr.substringAfter("@")}"), + ) + } + }, + ) + } + + website?.takeIf { it.isNotBlank() }?.let { site -> + Spacer(Modifier.height(4.dp)) + ProfileMetadataField( + text = site.removePrefix("https://").removePrefix("http://").removeSuffix("/"), + copyValue = site, + icon = MaterialSymbols.Language, + onClick = { + runCatching { + val url = if (site.contains("://")) site else "https://$site" + java.awt.Desktop + .getDesktop() + .browse(java.net.URI(url)) + } + }, + ) + } + + lnAddress?.takeIf { it.isNotBlank() }?.let { addr -> + Spacer(Modifier.height(4.dp)) + ProfileMetadataField( + text = addr, + icon = MaterialSymbols.Bolt, + ) + } + Spacer(Modifier.height(12.dp)) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { @@ -1173,3 +1242,64 @@ private fun PublishedHighlightCard( } } } + +@Composable +private fun ProfileMetadataField( + text: String, + copyValue: String = text, + icon: com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol? = null, + onClick: () -> Unit = {}, +) { + var showContextMenu by remember { mutableStateOf(false) } + + Box( + modifier = + Modifier + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if ( + event.buttons.isSecondaryPressed && + event.changes.any { it.pressed && !it.previousPressed } + ) { + showContextMenu = true + } + } + } + }.clickable { onClick() }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 2.dp), + ) { + if (icon != null) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(4.dp)) + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + DropdownMenu(expanded = showContextMenu, onDismissRequest = { showContextMenu = false }) { + DropdownMenuItem( + text = { Text("Copy") }, + onClick = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(copyValue), null) + showContextMenu = false + }, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt new file mode 100644 index 0000000000..ecd357b1df --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt @@ -0,0 +1,690 @@ +/* + * 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.desktop.ui.note + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.richtext.Base64Segment +import com.vitorpamplona.amethyst.commons.richtext.BechSegment +import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment +import com.vitorpamplona.amethyst.commons.richtext.CashuSegment +import com.vitorpamplona.amethyst.commons.richtext.EmailSegment +import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment +import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment +import com.vitorpamplona.amethyst.commons.richtext.HashIndexUserSegment +import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment +import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph +import com.vitorpamplona.amethyst.commons.richtext.ImageSegment +import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment +import com.vitorpamplona.amethyst.commons.richtext.LinkSegment +import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment +import com.vitorpamplona.amethyst.commons.richtext.PdfSegment +import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment +import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment +import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState +import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment +import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji +import com.vitorpamplona.amethyst.commons.richtext.Segment +import com.vitorpamplona.amethyst.commons.richtext.VideoSegment +import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlinx.collections.immutable.ImmutableMap +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.net.URI + +data class RichTextCallbacks( + val onMentionClick: ((String) -> Unit)? = null, + val onHashtagClick: ((String) -> Unit)? = null, + val onNavigateToThread: ((String) -> Unit)? = null, + val onImageClick: ((List, Int) -> Unit)? = null, + val onPayInvoice: ((String) -> Unit)? = null, +) + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DesktopRichTextViewer( + content: String, + state: RichTextViewerState, + localCache: DesktopLocalCache? = null, + callbacks: RichTextCallbacks = RichTextCallbacks(), + modifier: Modifier = Modifier, +) { + if (DesktopCachedRichTextParser.isMarkdown(content)) { + RenderMarkdown( + content = content, + onLinkClick = { url -> + when { + url.startsWith("nostr:") -> { + val parsed = Nip19Parser.uriToRoute(url) + when (val entity = parsed?.entity) { + is NPub -> { + callbacks.onMentionClick?.invoke(entity.hex) + } + + is NProfile -> { + callbacks.onMentionClick?.invoke(entity.hex) + } + + is NNote -> { + callbacks.onNavigateToThread?.invoke(entity.hex) + } + + is NEvent -> { + callbacks.onNavigateToThread?.invoke(entity.hex) + } + + else -> {} + } + } + + else -> { + runCatching { + java.awt.Desktop + .getDesktop() + .browse(URI(url)) + } + } + } + }, + modifier = modifier, + ) + return + } + + Column(modifier = modifier) { + for (paragraph in state.paragraphs) { + when (paragraph) { + is ImageGalleryParagraph -> { + val urls = paragraph.words.map { it.segmentText } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + for ((index, segment) in paragraph.words.withIndex()) { + AsyncImage( + model = segment.segmentText, + contentDescription = null, + modifier = + Modifier + .weight(1f) + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)) + .then( + if (callbacks.onImageClick != null) { + Modifier.clickable { callbacks.onImageClick.invoke(urls, index) } + } else { + Modifier + }, + ), + contentScale = ContentScale.Crop, + ) + } + } + } + + else -> { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + ) { + for (word in paragraph.words) { + RenderSegment(word, state, localCache, callbacks) + } + } + } + } + } + } +} + +@Composable +private fun RenderSegment( + segment: Segment, + state: RichTextViewerState, + localCache: DesktopLocalCache?, + callbacks: RichTextCallbacks, +) { + when (segment) { + is RegularTextSegment -> { + Text( + text = segment.segmentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + is LinkSegment -> { + ClickableLink(segment.segmentText, segment.segmentText) + } + + is SchemelessUrlSegment -> { + ClickableLink("https://${segment.segmentText}", segment.segmentText) + } + + is BechSegment -> { + RenderBechSegment(segment, localCache, callbacks) + } + + is HashTagSegment -> { + val display = "#${segment.hashtag}" + (segment.extras ?: "") + Text( + text = display, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { callbacks.onHashtagClick?.invoke(segment.hashtag) }, + ) + } + + is HashIndexUserSegment -> { + val user = localCache?.getUserIfExists(segment.hex) + val display = "@${user?.toBestDisplayName() ?: segment.hex.take(8) + "..."}" + Text( + text = display + (segment.extras ?: ""), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { callbacks.onMentionClick?.invoke(segment.hex) }, + ) + } + + is HashIndexEventSegment -> { + QuotedNoteEmbed( + noteId = segment.hex, + localCache = localCache, + onMentionClick = callbacks.onMentionClick, + onNavigateToThread = callbacks.onNavigateToThread, + ) + } + + is EmailSegment -> { + Text( + text = segment.segmentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + runCatching { + java.awt.Desktop + .getDesktop() + .browse(URI("mailto:${segment.segmentText}")) + } + }, + ) + } + + is PhoneSegment -> { + Text( + text = segment.segmentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + is RelayUrlSegment -> { + Text( + text = segment.segmentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { copyToClipboard(segment.segmentText) }, + ) + } + + is EmojiSegment -> { + RenderCustomEmojiSegment(segment.segmentText, state.customEmoji) + } + + is NowhereLinkSegment -> { + RenderNowhereLinkCard(segment) + } + + is InvoiceSegment -> { + RenderInvoiceCard(segment.segmentText, callbacks) + } + + is CashuSegment -> { + RenderCashuCard(segment.segmentText) + } + + is WithdrawSegment -> { + ClickableLink(segment.segmentText, segment.segmentText) + } + + is BlossomUriSegment -> { + ClickableLink(segment.segmentText, segment.segmentText) + } + + is PdfSegment -> { + RenderPdfCard(segment.segmentText) + } + + is Base64Segment -> { + AsyncImage( + model = segment.segmentText, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Fit, + ) + } + + is ImageSegment -> { + AsyncImage( + model = segment.segmentText, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Fit, + ) + } + + is VideoSegment -> { + Text( + text = segment.segmentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + runCatching { + java.awt.Desktop + .getDesktop() + .browse(URI(segment.segmentText)) + } + }, + ) + } + + is SecretEmoji -> { + RenderSecretEmoji(segment.segmentText) + } + + else -> { + Text( + text = segment.segmentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun ClickableLink( + url: String, + displayText: String, +) { + Text( + text = displayText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + runCatching { + java.awt.Desktop + .getDesktop() + .browse(URI(url)) + } + }, + ) +} + +@Composable +private fun RenderBechSegment( + segment: BechSegment, + localCache: DesktopLocalCache?, + callbacks: RichTextCallbacks, +) { + val resolved = + remember(segment.segmentText, localCache) { + resolveBech32(segment.segmentText, localCache) + } + when { + resolved.noteIdHex != null -> { + QuotedNoteEmbed( + noteId = resolved.noteIdHex, + localCache = localCache, + onMentionClick = callbacks.onMentionClick, + onNavigateToThread = callbacks.onNavigateToThread, + ) + } + + resolved.pubKeyHex != null -> { + Text( + text = resolved.displayText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { callbacks.onMentionClick?.invoke(resolved.pubKeyHex) }, + ) + } + + else -> { + Text( + text = resolved.displayText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun RenderInvoiceCard( + invoice: String, + callbacks: RichTextCallbacks, +) { + val amount = + remember(invoice) { + runCatching { LnInvoiceUtil.getAmountInSats(invoice) }.getOrNull() + } + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = "Lightning Invoice", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = if (amount != null) "$amount sats" else "Lightning Invoice", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { copyToClipboard(invoice) }) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Copy") + } + if (callbacks.onPayInvoice != null) { + TextButton(onClick = { callbacks.onPayInvoice.invoke(invoice) }) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = "Pay", + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Pay") + } + } + } + } + } +} + +@Composable +private fun RenderCashuCard(token: String) { + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Cashu Token", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = token.take(40) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(8.dp)) + TextButton(onClick = { copyToClipboard(token) }) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Copy") + } + } + } +} + +@Composable +private fun RenderPdfCard(url: String) { + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + runCatching { + java.awt.Desktop + .getDesktop() + .browse(URI(url)) + } + }, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.PictureAsPdf, + contentDescription = "PDF", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = url, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun RenderSecretEmoji(text: String) { + var expanded by remember { mutableStateOf(false) } + if (expanded) { + val decoded = remember(text) { runCatching { EmojiCoder.decode(text) }.getOrDefault(text) } + Text( + text = decoded, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.clickable { expanded = false }, + ) + } else { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { expanded = true }, + ) + } +} + +@Composable +private fun RenderCustomEmojiSegment( + word: String, + customEmoji: ImmutableMap, +) { + val matchedEmoji = remember(word, customEmoji) { customEmoji.entries.firstOrNull { word.contains(it.key) } } + if (matchedEmoji != null) { + val parts = word.split(matchedEmoji.key, limit = 2) + Row(verticalAlignment = Alignment.CenterVertically) { + if (parts[0].isNotEmpty()) { + Text(parts[0], style = MaterialTheme.typography.bodyMedium) + } + AsyncImage( + model = matchedEmoji.value, + contentDescription = matchedEmoji.key, + modifier = Modifier.size(20.dp), + contentScale = ContentScale.Fit, + ) + if (parts.size > 1 && parts[1].isNotEmpty()) { + Text(parts[1], style = MaterialTheme.typography.bodyMedium) + } + } + } else { + Text(word, style = MaterialTheme.typography.bodyMedium) + } +} + +private val nowhereToolLabels = + mapOf( + "e" to "Nowhere Event", + "f" to "Nowhere Fundraiser", + "s" to "Nowhere Store", + "p" to "Nowhere Petition", + "m" to "Nowhere Message", + "d" to "Nowhere Drop", + "a" to "Nowhere Art", + "fo" to "Nowhere Forum", + ) + +@Composable +private fun RenderNowhereLinkCard(segment: NowhereLinkSegment) { + val label = nowhereToolLabels[segment.tool] ?: "Nowhere Site" + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + runCatching { + java.awt.Desktop + .getDesktop() + .browse(URI(segment.segmentText)) + } + }, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = label, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = segment.host, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun copyToClipboard(text: String) { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(text), null) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 1b0208f63a..7b152971ec 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -46,20 +46,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withLink -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.UrlParser -import com.vitorpamplona.amethyst.commons.richtext.Urls import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer @@ -85,6 +80,7 @@ data class NoteDisplayData( val profilePictureUrl: String? = null, val content: String, val createdAt: Long, + val tags: ImmutableListOfLists = EmptyTagList, ) /** @@ -99,8 +95,10 @@ fun NoteCard( onClick: (() -> Unit)? = null, onAuthorClick: ((String) -> Unit)? = null, onMentionClick: ((String) -> Unit)? = null, + onHashtagClick: ((String) -> Unit)? = null, onImageClick: ((List, Int) -> Unit)? = null, onMediaClick: ((List, Int, Float) -> Unit)? = null, + onPayInvoice: ((String) -> Unit)? = null, ) { val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } val imageUrls = @@ -135,18 +133,6 @@ fun NoteCard( } text } - val strippedUrls = - remember(urls, mediaUrls) { - Urls( - withScheme = urls.withScheme - mediaUrls, - withoutScheme = urls.withoutScheme, - emails = urls.emails, - bech32s = urls.bech32s, - relayUrls = urls.relayUrls, - blossomUris = urls.blossomUris, - ) - } - // Cap media height to half the window so text is never pushed off-screen val windowState = LocalWindowState.current val maxMediaHeight = @@ -212,11 +198,22 @@ fun NoteCard( Spacer(Modifier.height(8.dp)) if (strippedContent.isNotBlank()) { - RichTextContent( + val richState = + remember(strippedContent, note.tags) { + DesktopCachedRichTextParser.parseText(strippedContent, note.tags) + } + DesktopRichTextViewer( content = strippedContent, - urls = strippedUrls, + state = richState, localCache = localCache, - onMentionClick = onMentionClick, + callbacks = + RichTextCallbacks( + onMentionClick = onMentionClick, + onHashtagClick = onHashtagClick, + onNavigateToThread = null, + onImageClick = onImageClick, + onPayInvoice = onPayInvoice, + ), modifier = Modifier.fillMaxWidth(), ) } @@ -325,7 +322,7 @@ fun NoteCard( /** * Resolved bech32 mention with display text and optional pubkey for click navigation. */ -private data class ResolvedMention( +internal data class ResolvedMention( val displayText: String, val pubKeyHex: String? = null, val noteIdHex: String? = null, @@ -336,7 +333,7 @@ private data class ResolvedMention( * For npub/nprofile → @displayName + pubkey hex for navigation. * For note/nevent → truncated note ID. */ -private fun resolveBech32( +internal fun resolveBech32( bech32: String, localCache: DesktopLocalCache?, ): ResolvedMention { @@ -386,99 +383,6 @@ fun extractMentionedPubkeys(bech32s: Set): List = } } -/** - * Renders text content with highlighted URLs and clickable nostr: bech32 mentions. - * URLs are underlined in primary color; bech32 mentions show as @displayName in primary color - * and navigate to profile on click. - */ -private data class ContentSegment( - val start: Int, - val raw: String, - val isUrl: Boolean, -) - -private fun buildSegments( - content: String, - schemeUrls: Collection, - bech32s: Collection, -): List { - val segments = mutableListOf() - for (url in schemeUrls) { - val idx = content.indexOf(url) - if (idx != -1) segments.add(ContentSegment(idx, url, true)) - } - for (bech32 in bech32s) { - val idx = content.indexOf(bech32) - if (idx != -1) segments.add(ContentSegment(idx, bech32, false)) - } - segments.sortBy { it.start } - return segments -} - -@Composable -fun RichTextContent( - content: String, - urls: Urls, - localCache: DesktopLocalCache? = null, - onMentionClick: ((String) -> Unit)? = null, - onNavigateToThread: ((String) -> Unit)? = null, - modifier: Modifier = Modifier, -) { - val defaultColor = MaterialTheme.colorScheme.onSurface - val primaryColor = MaterialTheme.colorScheme.primary - - if (urls.withScheme.isEmpty() && urls.bech32s.isEmpty()) { - Text( - text = content, - style = MaterialTheme.typography.bodyMedium, - color = defaultColor, - modifier = modifier, - ) - return - } - - // Resolve bech32s to find quoted notes vs inline mentions - val resolvedBech32s = - remember(urls.bech32s, localCache) { - urls.bech32s.associateWith { resolveBech32(it, localCache) } - } - - // Collect quoted note IDs (nevent/note references) - val quotedBech32s = remember(resolvedBech32s) { resolvedBech32s.filter { it.value.noteIdHex != null }.keys } - val quotedNoteIds = remember(resolvedBech32s) { resolvedBech32s.values.mapNotNull { it.noteIdHex }.toSet() } - - if (quotedNoteIds.isEmpty()) { - // No quoted notes — render everything as annotated text - val segments = remember(content, urls) { buildSegments(content, urls.withScheme, urls.bech32s) } - RichAnnotatedText(content, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick, modifier) - } else { - // Has quoted notes — render text + embedded note cards - Column(modifier = modifier) { - // Strip quoted note bech32 references from text - val strippedText = - remember(content, quotedBech32s) { - var text = content - for (bech32 in quotedBech32s) { - text = text.replace(bech32, "").trim() - } - text - } - - if (strippedText.isNotBlank()) { - val inlineBech32s = urls.bech32s - quotedBech32s - val segments = remember(strippedText, urls, inlineBech32s) { buildSegments(strippedText, urls.withScheme, inlineBech32s) } - RichAnnotatedText(strippedText, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick) - } - - // Render quoted notes as embedded cards - for (noteId in quotedNoteIds) { - Spacer(Modifier.height(8.dp)) - QuotedNoteEmbed(noteId, localCache, onMentionClick, onNavigateToThread) - } - } - } -} - /** * Renders a quoted note by ID. Observes the note's metadata flow so it * recomposes when the event arrives asynchronously from a relay fetch. @@ -533,60 +437,3 @@ fun QuotedNoteEmbed( } } } - -@Composable -private fun RichAnnotatedText( - content: String, - segments: List, - resolvedBech32s: Map, - defaultColor: androidx.compose.ui.graphics.Color, - primaryColor: androidx.compose.ui.graphics.Color, - onMentionClick: ((String) -> Unit)?, - modifier: Modifier = Modifier, -) { - val annotatedText = - buildAnnotatedString { - var lastIndex = 0 - for (seg in segments) { - if (seg.start < lastIndex) continue - if (seg.start > lastIndex) { - append(content.substring(lastIndex, seg.start)) - } - if (seg.isUrl) { - withStyle(SpanStyle(color = primaryColor, textDecoration = TextDecoration.Underline)) { - append(seg.raw) - } - } else { - val resolved = resolvedBech32s[seg.raw] ?: ResolvedMention(seg.raw) - if (resolved.pubKeyHex != null && onMentionClick != null) { - val pubKey = resolved.pubKeyHex - withLink( - LinkAnnotation.Clickable( - tag = "mention", - styles = TextLinkStyles(SpanStyle(color = primaryColor)), - ) { - onMentionClick(pubKey) - }, - ) { - append(resolved.displayText) - } - } else { - withStyle(SpanStyle(color = primaryColor)) { - append(resolved.displayText) - } - } - } - lastIndex = seg.start + seg.raw.length - } - if (lastIndex < content.length) { - append(content.substring(lastIndex)) - } - } - - Text( - text = annotatedText, - style = MaterialTheme.typography.bodyMedium, - color = defaultColor, - modifier = modifier, - ) -} From f10973c06e19643799df1de350de5acea8214ee5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 15:59:59 +0000 Subject: [PATCH 02/33] docs: add iOS support plan --- amethyst/plans/2026-05-24-ios-support.md | 345 +++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 amethyst/plans/2026-05-24-ios-support.md diff --git a/amethyst/plans/2026-05-24-ios-support.md b/amethyst/plans/2026-05-24-ios-support.md new file mode 100644 index 0000000000..44869b05bb --- /dev/null +++ b/amethyst/plans/2026-05-24-ios-support.md @@ -0,0 +1,345 @@ +# iOS Support for Amethyst + +**Date:** 2026-05-24 +**Status:** Draft — not yet started +**Owner:** TBD + +Plan to incrementally bring Amethyst to iOS by extending the existing +KMP layers from the bottom up. Each phase is independently shippable — +we can pause between any two phases without leaving the tree in a +broken state. + +## Why this is tractable today + +The structural work that usually dooms a KMP-to-iOS effort is already +done: + +- `quartz/` has `iosArm64` + `iosSimulatorArm64` targets configured, + a working `Platform.ios.kt` actual, and 6 iOS test files that pass. +- Jackson and OkHttp — the two big JVM-only dependencies — are *already + isolated to `jvmAndroid`* in quartz (`quartz/src/jvmAndroid/.../jackson/`, + `quartz/src/jvmAndroid/.../okhttp/`). `commonMain` is JVM-free except + for the obvious `kotlinx.*` stack. +- `commons/commonMain` has exactly **one** Jackson reference + (`FeedDefinitionSerializer.kt`) and zero OkHttp references. The rest + of the JVM stickiness lives in `jvmAndroid` / `jvmMain` / `androidMain`, + which is where it belongs. +- Compose Multiplatform 1.10.3 is in use, which supports iOS officially. +- `secp256k1-kmp` ships iOS targets. `androidx.collection` (LruCache) and + `androidx.lifecycle.viewmodel.compose` are KMP since 2.8. + +What this means: we are not embarking on a months-long "purify +commonMain" migration before any iOS code can compile. Phase 1 is +mostly **add iOS to CI** and **patch the last few leaks**. + +## Module-by-module dep matrix + +Status legend: +- ✅ iOS-ready (targets configured, no JVM-only deps in shared code) +- 🟡 Partial (intermediate source sets need adding, but no major dep blockers) +- 🔮 Blocked (significant native work required) +- ⛔ Out of scope (won't ship on iOS) + +| Module | Today | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 | +|---|---|---|---|---|---|---| +| `quartz/` | ✅ | CI + audit | — | — | — | — | +| `commons/` (non-UI) | 🟡 | — | ✅ | — | — | — | +| `commons/` (UI) | 🟡 | — | — | ✅ | — | — | +| `iosApp/` (new) | n/a | — | — | scaffold | feature-complete | — | +| `quic/` | 🔮 | — | — | — | — | iOS actuals | +| `nestsClient/` | 🔮 | — | — | — | — | iOS actuals | +| `amethyst/` (app) | ⛔ | — | — | — | — | — | +| `desktopApp/` | ⛔ | — | — | — | — | — | +| `cli/` | ⛔ | — | — | — | — | — | + +## Source-set diagram (target end state) + +``` +commons/src/ +├── commonMain/ ── all targets +│ ├── coreMain/ ── ViewModels, state, DAL (no Compose) +│ │ ├── jvmAndroidCore/ ── Android + Desktop +│ │ │ ├── androidCore/ +│ │ │ └── jvmCore/ +│ │ └── nativeCore/ ── iOS +│ │ ├── iosArm64Core/ +│ │ └── iosSimArm64Core/ +│ └── uiMain/ ── Compose UI, icons, resources +│ ├── jvmAndroidUi/ +│ │ ├── androidUi/ +│ │ └── jvmUi/ +│ └── nativeUi/ ── iOS Compose +``` + +(Names sketched for clarity; in practice we'll fold `coreMain` / +`uiMain` together once *every* file in `uiMain` compiles for iOS — +the split is a transitional scaffold for Phase 2 ↔ Phase 3.) + +`quartz/`, `quic/`, `nestsClient/` already use a `jvmAndroid` shared +source set; we'll add a sibling `nativeMain` (or just `iosMain` where +that's simpler) when each module turns on iOS. + +--- + +## Phase 1 — Lock down Quartz on iOS + +**Duration estimate:** 1–2 weeks +**Deliverable:** `./gradlew :quartz:iosSimulatorArm64Test` runs in CI on every PR. + +### Tasks + +1. **Add iOS to CI for `:quartz`.** + - GitHub Actions macOS runner step: `iosSimulatorArm64Test` + + `iosArm64SourceSetTest` (compile only). + - This is the single most valuable change in the entire plan — it + prevents anyone from accidentally re-adding a JVM-only import to + `commonMain`. + +2. **Audit the `jvmAndroid` boundary.** + - Confirm everything Jackson/OkHttp-related lives in `jvmAndroid` + (it does today — keep it that way). + - Add a checkstyle / detekt rule, or a simple grep gate in CI, that + fails the build if `com.fasterxml.jackson` or `okhttp3` shows up + in `commonMain`. + +3. **Validate `secp256k1` iOS path.** + - Make sure `KeyPair`, `SchnorrSigner`, NIP-44 v2 vectors run green + on `iosSimulatorArm64Test`. + - The iOS tests already cover NIP-04 / NIP-17 / NIP-19 / NIP-49 — we + just need to surface them in CI. + +4. **Plan the `expect`/`actual` for iOS HTTP.** + - Phase 1 only sketches the design; the actual `Ktor-darwin` wiring + lands in Phase 2 when `:commons` needs it. + - Decide: Ktor everywhere, vs OkHttp on JVM/Android + Ktor on iOS. + **Recommendation:** keep OkHttp on JVM/Android (we use OkHttp-specific + features in relay reconnect logic) and add an iOS-only Ktor actual. + +### Risks + +- None major. The work here is mostly defensive. + +--- + +## Phase 2 — Bring `:commons` to iOS, non-UI first + +**Duration estimate:** 2–3 weeks +**Deliverable:** `./gradlew :commons:iosSimulatorArm64Test` compiles every +shared ViewModel and state class. + +### Tasks + +1. **Add iOS targets to `commons/build.gradle.kts`.** + - `iosArm64()` + `iosSimulatorArm64()`. + - Introduce intermediate source sets `coreMain` (all targets) and + `uiMain` (JVM + Android only, for now). + +2. **Migrate `FeedDefinitionSerializer.kt` off Jackson.** + - Move to `kotlinx.serialization`, OR + - Push it down into `jvmAndroidCore` and create a `nativeCore` actual. + **Recommendation:** migrate. It's one file; one-time cost is small; + reduces split-actual surface area forever. + +3. **Add `expect`/`actual` wrappers for JVM-only deps used by ViewModels.** + + | Concern | JVM/Android | iOS actual | + |---|---|---| + | HTTP client | OkHttp | Ktor + `Ktor-darwin` | + | Secure key storage | Android Keystore / java-keyring | Keychain Services | + | EXIF strip (image upload) | `commons-imaging` | `ImageIO` (`CGImageSourceCopyPropertiesAtIndex`) | + | File I/O paths | `java.io.File` | `NSFileManager` / `okio` | + | Logging | `android.util.Log` / SLF4J | `os_log` via cinterop, or plain `println` to start | + +4. **Compile-only iOS for `:commons` ViewModels.** + - At the end of Phase 2 we have ViewModels, account state, LocalCache + wrappers, filter assemblers, and `ComposeSubscriptionManager` building + on iOS — but no UI yet. + - Smoke test: write a small `commonTest` that constructs an `Account`, + subscribes to a stub relay, and verifies a follow event lands in + `LocalCache`. Run it on iOS simulator. + +### Risks + +- **Ktor migration scope creep.** Hold the line: Phase 2 only wraps HTTP + behind `expect`. Don't refactor the relay pool. That's a separate PR. +- **Coroutines dispatcher differences.** `Dispatchers.IO` does not exist on + Kotlin/Native by default — code that explicitly references it needs a + `KmpDispatchers.IO` shim. Audit before Phase 2 starts. + +--- + +## Phase 3 — Compose Multiplatform UI on iOS + +**Duration estimate:** 3–4 weeks +**Deliverable:** A read-only iOS `.ipa` on TestFlight internal that connects +to relays and renders a feed. + +### Tasks + +1. **Flip `uiMain` to target = all (including iOS).** + - Compose Multiplatform 1.10.3 supports iOS. The Material Symbols font + and other Compose Resources already work cross-platform. + +2. **Audit UI deps for iOS.** + + | Dep | Status | Action | + |---|---|---| + | `jetbrains.compose.*` (1.10.3) | ✅ | None | + | `androidx.lifecycle.viewmodel.compose` 2.8+ | ✅ KMP | None | + | `coil3` | ✅ iOS | Swap network fetcher from `coil-okhttp` to `coil-ktor` on iOS via source-set split | + | `markdown-ui` / `markdown-ui-material3` | ⚠ Verify | Likely OK on iOS; if not, fall back to commonmark + custom renderer | + | `kotlinx-collections-immutable` | ✅ | None | + | Material Symbols font | ✅ | None (already via Compose Resources) | + +3. **Create the `iosApp/` module.** + - SwiftUI `App` + `UIViewControllerRepresentable` hosting + `ComposeUIViewController { App() }`. + - Tab bar (UIKit) for top-level navigation, Compose for each tab's + content area. **Same split philosophy as Desktop**: native shell, + shared content. + - Add Xcode project + Gradle Kotlin/Native framework wiring (no + CocoaPods; use the JetBrains-recommended `embedAndSignAppleFrameworkForXcode`). + +4. **Ship a "read-only Nostr browser" first cut.** + - Profile view, single-feed home, NoteCard rendering, image loading, + basic navigation. + - No posting, no DMs, no audio rooms. + - This validates the *entire* stack — relay client, LocalCache, feed DAL, + NoteCard composable, Coil 3, Compose Resources, font rendering — without + touching signing. + +### Risks + +- **Compose iOS performance on large feeds.** Profile early with a realistic + `LocalCache` (10k+ notes) before locking screen architecture. If recomposition + storms appear, lean harder on `compose-stability-diagnostics` and + `compose-state-deferred-reads` skills. +- **Touch interactions vs Android conventions.** Pull-to-refresh, swipe + back, long-press menus all differ on iOS. Some screens may need + platform-specific gesture handling. +- **Markdown rendering library iOS support.** If `markdown-ui-material3` + doesn't ship iOS artifacts, this is a half-week detour to switch + renderers. Verify in week 1 of Phase 3. + +--- + +## Phase 4 — Write paths: signing, posting, settings + +**Duration estimate:** 2–3 weeks +**Deliverable:** Fully read/write iOS client, minus audio rooms. + +### Tasks + +1. **Wire `NostrSignerInternal` to Keychain.** + - The signer is already KMP — only the key storage actual needs + adding (done in Phase 2's `SecureKeyStore` abstraction). + +2. **Make `NostrSignerRemote` (NIP-46 bunker) work on iOS.** + - Should be KMP-clean once Ktor migration is done. Audit for any + stray Jackson / OkHttp inside the NIP-46 path. + +3. **NIP-55 alternative.** + - **There is no Amber on iOS.** Plan replacements: + - Push users toward NIP-46 bunkers (Nsec.app, Amber-as-bunker, + remote nostr-connect URIs). + - URL-scheme handoff to native iOS signers (`nos2x-fhe`, `Nostore`) + *if* they expose a sign API. Track separately. + - Onboarding screen needs an iOS-specific copy variant. + +4. **Posting, reactions, zaps.** + - Mostly free — ViewModels already in `:commons`. Wire UI buttons and + test end-to-end on TestFlight. + +5. **Settings UI.** + - Share via Compose. iOS-native preference screens are a polish item + for later. + +### Risks + +- **Apple App Review on cryptocurrency / zaps.** Lightning zaps via LNURL + are fine (no in-app crypto purchase). Anything that looks like an + in-app wallet or onchain send may need legal review and / or feature + gating per-region. Start review conversations early. +- **Push notifications.** APNs is the only path on iOS. Nostr DM push + relays don't speak APNs natively. Likely needs a small relay-proxy + (similar to `notify.damus.io`'s architecture). Design doc in + `amethyst/plans/` before Phase 4 ends. + +--- + +## Phase 5 — `:quic` + `:nestsClient` for audio rooms (optional) + +**Duration estimate:** 4–6 weeks +**Status:** Defer until 1–4 are solid. App is shippable on iOS without +audio rooms. + +### Tasks + +1. **`:quic` — add iOS actuals.** + - UDP socket via `Network.framework` (`NWConnection` with `.udp`). + - AEAD (AES-GCM, ChaCha20-Poly1305) via Apple CryptoKit + (`AES.GCM.SealedBox`, `ChaChaPoly`). + - TLS state machine is already pure Kotlin in `commonMain` — no change. + +2. **`:nestsClient` — add iOS actuals.** + - Opus encode/decode: `libopus` via cinterop, or pull `opus.framework` + from a Swift Package / CocoaPods spec. + - Mic + speaker: `AVAudioEngine` (input/output nodes) instead of + `AudioRecord` / `AudioTrack`. + +3. **moq-lite listener path first** (the production path per CLAUDE.md), + then speaker. + +### Risks + +- **Background audio on iOS.** Audio rooms in the background need a + proper `AVAudioSession` category + the `audio` background mode in + `Info.plist`. Apple sometimes rejects apps that abuse this. Worth a + separate audit before submission. +- **Opus framework distribution.** `libopus` via Swift Package is + cleanest; CocoaPods is fine but pulls in a build-time dep on Ruby. + Decide before Phase 5 starts. + +--- + +## Phase 6 — Ship polish (ongoing, post-Phase 4) + +- App Store metadata, screenshots, privacy manifest + (`NSPrivacyAccessedAPI*` declarations — file access, user defaults). +- Localizations carry over automatically via Compose Resources. +- Background fetch limits — iOS is far stricter than Android. Tune + feed prefetch + relay reconnect for background launch budgets. +- TestFlight beta → public release. + +--- + +## Cross-cutting risks (track from day one) + +| Risk | Mitigation | First chance to catch | +|---|---|---| +| `commonMain` regresses with a JVM-only import | Add iOS to CI on every PR | Phase 1, task 1 | +| Coroutines `Dispatchers.IO` ergonomics on iOS | Audit + introduce `KmpDispatchers` shim | Phase 2, task 3 | +| Compose iOS performance on big feeds | Early profiling with realistic `LocalCache` | Phase 3 risk section | +| App Store review (zaps, onchain) | Talk to legal / read App Store guidelines early | Phase 4 risk section | +| No NIP-55 equivalent on iOS | Lean on NIP-46; document in onboarding | Phase 4, task 3 | +| Push notifications via APNs | Relay-proxy design doc | Phase 4, end of phase | +| Background audio policy | `AVAudioSession` audit + `Info.plist` review | Phase 5 risk section | + +## Suggested first PR + +Smallest useful start: **Phase 1, tasks 1 + 2** — add `:quartz` iOS to +CI and add the import-gate that prevents Jackson / OkHttp regressions +in `commonMain`. That single PR de-risks the rest of the plan without +touching any product code. + +## Open questions + +- Do we want a `:cli` analogue on iOS (a "headless" Nostr daemon)? Out + of scope for this plan, but iosArm64 *could* host one if we ever need + a CLI-on-phone story. +- Mac Catalyst vs native macOS: Desktop is already JVM-Compose. We + could theoretically also ship Catalyst from the iOS build, but that's + three "desktop"-ish targets to maintain. Recommendation: punt. +- iPad layout: do we want a separate split-view UI like `desktopApp`, + or just scale up the iPhone layout? Phase 3 keeps the iPhone layout; + iPad polish is a Phase 6 item. From 5a276ce8d7447c834b39e33e5107dfe4f8e5f84e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:06:52 +0000 Subject: [PATCH 03/33] docs: expand Child Safety Standards for Play Store compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google Play rejected v446 because the published Child Safety Standards did not explicitly prohibit CSAE, name a child-safety point of contact, describe the in-app reporting mechanism, or reference the app/developer as listed on Play. Rewrites the section in PRIVACY.md to: - Explain that Amethyst is a client, not a host: third-party relays host content and are responsible for moderation and any NCMEC reporting obligations (e.g. 18 U.S.C. §2258A). - Explicitly prohibit CSAE/CSAM in the app. - Document the in-app tools users have: Report Post, Report Account, Block Post/Account, Block Relay (NIP-51 Blocked Relay List), Mute Words/Hashtags. - Describe the escalation path: report in-app, block the hosting relay, report to NCMEC CyberTipline / INHOPE, optionally email the developer. - Provide a child-safety point of contact (amethyst@vitorpamplona.com) with realistic scope of action (forward to relay ops, drop the relay from default lists). - Reference the app name "Amethyst" and developer "Vitor Pamplona" as required by the checklist. Also surfaces the document from inside the app by adding an "About & Legal" section to Settings with two items: Privacy Policy and Child Safety Standards (anchor link to the section). --- PRIVACY.md | 52 ++++++++++++++++++- .../loggedIn/settings/AllSettingsScreen.kt | 28 ++++++++++ amethyst/src/main/res/values/strings.xml | 2 + 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index cfed3b5e56..cc4dafdf8d 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -33,9 +33,57 @@ The information you share is publicly visible to anyone reading from relays that Information shared on Nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted. -## Child safety standards +## Child Safety Standards -Amethyst does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. The application is 17+. We rely on Google Play's age verification to make sure the user downloading the app is an adult. Since we do not control which relays the user connects to, there is no content moderation beyond the standard block post, block account, and report post and/or account that will hide the content from the user. +Amethyst, published on Google Play by Vitor Pamplona, is committed to protecting children from sexual abuse and exploitation. These standards apply to the Amethyst app for Android. + +### How Amethyst Works (and Why That Matters Here) + +Amethyst is a decentralized Nostr client. **The app itself does not host, store, or moderate any user-generated content.** All content is hosted by independent third-party servers called **relays** that the user freely chooses to connect to. Amethyst is a viewer and a publisher; it has no central database, no upload servers, and no ability to delete content from the network. Content moderation, takedowns, and legal reporting are the responsibility of the **relay operators** who actually host the content. + +What Amethyst provides — and what these Standards cover — is (1) a clear prohibition of CSAE, (2) in-app tools that let users report content, hide content, and disconnect from abusive relays, and (3) a contact point for escalation. + +### Prohibition of Child Sexual Abuse and Exploitation (CSAE) + +Amethyst strictly prohibits the use of the app to create, upload, share, solicit, or distribute child sexual abuse and exploitation (CSAE) material, including child sexual abuse material (CSAM), in any form, or to groom, exploit, endanger, or otherwise harm minors. Users who use Amethyst for these purposes are in violation of these Standards and of the laws of essentially every jurisdiction. + +### In-App User Feedback and Reporting Mechanism + +Amethyst provides in-app mechanisms for users to flag, hide, and disconnect from harmful content: + +- **Report Post** — use the dropdown menu on any note to report it as illegal content, nudity, impersonation, spam, profanity, or other violations. The report is published as a signed Nostr report event so that relay operators and other clients can act on it. +- **Report Account** — open a user's profile and use the report action to flag the account, with the same publication behavior. +- **Block Post / Block Account** — hide a note or a user locally on your device. +- **Block Relay (NIP-51 Blocked Relay List)** — if a particular relay is hosting CSAE/CSAM or refuses to act on reports, add it to your Blocked Relay List so Amethyst will no longer fetch from or publish to it. This is the strongest tool the app provides: it cuts your client off from servers that won't moderate. +- **Mute Words and Hashtags** — filter out unwanted content from your feeds. + +### Addressing CSAM + +Because Amethyst does not host content, CSAM cannot be removed by Amethyst — it can only be removed by the relay operator who is actually hosting it, and reported to authorities by that operator under the laws that apply to them (in the United States, 18 U.S.C. §2258A makes hosting providers — not viewer applications — the entities required to report to the National Center for Missing & Exploited Children). + +If you become aware of CSAM accessible via Amethyst, please: + +1. **Report the content in-app** (select "Illegal Content") so the report propagates to relays and other clients, and +2. **Block the relay** that is hosting the content using the in-app Blocked Relay List, so your client disconnects from it, and +3. **Report the relay and the material directly to the authorities** who have jurisdiction over the hosting provider — for content reachable from the United States that is the **National Center for Missing & Exploited Children (NCMEC) CyberTipline** at https://report.cybertip.org/. For other jurisdictions see INHOPE members at https://www.inhope.org/. +4. **Optionally email the contact below** with the relay URL and event ID. We cannot remove the content from the relay, but we can amplify the report to other relay operators we know, and where appropriate we will recommend that the offending relay be removed from any default relay list shipped with Amethyst. + +### Compliance with Child Safety Laws + +Amethyst is built and distributed to comply with applicable child safety laws and regulations, including Google Play's Child Safety Standards policy. Where Amethyst itself is subject to a legal obligation (for example, as a distributor on Google Play), we will cooperate with lawful requests from child-safety authorities. Obligations that attach to the **hosting** of content (such as 18 U.S.C. §2258A NCMEC reporting in the U.S.) apply to the relay operators, not to the viewer application. + +### Child Safety Point of Contact + +Questions, reports of relays hosting CSAE/CSAM, or requests related to child safety on Amethyst should be sent to: + +- **Name:** Vitor Pamplona (developer, Amethyst for Android) +- **Email:** amethyst@vitorpamplona.com +- **What we can do:** acknowledge the report, forward it to relay operators we are in contact with, and consider removing the offending relay from any default/suggested relay list shipped with Amethyst. We cannot delete content from third-party relays — that is the relay operator's responsibility. +- **What you should also do:** report directly to NCMEC (https://report.cybertip.org/) or your local INHOPE hotline, who can compel the actual hosting provider to act. + +### Age Rating + +Amethyst is rated 17+. The app does not knowingly collect information from children and has no account-creation flow that targets minors. We rely on Google Play's age-gating to restrict downloads to users 17+. ## Terms of Use diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 4988276dde..ba17fa04d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -77,6 +78,7 @@ fun AllSettingsScreen( nav: INav, ) { val context = LocalContext.current + val uriHandler = LocalUriHandler.current val scope = rememberCoroutineScope() var showResetMarmotDialog by remember { mutableStateOf(false) } var isResettingMarmot by remember { mutableStateOf(false) } @@ -261,6 +263,32 @@ fun AllSettingsScreen( ) } + SettingsSection(R.string.about_legal) { + SettingsItem( + title = R.string.privacy_policy, + icon = MaterialSymbols.Lock, + onClick = { + runCatching { + uriHandler.openUri( + "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md", + ) + } + }, + ) + SettingsDivider() + SettingsItem( + title = R.string.child_safety_standards, + icon = MaterialSymbols.Shield, + onClick = { + runCatching { + uriHandler.openUri( + "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md#child-safety-standards", + ) + } + }, + ) + } + SettingsSection(R.string.danger_zone, isDanger = true) { if (hasPrivateKey) { SettingsItem( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 827d73674a..e9897200d3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1318,6 +1318,8 @@ Posting policy Privacy Policy Terms & Conditions + Child Safety Standards + About & Legal N/A Errors and Notices from this Relay Relay Monitor Reports From e0c3b187316352afdff60b61b13a27aef9b0511b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:12:34 +0000 Subject: [PATCH 04/33] ci: add iOS test job for quartz + commonMain purity gate Phase 1 of the iOS support plan (amethyst/plans/2026-05-24-ios-support.md). Two independent guards so JVM-only imports can't silently appear in quartz's iOS-bound source sets: - :quartz:verifyKmpPurity (Linux, ~1s): scans commonMain + apple/native source sets for com.fasterxml.jackson / okhttp3 references and fails the build with a clear pointer to the offending file:line. Wired into the existing lint job so it runs on every PR. - test-quartz-ios (macos-latest): runs :quartz:iosSimulatorArm64Test on the simulator (NIP-04, NIP-17, NIP-19, NIP-49 vectors + AES-GCM and chatroom-key tests already in quartz/src/iosTest) and additionally compileTestKotlinIosArm64 to catch device-variant compile drift. --- .github/workflows/build.yml | 49 +++++++++++++++++++++++++++++++++- quartz/build.gradle.kts | 53 +++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c158668108..225b78c5e8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,7 @@ jobs: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - name: Linter (gradle) - run: ./gradlew spotlessCheck + run: ./gradlew spotlessCheck :quartz:verifyKmpPurity build-desktop: needs: lint @@ -186,6 +186,53 @@ jobs: name: ${{ matrix.desktop-artifact-name }} path: ${{ matrix.desktop-artifact-path }} + test-quartz-ios: + # Phase 1 of the iOS support plan + # (amethyst/plans/2026-05-24-ios-support.md): keep :quartz green on iOS + # so JVM-only imports can't sneak into commonMain unnoticed. The + # `verifyKmpPurity` task in the lint job is the fast pre-check (Linux, + # ~1s); this job is the real one — compiles for the device variant + # and actually runs the simulator test suite. + needs: lint + runs-on: macos-latest + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + # Two tasks, two purposes: + # - iosSimulatorArm64Test runs the existing iosTest suite on the + # simulator (NIP-04 / NIP-17 / NIP-19 / NIP-49 / AES-GCM / + # Chatroom keys), exercising secp256k1 and CryptoKit-backed + # primitives on a real Apple toolchain. + # - compileTestKotlinIosArm64 catches any device-only compile drift + # (iosArm64 = aarch64-apple-ios) without needing a physical + # device to run on. Compile-only is enough — running on-device + # would require xcodebuild + a provisioning profile. + - name: Test Quartz on iOS + run: | + ./gradlew \ + :quartz:iosSimulatorArm64Test \ + :quartz:compileTestKotlinIosArm64 + + - name: Upload iOS Test Reports + uses: actions/upload-artifact@v7 + if: failure() + with: + name: Quartz iOS Test Reports + path: quartz/build/reports + test-and-build-android: needs: lint runs-on: ubuntu-latest diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index a654fcbebb..121dd4a2f7 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -358,6 +358,59 @@ dependencies { "jvmAndroidTestImplementation"(testFixtures(project(":geode"))) } +// iOS purity gate. Jackson and OkHttp are JVM-only and must never appear +// in source sets that compile for iOS / native / common targets. The KMP +// build would eventually fail with an "unresolved reference" if these +// leaked in, but that requires a macOS runner. This task is a fast Linux +// linter that fails with a clear message before iOS CI ever spins up. +// +// Scope: source sets whose code is compiled for at least one non-JVM +// target. Excludes jvmAndroid, jvmMain, androidMain (and their tests), +// where Jackson and OkHttp are legitimately used. +val verifyKmpPurity by tasks.registering { + group = "verification" + description = "Fails if iOS-targeted source sets import JVM-only deps." + val checkedDirs = + listOf( + "src/commonMain", "src/commonTest", + "src/appleMain", "src/appleTest", + "src/nativeMain", "src/nativeTest", + "src/iosMain", "src/iosTest", + "src/iosArm64Main", "src/iosArm64Test", + "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test", + "src/linuxMain", "src/linuxTest", + "src/linuxX64Main", "src/linuxX64Test", + "src/macosMain", "src/macosTest", + "src/macosArm64Main", "src/macosArm64Test", + ).map { layout.projectDirectory.dir(it).asFile } + .filter { it.exists() } + inputs.files(checkedDirs) + doLast { + val forbidden = listOf("com.fasterxml.jackson", "okhttp3") + val offenders = + checkedDirs.flatMap { dir -> + dir.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + file.readLines().withIndex().mapNotNull { (idx, line) -> + forbidden.firstOrNull { line.contains(it) }?.let { hit -> + "${file.relativeTo(rootDir)}:${idx + 1}: '$hit'" + } + } + } + } + if (offenders.isNotEmpty()) { + throw GradleException( + "iOS-targeted source sets must not reference JVM-only deps " + + "(Jackson, OkHttp). Move the offending code to jvmAndroid/ " + + "or behind an expect/actual:\n " + offenders.joinToString("\n "), + ) + } + } +} + +tasks.named("check").configure { dependsOn(verifyKmpPurity) } + mavenPublishing { // sources publishing is always enabled by the Kotlin Multiplatform plugin configure( From c3e03308f860b36bb97df077a42e3ed35315850c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:15:34 +0000 Subject: [PATCH 05/33] docs: clarify Child Safety Standards are a policy, not a license restriction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-Droid requires that apps add no restrictions to the FOSS license that ships with the source. The previous wording ("Amethyst strictly prohibits the use of the app to...") could be read as an EULA clause that restricts use beyond what the MIT LICENSE grants. Reframe the prohibition as a published community standard / acceptable- use policy — which is exactly what Google Play's Child Safety Standards policy requires anyway — and add an explicit "Free Software License" note clarifying that the MIT license terms in LICENSE are unchanged, and that F-Droid users and source redistributors retain every right granted by MIT. Google Play's requirements remain satisfied: the prohibition of CSAE is still explicit, the in-app reporting mechanism is documented, the child-safety point of contact and NCMEC escalation path are unchanged, and the app/developer name is still referenced. --- PRIVACY.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index cc4dafdf8d..e70ea00d30 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -35,17 +35,19 @@ Information shared on Nostr should be assumed permanent for privacy purposes. Th ## Child Safety Standards -Amethyst, published on Google Play by Vitor Pamplona, is committed to protecting children from sexual abuse and exploitation. These standards apply to the Amethyst app for Android. +These are the published Child Safety Standards for Amethyst, an Android Nostr client developed by Vitor Pamplona and distributed on Google Play. They are published to satisfy Google Play's Child Safety Standards policy and to set out the developer's public position on child safety. + +These Standards are a **community standard and published policy**. They do **not** modify, supersede, or add restrictions to the software license that governs the Amethyst source code; see the **Free Software License** note at the end of this section. ### How Amethyst Works (and Why That Matters Here) Amethyst is a decentralized Nostr client. **The app itself does not host, store, or moderate any user-generated content.** All content is hosted by independent third-party servers called **relays** that the user freely chooses to connect to. Amethyst is a viewer and a publisher; it has no central database, no upload servers, and no ability to delete content from the network. Content moderation, takedowns, and legal reporting are the responsibility of the **relay operators** who actually host the content. -What Amethyst provides — and what these Standards cover — is (1) a clear prohibition of CSAE, (2) in-app tools that let users report content, hide content, and disconnect from abusive relays, and (3) a contact point for escalation. +What these Standards cover is (1) a clear prohibition of CSAE as a community standard, (2) the in-app tools available to users to report content, hide content, and disconnect from abusive relays, and (3) a contact point for escalation. ### Prohibition of Child Sexual Abuse and Exploitation (CSAE) -Amethyst strictly prohibits the use of the app to create, upload, share, solicit, or distribute child sexual abuse and exploitation (CSAE) material, including child sexual abuse material (CSAM), in any form, or to groom, exploit, endanger, or otherwise harm minors. Users who use Amethyst for these purposes are in violation of these Standards and of the laws of essentially every jurisdiction. +These Standards prohibit using Amethyst to create, upload, share, solicit, or distribute child sexual abuse and exploitation (CSAE) material, including child sexual abuse material (CSAM), in any form, or to groom, exploit, endanger, or otherwise harm minors. Users who use Amethyst for these purposes are in violation of these Standards and of the laws of essentially every jurisdiction. ### In-App User Feedback and Reporting Mechanism @@ -85,6 +87,10 @@ Questions, reports of relays hosting CSAE/CSAM, or requests related to child saf Amethyst is rated 17+. The app does not knowingly collect information from children and has no account-creation flow that targets minors. We rely on Google Play's age-gating to restrict downloads to users 17+. +### Free Software License + +These Child Safety Standards are a public statement of the developer's commitments and the published policy that users of Amethyst on Google Play are expected to follow. They are **not** a restriction added to the source code license. Amethyst's source code is licensed under the MIT License (see the `LICENSE` file in the source repository); these Standards do not modify, supersede, or add conditions to that license. All users — including users of builds distributed by F-Droid, by other repositories, or built from source — retain every right granted by the MIT License, including the freedom to use, study, modify, and redistribute the software. + ## Terms of Use ### For versions downloaded from Google's Play Store From f05866234919d4bbf180e2f657ff0779eab77697 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:24:57 +0000 Subject: [PATCH 06/33] fix(fdroid): hide Play-only ToS gate and Privacy/Child-Safety links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-Droid distributes Amethyst as MIT-licensed free software with no acceptable-use terms layered on top — only the Play Store build needs a ToS-acceptance checkbox at login/signup and links to the published Child Safety Standards. Gates added behind `BuildConfig.FLAVOR == "play"`: - Settings → "About & Legal" section (Privacy Policy + Child Safety Standards) is now Play-only; F-Droid settings no longer link to PRIVACY.md. - AcceptTerms checkbox in LoginScreen and SignUpScreen is now Play-only. - LoginViewModel.load() / clear() and SignUpViewModel pre-accept `acceptedTerms` on F-Droid so the login/signup button isn't disabled. The Play build is unchanged: first-time login still requires checking the ToS box, and the About & Legal section still surfaces the published Child Safety Standards link required by Google Play. --- .../loggedIn/settings/AllSettingsScreen.kt | 51 ++++++++++--------- .../ui/screen/loggedOff/login/LoginScreen.kt | 3 +- .../screen/loggedOff/login/LoginViewModel.kt | 5 +- .../screen/loggedOff/signup/SignUpScreen.kt | 23 +++++---- .../loggedOff/signup/SignUpViewModel.kt | 3 +- 5 files changed, 47 insertions(+), 38 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index ba17fa04d9..fa8d8b6568 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -263,30 +264,32 @@ fun AllSettingsScreen( ) } - SettingsSection(R.string.about_legal) { - SettingsItem( - title = R.string.privacy_policy, - icon = MaterialSymbols.Lock, - onClick = { - runCatching { - uriHandler.openUri( - "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md", - ) - } - }, - ) - SettingsDivider() - SettingsItem( - title = R.string.child_safety_standards, - icon = MaterialSymbols.Shield, - onClick = { - runCatching { - uriHandler.openUri( - "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md#child-safety-standards", - ) - } - }, - ) + if (BuildConfig.FLAVOR == "play") { + SettingsSection(R.string.about_legal) { + SettingsItem( + title = R.string.privacy_policy, + icon = MaterialSymbols.Lock, + onClick = { + runCatching { + uriHandler.openUri( + "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md", + ) + } + }, + ) + SettingsDivider() + SettingsItem( + title = R.string.child_safety_standards, + icon = MaterialSymbols.Shield, + onClick = { + runCatching { + uriHandler.openUri( + "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md#child-safety-standards", + ) + } + }, + ) + } } SettingsSection(R.string.danger_zone, isDanger = true) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt index 6aef504782..382a29773d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons @@ -196,7 +197,7 @@ fun LoginPage( ) } - if (loginViewModel.isFirstLogin) { + if (loginViewModel.isFirstLogin && BuildConfig.FLAVOR == "play") { AcceptTerms( checked = loginViewModel.acceptedTerms, onCheckedChange = loginViewModel::updateAcceptedTerms, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt index 35d4bfff56..ef7e79f0bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @@ -68,7 +69,7 @@ class LoginViewModel : ViewModel() { ) { clear() this.isFirstLogin = isFirstLogin - acceptedTerms = !isFirstLogin + acceptedTerms = !isFirstLogin || BuildConfig.FLAVOR != "play" if (newAccountKey != null) { key = TextFieldValue(newAccountKey) } @@ -79,7 +80,7 @@ class LoginViewModel : ViewModel() { password = TextFieldValue("") errorManager.clearErrors() - acceptedTerms = false + acceptedTerms = BuildConfig.FLAVOR != "play" processingLogin = false isTemporary = false offerTemporaryLogin = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt index 62616886ce..5e5c4dd017 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons @@ -187,17 +188,19 @@ fun SignUpPage( }, ) - AcceptTerms( - checked = signUpViewModel.acceptedTerms, - onCheckedChange = signUpViewModel::updateAcceptedTerms, - ) - - if (signUpViewModel.termsAcceptanceIsRequiredError) { - Text( - text = stringRes(R.string.acceptance_of_terms_is_required), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, + if (BuildConfig.FLAVOR == "play") { + AcceptTerms( + checked = signUpViewModel.acceptedTerms, + onCheckedChange = signUpViewModel::updateAcceptedTerms, ) + + if (signUpViewModel.termsAcceptanceIsRequiredError) { + Text( + text = stringRes(R.string.acceptance_of_terms_is_required), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } } Spacer(modifier = Modifier.height(Size10dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt index 6f87efac7a..206878f39c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager @@ -40,7 +41,7 @@ class SignUpViewModel : ViewModel() { var displayName by mutableStateOf(TextFieldValue("")) - var acceptedTerms by mutableStateOf(false) + var acceptedTerms by mutableStateOf(BuildConfig.FLAVOR != "play") var termsAcceptanceIsRequiredError by mutableStateOf(false) fun init(accountSessionManager: AccountSessionManager) { From b27fc34786ae56f7d21afde04db66fcf9f89d082 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:41:00 +0000 Subject: [PATCH 07/33] refactor: migrate FeedDefinitionSerializer to kotlinx.serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one Jackson holdout in commons/commonMain. Migrating it unblocks the iOS purity gate for :commons (Phase 1 of the iOS plan). - Rewrites FeedDefinitionSerializer with the kotlinx.serialization JSON tree API (JsonObject / JsonArray / JsonPrimitive). Wire format is byte-identical, so users' existing on-disk custom-feed definitions keep deserializing — covered by a new parsesLegacyJacksonOutput test that pins a hand-written Jackson-shaped JSON blob. - Adds :commons:verifyKmpPurity (mirrors the one in :quartz) and wires it into the CI lint job alongside :quartz:verifyKmpPurity. - Pulls in kotlinx-serialization-json as a commonMain dep; the serialization plugin was already applied on :commons. --- .github/workflows/build.yml | 2 +- commons/build.gradle.kts | 52 +++++++ .../feeds/custom/FeedDefinitionSerializer.kt | 145 ++++++++++-------- .../custom/FeedDefinitionSerializerTest.kt | 27 ++++ 4 files changed, 164 insertions(+), 62 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 225b78c5e8..d3ecb2b3e0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,7 @@ jobs: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - name: Linter (gradle) - run: ./gradlew spotlessCheck :quartz:verifyKmpPurity + run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity build-desktop: needs: lint diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 2dc92ff7a3..684213eea6 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -75,6 +75,10 @@ kotlin { // Immutable collections api(libs.kotlinx.collections.immutable) + // JSON for custom-feed definitions (KMP — replaces Jackson + // for the one commonMain serializer that was blocking iOS). + implementation(libs.kotlinx.serialization.json) + // Compose Multiplatform Resources implementation(libs.jetbrains.compose.components.resources) @@ -150,3 +154,51 @@ compose.resources { packageOfResClass = "com.vitorpamplona.amethyst.commons.resources" generateResClass = always } + +// iOS purity gate — same shape as :quartz:verifyKmpPurity. See the rationale +// there. Commons gains this gate once FeedDefinitionSerializer.kt has been +// migrated off Jackson; future commonMain code must not reintroduce JVM-only +// JSON / HTTP deps. +val verifyKmpPurity by tasks.registering { + group = "verification" + description = "Fails if iOS-targeted source sets import JVM-only deps." + val checkedDirs = + listOf( + "src/commonMain", "src/commonTest", + "src/appleMain", "src/appleTest", + "src/nativeMain", "src/nativeTest", + "src/iosMain", "src/iosTest", + "src/iosArm64Main", "src/iosArm64Test", + "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test", + "src/linuxMain", "src/linuxTest", + "src/linuxX64Main", "src/linuxX64Test", + "src/macosMain", "src/macosTest", + "src/macosArm64Main", "src/macosArm64Test", + ).map { layout.projectDirectory.dir(it).asFile } + .filter { it.exists() } + inputs.files(checkedDirs) + doLast { + val forbidden = listOf("com.fasterxml.jackson", "okhttp3") + val offenders = + checkedDirs.flatMap { dir -> + dir.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + file.readLines().withIndex().mapNotNull { (idx, line) -> + forbidden.firstOrNull { line.contains(it) }?.let { hit -> + "${file.relativeTo(rootDir)}:${idx + 1}: '$hit'" + } + } + } + } + if (offenders.isNotEmpty()) { + throw GradleException( + "iOS-targeted source sets must not reference JVM-only deps " + + "(Jackson, OkHttp). Move the offending code to jvmAndroid/ " + + "or behind an expect/actual:\n " + offenders.joinToString("\n "), + ) + } + } +} + +tasks.named("check").configure { dependsOn(verifyKmpPurity) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt index 397491563b..918cab2458 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt @@ -20,29 +20,37 @@ */ package com.vitorpamplona.amethyst.commons.feeds.custom -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.node.ArrayNode -import com.fasterxml.jackson.databind.node.ObjectNode import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put object FeedDefinitionSerializer { - private val mapper = ObjectMapper() + private val json = Json { ignoreUnknownKeys = true } fun serializeList(feeds: List): String { - val array = mapper.createArrayNode() - feeds.forEach { feed -> array.add(serializeFeed(feed)) } - return mapper.writeValueAsString(array) + val array = + buildJsonArray { + feeds.forEach { add(serializeFeed(it)) } + } + return json.encodeToString(JsonArray.serializer(), array) } - fun deserializeList(json: String): List { - if (json.isBlank()) return emptyList() - val array = mapper.readTree(json) as? ArrayNode ?: return emptyList() - return array.mapNotNull { node -> deserializeFeed(node) } + fun deserializeList(jsonString: String): List { + if (jsonString.isBlank()) return emptyList() + val array = json.parseToJsonElement(jsonString) as? JsonArray ?: return emptyList() + return array.mapNotNull { node -> (node as? JsonObject)?.let { deserializeFeed(it) } } } - private fun serializeFeed(feed: FeedDefinition): ObjectNode = - mapper.createObjectNode().apply { + private fun serializeFeed(feed: FeedDefinition): JsonObject = + buildJsonObject { put("id", feed.id) put("name", feed.name) put("emoji", feed.emoji) @@ -50,25 +58,25 @@ object FeedDefinitionSerializer { put("pinOrder", feed.pinOrder) put("refreshMode", feed.refreshMode.name) put("createdAt", feed.createdAt) - set("source", serializeSource(feed.source)) + put("source", serializeSource(feed.source)) } - private fun deserializeFeed(node: JsonNode): FeedDefinition? { - val id = node.get("id")?.asText() ?: return null - val name = node.get("name")?.asText() ?: return null - val emoji = node.get("emoji")?.asText() ?: "" - val pinned = node.get("pinned")?.asBoolean() ?: false - val pinOrder = node.get("pinOrder")?.asInt() ?: Int.MAX_VALUE + private fun deserializeFeed(node: JsonObject): FeedDefinition? { + val id = node.string("id") ?: return null + val name = node.string("name") ?: return null + val emoji = node.string("emoji") ?: "" + val pinned = node.bool("pinned") ?: false + val pinOrder = node.int("pinOrder") ?: Int.MAX_VALUE val refreshMode = - node.get("refreshMode")?.asText()?.let { + node.string("refreshMode")?.let { try { RefreshMode.valueOf(it) } catch (_: Exception) { RefreshMode.LIVE_STREAM } } ?: RefreshMode.LIVE_STREAM - val createdAt = node.get("createdAt")?.asLong() ?: 0L - val source = node.get("source")?.let { deserializeSource(it) } ?: return null + val createdAt = node.long("createdAt") ?: 0L + val source = (node["source"] as? JsonObject)?.let { deserializeSource(it) } ?: return null return FeedDefinition( id = id, @@ -82,17 +90,20 @@ object FeedDefinitionSerializer { ) } - private fun serializeSource(source: FeedSource): ObjectNode = - mapper.createObjectNode().apply { + private fun serializeSource(source: FeedSource): JsonObject = + buildJsonObject { when (source) { is FeedSource.Filter -> { put("type", "filter") - set("hashtags", mapper.valueToTree(source.hashtags.toList())) - set("authors", mapper.valueToTree(source.authors.toList())) - set("relays", mapper.valueToTree(source.relays.toList())) - set("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList())) - set("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList())) - set("kinds", mapper.valueToTree(source.kinds.toList())) + put("hashtags", stringArray(source.hashtags)) + put("authors", stringArray(source.authors)) + put("relays", stringArray(source.relays)) + put("excludeAuthors", stringArray(source.excludeAuthors)) + put("excludeKeywords", stringArray(source.excludeKeywords)) + put( + "kinds", + buildJsonArray { source.kinds.forEach { add(JsonPrimitive(it)) } }, + ) } is FeedSource.PeopleList -> { @@ -131,61 +142,73 @@ object FeedDefinitionSerializer { } } - private fun deserializeSource(node: JsonNode): FeedSource? { - val type = node.get("type")?.asText() ?: return null + private fun deserializeSource(node: JsonObject): FeedSource? { + val type = node.string("type") ?: return null return when (type) { "filter" -> { FeedSource.Filter( - hashtags = node.get("hashtags")?.map { it.asText() }?.toImmutableList() ?: return null, - authors = node.get("authors")?.map { it.asText() }?.toImmutableList() ?: return null, - relays = node.get("relays")?.map { it.asText() }?.toImmutableList() ?: return null, - excludeAuthors = node.get("excludeAuthors")?.map { it.asText() }?.toImmutableList() ?: return null, - excludeKeywords = node.get("excludeKeywords")?.map { it.asText() }?.toImmutableList() ?: return null, - kinds = node.get("kinds")?.map { it.asInt() }?.toImmutableList() ?: return null, + hashtags = node.stringList("hashtags") ?: return null, + authors = node.stringList("authors") ?: return null, + relays = node.stringList("relays") ?: return null, + excludeAuthors = node.stringList("excludeAuthors") ?: return null, + excludeKeywords = node.stringList("excludeKeywords") ?: return null, + kinds = node.intList("kinds") ?: return null, ) } "people_list" -> { FeedSource.PeopleList( - kind = node.get("kind")?.asInt() ?: 30000, - pubkey = node.get("pubkey")?.asText() ?: return null, - dTag = node.get("dTag")?.asText() ?: return null, + kind = node.int("kind") ?: 30000, + pubkey = node.string("pubkey") ?: return null, + dTag = node.string("dTag") ?: return null, ) } "interest_set" -> { FeedSource.InterestSet( - kind = node.get("kind")?.asInt() ?: 30015, - pubkey = node.get("pubkey")?.asText() ?: return null, - dTag = node.get("dTag")?.asText() ?: return null, + kind = node.int("kind") ?: 30015, + pubkey = node.string("pubkey") ?: return null, + dTag = node.string("dTag") ?: return null, ) } "dvm" -> { FeedSource.DVM( - kind = node.get("kind")?.asInt() ?: 31990, - pubkey = node.get("pubkey")?.asText() ?: return null, - dTag = node.get("dTag")?.asText() ?: return null, + kind = node.int("kind") ?: 31990, + pubkey = node.string("pubkey") ?: return null, + dTag = node.string("dTag") ?: return null, ) } "single_relay" -> { FeedSource.SingleRelay( - url = node.get("url")?.asText() ?: return null, + url = node.string("url") ?: return null, ) } - "global" -> { - FeedSource.Global - } - - "following" -> { - FeedSource.Following - } - - else -> { - null - } + "global" -> FeedSource.Global + "following" -> FeedSource.Following + else -> null } } + + private fun stringArray(values: Iterable): JsonArray = buildJsonArray { values.forEach { add(JsonPrimitive(it)) } } + + private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + + private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull + + private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull + + private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull + + private fun JsonObject.stringList(key: String) = + (this[key] as? JsonArray) + ?.map { (it as? JsonPrimitive)?.content.orEmpty() } + ?.toImmutableList() + + private fun JsonObject.intList(key: String) = + (this[key] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.intOrNull } + ?.toImmutableList() } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt index 0badc90af2..b82cce5228 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt @@ -212,6 +212,33 @@ class FeedDefinitionSerializerTest { assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" ")) } + @Test + fun parsesLegacyJacksonOutput() { + // Wire format previously emitted by ObjectMapper. Users have feed + // definitions stored on disk in exactly this shape — the new + // kotlinx.serialization-backed parser must keep accepting it. + val legacy = + """[{"id":"x","name":"Bitcoin","emoji":"₿","pinned":true,"pinOrder":0,""" + + """"refreshMode":"LIVE_STREAM","createdAt":1000,""" + + """"source":{"type":"filter","hashtags":["bitcoin","btc"],"authors":["abc"],""" + + """"relays":[],"excludeAuthors":[],"excludeKeywords":[],"kinds":[1,6]}}]""" + + val feeds = FeedDefinitionSerializer.deserializeList(legacy) + assertEquals(1, feeds.size) + val feed = feeds[0] + assertEquals("x", feed.id) + assertEquals("Bitcoin", feed.name) + assertEquals("₿", feed.emoji) + assertTrue(feed.pinned) + assertEquals(0, feed.pinOrder) + assertEquals(RefreshMode.LIVE_STREAM, feed.refreshMode) + assertEquals(1000L, feed.createdAt) + val source = feed.source as FeedSource.Filter + assertEquals(persistentListOf("bitcoin", "btc"), source.hashtags) + assertEquals(persistentListOf("abc"), source.authors) + assertEquals(persistentListOf(1, 6), source.kinds) + } + @Test fun defaultFeedsAreValid() { val defaults = defaultFeeds() From 2628f4578850ab82f6af4cd04813fc0c8d44288e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:45:48 +0000 Subject: [PATCH 08/33] refactor: move ToS / legal links into flavor source sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Play-Store-only Terms-of-Use checkbox and the "About & Legal" Settings section both contain hardcoded GitHub URLs to the published PRIVACY.md. Previously they were in src/main and gated at call sites by `BuildConfig.FLAVOR == "play"` — which works at runtime but still compiles the GitHub URLs into the F-Droid APK. This commit moves the URL-bearing UI into src/play and adds empty src/fdroid stubs with the same signatures, so the F-Droid build is physically free of the URLs. New composables (same package + signature in both flavor source sets, so callers in src/main link to whichever flavor is being built): - com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate * src/play: renders an AcceptTerms checkbox with the PRIVACY.md link and the "acceptance required" error text. * src/fdroid: empty body. - com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.LegalSettingsSection * src/play: renders the SettingsSection with Privacy Policy and Child Safety Standards rows that open the GitHub-hosted PRIVACY.md. * src/fdroid: empty body. Call-site changes: - LoginScreen: drop `BuildConfig.FLAVOR == "play"` guard and call `TermsGate(...)` inside the existing `isFirstLogin` block. The flavor source set picks the right body. - SignUpScreen: drop `BuildConfig.FLAVOR == "play"` guard, call `TermsGate(...)` unconditionally. - AllSettingsScreen: drop `BuildConfig.FLAVOR == "play"` guard and the inline About & Legal SettingsSection, call `LegalSettingsSection()` instead. LocalUriHandler and BuildConfig imports removed. The old `src/main/.../loggedOff/AcceptTerms.kt` is deleted; its body moves into the play-flavor TermsGate as a file-private helper. The LoginViewModel / SignUpViewModel still use `BuildConfig.FLAVOR` to decide the initial `acceptedTerms` value (so the login/signup button isn't disabled on F-Droid). That check is logic only, contains no URLs, and is safe for F-Droid distribution. Verified: `grep -r "github.com/vitorpamplona/amethyst/blob"` against src/main + src/fdroid returns zero matches. Both :amethyst:compileFdroidDebugKotlin and :amethyst:compilePlayDebugKotlin compile cleanly. --- .../loggedIn/settings/LegalSettingsSection.kt | 29 ++++++++++ .../ui/screen/loggedOff/legal/TermsGate.kt | 35 ++++++++++++ .../loggedIn/settings/AllSettingsScreen.kt | 31 +--------- .../ui/screen/loggedOff/login/LoginScreen.kt | 16 ++---- .../screen/loggedOff/signup/SignUpScreen.kt | 22 ++----- .../loggedIn/settings/LegalSettingsSection.kt | 57 +++++++++++++++++++ .../ui/screen/loggedOff/legal/TermsGate.kt} | 23 +++++++- 7 files changed, 152 insertions(+), 61 deletions(-) create mode 100644 amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt create mode 100644 amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt rename amethyst/src/{main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt => play/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt} (80%) diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt new file mode 100644 index 0000000000..65bf2ce996 --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt @@ -0,0 +1,29 @@ +/* + * 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.ui.screen.loggedIn.settings + +import androidx.compose.runtime.Composable + +// F-Droid distributes Amethyst as MIT-licensed free software; the build must +// not surface links to external (e.g. GitHub-hosted) policy documents. +@Composable +fun LegalSettingsSection() { +} diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt new file mode 100644 index 0000000000..736b2dc0d5 --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt @@ -0,0 +1,35 @@ +/* + * 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.ui.screen.loggedOff.legal + +import androidx.compose.runtime.Composable + +// F-Droid distributes Amethyst as MIT-licensed free software; there is no +// terms-of-use acceptance layered on top of the source license, and the build +// must not link to any external (e.g. GitHub) policy document. +@Composable +@Suppress("UNUSED_PARAMETER") +fun TermsGate( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + showError: Boolean, +) { +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index fa8d8b6568..7e4a5fc789 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -42,11 +42,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -79,7 +77,6 @@ fun AllSettingsScreen( nav: INav, ) { val context = LocalContext.current - val uriHandler = LocalUriHandler.current val scope = rememberCoroutineScope() var showResetMarmotDialog by remember { mutableStateOf(false) } var isResettingMarmot by remember { mutableStateOf(false) } @@ -264,33 +261,7 @@ fun AllSettingsScreen( ) } - if (BuildConfig.FLAVOR == "play") { - SettingsSection(R.string.about_legal) { - SettingsItem( - title = R.string.privacy_policy, - icon = MaterialSymbols.Lock, - onClick = { - runCatching { - uriHandler.openUri( - "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md", - ) - } - }, - ) - SettingsDivider() - SettingsItem( - title = R.string.child_safety_standards, - icon = MaterialSymbols.Shield, - onClick = { - runCatching { - uriHandler.openUri( - "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md#child-safety-standards", - ) - } - }, - ) - } - } + LegalSettingsSection() SettingsSection(R.string.danger_zone, isDanger = true) { if (hasPrivateKey) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt index 382a29773d..eef969c214 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt @@ -66,15 +66,14 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager -import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup +import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20dp @@ -197,19 +196,12 @@ fun LoginPage( ) } - if (loginViewModel.isFirstLogin && BuildConfig.FLAVOR == "play") { - AcceptTerms( + if (loginViewModel.isFirstLogin) { + TermsGate( checked = loginViewModel.acceptedTerms, onCheckedChange = loginViewModel::updateAcceptedTerms, + showError = loginViewModel.termsAcceptanceIsRequiredError, ) - - if (loginViewModel.termsAcceptanceIsRequiredError) { - Text( - text = stringRes(R.string.acceptance_of_terms_is_required), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } } Spacer(modifier = Modifier.height(Size10dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt index 5e5c4dd017..72797b6abe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt @@ -50,13 +50,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager -import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup +import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp @@ -188,20 +187,11 @@ fun SignUpPage( }, ) - if (BuildConfig.FLAVOR == "play") { - AcceptTerms( - checked = signUpViewModel.acceptedTerms, - onCheckedChange = signUpViewModel::updateAcceptedTerms, - ) - - if (signUpViewModel.termsAcceptanceIsRequiredError) { - Text( - text = stringRes(R.string.acceptance_of_terms_is_required), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - } + TermsGate( + checked = signUpViewModel.acceptedTerms, + onCheckedChange = signUpViewModel::updateAcceptedTerms, + showError = signUpViewModel.termsAcceptanceIsRequiredError, + ) Spacer(modifier = Modifier.height(Size10dp)) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt new file mode 100644 index 0000000000..0f17e6ca28 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt @@ -0,0 +1,57 @@ +/* + * 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.ui.screen.loggedIn.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalUriHandler +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols + +@Composable +fun LegalSettingsSection() { + val uriHandler = LocalUriHandler.current + + SettingsSection(R.string.about_legal) { + SettingsItem( + title = R.string.privacy_policy, + icon = MaterialSymbols.Lock, + onClick = { + runCatching { + uriHandler.openUri( + "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md", + ) + } + }, + ) + SettingsDivider() + SettingsItem( + title = R.string.child_safety_standards, + icon = MaterialSymbols.Shield, + onClick = { + runCatching { + uriHandler.openUri( + "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md#child-safety-standards", + ) + } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt rename to amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt index 12cccee79c..fc6edc8f72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.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.ui.screen.loggedOff +package com.vitorpamplona.amethyst.ui.screen.loggedOff.legal import androidx.compose.foundation.layout.Row import androidx.compose.material3.Checkbox @@ -33,9 +33,26 @@ import com.vitorpamplona.amethyst.ui.components.appendLink import com.vitorpamplona.amethyst.ui.stringRes @Composable -fun AcceptTerms( +fun TermsGate( checked: Boolean, - onCheckedChange: ((Boolean) -> Unit)?, + onCheckedChange: (Boolean) -> Unit, + showError: Boolean, +) { + AcceptTerms(checked = checked, onCheckedChange = onCheckedChange) + + if (showError) { + Text( + text = stringRes(R.string.acceptance_of_terms_is_required), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun AcceptTerms( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, ) { Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( From 6f8f5f7d5738ac4bb8dd757a1229e25acce0f543 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:50:45 +0000 Subject: [PATCH 09/33] =?UTF-8?q?docs:=20tighten=20PRIVACY.md=20=E2=80=94?= =?UTF-8?q?=20concise,=20truthful,=20lower-liability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the policy/terms doc with three goals: 1) **Concise & easier to read.** Plain English, short sentences, removed redundant intros (the "How Amethyst Works (and Why That Matters Here)" block restated the Privacy intro), merged the "Visibility" + "Permanence" sections into one paragraph, and collapsed the Child Safety POC section into a single contact block near the top of the document. 2) **More truthful.** Two corrections: - F-Droid build uses UnifiedPush for notifications, not FCM. The previous text only mentioned Google Firebase Cloud Messaging, which was inaccurate for the F-Droid distribution. - Replaced "We rely on Google Play's age verification to make sure the user downloading the app is an adult" with "Amethyst's Google Play listing is rated 17+. The app does not request or store age information." Google Play does not actually verify user age, so the old wording overstated the protection. 3) **Lower liability.** Several specific changes: - Dropped the "We aim to acknowledge child-safety reports within 72 hours" service-level commitment that the solo developer cannot reliably meet. - Softened "we will recommend that the offending relay be removed" and "What we can do: acknowledge the report, forward..." to discretionary "may forward" / "may stop recommending" phrasing. - Removed the absolute "data is strictly confidential and cannot be accessed by other apps" guarantee. Replaced with the narrower, verifiable claim that other apps cannot read app-local storage on a standard, non-rooted Android device. - Narrowed "Amethyst is built and distributed to comply with applicable child safety laws and regulations" to "Amethyst is distributed under Google Play's Child Safety Standards policy and applicable law" — same in spirit, smaller surface for dispute. Content that the Google Play Child Safety Standards checklist requires is unchanged: explicit CSAE prohibition, child-safety point of contact (amethyst@vitorpamplona.com), in-app feedback mechanism (Report Post / Report Account / Block Post / Block Account / Block Relay / Mute), method for addressing CSAM (in-app report → block relay → NCMEC/INHOPE → optional developer notice), compliance statement, and references to the app name "Amethyst" and the Google Play publisher "Vitor Pamplona". The F-Droid carve-out also remains: the MIT License in LICENSE is identified as the only instrument governing source-built distributions, with no additional terms. --- PRIVACY.md | 134 ++++++++++++++++++++++++++++------------------------- 1 file changed, 70 insertions(+), 64 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index e70ea00d30..32208aab91 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,108 +1,114 @@ # Amethyst Privacy Policy and Terms of Use -## Privacy Policy +**App:** Amethyst (Android Nostr client) +**Publisher:** Vitor Pamplona +**Contact:** amethyst@vitorpamplona.com +**Last updated:** 2026-05-24 -Effective as of Jun 12, 2023 +Amethyst is free, open-source software (MIT License — see `LICENSE`). It is not a service. There is no Amethyst server, no Amethyst account, and the developer has no access to data stored on your device. -The Amethyst app for Android does not collect or process any personal information from its users. +Amethyst connects to third-party Nostr **relays** that you choose. Those relays host the content. They are independent of Amethyst, with their own operators and their own policies. -The app is used to browse third-party Nostr servers (called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open-source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them. +This document explains what data leaves your phone, who can see it, and the standards that apply to use of the app. -The app may collect a per-device token, your public key, and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it's required for the functionality and performance of Amethyst. This data is strictly confidential and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app. +## Privacy -Amethyst offers several options for uploading pictures and videos to post online. You can choose the server at your discretion. Similar to relays, such services are independent of the app and have their own privacy policy and terms of use. +### Data sent off-device -### Privacy with Relay services +Using the app causes the following data to leave your phone: -Your Internet Protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g., a VPN) from trackers online. +- **Nostr events** you publish, sent to the relays you have configured. +- **Subscriptions** (filters describing what you want to read), sent to those relays. +- **Media uploads** (images, audio, video), sent to the media server you select. +- *(Google Play build, push notifications enabled)* a per-device push token, your public key, and a preferred relay, registered with Google Firebase Cloud Messaging so a notification proxy can wake the app. +- *(F-Droid build, push notifications enabled)* a per-device token registered with whichever UnifiedPush distributor you install (e.g. ntfy). -The relay can also see which public keys you are using and what information you are requesting from the network. Your public key is tied to your IP address and your relay filters. +The developer does not run any server that aggregates or stores this data. -Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of the content inside Private Zaps and Private DMs. +### Data stored on your device -While the content of direct messages (DMs) is only visible to you and your DM Nostr counterparty, everyone can see when you and your counterparty are DM-ing each other. Image uploads in the DM screen use one of the chosen image servers and simply paste the image link into the DM text. Your uploaded pictures are available to anyone with that direct link. +Configuration, cached events, keys, drafts, and other operational data live in the app's local storage. Other apps cannot read it on a standard, non-rooted Android device. You can wipe it by clearing the app's storage or uninstalling. -### Visibility & Permanence of Your Content on Nostr Relays +### What relays can see -#### Information Visibility +A relay you connect to sees: -Content that you share can be shared with other relays by any user of the network. -The information you share is publicly visible to anyone reading from relays that have access to your information. Your information may also be visible to Nostr users who do not share relays with you. +- Your IP address. +- Your public key. +- The events you publish (posts, reactions, reposts, reports, etc.). +- The filters you subscribe to. -#### Information Permanence +A relay does **not** see the plaintext of: -Information shared on Nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted. +- Private Direct Messages (encrypted to the recipient under NIP-17 / NIP-44). +- Private Zaps. + +A relay can still see *that* you and another user are exchanging DMs even though it cannot read them. To reduce what a relay can correlate to you, route the app over a VPN or Tor. + +### Media uploads + +Uploads go to the media server you select. That server is independent of Amethyst and has its own policy. Anyone holding the resulting link — including media attached to a DM — can fetch the file. + +### Public content is effectively permanent + +Anything you publish to a relay can be copied to other relays or clients. Once published, you should assume it cannot be reliably deleted from the network. ## Child Safety Standards -These are the published Child Safety Standards for Amethyst, an Android Nostr client developed by Vitor Pamplona and distributed on Google Play. They are published to satisfy Google Play's Child Safety Standards policy and to set out the developer's public position on child safety. +These are the published Child Safety Standards for **Amethyst**, the Android Nostr client published on Google Play by **Vitor Pamplona**. They are published under Google Play's Child Safety Standards policy. -These Standards are a **community standard and published policy**. They do **not** modify, supersede, or add restrictions to the software license that governs the Amethyst source code; see the **Free Software License** note at the end of this section. +They are a community standard, not a license restriction. Amethyst's source code remains licensed under the MIT License in `LICENSE`. -### How Amethyst Works (and Why That Matters Here) +### Prohibition -Amethyst is a decentralized Nostr client. **The app itself does not host, store, or moderate any user-generated content.** All content is hosted by independent third-party servers called **relays** that the user freely chooses to connect to. Amethyst is a viewer and a publisher; it has no central database, no upload servers, and no ability to delete content from the network. Content moderation, takedowns, and legal reporting are the responsibility of the **relay operators** who actually host the content. +Using Amethyst to create, upload, share, solicit, or distribute child sexual abuse and exploitation (CSAE) material — including child sexual abuse material (CSAM) — or to groom, exploit, or harm a minor is prohibited and is illegal in essentially every jurisdiction. -What these Standards cover is (1) a clear prohibition of CSAE as a community standard, (2) the in-app tools available to users to report content, hide content, and disconnect from abusive relays, and (3) a contact point for escalation. +### In-app tools -### Prohibition of Child Sexual Abuse and Exploitation (CSAE) +Amethyst provides: -These Standards prohibit using Amethyst to create, upload, share, solicit, or distribute child sexual abuse and exploitation (CSAE) material, including child sexual abuse material (CSAM), in any form, or to groom, exploit, endanger, or otherwise harm minors. Users who use Amethyst for these purposes are in violation of these Standards and of the laws of essentially every jurisdiction. - -### In-App User Feedback and Reporting Mechanism - -Amethyst provides in-app mechanisms for users to flag, hide, and disconnect from harmful content: - -- **Report Post** — use the dropdown menu on any note to report it as illegal content, nudity, impersonation, spam, profanity, or other violations. The report is published as a signed Nostr report event so that relay operators and other clients can act on it. -- **Report Account** — open a user's profile and use the report action to flag the account, with the same publication behavior. -- **Block Post / Block Account** — hide a note or a user locally on your device. -- **Block Relay (NIP-51 Blocked Relay List)** — if a particular relay is hosting CSAE/CSAM or refuses to act on reports, add it to your Blocked Relay List so Amethyst will no longer fetch from or publish to it. This is the strongest tool the app provides: it cuts your client off from servers that won't moderate. -- **Mute Words and Hashtags** — filter out unwanted content from your feeds. +- **Report Post** and **Report Account** — publish a signed Nostr report (including the "Illegal Content" reason) so relays and other clients can act on it. +- **Block Post** / **Block Account** — hide content locally on your device. +- **Block Relay** — add a relay to your NIP-51 Blocked Relay List so the app stops fetching from or publishing to it. This is the strongest tool the app offers against a relay that refuses to moderate. +- **Mute Words / Hashtags** — filter unwanted content from your feeds. ### Addressing CSAM -Because Amethyst does not host content, CSAM cannot be removed by Amethyst — it can only be removed by the relay operator who is actually hosting it, and reported to authorities by that operator under the laws that apply to them (in the United States, 18 U.S.C. §2258A makes hosting providers — not viewer applications — the entities required to report to the National Center for Missing & Exploited Children). +Amethyst does not host content, so the app cannot remove CSAM. Only the relay hosting the content can remove it. In the United States, 18 U.S.C. §2258A makes hosting providers — not viewer applications — the entities required to report to the National Center for Missing & Exploited Children (NCMEC). -If you become aware of CSAM accessible via Amethyst, please: +If you encounter CSAM through Amethyst: -1. **Report the content in-app** (select "Illegal Content") so the report propagates to relays and other clients, and -2. **Block the relay** that is hosting the content using the in-app Blocked Relay List, so your client disconnects from it, and -3. **Report the relay and the material directly to the authorities** who have jurisdiction over the hosting provider — for content reachable from the United States that is the **National Center for Missing & Exploited Children (NCMEC) CyberTipline** at https://report.cybertip.org/. For other jurisdictions see INHOPE members at https://www.inhope.org/. -4. **Optionally email the contact below** with the relay URL and event ID. We cannot remove the content from the relay, but we can amplify the report to other relay operators we know, and where appropriate we will recommend that the offending relay be removed from any default relay list shipped with Amethyst. +1. Report the content in-app and select "Illegal Content." +2. Add the hosting relay to your Blocked Relay List. +3. Report directly to **NCMEC** at https://report.cybertip.org/ (United States) or to an **INHOPE** hotline at https://www.inhope.org/ (other jurisdictions). These bodies can compel the hosting provider to act. +4. You may also email **amethyst@vitorpamplona.com** with the relay URL and event ID. The developer cannot remove content from third-party relays, but may forward the report to relay operators it is in contact with and may stop recommending the offending relay in any list shipped with the app. -### Compliance with Child Safety Laws +### Compliance -Amethyst is built and distributed to comply with applicable child safety laws and regulations, including Google Play's Child Safety Standards policy. Where Amethyst itself is subject to a legal obligation (for example, as a distributor on Google Play), we will cooperate with lawful requests from child-safety authorities. Obligations that attach to the **hosting** of content (such as 18 U.S.C. §2258A NCMEC reporting in the U.S.) apply to the relay operators, not to the viewer application. +Amethyst is distributed under Google Play's Child Safety Standards policy and applicable law. Obligations attached to the **hosting** of content rest with relay operators. -### Child Safety Point of Contact +### Age rating -Questions, reports of relays hosting CSAE/CSAM, or requests related to child safety on Amethyst should be sent to: - -- **Name:** Vitor Pamplona (developer, Amethyst for Android) -- **Email:** amethyst@vitorpamplona.com -- **What we can do:** acknowledge the report, forward it to relay operators we are in contact with, and consider removing the offending relay from any default/suggested relay list shipped with Amethyst. We cannot delete content from third-party relays — that is the relay operator's responsibility. -- **What you should also do:** report directly to NCMEC (https://report.cybertip.org/) or your local INHOPE hotline, who can compel the actual hosting provider to act. - -### Age Rating - -Amethyst is rated 17+. The app does not knowingly collect information from children and has no account-creation flow that targets minors. We rely on Google Play's age-gating to restrict downloads to users 17+. - -### Free Software License - -These Child Safety Standards are a public statement of the developer's commitments and the published policy that users of Amethyst on Google Play are expected to follow. They are **not** a restriction added to the source code license. Amethyst's source code is licensed under the MIT License (see the `LICENSE` file in the source repository); these Standards do not modify, supersede, or add conditions to that license. All users — including users of builds distributed by F-Droid, by other repositories, or built from source — retain every right granted by the MIT License, including the freedom to use, study, modify, and redistribute the software. +Amethyst's Google Play listing is rated 17+. The app does not request or store age information. ## Terms of Use -### For versions downloaded from Google's Play Store +### Google Play build -You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary rights, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling. +You agree not to use the Google Play build of Amethyst to submit Objectionable Content to relays. Objectionable Content includes: -### For versions downloaded from F-Droid +- Sexually explicit material. +- Obscene, defamatory, libelous, slanderous, violent, or unlawful content. +- Content that infringes third-party rights (copyright, trademark, privacy, publicity). +- Content that is deceptive or fraudulent. +- Content promoting illegal drugs, tobacco, firearms, ammunition, or illegal gambling. -We do not control the distribution of the application in F-Droid. Legal matters should be resolved between the user and F-Droid. +These Terms apply only to the Google Play distribution of Amethyst. -## Other Notes +### F-Droid and other source-built distributions -We reserve the right to modify this Privacy Policy and Terms of Use at any time. Any modifications to this document will be effective upon our posting of the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy and Terms of Use indicates your acceptance of the terms of the modified Privacy Policy and/or Terms of Use. +The MIT License in `LICENSE` is the only instrument governing your right to use, study, modify, and redistribute the software. No additional terms are imposed on these builds. Any dispute over distribution through F-Droid is between you and F-Droid. -If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com +## Updates + +This document may change. The current version is published at https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md. From 24509554787254df10184737bfc1c2a41c1da6d5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 24 May 2026 13:58:57 -0400 Subject: [PATCH 10/33] adjusting format --- PRIVACY.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 32208aab91..08ef1e94ea 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,13 +1,13 @@ # Amethyst Privacy Policy and Terms of Use -**App:** Amethyst (Android Nostr client) -**Publisher:** Vitor Pamplona -**Contact:** amethyst@vitorpamplona.com +**App:** Amethyst (Android Nostr client)
+**Publisher:** Vitor Pamplona
+**Contact:** amethyst@vitorpamplona.com
**Last updated:** 2026-05-24 Amethyst is free, open-source software (MIT License — see `LICENSE`). It is not a service. There is no Amethyst server, no Amethyst account, and the developer has no access to data stored on your device. -Amethyst connects to third-party Nostr **relays** that you choose. Those relays host the content. They are independent of Amethyst, with their own operators and their own policies. +Amethyst lets you browse content from third-party Nostr **relays** that you choose. Those relays host the content. They are independent of Amethyst, with their own operators and their own policies. This document explains what data leaves your phone, who can see it, and the standards that apply to use of the app. @@ -33,7 +33,7 @@ Configuration, cached events, keys, drafts, and other operational data live in t A relay you connect to sees: -- Your IP address. +- Your IP address (or the Tor exit node when using it). - Your public key. - The events you publish (posts, reactions, reposts, reports, etc.). - The filters you subscribe to. From 78ef4fa67235ea26ebd0e52c5be29d28b24ab3a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 18:07:25 +0000 Subject: [PATCH 11/33] refactor: migrate Base64Image off java.util.Base64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the iOS plan — first of ~9 small migrations to clear the java.* imports out of commons/commonMain. Replaces java.util.Base64 with kotlin.io.encoding.Base64 (stdlib, KMP-clean). The two callers (Android Base64Fetcher, Desktop DesktopBase64Fetcher) use the public parse() signature only, which is unchanged. Also documents the full Phase 2 audit in amethyst/plans/2026-05-24-ios-support.md: out of 335 commonMain files, 21 are real iOS blockers grouped into ~10 small mergeable PRs. The remaining 183 androidx.compose users and 7 androidx.lifecycle users already map to JetBrains Compose Multiplatform / AndroidX KMP and need no work. --- amethyst/plans/2026-05-24-ios-support.md | 47 ++++++++++++++++++- .../amethyst/commons/richtext/Base64Image.kt | 9 ++-- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/amethyst/plans/2026-05-24-ios-support.md b/amethyst/plans/2026-05-24-ios-support.md index 44869b05bb..340b2eeb24 100644 --- a/amethyst/plans/2026-05-24-ios-support.md +++ b/amethyst/plans/2026-05-24-ios-support.md @@ -1,7 +1,7 @@ # iOS Support for Amethyst **Date:** 2026-05-24 -**Status:** Draft — not yet started +**Status:** Phase 1 complete; Phase 2 in flight **Owner:** TBD Plan to incrementally bring Amethyst to iOS by extending the existing @@ -166,6 +166,51 @@ shared ViewModel and state class. Kotlin/Native by default — code that explicitly references it needs a `KmpDispatchers.IO` shim. Audit before Phase 2 starts. +### Phase 2 audit (2026-05-24): commons/commonMain iOS-blocker inventory + +Audit of all 335 .kt files in `commons/src/commonMain/`. Better than feared +— most files are already KMP-clean. The actual blockers are 21 files +across ~6 distinct concerns. Each row below is a small mergeable PR. + +**By blocker category:** + +| Blocker | Files | Fix | +|---|---|---| +| `java.util.Base64` | 1 (`Base64Image.kt`) | `kotlin.io.encoding.Base64` (stdlib since 1.8) | +| `AtomicLong` / `AtomicInteger` | 2 (`ChessLobbyState.kt`, `SigningState.kt`) | `kotlinx.atomicfu.atomic` | +| `ConcurrentHashMap` | 4 (`ChessRelayFetchHelper.kt`, `ChessEventCollector.kt`, `ComposeSubscriptionManager.kt`, `MutableComposeSubscriptionManager.kt`) | `androidx.collection.MutableScatterMap` (KMP) — synchronization most likely already provided by enclosing scope; audit per file | +| `SortedSet` + `ConcurrentSkipListSet` | 2 (`EventListMatchingFilter.kt`, `NoteListMatchingFilter.kt`) | Switch to `mutableListOf` + sort-on-access, or `androidx.collection.MutableScatterSet` with manual order | +| `WeakReference` | 5 (`Channel.kt`, `Chatroom.kt`, `MarmotGroupChatroom.kt`, `UserRelaysCache.kt`, **+1**) | `expect class KmpWeakReference` actuals: JVM `java.lang.ref.WeakReference`; iOS `kotlin.native.ref.WeakReference` | +| `BigDecimal` | 1 (`Note.kt`) | Either KMP bignum lib (`com.ionspin:bignum`) or move the BigDecimal-using helper to `jvmAndroid` and stub on iOS | +| `java.io.File` | 1 (`MediaContentModels.kt`) | Replace with `String` path, or `okio.Path` | +| `java.net.URI` / `MalformedURLException` | 2 (`RichTextParser.kt`, `UrlInfoItem.kt`) | KMP URL lib (`io.ktor:ktor-http`) or stay JVM via expect/actual `parseUrl()` | +| `java.nio.charset.Charset` | 1 (`HtmlCharsetParser.kt`) | `kotlin.text.Charsets` for UTF-8/16; for arbitrary charsets, expect/actual | +| `:nestsClient` project dep | 2 (`NestViewModel.kt`, `ActiveSubscription.kt`) | Move both files to `jvmAndroid` source set (audio rooms are Phase 5 anyway) | +| `com.halilibo.richtext.*` | 1 (`RenderMarkdown.kt`) | Verify iOS artifact; if missing, move to `jvmAndroid` until Phase 3 markdown decision | + +**Files that look scary but aren't:** +- 183 files import `androidx.compose.*` — these all map to JetBrains Compose + Multiplatform's iOS artifacts (identical package paths). No work needed. +- 7 files import `androidx.lifecycle.*` — KMP since 2.8.0. No work needed. +- 0 files import `coil3.network.okhttp` (Coil network is already isolated). +- 0 files import `javax.*` or `android.*` directly from commonMain. + +**Recommended PR order** (ascending cost, descending obviousness): + +1. ✅ Phase 1 complete (gates + iOS CI for quartz, Jackson migration). +2. **Base64** (1 file, ~2 LOC change). Demonstrates the pattern. +3. **Atomics** (2 files, atomicfu plugin + ~10 LOC). +4. **ConcurrentHashMap** (4 files; needs concurrency audit per file). +5. **WeakReference** (5 files + 1 new expect/actual). +6. **`:nestsClient` files → jvmAndroid** (2 files; pure source-set move). +7. **`RenderMarkdown.kt` → jvmAndroid OR iOS verification** (1 file; depends on lib check). +8. **URL parsing** (2 files; either ktor-http dep or expect/actual). +9. **Charsets, BigDecimal, File** (3 files; small per-file decisions). +10. After ~9 lands: add iOS targets to `:commons`, expect failures to be down + to ~zero, run `compileKotlinIosSimulatorArm64` to confirm. +11. Then proceed with the original Phase 2 plan items (Ktor for HTTP, + SecureKeyStore expect/actual, etc.) for the cross-cutting deps. + --- ## Phase 3 — Compose Multiplatform UI on iOS diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt index 342f0b6205..f64d8872df 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt @@ -20,19 +20,20 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import java.util.Base64 +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi object Base64Image { val pattern = Patterns.BASE64_IMAGE fun isBase64(content: String): Boolean = Patterns.BASE64_IMAGE.matches(content) + @OptIn(ExperimentalEncodingApi::class) fun parse(content: String): ByteArray { val matcher = pattern.find(content) if (matcher != null) { - val base64String = matcher.groups[2]?.value - val byteArray = Base64.getDecoder().decode(base64String) - return byteArray + val base64String = matcher.groups[2]?.value ?: "" + return Base64.decode(base64String) } throw Exception("Unable to convert base64 to image $content") From 1b6b699d76f4f6b6336bd704ef889be353cf733c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 18:11:20 +0000 Subject: [PATCH 12/33] refactor: drop java.util.concurrent atomics from commonMain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the iOS plan — two of the ~9 small migrations to clear java.* imports out of commons/commonMain. - ChessLobbyState: the AtomicLong stateVersionCounter only existed to bump a MutableStateFlow. MutableStateFlow.update is itself atomic, so the counter is redundant — replaced with _stateVersion.update { it + 1 }. Removes the dep and simplifies the code. - SigningState (GlobalSigningStatus): AtomicInteger is doing real cross-thread coordination. Migrated to kotlin.concurrent.atomics. AtomicInt (KMP stdlib). The common-API method names differ from AtomicInteger — addAndFetch(±1) / store(0) instead of incrementAndGet / decrementAndGet / set. --- .../amethyst/commons/chess/ChessLobbyState.kt | 15 ++++++--------- .../amethyst/commons/ui/signing/SigningState.kt | 14 ++++++++------ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt index f60b777d32..6323e99b99 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import java.util.concurrent.atomic.AtomicLong /** * Challenge expiry: 24 hours @@ -245,9 +244,9 @@ class ChessLobbyState( private val _selectedGameId = MutableStateFlow(null) val selectedGameId: StateFlow = _selectedGameId.asStateFlow() - // State version counter - increments on every game state update - // UI can observe this to force recomposition when internal state changes - private val stateVersionCounter = AtomicLong(0) + // State version counter — bumped on every game state update so the UI + // can force recomposition when internal state changes. MutableStateFlow.update + // is itself atomic, so a separate counter is not needed. private val _stateVersion = MutableStateFlow(0L) val stateVersion: StateFlow = _stateVersion.asStateFlow() @@ -383,13 +382,11 @@ class ChessLobbyState( if (inActiveGames) { _activeGames.update { it + (gameId to stateToUse) } - // Increment version to force UI recomposition even if map equals() returns true - val newVersion = stateVersionCounter.incrementAndGet() - _stateVersion.value = newVersion + // Bump version to force UI recomposition even if map equals() returns true + _stateVersion.update { it + 1 } } else if (inSpectatingGames) { _spectatingGames.update { it + (gameId to stateToUse) } - val newVersion = stateVersionCounter.incrementAndGet() - _stateVersion.value = newVersion + _stateVersion.update { it + 1 } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt index 743cc70398..c6a8752554 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt @@ -27,7 +27,8 @@ import androidx.compose.runtime.setValue import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.coroutines.cancellation.CancellationException sealed class SigningOpState { @@ -44,26 +45,27 @@ sealed class SigningOpState { * Global signing status — any [SigningState] instance updates this when signing starts/ends. * Observe [globalState] from a screen-level composable to show a persistent status bar. */ +@OptIn(ExperimentalAtomicApi::class) object GlobalSigningStatus { var globalState by mutableStateOf(SigningOpState.Idle) private set - private val activeCount = AtomicInteger(0) + private val activeCount = AtomicInt(0) fun onPending() { - activeCount.incrementAndGet() + activeCount.addAndFetch(1) globalState = SigningOpState.Pending } fun onIdle() { - if (activeCount.decrementAndGet() <= 0) { - activeCount.set(0) + if (activeCount.addAndFetch(-1) <= 0) { + activeCount.store(0) globalState = SigningOpState.Idle } } fun onError(message: String) { - activeCount.decrementAndGet() + activeCount.addAndFetch(-1) globalState = SigningOpState.Error(message) } } From bf6467cdcfd1afc908a7059b64453147c3bd1374 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 18:24:22 +0000 Subject: [PATCH 13/33] refactor: drop ConcurrentHashMap from commonMain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the iOS plan — clears the ConcurrentHashMap blockers from commons/commonMain. Five files migrated (the four flagged in the initial audit + ChessLobbyLogic, which used fully-qualified inline java.util references that the import-based audit missed). Adds co.touchlab:stately-concurrent-collections 2.1.0 — a small, mature KMP library that provides ConcurrentMutableMap / ConcurrentMutableSet with semantics equivalent to ConcurrentHashMap / ConcurrentHashMap.newKeySet on every Kotlin target. The .block { } helper covers the compound-update paths (ChessRelayFetchHelper's per-relay event-count compute, ChessLobbyLogic's bounded-LRU dedup). - ComposeSubscriptionManager + MutableComposeSubscriptionManager: ConcurrentHashMap -> ConcurrentMutableMap - ChessEventCollector + ChessEventCollectorManager: map and Set - ChessRelayFetchHelper: in-function event/relay state - ChessLobbyLogic: replaces dismissedGameIds (synchronizedSet), recentlyLoadedGames (ConcurrentHashMap), seenEventIds (bounded LRU using LinkedHashSet via Collections.synchronizedSet + synchronized {}). seenEventIds keeps insertion-order eviction semantics because mutableSetOf returns LinkedHashSet on every KMP target. --- commons/build.gradle.kts | 7 +++ .../commons/chess/ChessEventCollector.kt | 9 ++-- .../amethyst/commons/chess/ChessLobbyLogic.kt | 44 ++++++++++++------- .../commons/chess/ChessRelayFetchHelper.kt | 16 ++++--- .../ComposeSubscriptionManager.kt | 4 +- .../MutableComposeSubscriptionManager.kt | 4 +- gradle/libs.versions.toml | 2 + 7 files changed, 56 insertions(+), 30 deletions(-) diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 684213eea6..acfd45e211 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -72,6 +72,13 @@ kotlin { // LruCache (KMP-ready) implementation(libs.androidx.collection) + // KMP-friendly concurrent map/set. Replaces ConcurrentHashMap + // for code that genuinely needs cross-thread atomic put/remove + // (chess event collector, relay subscription managers). On JVM + // it wraps mutableMapOf with synchronized; on Native it uses + // platform mutex. No new gradle plugin required. + implementation(libs.stately.concurrent.collections) + // Immutable collections api(libs.kotlinx.collections.immutable) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt index 9c9b0e6761..2f5db8dffb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.chess +import co.touchlab.stately.collections.ConcurrentMutableMap +import co.touchlab.stately.collections.ConcurrentMutableSet import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents @@ -29,7 +31,6 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import java.util.concurrent.ConcurrentHashMap /** * Collects and aggregates Jester chess events for a game from any source. @@ -68,10 +69,10 @@ class ChessEventCollector( val startEvent: StateFlow = _startEvent.asStateFlow() // Move events (deduplicated by event ID) - private val moves = ConcurrentHashMap() + private val moves = ConcurrentMutableMap() // Track all processed event IDs for fast deduplication - private val processedEventIds = ConcurrentHashMap.newKeySet() + private val processedEventIds = ConcurrentMutableSet() // Flow that emits when any event is added (for reactive updates) private val _eventCount = MutableStateFlow(0) @@ -218,7 +219,7 @@ class ChessEventCollector( * such as in a chess lobby or when spectating multiple games. */ class ChessEventCollectorManager { - private val collectors = ConcurrentHashMap() + private val collectors = ConcurrentMutableMap() /** * Get or create a collector for a game. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 8735c8ec09..e49849a0f4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.chess +import co.touchlab.stately.collections.ConcurrentMutableMap +import co.touchlab.stately.collections.ConcurrentMutableSet import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color @@ -129,18 +131,20 @@ class ChessLobbyLogic( ) { val state = ChessLobbyState(userPubkey, scope) - private val dismissedGameIds: MutableSet = - java.util.Collections.synchronizedSet( - dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf(), - ) + private val dismissedGameIds = + ConcurrentMutableSet().apply { + dismissedStorage?.load(userPubkey)?.let { addAll(it) } + } // Track when games were last loaded to prevent duplicate fetches // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it) - private val recentlyLoadedGames = java.util.concurrent.ConcurrentHashMap() + private val recentlyLoadedGames = ConcurrentMutableMap() - // Dedup incoming events (same event delivered by multiple relays) - // Bounded LRU: evict oldest when exceeding capacity - private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet()) + // Dedup incoming events (same event delivered by multiple relays). + // Bounded LRU: evict oldest when exceeding capacity. ConcurrentMutableSet + // wraps mutableSetOf() (LinkedHashSet on every KMP target), so iterator + // order is insertion order — required for LRU eviction below. + private val seenEventIds = ConcurrentMutableSet() private val seenEventIdsMax = 500 private val pollingDelegate = @@ -207,15 +211,21 @@ class ChessLobbyLogic( if (!event.isStartEvent() && !event.isMoveEvent()) return // Dedup: skip if we already processed this event ID (multiple relays deliver same event) - synchronized(seenEventIds) { - if (!seenEventIds.add(event.id)) return - if (seenEventIds.size > seenEventIdsMax) { - seenEventIds.iterator().let { - it.next() - it.remove() + val isNew = + seenEventIds.block { + if (!seenEventIds.add(event.id)) { + false + } else { + if (seenEventIds.size > seenEventIdsMax) { + seenEventIds.iterator().let { + it.next() + it.remove() + } + } + true } } - } + if (!isNew) return Log.d("chessdebug") { "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}" } when { @@ -952,14 +962,14 @@ class ChessLobbyLogic( fun dismissCompletedGame(gameId: String) { state.removeCompletedGame(gameId) dismissedGameIds.add(gameId) - dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds)) + dismissedStorage?.save(userPubkey, dismissedGameIds.toSet()) } fun dismissAllCompletedGames() { val allIds = state.completedGames.value.map { it.gameId } state.clearCompletedGames() dismissedGameIds.addAll(allIds) - dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds)) + dismissedStorage?.save(userPubkey, dismissedGameIds.toSet()) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt index 703ab3c985..6cfb0971e3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.chess +import co.touchlab.stately.collections.ConcurrentMutableMap +import co.touchlab.stately.collections.ConcurrentMutableSet import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener @@ -28,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeoutOrNull -import java.util.concurrent.ConcurrentHashMap /** * Progress callback for relay fetch operations @@ -74,10 +75,10 @@ class ChessRelayFetchHelper( ): List { if (filters.isEmpty()) return emptyList() - val events = ConcurrentHashMap() + val events = ConcurrentMutableMap() val relayCount = filters.keys.size - val eoseReceived = ConcurrentHashMap.newKeySet() - val relayEventCounts = ConcurrentHashMap() + val eoseReceived = ConcurrentMutableSet() + val relayEventCounts = ConcurrentMutableMap() val allEose = CompletableDeferred() val subId = newSubId() @@ -96,7 +97,12 @@ class ChessRelayFetchHelper( forFilters: List?, ) { events[event.id] = event - val count = relayEventCounts.compute(relay) { _, v -> (v ?: 0) + 1 } ?: 1 + val count = + relayEventCounts.block { + val newVal = (relayEventCounts[relay] ?: 0) + 1 + relayEventCounts[relay] = newVal + newVal + } onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count)) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt index 06851efead..4e66aee673 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers import androidx.compose.runtime.Stable -import java.util.concurrent.ConcurrentHashMap +import co.touchlab.stately.collections.ConcurrentMutableMap /** * This allows composables to directly register their queries @@ -32,7 +32,7 @@ import java.util.concurrent.ConcurrentHashMap abstract class ComposeSubscriptionManager : ComposeSubscriptionManagerControls, Subscribable { - private var composeSubscriptions: ConcurrentHashMap = ConcurrentHashMap() + private val composeSubscriptions = ConcurrentMutableMap() // This is called by main. Keep it really fast. override fun subscribe(query: T?) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt index da8e82276a..61e6d46ce7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt @@ -21,12 +21,12 @@ package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers import androidx.compose.runtime.Stable +import co.touchlab.stately.collections.ConcurrentMutableMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap /** * This allows composables to directly register their queries @@ -41,7 +41,7 @@ import java.util.concurrent.ConcurrentHashMap abstract class MutableComposeSubscriptionManager( val scope: CoroutineScope, ) : ComposeSubscriptionManagerControls { - private var composeSubscriptions: ConcurrentHashMap = ConcurrentHashMap() + private val composeSubscriptions = ConcurrentMutableMap() // This is called by main. Keep it really fast. fun subscribe(query: T?) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24ca396591..c33aeb00e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ zelory = "3.0.1" zoomable = "2.11.1" vlcj = "4.8.3" commonsImaging = "1.0.0-alpha6" +statelyConcurrent = "2.1.0" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" @@ -100,6 +101,7 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-runtime-annotation = { group = "androidx.compose.runtime", name = "runtime-annotation", version.ref = "composeRuntimeAnnotation" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" } +stately-concurrent-collections = { group = "co.touchlab", name = "stately-concurrent-collections", version.ref = "statelyConcurrent" } androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "androidxExifinterface" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } From 95beed16e171a45e19bb1cce421f5b51fb07ca0e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 18:30:27 +0000 Subject: [PATCH 14/33] refactor: KMP WeakReference + drop synchronized(this) from commonMain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the iOS plan — clears the java.lang.ref.WeakReference blocker from commons/commonMain. Four model files migrated; one additional sync primitive replaced. - Adds expect class WeakReference in commons/commonMain/util/, with a jvmAndroid actual that typealiases to java.lang.ref.WeakReference. iOS actual will typealias to kotlin.native.ref.WeakReference when the target is added. - Channel / Chatroom / MarmotGroupChatroom: the WeakReference(null) initializer relied on platform-type nullability of java.lang.ref.WeakReference's constructor. With T : Any in the expect class, fields become nullable (WeakReference<...>? = null) and the .get() callsites become ?.get(). Behaviorally equivalent. - UserRelaysCache: same WeakReference migration, plus the synchronized(this) double-checked-locking idiom is replaced with co.touchlab.stately.concurrency.Lock + withLock (KMP). kotlin.synchronized is JVM-only; Lock comes in transitively via stately-concurrent-collections already added in the previous PR. Model-layer @Synchronized usage in Channel/Chatroom/MarmotGroupChatroom/ Note (also JVM-only) is a separate iOS blocker and a separate PR. --- .../amethyst/commons/model/Channel.kt | 14 ++++---- .../model/marmotGroups/MarmotGroupChatroom.kt | 16 ++++----- .../model/nip01Core/UserRelaysCache.kt | 7 ++-- .../commons/model/privateChats/Chatroom.kt | 12 +++---- .../amethyst/commons/util/WeakReference.kt | 34 +++++++++++++++++++ .../commons/util/WeakReference.jvmAndroid.kt | 23 +++++++++++++ 6 files changed, 83 insertions(+), 23 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.jvmAndroid.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt index eac20cda18..af328640c4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt @@ -21,13 +21,13 @@ package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.util.WeakReference import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import java.lang.ref.WeakReference @Stable abstract class Channel : NotesGatherer { @@ -41,10 +41,10 @@ abstract class Channel : NotesGatherer { private var relays = mapOf() - private var changesFlow: WeakReference>> = WeakReference(null) + private var changesFlow: WeakReference>>? = null fun changesFlow(): MutableSharedFlow> { - val current = changesFlow.get() + val current = changesFlow?.get() if (current != null) return current val new = MutableSharedFlow>(0, 10, BufferOverflow.DROP_OLDEST) changesFlow = WeakReference(new) @@ -107,7 +107,7 @@ abstract class Channel : NotesGatherer { addRelay(relay) } - changesFlow.get()?.tryEmit(ListChange.Addition(note)) + changesFlow?.get()?.tryEmit(ListChange.Addition(note)) flowSet?.notes?.invalidateData() } @@ -122,7 +122,7 @@ abstract class Channel : NotesGatherer { lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull() } - changesFlow.get()?.tryEmit(ListChange.Deletion(note)) + changesFlow?.get()?.tryEmit(ListChange.Deletion(note)) flowSet?.notes?.invalidateData() } @@ -140,7 +140,7 @@ abstract class Channel : NotesGatherer { toBeRemoved.forEach { notes.remove(it.idHex) } - changesFlow.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet())) + changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet())) flowSet?.notes?.invalidateData() @@ -156,7 +156,7 @@ abstract class Channel : NotesGatherer { hidden.forEach { notes.remove(it.idHex) } - changesFlow.get()?.tryEmit(ListChange.SetDeletion(hidden)) + changesFlow?.get()?.tryEmit(ListChange.SetDeletion(hidden)) flowSet?.notes?.invalidateData() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index 1404783958..c5befd527b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -26,13 +26,13 @@ import com.vitorpamplona.amethyst.commons.model.Channel.Companion.DefaultFeedOrd import com.vitorpamplona.amethyst.commons.model.ListChange import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NotesGatherer +import com.vitorpamplona.amethyst.commons.util.WeakReference import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import java.lang.ref.WeakReference /** * Represents a Marmot MLS group chat room. @@ -105,10 +105,10 @@ class MarmotGroupChatroom( fun placeholderIdHex(nostrGroupId: HexKey): HexKey = "marmot-empty-$nostrGroupId" } - private var changesFlow: WeakReference>> = WeakReference(null) + private var changesFlow: WeakReference>>? = null fun changesFlow(): MutableSharedFlow> { - val current = changesFlow.get() + val current = changesFlow?.get() if (current != null) return current val new = MutableSharedFlow>(0, 100, BufferOverflow.DROP_OLDEST) changesFlow = WeakReference(new) @@ -131,7 +131,7 @@ class MarmotGroupChatroom( } unreadCount.value += 1 - changesFlow.get()?.tryEmit(ListChange.Addition(msg)) + changesFlow?.get()?.tryEmit(ListChange.Addition(msg)) return true } return false @@ -154,7 +154,7 @@ class MarmotGroupChatroom( newestMessage = msg } - changesFlow.get()?.tryEmit(ListChange.Addition(msg)) + changesFlow?.get()?.tryEmit(ListChange.Addition(msg)) return true } return false @@ -170,7 +170,7 @@ class MarmotGroupChatroom( newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L } } - changesFlow.get()?.tryEmit(ListChange.Deletion(msg)) + changesFlow?.get()?.tryEmit(ListChange.Deletion(msg)) return true } return false @@ -199,7 +199,7 @@ class MarmotGroupChatroom( val toRemove = messages.minus(toKeep) messages = toKeep - changesFlow.get()?.tryEmit(ListChange.SetDeletion(toRemove)) + changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toRemove)) return toRemove } @@ -216,7 +216,7 @@ class MarmotGroupChatroom( messages = emptySet() newestMessage = null unreadCount.value = 0 - changesFlow.get()?.tryEmit(ListChange.SetDeletion(toRemove)) + changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toRemove)) return toRemove } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt index 6717126d48..70007668bf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt @@ -21,10 +21,12 @@ package com.vitorpamplona.amethyst.commons.model.nip01Core import androidx.compose.runtime.Stable +import co.touchlab.stately.concurrency.Lock +import co.touchlab.stately.concurrency.withLock +import com.vitorpamplona.amethyst.commons.util.WeakReference import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import kotlinx.coroutines.flow.MutableStateFlow -import java.lang.ref.WeakReference @Stable data class RelayInfo( @@ -53,9 +55,10 @@ val DefaultOrder = class UserRelaysCache { var data: Map = mapOf() private var flow: WeakReference>? = null + private val flowLock = Lock() fun flow() = - flow?.get() ?: synchronized(this) { + flow?.get() ?: flowLock.withLock { flow?.get() ?: MutableStateFlow(Wrapper(data)).also { flow = WeakReference(it) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 0b732db7cf..f4476777f2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.ListChange import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NotesGatherer import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.util.WeakReference import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject @@ -33,7 +34,6 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import java.lang.ref.WeakReference @Stable class Chatroom : NotesGatherer { @@ -44,10 +44,10 @@ class Chatroom : NotesGatherer { var ownerSentMessage: Boolean = false var newestMessage: Note? = null - private var changesFlow: WeakReference>> = WeakReference(null) + private var changesFlow: WeakReference>>? = null fun changesFlow(): MutableSharedFlow> { - val current = changesFlow.get() + val current = changesFlow?.get() if (current != null) return current val new = MutableSharedFlow>(0, 100, BufferOverflow.DROP_OLDEST) changesFlow = WeakReference(new) @@ -82,7 +82,7 @@ class Chatroom : NotesGatherer { subjectCreatedAt = msg.createdAt() } - changesFlow.get()?.tryEmit(ListChange.Addition(msg)) + changesFlow?.get()?.tryEmit(ListChange.Addition(msg)) return true } @@ -114,7 +114,7 @@ class Chatroom : NotesGatherer { } } - changesFlow.get()?.tryEmit(ListChange.Deletion(msg)) + changesFlow?.get()?.tryEmit(ListChange.Deletion(msg)) return true } @@ -138,7 +138,7 @@ class Chatroom : NotesGatherer { val toRemove = messages.minus(toKeep) messages = toKeep - changesFlow.get()?.tryEmit(ListChange.SetDeletion(toRemove)) + changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toRemove)) return toRemove } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.kt new file mode 100644 index 0000000000..1521301189 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.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.util + +/** + * KMP-friendly weak reference. JVM/Android map to `java.lang.ref.WeakReference`; + * iOS will map to `kotlin.native.ref.WeakReference` when that target is added. + * + * The referent may be reclaimed by GC at any time after this reference is + * constructed — callers must always null-check the result of [get]. + */ +expect class WeakReference( + referent: T, +) { + fun get(): T? +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.jvmAndroid.kt new file mode 100644 index 0000000000..15b46abcaf --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.jvmAndroid.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +actual typealias WeakReference = java.lang.ref.WeakReference From 5c2f93f82f5a0f2eb4a0868a7cee763a1b8d6852 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 21:10:35 +0000 Subject: [PATCH 15/33] refactor: replace stately with LargeCache + KmpLock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: the project already has LargeCache (in quartz, with jvmAndroid/appleMain/linuxMain actuals) as its KMP concurrent-map abstraction — it's used pervasively in the model layer. Adding stately duplicated that capability with an external dep. - Comparable-key maps switch to LargeCache: * ChessEventCollector.moves (String key) * ChessEventCollectorManager.collectors (String key) * ChessRelayFetchHelper.events (String key) * ChessRelayFetchHelper.relayEventCounts: LargeCache with getOrCreate { AtomicInt(0) }.addAndFetch(1) — replaces the stately .block { compute } increment idiom. getOrCreate is atomic via ConcurrentSkipListMap.putIfAbsent so all threads end up incrementing the same AtomicInt instance. * ChessLobbyLogic.recentlyLoadedGames (String key) - The SubscriptionManager pair (MutableComposeSubscriptionManager, ComposeSubscriptionManager) keeps a plain mutableMapOf — T : MutableQueryState is generic and not Comparable, so LargeCache's ConcurrentSkipListMap backing would ClassCastException at put time. Concurrency comes from a KmpLock-guarded map. - Set-shaped uses switch to KmpLock + mutableSetOf: * ChessEventCollector.processedEventIds * ChessRelayFetchHelper.eoseReceived * ChessLobbyLogic.dismissedGameIds + seenEventIds (the bounded LRU keeps insertion-order eviction; mutableSetOf returns LinkedHashSet on every KMP target). - UserRelaysCache.flow's lock: stately Lock -> KmpLock. Adds expect class KmpLock() with jvmAndroid actual that wraps ReentrantLock. iOS actual (NSLock) will land with the iOS target. Mirrors the WeakReference pattern from the previous PR. Drops stately-concurrent-collections 2.1.0 from libs.versions.toml and commons/build.gradle.kts (no remaining consumers). --- commons/build.gradle.kts | 7 -- .../commons/chess/ChessEventCollector.kt | 66 +++++++++++-------- .../amethyst/commons/chess/ChessLobbyLogic.kt | 63 ++++++++++-------- .../commons/chess/ChessRelayFetchHelper.kt | 50 ++++++++------ .../model/nip01Core/UserRelaysCache.kt | 6 +- .../ComposeSubscriptionManager.kt | 22 ++++--- .../MutableComposeSubscriptionManager.kt | 35 ++++++---- .../amethyst/commons/util/KmpLock.kt | 44 +++++++++++++ .../commons/util/KmpLock.jvmAndroid.kt | 31 +++++++++ gradle/libs.versions.toml | 2 - 10 files changed, 219 insertions(+), 107 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.jvmAndroid.kt diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index acfd45e211..684213eea6 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -72,13 +72,6 @@ kotlin { // LruCache (KMP-ready) implementation(libs.androidx.collection) - // KMP-friendly concurrent map/set. Replaces ConcurrentHashMap - // for code that genuinely needs cross-thread atomic put/remove - // (chess event collector, relay subscription managers). On JVM - // it wraps mutableMapOf with synchronized; on Native it uses - // platform mutex. No new gradle plugin required. - implementation(libs.stately.concurrent.collections) - // Immutable collections api(libs.kotlinx.collections.immutable) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt index 2f5db8dffb..da643df359 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt @@ -20,14 +20,15 @@ */ package com.vitorpamplona.amethyst.commons.chess -import co.touchlab.stately.collections.ConcurrentMutableMap -import co.touchlab.stately.collections.ConcurrentMutableSet +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -68,11 +69,15 @@ class ChessEventCollector( private val _startEvent = MutableStateFlow(null) val startEvent: StateFlow = _startEvent.asStateFlow() - // Move events (deduplicated by event ID) - private val moves = ConcurrentMutableMap() + // Move events (deduplicated by event ID). String keys are Comparable, so + // LargeCache (ConcurrentSkipListMap on JVM, CacheMap on Apple) works. + private val moves = LargeCache() - // Track all processed event IDs for fast deduplication - private val processedEventIds = ConcurrentMutableSet() + // Track all processed event IDs for fast deduplication. A plain HashSet + // guarded by KmpLock — simpler than a LargeCache for set-shaped + // membership. + private val processedEventIdsLock = KmpLock() + private val processedEventIds = mutableSetOf() // Flow that emits when any event is added (for reactive updates) private val _eventCount = MutableStateFlow(0) @@ -81,7 +86,11 @@ class ChessEventCollector( /** * Check if an event has already been processed. */ - fun hasEvent(eventId: String): Boolean = processedEventIds.contains(eventId) + fun hasEvent(eventId: String): Boolean = processedEventIdsLock.withLock { processedEventIds.contains(eventId) } + + private fun markProcessed(eventId: String): Boolean = processedEventIdsLock.withLock { processedEventIds.add(eventId) } + + private fun processedEventCount(): Int = processedEventIdsLock.withLock { processedEventIds.size } /** * Add a Jester event for this game. @@ -90,7 +99,7 @@ class ChessEventCollector( * @return true if the event was added, false if already exists or invalid */ fun addEvent(event: JesterEvent): Boolean { - if (processedEventIds.contains(event.id)) { + if (hasEvent(event.id)) { Log.d("chessdebug") { "[Collector] DEDUP: event ${event.id.take(8)} already processed for game ${startEventId.take(8)}" } return false } @@ -130,12 +139,12 @@ class ChessEventCollector( * @return true if the event was added, false if already exists or invalid */ private fun addStartEvent(event: JesterEvent): Boolean { - if (processedEventIds.contains(event.id)) return false + if (hasEvent(event.id)) return false if (!event.isStartEvent()) return false if (event.id != startEventId) return false if (_startEvent.compareAndSet(null, event)) { - processedEventIds.add(event.id) + markProcessed(event.id) incrementEventCount() Log.d("chessdebug") { "[Collector] START event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, createdAt=${event.createdAt}" } return true @@ -150,14 +159,15 @@ class ChessEventCollector( * @return true if the event was added, false if already exists or invalid */ private fun addMoveEvent(event: JesterEvent): Boolean { - if (processedEventIds.contains(event.id)) return false + if (hasEvent(event.id)) return false if (!event.isMoveEvent()) return false if (event.startEventId() != startEventId) return false - if (moves.putIfAbsent(event.id, event) == null) { - processedEventIds.add(event.id) + // createIfAbsent returns true iff the entry was added (no prior entry). + if (moves.createIfAbsent(event.id) { event }) { + markProcessed(event.id) incrementEventCount() - Log.d("chessdebug") { "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size}" } + Log.d("chessdebug") { "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size()}" } return true } return false @@ -169,7 +179,7 @@ class ChessEventCollector( fun getEvents(): JesterGameEvents = JesterGameEvents( startEvent = _startEvent.value, - moves = moves.values.toList(), + moves = moves.values().toList(), ) /** @@ -180,22 +190,22 @@ class ChessEventCollector( /** * Check if the game has any moves (indicates game is active). */ - fun hasMoves(): Boolean = moves.isNotEmpty() + fun hasMoves(): Boolean = !moves.isEmpty() /** * Check if the game has ended (has a move with result). */ - fun hasEnded(): Boolean = moves.values.any { it.result() != null } + fun hasEnded(): Boolean = moves.values().any { it.result() != null } /** * Get the number of moves collected. */ - fun moveCount(): Int = moves.size + fun moveCount(): Int = moves.size() /** * Get the latest move (with longest history). */ - fun latestMove(): JesterEvent? = moves.values.maxByOrNull { it.history().size } + fun latestMove(): JesterEvent? = moves.values().maxByOrNull { it.history().size } /** * Clear all collected events. @@ -203,12 +213,12 @@ class ChessEventCollector( fun clear() { _startEvent.value = null moves.clear() - processedEventIds.clear() + processedEventIdsLock.withLock { processedEventIds.clear() } _eventCount.value = 0 } private fun incrementEventCount() { - _eventCount.value = processedEventIds.size + _eventCount.value = processedEventCount() } } @@ -219,21 +229,21 @@ class ChessEventCollector( * such as in a chess lobby or when spectating multiple games. */ class ChessEventCollectorManager { - private val collectors = ConcurrentMutableMap() + private val collectors = LargeCache() /** * Get or create a collector for a game. * * @param startEventId The start event ID (game identifier) */ - fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrPut(startEventId) { ChessEventCollector(startEventId) } + fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrCreate(startEventId) { ChessEventCollector(it) } /** * Get a collector if it exists. * * @param startEventId The start event ID (game identifier) */ - fun get(startEventId: String): ChessEventCollector? = collectors[startEventId] + fun get(startEventId: String): ChessEventCollector? = collectors.get(startEventId) /** * Remove a collector for a game. @@ -245,13 +255,13 @@ class ChessEventCollectorManager { /** * Get all active game IDs (start event IDs). */ - fun activeGameIds(): Set = collectors.keys.toSet() + fun activeGameIds(): Set = collectors.keys() /** * Clear all collectors. */ fun clear() { - collectors.values.forEach { it.clear() } + collectors.values().forEach { it.clear() } collectors.clear() } @@ -276,8 +286,8 @@ class ChessEventCollectorManager { return false } val collector = - collectors[startId] ?: run { - Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys.map { it.take(8) }})" } + collectors.get(startId) ?: run { + Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys().map { it.take(8) }})" } return false } return collector.addEvent(event) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index e49849a0f4..8d8b2791ac 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.chess -import co.touchlab.stately.collections.ConcurrentMutableMap -import co.touchlab.stately.collections.ConcurrentMutableSet +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -131,20 +132,21 @@ class ChessLobbyLogic( ) { val state = ChessLobbyState(userPubkey, scope) - private val dismissedGameIds = - ConcurrentMutableSet().apply { - dismissedStorage?.load(userPubkey)?.let { addAll(it) } - } + private val dismissedGameIdsLock = KmpLock() + private val dismissedGameIds: MutableSet = + (dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf()) // Track when games were last loaded to prevent duplicate fetches - // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it) - private val recentlyLoadedGames = ConcurrentMutableMap() + // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it). + // String keys are Comparable, so LargeCache works. + private val recentlyLoadedGames = LargeCache() // Dedup incoming events (same event delivered by multiple relays). - // Bounded LRU: evict oldest when exceeding capacity. ConcurrentMutableSet - // wraps mutableSetOf() (LinkedHashSet on every KMP target), so iterator - // order is insertion order — required for LRU eviction below. - private val seenEventIds = ConcurrentMutableSet() + // Bounded LRU: evict oldest when exceeding capacity. mutableSetOf returns + // LinkedHashSet on every KMP target, preserving insertion-order iteration + // required for LRU eviction below. + private val seenEventIdsLock = KmpLock() + private val seenEventIds = mutableSetOf() private val seenEventIdsMax = 500 private val pollingDelegate = @@ -212,7 +214,7 @@ class ChessLobbyLogic( // Dedup: skip if we already processed this event ID (multiple relays deliver same event) val isNew = - seenEventIds.block { + seenEventIdsLock.withLock { if (!seenEventIds.add(event.id)) { false } else { @@ -448,13 +450,13 @@ class ChessLobbyLogic( */ fun handleGameAccepted(startEventId: String) { // Skip if already loaded or in-flight (multiple move events from same game trigger this) - val lastLoaded = recentlyLoadedGames[startEventId] + val lastLoaded = recentlyLoadedGames.get(startEventId) if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) { Log.d("chessdebug") { "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" } return } // Mark immediately to prevent concurrent launches - recentlyLoadedGames[startEventId] = TimeUtils.now() + recentlyLoadedGames.put(startEventId, TimeUtils.now()) Log.d("chessdebug") { "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays" } scope.launch(Dispatchers.Default) { @@ -466,7 +468,7 @@ class ChessLobbyLogic( when (result) { is LoadGameResult.Success -> { - recentlyLoadedGames[startEventId] = TimeUtils.now() + recentlyLoadedGames.put(startEventId, TimeUtils.now()) Log.d("chessdebug") { "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}" } state.addActiveGame(startEventId, result.liveState) pollingDelegate.addGameId(startEventId) @@ -618,7 +620,7 @@ class ChessLobbyLogic( when (result) { is LoadGameResult.Success -> { - recentlyLoadedGames[startEventId] = TimeUtils.now() + recentlyLoadedGames.put(startEventId, TimeUtils.now()) state.addSpectatingGame(startEventId, result.liveState) pollingDelegate.addGameId(startEventId) state.setBroadcastStatus(ChessBroadcastStatus.Idle) @@ -651,7 +653,7 @@ class ChessLobbyLogic( when (result) { is LoadGameResult.Success -> { - recentlyLoadedGames[startEventId] = TimeUtils.now() + recentlyLoadedGames.put(startEventId, TimeUtils.now()) if (result.liveState.isSpectator) { state.addSpectatingGame(startEventId, result.liveState) } else { @@ -688,13 +690,13 @@ class ChessLobbyLogic( private suspend fun refreshGame(startEventId: String) { // Skip if this game was just loaded (prevents duplicate fetch after discoverUserGames) - val lastLoaded = recentlyLoadedGames[startEventId] + val lastLoaded = recentlyLoadedGames.get(startEventId) if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) { Log.d("chessdebug") { "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" } return } // Mark immediately to prevent concurrent fetches for the same game - recentlyLoadedGames[startEventId] = TimeUtils.now() + recentlyLoadedGames.put(startEventId, TimeUtils.now()) Log.d("chessdebug") { "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays" } val events = fetcher.fetchGameEvents(startEventId) @@ -883,16 +885,17 @@ class ChessLobbyLogic( .map { it.gameId } .toSet() + val dismissedSnapshot = dismissedGameIdsLock.withLock { dismissedGameIds.toSet() } for (startEventId in newGameIds) { if (startEventId in completedGameIds) continue - if (startEventId in dismissedGameIds) continue + if (startEventId in dismissedSnapshot) continue val events = fetcher.fetchGameEvents(startEventId) val result = ChessGameLoader.loadGame(events, userPubkey) when (result) { is LoadGameResult.Success -> { - recentlyLoadedGames[startEventId] = TimeUtils.now() + recentlyLoadedGames.put(startEventId, TimeUtils.now()) // If the discovered game is already finished, send it straight to completed val gameStatus = result.liveState.gameStatus.value if (gameStatus is GameStatus.Finished) { @@ -961,15 +964,23 @@ class ChessLobbyLogic( fun dismissCompletedGame(gameId: String) { state.removeCompletedGame(gameId) - dismissedGameIds.add(gameId) - dismissedStorage?.save(userPubkey, dismissedGameIds.toSet()) + val snapshot = + dismissedGameIdsLock.withLock { + dismissedGameIds.add(gameId) + dismissedGameIds.toSet() + } + dismissedStorage?.save(userPubkey, snapshot) } fun dismissAllCompletedGames() { val allIds = state.completedGames.value.map { it.gameId } state.clearCompletedGames() - dismissedGameIds.addAll(allIds) - dismissedStorage?.save(userPubkey, dismissedGameIds.toSet()) + val snapshot = + dismissedGameIdsLock.withLock { + dismissedGameIds.addAll(allIds) + dismissedGameIds.toSet() + } + dismissedStorage?.save(userPubkey, snapshot) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt index 6cfb0971e3..7a1a945712 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt @@ -20,16 +20,19 @@ */ package com.vitorpamplona.amethyst.commons.chess -import co.touchlab.stately.collections.ConcurrentMutableMap -import co.touchlab.stately.collections.ConcurrentMutableSet +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeoutOrNull +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi /** * Progress callback for relay fetch operations @@ -68,6 +71,7 @@ class ChessRelayFetchHelper( * @param onProgress Optional callback for progress updates per relay * @return Deduplicated list of events received before timeout/EOSE */ + @OptIn(ExperimentalAtomicApi::class) suspend fun fetchEvents( filters: Map>, timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS, @@ -75,16 +79,22 @@ class ChessRelayFetchHelper( ): List { if (filters.isEmpty()) return emptyList() - val events = ConcurrentMutableMap() + val events = LargeCache() val relayCount = filters.keys.size - val eoseReceived = ConcurrentMutableSet() - val relayEventCounts = ConcurrentMutableMap() + // Per-relay event counters. Each counter is independently atomic, so + // increments don't need to lock the whole map. + val relayEventCounts = LargeCache() + // Small bounded set; KmpLock-guarded plain set is fine for the size we + // expect (one entry per relay in the filter map). + val eoseLock = KmpLock() + val eoseReceived = mutableSetOf() val allEose = CompletableDeferred() val subId = newSubId() - // Initialize all relays as WAITING + // Initialize all relays as WAITING. Eagerly create AtomicInt counters + // so onEose / timeout paths can read load() without a put race. filters.keys.forEach { relay -> - relayEventCounts[relay] = 0 + relayEventCounts.getOrCreate(relay) { AtomicInt(0) } onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.WAITING, 0)) } @@ -96,13 +106,8 @@ class ChessRelayFetchHelper( relay: NormalizedRelayUrl, forFilters: List?, ) { - events[event.id] = event - val count = - relayEventCounts.block { - val newVal = (relayEventCounts[relay] ?: 0) + 1 - relayEventCounts[relay] = newVal - newVal - } + events.put(event.id, event) + val count = relayEventCounts.getOrCreate(relay) { AtomicInt(0) }.addAndFetch(1) onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count)) } @@ -110,11 +115,15 @@ class ChessRelayFetchHelper( relay: NormalizedRelayUrl, forFilters: List?, ) { - eoseReceived.add(relay) - val count = relayEventCounts[relay] ?: 0 + val newEoseSize = + eoseLock.withLock { + eoseReceived.add(relay) + eoseReceived.size + } + val count = relayEventCounts.get(relay)?.load() ?: 0 onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.EOSE_RECEIVED, count)) // Complete when all relays respond - if (eoseReceived.size >= relayCount) { + if (newEoseSize >= relayCount) { allEose.complete(Unit) } } @@ -125,9 +134,10 @@ class ChessRelayFetchHelper( // Mark timed-out relays if (eoseResult == null) { + val eoseSnapshot = eoseLock.withLock { eoseReceived.toSet() } filters.keys.forEach { relay -> - if (relay !in eoseReceived) { - val count = relayEventCounts[relay] ?: 0 + if (relay !in eoseSnapshot) { + val count = relayEventCounts.get(relay)?.load() ?: 0 onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.TIMEOUT, count)) } } @@ -135,6 +145,6 @@ class ChessRelayFetchHelper( client.unsubscribe(subId) - return events.values.toList() + return events.values().toList() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt index 70007668bf..a1c5385b37 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.commons.model.nip01Core import androidx.compose.runtime.Stable -import co.touchlab.stately.concurrency.Lock -import co.touchlab.stately.concurrency.withLock +import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.WeakReference +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import kotlinx.coroutines.flow.MutableStateFlow @@ -55,7 +55,7 @@ val DefaultOrder = class UserRelaysCache { var data: Map = mapOf() private var flow: WeakReference>? = null - private val flowLock = Lock() + private val flowLock = KmpLock() fun flow() = flow?.get() ?: flowLock.withLock { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt index 4e66aee673..22ffa50d4e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt @@ -21,7 +21,8 @@ package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers import androidx.compose.runtime.Stable -import co.touchlab.stately.collections.ConcurrentMutableMap +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock /** * This allows composables to directly register their queries @@ -32,13 +33,16 @@ import co.touchlab.stately.collections.ConcurrentMutableMap abstract class ComposeSubscriptionManager : ComposeSubscriptionManagerControls, Subscribable { - private val composeSubscriptions = ConcurrentMutableMap() + // T has no Comparable bound — see the note in MutableComposeSubscriptionManager + // for why LargeCache isn't used here. + private val lock = KmpLock() + private val composeSubscriptions = mutableMapOf() // This is called by main. Keep it really fast. override fun subscribe(query: T?) { if (query == null) return - composeSubscriptions.put(query, query) + lock.withLock { composeSubscriptions[query] = query } invalidateKeys() } @@ -47,7 +51,7 @@ abstract class ComposeSubscriptionManager : override fun unsubscribe(query: T?) { if (query == null) return - composeSubscriptions.remove(query) + lock.withLock { composeSubscriptions.remove(query) } invalidateKeys() } @@ -55,8 +59,8 @@ abstract class ComposeSubscriptionManager : override fun subscribe(query: List) { if (query.isEmpty()) return - query.forEach { - composeSubscriptions.put(it, it) + lock.withLock { + query.forEach { composeSubscriptions[it] = it } } invalidateKeys() @@ -66,14 +70,14 @@ abstract class ComposeSubscriptionManager : override fun unsubscribe(query: List) { if (query.isEmpty()) return - query.forEach { - composeSubscriptions.remove(it) + lock.withLock { + query.forEach { composeSubscriptions.remove(it) } } invalidateKeys() } - fun allKeys() = composeSubscriptions.keys + fun allKeys(): Set = lock.withLock { composeSubscriptions.keys.toSet() } } interface Subscribable { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt index 61e6d46ce7..97eabca106 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt @@ -21,7 +21,8 @@ package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers import androidx.compose.runtime.Stable -import co.touchlab.stately.collections.ConcurrentMutableMap +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -41,19 +42,26 @@ import kotlinx.coroutines.launch abstract class MutableComposeSubscriptionManager( val scope: CoroutineScope, ) : ComposeSubscriptionManagerControls { - private val composeSubscriptions = ConcurrentMutableMap() + // T is generic and not required to be Comparable, so this can't use + // LargeCache (ConcurrentSkipListMap-backed on JVM). A plain map guarded by + // KmpLock gives the same atomicity guarantees ConcurrentHashMap did + // previously, with no Comparable requirement. + private val lock = KmpLock() + private val composeSubscriptions = mutableMapOf() // This is called by main. Keep it really fast. fun subscribe(query: T?) { if (query == null) return - composeSubscriptions[query]?.cancel() - composeSubscriptions[query] = - scope.launch { - query.flow().collectLatest { - invalidateKeys() + lock.withLock { + composeSubscriptions[query]?.cancel() + composeSubscriptions[query] = + scope.launch { + query.flow().collectLatest { + invalidateKeys() + } } - } + } invalidateKeys() } @@ -62,16 +70,19 @@ abstract class MutableComposeSubscriptionManager( fun unsubscribe(query: T?) { if (query == null) return - composeSubscriptions[query]?.cancel() - composeSubscriptions.remove(query) + lock.withLock { + composeSubscriptions[query]?.cancel() + composeSubscriptions.remove(query) + } invalidateKeys() } - fun allKeys() = composeSubscriptions.keys + fun allKeys(): Set = lock.withLock { composeSubscriptions.keys.toSet() } fun forEachSubscriber(action: (T) -> Unit) { - composeSubscriptions.keys.forEach(action) + val snapshot = lock.withLock { composeSubscriptions.keys.toList() } + snapshot.forEach(action) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt new file mode 100644 index 0000000000..b6a5ac0a66 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +/** + * KMP-friendly reentrant lock. JVM/Android map to `java.util.concurrent.locks.ReentrantLock`; + * iOS will map to `NSLock` (or a thin wrapper around it) when the target is added. + * + * Use [withLock] in preference to manual lock/unlock — it guarantees release on + * exception. The class is reentrant on every platform, so a thread that already + * holds the lock can re-enter without deadlocking. + */ +expect class KmpLock() { + fun lock() + + fun unlock() +} + +inline fun KmpLock.withLock(block: () -> T): T { + lock() + try { + return block() + } finally { + unlock() + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.jvmAndroid.kt new file mode 100644 index 0000000000..db64401eb5 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.jvmAndroid.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import java.util.concurrent.locks.ReentrantLock + +actual class KmpLock { + private val delegate = ReentrantLock() + + actual fun lock() = delegate.lock() + + actual fun unlock() = delegate.unlock() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c33aeb00e7..24ca396591 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,6 @@ zelory = "3.0.1" zoomable = "2.11.1" vlcj = "4.8.3" commonsImaging = "1.0.0-alpha6" -statelyConcurrent = "2.1.0" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" @@ -101,7 +100,6 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-runtime-annotation = { group = "androidx.compose.runtime", name = "runtime-annotation", version.ref = "composeRuntimeAnnotation" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" } -stately-concurrent-collections = { group = "co.touchlab", name = "stately-concurrent-collections", version.ref = "statelyConcurrent" } androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "androidxExifinterface" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } From 6f1292bfcf31dad3638f2b886829366fdcac8835 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 23:06:30 +0000 Subject: [PATCH 16/33] refactor: replace @Synchronized / @Volatile with KMP primitives Clears the last of the JVM-only synchronization annotations from commons/commonMain so the model layer can compile on iOS. 15 methods across 4 files migrated. - @Synchronized -> KmpLock.withLock { } with one per-instance syncLock field per class. Original semantics preserved: @Synchronized on methods of the same class synchronized on `this`, and a single per-instance KmpLock gives the same exclusion. * Channel.kt: addRelaySync, createOrDestroyFlowSync * Chatroom.kt: addMessageSync, removeMessageSync * MarmotGroupChatroom.kt: placeholderNote, addMessageSync, restoreMessageSync, removeMessageSync, clearAllMessagesSync * Note.kt: innerAddZap, innerAddOnchainZap, innerRemoveOnchainZapForSource, innerAddZapPayment, addRelaySync, createOrDestroyFlowSync - Note.kt's @Volatile fields: now use kotlin.concurrent.Volatile (KMP) instead of kotlin.jvm.Volatile (JVM-only) via explicit import. Volatile semantics preserved on every target. NestViewModel.kt also uses @Volatile (and the nestsClient project dep); that file moves to jvmAndroid in a separate PR as planned (audio rooms is Phase 5). --- .../amethyst/commons/model/Channel.kt | 36 +++-- .../amethyst/commons/model/Note.kt | 138 +++++++++--------- .../model/marmotGroups/MarmotGroupChatroom.kt | 125 ++++++++-------- .../commons/model/privateChats/Chatroom.kt | 121 +++++++-------- 4 files changed, 221 insertions(+), 199 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt index af328640c4..0023d5ad5c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.WeakReference +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.cache.LargeCache @@ -41,6 +43,10 @@ abstract class Channel : NotesGatherer { private var relays = mapOf() + // Single per-instance lock that previously @Synchronized methods share. + // Replaces JVM-only @Synchronized so this class compiles on iOS. + private val syncLock = KmpLock() + private var changesFlow: WeakReference>>? = null fun changesFlow(): MutableSharedFlow> { @@ -75,12 +81,12 @@ abstract class Channel : NotesGatherer { flowSet?.metadata?.invalidateData() } - @Synchronized - fun addRelaySync(briefInfo: NormalizedRelayUrl) { - if (briefInfo !in relays) { - relays = relays + Pair(briefInfo, Counter(1)) + fun addRelaySync(briefInfo: NormalizedRelayUrl) = + syncLock.withLock { + if (briefInfo !in relays) { + relays = relays + Pair(briefInfo, Counter(1)) + } } - } fun addRelay(relay: NormalizedRelayUrl) { val counter = relays[relay] @@ -165,18 +171,18 @@ abstract class Channel : NotesGatherer { var flowSet: ChannelFlowSet? = null - @Synchronized - fun createOrDestroyFlowSync(create: Boolean) { - if (create) { - if (flowSet == null) { - flowSet = ChannelFlowSet(this) - } - } else { - if (flowSet != null && flowSet?.isInUse() == false) { - flowSet = null + fun createOrDestroyFlowSync(create: Boolean) = + syncLock.withLock { + if (create) { + if (flowSet == null) { + flowSet = ChannelFlowSet(this) + } + } else { + if (flowSet != null && flowSet?.isInUse() == false) { + flowSet = null + } } } - } fun flow(): ChannelFlowSet { if (flowSet == null) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index d250ae7d2a..5e604f97a7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -24,9 +24,11 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread +import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji import com.vitorpamplona.amethyst.commons.util.replace import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -73,6 +75,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import java.math.BigDecimal +import kotlin.concurrent.Volatile interface NotesGatherer { fun removeNote(note: Note) @@ -105,6 +108,11 @@ class AddressableNote( open class Note( val idHex: HexKey, ) : NotesGatherer { + // Per-instance lock shared by the previously @Synchronized methods (zap / + // onchain-zap / zap-payment / relay-add / flowSet lifecycle). Replaces the + // JVM-only @Synchronized annotation so this class compiles on iOS. + private val syncLock = KmpLock() + // These fields are only available after the Text Note event is received. // They are immutable after that. var event: Event? = null @@ -165,7 +173,7 @@ open class Note( * only counts CONFIRMED amounts. * * `@Volatile` ensures cross-thread visibility: writes happen on `applicationIOScope` - * (inside the @Synchronized inner methods) and reads happen on the Compose Main + * (inside the syncLock-guarded inner methods) and reads happen on the Compose Main * thread (gallery recomposition + the reverification driver's `any { 
 }` check). */ @Volatile @@ -421,19 +429,18 @@ open class Note( } } - @Synchronized private fun innerAddZap( zapRequest: Note, zap: Note?, - ): Boolean { - if (zaps[zapRequest] == null) { - zaps = zaps + Pair(zapRequest, zap) - return true + ): Boolean = + syncLock.withLock { + if (zaps[zapRequest] == null) { + zaps = zaps + Pair(zapRequest, zap) + return@withLock true + } + return@withLock false } - return false - } - fun addZap( zapRequest: Note, zap: Note?, @@ -447,48 +454,48 @@ open class Note( } } - @Synchronized private fun innerAddOnchainZap( txid: String, entry: OnchainZapEntry, - ): Boolean { - val existing = onchainZaps[txid] - if (existing != null) { - // Exact structural duplicate (same source Note + same fields) — typical - // relay echo of the same event. Skip the rewrite to avoid spurious - // flowSet invalidation. - if (entry == existing) return false - // Reject downgrades using the explicit OnchainZapStatus.level (not ordinal) - // so the upgrade contract survives future enum reordering or insertions. - if (entry.status.level < existing.status.level) return false - // Same level: accept only when verifiedSats grows OR the source differs - // (legitimate alternate signer republishing a split-zap receipt). A strictly - // smaller verifiedSats is a backend downgrade and we ignore it. - if (entry.status.level == existing.status.level && entry.verifiedSats < existing.verifiedSats) return false + ): Boolean = + syncLock.withLock { + val existing = onchainZaps[txid] + if (existing != null) { + // Exact structural duplicate (same source Note + same fields) — typical + // relay echo of the same event. Skip the rewrite to avoid spurious + // flowSet invalidation. + if (entry == existing) return@withLock false + // Reject downgrades using the explicit OnchainZapStatus.level (not ordinal) + // so the upgrade contract survives future enum reordering or insertions. + if (entry.status.level < existing.status.level) return@withLock false + // Same level: accept only when verifiedSats grows OR the source differs + // (legitimate alternate signer republishing a split-zap receipt). A strictly + // smaller verifiedSats is a backend downgrade and we ignore it. + if (entry.status.level == existing.status.level && entry.verifiedSats < existing.verifiedSats) return@withLock false + } + onchainZaps = onchainZaps + Pair(txid, entry) + return@withLock true } - onchainZaps = onchainZaps + Pair(txid, entry) - return true - } - @Synchronized private fun innerRemoveOnchainZapForSource( txid: String, sourceAuthorPubKey: HexKey, - ): Boolean { - val existing = onchainZaps[txid] ?: return false - // Anti-spoof: only remove the entry if its source matches the rejecting event. - // Otherwise a malicious third party could erase a legitimate CONFIRMED entry - // by publishing a spoofed kind:8333 with the same txid but a bystander recipient. - // Also refuse to remove a CONFIRMED entry — once chain-verified, only a fresh - // CONFIRMED replacement should change it; a transient backend hiccup must not - // wipe a previously-CONFIRMED entry just because some other target on the same - // event is still UNVERIFIED. - if (existing.status == OnchainZapStatus.CONFIRMED) return false - if (existing.source.author?.pubkeyHex == null) return false - if (existing.source.author?.pubkeyHex != sourceAuthorPubKey) return false - onchainZaps = onchainZaps - txid - return true - } + ): Boolean = + syncLock.withLock { + val existing = onchainZaps[txid] ?: return@withLock false + // Anti-spoof: only remove the entry if its source matches the rejecting event. + // Otherwise a malicious third party could erase a legitimate CONFIRMED entry + // by publishing a spoofed kind:8333 with the same txid but a bystander recipient. + // Also refuse to remove a CONFIRMED entry — once chain-verified, only a fresh + // CONFIRMED replacement should change it; a transient backend hiccup must not + // wipe a previously-CONFIRMED entry just because some other target on the same + // event is still UNVERIFIED. + if (existing.status == OnchainZapStatus.CONFIRMED) return@withLock false + if (existing.source.author?.pubkeyHex == null) return@withLock false + if (existing.source.author?.pubkeyHex != sourceAuthorPubKey) return@withLock false + onchainZaps = onchainZaps - txid + return@withLock true + } /** * Register a NIP-BC onchain zap targeting this note. `source` is the OnchainZapEvent's own @@ -535,19 +542,18 @@ open class Note( } } - @Synchronized private fun innerAddZapPayment( zapPaymentRequest: Note, zapPayment: Note?, - ): Boolean { - if (zapPayments[zapPaymentRequest] == null) { - zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) - return true + ): Boolean = + syncLock.withLock { + if (zapPayments[zapPaymentRequest] == null) { + zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) + return@withLock true + } + return@withLock false } - return false - } - fun addZapPayment( zapPaymentRequest: Note, zapPayment: Note?, @@ -589,12 +595,12 @@ open class Note( } } - @Synchronized - fun addRelaySync(relay: NormalizedRelayUrl) { - if (relay !in relays) { - relays = relays + relay + fun addRelaySync(relay: NormalizedRelayUrl) = + syncLock.withLock { + if (relay !in relays) { + relays = relays + relay + } } - } fun hasRelay(relay: NormalizedRelayUrl) = relay in relays @@ -1035,18 +1041,18 @@ open class Note( var flowSet: NoteFlowSet? = null - @Synchronized - fun createOrDestroyFlowSync(create: Boolean) { - if (create) { - if (flowSet == null) { - flowSet = NoteFlowSet(this) - } - } else { - if (flowSet != null && flowSet?.isInUse() == false) { - flowSet = null + fun createOrDestroyFlowSync(create: Boolean) = + syncLock.withLock { + if (create) { + if (flowSet == null) { + flowSet = NoteFlowSet(this) + } + } else { + if (flowSet != null && flowSet?.isInUse() == false) { + flowSet = null + } } } - } fun flow(): NoteFlowSet { if (flowSet == null) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index c5befd527b..92b63c5b89 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -26,7 +26,9 @@ import com.vitorpamplona.amethyst.commons.model.Channel.Companion.DefaultFeedOrd import com.vitorpamplona.amethyst.commons.model.ListChange import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NotesGatherer +import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.WeakReference +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.channels.BufferOverflow @@ -84,22 +86,25 @@ class MarmotGroupChatroom( return adminPubkeys.value.any { it in followingKeySet } } + // Per-instance lock shared by previously @Synchronized methods. + private val syncLock = KmpLock() + // Synthetic note used by list views to represent the group when no // messages have been received yet. Lazily created and kept stable so // equality-based feed diffing treats it as the same row across refreshes. private var cachedPlaceholder: Note? = null - @Synchronized - fun placeholderNote(): Note { - val existing = cachedPlaceholder - if (existing != null) return existing - val created = - Note(placeholderIdHex(nostrGroupId)).apply { - addGatherer(this@MarmotGroupChatroom) - } - cachedPlaceholder = created - return created - } + fun placeholderNote(): Note = + syncLock.withLock { + val existing = cachedPlaceholder + if (existing != null) return@withLock existing + val created = + Note(placeholderIdHex(nostrGroupId)).apply { + addGatherer(this@MarmotGroupChatroom) + } + cachedPlaceholder = created + created + } companion object { fun placeholderIdHex(nostrGroupId: HexKey): HexKey = "marmot-empty-$nostrGroupId" @@ -119,23 +124,23 @@ class MarmotGroupChatroom( removeMessageSync(note) } - @Synchronized - fun addMessageSync(msg: Note): Boolean { - if (msg !in messages) { - messages = messages + msg - msg.addGatherer(this) + fun addMessageSync(msg: Note): Boolean = + syncLock.withLock { + if (msg !in messages) { + messages = messages + msg + msg.addGatherer(this) - val createdAt = msg.createdAt() ?: 0L - if (createdAt > (newestMessage?.createdAt() ?: 0L)) { - newestMessage = msg + val createdAt = msg.createdAt() ?: 0L + if (createdAt > (newestMessage?.createdAt() ?: 0L)) { + newestMessage = msg + } + + unreadCount.value += 1 + changesFlow?.get()?.tryEmit(ListChange.Addition(msg)) + return@withLock true } - - unreadCount.value += 1 - changesFlow?.get()?.tryEmit(ListChange.Addition(msg)) - return true + return@withLock false } - return false - } /** * Add a message that is being restored from persistent storage on app @@ -143,38 +148,38 @@ class MarmotGroupChatroom( * count — restored messages were already seen by the user in a previous * session. */ - @Synchronized - fun restoreMessageSync(msg: Note): Boolean { - if (msg !in messages) { - messages = messages + msg - msg.addGatherer(this) + fun restoreMessageSync(msg: Note): Boolean = + syncLock.withLock { + if (msg !in messages) { + messages = messages + msg + msg.addGatherer(this) - val createdAt = msg.createdAt() ?: 0L - if (createdAt > (newestMessage?.createdAt() ?: 0L)) { - newestMessage = msg + val createdAt = msg.createdAt() ?: 0L + if (createdAt > (newestMessage?.createdAt() ?: 0L)) { + newestMessage = msg + } + + changesFlow?.get()?.tryEmit(ListChange.Addition(msg)) + return@withLock true } - - changesFlow?.get()?.tryEmit(ListChange.Addition(msg)) - return true + return@withLock false } - return false - } - @Synchronized - fun removeMessageSync(msg: Note): Boolean { - if (msg in messages) { - messages = messages - msg - msg.removeGatherer(this) + fun removeMessageSync(msg: Note): Boolean = + syncLock.withLock { + if (msg in messages) { + messages = messages - msg + msg.removeGatherer(this) - if (msg == newestMessage) { - newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L } + if (msg == newestMessage) { + newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L } + } + + changesFlow?.get()?.tryEmit(ListChange.Deletion(msg)) + return@withLock true } - - changesFlow?.get()?.tryEmit(ListChange.Deletion(msg)) - return true + return@withLock false } - return false - } fun markAsRead() { unreadCount.value = 0 @@ -209,14 +214,14 @@ class MarmotGroupChatroom( * decrypted inner notes become eligible for GC out of LocalCache (which * holds them weakly). */ - @Synchronized - fun clearAllMessagesSync(): Set { - val toRemove = messages - if (toRemove.isEmpty()) return toRemove - messages = emptySet() - newestMessage = null - unreadCount.value = 0 - changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toRemove)) - return toRemove - } + fun clearAllMessagesSync(): Set = + syncLock.withLock { + val toRemove = messages + if (toRemove.isEmpty()) return@withLock toRemove + messages = emptySet() + newestMessage = null + unreadCount.value = 0 + changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toRemove)) + toRemove + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index f4476777f2..43678ca38a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -26,7 +26,9 @@ import com.vitorpamplona.amethyst.commons.model.ListChange import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NotesGatherer import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.WeakReference +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject @@ -44,6 +46,9 @@ class Chatroom : NotesGatherer { var ownerSentMessage: Boolean = false var newestMessage: Note? = null + // Per-instance lock shared by previously @Synchronized methods. + private val syncLock = KmpLock() + private var changesFlow: WeakReference>>? = null fun changesFlow(): MutableSharedFlow> { @@ -58,68 +63,68 @@ class Chatroom : NotesGatherer { removeMessageSync(note) } - @Synchronized - fun addMessageSync(msg: Note): Boolean { - if (msg !in messages) { - messages = messages + msg - msg.addGatherer(this) + fun addMessageSync(msg: Note): Boolean = + syncLock.withLock { + if (msg !in messages) { + messages = messages + msg + msg.addGatherer(this) - msg.author?.let { author -> - if (author !in activeSenders) { - activeSenders + author - } - } - - val createdAt = msg.createdAt() ?: 0L - if (createdAt > (newestMessage?.createdAt() ?: 0L)) { - newestMessage = msg - } - - val newSubject = msg.event?.subject() - - if (newSubject != null && (msg.createdAt() ?: 0L) > (subjectCreatedAt ?: 0)) { - subject.tryEmit(newSubject) - subjectCreatedAt = msg.createdAt() - } - - changesFlow?.get()?.tryEmit(ListChange.Addition(msg)) - - return true - } - return false - } - - @Synchronized - fun removeMessageSync(msg: Note): Boolean { - if (msg in messages) { - messages = messages - msg - msg.removeGatherer(this) - - if (msg == newestMessage) { - newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L } - } - - if (msg.event?.subject() == subject.value) { - messages - .maxByOrNull { - val noteEvent = it.event - if (noteEvent?.subject() != null) { - noteEvent.createdAt - } else { - 0 - } - }?.let { - subject.tryEmit(it.event?.subject()) - subjectCreatedAt = it.createdAt() + msg.author?.let { author -> + if (author !in activeSenders) { + activeSenders + author } + } + + val createdAt = msg.createdAt() ?: 0L + if (createdAt > (newestMessage?.createdAt() ?: 0L)) { + newestMessage = msg + } + + val newSubject = msg.event?.subject() + + if (newSubject != null && (msg.createdAt() ?: 0L) > (subjectCreatedAt ?: 0)) { + subject.tryEmit(newSubject) + subjectCreatedAt = msg.createdAt() + } + + changesFlow?.get()?.tryEmit(ListChange.Addition(msg)) + + return@withLock true } - - changesFlow?.get()?.tryEmit(ListChange.Deletion(msg)) - - return true + return@withLock false + } + + fun removeMessageSync(msg: Note): Boolean = + syncLock.withLock { + if (msg in messages) { + messages = messages - msg + msg.removeGatherer(this) + + if (msg == newestMessage) { + newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L } + } + + if (msg.event?.subject() == subject.value) { + messages + .maxByOrNull { + val noteEvent = it.event + if (noteEvent?.subject() != null) { + noteEvent.createdAt + } else { + 0 + } + }?.let { + subject.tryEmit(it.event?.subject()) + subjectCreatedAt = it.createdAt() + } + } + + changesFlow?.get()?.tryEmit(ListChange.Deletion(msg)) + + return@withLock true + } + return@withLock false } - return false - } fun senderIntersects(keySet: Set): Boolean = activeSenders.any { it.pubkeyHex in keySet } From 39008f90d309ac3feb550e918648928c23ef4bc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 23:36:10 +0000 Subject: [PATCH 17/33] refactor: move feature-not-yet-iOS-ready files to jvmAndroid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears the remaining easy iOS blockers in commons/commonMain by relocating files whose underlying feature isn't iOS-ready yet, rather than fabricating expect/actuals we won't need until that feature ships. - NestViewModel + ActiveSubscription: depend on :nestsClient (audio rooms — Phase 5 per the iOS plan). Moved as-is; both already lived in a jvmAndroid-shaped package. - HtmlCharsetParser: depends on java.nio.charset.Charset, used only by the Android link-preview pipeline (no Desktop / iOS consumer today). - RenderMarkdown: depends on com.halilibo.richtext.* — needs iOS artifact verification before it can return to commonMain (tracked for Phase 3). - MediaContentModels.kt is split: * URL-based models (BaseMediaContent, MediaUrlImage/Video/Pdf, EncryptedMediaUrlImage/Video) stay in commonMain — pure KMP, no java.io.File reference. * Locally-cached variants (MediaPreloadedContent, MediaLocalImage, MediaLocalVideo) move to a new MediaLocalContent.kt under jvmAndroid — they hold a java.io.File and call .exists(). After this PR commons/commonMain has 5 remaining java.* importers (Note's BigDecimal, the two SortedSet observables, URL parsing in RichTextParser + UrlInfoItem). Down from 18 at the start of Phase 2. --- .../commons/richtext/MediaContentModels.kt | 47 ++---------- .../compose/markdown/RenderMarkdown.kt | 0 .../commons/preview/HtmlCharsetParser.kt | 0 .../commons/richtext/MediaLocalContent.kt | 72 +++++++++++++++++++ .../commons/viewmodels/ActiveSubscription.kt | 0 .../commons/viewmodels/NestViewModel.kt | 0 6 files changed, 77 insertions(+), 42 deletions(-) rename commons/src/{commonMain => jvmAndroid}/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt (100%) rename commons/src/{commonMain => jvmAndroid}/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt (100%) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaLocalContent.kt rename commons/src/{commonMain => jvmAndroid}/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt (100%) rename commons/src/{commonMain => jvmAndroid}/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt (100%) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index 1d2a534741..be472293f2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -22,7 +22,11 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import java.io.File + +// URL-based media content models (KMP). The locally-cached variants +// (MediaPreloadedContent, MediaLocalImage, MediaLocalVideo) live in +// MediaLocalContent.kt under the jvmAndroid source set because they depend +// on java.io.File. @Immutable abstract class BaseMediaContent( @@ -123,44 +127,3 @@ class EncryptedMediaUrlVideo( thumbhash: String? = null, authorPubKey: String? = null, ) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash, authorPubKey = authorPubKey) - -@Immutable -abstract class MediaPreloadedContent( - val localFile: File?, - description: String? = null, - val mimeType: String? = null, - val isVerified: Boolean? = null, - dim: DimensionTag? = null, - blurhash: String? = null, - val uri: String, - val id: String? = null, - thumbhash: String? = null, -) : BaseMediaContent(description, dim, blurhash, thumbhash) { - fun localFileExists() = localFile != null && localFile.exists() -} - -@Immutable -class MediaLocalImage( - localFile: File?, - mimeType: String? = null, - description: String? = null, - dim: DimensionTag? = null, - blurhash: String? = null, - isVerified: Boolean? = null, - uri: String, - thumbhash: String? = null, -) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash) - -@Immutable -class MediaLocalVideo( - localFile: File?, - mimeType: String? = null, - description: String? = null, - dim: DimensionTag? = null, - blurhash: String? = null, - isVerified: Boolean? = null, - uri: String, - val artworkUri: String? = null, - val authorName: String? = null, - thumbhash: String? = null, -) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaLocalContent.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaLocalContent.kt new file mode 100644 index 0000000000..30c0de6441 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaLocalContent.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.richtext + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import java.io.File + +// Locally-cached media content. Lives in jvmAndroid because java.io.File is +// JVM-only; iOS support will arrive when we either swap to okio.Path or pull +// these classes back into commonMain behind an expect/actual File abstraction. +// URL-based media classes (MediaUrlImage, MediaUrlVideo, 
) remain in +// commonMain in MediaContentModels.kt. + +@Immutable +abstract class MediaPreloadedContent( + val localFile: File?, + description: String? = null, + val mimeType: String? = null, + val isVerified: Boolean? = null, + dim: DimensionTag? = null, + blurhash: String? = null, + val uri: String, + val id: String? = null, + thumbhash: String? = null, +) : BaseMediaContent(description, dim, blurhash, thumbhash) { + fun localFileExists() = localFile != null && localFile.exists() +} + +@Immutable +class MediaLocalImage( + localFile: File?, + mimeType: String? = null, + description: String? = null, + dim: DimensionTag? = null, + blurhash: String? = null, + isVerified: Boolean? = null, + uri: String, + thumbhash: String? = null, +) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash) + +@Immutable +class MediaLocalVideo( + localFile: File?, + mimeType: String? = null, + description: String? = null, + dim: DimensionTag? = null, + blurhash: String? = null, + isVerified: Boolean? = null, + uri: String, + val artworkUri: String? = null, + val authorName: String? = null, + thumbhash: String? = null, +) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt From 90fbe06f196f9f4c18a9e901d192cc596bec68da Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 23:38:58 +0000 Subject: [PATCH 18/33] refactor: KMP URL validation + move UrlInfoItem to jvmAndroid Clears the two java.net.* importers from commons/commonMain. - Adds expect fun isValidUrl(url: String?): Boolean in commons/commonMain/util/. The jvmAndroid actual preserves the exact JVM semantics (URI.toURL() + the same 3 catch arms); iOS actual will use NSURL when the target lands. - RichTextParser.isValidURL becomes a thin wrapper around isValidUrl. Keeps the existing static call site so callers in the Android app and Desktop need no change. - UrlInfoItem.kt (link-preview model that wraps URI) moves to jvmAndroid; its only consumers are the Android link-preview pipeline (HtmlCharsetParser, UrlPreviewState, UrlPreviewCard), which already live outside commonMain. commons/commonMain is now down to 3 java.* importers: Note (BigDecimal) and the two SortedSet-based observables. --- .../commons/richtext/RichTextParser.kt | 20 +-------- .../amethyst/commons/util/UrlValidation.kt | 31 ++++++++++++++ .../amethyst/commons/preview/UrlInfoItem.kt | 0 .../commons/util/UrlValidation.jvmAndroid.kt | 41 +++++++++++++++++++ 4 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.kt rename commons/src/{commonMain => jvmAndroid}/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt (100%) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.jvmAndroid.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 05399c493e..b8f8736716 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.amethyst.commons.util.isValidUrl import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip31Alts.AltTag @@ -40,9 +41,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toPersistentList -import java.net.MalformedURLException -import java.net.URI -import java.net.URISyntaxException import kotlin.coroutines.cancellation.CancellationException class RichTextParser { @@ -489,21 +487,7 @@ class RichTextParser { return pdfExtensions.any { removedParamsFromUrl.endsWith(it) } } - fun isValidURL(url: String?): Boolean = - try { - if (url != null) { - URI(url).toURL() - true - } else { - false - } - } catch (e: MalformedURLException) { - false - } catch (e: URISyntaxException) { - false - } catch (e: IllegalArgumentException) { - false - } + fun isValidURL(url: String?): Boolean = isValidUrl(url) fun parseImageOrVideo(fullUrl: String): BaseMediaContent { val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.kt new file mode 100644 index 0000000000..9a63884678 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +/** + * True iff [url] is a syntactically valid absolute URL with a recognized + * scheme. JVM/Android delegate to `java.net.URI(url).toURL()`; the iOS actual + * will use `NSURL(string:)` when that target is added. + * + * Called per-URL during feed render — actuals should keep this fast and + * allocation-light. + */ +expect fun isValidUrl(url: String?): Boolean diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.jvmAndroid.kt new file mode 100644 index 0000000000..70905418b4 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.jvmAndroid.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import java.net.MalformedURLException +import java.net.URI +import java.net.URISyntaxException + +actual fun isValidUrl(url: String?): Boolean = + try { + if (url != null) { + URI(url).toURL() + true + } else { + false + } + } catch (_: MalformedURLException) { + false + } catch (_: URISyntaxException) { + false + } catch (_: IllegalArgumentException) { + false + } From 880c1bfd4a5a5c2d42ca6a8d9815b25559659134 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 23:48:29 +0000 Subject: [PATCH 19/33] refactor: clear final java.* imports from commons/commonMain Three changes that bring commons/commonMain to zero java.* imports (down from 18 at the start of Phase 2). - EventListMatchingFilter, NoteListMatchingFilter: moved to jvmAndroid. Both use ConcurrentSkipListSet + SortedSet for ordered concurrent iteration, and their only consumer is LocalCache in the Android app. iOS-time we can revisit if a KMP ordered concurrent set is needed. - Note.kt's BigDecimal: switch import from java.math.BigDecimal to quartz's existing expect/actual com.vitorpamplona.quartz.utils.BigDecimal. BigDecimal.ZERO -> BigDecimal(0); BigDecimal.valueOf(longVal) -> BigDecimal(longVal) (the expect class already has the Long constructor). NoteOnchainZapTest gets the same treatment. - Adds two top-level extensions in quartz commonMain (separate BigDecimalOps.kt file to avoid the duplicate-JVM-classname collision with the existing BigDecimal.kt actuals): operator fun BigDecimal.plus(other: BigDecimal) operator fun BigDecimal.minus(other: BigDecimal) Lets += / + / - continue to work on commonMain BigDecimal values. Commons/commonMain is now structurally iOS-ready as far as the java.* import audit can tell. Remaining iOS work: actually flip on the iOS targets, see what UI / dep transitives break, and address. --- .../amethyst/commons/model/Note.kt | 17 ++++++----- .../commons/model/NoteOnchainZapTest.kt | 30 +++++++++---------- .../observables/EventListMatchingFilter.kt | 0 .../observables/NoteListMatchingFilter.kt | 0 .../quartz/utils/BigDecimalOps.kt | 25 ++++++++++++++++ 5 files changed, 49 insertions(+), 23 deletions(-) rename commons/src/{commonMain => jvmAndroid}/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt (100%) rename commons/src/{commonMain => jvmAndroid}/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt (100%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 5e604f97a7..bc131fee37 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -64,17 +64,18 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.BigDecimal import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.anyAsync import com.vitorpamplona.quartz.utils.containsAny import com.vitorpamplona.quartz.utils.launchAndWaitAll +import com.vitorpamplona.quartz.utils.plus import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest -import java.math.BigDecimal import kotlin.concurrent.Volatile interface NotesGatherer { @@ -162,7 +163,7 @@ open class Note( var zaps = mapOf() private set - var zapsAmount: BigDecimal = BigDecimal.ZERO + var zapsAmount: BigDecimal = BigDecimal(0) /** * NIP-BC onchain zaps targeting this note. @@ -357,7 +358,7 @@ open class Note( onchainZaps = mapOf() onchainZapResolved = false zapPayments = mapOf() - zapsAmount = BigDecimal.ZERO + zapsAmount = BigDecimal(0) relays = listOf() if (repliesChanged) flowSet?.replies?.invalidateData() @@ -743,13 +744,13 @@ open class Note( }.flatten() private fun updateZapTotal() { - var sumOfAmounts = BigDecimal.ZERO + var sumOfAmounts = BigDecimal(0) // Regular Zap Receipts zaps.forEach { val noteEvent = it.value?.event if (noteEvent is LnZapEvent) { - sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO + sumOfAmounts += noteEvent.amount ?: BigDecimal(0) } } @@ -757,7 +758,7 @@ open class Note( // Unverified/pending entries are tracked but excluded from the total per spec. onchainZaps.values.forEach { entry -> if (entry.status == OnchainZapStatus.CONFIRMED) { - sumOfAmounts += BigDecimal.valueOf(entry.verifiedSats) + sumOfAmounts += BigDecimal(entry.verifiedSats) } } @@ -895,7 +896,7 @@ open class Note( pledgeValue != null && it.author == user } - fun pledgedAmountByOthers(): BigDecimal = replies.sumOf { it.event?.addedRewardValue() ?: BigDecimal.ZERO } + fun pledgedAmountByOthers(): BigDecimal = replies.sumOf { it.event?.addedRewardValue() ?: BigDecimal(0) } fun hasAnyReports(): Boolean { val dayAgo = TimeUtils.oneDayAgo() @@ -984,7 +985,7 @@ open class Note( boosts = emptyList() reports = emptyMap() zaps = emptyMap() - zapsAmount = BigDecimal.ZERO + zapsAmount = BigDecimal(0) } fun isHiddenFor(accountChoices: LiveHiddenUsers): Boolean { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt index 59064d5512..5bd761e324 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.commons.model import com.vitorpamplona.quartz.nip01Core.core.HexKey -import java.math.BigDecimal +import com.vitorpamplona.quartz.utils.BigDecimal import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -67,7 +67,7 @@ class NoteOnchainZapTest { assertEquals(1000L, entry?.claimedSats) assertEquals(0L, entry?.verifiedSats) assertEquals(OnchainZapStatus.UNVERIFIED, entry?.status) - assertEquals(BigDecimal.ZERO, target.zapsAmount) + assertEquals(BigDecimal(0), target.zapsAmount) } @Test @@ -82,7 +82,7 @@ class NoteOnchainZapTest { assertSame(src, entry?.source) assertEquals(1000L, entry?.verifiedSats) assertEquals(OnchainZapStatus.PENDING, entry?.status) - assertEquals(BigDecimal.ZERO, target.zapsAmount) + assertEquals(BigDecimal(0), target.zapsAmount) } @Test @@ -93,7 +93,7 @@ class NoteOnchainZapTest { target.addOnchainZap(src, "tx1", claimedSats = 5000L, verifiedSats = 5000L, status = OnchainZapStatus.CONFIRMED) assertEquals(OnchainZapStatus.CONFIRMED, target.onchainZaps["tx1"]?.status) - assertEquals(BigDecimal.valueOf(5000L), target.zapsAmount) + assertEquals(BigDecimal(5000L), target.zapsAmount) } @Test @@ -109,7 +109,7 @@ class NoteOnchainZapTest { assertSame(secondSrc, entry?.source) assertEquals(OnchainZapStatus.PENDING, entry?.status) assertEquals(2400L, entry?.verifiedSats) - assertEquals(BigDecimal.ZERO, target.zapsAmount) + assertEquals(BigDecimal(0), target.zapsAmount) } @Test @@ -124,7 +124,7 @@ class NoteOnchainZapTest { val entry = target.onchainZaps["tx1"] assertSame(secondSrc, entry?.source) assertEquals(OnchainZapStatus.CONFIRMED, entry?.status) - assertEquals(BigDecimal.valueOf(2500L), target.zapsAmount) + assertEquals(BigDecimal(2500L), target.zapsAmount) } @Test @@ -139,7 +139,7 @@ class NoteOnchainZapTest { val entry = target.onchainZaps["tx1"] assertSame(firstSrc, entry?.source) assertEquals(OnchainZapStatus.CONFIRMED, entry?.status) - assertEquals(BigDecimal.valueOf(7500L), target.zapsAmount) + assertEquals(BigDecimal(7500L), target.zapsAmount) } @Test @@ -188,7 +188,7 @@ class NoteOnchainZapTest { val entry = target.onchainZaps["tx1"] assertSame(firstSrc, entry?.source) assertEquals(999L, entry?.verifiedSats) - assertEquals(BigDecimal.valueOf(999L), target.zapsAmount) + assertEquals(BigDecimal(999L), target.zapsAmount) } @Test @@ -206,7 +206,7 @@ class NoteOnchainZapTest { val entry = target.onchainZaps["tx1"] assertSame(secondSrc, entry?.source) - assertEquals(BigDecimal.valueOf(1000L), target.zapsAmount) + assertEquals(BigDecimal(1000L), target.zapsAmount) } @Test @@ -224,7 +224,7 @@ class NoteOnchainZapTest { val secondEntry = target.onchainZaps["tx1"] assertSame(firstEntry, secondEntry) - assertEquals(BigDecimal.valueOf(100L), target.zapsAmount) + assertEquals(BigDecimal(100L), target.zapsAmount) } @Test @@ -238,13 +238,13 @@ class NoteOnchainZapTest { target.addOnchainZap(src, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.PENDING) // PENDING entries don't contribute to total per spec. - assertEquals(BigDecimal.ZERO, target.zapsAmount) + assertEquals(BigDecimal(0), target.zapsAmount) assertNotNull(target.onchainZaps["tx1"]) target.removeOnchainZapForSource("tx1", srcKey) assertNull(target.onchainZaps["tx1"]) - assertEquals(BigDecimal.ZERO, target.zapsAmount) + assertEquals(BigDecimal(0), target.zapsAmount) } @Test @@ -277,13 +277,13 @@ class NoteOnchainZapTest { val src = sourceNote(srcKey) target.addOnchainZap(src, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.CONFIRMED) - assertEquals(BigDecimal.valueOf(4200L), target.zapsAmount) + assertEquals(BigDecimal(4200L), target.zapsAmount) target.removeOnchainZapForSource("tx1", srcKey) assertNotNull(target.onchainZaps["tx1"]) assertEquals(OnchainZapStatus.CONFIRMED, target.onchainZaps["tx1"]?.status) - assertEquals(BigDecimal.valueOf(4200L), target.zapsAmount) + assertEquals(BigDecimal(4200L), target.zapsAmount) } @Test @@ -342,7 +342,7 @@ class NoteOnchainZapTest { target.addOnchainZap(sourceNote("82".repeat(32)), "tx3", claimedSats = 9999L, verifiedSats = 9999L, status = OnchainZapStatus.PENDING) target.addOnchainZap(sourceNote("93".repeat(32)), "tx4", claimedSats = 1234L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED) - assertEquals(BigDecimal.valueOf(3000L), target.zapsAmount) + assertEquals(BigDecimal(3000L), target.zapsAmount) assertEquals(4, target.onchainZaps.size) assertTrue(target.onchainZaps.containsKey("tx3")) assertTrue(target.onchainZaps.containsKey("tx4")) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt new file mode 100644 index 0000000000..571f812db1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt @@ -0,0 +1,25 @@ +/* + * 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.utils + +operator fun BigDecimal.plus(other: BigDecimal): BigDecimal = add(other) + +operator fun BigDecimal.minus(other: BigDecimal): BigDecimal = subtract(other) From e02972e1bed005f51bacda297b3dc117f0a35e20 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 00:13:13 +0000 Subject: [PATCH 20/33] build(commons): enable iosArm64 + iosSimulatorArm64 targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 task 10 of the iOS plan — flip on iOS targets for :commons. Gradle dep resolution is fully green for iOS; actual Kotlin/Native compilation runs on the macOS CI job (the dev container in which this was authored can't extract the K/N LLVM toolchain). Dep reshuffle to match what's actually KMP-available: - commonMain: kept project(":quartz"), Compose Multiplatform, coil-compose, androidx-collection, kotlinx-collections-immutable, kotlinx-serialization-json, compose components-resources, androidx-lifecycle-viewmodel, androidx-lifecycle-runtime-compose. These all publish iosArm64 + iosSimulatorArm64 variants per `.module` inspection. - jvmAndroid (NEW location): project(":nestsClient") (JVM+Android-only), coil-okhttp (JVM-only), markdown-commonmark / markdown-ui / markdown-ui-material3 (the RenderMarkdown.kt consumer is already in jvmAndroid), and androidx-lifecycle-viewmodel-compose (AndroidX publishes android + jvmStubs + linuxx64Stubs variants — no iOS, so the viewModel() Composable helper stays JVM-bound until we either swap to the org.jetbrains.androidx.lifecycle variant or accept a platform-specific ViewModel access pattern on iOS). - libs.versions.toml: adds androidx-lifecycle-viewmodel catalog entry. - New intermediate source set iosMain → both iosArm64Main and iosSimulatorArm64Main depend on it (clean place for iOS-only actuals when KmpLock, WeakReference, etc. get their iOS implementations). - .github/workflows/build.yml: test-quartz-ios job now also runs :commons:compileKotlinIosArm64 + :commons:compileKotlinIosSimulatorArm64. --- .github/workflows/build.yml | 12 +++++++ commons/build.gradle.kts | 62 ++++++++++++++++++++++++++++--------- gradle/libs.versions.toml | 1 + 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d3ecb2b3e0..d9ed078f30 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -226,6 +226,18 @@ jobs: :quartz:iosSimulatorArm64Test \ :quartz:compileTestKotlinIosArm64 + # :commons gained iosArm64 + iosSimulatorArm64 targets in Phase 2 of the + # iOS plan. Compile-only for now — actual UI / lifecycle wiring will + # land with the iosApp module in Phase 3. The container that runs Claude + # Code can't extract the Kotlin/Native LLVM toolchain (sandbox limit), + # so this is the first place commonMain Compose code is actually + # type-checked against an Apple Native frontend. + - name: Compile Commons for iOS + run: | + ./gradlew \ + :commons:compileKotlinIosSimulatorArm64 \ + :commons:compileKotlinIosArm64 + - name: Upload iOS Test Reports uses: actions/upload-artifact@v7 if: failure() diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 684213eea6..1205ae2ad9 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -44,15 +44,16 @@ kotlin { } } + // iOS targets — Phase 2 spike. Compile-only for now (no framework binary + // configured yet). Reveals which transitive deps need iOS variants and + // which commonMain files still reach for platform-only APIs. + iosArm64() + iosSimulatorArm64() + sourceSets { commonMain { dependencies { implementation(project(":quartz")) - // Audio-rooms ViewModel needs the listener orchestration + audio - // pipeline types (NestsListener, AudioRoomPlayer, AudioPlayer - // interface). Concrete OkHttp/Quic/MediaCodec/AudioTrack actuals - // stay in :nestsClient's platform source sets. - implementation(project(":nestsClient")) // Compose Multiplatform implementation(libs.jetbrains.compose.ui) @@ -61,13 +62,17 @@ kotlin { implementation(libs.jetbrains.compose.material3) implementation(libs.jetbrains.compose.ui.tooling.preview) - // Lifecycle ViewModel (KMP since 2.8.0) - implementation(libs.androidx.lifecycle.viewmodel.compose) + // Lifecycle (KMP since 2.8.0). lifecycle-viewmodel and + // lifecycle-runtime-compose ship iOS variants; + // lifecycle-viewmodel-compose (the viewModel() Composable + // helper) is Android-only and lives in jvmAndroid below. + implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.runtime.compose) - // Image loading (Coil 3 - KMP) + // Image loading (Coil 3 - KMP). The okhttp network fetcher is + // JVM-only and lives in jvmAndroid; iOS will pull coil-ktor + // when that target wires its actual. implementation(libs.coil.compose) - implementation(libs.coil.okhttp) // LruCache (KMP-ready) implementation(libs.androidx.collection) @@ -81,11 +86,6 @@ kotlin { // Compose Multiplatform Resources implementation(libs.jetbrains.compose.components.resources) - - // Markdown rendering (richtext-commonmark) - implementation(libs.markdown.commonmark) - implementation(libs.markdown.ui) - implementation(libs.markdown.ui.material3) } } @@ -101,6 +101,32 @@ kotlin { create("jvmAndroid") { dependsOn(commonMain.get()) dependencies { + // Audio-rooms ViewModel needs the listener orchestration + + // audio pipeline types (NestsListener, AudioRoomPlayer, + // AudioPlayer interface). The :nestsClient module is + // jvmAndroid-only today (its QUIC + Opus + AudioRecord/Track + // stacks are JVM-bound), so the dep lives here, not in + // commonMain. iOS will need an audio-rooms reroute when + // Phase 5 lands. + implementation(project(":nestsClient")) + + // Coil's OkHttp network fetcher (JVM-only). iOS will use + // coil-ktor when the iOS Compose UI ships. + implementation(libs.coil.okhttp) + + // Markdown rendering (richtext-commonmark). The single + // consumer (RenderMarkdown.kt) already lives in jvmAndroid. + // iOS support pending Phase 3 markdown decision. + implementation(libs.markdown.commonmark) + implementation(libs.markdown.ui) + implementation(libs.markdown.ui.material3) + + // viewModel() Compose helper. AndroidX publishes this + // artifact for android/jvmStubs/linuxx64Stubs but not iOS, + // so it stays in jvmAndroid until we either swap to the + // org.jetbrains.androidx.lifecycle variant or accept a + // platform-specific ViewModel access pattern on iOS. + implementation(libs.androidx.lifecycle.viewmodel.compose) } } @@ -131,6 +157,14 @@ kotlin { } } + // iOS intermediate so iosArm64Main and iosSimulatorArm64Main share code. + val iosMain = + create("iosMain") { + dependsOn(commonMain.get()) + } + getByName("iosArm64Main").dependsOn(iosMain) + getByName("iosSimulatorArm64Main").dependsOn(iosMain) + getByName("androidHostTest") { dependencies { implementation(libs.junit) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24ca396591..fff496a4cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -109,6 +109,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "a androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxJunit" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3-windowSize = { group = "androidx.compose.material3", name = "material3-window-size-class" } From f1845d6a069d32f8fd84b63599dafccd9f25daeb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 00:44:38 +0000 Subject: [PATCH 21/33] feat(commons): add iOS actuals for KmpLock, WeakReference, isValidUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-stages the three iosMain actuals that the macOS CI run is most likely to demand once it compiles :commons for Native (the dev container can't extract the K/N LLVM toolchain to validate locally). - KmpLock.ios.kt: NSRecursiveLock — mirrors the ReentrantLock semantics the jvmAndroid actual exposes (reentrant per-thread). - WeakReference.ios.kt: actual typealias to kotlin.native.ref. WeakReference — same constructor + get(): T? shape as the jvmAndroid typealias to java.lang.ref.WeakReference. - UrlValidation.ios.kt: NSURL.URLWithString with an explicit scheme check, since NSURL is more permissive than JVM's URI.toURL() and accepts scheme-less relatives that the JVM contract rejects. Lands together so the next CI run's failure mode (if any) is more informative than "iosArm64 unresolved reference" three times over. --- .../amethyst/commons/util/KmpLock.ios.kt | 33 +++++++++++++++++++ .../commons/util/UrlValidation.ios.kt | 33 +++++++++++++++++++ .../commons/util/WeakReference.ios.kt | 23 +++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.ios.kt create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.ios.kt new file mode 100644 index 0000000000..71cfb217fe --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.ios.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import platform.Foundation.NSRecursiveLock + +actual class KmpLock { + // NSRecursiveLock mirrors ReentrantLock semantics on JVM/Android — a + // thread that already holds the lock can re-enter without deadlock. + private val delegate = NSRecursiveLock() + + actual fun lock() = delegate.lock() + + actual fun unlock() = delegate.unlock() +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt new file mode 100644 index 0000000000..76d9f11e0a --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import platform.Foundation.NSURL + +actual fun isValidUrl(url: String?): Boolean { + if (url == null) return false + // NSURL.URLWithString returns null for syntactically invalid URLs. It is + // more permissive than JVM's URI(url).toURL() — it accepts scheme-less + // relative URLs ("foo", "/bar"). Match the JVM contract (absolute URL + // with a known scheme) by also requiring a non-empty scheme. + val nsUrl = NSURL.URLWithString(url) ?: return false + return !nsUrl.scheme.isNullOrEmpty() +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt new file mode 100644 index 0000000000..0d42a09d2a --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +actual typealias WeakReference = kotlin.native.ref.WeakReference From d1749c314f34a1c6a55e7f4041cb40bd2063b660 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 02:17:32 +0000 Subject: [PATCH 22/33] fix: audit findings on iOS-readiness migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address bugs and gaps surfaced by an audit of the prior 14 commits. JVM tests passed because of typealias / platform-type lenience that won't hold on Native; these are real iOS compile / behavior issues. BUG fixes (iOS compile failures): - commons/.../Note.kt:899 — Iterable.sumOf { -> BigDecimal } is a JVM-stdlib-only overload. Common stdlib ships sumOf only for Int/Long/Double/Float/UInt/ULong. Replaced with fold(BigDecimal(0)). - commons/.../Note.kt:889 — BigDecimal(it.event?.content): the quartz expect-class constructor takes String non-null; JVM accepted nullable via platform-type lenience and threw NPE caught downstream. Switched to ?.let { content -> BigDecimal(content) }. - commons/.../Note.kt:838 — `catch (e: java.lang.Exception)` -> `Exception`. - commons/.../feeds/custom/FeedDefinitionBuilder.kt + FeedBuilderState.kt: inline FQN `java.util.UUID.randomUUID().toString()` -> kotlin.uuid.Uuid. random().toString() (Kotlin 2.0+, @OptIn ExperimentalUuidApi). inline `System.currentTimeMillis() / 1000` -> TimeUtils.now() (already used elsewhere in the codebase). - commons/.../viewmodels/NestViewModelTest.kt: moved from commonTest to jvmTest. The test imports NestViewModel + nestsclient, both of which the prior PR moved to jvmAndroid. commonTest depends on commonMain only, so the test would fail to compile for iosSimulatorArm64Test. SUBTLE fixes: - commons/.../UserRelaysCache.kt: the flow field used double-checked locking on a non-volatile var. JMM hazard on Native (ARM weak memory model) — outer fast-path could observe a partially-published WeakReference. Added @kotlin.concurrent.Volatile. - commons/.../util/UrlValidation.ios.kt: NSURL.URLWithString("http:") returns non-null with scheme="http" and no host; JVM's URI.toURL() rejects with MalformedURLException. Reject scheme-only network URLs (http/https/ws/wss/ftp without a host) to match JVM behavior. - commons/.../util/KmpLock.kt commonMain doc: corrected "NSLock" -> "NSRecursiveLock" to match the actual iOS implementation. verifyKmpPurity gate extended (commons + quartz): - Adds patterns: System.currentTimeMillis, Thread.sleep, java.util.UUID, kotlin.jvm.Synchronized, kotlin.jvm.Volatile. - Each pattern paired with a hint pointing at the canonical KMP replacement; the error message surfaces both. - Skips lines that start with //, *, or /* to avoid false positives on KDoc / migration notes. --- commons/build.gradle.kts | 29 +++++++++++++++---- .../commons/feeds/custom/FeedBuilderState.kt | 11 +++---- .../feeds/custom/FeedDefinitionBuilder.kt | 11 +++---- .../amethyst/commons/model/Note.kt | 12 ++++++-- .../model/nip01Core/UserRelaysCache.kt | 6 ++++ .../amethyst/commons/util/KmpLock.kt | 2 +- .../commons/util/UrlValidation.ios.kt | 15 ++++++++-- .../commons/viewmodels/NestViewModelTest.kt | 0 quartz/build.gradle.kts | 27 +++++++++++++---- 9 files changed, 84 insertions(+), 29 deletions(-) rename commons/src/{commonTest => jvmTest}/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt (100%) diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 1205ae2ad9..cb8d7dc1a8 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -212,24 +212,41 @@ val verifyKmpPurity by tasks.registering { .filter { it.exists() } inputs.files(checkedDirs) doLast { - val forbidden = listOf("com.fasterxml.jackson", "okhttp3") + // Each pattern is paired with a short hint so the failure message + // points at the canonical KMP replacement. + val forbidden = + listOf( + "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization", + "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS", + "System.currentTimeMillis" to "use TimeUtils.now()", + "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", + "java.util.UUID" to "use kotlin.uuid.Uuid", + "kotlin.jvm.Synchronized" to "use KmpLock.withLock {}", + "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", + ) val offenders = checkedDirs.flatMap { dir -> dir.walkTopDown() .filter { it.isFile && it.extension == "kt" } .flatMap { file -> file.readLines().withIndex().mapNotNull { (idx, line) -> - forbidden.firstOrNull { line.contains(it) }?.let { hit -> - "${file.relativeTo(rootDir)}:${idx + 1}: '$hit'" + val trimmed = line.trimStart() + // Skip KDoc / line-comment lines — those legitimately + // mention forbidden names (migration notes, doc refs). + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { + return@mapNotNull null + } + forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) -> + "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint" } } } } if (offenders.isNotEmpty()) { throw GradleException( - "iOS-targeted source sets must not reference JVM-only deps " + - "(Jackson, OkHttp). Move the offending code to jvmAndroid/ " + - "or behind an expect/actual:\n " + offenders.joinToString("\n "), + "iOS-targeted source sets must not reference JVM-only APIs. " + + "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " + + offenders.joinToString("\n "), ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt index 9417caffc7..285ffc2b66 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt @@ -26,7 +26,10 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid @Stable class FeedBuilderState( @@ -66,6 +69,7 @@ class FeedBuilderState( private val editId: String? = initial?.id + @OptIn(ExperimentalUuidApi::class) fun toDefinition(): FeedDefinition { val source = FeedSource.Filter( @@ -77,17 +81,14 @@ class FeedBuilderState( kinds = kinds.toImmutableList(), ) return FeedDefinition( - id = - editId ?: java.util.UUID - .randomUUID() - .toString(), + id = editId ?: Uuid.random().toString(), name = name, emoji = emoji, pinned = false, pinOrder = Int.MAX_VALUE, source = source, refreshMode = refreshMode, - createdAt = System.currentTimeMillis() / 1000, + createdAt = TimeUtils.now(), ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt index 58d72f665b..71a6dab603 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.amethyst.commons.feeds.custom import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid class FeedDefinitionBuilder { var name: String = "" @@ -70,13 +73,11 @@ class FeedDefinitionBuilder { pinOrder = Int.MAX_VALUE, source = source ?: error("FeedDefinition requires a source"), refreshMode = refreshMode, - createdAt = System.currentTimeMillis() / 1000, + createdAt = TimeUtils.now(), ) - private fun generateId(): String = - java.util.UUID - .randomUUID() - .toString() + @OptIn(ExperimentalUuidApi::class) + private fun generateId(): String = Uuid.random().toString() } class FilterBuilder { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index bc131fee37..88b5505385 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -835,7 +835,7 @@ open class Note( val amount = try { LnInvoiceUtil.getAmountInSats(invoice) - } catch (e: java.lang.Exception) { + } catch (e: Exception) { if (e is CancellationException) throw e null } @@ -886,7 +886,7 @@ open class Note( .any { val pledgeValue = try { - BigDecimal(it.event?.content) + it.event?.content?.let { content -> BigDecimal(content) } } catch (e: Exception) { if (e is CancellationException) throw e null @@ -896,7 +896,13 @@ open class Note( pledgeValue != null && it.author == user } - fun pledgedAmountByOthers(): BigDecimal = replies.sumOf { it.event?.addedRewardValue() ?: BigDecimal(0) } + // Manual fold rather than Iterable.sumOf { -> BigDecimal } because that + // overload is JVM-only; the common stdlib only ships sumOf for the + // primitive numeric types. + fun pledgedAmountByOthers(): BigDecimal = + replies.fold(BigDecimal(0)) { acc, note -> + acc + (note.event?.addedRewardValue() ?: BigDecimal(0)) + } fun hasAnyReports(): Boolean { val dayAgo = TimeUtils.oneDayAgo() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt index a1c5385b37..8622d1b5f7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.concurrent.Volatile @Stable data class RelayInfo( @@ -54,6 +55,11 @@ val DefaultOrder = @Stable class UserRelaysCache { var data: Map = mapOf() + + // @Volatile is required for the double-checked locking in flow() to be + // safe on weak-memory platforms (K/N + ARM). Without it, the outer + // fast-path read could observe a partially-published WeakReference. + @Volatile private var flow: WeakReference>? = null private val flowLock = KmpLock() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt index b6a5ac0a66..ad2622de74 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.util /** * KMP-friendly reentrant lock. JVM/Android map to `java.util.concurrent.locks.ReentrantLock`; - * iOS will map to `NSLock` (or a thin wrapper around it) when the target is added. + * iOS maps to `NSRecursiveLock`. * * Use [withLock] in preference to manual lock/unlock — it guarantees release on * exception. The class is reentrant on every platform, so a thread that already diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt index 76d9f11e0a..c72e2da008 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt @@ -22,12 +22,21 @@ package com.vitorpamplona.amethyst.commons.util import platform.Foundation.NSURL +// Schemes that JVM's URI.toURL() requires a host for (network schemes). +// "http:" without a host throws MalformedURLException on JVM; NSURL accepts +// it. Explicitly reject to keep behavior aligned. +private val HOST_REQUIRED_SCHEMES = setOf("http", "https", "ws", "wss", "ftp") + actual fun isValidUrl(url: String?): Boolean { if (url == null) return false // NSURL.URLWithString returns null for syntactically invalid URLs. It is // more permissive than JVM's URI(url).toURL() — it accepts scheme-less - // relative URLs ("foo", "/bar"). Match the JVM contract (absolute URL - // with a known scheme) by also requiring a non-empty scheme. + // relative URLs ("foo", "/bar") and scheme-only inputs ("http:"). Match + // the JVM contract (absolute URL with a known scheme and, for network + // schemes, a host). val nsUrl = NSURL.URLWithString(url) ?: return false - return !nsUrl.scheme.isNullOrEmpty() + val scheme = nsUrl.scheme?.lowercase() ?: return false + if (scheme.isEmpty()) return false + if (scheme in HOST_REQUIRED_SCHEMES && nsUrl.host.isNullOrEmpty()) return false + return true } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt similarity index 100% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 121dd4a2f7..9652b8df67 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -386,24 +386,39 @@ val verifyKmpPurity by tasks.registering { .filter { it.exists() } inputs.files(checkedDirs) doLast { - val forbidden = listOf("com.fasterxml.jackson", "okhttp3") + // Each pattern is paired with a short hint so the failure message + // points at the canonical KMP replacement. + val forbidden = + listOf( + "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization", + "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS", + "System.currentTimeMillis" to "use TimeUtils.now()", + "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", + "java.util.UUID" to "use kotlin.uuid.Uuid", + "kotlin.jvm.Synchronized" to "use a KMP lock primitive", + "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", + ) val offenders = checkedDirs.flatMap { dir -> dir.walkTopDown() .filter { it.isFile && it.extension == "kt" } .flatMap { file -> file.readLines().withIndex().mapNotNull { (idx, line) -> - forbidden.firstOrNull { line.contains(it) }?.let { hit -> - "${file.relativeTo(rootDir)}:${idx + 1}: '$hit'" + val trimmed = line.trimStart() + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { + return@mapNotNull null + } + forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) -> + "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint" } } } } if (offenders.isNotEmpty()) { throw GradleException( - "iOS-targeted source sets must not reference JVM-only deps " + - "(Jackson, OkHttp). Move the offending code to jvmAndroid/ " + - "or behind an expect/actual:\n " + offenders.joinToString("\n "), + "iOS-targeted source sets must not reference JVM-only APIs. " + + "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " + + offenders.joinToString("\n "), ) } } From 4d532e069856ed6a2674540c2a53e3a40e6ed465 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 25 May 2026 06:43:21 +0300 Subject: [PATCH 23/33] fix(desktop): wire onHashtagClick, add RTL support, add identity fields - Add onHashtagClick param to FeedNoteCard for hashtag navigation - RTL paragraph alignment in DesktopRichTextViewer - External identities (Twitter, GitHub, Mastodon) on profile card Co-Authored-By: Claude --- .../amethyst/desktop/ui/FeedScreen.kt | 3 ++ .../amethyst/desktop/ui/UserProfileScreen.kt | 31 +++++++++++++++++++ .../desktop/ui/note/DesktopRichTextViewer.kt | 2 +- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index b8acdd51a3..35849914cb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -125,6 +125,7 @@ fun FeedNoteCard( onNavigateToThread: (String) -> Unit = {}, onImageClick: ((List, Int) -> Unit)? = null, onMediaClick: ((List, Int, Float) -> Unit)? = null, + onHashtagClick: ((String) -> Unit)? = null, ) { val event = note.event ?: return val isRepost = event is RepostEvent || event is GenericRepostEvent @@ -194,6 +195,7 @@ fun FeedNoteCard( onClick = { onNavigateToThread(originalEvent.id) }, onAuthorClick = onNavigateToProfile, onMentionClick = onNavigateToProfile, + onHashtagClick = onHashtagClick, onImageClick = onImageClick, onMediaClick = onMediaClick, ) @@ -243,6 +245,7 @@ fun FeedNoteCard( onClick = { onNavigateToThread(event.id) }, onAuthorClick = onNavigateToProfile, onMentionClick = onNavigateToProfile, + onHashtagClick = onHashtagClick, onImageClick = onImageClick, onMediaClick = onMediaClick, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 2aa85742a2..27060880ba 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -99,6 +99,10 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity +import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity +import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity +import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import kotlinx.coroutines.Dispatchers @@ -163,6 +167,11 @@ fun UserProfileScreen( ) } var lnAddress by remember { mutableStateOf(cachedMetadata?.lnAddress()) } + var identities by remember { + mutableStateOf( + cachedMetadata?.flow?.value?.identities ?: emptyList(), + ) + } var followersCount by remember { mutableStateOf(localCache.getCachedFollowerCount(pubKeyHex)) } var followingCount by remember { mutableStateOf(localCache.getCachedFollowingCount(pubKeyHex)) } @@ -286,6 +295,7 @@ fun UserProfileScreen( nip05 = metadata.nip05 website = metadata.website lnAddress = metadata.lnAddress() + identities = event.identityClaims() } // Store MetadataEvent for editing (only for own profile) @@ -747,6 +757,27 @@ fun UserProfileScreen( ) } + identities.forEach { identity -> + Spacer(Modifier.height(4.dp)) + ProfileMetadataField( + text = + when (identity) { + is TwitterIdentity -> "@${identity.identity}" + is GitHubIdentity -> identity.identity + is MastodonIdentity -> identity.identity + else -> identity.identity + }, + icon = MaterialSymbols.Language, + onClick = { + runCatching { + java.awt.Desktop.getDesktop().browse( + java.net.URI(identity.toProofUrl()), + ) + } + }, + ) + } + Spacer(Modifier.height(12.dp)) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt index ecd357b1df..3161a53f16 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt @@ -188,7 +188,7 @@ fun DesktopRichTextViewer( else -> { FlowRow( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, + horizontalArrangement = if (paragraph.isRTL) Arrangement.End else Arrangement.Start, ) { for (word in paragraph.words) { RenderSegment(word, state, localCache, callbacks) From d6875a144bad84c1db8a5b2c9ddcaf962f9464c8 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 25 May 2026 09:35:46 +0300 Subject: [PATCH 24/33] feat(desktop): add @mention autocomplete to compose dialog Type @ followed by a name in the compose/reply dialog to see a dropdown of matching users from the local cache. Selecting a user inserts their nostr:npub reference. Shows avatar + display name + truncated npub. Co-Authored-By: Claude --- .../vitorpamplona/amethyst/desktop/Main.kt | 1 + .../amethyst/desktop/ui/ComposeNoteDialog.kt | 152 ++++++++++++++++-- .../amethyst/desktop/ui/FeedScreen.kt | 1 + .../amethyst/desktop/ui/NoteActions.kt | 1 + 4 files changed, 138 insertions(+), 17 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 2797a26071..a314a3449a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1043,6 +1043,7 @@ fun App( onDismiss = onDismissComposeDialog, relayManager = relayManager, account = account, + localCache = localCache, replyTo = replyToNote, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index ab5a6c3ef7..9d8f5d3b65 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -21,24 +21,32 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -46,14 +54,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadResult +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager @@ -98,6 +111,7 @@ fun ComposeNoteDialog( onDismiss: () -> Unit, relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, + localCache: com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache? = null, replyTo: Event? = null, quoteOf: Event? = null, ) { @@ -111,8 +125,43 @@ fun ComposeNoteDialog( "" } } - var content by remember { mutableStateOf(initialContent) } + var contentField by remember { + mutableStateOf( + TextFieldValue( + text = initialContent, + selection = TextRange(initialContent.length), + ), + ) + } + val content = contentField.text var isPosting by remember { mutableStateOf(false) } + + // Mention autocomplete state + var mentionSuggestions by remember { mutableStateOf>(emptyList()) } + var mentionQuery by remember { mutableStateOf(null) } + var mentionWordStart by remember { mutableStateOf(0) } + + LaunchedEffect(contentField) { + val cursor = contentField.selection.end + val text = contentField.text + if (cursor > 0 && localCache != null) { + // Find the word at cursor + val wordStart = text.lastIndexOf(' ', cursor - 1) + 1 + val wordAtCursor = text.substring(wordStart, cursor) + if (wordAtCursor.startsWith("@") && wordAtCursor.length > 1) { + val query = wordAtCursor.removePrefix("@") + mentionQuery = query + mentionWordStart = wordStart + mentionSuggestions = localCache.findUsersStartingWith(query, 5) + } else { + mentionQuery = null + mentionSuggestions = emptyList() + } + } else { + mentionQuery = null + mentionSuggestions = emptyList() + } + } var errorMessage by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val attachedFiles = remember { mutableStateListOf() } @@ -208,22 +257,55 @@ fun ComposeNoteDialog( Spacer(Modifier.height(16.dp)) - OutlinedTextField( - value = if (postAsPicture) "" else content, - onValueChange = { - content = it - errorMessage = null - }, - modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp), - label = { - Text( - if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?", - ) - }, - placeholder = { Text(if (postAsPicture) "" else "Write your note...") }, - enabled = !isPosting && !postAsPicture, - maxLines = if (postAsPicture) 1 else 10, - ) + Box { + OutlinedTextField( + value = if (postAsPicture) TextFieldValue("") else contentField, + onValueChange = { + contentField = it + errorMessage = null + }, + modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp), + label = { + Text( + if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?", + ) + }, + placeholder = { Text(if (postAsPicture) "" else "Write your note... (type @ to mention)") }, + enabled = !isPosting && !postAsPicture, + maxLines = if (postAsPicture) 1 else 10, + ) + + // Mention autocomplete dropdown + if (mentionSuggestions.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) { + items(mentionSuggestions, key = { it.pubkeyHex }) { user -> + MentionSuggestionRow( + user = user, + onClick = { + val npub = user.pubkeyNpub() + val replacement = "nostr:$npub " + val cursorEnd = contentField.selection.end + val newText = + contentField.text.replaceRange( + mentionWordStart, + cursorEnd, + replacement, + ) + val newCursor = mentionWordStart + replacement.length + contentField = TextFieldValue(newText, TextRange(newCursor)) + mentionSuggestions = emptyList() + mentionQuery = null + }, + ) + } + } + } + } + } Spacer(Modifier.height(8.dp)) @@ -590,3 +672,39 @@ private suspend fun publishNote( relayManager.publish(signedEvent, relays) } } + +@Composable +private fun MentionSuggestionRow( + user: User, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserAvatar( + userHex = user.pubkeyHex, + pictureUrl = user.profilePicture(), + size = 28.dp, + contentDescription = null, + ) + Column { + Text( + text = user.toBestDisplayName(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + Text( + text = user.pubkeyNpub().take(24) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 849a4a4abc..4bc55e7cee 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -689,6 +689,7 @@ fun FeedScreen( onDismiss = { replyToEvent = null }, relayManager = relayManager, account = account, + localCache = localCache, replyTo = replyToEvent, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 84f3602358..a515977d43 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -1407,6 +1407,7 @@ fun NoteActionsRow( onDismiss = { quoteEvent = null }, relayManager = relayManager, account = account, + localCache = localCache, quoteOf = quoteEvent, ) } From 64293f2fcc047d26e97ab8051ac75aaabf9aa766 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 25 May 2026 09:50:43 +0300 Subject: [PATCH 25/33] fix(desktop): fix text spacing in rich text viewer Pure text paragraphs now render as a single Text composable instead of individual words in FlowRow, eliminating unwanted inter-word gaps. Co-Authored-By: Claude --- .../desktop/ui/note/DesktopRichTextViewer.kt | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt index 3161a53f16..9039978f28 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt @@ -186,12 +186,22 @@ fun DesktopRichTextViewer( } else -> { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (paragraph.isRTL) Arrangement.End else Arrangement.Start, - ) { - for (word in paragraph.words) { - RenderSegment(word, state, localCache, callbacks) + val hasOnlyText = paragraph.words.all { it is RegularTextSegment } + if (hasOnlyText) { + Text( + text = paragraph.words.joinToString("") { it.segmentText }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) + } else { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (paragraph.isRTL) Arrangement.End else Arrangement.Start, + ) { + for (word in paragraph.words) { + RenderSegment(word, state, localCache, callbacks) + } } } } From 46c46aa69e41584a78750b5a6bfef9a309480d82 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 25 May 2026 20:49:32 +0200 Subject: [PATCH 26/33] i18n: translate missing cs/de/sv strings (apps, on-chain zap, legal, NIP-82) Co-Authored-By: Claude Opus 4.7 (1M context) --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 9 +++++++++ amethyst/src/main/res/values-de-rDE/strings.xml | 8 ++++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 8 ++++++++ 3 files changed, 25 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 8f751e8640..96299a590e 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -2790,4 +2790,13 @@ Nowhere ZprĂĄva Nowhere UměnĂ­ Nowhere FĂłrum + O aplikaci a prĂĄvnĂ­ informace + Standardy ochrany dětĂ­ + StĂĄhnout + Zdroj: %1$s + v%1$s + Nowhere Drop + On-chain Bitcoin zap + Aplikace + Aplikace diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index cd69d1a59f..59c1954d47 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2761,4 +2761,12 @@ anz der Bedingungen ist erforderlich Nowhere Shop Nowhere Nachricht Nowhere Kunst + Über & Rechtliches + Kinderschutzstandards + Herunterladen + Quelle: %1$s + v%1$s + Nowhere Drop + Apps + Apps diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 010fb91c5a..b32f861885 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2757,4 +2757,12 @@ Nowhere Namninsamling Nowhere Meddelande Nowhere Konst + Om och juridiskt + Standarder för barnsĂ€kerhet + Ladda ner + KĂ€lla: %1$s + v%1$s + Nowhere Drop + Appar + Appar From 61372313fdd01b16a15a0fad3822c8a9c0d57736 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 25 May 2026 18:55:58 +0000 Subject: [PATCH 27/33] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 15 ++++++--------- amethyst/src/main/res/values-de-rDE/strings.xml | 12 ++++-------- amethyst/src/main/res/values-hu-rHU/strings.xml | 11 +++++++++++ amethyst/src/main/res/values-pl-rPL/strings.xml | 7 +++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 14 ++++++-------- amethyst/src/main/res/values-zh-rCN/strings.xml | 10 ++++++++++ 6 files changed, 44 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 96299a590e..1126d462ad 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -562,6 +562,10 @@ Vyberte, kterĂ© z odznakĆŻ, kterĂ© jste obdrĆŸeli, se zobrazĂ­ na vaĆĄem profilu. ZatĂ­m jste neobdrĆŸeli ĆŸĂĄdnĂ© odznaky. ObrĂĄzky + Aplikace + Aplikace + Zdroj: %1$s + StĂĄhnout Kalendáƙe KrĂĄtkĂĄ videa VeƙejnĂ© chaty @@ -1199,6 +1203,8 @@ Politika pƙíspěvkĆŻ ZĂĄsady ochrany soukromĂ­ PodmĂ­nky & ujednĂĄnĂ­ + Standardy ochrany dětĂ­ + O aplikaci a prĂĄvnĂ­ informace N/A Chyby a upozorněnĂ­ z tohoto relĂ© ZprĂĄvy monitoru relĂ© @@ -2790,13 +2796,4 @@ Nowhere ZprĂĄva Nowhere UměnĂ­ Nowhere FĂłrum - O aplikaci a prĂĄvnĂ­ informace - Standardy ochrany dětĂ­ - StĂĄhnout - Zdroj: %1$s - v%1$s - Nowhere Drop - On-chain Bitcoin zap - Aplikace - Aplikace diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 59c1954d47..fe8e93fc93 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -556,6 +556,8 @@ anz der Bedingungen ist erforderlich WĂ€hle aus, welche deiner erhaltenen Abzeichen auf deinem Profil erscheinen sollen. Du hast noch keine Abzeichen erhalten. Bilder + Quelle: %1$s + Herunterladen Kalender Kurzvideos Öffentliche Chats @@ -1190,6 +1192,8 @@ anz der Bedingungen ist erforderlich Veröffentlichungsrichtlinie DatenschutzerklĂ€rung Allgemeine GeschĂ€ftsbedingungen + Kinderschutzstandards + Über & Rechtliches N/A Fehler und Hinweise von diesem Relais Relay-Überwachungsberichte @@ -2761,12 +2765,4 @@ anz der Bedingungen ist erforderlich Nowhere Shop Nowhere Nachricht Nowhere Kunst - Über & Rechtliches - Kinderschutzstandards - Herunterladen - Quelle: %1$s - v%1$s - Nowhere Drop - Apps - Apps diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 96415b3792..a991324f8f 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -12,6 +12,10 @@ Ez a bejegyzĂ©s több mint %1$d kulcsszĂłt tartalmaz Ez a bejegyzĂ©s több mint %1$d kulcsszĂłt tartalmaz + + %1$d asset egybecsomagolva + %1$d asset egybecsomagolva + Az esemĂ©ny Ă©pp betöltƑdik vagy nem talĂĄlhatĂł az ĂĄtjĂĄtszĂłlistĂĄban 👀 Csatorna profilkĂ©pe @@ -552,6 +556,11 @@ VĂĄlassza ki, hogy a megszerzett kitƱzƑk közĂŒl melyek jelenjenek meg a profilban. Ön mĂ©g nem kapott kitƱzƑt. KĂ©pek + AlkalmazĂĄsok + AlkalmazĂĄsok + ForrĂĄs: %1$s + v%1$s + LetöltĂ©s NaptĂĄrak Rövidek NyilvĂĄnos csevegĂ©sek @@ -1189,6 +1198,8 @@ KözzĂ©tĂ©teli szabĂĄlyzat AdatvĂ©delmi irĂĄnyelvek ÁltalĂĄnos szerzƑdĂ©si feltĂ©telek + GyermekbiztonsĂĄgi szabvĂĄnyok + NĂ©vjegy & Jogi informĂĄciĂłk Nem Ă©rhetƑ el HibĂĄk Ă©s megjegyzĂ©sek ettƑl az ĂĄtjĂĄtszĂłtĂłl ÁtjĂĄtszĂłfigyelĂ©si jelentĂ©sek diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 5f9ee02a4d..b25b0ffc3d 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -562,6 +562,11 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopĂłki to konto jest Wybierz, ktĂłre z otrzymanych odznak pojawią się na Twoim profilu. Nie otrzymaƂeƛ jeszcze ĆŒadnych odznak. Zdjęcia + Aplikacje + Aplikacje + ĆčrĂłdƂo: %1$s + v%1$s + Pobierz Kalendarze Filmiki Czaty publiczne @@ -1201,6 +1206,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopĂłki to konto jest Polityka publikowania Polityka Prywatnoƛci Zasady i warunki uĆŒytkowania + Normy bezpieczeƄstwa dzieci + O Nas & Informacje Prawne Nie dotyczy BƂędy i powiadomienia z tego transmitera Raporty z monitoringu transmitera diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index b32f861885..6060576c3c 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -550,6 +550,10 @@ VĂ€lj vilka av mĂ€rkena du fĂ„tt som ska visas pĂ„ din profil. Du har inte fĂ„tt nĂ„gra mĂ€rken Ă€nnu. Bilder + Appar + Appar + KĂ€lla: %1$s + Ladda ner Kalendrar Kortfilmer Offentliga chattar @@ -1184,6 +1188,8 @@ Publiceringspolicy Integritetspolicy Villkor & bestĂ€mmelser + Standarder för barnsĂ€kerhet + Om och juridiskt N/A Fel och meddelanden frĂ„n detta relĂ€ RelÀövervakningsrapporter @@ -2757,12 +2763,4 @@ Nowhere Namninsamling Nowhere Meddelande Nowhere Konst - Om och juridiskt - Standarder för barnsĂ€kerhet - Ladda ner - KĂ€lla: %1$s - v%1$s - Nowhere Drop - Appar - Appar diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 1656bf9fde..0df7e136ba 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -11,6 +11,9 @@ æ­€ćž–æœ‰è¶…èż‡ %1$d äžȘèŻéą˜æ ‡ç­Ÿ + + 捆绑äș† %1$d äžȘ蔄äș§ + äș‹ä»¶æ­ŁćœšćŠ èœœæˆ–æ— æł•ćœšäœ çš„äž­ç»§ćˆ—èĄšäž­æ‰Ÿćˆ° 👀 éą‘é“ć›Ÿç‰‡ @@ -546,6 +549,11 @@ é€‰æ‹©èŠćœšäžȘäșș蔄料䞭星ç€ș的ć·Čæ”¶ćˆ°çš„ćŸœç« ă€‚ æ‚šèż˜æČĄæœ‰æ”¶ćˆ°ä»»äœ•ćŸœç« ă€‚ ć›Ÿç‰‡ + ćș”甚 + ćș”甚 + 杄æșïŒš %1$s + v%1$s + 䞋蜜 æ—„ćŽ† çŸ­è§†éą‘ ć…Źć…±èŠć€© @@ -1182,6 +1190,8 @@ ć‘ćžƒæ”żç­– éšç§æ”żç­– äœżç”šæĄæŹŸ + ć„żç«„ćź‰ć…šæ ‡ć‡† + 慳äșŽ & æł•ćŸ‹ N/A èŻ„äž­ç»§çš„é”™èŻŻć’Œé€šçŸ„ äž­ç»§ç›‘è§†ć™šæŠ„ć‘Š From 44aa2623639e13b6cdffb3e77b8b21ddebd50cb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 20:05:00 +0000 Subject: [PATCH 28/33] fix(commons): tighter Base64Image contract + pin serializer wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up cleanups from the audit. Base64Image.parse: when the regex matched but the data capture group was missing, the migrated version returned an empty ByteArray. The original threw NPE (java.util.Base64.getDecoder().decode(null)). Both behaviors are accidents — restore the intended contract: throw the existing "Unable to convert base64 to image" Exception explicitly. FeedDefinitionSerializerTest gains a serializesToExpectedWireFormat test that pins the byte-exact JSON output for a representative multi-field feed. The legacy-Jackson migration claimed byte-identity but only round-trip and reverse-compat were covered. Any future change to field ordering / null handling / number formatting now fails this test loudly, protecting users who have saved feeds on disk and any downstream consumer expecting the stable order. --- .../amethyst/commons/richtext/Base64Image.kt | 12 +++--- .../custom/FeedDefinitionSerializerTest.kt | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt index f64d8872df..09a67302dc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt @@ -30,12 +30,10 @@ object Base64Image { @OptIn(ExperimentalEncodingApi::class) fun parse(content: String): ByteArray { - val matcher = pattern.find(content) - if (matcher != null) { - val base64String = matcher.groups[2]?.value ?: "" - return Base64.decode(base64String) - } - - throw Exception("Unable to convert base64 to image $content") + val match = pattern.find(content) ?: throw Exception("Unable to convert base64 to image $content") + val base64String = + match.groups[2]?.value + ?: throw Exception("Unable to convert base64 to image $content") + return Base64.decode(base64String) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt index b82cce5228..afbbfb5379 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt @@ -212,6 +212,44 @@ class FeedDefinitionSerializerTest { assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" ")) } + @Test + fun serializesToExpectedWireFormat() { + // Pins the byte-exact JSON output so any future change to the + // serializer (field order, null handling, number formatting) is + // caught here. Users have feeds saved on disk in this exact format + // and may share JSON between app versions; downstream consumers + // depend on the field order being stable. + val feed = + FeedDefinition( + id = "test-1", + name = "Bitcoin", + emoji = "₿", + pinned = true, + pinOrder = 0, + source = + FeedSource.Filter( + hashtags = persistentListOf("bitcoin", "btc"), + authors = persistentListOf("abc123"), + relays = persistentListOf("wss://relay.damus.io"), + excludeAuthors = persistentListOf("spammer"), + excludeKeywords = persistentListOf("scam"), + kinds = persistentListOf(1, 6), + ), + refreshMode = RefreshMode.LIVE_STREAM, + createdAt = 1000L, + ) + + val expected = + """[{"id":"test-1","name":"Bitcoin","emoji":"₿","pinned":true,""" + + """"pinOrder":0,"refreshMode":"LIVE_STREAM","createdAt":1000,""" + + """"source":{"type":"filter","hashtags":["bitcoin","btc"],""" + + """"authors":["abc123"],"relays":["wss://relay.damus.io"],""" + + """"excludeAuthors":["spammer"],"excludeKeywords":["scam"],""" + + """"kinds":[1,6]}}]""" + + assertEquals(expected, FeedDefinitionSerializer.serializeList(listOf(feed))) + } + @Test fun parsesLegacyJacksonOutput() { // Wire format previously emitted by ObjectMapper. Users have feed From 027808ae54cf48ed9aa41bc747941153119257f4 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 26 May 2026 09:20:51 +0200 Subject: [PATCH 29/33] skills(find-missing-translations): filter out keys Crowdin already owns A "missing" key in values-/strings.xml is not always actionable: Crowdin omits source-identical translations on export (translator chose "use English" for brand terms like "Nowhere X", loanwords like "Apps", or version prefixes like "v%1$s"). Adding source-identical fallbacks locally is noise that the next Crowdin sync strips again; Android already falls back to values/strings.xml at runtime. Add a Step 2.5 sync-timestamp filter that uses the latest "New Crowdin translations by GitHub Action" commit reachable from HEAD as the cutoff. Keys added to values/strings.xml after that commit are genuinely new (Crowdin hasn't exported them yet); anything older is Crowdin's responsibility. The reachable-from-HEAD check survives the common workflow of deleting the l10n_crowdin_translations branch after merging. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../skills/find-missing-translations/SKILL.md | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md index f9bc30f72a..2bb00d90e9 100644 --- a/.claude/skills/find-missing-translations/SKILL.md +++ b/.claude/skills/find-missing-translations/SKILL.md @@ -15,6 +15,18 @@ Extract string resource keys from the default `values/strings.xml` that are abse - Preparing a batch of strings for a translator - Checking translation coverage after adding new features +## Background: Crowdin strip-identical behavior + +This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated. + +Consequences for this skill: + +1. **A "missing" key on disk is not always actionable.** It may be Crowdin-stripped (translator already chose source-identical and Crowdin didn't export it) rather than genuinely new. +2. **Don't add source-identical fallbacks locally.** Android's resource resolution falls back to `values/strings.xml` at runtime, so the user already sees the correct text. Local additions will be silently overwritten on Crowdin's next sync anyway. +3. **The only actionable cases are keys Crowdin has never exported.** Whether the translator picked "use English" or simply hasn't translated the key yet, both states are owned by Crowdin and look identical on disk. The local repo cannot distinguish them. + +The Step 2.5 filter below uses the **most recent Crowdin export commit reachable from `HEAD`** (subject: `"New Crowdin translations by GitHub Action"`) as the cutoff: any key added to `values/strings.xml` after that commit is genuinely new (Crowdin hasn't exported it yet); anything older is Crowdin's responsibility regardless of why it's missing. The reachable-from-HEAD check survives the common workflow of deleting the `l10n_crowdin_translations` branch after merging. + ## Target Locales The default set of locales (unless the user specifies otherwise): @@ -51,6 +63,59 @@ comm -23 \ This gives the list of missing key names. Do NOT diff each locale separately — assume the same keys are missing in all target locales. +> **Caveat:** Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys). If the cs-rCZ list looks suspiciously short, run the same diff for each target locale individually and union the results before Step 2.5. + +### 2.5. Filter out keys Crowdin has already seen (sync-timestamp check) + +A missing key is **only actionable if Crowdin has never exported it**. Once a key has been pushed to Crowdin and exported back, the translator may have chosen "use English" — Crowdin stores that choice in its own database and strips the entry from the exported `strings.xml`. From disk we cannot tell "translator picked English" from "Crowdin never saw the key": both look identical. + +The reliable signal is **time**: compare when the key was added to `values/strings.xml` against the timestamp of the **most recent Crowdin export that has been merged into the current branch**. Crowdin's GitHub Action produces commits with the literal subject `New Crowdin translations by GitHub Action`; finding the latest such commit reachable from `HEAD` works even if the `l10n_crowdin_translations` branch has been deleted post-merge (a common cleanup workflow). + +- Key added **before** that commit → Crowdin saw it on a prior export; translator made a decision; the absence on disk is a deliberate "use English" or "leave blank" choice. **Skip.** +- Key added **after** → Crowdin has not exported it yet; genuinely new and actionable. + +```bash +# Latest Crowdin export reachable from HEAD (survives branch deletion). +sync_ts=$(git log -1 --format=%ct --grep='^New Crowdin translations by GitHub Action$' 2>/dev/null) +if [ -z "$sync_ts" ]; then + echo "WARNING: no Crowdin export commit found in history; treating all missing as actionable" >&2 + sync_ts=0 +else + echo "Crowdin sync cutoff: $(git log -1 --format='%ci %h' --grep='^New Crowdin translations by GitHub Action$')" +fi + +# For each locale, list only keys added after the Crowdin sync (truly new). +for locale in cs-rCZ de-rDE sv-rSE; do + echo "=== $locale: genuinely new (post-sync) keys ===" + comm -23 \ + <(grep '` misses other resource types -- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere +- **Treating every missing key as actionable** — Crowdin strips on export any translation the translator marked as "use English", and we cannot distinguish that from "never seen" by looking at disk. Use the Step 2.5 sync-timestamp filter: only keys added to `values/strings.xml` after the last `l10n_crowdin_translations` sync are genuinely new. +- **Trying to detect "stripped" from git history alone** — the on-disk locale file only sees keys the translator typed a non-identical value for. The "translator opened the key and picked English from the start" case never touches disk, so a history-only check misses it. Use the sync-timestamp cutoff instead. +- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so there is no user-visible bug to fix. +- **Skipping per-locale diffs when only diffing cs-rCZ** — Crowdin can strip different keys in different locales (each translator's choice), so cs-rCZ is not a reliable upper bound. Diff each target locale, then apply the sync-timestamp filter. - **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately - **Hardcoding `"1"` in a `` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text - **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`) From 5ec60e285b6228a31299963019bdc163fd72cbcb Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 26 May 2026 13:34:22 +0200 Subject: [PATCH 30/33] fix(commons): unblock :commons iOS compile after Phase 2 target flip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3047 enabled iosArm64 + iosSimulatorArm64 on :commons and added :commons:compileKotlinIosSimulatorArm64 as a CI gate, but the Phase 2 migration was incomplete — JVM-only APIs survived in commonMain and several expect declarations had no iOS actual. Every main CI run since the merge failed at "Compile Commons for iOS". Migrations in commonMain - Dispatchers.IO: add `import kotlinx.coroutines.IO` to 16 files, matching the quartz/NostrClient.kt pattern (kotlinx-coroutines 1.11 exposes IO on Native via this import; no shim needed). - synchronized {}: replace with the existing KmpLock + withLock in EOSECache, AcceptedGamesRegistry, EventDeduplicator, ThumbHashDecoder, PeerSessionManager. Restructure two PeerSessionManager methods that late-init vals from inside the lock — withLock returns a tuple now. - Unicode code points: drop java.lang.Character / String.codePointAt / String.offsetByCodePoints. Add commons/util/CodePoints.kt with surrogate -pair-aware KMP helpers; rewrite EmojiCoder + EmojiUtils against them. - Byte<->String: encodeToByteArray() / decodeToString() / concatToString() in EmojiCoder, Base83, BlurHashEncoder, RobohashAssembler, LongFormPublishAction (drops Charsets / String(CharArray) / toByteArray no-arg). - Math.round → Double.roundToLong in BlurHashEncoder. - String.format → Compose Resources stringResource(res, vararg) overload in LoadingState (FeedErrorState). - toSortedSet → sortedByDescending { }.mapTo(LinkedHashSet) in Channel — preserves the descending-by-relay-count iteration order callers depend on. - Comparator: kotlin.Comparator on Native takes non-null T. Align CreatedAtComparator / CreatedAtComparatorAddresses to compare(a, b) and drop dead null checks in CreatedAtIdHexComparator. iOS actuals (commons/src/iosMain/) - WeakReference: switch from typealias to explicit `actual class`. The expect param is `referent` (matches java.lang.ref); kotlin.native.ref. WeakReference uses `referred`, so typealias fails the expect/actual name-match check on Native. Add @file:OptIn(ExperimentalNativeApi). - PlatformImage: functional IntArray-backed actual (used by BlurHash and ThumbHash decoders at runtime); Phase 3 will swap to CGImage. - ChessDismissedGamesStorage: in-memory only; NSUserDefaults wiring lands with iosApp in Phase 3. - SecureKeyStorage: stub throwing SecureStorageException. Keychain Services binding is Phase 4 per the iOS plan. - formattedDateTime: NSDateFormatter with "yyyy-MM-dd-HH:mm:ss" + POSIX locale + local time zone (semantically matches the JVM DateTimeFormatter "uuuu-MM-dd-HH:mm:ss" for post-1970 timestamps). - checkNotInMainThread: no-op (mirrors jvmMain). - PlatformNumberFormatter: NSNumberFormatter(.DecimalStyle), with NSNumber.numberWithLongLong to disambiguate the NSNumber(Long) overload set. - isDebug: false constant; iosApp can flip via Swift `DEBUG` flag later. Verified locally - :commons:compileKotlinIosSimulatorArm64 + compileKotlinIosArm64 green - :quartz:iosSimulatorArm64Test green - :commons:jvmTest + :quartz:jvmTest green (no JVM regression) - :quartz:verifyKmpPurity + :commons:verifyKmpPurity + spotlessCheck green Co-Authored-By: Claude Opus 4.7 (1M context) --- .../amethyst/commons/blurhash/Base83.kt | 2 +- .../commons/blurhash/BlurHashEncoder.kt | 7 +- .../commons/call/PeerSessionManager.kt | 53 +++++++------ .../commons/chess/AcceptedGamesRegistry.kt | 13 ++-- .../amethyst/commons/emojicoder/EmojiCoder.kt | 77 ++++++++----------- .../amethyst/commons/model/Channel.kt | 9 +-- .../model/emphChat/EphemeralChatListState.kt | 1 + .../nip02FollowList/Kind3FollowListState.kt | 1 + .../nip23LongContent/LongFormPublishAction.kt | 2 +- .../nip28PublicChats/PublicChatListState.kt | 1 + .../model/nip30CustomEmojis/EmojiPackState.kt | 1 + .../model/nip51Lists/BookmarkListState.kt | 1 + .../model/nip51Lists/OldBookmarkListState.kt | 1 + .../nip65RelayList/Nip65RelayListState.kt | 1 + .../model/observables/CreatedAtComparator.kt | 57 ++++++-------- .../model/trustedAssertions/UserCardsCache.kt | 1 + .../eoseManagers/BaseEoseManager.kt | 1 + .../amethyst/commons/relays/EOSECache.kt | 24 +++--- .../commons/robohash/RobohashAssembler.kt | 2 +- .../commons/search/EventDeduplicator.kt | 13 ++-- .../amethyst/commons/service/BundledUpdate.kt | 1 + .../commons/thumbhash/ThumbHashDecoder.kt | 16 ++-- .../commons/ui/components/LoadingState.kt | 2 +- .../commons/ui/feeds/FeedContentState.kt | 1 + .../commons/ui/signing/SigningState.kt | 1 + .../amethyst/commons/util/CodePoints.kt | 76 ++++++++++++++++++ .../amethyst/commons/util/EmojiUtils.kt | 10 +-- .../commons/viewmodels/FeedViewModel.kt | 1 + .../viewmodels/ListChangeFeedViewModel.kt | 1 + .../LiveStreamTopZappersViewModel.kt | 1 + .../viewmodels/thread/LevelFeedViewModel.kt | 1 + .../commons/blurhash/PlatformImage.ios.kt | 76 ++++++++++++++++++ .../chess/ChessDismissedGamesStorage.ios.kt | 44 +++++++++++ .../keystorage/SecureKeyStorage.ios.kt | 40 ++++++++++ .../model/ThreadLevelCalculator.ios.kt | 42 ++++++++++ .../commons/threading/Threading.ios.kt | 28 +++++++ .../util/PlatformNumberFormatter.ios.kt | 37 +++++++++ .../commons/util/WeakReference.ios.kt | 10 ++- .../amethyst/commons/utils/DebugUtils.ios.kt | 25 ++++++ 39 files changed, 527 insertions(+), 154 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CodePoints.kt create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.ios.kt create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.ios.kt create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.ios.kt create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.ios.kt create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.ios.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt index efa37a1408..ec75e7f01f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt @@ -37,7 +37,7 @@ object Base83 { ): String { val buffer = CharArray(length) encode(value, length, buffer, 0) - return String(buffer) + return buffer.concatToString() } fun encode( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt index 529ab2fdac..e1c299749a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt @@ -27,6 +27,7 @@ import kotlin.math.floor import kotlin.math.max import kotlin.math.min import kotlin.math.pow +import kotlin.math.roundToLong import kotlin.math.withSign class BlurHashEncoder { @@ -42,7 +43,7 @@ class BlurHashEncoder { val quantR = floor(max(0.0, min(18.0, floor(signPow(value[0] / maximumValue, 0.5) * 9 + 9.5)))) val quantG = floor(max(0.0, min(18.0, floor(signPow(value[1] / maximumValue, 0.5) * 9 + 9.5)))) val quantB = floor(max(0.0, min(18.0, floor(signPow(value[2] / maximumValue, 0.5) * 9 + 9.5)))) - return Math.round(quantR * 19 * 19 + quantG * 19 + quantB) + return (quantR * 19 * 19 + quantG * 19 + quantB).roundToLong() } private fun encodeDC(value: DoubleArray): Long { @@ -126,7 +127,7 @@ class BlurHashEncoder { val actualMaximumValue = max(factors, 1, factors.size) val quantisedMaximumValue = floor(max(0.0, min(82.0, floor(actualMaximumValue * 166 - 0.5)))) maximumValue = (quantisedMaximumValue + 1) / 166 - encode(Math.round(quantisedMaximumValue), 1, hash, 1) + encode(quantisedMaximumValue.roundToLong(), 1, hash, 1) } else { maximumValue = 1.0 encode(0, 1, hash, 1) @@ -138,6 +139,6 @@ class BlurHashEncoder { for (i in 1 until factors.size) { encode(encodeAC(factors[i], maximumValue), 2, hash, 6 + 2 * (i - 1)) } - return String(hash) + return hash.concatToString() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt index 3758f90c6b..75b56ae8fb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.call +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey /** @@ -46,7 +48,7 @@ class PeerSessionManager( val pendingIceCandidates: MutableList = mutableListOf(), ) - private val lock = Any() + private val lock = KmpLock() private val sessions = mutableMapOf() /** Candidates received before a session exists for the sender. */ @@ -58,7 +60,7 @@ class PeerSessionManager( peerPubKey: HexKey, session: PeerSession, ): SessionEntry = - synchronized(lock) { + lock.withLock { val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList() val entry = SessionEntry(session) entry.pendingIceCandidates.addAll(globalPending) @@ -66,17 +68,17 @@ class PeerSessionManager( entry } - fun getSession(peerPubKey: HexKey): SessionEntry? = synchronized(lock) { sessions[peerPubKey] } + fun getSession(peerPubKey: HexKey): SessionEntry? = lock.withLock { sessions[peerPubKey] } - fun hasSession(peerPubKey: HexKey): Boolean = synchronized(lock) { sessions.containsKey(peerPubKey) } + fun hasSession(peerPubKey: HexKey): Boolean = lock.withLock { sessions.containsKey(peerPubKey) } fun removeSession(peerPubKey: HexKey): SessionEntry? = - synchronized(lock) { + lock.withLock { globalPendingIce.remove(peerPubKey) sessions.remove(peerPubKey) } - fun allSessionKeys(): Set = synchronized(lock) { sessions.keys.toSet() } + fun allSessionKeys(): Set = lock.withLock { sessions.keys.toSet() } // ---- ICE candidate routing (two-layer buffering) ---- @@ -92,7 +94,7 @@ class PeerSessionManager( senderPubKey: HexKey, candidate: IceCandidateData, ): IceRouteAction = - synchronized(lock) { + lock.withLock { val entry = sessions[senderPubKey] when { entry != null && entry.remoteDescriptionSet -> { @@ -117,21 +119,21 @@ class PeerSessionManager( * Called after setRemoteDescription succeeds. */ fun flushPendingIceCandidates(peerPubKey: HexKey): Int { - val candidates: List - val entry: SessionEntry - synchronized(lock) { - entry = sessions[peerPubKey] ?: return 0 - entry.remoteDescriptionSet = true - candidates = entry.pendingIceCandidates.toList() - entry.pendingIceCandidates.clear() - } + val (entry, candidates) = + lock.withLock { + val entry = sessions[peerPubKey] ?: return@withLock null + entry.remoteDescriptionSet = true + val candidates = entry.pendingIceCandidates.toList() + entry.pendingIceCandidates.clear() + entry to candidates + } ?: return 0 candidates.forEach { entry.session.addIceCandidate(it) } return candidates.size } - fun globalPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { globalPendingIce[peerPubKey]?.size ?: 0 } + fun globalPendingCount(peerPubKey: HexKey): Int = lock.withLock { globalPendingIce[peerPubKey]?.size ?: 0 } - fun sessionPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0 } + fun sessionPendingCount(peerPubKey: HexKey): Int = lock.withLock { sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0 } // ---- Renegotiation glare handling ---- @@ -147,7 +149,7 @@ class PeerSessionManager( remoteSdpOffer: String, onAcceptRemote: (SessionEntry) -> Unit, ): GlareResolution { - val entry = synchronized(lock) { sessions[peerPubKey] } ?: return GlareResolution.NO_SESSION + val entry = lock.withLock { sessions[peerPubKey] } ?: return GlareResolution.NO_SESSION val signalingState = entry.session.getSignalingState() if (signalingState != SignalingState.HAVE_LOCAL_OFFER) { @@ -184,7 +186,7 @@ class PeerSessionManager( peerPubKey: HexKey, sdpAnswer: String, ): AnswerRouteAction { - val entry = synchronized(lock) { sessions[peerPubKey] } ?: return AnswerRouteAction.NO_SESSION + val entry = lock.withLock { sessions[peerPubKey] } ?: return AnswerRouteAction.NO_SESSION val signalingState = entry.session.getSignalingState() if (signalingState != SignalingState.HAVE_LOCAL_OFFER) { @@ -199,12 +201,13 @@ class PeerSessionManager( // ---- Cleanup ---- fun disposeAll() { - val entries: List - synchronized(lock) { - entries = sessions.values.toList() - sessions.clear() - globalPendingIce.clear() - } + val entries = + lock.withLock { + val snapshot = sessions.values.toList() + sessions.clear() + globalPendingIce.clear() + snapshot + } for (entry in entries) { entry.session.dispose() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt index eb7b528af7..e1a6521121 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.chess +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock + /** * Global registry of accepted game IDs. * @@ -29,28 +32,28 @@ package com.vitorpamplona.amethyst.commons.chess */ object AcceptedGamesRegistry { private val acceptedGameIds = mutableSetOf() - private val lock = Any() + private val lock = KmpLock() fun markAsAccepted(gameId: String) { - synchronized(lock) { + lock.withLock { acceptedGameIds.add(gameId) } } fun wasAccepted(gameId: String): Boolean = - synchronized(lock) { + lock.withLock { acceptedGameIds.contains(gameId) } fun clear() { - synchronized(lock) { + lock.withLock { acceptedGameIds.clear() } } /** Remove old entries - call periodically to prevent memory leak */ fun clearOldEntries(keepGameIds: Set) { - synchronized(lock) { + lock.withLock { acceptedGameIds.retainAll(keepGameIds) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt index 4513306aca..8704c46398 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.emojicoder +import com.vitorpamplona.amethyst.commons.util.codePointAtKmp +import com.vitorpamplona.amethyst.commons.util.codePointCharCount +import com.vitorpamplona.amethyst.commons.util.codePointToChars + object EmojiCoder { // Variation selectors block https://unicode.org/charts/nameslist/n_FE00.html // VS1..=VS16 @@ -35,8 +39,8 @@ object EmojiCoder { Array(256) { // converts to UTF-16. Always char[2] back when (it) { - in 0..15 -> Character.toChars(VARIATION_SELECTOR_START + it) - in 16..255 -> Character.toChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16) + in 0..15 -> codePointToChars(VARIATION_SELECTOR_START + it) + in 16..255 -> codePointToChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16) else -> throw RuntimeException("This should never happen") } } @@ -55,11 +59,11 @@ object EmojiCoder { fun isCoded(text: String): Boolean { if (text.length <= 3) return false - if (!isVariationChar(text.codePointAt(text.length - 2))) { + if (!isVariationChar(text.codePointAtKmp(text.length - 2))) { return false } - if (text.length > 4 && !isVariationChar(text.codePointAt(text.length - 4))) { + if (text.length > 4 && !isVariationChar(text.codePointAtKmp(text.length - 4))) { return false } @@ -70,7 +74,7 @@ object EmojiCoder { emoji: String, text: String, ): String { - val input = text.toByteArray(Charsets.UTF_8) + val input = text.encodeToByteArray() val out = CharArray(input.size * 2) var outIdx = 0 for (i in 0 until input.size) { @@ -78,51 +82,34 @@ object EmojiCoder { out[outIdx++] = chars[0] out[outIdx++] = chars[1] } - return emoji + String(out) + return emoji + out.concatToString() + } + + /** + * Scans [text] collecting variation-selector bytes until a non-selector is found + * after at least one byte has been collected. + * Returns the end index (exclusive) in [text] and the collected byte values. + */ + private fun scanVariationBytes(text: String): Pair> { + val bytes = mutableListOf() + var i = 0 + while (i < text.length) { + val codePoint = text.codePointAtKmp(i) + val byte = fromVariationSelector(codePoint) + if (byte == null && bytes.isNotEmpty()) break + i += codePointCharCount(codePoint) // Advance by correct char count + if (byte != null) bytes.add(byte) + } + return i to bytes } fun decode(text: String): String { - val decoded = mutableListOf() - - var i = 0 - while (i < text.length) { - val codePoint = text.codePointAt(i) - val byte = fromVariationSelector(codePoint) - - if (byte == null && decoded.isNotEmpty()) { - break - } else if (byte == null) { - i += Character.charCount(codePoint) // Advance index by correct number of chars - continue - } - - decoded.add(byte) - i += Character.charCount(codePoint) // Advance index by correct number of chars - } - - val decodedArray = ByteArray(decoded.size) { decoded[it].toByte() } - return String(decodedArray, Charsets.UTF_8) + val (_, bytes) = scanVariationBytes(text) + return ByteArray(bytes.size) { bytes[it].toByte() }.decodeToString() } fun cropToFirstMessage(text: String): String { - val decoded = mutableListOf() - - var i = 0 - while (i < text.length) { - val codePoint = text.codePointAt(i) - val byte = fromVariationSelector(codePoint) - - if (byte == null && decoded.isNotEmpty()) { - break - } else if (byte == null) { - i += Character.charCount(codePoint) // Advance index by correct number of chars - continue - } - - decoded.add(byte) - i += Character.charCount(codePoint) // Advance index by correct number of chars - } - - return text.substring(0, i) + val (endIndex, _) = scanVariationBytes(text) + return text.substring(0, endIndex) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt index 0023d5ad5c..960d123b87 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt @@ -70,12 +70,9 @@ abstract class Channel : NotesGatherer { abstract fun toBestDisplayName(): String open fun relays(): Set = - relays.keys - .toSortedSet { o1, o2 -> - val o1Count = relays[o1]?.number ?: 0 - val o2Count = relays[o2]?.number ?: 0 - o2Count.compareTo(o1Count) // descending - } + relays.entries + .sortedByDescending { it.value.number } + .mapTo(LinkedHashSet(relays.size)) { it.key } fun updateChannelInfo() { flowSet?.metadata?.invalidateData() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt index e8cccecac0..ce28f72ffa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt index 509e2e1c3e..3d3aa45178 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt index d9c151b018..f34b9a0792 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt @@ -58,7 +58,7 @@ object LongFormPublishAction { throw IllegalStateException("Cannot publish: signer is not writeable") } - if (content.toByteArray().size > MAX_CONTENT_BYTES) { + if (content.encodeToByteArray().size > MAX_CONTENT_BYTES) { throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes") } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt index 3103bc76a7..2c59434441 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt index 2fc9bfbf34..f8f77d36c9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEve import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combineTransform diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt index 8b56b6e2ba..726c677e88 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combineTransform diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt index 67f499deba..5da941a712 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combineTransform diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt index f21bf18a7f..557e3fd0f2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/CreatedAtComparator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/CreatedAtComparator.kt index b5af6e94e6..947fa61ab8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/CreatedAtComparator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/CreatedAtComparator.kt @@ -22,15 +22,13 @@ package com.vitorpamplona.amethyst.commons.model.observables import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Event val CreatedAtIdHexComparator: Comparator = Comparator { note1, note2 -> // Sort by created_at and idhex, but define uniqueness by reference if (note1 === note2) return@Comparator 0 - if (note1 == null) return@Comparator 1 // null is greater, moves to last - if (note2 == null) return@Comparator -1 // null is greater, moves to last - val createdAt1 = note1.createdAt() val createdAt2 = note2.createdAt() @@ -49,42 +47,31 @@ val CreatedAtIdHexComparator: Comparator = } } +private inline fun compareByCreatedAt( + a: T, + b: T, + eventOf: (T) -> Event?, +): Int { + val firstEvent = eventOf(a) + val secondEvent = eventOf(b) + return when { + firstEvent == null && secondEvent == null -> 0 + firstEvent == null -> 1 + secondEvent == null -> -1 + else -> firstEvent.createdAt.compareTo(secondEvent.createdAt) + } +} + object CreatedAtComparator : Comparator { override fun compare( - first: Note?, - second: Note?, - ): Int { - val firstEvent = first?.event - val secondEvent = second?.event - - return if (firstEvent == null && secondEvent == null) { - 0 - } else if (firstEvent == null) { - 1 - } else if (secondEvent == null) { - -1 - } else { - firstEvent.createdAt.compareTo(secondEvent.createdAt) - } - } + a: Note, + b: Note, + ): Int = compareByCreatedAt(a, b) { it.event } } object CreatedAtComparatorAddresses : Comparator { override fun compare( - first: AddressableNote?, - second: AddressableNote?, - ): Int { - val firstEvent = first?.event - val secondEvent = second?.event - - return if (firstEvent == null && secondEvent == null) { - 0 - } else if (firstEvent == null) { - 1 - } else if (secondEvent == null) { - -1 - } else { - firstEvent.createdAt.compareTo(secondEvent.createdAt) - } - } + a: AddressableNote, + b: AddressableNote, + ): Int = compareByCreatedAt(a, b) { it.event } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt index c8970683e7..295f945397 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relays.EOSERelayList import com.vitorpamplona.amethyst.commons.util.PlatformNumberFormatter import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.emitAll diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt index 427d341953..a16d3734e7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO abstract class BaseEoseManager( val client: INostrClient, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt index 7cf60cb2dd..71a9288804 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.relays +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** @@ -33,14 +35,14 @@ open class EOSECache( private val maxSize: Int = 200, ) { private val cache = linkedMapOf() - private val lock = Any() + private val lock = KmpLock() fun addOrUpdate( key: K, relayUrl: NormalizedRelayUrl, time: Long, ) { - synchronized(lock) { + lock.withLock { val relayList = cache[key] if (relayList == null) { // Evict oldest if at capacity @@ -57,7 +59,7 @@ open class EOSECache( } fun since(key: K): SincePerRelayMap? = - synchronized(lock) { + lock.withLock { cache[key]?.relayList?.toMutableMap() } @@ -68,18 +70,18 @@ open class EOSECache( ) = addOrUpdate(key, relayUrl, time) fun remove(key: K) { - synchronized(lock) { + lock.withLock { cache.remove(key) } } fun clear() { - synchronized(lock) { + lock.withLock { cache.clear() } } - fun size(): Int = synchronized(lock) { cache.size } + fun size(): Int = lock.withLock { cache.size } } /** @@ -91,7 +93,7 @@ open class EOSETwoLevelCache( private val innerMaxSize: Int = 200, ) { private val cache = linkedMapOf>() - private val lock = Any() + private val lock = KmpLock() fun addOrUpdate( outerKey: K1, @@ -99,7 +101,7 @@ open class EOSETwoLevelCache( relayUrl: NormalizedRelayUrl, time: Long, ) { - synchronized(lock) { + lock.withLock { val innerCache = cache[outerKey] if (innerCache == null) { // Evict oldest if at capacity @@ -119,7 +121,7 @@ open class EOSETwoLevelCache( outerKey: K1, innerKey: K2, ): SincePerRelayMap? = - synchronized(lock) { + lock.withLock { cache[outerKey]?.since(innerKey) } @@ -131,13 +133,13 @@ open class EOSETwoLevelCache( ) = addOrUpdate(outerKey, innerKey, relayUrl, time) fun removeOuter(key: K1) { - synchronized(lock) { + lock.withLock { cache.remove(key) } } fun clear() { - synchronized(lock) { + lock.withLock { cache.clear() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt index 66edbae1e3..97621650b2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt @@ -168,7 +168,7 @@ class RobohashAssembler { Hex.decode(msg) } else { Log.w("Robohash") { "$msg is not a hex" } - sha256(msg.toByteArray()) + sha256(msg.encodeToByteArray()) } val bgColor = SolidColor(bytesToColor(hash[0], hash[1], hash[2], isLightTheme)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt index 8a843955ae..24cc954eb5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt @@ -20,15 +20,18 @@ */ package com.vitorpamplona.amethyst.commons.search +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock + class EventDeduplicator { - private val lock = Any() + private val lock = KmpLock() private val seenIds = mutableSetOf() - fun tryAdd(id: String): Boolean = synchronized(lock) { seenIds.add(id) } + fun tryAdd(id: String): Boolean = lock.withLock { seenIds.add(id) } - fun contains(id: String): Boolean = synchronized(lock) { id in seenIds } + fun contains(id: String): Boolean = lock.withLock { id in seenIds } - fun clear() = synchronized(lock) { seenIds.clear() } + fun clear() = lock.withLock { seenIds.clear() } - val size: Int get() = synchronized(lock) { seenIds.size } + val size: Int get() = lock.withLock { seenIds.size } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt index cad35cbc4c..79ff14f824 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt index 30bc6fd3b0..a73db81302 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.commons.thumbhash import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.PI @@ -50,14 +52,14 @@ object ThumbHashDecoder { // (size, components) pairs across the entire app lifetime, each table is // a few KB, so the memory ceiling is tiny. private val cosineCache = HashMap() - private val cosineCacheLock = Any() + private val cosineCacheLock = KmpLock() /** * Clear the cosine table cache. Tables are tiny but callers under memory * pressure can release them; they will be recomputed on demand. */ fun clearCache() { - synchronized(cosineCacheLock) { cosineCache.clear() } + cosineCacheLock.withLock { cosineCache.clear() } } private fun cosTable( @@ -65,9 +67,8 @@ object ThumbHashDecoder { components: Int, ): DoubleArray { val key = (size.toLong() shl 32) or components.toLong() - synchronized(cosineCacheLock) { - cosineCache[key]?.let { return it } - } + val cached = cosineCacheLock.withLock { cosineCache[key] } + if (cached != null) return cached val table = DoubleArray(size * components) val piOverSize = PI / size for (i in 0 until size) { @@ -77,10 +78,7 @@ object ThumbHashDecoder { table[rowOffset + c] = cos(phase * c) } } - synchronized(cosineCacheLock) { - cosineCache.getOrPut(key) { table } - } - return table + return cosineCacheLock.withLock { cosineCache.getOrPut(key) { table } } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt index 61d08af462..0ca8bb4748 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt @@ -175,7 +175,7 @@ fun FeedErrorState( modifier: Modifier = Modifier, onRetry: (() -> Unit)? = null, ) { - val formattedMessage = stringResource(Res.string.error_loading_feed).format(errorMessage) + val formattedMessage = stringResource(Res.string.error_loading_feed, errorMessage) ErrorState( message = formattedMessage, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt index 996e8d052f..2071bbd55c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt @@ -35,6 +35,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt index c6a8752554..d76efe5b99 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CodePoints.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CodePoints.kt new file mode 100644 index 0000000000..41df19f0d7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CodePoints.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +// KMP code-point helpers — replacements for `java.lang.Character.*` and the +// JVM-only `String.codePointAt(Int)` / `String.offsetByCodePoints(Int, Int)` +// extensions. UTF-16 surrogate-pair aware so emoji and other supplementary +// code points are handled correctly on Native targets. + +/** Number of UTF-16 code units (1 or 2) needed for [codePoint]. */ +fun codePointCharCount(codePoint: Int): Int = if (codePoint >= 0x10000) 2 else 1 + +/** + * Encode [codePoint] as UTF-16. Returns a 1-char array for BMP code points and + * a high+low surrogate pair for supplementary code points (≄ U+10000). + */ +fun codePointToChars(codePoint: Int): CharArray = + if (codePoint < 0x10000) { + charArrayOf(codePoint.toChar()) + } else { + val offset = codePoint - 0x10000 + charArrayOf( + (0xD800 + (offset ushr 10)).toChar(), + (0xDC00 + (offset and 0x3FF)).toChar(), + ) + } + +/** + * Decode the Unicode code point that starts at [index]. Mirrors + * `java.lang.Character.codePointAt(CharSequence, int)`: returns the + * supplementary code point when [index] points at a well-formed high surrogate + * followed by a low surrogate; otherwise returns the raw `Char.code`. + */ +fun String.codePointAtKmp(index: Int): Int { + val high = this[index] + if (high.isHighSurrogate() && index + 1 < length) { + val low = this[index + 1] + if (low.isLowSurrogate()) { + return 0x10000 + ((high.code - 0xD800) shl 10) + (low.code - 0xDC00) + } + } + return high.code +} + +/** + * Return the index that is [codePointOffset] code points away from [index]. + * Negative offsets are not supported (matches the only call site usage today). + */ +fun String.offsetByCodePointsKmp( + index: Int, + codePointOffset: Int, +): Int { + var i = index + repeat(codePointOffset) { + i += codePointCharCount(codePointAtKmp(i)) + } + return i +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt index e383b1fe45..d225d89c02 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.util import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists -fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2 +fun String.isUTF16Char(pos: Int): Boolean = codePointCharCount(this.codePointAtKmp(pos)) == 2 fun String.firstFullCharOld(): String { return when (this.length) { @@ -64,11 +64,11 @@ fun String.firstFullChar(): String { var i = 0 while (i < this.length) { - codePoint = codePointAt(i) + codePoint = codePointAtKmp(i) // Skips if it starts with the join char 0x200D if (codePoint == 0x200D && previousCharLength == 0) { - next = offsetByCodePoints(i, 1) + next = offsetByCodePointsKmp(i, 1) start = next } else { // If join, searches for the next char @@ -78,7 +78,7 @@ fun String.firstFullChar(): String { } else { // stops when two chars are not joined together if (previousCharLength > 0 && !isInJoin) { - if (Character.charCount(codePoint) == 1 || hasHadSecondChance) { + if (codePointCharCount(codePoint) == 1 || hasHadSecondChance) { break } else { hasHadSecondChance = true @@ -91,7 +91,7 @@ fun String.firstFullChar(): String { } // next char to evaluate - next = offsetByCodePoints(i, 1) + next = offsetByCodePointsKmp(i, 1) previousCharLength += (next - i) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt index 47ff0d18f7..172614a573 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.launch @Stable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt index 7e8f5ec8ca..d4dc7e0a78 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.launch @Stable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt index 032c53a754..66a790c31a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt index a3b0b2c32c..1f3f0bd1ed 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.ios.kt new file mode 100644 index 0000000000..54bfed7b5c --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.ios.kt @@ -0,0 +1,76 @@ +/* + * 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.blurhash + +// Phase 2 compile-only iOS actual. Holds an ARGB pixel buffer + size. Phase 3 +// will swap to a UIImage/CGImage-backed implementation when the iosApp module +// lands and BlurHash/ThumbHash output needs to render to UIKit. +actual class PlatformImage internal constructor( + private val pixels: IntArray, + actual val width: Int, + actual val height: Int, +) { + actual fun getPixels( + pixels: IntArray, + offset: Int, + stride: Int, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + for (row in 0 until height) { + val srcStart = (y + row) * this.width + x + val dstStart = offset + row * stride + this.pixels.copyInto( + destination = pixels, + destinationOffset = dstStart, + startIndex = srcStart, + endIndex = srcStart + width, + ) + } + } + + actual fun scale( + width: Int, + height: Int, + ): PlatformImage { + // Nearest-neighbour resample. Adequate for the Phase 2 compile-only target; + // Phase 3 should swap to CoreGraphics scaling for production quality. + val out = IntArray(width * height) + for (j in 0 until height) { + val srcY = (j.toLong() * this.height / height).toInt().coerceAtMost(this.height - 1) + for (i in 0 until width) { + val srcX = (i.toLong() * this.width / width).toInt().coerceAtMost(this.width - 1) + out[j * width + i] = pixels[srcY * this.width + srcX] + } + } + return PlatformImage(out, width, height) + } + + actual companion object { + actual fun create( + pixels: IntArray, + width: Int, + height: Int, + ): PlatformImage = PlatformImage(pixels.copyOf(), width, height) + } +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.ios.kt new file mode 100644 index 0000000000..268b92d6a7 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.ios.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +// Phase 2 compile-only iOS actual. In-memory only; persistence via +// NSUserDefaults arrives with the iosApp module in Phase 3. +actual class ChessDismissedGamesStorage private actual constructor() { + private val dismissed = mutableMapOf>() + + actual companion object { + actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage() + } + + actual fun load(userPubkey: String): Set = dismissed[userPubkey] ?: emptySet() + + actual fun save( + userPubkey: String, + ids: Set, + ) { + if (ids.isEmpty()) { + dismissed.remove(userPubkey) + } else { + dismissed[userPubkey] = ids + } + } +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt new file mode 100644 index 0000000000..d67627da12 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt @@ -0,0 +1,40 @@ +/* + * 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.keystorage + +// Phase 2 compile-only iOS actual. Keychain Services wiring lands in Phase 4 +// (write paths: signing, posting, settings) per amethyst/plans/2026-05-24-ios-support.md. +actual class SecureKeyStorage private actual constructor() { + actual companion object { + actual fun create(context: Any?): SecureKeyStorage = SecureKeyStorage() + } + + actual suspend fun savePrivateKey( + npub: String, + privKeyHex: String, + ): Unit = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)") + + actual suspend fun getPrivateKey(npub: String): String? = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)") + + actual suspend fun deletePrivateKey(npub: String): Boolean = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)") + + actual suspend fun hasPrivateKey(npub: String): Boolean = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)") +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt new file mode 100644 index 0000000000..289e403982 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt @@ -0,0 +1,42 @@ +/* + * 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. + */ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package com.vitorpamplona.amethyst.commons.model + +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter +import platform.Foundation.NSLocale +import platform.Foundation.NSTimeZone +import platform.Foundation.dateWithTimeIntervalSince1970 +import platform.Foundation.localTimeZone + +// Format pattern matches the JVM actual ("uuuu-MM-dd-HH:mm:ss"). For the +// post-1970 timestamps Nostr events use, `yyyy` and `uuuu` are equivalent +// (proleptic Gregorian, year >= 1), so NSDateFormatter's `yyyy` is fine. +private val levelFormatter: NSDateFormatter = + NSDateFormatter().apply { + dateFormat = "yyyy-MM-dd-HH:mm:ss" + timeZone = NSTimeZone.localTimeZone + locale = NSLocale("en_US_POSIX") + } + +actual fun formattedDateTime(timestamp: Long): String = levelFormatter.stringFromDate(NSDate.dateWithTimeIntervalSince1970(timestamp.toDouble())) diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.ios.kt new file mode 100644 index 0000000000..7eff5bb65b --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.ios.kt @@ -0,0 +1,28 @@ +/* + * 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.threading + +// Mirrors the jvmMain no-op. The iosApp module (Phase 3) can swap this for an +// `NSThread.isMainThread` check if iOS-specific main-thread restrictions need +// enforcement; today there are none. +actual fun checkNotInMainThread() { + // No-op +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.ios.kt new file mode 100644 index 0000000000..6179b77777 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.ios.kt @@ -0,0 +1,37 @@ +/* + * 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. + */ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package com.vitorpamplona.amethyst.commons.util + +import platform.Foundation.NSNumber +import platform.Foundation.NSNumberFormatter +import platform.Foundation.NSNumberFormatterDecimalStyle +import platform.Foundation.numberWithLongLong + +actual class PlatformNumberFormatter { + private val formatter: NSNumberFormatter = + NSNumberFormatter().apply { + numberStyle = NSNumberFormatterDecimalStyle + } + + actual fun format(value: Long): String = formatter.stringFromNumber(NSNumber.numberWithLongLong(value)) ?: value.toString() +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt index 0d42a09d2a..cc52116fef 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt @@ -18,6 +18,14 @@ * 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. */ +@file:OptIn(kotlin.experimental.ExperimentalNativeApi::class) + package com.vitorpamplona.amethyst.commons.util -actual typealias WeakReference = kotlin.native.ref.WeakReference +actual class WeakReference actual constructor( + referent: T, +) { + private val ref = kotlin.native.ref.WeakReference(referent) + + actual fun get(): T? = ref.get() +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.ios.kt new file mode 100644 index 0000000000..169879b250 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.ios.kt @@ -0,0 +1,25 @@ +/* + * 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.utils + +// iosApp (Phase 3) can flip this from a build flag (`DEBUG` Swift compile +// condition surfaced through the framework). Compile-only target ships false. +actual val isDebug: Boolean = false From 771ba67f316399f5b12547b4862aac9ed6c4e15c Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 26 May 2026 14:20:47 +0200 Subject: [PATCH 31/33] Code review: - guard shared NSDateFormatter in formattedDateTime iOS actual --- .../commons/model/ThreadLevelCalculator.ios.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt index 289e403982..960eb5282b 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.model +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import platform.Foundation.NSDate import platform.Foundation.NSDateFormatter import platform.Foundation.NSLocale @@ -39,4 +41,14 @@ private val levelFormatter: NSDateFormatter = locale = NSLocale("en_US_POSIX") } -actual fun formattedDateTime(timestamp: Long): String = levelFormatter.stringFromDate(NSDate.dateWithTimeIntervalSince1970(timestamp.toDouble())) +// NSDateFormatter is not thread-safe for concurrent stringFromDate calls +// (Apple docs). ThreadLevelCalculator.replyLevelSignature runs under +// Dispatchers.IO via LevelFeedViewModel, so multiple threads can hit +// formattedDateTime simultaneously. Guard the shared formatter rather than +// allocating one per call (cheaper for the O(notes-per-thread-sort) call rate). +private val levelFormatterLock = KmpLock() + +actual fun formattedDateTime(timestamp: Long): String = + levelFormatterLock.withLock { + levelFormatter.stringFromDate(NSDate.dateWithTimeIntervalSince1970(timestamp.toDouble())) + } From 28865f38c31601138131b409c4f2b86469f3e08b Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 26 May 2026 15:24:02 +0200 Subject: [PATCH 32/33] tests: - cover CodePoints helpers and Channel.relays() equal-count behaviour - Two new test files in commons/src/commonTest/, both run under :commons:jvmTest. --- .../commons/model/ChannelRelaysTest.kt | 92 +++++++++++ .../amethyst/commons/util/CodePointsTest.kt | 152 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt new file mode 100644 index 0000000000..316c8b3743 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt @@ -0,0 +1,92 @@ +/* + * 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.model + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Lock in the descending-by-usage ordering and equal-count preservation of + * [Channel.relays]. Phase 2 KMP migration replaced the JVM `toSortedSet` + * (TreeSet) implementation — which silently dropped duplicates whose + * comparator returned 0 — with a LinkedHashSet built from a pre-sorted list. + * That keeps every distinct relay even when usage counts tie. + */ +class ChannelRelaysTest { + /** Minimal concrete subclass — the base [Channel] is abstract. */ + private class TestChannel : Channel() { + override fun toBestDisplayName(): String = "test" + } + + private val relayA = NormalizedRelayUrl("wss://a.example/") + private val relayB = NormalizedRelayUrl("wss://b.example/") + private val relayC = NormalizedRelayUrl("wss://c.example/") + + @Test + fun emptyWhenNoRelaysAdded() { + assertEquals(emptySet(), TestChannel().relays()) + } + + @Test + fun singleRelayIsReturned() { + val channel = TestChannel() + channel.addRelay(relayA) + assertEquals(setOf(relayA), channel.relays()) + } + + @Test + fun relaysAreOrderedDescendingByUsageCount() { + val channel = TestChannel() + // C will end up with the highest count. + channel.addRelay(relayA) // A: 1 + channel.addRelay(relayB) // B: 1 + channel.addRelay(relayB) // B: 2 + channel.addRelay(relayC) // C: 1 + channel.addRelay(relayC) // C: 2 + channel.addRelay(relayC) // C: 3 + + // LinkedHashSet preserves insertion order, which is the + // descending-by-count order produced by sortedByDescending. + assertEquals(listOf(relayC, relayB, relayA), channel.relays().toList()) + } + + /** + * Behavioural change from the JVM-only implementation: the previous + * `toSortedSet` returned a [java.util.TreeSet] whose membership uses the + * comparator for equality. Two relays with equal counts would be treated + * as duplicates by the TreeSet and one would be silently dropped. The + * KMP-friendly replacement uses [LinkedHashSet], whose membership uses + * `equals`, so every distinct relay is preserved regardless of count. + */ + @Test + fun relaysWithEqualUsageCountsAreAllPreserved() { + val channel = TestChannel() + channel.addRelay(relayA) // A: 1 + channel.addRelay(relayB) // B: 1 + channel.addRelay(relayC) // C: 1 + + // All three relays share count 1. The old behaviour would return + // exactly one of them; the new behaviour returns all three. + assertEquals(setOf(relayA, relayB, relayC), channel.relays()) + assertEquals(3, channel.relays().size) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt new file mode 100644 index 0000000000..ee7ea2c932 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class CodePointsTest { + // ---- codePointCharCount ---- + + @Test + fun charCountIsOneForBmp() { + assertEquals(1, codePointCharCount(0x0041)) // 'A' + assertEquals(1, codePointCharCount(0xFFFF)) // last BMP code point + } + + @Test + fun charCountIsTwoForSupplementary() { + assertEquals(2, codePointCharCount(0x10000)) // first supplementary code point + assertEquals(2, codePointCharCount(0x1F600)) // 😀 grinning face + assertEquals(2, codePointCharCount(0x10FFFF)) // last valid code point + } + + // ---- codePointToChars ---- + + @Test + fun toCharsRoundTripsAscii() { + assertArrayEquals(charArrayOf('A'), codePointToChars(0x0041)) + } + + @Test + fun toCharsRoundTripsLastBmp() { + assertArrayEquals(charArrayOf('ïżż'), codePointToChars(0xFFFF)) + } + + @Test + fun toCharsProducesSurrogatePairForGrinningFace() { + // U+1F600 (😀) is encoded as the surrogate pair (0xD83D, 0xDE00). + assertArrayEquals(charArrayOf('\uD83D', '\uDE00'), codePointToChars(0x1F600)) + } + + @Test + fun toCharsProducesSurrogatePairForFirstSupplementary() { + // U+10000 -> (0xD800, 0xDC00). + assertArrayEquals(charArrayOf('\uD800', '\uDC00'), codePointToChars(0x10000)) + } + + @Test + fun toCharsProducesSurrogatePairForLastCodePoint() { + // U+10FFFF -> (0xDBFF, 0xDFFF). + assertArrayEquals(charArrayOf('\uDBFF', '\uDFFF'), codePointToChars(0x10FFFF)) + } + + // ---- String.codePointAtKmp ---- + + @Test + fun codePointAtReturnsAsciiCodeForBmp() { + assertEquals(0x0041, "Aa".codePointAtKmp(0)) + assertEquals(0x0061, "Aa".codePointAtKmp(1)) + } + + @Test + fun codePointAtDecodesSurrogatePair() { + // "😀X" — surrogate pair followed by 'X'. + val s = "😀X" + assertEquals(0x1F600, s.codePointAtKmp(0)) + } + + @Test + fun codePointAtReturnsLowSurrogateWhenIndexIsOnLowHalf() { + // Indexing into the middle of a surrogate pair returns the low surrogate's raw code unit. + val s = "😀" + assertEquals(0xDE00, s.codePointAtKmp(1)) + } + + @Test + fun codePointAtReturnsHighSurrogateWhenStringEndsOnLoneHighSurrogate() { + // Lone high surrogate at end of string: `index + 1 < length` is false, so we + // fall back to returning the surrogate's raw code unit. This matches + // java.lang.Character.codePointAt(CharSequence, int). + val s = "\uD83D" + assertEquals(0xD83D, s.codePointAtKmp(0)) + } + + @Test + fun codePointAtReturnsHighSurrogateWhenFollowedByNonSurrogate() { + // High surrogate followed by a non-low-surrogate char: return the high surrogate raw. + val s = "\uD83DA" + assertEquals(0xD83D, s.codePointAtKmp(0)) + assertEquals(0x0041, s.codePointAtKmp(1)) + } + + // ---- String.offsetByCodePointsKmp ---- + + @Test + fun offsetByOneStepsOverBmp() { + // Two ASCII chars; offset 1 from index 0 lands on index 1. + assertEquals(1, "Aa".offsetByCodePointsKmp(0, 1)) + } + + @Test + fun offsetByOneStepsOverSupplementaryPair() { + // 😀 is 2 UTF-16 code units; offset 1 from index 0 lands on index 2. + val s = "😀X" + assertEquals(2, s.offsetByCodePointsKmp(0, 1)) + // Then a single step lands on the end of the string. + assertEquals(3, s.offsetByCodePointsKmp(2, 1)) + } + + @Test + fun offsetByZeroIsIdentity() { + assertEquals(0, "Aa".offsetByCodePointsKmp(0, 0)) + assertEquals(2, "Aa".offsetByCodePointsKmp(2, 0)) + } + + // ---- Round-trip ---- + + @Test + fun encodeDecodeRoundTripCoversFullCodePointRange() { + // Spot-check a handful of code points across BMP and supplementary planes. + val samples = intArrayOf(0x0001, 0x0041, 0x00FF, 0x4F60, 0xFFFF, 0x10000, 0x1F600, 0x10FFFF) + for (cp in samples) { + val chars = codePointToChars(cp) + val asString = chars.concatToString() + assertEquals("round-trip code point U+${cp.toString(16).uppercase()}", cp, asString.codePointAtKmp(0)) + assertEquals( + "char count for U+${cp.toString(16).uppercase()}", + chars.size, + codePointCharCount(cp), + ) + } + } +} From 153b9495dae0e20e98e54feb61d868d0ecc0e56d Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 26 May 2026 14:21:22 +0000 Subject: [PATCH 33/33] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 16 ++++++++++++++++ amethyst/src/main/res/values-de-rDE/strings.xml | 16 ++++++++++++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 12 ++++++++++++ 3 files changed, 44 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 1126d462ad..f6f46d9a22 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -14,6 +14,12 @@ Tento pƙíspěvek mĂĄ vĂ­ce neĆŸ %1$d hashtagĆŻ Tento pƙíspěvek mĂĄ vĂ­ce neĆŸ %1$d hashtagĆŻ + + %1$d majetek je zbalenĂœ + %1$d majetky jsou zbalenĂœ + %1$d majetkĆŻ jsou zbalenĂœ + %1$d majetkĆŻ jsou zbalenĂœ + Pƙíspěvek se načítĂĄ nebo nemĆŻĆŸe bĂœt nalezena ve vaĆĄem seznamu relacĂ­ 👀 ObrĂĄzek kanĂĄlu @@ -565,6 +571,7 @@ Aplikace Aplikace Zdroj: %1$s + v%1$s StĂĄhnout Kalendáƙe KrĂĄtkĂĄ videa @@ -1776,6 +1783,7 @@ %1$s · končí %2$s SdĂ­let udĂĄlost kalendáƙe Exportovat do kalendáƙe (.ics) + calendar_reminders PƙipomenutĂ­ kalendáƙe UpozorněnĂ­, kdyĆŸ je udĂĄlost, kterĂ© se ĂșčastnĂ­te, blĂ­zko začátku. UdĂĄlost kalendáƙe @@ -1803,6 +1811,12 @@ OznĂĄmenĂ­ se spustĂ­, kdyĆŸ je udĂĄlost, kterĂ© se ĂșčastnĂ­te, blĂ­zko začátku. Doba pƙedstihu pƙipomenutĂ­ Kolik minut pƙed udĂĄlostĂ­ chcete bĂœt upozorněni. + + %1$d min + %1$d min + %1$d min + %1$d min + SdĂ­let jako Nostr odkaz SdĂ­let odkaz na kalendáƙ VĆĄechny kalendáƙe @@ -1834,6 +1848,7 @@ ZvĂœĆĄit nebo citovat Olajkovat Zap + On-chain Bitcoin zap ČekĂĄ na potvrzenĂ­ Změnit rychlĂ© reakce SpodnĂ­ navigačnĂ­ liĆĄta @@ -2794,6 +2809,7 @@ Nowhere Obchod Nowhere Petice Nowhere ZprĂĄva + Nowhere Drop Nowhere UměnĂ­ Nowhere FĂłrum diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index fe8e93fc93..f4ef6844e3 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -12,6 +12,10 @@ Dieser Beitrag hat mehr als %1$d Hashtag Dieser Beitrag hat mehr als %1$d Hashtags + + %1$d Asset gebĂŒndelt + %1$d Assets bĂŒndelt + Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden 👀 Kanalbild @@ -556,7 +560,10 @@ anz der Bedingungen ist erforderlich WĂ€hle aus, welche deiner erhaltenen Abzeichen auf deinem Profil erscheinen sollen. Du hast noch keine Abzeichen erhalten. Bilder + Apps + Apps Quelle: %1$s + v%1$s Herunterladen Kalender Kurzvideos @@ -1685,6 +1692,7 @@ anz der Bedingungen ist erforderlich Kalender bearbeiten Termine in diesem Kalender (%1$d) Du hast noch keine Kalendertermine erstellt. + Feed Monat Woche Tag @@ -1750,6 +1758,7 @@ anz der Bedingungen ist erforderlich Komme Vielleicht Komme nicht + RSVPs (%1$d) Noch keine Antworten. Teilnehmer (%1$d) In Kalendern (%1$d) @@ -1759,6 +1768,7 @@ anz der Bedingungen ist erforderlich %1$s · endet %2$s Kalendertermin teilen In Kalender exportieren (.ics) + calendar_reminders Kalendererinnerungen Hinweis, wenn ein Termin, an dem du teilnimmst, bald beginnt. Kalendertermin @@ -1995,8 +2005,10 @@ anz der Bedingungen ist erforderlich Geschlossen Entwurf Übersicht + Tickets Patches und PRs Über + Links Maintainer Themen Persönlicher Fork @@ -2761,8 +2773,12 @@ anz der Bedingungen ist erforderlich Das aktuelle Community-Regeldokument wurde als veraltet abgelehnt. Nowhere-Seite + Nowhere Ereignis Nowhere Spendenaktion Nowhere Shop + Nowhere Petition Nowhere Nachricht + Nowhere Drop Nowhere Kunst + Nowhere Forum diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 6060576c3c..1f6d0679ed 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -12,6 +12,10 @@ Det hĂ€r inlĂ€gget har fler Ă€n %1$d hashtagg Det hĂ€r inlĂ€gget har fler Ă€n %1$d hashtaggar + + %1$d tillgĂ„ng paketerad + %1$d tillgĂ„ngar buntade + InlĂ€gg hittades inte 👀 Kanal bild @@ -553,6 +557,7 @@ Appar Appar KĂ€lla: %1$s + v%1$s Ladda ner Kalendrar Kortfilmer @@ -1757,6 +1762,7 @@ %1$s · slutar %2$s Dela kalenderhĂ€ndelse Exportera till kalender (.ics) + calendar_reminders KalenderpĂ„minnelser PĂ„minnelse nĂ€r en hĂ€ndelse du deltar i snart ska börja. KalenderhĂ€ndelse @@ -1782,6 +1788,10 @@ En notis visas nĂ€r en hĂ€ndelse du deltar i snart ska börja. PĂ„minnelsetid Hur mĂ„nga minuter före hĂ€ndelsen du vill bli notifierad. + + %1$d min + %1$d min + Dela som Nostr-lĂ€nk Dela kalenderlĂ€nk Alla kalendrar @@ -2762,5 +2772,7 @@ Nowhere Butik Nowhere Namninsamling Nowhere Meddelande + Nowhere Drop Nowhere Konst + Nowhere Forum