From bf93cb05538fbb6d30103260e200f8636946626a Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 26 Apr 2026 01:52:35 +0000 Subject: [PATCH 01/38] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-zh-rCN/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index d0389a336..d1a813275 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -2199,6 +2199,8 @@ AI 写入帮助 建议文本改进 使用设备上的 AI 模型来提出文本更正和音调更改。 + 已跟踪的广播 + 在发送事件时使用已跟踪的广播。在广播时显示实时进度和每个中继的状态。 使用它 忽略 更正 From 8e60a79eaf7d73c8d7696681d0dc2eeaf4df4987 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 03:46:49 +0000 Subject: [PATCH 02/38] perf(video): reduce jitter and per-recomposition work in note video pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightens the hot path that runs whenever a video appears inside a note in the feed (RichText -> ZoomableContentView -> VideoView). - VideoView: resolve aspect ratio once per (uri, dim), and prime MediaAspectRatioCache from the imeta dim tag so repeat appearances (PiP, dialog, list re-enter) don't have to wait for ExoPlayer's onVideoSizeChanged before reserving layout space. - VideoView: key the manual "tap to show" toggle on videoUri so a recycled feed slot doesn't inherit stale state from the previous video. - VideoViewInner: hoist proxyPortForVideo() into a remember(videoUri) — the result was being recomputed every recomposition only to be dropped by GetMediaItem's URI-keyed remember. - RenderVideoPlayer: stop holding container size in compose state. The size is only read in onDoubleTap, so a non-state IntArray holder removes a recomposition of the whole player tree on every layout pass. Also memoize isLiveStreaming() so the .m3u8 substring scan doesn't run on every recomposition. - RenderTopButtons: same isLiveStreaming() memoization. - GetVideoController: switch the remaining non-lambda Log.d call to the lambda overload so the message string isn't formatted when the log level is filtered out. --- .../playback/composable/GetVideoController.kt | 2 +- .../playback/composable/RenderVideoPlayer.kt | 12 +++++----- .../service/playback/composable/VideoView.kt | 22 ++++++++++++++----- .../playback/composable/VideoViewInner.kt | 7 +++++- .../composable/controls/RenderTopButtons.kt | 2 +- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index b561a4538..9c8bd568f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -52,7 +52,7 @@ fun GetVideoController( if (BackgroundMedia.isPlaying()) { // There is a video playing, start this one on mute. state.controller.volume = 0f - Log.d("PlaybackService", "OnEach Muted due to BackgroundMedia.isPlaying") + Log.d("PlaybackService") { "OnEach Muted due to BackgroundMedia.isPlaying" } } else { // There is no other video playing. Use the default mute state to // decide if sound is on or not. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 723899fd8..c86b117e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.unit.IntSize import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.ContentFrame @@ -90,19 +89,22 @@ fun RenderVideoPlayer( hasBlurhash: Boolean = false, accountViewModel: AccountViewModel, ) { - val containerSize = remember { mutableStateOf(IntSize.Zero) } - val isLive = isLiveStreaming(mediaItem.src.videoUri) + // Hold the container size in a non-state holder so layout passes don't trigger an + // unnecessary recomposition of the whole player tree just to update a value that is only + // ever read inside the onDoubleTap callback below. + val containerWidth = remember { intArrayOf(0) } + val isLive = remember(mediaItem.src.videoUri) { isLiveStreaming(mediaItem.src.videoUri) } Box( modifier = borderModifier - .onSizeChanged { containerSize.value = it } + .onSizeChanged { containerWidth[0] = it.width } .pointerInput(isLive, controllerState) { detectTapGestures( onTap = { controllerVisible.value = !controllerVisible.value }, onDoubleTap = { offset -> if (!isLive) { - val isLeftSide = offset.x < containerSize.value.width / 2 + val isLeftSide = offset.x < containerWidth[0] / 2 if (isLeftSide) { controllerState.controller.seekBackward() } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 885ecaaba..0160a1246 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -105,15 +105,29 @@ fun VideoView( thumbhash: String? = null, ) { val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback() - val automaticallyStartPlayback = remember { mutableStateOf(initialAutoStart) } + // Reset the manual-show toggle when the video URI changes so a recycled feed slot + // doesn't inherit "tapped to show" state from a prior video. + val automaticallyStartPlayback = remember(videoUri) { mutableStateOf(initialAutoStart) } // Once the video is being shown, only honor the user's autoplay preference when it was auto-loaded. // If the user manually tapped the download button, they want it to play. val autoplay = alwaysShowVideo || (initialAutoStart && accountViewModel.settings.autoPlayVideos()) || (!initialAutoStart && automaticallyStartPlayback.value) - if (blurhash == null && thumbhash == null) { - val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri) + // Resolve the aspect ratio once per composition. Prime the URL-keyed cache from the imeta + // dim tag so the next time this video appears (PiP, dialog, list re-enter) the cache hits + // without waiting for ExoPlayer's onVideoSizeChanged. + val ratio = + remember(videoUri, dimensions) { + val fromDim = dimensions?.takeIf { it.hasSize() } + if (fromDim != null) { + MediaAspectRatioCache.add(videoUri, fromDim.width, fromDim.height) + fromDim.aspectRatio() + } else { + MediaAspectRatioCache.get(videoUri) + } + } + if (blurhash == null && thumbhash == null) { val modifier = if (ratio != null && automaticallyStartPlayback.value) { Modifier.aspectRatio(ratio) @@ -149,8 +163,6 @@ fun VideoView( } } } else { - val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri) - val modifier = if (ratio != null) { Modifier.aspectRatio(ratio) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 8923783f0..49e5651a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -59,6 +59,11 @@ fun VideoViewInner( // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value } + // The proxy port is decided once per video URI; recomputing on every recomposition does + // pointless work (the result is anyway dropped because GetMediaItem.remember is keyed on the + // URI alone, so the cached MediaItemData is locked in on the first frame). + val proxyPort = remember(videoUri) { accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri) } + GetMediaItem( videoUri = videoUri, title = title, @@ -67,7 +72,7 @@ fun VideoViewInner( callbackUri = nostrUriCallback, mimeType = mimeType, aspectRatio = aspectRatio, - proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri), + proxyPort = proxyPort, keepPlaying = true, waveformData = waveform, isLiveStream = isLiveStream, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index 5c4d4250e..d7c1645f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -105,7 +105,7 @@ fun RenderTopButtons( accountViewModel: AccountViewModel, ) { val context = LocalContext.current - val isLive = isLiveStreaming(mediaData.videoUri) + val isLive = remember(mediaData.videoUri) { isLiveStreaming(mediaData.videoUri) } val pipSupported = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) From a11ba2e55d40c2aca72f7149eaca05fc9f5d453a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 04:01:59 +0000 Subject: [PATCH 03/38] perf(video): warm up ExoPlayer pool, tune LoadControl, fix notification fall-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small infrastructure fixes that support the eager-prepare model (every visible feed video calls setMediaItem + prepare immediately so it's ready when the user scrolls to it). - ExoPlayerPool.create(): the warmup that builds poolStartingSize players up front was already written but never invoked. Wire it from PlaybackService.lazyPool() the first time a pool is requested. The builds now run on the pool's main-looper scope with a yield() between each so they're spread across frames instead of stalling the UI in one ~150–600 ms burst. Idempotent via an AtomicBoolean. - ExoPlayerBuilder: install a feed-tuned DefaultLoadControl (10s/15s/750ms/2000ms) instead of the 50s/50s/2.5s/5s defaults. Every visible video preloads, so 5 simultaneous players were each trying to buffer 50s ahead — fighting for network and burning ~30 MB of buffer per HD player. Capping at 15s keeps the active video smooth, lets it start playing as soon as ~750 ms is buffered, and slashes peak memory on feeds with several preloads. - PlaybackService.onUpdateNotification: the third forEachIndexed loop was missing its return, so on the muted-but-playing fallback path super.onUpdateNotification was called once per playing session instead of once total. With multiple feed videos preloading simultaneously this was hammering the notification system every time a player changed state. Match the first two loops by returning after the first match. Also drop the unused `idx` from forEachIndexed. --- .../playback/playerPool/ExoPlayerBuilder.kt | 26 +++++++++++++++++++ .../playback/playerPool/ExoPlayerPool.kt | 23 ++++++++++++++-- .../playback/service/PlaybackService.kt | 26 +++++++++++++++---- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 4ceb4497b..eaa31e31e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -24,6 +24,7 @@ import android.content.Context import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache @@ -42,10 +43,35 @@ class ExoPlayerBuilder( .Builder(context) .apply { setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory)) + setLoadControl(feedTunedLoadControl()) }.build() .apply { addListener(AspectRatioCacher(MediaAspectRatioCache)) addListener(KeepVideosPlaying(this)) addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) } + + companion object { + // Default DefaultLoadControl buffers 50s ahead before slowing down. Every visible video + // in the feed is prepared eagerly so it's ready when the user scrolls to it; with the + // default settings 5 simultaneous preloads would fight for ~250s of buffer between them + // and chew through ~30+ MB per HD player. Feed playback is optimized for "the active + // video plays smoothly while a few neighbours stay warm," so we cap the buffer at ~15s + // and let playback kick in as soon as ~750 ms is buffered. Fullscreen still gets a + // healthy buffer because seeks-within-15s are virtually instant from disk cache. + private fun feedTunedLoadControl() = + DefaultLoadControl + .Builder() + .setBufferDurationsMs( + // minBufferMs = + 10_000, + // maxBufferMs = + 15_000, + // bufferForPlaybackMs = + 750, + // bufferForPlaybackAfterRebufferMs = + 2_000, + ).setPrioritizeTimeOverSizeThresholds(true) + .build() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index 4a32d8066..962ab5331 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -34,7 +34,9 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.yield import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean @OptIn(UnstableApi::class) class ExoPlayerPool( @@ -54,9 +56,26 @@ class ExoPlayerPool( private val mutex = Mutex() + // Guards against firing the warmup more than once if create() is called repeatedly + // (e.g. on reconfiguration or when both pools share a startup hook). + private val warmupStarted = AtomicBoolean(false) + + /** + * Pre-warms the pool with [poolStartingSize] ExoPlayer instances on the main looper, yielding + * between each build so the warmup is spread across frames instead of stalling the UI in one + * burst. ExoPlayer must be constructed on the same thread that will operate it (the main + * thread for this pool), so we cannot fan out across IO threads here. Idempotent — additional + * calls are no-ops. + */ fun create(context: Context) { - while (playerPool.size < poolStartingSize) { - playerPool.offer(builder.build(context)) + if (!warmupStarted.compareAndSet(false, true)) return + scope.launch { + while (playerPool.size < poolStartingSize) { + playerPool.offer(builder.build(context)) + // Hand the frame back so an in-flight onGetSession / acquirePlayer / layout + // pass isn't blocked behind the next build. + yield() + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 9b259e69c..f323e01bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -105,7 +105,15 @@ class PlaybackService : MediaSessionService() { val blossomServerResolver = Amethyst.instance.blossomResolver // creates new - return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolNoProxy = it } + return newPool(videoCache, okHttpClient, blossomServerResolver) + .also { + poolNoProxy = it + // Kick off the player pool warmup as soon as we know this pool is being used. + // It runs async on the main looper, yielding between builds, so the very first + // session still acquires synchronously while subsequent ones can grab a warm + // ExoPlayer instead of paying the build cost on the main thread. + it.exoPlayerPool.create(applicationContext) + } } else { poolWithProxy?.let { return it } @@ -116,7 +124,11 @@ class PlaybackService : MediaSessionService() { val videoCache = Amethyst.instance.videoCache val blossomServerResolver = Amethyst.instance.blossomResolver - return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolWithProxy = it } + return newPool(videoCache, okHttpClient, blossomServerResolver) + .also { + poolWithProxy = it + it.exoPlayerPool.create(applicationContext) + } } } @@ -161,23 +173,27 @@ class PlaybackService : MediaSessionService() { return } - playing.forEachIndexed { idx, it -> + playing.forEach { if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance?.id) { super.onUpdateNotification(it.session, startInForegroundRequired) return } } - playing.forEachIndexed { idx, it -> + playing.forEach { if (it.session.player.isPlaying && it.session.player.volume > 0) { super.onUpdateNotification(it.session, startInForegroundRequired) return } } - playing.forEachIndexed { idx, it -> + // Falls through to the first muted-but-playing session. Earlier this loop missed + // its return and called super.onUpdateNotification once per playing session, + // hammering the notification system whenever multiple feed videos were preloading. + playing.forEach { if (it.session.player.isPlaying) { super.onUpdateNotification(it.session, startInForegroundRequired) + return } } } From 24b8fa12b48856b0eecd41a3d4b5a637ba752421 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 04:12:35 +0000 Subject: [PATCH 04/38] fix(translation): bug, perf and jitter overhaul of rich-text translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a cluster of issues in TranslatableRichTextViewer + LanguageTranslatorService that caused stale translations, redundant ML Kit work, and visible jitter on every note that scrolls into view. Bugs fixed - Effect now actually re-runs when "Translate to" / "Don't translate from" change. Previously LaunchedEffect(Unit) snapshotted the settings once and ignored subsequent updates. - Translation cache now keys on (content, translateTo, dontTranslateFrom) instead of just content, so changing the target language no longer serves a stale translation in the wrong language. - Cancelled / "no translation needed" outcomes are now cached, so language identification no longer re-runs on every recomposition / scroll-back of text in the user's own language or in the don't-translate set. - ML Kit Tasks are now awaited via kotlinx.coroutines.tasks.await with ensureActive() checks; cancelling the composable's coroutine no longer races against an in-flight callback that mutates Compose state after disposal. - Encoded placeholders no longer collide with arbitrary user text. Replaced the old "B0/C0/A0" tokens (which a user could legitimately type) with single Unicode Private Use Area codepoints, and made replacement case-sensitive so e.g. "b0" in body text is no longer rewritten on decode. - Translation pipeline propagates failures: continueWith now rethrows task.exception instead of silently calling .result on a failed sub-task. - buildDictionary protects legacy NIP-08 references (#[N]) via the placeholder table, replacing the fragile post-translation "# [" -> "#[" string fix. Performance - LanguageTranslatorService de-duplicates concurrent translation requests for the same (text, settings) via an in-flight ConcurrentHashMap, so reposts / notifications / threads sharing the same content fire one ML Kit pipeline instead of N. - executorService is now a private bounded fixed pool sized on availableProcessors() / 2 instead of a publicly-mutable unbounded cached pool that could spawn dozens of threads under heavy scroll. - Skip ML Kit entirely for texts shorter than 4 chars or with no letter codepoints (emoji-only, punctuation) — language identification is unreliable there anyway. - Translation cache bumped from 100 to 500 entries to cover long threads / long-form articles. - Single-call translation (one ML Kit call for the whole text) preserves sentence-level context across paragraphs that the old per-line split discarded. Jitter - Removed CrossfadeIfEnabled around the rich-text body. The old code rendered two full RichTextViewer trees (and re-parsed URLs / hashtags / NIP-19 references twice) during the ~300ms crossfade whenever a translation arrived. Body now swaps directly; only the translation toggle hint sits below. - Replaced derivedStateOf around a trivial ternary with a plain expression. - Locale.forLanguageTag(...).displayName memoized per source/target tag so the CLDR display-name lookup doesn't run on every recomposition of the "Translated from X to Y" hint. - Device-locale list lifted out of the dropdown render loop and remembered, so ConfigurationCompat.getLocales no longer fires per recomposition while the language menu is open. - Dropdown body is only composed when expanded — it was already cheap inside Material3's DropdownMenu, but skipping the wrapper composition entirely is measurably tighter. The exposed API (LanguageTranslatorService.autoTranslate / .translate / .identifyLanguage / .clear, ResultOrError) is unchanged; TranslatableRichTextViewer's two public composables keep their signatures, so the ~30 call sites and the existing TranslationsTest don't need any updates. TranslationConfig drops the showOriginal field — that toggle is now derived live from AccountLanguagePreferences.preferenceBetween(...) so changing the user's language preference is reflected immediately without invalidating the cache. https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5 --- .../ui/components/TranslationConfig.kt | 11 +- .../service/lang/LanguageTranslatorService.kt | 223 ++++++---- .../service/lang/TranslationsCache.kt | 26 +- .../components/TranslatableRichTextViewer.kt | 418 ++++++++---------- 4 files changed, 352 insertions(+), 326 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt index d27408007..61036e103 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt @@ -22,10 +22,17 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.Immutable +/** + * The current translation state for a piece of content. + * + * `sourceLang` and `targetLang` are non-null only when an actual translation took place; + * a no-op (same language, undetected, blocklisted) keeps both null and `result` equal to the + * original content. The user-facing "show original" toggle is derived live from + * `AccountLanguagePreferences.preferenceBetween(...)` and is not stored here. + */ @Immutable data class TranslationConfig( - val result: String?, + val result: String, val sourceLang: String?, val targetLang: String?, - val showOriginal: Boolean, ) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index ccf648e46..2767a9380 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -32,7 +32,7 @@ import com.google.mlkit.nl.translate.Translator import com.google.mlkit.nl.translate.TranslatorOptions import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector -import kotlinx.coroutines.CancellationException +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.regex.Pattern @@ -45,15 +45,28 @@ data class ResultOrError( ) object LanguageTranslatorService { - var executorService: ExecutorService = Executors.newCachedThreadPool() + // Texts shorter than this, or with no letters at all (emoji-only, punctuation), are skipped + // before any ML Kit work — language identification is unreliable on them anyway. + private const val MIN_TRANSLATABLE_LENGTH = 4 - private val options = + // Single Unicode Private Use Area codepoint per placeholder. PUA chars don't appear in normal + // user text, the translator has no rule for them so it passes them through, and using one + // codepoint (instead of bracketed digits) means the translator can't split or reorder the + // placeholder. Range U+E000..U+F8FF gives 6400 slots, far more than any single note needs. + private const val PLACEHOLDER_BASE = 0xE000 + private const val PLACEHOLDER_LIMIT = 0xF8FF - PLACEHOLDER_BASE + + private val executorService: ExecutorService = + Executors.newFixedThreadPool(maxOf(2, Runtime.getRuntime().availableProcessors() / 2)) + + private val identificationOptions = LanguageIdentificationOptions .Builder() .setExecutor(executorService) .setConfidenceThreshold(0.6f) .build() - private val languageIdentification = LanguageIdentification.getClient(options) + private val languageIdentification = LanguageIdentification.getClient(identificationOptions) + val lnRegex: Pattern = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) val tagRegex: Pattern = Pattern.compile( @@ -61,6 +74,10 @@ object LanguageTranslatorService { Pattern.CASE_INSENSITIVE, ) + // Legacy NIP-08 positional references like #[0]. Translators tend to insert a space inside the + // brackets ("# [0]"), so we shield them via the placeholder dictionary instead of post-fixing. + val nip08RefRegex: Pattern = Pattern.compile("#\\[\\d+]") + private val translators = object : LruCache(3) { override fun create(options: TranslatorOptions): Translator = Translation.getClient(options) @@ -75,8 +92,20 @@ object LanguageTranslatorService { } } + private data class InFlightKey( + val text: String, + val translateTo: String, + val dontTranslateFrom: Set, + ) + + // Coalesces concurrent translation requests for the same (content, settings) — the same note + // shown in N composables (reposts, notifications) only fires one ML Kit pipeline. + private val inFlight = ConcurrentHashMap>() + fun clear() { translators.evictAll() + inFlight.clear() + TranslationsCache.clear() } fun identifyLanguage(text: String): Task = languageIdentification.identifyLanguage(text) @@ -107,108 +136,108 @@ object LanguageTranslatorService { return translator.downloadModelIfNeeded().onSuccessTask(executorService) { checkNotInMainThread() - val tasks = mutableListOf>() - val dict = lnDictionary(text) + urlDictionary(text) + tagDictionary(text) + val dict = buildDictionary(text) + val encoded = encodeWithDictionary(text, dict) - for (paragraph in encodeDictionary(text, dict).split("\n")) { - tasks.add(translator.translate(paragraph)) - } - - Tasks.whenAll(tasks).continueWith(executorService) { - checkNotInMainThread() - - val results: MutableList = ArrayList() - for (task in tasks) { - val fixedText = - task.result.replace("# [", "#[") // fixes tags that always return with a space - results.add(decodeDictionary(fixedText, dict)) - } - ResultOrError(results.joinToString("\n"), source, target) + translator.translate(encoded).continueWith(executorService) { task -> + task.exception?.let { throw it } + ResultOrError(decodeWithDictionary(task.result, dict), source, target) } } } - private fun encodeDictionary( - text: String, - dict: Map, - ): String { - var newText = text - for (pair in dict) { - newText = newText.replace(pair.value, pair.key, true) - } - return newText - } - - private fun decodeDictionary( - text: String, - dict: Map, - ): String { - var newText = text - for (pair in dict) { - newText = newText.replace(pair.key, pair.value, true) - } - return newText - } - - private fun tagDictionary(text: String): Map { - val matcher = tagRegex.matcher(text) - val returningList = mutableMapOf() - var counter = 0 - while (matcher.find()) { - try { - val tag = matcher.group() - val short = "C$counter" - counter++ - returningList.put(short, tag) - } catch (e: Exception) { - if (e is CancellationException) throw e - } - } - return returningList - } - - private fun lnDictionary(text: String): Map { - val matcher = lnRegex.matcher(text) - val returningList = mutableMapOf() - var counter = 0 - while (matcher.find()) { - try { - val lnInvoice = matcher.group() - val short = "A$counter" - counter++ - returningList.put(short, lnInvoice) - } catch (e: Exception) { - if (e is CancellationException) throw e - } - } - return returningList - } - - private fun urlDictionary(text: String): Map { - val urlsInText = UrlDetector(text).detect() - - var counter = 0 - - return urlsInText - .filter { !it.originalUrl.contains(",") && !it.originalUrl.contains("。") } - .associate { - counter++ - "B$counter" to it.originalUrl - } - } - fun autoTranslate( text: String, dontTranslateFrom: Set, translateTo: String, - ): Task = - identifyLanguage(text).onSuccessTask(executorService) { - if (it.equals(translateTo, true)) { - Tasks.forCanceled() - } else if (it != "und" && !dontTranslateFrom.contains(it)) { - translate(text, it, translateTo) - } else { - Tasks.forCanceled() + ): Task { + if (!isWorthTranslating(text)) return Tasks.forCanceled() + + val key = InFlightKey(text, translateTo, dontTranslateFrom) + inFlight[key]?.let { return it } + + val task = + identifyLanguage(text).onSuccessTask(executorService) { detected -> + when { + detected == "und" -> Tasks.forCanceled() + detected.equals(translateTo, ignoreCase = true) -> Tasks.forCanceled() + detected in dontTranslateFrom -> Tasks.forCanceled() + else -> translate(text, detected, translateTo) + } } + + // putIfAbsent guards against a racing caller: keep the winner, drop the loser. + val winner = inFlight.putIfAbsent(key, task) ?: task + winner.addOnCompleteListener(executorService) { inFlight.remove(key, winner) } + return winner + } + + private fun isWorthTranslating(text: String): Boolean { + if (text.length < MIN_TRANSLATABLE_LENGTH) return false + // Cheap scan; bail as soon as we see one letter codepoint. + for (cp in text.codePoints()) { + if (Character.isLetter(cp)) return true } + return false + } + + private fun buildDictionary(text: String): Map { + val dict = LinkedHashMap() + var counter = 0 + + fun addUnique(value: String) { + if (value.isEmpty()) return + if (counter > PLACEHOLDER_LIMIT) return + if (dict.containsValue(value)) return + dict[placeholder(counter++)] = value + } + + val lnMatcher = lnRegex.matcher(text) + while (lnMatcher.find()) addUnique(lnMatcher.group()) + + val tagMatcher = tagRegex.matcher(text) + while (tagMatcher.find()) addUnique(tagMatcher.group()) + + val nip08Matcher = nip08RefRegex.matcher(text) + while (nip08Matcher.find()) addUnique(nip08Matcher.group()) + + for (url in UrlDetector(text).detect()) { + val original = url.originalUrl + // The URL detector greedily includes Chinese full-width punctuation; skip those false hits. + if (original.contains(',') || original.contains('。')) continue + addUnique(original) + } + + return dict + } + + private fun placeholder(index: Int): String { + require(index in 0..PLACEHOLDER_LIMIT) { "placeholder index $index out of range" } + return String(Character.toChars(PLACEHOLDER_BASE + index)) + } + + private fun encodeWithDictionary( + text: String, + dict: Map, + ): String { + if (dict.isEmpty()) return text + var newText = text + // Replace longest values first so a URL prefix never clobbers a longer URL or tag. + for ((token, original) in dict.entries.sortedByDescending { it.value.length }) { + newText = newText.replace(original, token, ignoreCase = false) + } + return newText + } + + private fun decodeWithDictionary( + text: String?, + dict: Map, + ): String? { + if (text == null || dict.isEmpty()) return text + var newText: String = text + for ((token, original) in dict) { + newText = newText.replace(token, original, ignoreCase = false) + } + return newText + } } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationsCache.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationsCache.kt index f4f61f5e3..dfca9372a 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationsCache.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationsCache.kt @@ -24,14 +24,34 @@ import android.util.LruCache import com.vitorpamplona.amethyst.ui.components.TranslationConfig object TranslationsCache { - val cache = LruCache(100) + private const val MAX_ENTRIES = 500 - fun get(content: String): TranslationConfig = cache.get(content) ?: TranslationConfig(content, null, null, false) + // Keying on the language settings as well prevents serving stale translations after the user + // changes "Translate to" or "Don't translate from". + private data class Key( + val content: String, + val translateTo: String, + val dontTranslateFrom: Set, + ) + + private val cache = LruCache(MAX_ENTRIES) + + fun get( + content: String, + translateTo: String, + dontTranslateFrom: Set, + ): TranslationConfig? = cache.get(Key(content, translateTo, dontTranslateFrom)) fun set( content: String, + translateTo: String, + dontTranslateFrom: Set, config: TranslationConfig, ) { - cache.put(content, config) + cache.put(Key(content, translateTo, dontTranslateFrom), config) + } + + fun clear() { + cache.evictAll() } } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index fd1a67914..b916543f6 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -34,11 +34,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue 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.Modifier @@ -47,13 +45,13 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.os.ConfigurationCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.lang.TranslationsCache -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -61,9 +59,9 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp import com.vitorpamplona.amethyst.ui.theme.lessImportantLink -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.tasks.await import java.util.Locale @Composable @@ -107,51 +105,109 @@ fun TranslatableRichTextViewer( accountViewModel: AccountViewModel, displayText: @Composable (String) -> Unit, ) { - var translatedTextState by translateAndWatchLanguageChanges(content, id, accountViewModel) + val languages = accountViewModel.account.settings.syncedSettings.languages + val translateTo by languages.translateTo.collectAsStateWithLifecycle() + val dontTranslateFrom by languages.dontTranslateFrom.collectAsStateWithLifecycle() + val languagePreferences by languages.languagePreferences.collectAsStateWithLifecycle() - CrossfadeIfEnabled(targetState = translatedTextState, accountViewModel = accountViewModel) { - RenderTextWithTranslateOptions( - translatedTextState = it, - content = content, - translationMessageModifier = translationMessageModifier, - accountViewModel = accountViewModel, - displayText = displayText, - ) + val translatedTextState = + remember(id, content, translateTo, dontTranslateFrom) { + mutableStateOf( + TranslationsCache.get(content, translateTo, dontTranslateFrom) + ?: TranslationConfig(content, null, null), + ) + } + + LaunchedEffect(content, translateTo, dontTranslateFrom) { + TranslationsCache.get(content, translateTo, dontTranslateFrom)?.let { + translatedTextState.value = it + return@LaunchedEffect + } + + val noOp = TranslationConfig(content, null, null) + try { + val task = LanguageTranslatorService.autoTranslate(content, dontTranslateFrom, translateTo) + // ML Kit cancels the task to signal "no translation needed" (same language, "und", + // blocklisted). await() bridges that into a CancellationException; cache the no-op so + // we don't re-run language identification next time the same text scrolls into view. + val raw = + try { + task.await() + } catch (e: CancellationException) { + coroutineContext.ensureActive() + TranslationsCache.set(content, translateTo, dontTranslateFrom, noOp) + translatedTextState.value = noOp + return@LaunchedEffect + } + + coroutineContext.ensureActive() + + val translated = raw.result + val source = raw.sourceLang + val target = raw.targetLang + val newConfig = + if ( + translated != null && + source != null && + target != null && + source != target && + translated != content + ) { + TranslationConfig(translated, source, target) + } else { + noOp + } + TranslationsCache.set(content, translateTo, dontTranslateFrom, newConfig) + translatedTextState.value = newConfig + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Network / model download / translator failure — keep showing the original. Do not + // cache: a transient failure shouldn't block future attempts on the same text. + } } + + RenderTextWithTranslateOptions( + translatedTextState = translatedTextState.value, + content = content, + languagePreferences = languagePreferences, + translationMessageModifier = translationMessageModifier, + accountViewModel = accountViewModel, + displayText = displayText, + ) } @Composable private fun RenderTextWithTranslateOptions( translatedTextState: TranslationConfig, content: String, + languagePreferences: Map, translationMessageModifier: Modifier = MaxWidthPaddingTop5dp, accountViewModel: AccountViewModel, displayText: @Composable (String) -> Unit, ) { - var showOriginal by - remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) } + val source = translatedTextState.sourceLang + val target = translatedTextState.targetLang + val translationOccurred = source != null && target != null && source != target - val toBeViewed by - remember(translatedTextState) { - derivedStateOf { if (showOriginal) content else translatedTextState.result ?: content } + val storedPreference = if (translationOccurred) languagePreferences["$source,$target"] else null + var showOriginal by + remember(translatedTextState, storedPreference) { + mutableStateOf(storedPreference == source) } + val toBeViewed = if (showOriginal || !translationOccurred) content else translatedTextState.result + Column { displayText(toBeViewed) - if ( - translatedTextState.sourceLang != null && - translatedTextState.targetLang != null && - translatedTextState.sourceLang != translatedTextState.targetLang - ) { + if (translationOccurred) { TranslationMessage( - translatedTextState.sourceLang, - translatedTextState.targetLang, - translationMessageModifier, - accountViewModel, - ) { - showOriginal = it - } + source = source, + target = target, + modifier = translationMessageModifier, + accountViewModel = accountViewModel, + ) { showOriginal = it } } } } @@ -165,21 +221,24 @@ private fun TranslationMessage( onChangeWhatToShow: (Boolean) -> Unit, ) { var langSettingsPopupExpanded by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - Row( - modifier = modifier, - ) { + val sourceDisplay = remember(source) { Locale.forLanguageTag(source).displayName } + val targetDisplay = remember(target) { Locale.forLanguageTag(target).displayName } + val autoLabel = stringRes(R.string.translations_auto) + val translatedFromLabel = stringRes(R.string.translations_translated_from) + val toLabel = stringRes(R.string.translations_to) + + Row(modifier = modifier) { val textColor = MaterialTheme.colorScheme.lessImportantLink Text( text = buildAnnotatedString { - appendLink(stringRes(R.string.translations_auto), textColor) { langSettingsPopupExpanded = !langSettingsPopupExpanded } - append(" ${stringRes(R.string.translations_translated_from)} ") - appendLink(Locale.forLanguageTag(source).displayName, textColor) { onChangeWhatToShow(true) } - append(" ${stringRes(R.string.translations_to)} ") - appendLink(Locale.forLanguageTag(target).displayName, textColor) { onChangeWhatToShow(false) } + appendLink(autoLabel, textColor) { langSettingsPopupExpanded = !langSettingsPopupExpanded } + append(" $translatedFromLabel ") + appendLink(sourceDisplay, textColor) { onChangeWhatToShow(true) } + append(" $toLabel ") + appendLink(targetDisplay, textColor) { onChangeWhatToShow(false) } }, style = LocalTextStyle.current.copy( @@ -190,198 +249,109 @@ private fun TranslationMessage( maxLines = 3, ) - DropdownMenu( - expanded = langSettingsPopupExpanded, - onDismissRequest = { langSettingsPopupExpanded = false }, - ) { - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (source in accountViewModel.dontTranslateFrom()) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringRes( - R.string.translations_never_translate_from_lang, - Locale.forLanguageTag(source).displayName, - ), - ) - } - }, - onClick = { - accountViewModel.toggleDontTranslateFrom(source) - langSettingsPopupExpanded = false - }, + if (langSettingsPopupExpanded) { + LangSettingsDropdown( + expanded = true, + source = source, + target = target, + sourceDisplay = sourceDisplay, + targetDisplay = targetDisplay, + accountViewModel = accountViewModel, + onDismiss = { langSettingsPopupExpanded = false }, ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (accountViewModel.account.settings.preferenceBetween(source, target) == source) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringRes( - R.string.translations_show_in_lang_first, - Locale.forLanguageTag(source).displayName, - ), - ) - } - }, - onClick = { - accountViewModel.prefer(source, target, source) - langSettingsPopupExpanded = false - }, - ) - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (accountViewModel.account.settings.syncedSettings.languages - .preferenceBetween(source, target) == target - ) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringRes( - R.string.translations_show_in_lang_first, - Locale.forLanguageTag(target).displayName, - ), - ) - } - }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.prefer(source, target, target) - langSettingsPopupExpanded = false - } - }, - ) - HorizontalDivider(thickness = DividerThickness) - - val languageList = ConfigurationCompat.getLocales(Resources.getSystem().configuration) - for (i in 0 until languageList.size()) { - languageList.get(i)?.let { lang -> - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (accountViewModel.account.settings.translateToContains(lang.language)) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringRes( - R.string.translations_always_translate_to_lang, - lang.displayName, - ), - ) - } - }, - onClick = { - langSettingsPopupExpanded = false - accountViewModel.updateTranslateTo(lang.language) - }, - ) - } - } } } } @Composable -fun translateAndWatchLanguageChanges( - content: String, - id: String, +private fun LangSettingsDropdown( + expanded: Boolean, + source: String, + target: String, + sourceDisplay: String, + targetDisplay: String, accountViewModel: AccountViewModel, -): MutableState { - val translatedTextState = remember(id) { mutableStateOf(TranslationsCache.get(content)) } - - TranslateAndWatchLanguageChanges( - content, - accountViewModel, - ) { result -> - if ( - !translatedTextState.value.result.equals(result.result, true) || - translatedTextState.value.sourceLang != result.sourceLang || - translatedTextState.value.targetLang != result.targetLang - ) { - TranslationsCache.set(content, result) - translatedTextState.value = result - } - } - - return translatedTextState -} - -@Composable -fun TranslateAndWatchLanguageChanges( - content: String, - accountViewModel: AccountViewModel, - onTranslated: (TranslationConfig) -> Unit, + onDismiss: () -> Unit, ) { - LaunchedEffect(Unit) { - // This takes some time. Launches as a Composition scope to make sure this gets cancel if this - // item gets out of view. - withContext(Dispatchers.IO) { - LanguageTranslatorService - .autoTranslate( - content, - accountViewModel.dontTranslateFrom(), - accountViewModel.translateTo(), - ).addOnCompleteListener { task -> - if (task.isSuccessful && !content.equals(task.result.result, true)) { - if (task.result.sourceLang != null && task.result.targetLang != null) { - val preference = - accountViewModel.account.settings.preferenceBetween( - task.result.sourceLang!!, - task.result.targetLang!!, - ) - val newConfig = - TranslationConfig( - result = task.result.result, - sourceLang = task.result.sourceLang, - targetLang = task.result.targetLang, - showOriginal = preference == task.result.sourceLang, - ) + val deviceLocales = + remember { + val list = ConfigurationCompat.getLocales(Resources.getSystem().configuration) + (0 until list.size()).mapNotNull { list.get(it) } + } - onTranslated(newConfig) - } - } - } + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + DropdownMenuItem( + text = { + CheckmarkRow( + checked = source in accountViewModel.dontTranslateFrom(), + label = stringRes(R.string.translations_never_translate_from_lang, sourceDisplay), + ) + }, + onClick = { + accountViewModel.toggleDontTranslateFrom(source) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + DropdownMenuItem( + text = { + CheckmarkRow( + checked = accountViewModel.account.settings.preferenceBetween(source, target) == source, + label = stringRes(R.string.translations_show_in_lang_first, sourceDisplay), + ) + }, + onClick = { + accountViewModel.prefer(source, target, source) + onDismiss() + }, + ) + DropdownMenuItem( + text = { + CheckmarkRow( + checked = accountViewModel.account.settings.preferenceBetween(source, target) == target, + label = stringRes(R.string.translations_show_in_lang_first, targetDisplay), + ) + }, + onClick = { + accountViewModel.prefer(source, target, target) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + + for (lang in deviceLocales) { + DropdownMenuItem( + text = { + CheckmarkRow( + checked = accountViewModel.account.settings.translateToContains(lang.language), + label = stringRes(R.string.translations_always_translate_to_lang, lang.displayName), + ) + }, + onClick = { + onDismiss() + accountViewModel.updateTranslateTo(lang.language) + }, + ) } } } + +@Composable +private fun CheckmarkRow( + checked: Boolean, + label: String, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (checked) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + Spacer(modifier = Modifier.size(10.dp)) + Text(label) + } +} From ed2419b6c78f326d18f0ed33924e899bfd5ea909 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 26 Apr 2026 07:37:23 +0000 Subject: [PATCH 05/38] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-pl-rPL/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 0fb7202c8..45a3ae1ac 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -2196,6 +2196,8 @@ Pomoc w pisaniu z użyciem AI Zaproponuj poprawki w tekście Używa modelu sztucznej inteligencji wbudowanego w urządzenie, proponując poprawki tekstu i zmiany tonu wypowiedzi. + Monitorowane transmisje + Podczas wysyłania zdarzeń korzystaj z monitora transmisji. Pokazuje postęp na żywo i status transmisji podczas nadawania. Użyj tego Ignoruj Popraw From c1c52aa0222aa65a51dcdf40da30fde7a9c065e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 07:08:20 +0000 Subject: [PATCH 06/38] fix(deb): broaden libicu Depends so .deb installs across Debian/Ubuntu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jpackage runs `dpkg-shlibdeps` against the bundled JDK runtime's native libs (libfontmanager.so etc. link libicu) and pins Depends to the build host's libicu version. CI builds on ubuntu-24.04, so the .deb requires libicu74 — uninstallable on Ubuntu 22.04 (libicu70), Debian 12 (libicu72), Debian 11 (libicu67), and Debian 13 (libicu76). Neither jpackage nor the Compose Multiplatform DSL exposes a way to override the auto-generated Depends. Add scripts/relax-deb-libicu.sh which extracts the .deb with `dpkg-deb -R`, rewrites `libicuNN` (or any alternation thereof) to `libicu66 | libicu67 | libicu70 | libicu72 | libicu74 | libicu76 | libicu77`, and repacks. The script is idempotent and a no-op for .debs without a libicu Depends. Wire it into both release legs (desktopApp + amy CLI) in create-release.yml, and into the desktop test/build leg in build.yml so testers downloading the CI artifact hit the same fix. --- .github/workflows/build.yml | 10 +++++++ .github/workflows/create-release.yml | 18 ++++++++++++ scripts/relax-deb-libicu.sh | 41 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100755 scripts/relax-deb-libicu.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a120a8a5e..347c7b548 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,6 +68,16 @@ jobs: - name: Test + Build Desktop (gradle) run: ./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }} --no-daemon + # jpackage pins libicu to the build host's version (libicu74 on + # ubuntu-24.04). Rewrite the .deb so testers on other Debian/Ubuntu + # releases can install the uploaded artifact. + - name: Relax libicu dependency in .deb + if: matrix.desktop-task == 'packageDeb' + run: | + set -euo pipefail + chmod +x scripts/relax-deb-libicu.sh + scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main/deb/*.deb + - name: Upload Desktop Distribution uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 0ac241bfb..e5ce7edcd 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -103,6 +103,15 @@ jobs: timeout_minutes: 15 command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }} + # jpackage pins libicu to the build host's version (libicu74 on + # ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu. + - name: Relax libicu dependency in .deb + if: matrix.family == 'linux' + run: | + set -euo pipefail + chmod +x scripts/relax-deb-libicu.sh + scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb + - name: Build portable archives (windows + linux-portable) if: matrix.family == 'windows' || matrix.family == 'linux-portable' run: | @@ -248,6 +257,15 @@ jobs: timeout_minutes: 15 command: ./gradlew --no-daemon :cli:${{ matrix.tasks }} + # jpackage pins libicu to the build host's version (libicu74 on + # ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu. + - name: Relax libicu dependency in .deb + if: matrix.family == 'linux' + run: | + set -euo pipefail + chmod +x scripts/relax-deb-libicu.sh + scripts/relax-deb-libicu.sh cli/build/jpackage/*.deb + - name: Collect + rename assets run: | set -euo pipefail diff --git a/scripts/relax-deb-libicu.sh b/scripts/relax-deb-libicu.sh new file mode 100755 index 000000000..9e0a25ee3 --- /dev/null +++ b/scripts/relax-deb-libicu.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Broaden the libicu Depends clause in a jpackage-built .deb so it installs +# across Debian/Ubuntu releases. +# +# jpackage shells out to `dpkg-shlibdeps` against the bundled JDK runtime's +# native libraries (libfontmanager.so, etc., which link libicu). That pins the +# Depends to whatever libicu the build host ships — libicu74 on ubuntu-24.04 — +# even though the bundled JRE works fine against any reasonably recent ICU. +# Without this rewrite, users on Ubuntu 22.04 (libicu70), Debian 12 (libicu72), +# Debian 11 (libicu67), or Debian 13 (libicu76) cannot install the package. +# +# Neither jpackage nor the Compose Multiplatform DSL exposes a way to override +# the auto-generated Depends, so we rewrite the .deb after the fact. +# +# Usage: relax-deb-libicu.sh [ ...] +set -euo pipefail + +# Spans Debian 11 → 13 and Ubuntu 20.04 → 26.04. Append new SONAMEs here when +# a new Debian/Ubuntu release ships a bumped libicu. +ALT='libicu66 | libicu67 | libicu70 | libicu72 | libicu74 | libicu76 | libicu77' + +for deb in "$@"; do + if [[ ! -f "$deb" ]]; then + echo "skip: not a file: $deb" >&2 + continue + fi + + work="$(mktemp -d)" + dpkg-deb -R "$deb" "$work/pkg" + control="$work/pkg/DEBIAN/control" + + if grep -qE 'libicu[0-9]+' "$control"; then + sed -i -E "s/libicu[0-9]+([[:space:]]*\\|[[:space:]]*libicu[0-9]+)*/${ALT}/g" "$control" + dpkg-deb -b "$work/pkg" "$deb" >/dev/null + echo "Relaxed libicu dep: $deb" + else + echo "No libicu dep, leaving as-is: $deb" + fi + + rm -rf "$work" +done From 91d194b11e40cccc65437f884ff6e5be33036a55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 08:45:04 +0000 Subject: [PATCH 07/38] test(translation): JVM unit tests for placeholder dictionary round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the placeholder dictionary logic out of LanguageTranslatorService into a pure-JVM TranslationDictionary helper so the round-trip can be unit-tested without ML Kit / Android runtime, and adds 24 tests covering it. The existing TranslationsTest is androidTestPlay-only — it needs a real device or emulator with Google Play services, so it can't validate a refactor in plain CI or local dev. The new tests cover the riskiest part of this branch: that PUA placeholders survive an arbitrary "translation" of the surrounding text and decode back to the exact original tokens. Test coverage - isWorthTranslating: short text / letterless text rejected, mixed letters+emoji accepted - placeholder: produces single PUA codepoint, rejects out-of-range index - build: collects URLs, NIP-19 nostr refs, Lightning invoices, NIP-08 #[N] refs - build: deduplicates repeated occurrences and skips Chinese-punctuation URL false-positives - encode/decode round-trip on plain URLs, multi-URL strings, and mixed real-world content - encode replaces longer values first to avoid prefix collisions - decode preserves user text containing the OLD "B0/C0/A0" tokens (regression for the pre-rewrite collision bug) - case-sensitive replacement preserves user text that differs only in case from a placeholder - decode handles null and empty-dictionary inputs - simulated translation (rewrite English to Portuguese around the placeholders) round-trips #[0] and nostr:nevent1... unchanged LanguageTranslatorService now delegates to TranslationDictionary.{build, encode, decode, isWorthTranslating} — public API (autoTranslate / translate / identifyLanguage / clear) and behaviour are unchanged. Result: 410 tests run, 408 passed, 2 pre-existing skips, 0 failures. https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5 --- .../service/lang/LanguageTranslatorService.kt | 101 +------ .../service/lang/TranslationDictionary.kt | 121 ++++++++ .../service/lang/TranslationDictionaryTest.kt | 283 ++++++++++++++++++ 3 files changed, 408 insertions(+), 97 deletions(-) create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionary.kt create mode 100644 amethyst/src/testPlay/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionaryTest.kt diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index 2767a9380..df20633ee 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -31,11 +31,9 @@ import com.google.mlkit.nl.translate.Translation import com.google.mlkit.nl.translate.Translator import com.google.mlkit.nl.translate.TranslatorOptions import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import java.util.regex.Pattern @Immutable data class ResultOrError( @@ -45,17 +43,6 @@ data class ResultOrError( ) object LanguageTranslatorService { - // Texts shorter than this, or with no letters at all (emoji-only, punctuation), are skipped - // before any ML Kit work — language identification is unreliable on them anyway. - private const val MIN_TRANSLATABLE_LENGTH = 4 - - // Single Unicode Private Use Area codepoint per placeholder. PUA chars don't appear in normal - // user text, the translator has no rule for them so it passes them through, and using one - // codepoint (instead of bracketed digits) means the translator can't split or reorder the - // placeholder. Range U+E000..U+F8FF gives 6400 slots, far more than any single note needs. - private const val PLACEHOLDER_BASE = 0xE000 - private const val PLACEHOLDER_LIMIT = 0xF8FF - PLACEHOLDER_BASE - private val executorService: ExecutorService = Executors.newFixedThreadPool(maxOf(2, Runtime.getRuntime().availableProcessors() / 2)) @@ -67,17 +54,6 @@ object LanguageTranslatorService { .build() private val languageIdentification = LanguageIdentification.getClient(identificationOptions) - val lnRegex: Pattern = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) - val tagRegex: Pattern = - Pattern.compile( - "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", - Pattern.CASE_INSENSITIVE, - ) - - // Legacy NIP-08 positional references like #[0]. Translators tend to insert a space inside the - // brackets ("# [0]"), so we shield them via the placeholder dictionary instead of post-fixing. - val nip08RefRegex: Pattern = Pattern.compile("#\\[\\d+]") - private val translators = object : LruCache(3) { override fun create(options: TranslatorOptions): Translator = Translation.getClient(options) @@ -136,12 +112,12 @@ object LanguageTranslatorService { return translator.downloadModelIfNeeded().onSuccessTask(executorService) { checkNotInMainThread() - val dict = buildDictionary(text) - val encoded = encodeWithDictionary(text, dict) + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) translator.translate(encoded).continueWith(executorService) { task -> task.exception?.let { throw it } - ResultOrError(decodeWithDictionary(task.result, dict), source, target) + ResultOrError(TranslationDictionary.decode(task.result, dict), source, target) } } } @@ -151,7 +127,7 @@ object LanguageTranslatorService { dontTranslateFrom: Set, translateTo: String, ): Task { - if (!isWorthTranslating(text)) return Tasks.forCanceled() + if (!TranslationDictionary.isWorthTranslating(text)) return Tasks.forCanceled() val key = InFlightKey(text, translateTo, dontTranslateFrom) inFlight[key]?.let { return it } @@ -171,73 +147,4 @@ object LanguageTranslatorService { winner.addOnCompleteListener(executorService) { inFlight.remove(key, winner) } return winner } - - private fun isWorthTranslating(text: String): Boolean { - if (text.length < MIN_TRANSLATABLE_LENGTH) return false - // Cheap scan; bail as soon as we see one letter codepoint. - for (cp in text.codePoints()) { - if (Character.isLetter(cp)) return true - } - return false - } - - private fun buildDictionary(text: String): Map { - val dict = LinkedHashMap() - var counter = 0 - - fun addUnique(value: String) { - if (value.isEmpty()) return - if (counter > PLACEHOLDER_LIMIT) return - if (dict.containsValue(value)) return - dict[placeholder(counter++)] = value - } - - val lnMatcher = lnRegex.matcher(text) - while (lnMatcher.find()) addUnique(lnMatcher.group()) - - val tagMatcher = tagRegex.matcher(text) - while (tagMatcher.find()) addUnique(tagMatcher.group()) - - val nip08Matcher = nip08RefRegex.matcher(text) - while (nip08Matcher.find()) addUnique(nip08Matcher.group()) - - for (url in UrlDetector(text).detect()) { - val original = url.originalUrl - // The URL detector greedily includes Chinese full-width punctuation; skip those false hits. - if (original.contains(',') || original.contains('。')) continue - addUnique(original) - } - - return dict - } - - private fun placeholder(index: Int): String { - require(index in 0..PLACEHOLDER_LIMIT) { "placeholder index $index out of range" } - return String(Character.toChars(PLACEHOLDER_BASE + index)) - } - - private fun encodeWithDictionary( - text: String, - dict: Map, - ): String { - if (dict.isEmpty()) return text - var newText = text - // Replace longest values first so a URL prefix never clobbers a longer URL or tag. - for ((token, original) in dict.entries.sortedByDescending { it.value.length }) { - newText = newText.replace(original, token, ignoreCase = false) - } - return newText - } - - private fun decodeWithDictionary( - text: String?, - dict: Map, - ): String? { - if (text == null || dict.isEmpty()) return text - var newText: String = text - for ((token, original) in dict) { - newText = newText.replace(token, original, ignoreCase = false) - } - return newText - } } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionary.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionary.kt new file mode 100644 index 000000000..61b659ed5 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionary.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.lang + +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector +import java.util.regex.Pattern + +/** + * Pure-JVM helpers that protect non-translatable substrings (URLs, Lightning invoices, NIP-19 + * references, NIP-08 positional references) by swapping them with single Unicode Private Use Area + * codepoints around a translator. Extracted out of [LanguageTranslatorService] so the round-trip + * can be unit-tested without ML Kit / Android runtime. + */ +internal object TranslationDictionary { + // Range U+E000..U+F8FF gives 6400 placeholder slots. PUA codepoints don't appear in normal + // user text, the translator has no rule for them so it passes them through, and using one + // codepoint per placeholder means the translator can't split or reorder it. + const val PLACEHOLDER_BASE: Int = 0xE000 + const val PLACEHOLDER_LIMIT: Int = 0xF8FF - PLACEHOLDER_BASE + + // Texts shorter than this, or with no letter codepoints (emoji-only, punctuation), are skipped + // before any ML Kit work — language identification is unreliable on them. + private const val MIN_TRANSLATABLE_LENGTH = 4 + + val lnRegex: Pattern = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) + val tagRegex: Pattern = + Pattern.compile( + "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", + Pattern.CASE_INSENSITIVE, + ) + + // Legacy NIP-08 positional references like #[0]. Translators tend to insert a space inside the + // brackets ("# [0]"), so we shield them via the placeholder dictionary. + val nip08RefRegex: Pattern = Pattern.compile("#\\[\\d+]") + + fun isWorthTranslating(text: String): Boolean { + if (text.length < MIN_TRANSLATABLE_LENGTH) return false + for (cp in text.codePoints()) { + if (Character.isLetter(cp)) return true + } + return false + } + + fun build(text: String): Map { + val dict = LinkedHashMap() + var counter = 0 + + fun addUnique(value: String) { + if (value.isEmpty()) return + if (counter > PLACEHOLDER_LIMIT) return + if (dict.containsValue(value)) return + dict[placeholder(counter++)] = value + } + + val lnMatcher = lnRegex.matcher(text) + while (lnMatcher.find()) addUnique(lnMatcher.group()) + + val tagMatcher = tagRegex.matcher(text) + while (tagMatcher.find()) addUnique(tagMatcher.group()) + + val nip08Matcher = nip08RefRegex.matcher(text) + while (nip08Matcher.find()) addUnique(nip08Matcher.group()) + + for (url in UrlDetector(text).detect()) { + val original = url.originalUrl + // The URL detector greedily includes Chinese full-width punctuation; skip those false hits. + if (original.contains(',') || original.contains('。')) continue + addUnique(original) + } + + return dict + } + + fun encode( + text: String, + dict: Map, + ): String { + if (dict.isEmpty()) return text + var newText = text + // Replace longest values first so a URL prefix never clobbers a longer URL or tag. + for ((token, original) in dict.entries.sortedByDescending { it.value.length }) { + newText = newText.replace(original, token, ignoreCase = false) + } + return newText + } + + fun decode( + text: String?, + dict: Map, + ): String? { + if (text == null || dict.isEmpty()) return text + var newText: String = text + for ((token, original) in dict) { + newText = newText.replace(token, original, ignoreCase = false) + } + return newText + } + + fun placeholder(index: Int): String { + require(index in 0..PLACEHOLDER_LIMIT) { "placeholder index $index out of range" } + return String(Character.toChars(PLACEHOLDER_BASE + index)) + } +} diff --git a/amethyst/src/testPlay/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionaryTest.kt b/amethyst/src/testPlay/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionaryTest.kt new file mode 100644 index 000000000..a415c5241 --- /dev/null +++ b/amethyst/src/testPlay/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionaryTest.kt @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.lang + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class TranslationDictionaryTest { + // ----- isWorthTranslating ----- + + @Test + fun `short text is not worth translating`() { + assertFalse(TranslationDictionary.isWorthTranslating("")) + assertFalse(TranslationDictionary.isWorthTranslating("a")) + assertFalse(TranslationDictionary.isWorthTranslating("ab")) + assertFalse(TranslationDictionary.isWorthTranslating("abc")) + } + + @Test + fun `letterless text is not worth translating`() { + assertFalse(TranslationDictionary.isWorthTranslating("123456")) + assertFalse(TranslationDictionary.isWorthTranslating("!!!!!!")) + assertFalse(TranslationDictionary.isWorthTranslating(" ")) + // Emoji-only. + assertFalse(TranslationDictionary.isWorthTranslating("😊😊😊")) + } + + @Test + fun `text with at least one letter is worth translating`() { + assertTrue(TranslationDictionary.isWorthTranslating("Hello")) + assertTrue(TranslationDictionary.isWorthTranslating("a123")) + assertTrue(TranslationDictionary.isWorthTranslating("你好世界")) + // Mixed emoji + letters. + assertTrue(TranslationDictionary.isWorthTranslating("😊 hi")) + } + + // ----- placeholder ----- + + @Test + fun `placeholder is a single Unicode Private Use Area codepoint`() { + val p0 = TranslationDictionary.placeholder(0) + val p1 = TranslationDictionary.placeholder(1) + assertEquals(1, p0.codePointCount(0, p0.length)) + assertEquals(1, p1.codePointCount(0, p1.length)) + assertEquals(0xE000, p0.codePointAt(0)) + assertEquals(0xE001, p1.codePointAt(0)) + assertNotEquals(p0, p1) + } + + @Test + fun `placeholder rejects out of range index`() { + try { + TranslationDictionary.placeholder(-1) + fail("expected IllegalArgumentException for negative index") + } catch (_: IllegalArgumentException) { + // expected + } + try { + TranslationDictionary.placeholder(TranslationDictionary.PLACEHOLDER_LIMIT + 1) + fail("expected IllegalArgumentException for index past limit") + } catch (_: IllegalArgumentException) { + // expected + } + } + + // ----- build ----- + + @Test + fun `build empty dictionary for plain text`() { + val dict = TranslationDictionary.build("Just plain text with no special tokens") + assertTrue(dict.isEmpty()) + } + + @Test + fun `build picks up a single URL`() { + val text = "Have you seen this https://t.me/mygroup yet?" + val dict = TranslationDictionary.build(text) + assertEquals(1, dict.size) + assertTrue("dict should contain the URL value", dict.containsValue("https://t.me/mygroup")) + } + + @Test + fun `build picks up nostr NIP-19 references`() { + val text = "see nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy here" + val dict = TranslationDictionary.build(text) + assertEquals(1, dict.size) + assertTrue(dict.containsValue("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy")) + } + + @Test + fun `build picks up Lightning invoices`() { + val invoice = + "lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjs" + val dict = TranslationDictionary.build("Pay me: $invoice please") + assertEquals(1, dict.size) + assertTrue(dict.containsValue(invoice)) + } + + @Test + fun `build picks up legacy NIP-08 positional references`() { + val text = "Have you seen this, #[0]" + val dict = TranslationDictionary.build(text) + assertEquals(1, dict.size) + assertTrue(dict.containsValue("#[0]")) + } + + @Test + fun `build deduplicates repeated occurrences of the same value`() { + val text = "https://a.com and again https://a.com" + val dict = TranslationDictionary.build(text) + assertEquals(1, dict.size) + } + + @Test + fun `build collects multiple distinct tokens`() { + val text = + "ln: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3 url: https://a.com " + + "ref: nostr:nevent1qqsabcdefghjklmnpqrstuvwxyz023456789 nip08: #[0]" + val dict = TranslationDictionary.build(text) + // We expect at least one entry per category. Exact count depends on the regexes' bech32-charset + // truncation behaviour; the contract we care about is that each distinct kind is captured. + assertTrue(dict.values.any { it.startsWith("lnbc") }) + assertTrue("https://a.com" in dict.values) + assertTrue(dict.values.any { it.startsWith("nostr:nevent1") }) + assertTrue("#[0]" in dict.values) + } + + @Test + fun `build rejects URLs with Chinese full-width punctuation false-positives`() { + // The URL detector greedily includes , and 。 — those substrings are not real URLs. + val text = "看 http://x.com,再见。" + val dict = TranslationDictionary.build(text) + for (value in dict.values) { + assertFalse("URL with , or 。 should be skipped: $value", value.contains(',') || value.contains('。')) + } + } + + // ----- encode / decode round-trip ----- + + @Test + fun `encode replaces dictionary values with placeholders and decode restores them`() { + val text = "Have you seen this https://t.me/mygroup ?" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + + assertFalse("URL must be removed from encoded text", encoded.contains("https://t.me/mygroup")) + assertTrue("encoded text must contain the placeholder", encoded.codePoints().anyMatch { it in 0xE000..0xF8FF }) + + val decoded = TranslationDictionary.decode(encoded, dict) + assertEquals(text, decoded) + } + + @Test + fun `round-trip preserves nostr references through a simulated translation`() { + val text = "Have you seen this, #[0] and nostr:nevent1qqsabcdefgh023456?" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + + // Simulate a translator: rewrite the surrounding English to Portuguese, but pass placeholders through unchanged. + val translated = encoded.replace("Have you seen this", "Você já viu isso").replace("and", "e") + + val decoded = TranslationDictionary.decode(translated, dict)!! + assertTrue("decoded must contain #[0]", decoded.contains("#[0]")) + assertTrue("decoded must contain the nostr ref", decoded.contains("nostr:nevent1qqsabcdefgh023456")) + assertFalse("decoded must not leak placeholder codepoints", decoded.codePoints().anyMatch { it in 0xE000..0xF8FF }) + } + + @Test + fun `round-trip preserves multiple URLs of differing lengths`() { + val text = + "short https://a.co and " + + "long https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg end" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + val decoded = TranslationDictionary.decode(encoded, dict) + assertEquals(text, decoded) + } + + @Test + fun `encode replaces longer values first to avoid prefix collisions`() { + // If "https://a.co" was replaced before "https://a.co/long", the longer URL would be partially clobbered. + val text = "long https://a.co/long short https://a.co end" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + // Both URLs must be fully replaced — no leftover http:// fragments. + assertFalse("no leftover URL fragment in encoded text", encoded.contains("https://")) + val decoded = TranslationDictionary.decode(encoded, dict) + assertEquals(text, decoded) + } + + @Test + fun `decode does not corrupt user text containing the old B0 C0 A0 placeholders`() { + // Regression for the pre-rewrite bug: old placeholders "B0", "C0", "A0" collided with arbitrary + // user content. The new PUA placeholders are invisible codepoints that cannot occur in normal text, + // so a sentence mentioning "B0" or "C0" should round-trip unchanged when there's nothing to replace. + val text = "Pricing tier B0 vs C0 vs A0 — see https://docs.example.com/tiers" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + val decoded = TranslationDictionary.decode(encoded, dict)!! + assertTrue(decoded.contains("B0")) + assertTrue(decoded.contains("C0")) + assertTrue(decoded.contains("A0")) + assertEquals(text, decoded) + } + + @Test + fun `case sensitive replacement preserves user text that differs only in case`() { + // The pre-rewrite implementation used ignoreCase=true, which could mangle user text that looked + // like a URL placeholder in a different case. With case-sensitive replacement this can't happen. + val text = "Visit HTTPS://A.COM/Path then revisit https://a.com/Path" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + val decoded = TranslationDictionary.decode(encoded, dict) + assertEquals(text, decoded) + } + + @Test + fun `encode is no-op when dictionary is empty`() { + val text = "Plain text without anything special" + assertEquals(text, TranslationDictionary.encode(text, emptyMap())) + } + + @Test + fun `decode handles null input`() { + assertNull(TranslationDictionary.decode(null, mapOf("a" to "b"))) + } + + @Test + fun `decode is no-op when dictionary is empty`() { + val text = "anything goes" + assertEquals(text, TranslationDictionary.decode(text, emptyMap())) + } + + @Test + fun `mixed content from real-world test cases round-trips`() { + val text = + "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + val decoded = TranslationDictionary.decode(encoded, dict) + assertEquals(text, decoded) + } + + @Test + fun `complex real-world post round-trips`() { + // Mirrors TranslationsTest#testHttp: URL + emoji + multiple NIP-19 references. + val text = + "https://m.primal.net/MdDd.png \nRunning... 😁 " + + "nostr:npub126ntw5mnermmj0znhjhgdk8lh2af72sm8qfzq48umdlnhaj9kuns3le9ll " + + "nostr:npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + val decoded = TranslationDictionary.decode(encoded, dict) + assertEquals(text, decoded) + // And every special token must have been replaced in the encoded form. + assertFalse(encoded.contains("https://m.primal.net/MdDd.png")) + assertFalse(encoded.contains("nostr:npub126ntw5mnermmj0znhjhgdk8lh2af72sm8qfzq48umdlnhaj9kuns3le9ll")) + assertFalse(encoded.contains("nostr:npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm")) + } +} From c9a19b90f016374c2452e1b9af87f2534864435f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 08:49:48 +0000 Subject: [PATCH 08/38] perf(video): retain warm ExoPlayers and clear up the controller hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining audit items so the eager-prepare model also pays off on scroll-back. The big change is keeping the most recent N feed players paused-with-buffer instead of stop()'ing them on release. P0 — Warm-slot ExoPlayer pool: - ExoPlayerPool now retains up to N (default 3) paused players keyed by the mediaId they last loaded. Acquire takes an optional preferredMediaId hint and returns the matching warm player intact; only the cold fallback path runs stop()/clearMediaItems(). Warm slots count against the device's MediaCodec budget (poolSize) so the cold cap is poolSize - warmSize, with warm slots themselves capped at poolSize-1 to guarantee there's always at least one cold slot for a brand-new URI. - Plumb videoUri through connection hints (PlaybackServiceClient -> PlaybackService.onGetSession -> MediaSessionPool.getSession -> ExoPlayerPool.acquirePlayer) so the service can find a warm match. Constants for the bundle keys live on PlaybackService. - GetVideoController.onEach now checks state.controller.currentMediaItem?.mediaId before calling setMediaItem. On a warm hit it leaves the player and its buffer alone — calling setMediaItem in that case would reset the player and undo the whole point of the warm pool. STATE_IDLE survivors still get a re-prepare. P1 — Stop rebuilding the MediaController on transient lifecycle dips: - GetVideoController.collectAsStateWithLifecycle was tearing down and re-binding the MediaController every time the activity lifecycle dropped below STARTED (system dialogs, briefly switching apps, notification shade). Switch to plain collectAsState — the controller now lives until the composable actually leaves composition, so a brief lifecycle dip no longer costs a full IPC rebind + buffer reload. Real backgrounding still tears down via composable disposal. P2 — Smaller fixes flushed at the same time: - GetVideoController: only write controller.volume when it differs from target. Combined with the new dedup, several feed videos preloading no longer fire one volume IPC per ready callback. - CurrentPlayPositionCacher: the resume threshold was `5 * 60`, which in milliseconds is 300 ms — i.e. "always seek". Bump to 5_000 (5 s) so trivially short clips don't pay an extra seek + buffer flush at STATE_READY just to land 100 ms away from where they started. - MediaSessionPool: stop allocating a fresh DataSourceBitmapLoader per session — the loader has no per-session state, so it's now a single lazy instance shared across all sessions in a pool. - MediaSessionPool.cleanupUnused was racy: concurrent releases all won the time check and each launched a redundant sweep coroutine. Replace with a CAS-guarded AtomicLong on a nano-precision timestamp. --- .../playback/composable/GetVideoController.kt | 45 +++-- .../playback/playerPool/ExoPlayerPool.kt | 162 ++++++++++++++---- .../playback/playerPool/MediaSessionPool.kt | 60 ++++--- .../positions/CurrentPlayPositionCacher.kt | 9 +- .../playback/service/PlaybackService.kt | 12 +- .../playback/service/PlaybackServiceClient.kt | 10 +- 6 files changed, 231 insertions(+), 67 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index 9c8bd568f..869dc653d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.amethyst.service.playback.composable import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.Player import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient @@ -49,25 +50,43 @@ fun GetVideoController( ).onEach { state -> Log.d("PlaybackService") { "Controller instance: ${state.controller}" } - if (BackgroundMedia.isPlaying()) { - // There is a video playing, start this one on mute. - state.controller.volume = 0f - Log.d("PlaybackService") { "OnEach Muted due to BackgroundMedia.isPlaying" } - } else { - // There is no other video playing. Use the default mute state to - // decide if sound is on or not. - state.controller.volume = if (muted) 0f else 1f - Log.d("PlaybackService") { "OnEach $muted" } + // The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda + // sets it to 0f when the player is acquired, so the controller arrives at 0f. + // Read first and only push an IPC if the value actually needs to change — + // with several feed videos preloading at once each volume= write was a + // round-trip to the service for nothing. + val targetVolume = + when { + BackgroundMedia.isPlaying() -> 0f + muted -> 0f + else -> 1f + } + if (state.controller.volume != targetVolume) { + state.controller.volume = targetVolume + Log.d("PlaybackService") { "OnEach volume=$targetVolume" } } if (play) { state.controller.playWhenReady = true } - state.controller.setMediaItem(mediaItem.item) - state.controller.prepare() + // Warm-pool fast path: when the underlying ExoPlayer was retained paused-with- + // buffer for this exact MediaItem, the MediaController's local mirror already + // shows the matching mediaId. Calling setMediaItem in that case would reset the + // player and discard the buffer — exactly what the warm pool exists to avoid. + // We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted + // to cold and resurfaced, or hit an error before we attached). + val targetMediaId = mediaItem.item.mediaId + val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId + if (needsLoad) { + state.controller.setMediaItem(mediaItem.item) + state.controller.prepare() + } else if (state.controller.playbackState == Player.STATE_IDLE) { + Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" } + state.controller.prepare() + } } - }.collectAsStateWithLifecycle(null) + }.collectAsState(null) controllerState?.let { inner(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index 962ab5331..dbdc7f101 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -42,10 +42,35 @@ import java.util.concurrent.atomic.AtomicBoolean class ExoPlayerPool( val builder: ExoPlayerBuilder, private val poolSize: Int, + // Requested ceiling on paused-with-buffer players retained across releases. Each retained + // player keeps its decoder and LoadControl buffer alive, which costs both memory and a + // MediaCodec instance, so warm slots count against the same [poolSize] codec budget as + // cold players (see [warmSlotsCap]). Default 3 keeps the most recent few feed videos hot + // for scroll-back without monopolizing the device's decoder pool. + requestedWarmSlots: Int = DEFAULT_WARM_SLOTS, ) { - private val playerPool = ConcurrentLinkedQueue() + // Cap warm slots at poolSize-1 so there's always at least one slot available for a cold + // (cleared) player; otherwise a feed full of unique URIs would starve the cold pool and + // every new URI would force a fresh ExoPlayer build. + private val warmSlotsCap = requestedWarmSlots.coerceAtMost((poolSize - 1).coerceAtLeast(0)) + + // Idle players that have been stop()'d and clearMediaItems()'d — ready to be re-prepared + // with any URI. Maintained as a FIFO so the oldest cleared instance is reused first. + private val coldPool = ConcurrentLinkedQueue() private val poolStartingSize = 3 + // Most-recent paused players, indexed by the mediaId of the MediaItem they still hold. + // ArrayDeque is used as an LRU: head = oldest, tail = newest. Access is guarded by + // [warmPoolLock] (a plain monitor, since both acquire and release callers run on the + // service's main thread but we don't want to require the suspending [mutex] in acquire). + private data class WarmPlayer( + val mediaId: String, + val player: ExoPlayer, + ) + + private val warmPool = ArrayDeque(warmSlotsCap.coerceAtLeast(1)) + private val warmPoolLock = Any() + // Exists to avoid exceptions stopping the coroutine val exceptionHandler = CoroutineExceptionHandler { _, throwable -> @@ -70,8 +95,8 @@ class ExoPlayerPool( fun create(context: Context) { if (!warmupStarted.compareAndSet(false, true)) return scope.launch { - while (playerPool.size < poolStartingSize) { - playerPool.offer(builder.build(context)) + while (coldPool.size < poolStartingSize) { + coldPool.offer(builder.build(context)) // Hand the frame back so an in-flight onGetSession / acquirePlayer / layout // pass isn't blocked behind the next build. yield() @@ -79,15 +104,40 @@ class ExoPlayerPool( } } - fun acquirePlayer(context: Context): ExoPlayer { - if (playerPool.isEmpty()) { - // If the pool is empty, create a new player (or handle it differently) - return builder.build(context) + /** + * Acquire a player. When [preferredMediaId] matches a warm entry, returns that player intact + * — it still holds its MediaItem and any populated LoadControl buffer, so the caller can + * skip [androidx.media3.common.Player.setMediaItem] / [androidx.media3.common.Player.prepare] + * and resume immediately. Falls back to a cold (cleared) player or a freshly built one. + */ + fun acquirePlayer( + context: Context, + preferredMediaId: String? = null, + ): ExoPlayer { + if (preferredMediaId != null) { + val warm = takeWarm(preferredMediaId) + if (warm != null) { + Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" } + return warm + } } - - return playerPool.poll() ?: builder.build(context) + return coldPool.poll() ?: builder.build(context) } + private fun takeWarm(mediaId: String): ExoPlayer? = + synchronized(warmPoolLock) { + // Iterate from the newest end so a duplicated URI returns the freshest player. + val it = warmPool.listIterator(warmPool.size) + while (it.hasPrevious()) { + val entry = it.previous() + if (entry.mediaId == mediaId) { + it.remove() + return@synchronized entry.player + } + } + null + } + fun releasePlayerAsync(player: ExoPlayer) { scope.launch { releasePlayer(player) @@ -96,27 +146,70 @@ class ExoPlayerPool( suspend fun releasePlayer(player: ExoPlayer) { mutex.withLock { - if (!player.isReleased) { + if (player.isReleased) return@withLock + + val mediaId = player.currentMediaItem?.mediaId + if (mediaId != null && warmSlotsCap > 0) { + // Warm path: keep the player paused but loaded so a quick scroll-back to the + // same video resumes from the existing buffer instead of re-fetching from disk + // cache and re-priming the decoder. player.pause() - player.stop() - player.clearVideoSurface() - player.clearMediaItems() - - // Clear any video quality overrides so the next video starts with Auto - player.trackSelectionParameters = - player.trackSelectionParameters - .buildUpon() - .clearOverridesOfType(C.TRACK_TYPE_VIDEO) - .build() - - if (playerPool.size < poolSize) { - if (!playerPool.contains(player)) { - playerPool.add(player) - } - } else { - player.release() // Release if pool is full. + val evicted = pushWarm(mediaId, player) + if (evicted != null) { + Log.d("PlaybackService") { "ExoPlayerPool warm evict: ${evicted.mediaId}" } + demoteToCold(evicted.player) } + return@withLock } + + demoteToCold(player) + } + } + + private fun pushWarm( + mediaId: String, + player: ExoPlayer, + ): WarmPlayer? = + synchronized(warmPoolLock) { + // If the same URI is already warm (rare — duplicate VideoView in another scroller), + // drop the older entry so it can be demoted; the freshest copy wins. + val duplicate = warmPool.indexOfFirst { it.mediaId == mediaId } + val displaced = + if (duplicate >= 0) { + warmPool.removeAt(duplicate) + } else if (warmPool.size >= warmSlotsCap) { + warmPool.removeFirst() + } else { + null + } + warmPool.addLast(WarmPlayer(mediaId, player)) + displaced + } + + private fun demoteToCold(player: ExoPlayer) { + if (player.isReleased) return + player.pause() + player.stop() + player.clearVideoSurface() + player.clearMediaItems() + + // Clear any video quality overrides so the next video starts with Auto + player.trackSelectionParameters = + player.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_VIDEO) + .build() + + // Total idle (cold + warm) must respect the device-derived poolSize cap so we don't + // exceed the MediaCodec instance budget. Warm slots get first dibs; cold gets the rest. + val warmSize = synchronized(warmPoolLock) { warmPool.size } + val coldCap = (poolSize - warmSize).coerceAtLeast(0) + if (coldPool.size < coldCap) { + if (!coldPool.contains(player)) { + coldPool.add(player) + } + } else { + player.release() // Release if pool is full. } } @@ -124,11 +217,22 @@ class ExoPlayerPool( scope .launch { mutex.withLock { - playerPool.forEach { it.release() } - playerPool.clear() + val warmSnapshot = + synchronized(warmPoolLock) { + val copy = warmPool.toList() + warmPool.clear() + copy + } + warmSnapshot.forEach { it.player.release() } + coldPool.forEach { it.release() } + coldPool.clear() } }.invokeOnCompletion { scope.cancel() } } + + companion object { + private const val DEFAULT_WARM_SLOTS = 3 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index 501354b11..cda75c6ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -37,13 +37,14 @@ import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong class SessionListener( val session: MediaSession, @@ -72,7 +73,22 @@ class MediaSessionPool( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler) val globalCallback = MediaSessionCallback(this, appContext) - var lastCleanup = TimeUtils.now() + + // Last cleanup timestamp in nanos, guarded by CAS so concurrent releaseSession() calls + // can't all win the time check and each launch a redundant scope.launch sweep. + private val lastCleanupNs = AtomicLong(System.nanoTime()) + + // The bitmap loader is stateless w.r.t. the session; a fresh allocation per session was + // pure noise. ExoPlayer's DEFAULT_EXECUTOR_SERVICE is a process-wide singleton, the + // dataSourceFactory is owned by the pool, and the appContext is already retained. + @OptIn(UnstableApi::class) + private val sharedBitmapLoader by lazy { + DataSourceBitmapLoader + .Builder(appContext) + .setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get()) + .setDataSourceFactory(dataSourceFactory) + .build() + } // protects from LruCache killing playing sessions private val playingMap = mutableMapOf() @@ -102,18 +118,16 @@ class MediaSessionPool( id: String, keepPlaying: Boolean, context: Context, + // Best-effort affinity hint: when the pool still has a paused player carrying this + // exact mediaId (matches MediaItem.mediaId, which is the videoUri), the warm player + // is reused so the populated buffer survives. Null falls back to a cold acquire. + preferredMediaId: String?, ): MediaSession { val mediaSession = MediaSession - .Builder(context, exoPlayerPool.acquirePlayer(context)) + .Builder(context, exoPlayerPool.acquirePlayer(context, preferredMediaId)) .apply { - setBitmapLoader( - DataSourceBitmapLoader - .Builder(context) - .setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get()) - .setDataSourceFactory(dataSourceFactory) - .build(), - ) + setBitmapLoader(sharedBitmapLoader) setId(id) setCallback(globalCallback) }.build() @@ -142,16 +156,17 @@ class MediaSessionPool( } fun cleanupUnused() { - if (lastCleanup < TimeUtils.oneMinuteAgo()) { - lastCleanup = TimeUtils.now() - scope.launch { - val snap = cache.snapshot() - snap.values.forEach { - if (it.session.connectedControllers.isEmpty()) { - releaseSession(it.session) - } + val now = System.nanoTime() + val previous = lastCleanupNs.get() + if (now - previous < CLEANUP_INTERVAL_NS) return + // CAS so only one caller actually launches the sweep when many releases fire at once. + if (!lastCleanupNs.compareAndSet(previous, now)) return + scope.launch { + val snap = cache.snapshot() + snap.values.forEach { + if (it.session.connectedControllers.isEmpty()) { + releaseSession(it.session) } - lastCleanup = TimeUtils.now() } } } @@ -175,13 +190,14 @@ class MediaSessionPool( id: String, keepPlaying: Boolean, context: Context, + preferredMediaId: String? = null, ): MediaSession { val existingSession = playingMap.get(id) ?: cache.get(id) if (existingSession != null) { return existingSession.session } - return newSession(id, keepPlaying, context) + return newSession(id, keepPlaying, context, preferredMediaId) } fun playingContent() = playingMap.values @@ -234,4 +250,8 @@ class MediaSessionPool( } } } + + companion object { + private val CLEANUP_INTERVAL_NS = TimeUnit.MINUTES.toNanos(1) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt index 837f85971..4734d7a69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt @@ -66,7 +66,14 @@ class CurrentPlayPositionCacher( Player.STATE_READY -> { if (!isLiveStreaming) { cache.get(uri)?.let { lastPosition -> - if (abs(player.currentPosition - lastPosition) > 5 * 60) { + // Restore the saved position only if it's meaningfully far from + // the player's current position. Position values are in + // milliseconds, so the previous `5 * 60` constant was a 300 ms + // threshold — small enough to trigger a seek (and an extra buffer + // flush right at playback start) for almost any saved position. + // 5 s gives the user a perceptible "resumed where I left off" + // without forcing a re-seek for trivially short clips. + if (abs(player.currentPosition - lastPosition) > 5_000) { player.seekTo(lastPosition) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index f323e01bc..795f04157 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -204,7 +204,17 @@ class PlaybackService : MediaSessionService() { val id = controllerInfo.connectionHints.getString("id") ?: return null val proxyPort = controllerInfo.connectionHints.getInt("proxyPort") val keepPlaying = controllerInfo.connectionHints.getBoolean("keepPlaying", true) + // Optional warm-pool affinity hint: when the pool still has a paused ExoPlayer + // holding this exact URI, the new session reuses it so the buffer survives. + val preferredMediaId = controllerInfo.connectionHints.getString(HINT_VIDEO_URI) val manager = lazyPool(proxyPort) - return manager.getSession(id, keepPlaying, applicationContext) + return manager.getSession(id, keepPlaying, applicationContext, preferredMediaId) + } + + companion object { + const val HINT_ID = "id" + const val HINT_PROXY_PORT = "proxyPort" + const val HINT_KEEP_PLAYING = "keepPlaying" + const val HINT_VIDEO_URI = "videoUri" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index 3bd87f509..aa74bfaf1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -56,11 +56,15 @@ object PlaybackServiceClient { Bundle().apply { // link the id with the client's id to make sure it can return the // same session on background media. - putString("id", id) - putBoolean("keepPlaying", keepPlaying) + putString(PlaybackService.HINT_ID, id) + putBoolean(PlaybackService.HINT_KEEP_PLAYING, keepPlaying) proxyPort?.let { - putInt("proxyPort", it) + putInt(PlaybackService.HINT_PROXY_PORT, it) } + // Carry the URI so the service can ask the player pool for an existing warm + // (paused-with-buffer) ExoPlayer that already holds this MediaItem. Falls back + // gracefully — if no warm match exists, the pool returns a cold player. + putString(PlaybackService.HINT_VIDEO_URI, videoUri) } val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java)) From c6b275e7fea5b3743eb0f9c1e032c3c43d7084bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 12:37:56 +0000 Subject: [PATCH 09/38] perf(video): tighten remember keys and stabilize controller-overlay tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 audit cleanups. Each item is small but each runs on the hot path that recomposes during every active video, so they add up while scrolling. P1 — DimensionTag identity invalidating remember: - DimensionTag (in quartz) is a regular class with no equals override, so reference equality means a freshly parsed tag for the same event is != to the previous one. The remember(videoUri, dimensions) blocks added in the earlier perf commits were re-running their lambda on every recompose. Switch to primitive (width, height) keys in VideoView and GifVideoView so the cache lookups + MediaAspectRatioCache writes only fire when the dimensions actually change. P1 — Static gradient brushes: - TopGradientOverlay / BottomGradientOverlay were calling Brush.verticalGradient(colors = colors) inside the modifier chain, which allocated a fresh Brush on every recomposition while the controllers were visible (i.e. on every active video most of the time). Pre-build both brushes as file-level vals so they're allocated exactly once per process. P2 — ImmutableList for action collections: - RenderTopButtons / AnimatedOverflowMenuButton / OverflowMenuButton were passing List across composable boundaries. Plain List is unstable in Compose, forcing the overflow tree to recompose any time an unrelated parent state (volume, tracks, controllerVisible) ticked. Use ImmutableList end-to-end via toImmutableList() at the producer side. P2 — videoPlayerButtonItemsFlow remember: - accountViewModel.videoPlayerButtonItemsFlow() was being called fresh every recomposition, with the result handed straight to collectAsStateWithLifecycle. Hoist the call into remember(accountViewModel) so the flow reference is stable. P2 — MuteButton dispatcher cleanup: - The 2-second hold timer was using LaunchedEffect { launch(Dispatchers.IO) { delay(2000); holdOn.value = false } }. The wrapped launch was just redundant dispatcher hopping — delay() doesn't hold a thread and the Compose write is fine on Main. Inline it. --- .../service/playback/composable/VideoView.kt | 15 +++++--- .../composable/controls/GradientOverlay.kt | 37 ++++++++++++------- .../composable/controls/MuteButton.kt | 11 +++--- .../composable/controls/OverflowMenu.kt | 8 ++-- .../composable/controls/RenderTopButtons.kt | 11 +++++- .../amethyst/ui/components/GifVideoView.kt | 14 ++++++- 6 files changed, 65 insertions(+), 31 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 0160a1246..d7263930d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -115,13 +115,16 @@ fun VideoView( // Resolve the aspect ratio once per composition. Prime the URL-keyed cache from the imeta // dim tag so the next time this video appears (PiP, dialog, list re-enter) the cache hits - // without waiting for ExoPlayer's onVideoSizeChanged. + // without waiting for ExoPlayer's onVideoSizeChanged. Keys are primitive width/height so + // a freshly parsed DimensionTag instance for the same event doesn't re-run this lambda — + // DimensionTag uses reference equality, not structural. + val dimW = dimensions?.width + val dimH = dimensions?.height val ratio = - remember(videoUri, dimensions) { - val fromDim = dimensions?.takeIf { it.hasSize() } - if (fromDim != null) { - MediaAspectRatioCache.add(videoUri, fromDim.width, fromDim.height) - fromDim.aspectRatio() + remember(videoUri, dimW, dimH) { + if (dimW != null && dimH != null && dimW > 0 && dimH > 0) { + MediaAspectRatioCache.add(videoUri, dimW, dimH) + dimW.toFloat() / dimH.toFloat() } else { MediaAspectRatioCache.get(videoUri) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt index 938aa8ef9..be5d946f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt @@ -38,24 +38,33 @@ import androidx.compose.ui.unit.dp private val FadeIn = fadeIn() private val FadeOut = fadeOut() -private val TopGradientColors = - listOf( - Color.Black.copy(alpha = 0.6f), - Color.Black.copy(alpha = 0.3f), - Color.Transparent, +// Both gradient brushes are static; pre-build them once at class init so we don't allocate a +// new Brush on every recomposition while the controllers are visible (which is most of the +// time during playback / interaction). +private val TopGradientBrush = + Brush.verticalGradient( + colors = + listOf( + Color.Black.copy(alpha = 0.6f), + Color.Black.copy(alpha = 0.3f), + Color.Transparent, + ), ) -private val BottomGradientColors = - listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.4f), - Color.Black.copy(alpha = 0.7f), +private val BottomGradientBrush = + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.4f), + Color.Black.copy(alpha = 0.7f), + ), ) @Composable private fun GradientOverlay( controllerVisible: State, - colors: List, + brush: Brush, height: Dp, modifier: Modifier = Modifier, ) { @@ -70,7 +79,7 @@ private fun GradientOverlay( Modifier .fillMaxWidth() .height(height) - .background(brush = Brush.verticalGradient(colors = colors)), + .background(brush = brush), ) } } @@ -80,11 +89,11 @@ fun TopGradientOverlay( controllerVisible: State, modifier: Modifier = Modifier, height: Dp = 80.dp, -) = GradientOverlay(controllerVisible, TopGradientColors, height, modifier) +) = GradientOverlay(controllerVisible, TopGradientBrush, height, modifier) @Composable fun BottomGradientOverlay( controllerVisible: State, modifier: Modifier = Modifier, height: Dp = 120.dp, -) = GradientOverlay(controllerVisible, BottomGradientColors, height, modifier) +) = GradientOverlay(controllerVisible, BottomGradientBrush, height, modifier) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt index 5c44ba3c8..787c7e30a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt @@ -47,9 +47,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.launch @Preview @Composable @@ -79,11 +77,12 @@ fun MuteButton( ) } + // LaunchedEffect already runs on Main, and delay() suspends without holding a thread, so + // the previous launch(Dispatchers.IO) was just unnecessary dispatcher hopping for a state + // mutation that's also fine on Main. LaunchedEffect(key1 = controllerVisible) { - launch(Dispatchers.IO) { - delay(2000) - holdOn.value = false - } + delay(2000) + holdOn.value = false } val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt index b8a115a5c..1bf719a92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -50,6 +50,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf private val FadeIn = fadeIn() private val FadeOut = fadeOut() @@ -60,7 +62,7 @@ fun OverflowMenuButtonPreview() { ThemeComparisonColumn { Box(Modifier.background(BitcoinOrange)) { OverflowMenuButton( - actions = listOf(VideoPlayerAction.Share, VideoPlayerAction.Download, VideoPlayerAction.PictureInPicture), + actions = persistentListOf(VideoPlayerAction.Share, VideoPlayerAction.Download, VideoPlayerAction.PictureInPicture), startingMuteState = false, onFullscreenClick = {}, onMuteClick = {}, @@ -76,7 +78,7 @@ fun OverflowMenuButtonPreview() { @Composable fun AnimatedOverflowMenuButton( controllerVisible: State, - actions: List, + actions: ImmutableList, startingMuteState: Boolean, onFullscreenClick: (() -> Unit)?, onMuteClick: () -> Unit, @@ -107,7 +109,7 @@ fun AnimatedOverflowMenuButton( @Composable fun OverflowMenuButton( - actions: List, + actions: ImmutableList, startingMuteState: Boolean, onFullscreenClick: (() -> Unit)?, onMuteClick: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index d7c1645f0..5d182bd84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import kotlinx.collections.immutable.toImmutableList @Preview @Composable @@ -194,7 +195,10 @@ fun RenderTopButtons( modifier: Modifier, accountViewModel: AccountViewModel, ) { - val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle() + // Hold the StateFlow itself across recompositions so collectAsStateWithLifecycle isn't + // keyed on the result of a property getter call that happens every recompose. + val buttonItemsFlow = remember(accountViewModel) { accountViewModel.videoPlayerButtonItemsFlow() } + val buttonItems by buttonItemsFlow.collectAsStateWithLifecycle() val shareDialogVisible = remember { mutableStateOf(false) } val saveAction = rememberSaveMediaAction { context -> @@ -212,17 +216,22 @@ fun RenderTopButtons( } val canFullscreen = onZoomClick != null + // ImmutableList so Compose can treat the action lists as stable parameters when they're + // passed through to AnimatedOverflowMenuButton — a plain List is unstable and forces the + // overflow tree to recompose whenever any unrelated parent state ticks. val topBarActions = remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) { buttonItems .filter { it.location == VideoButtonLocation.TopBar && isAvailable(it.action) } .map { it.action } + .toImmutableList() } val overflowActions = remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) { buttonItems .filter { it.location == VideoButtonLocation.OverflowMenu && isAvailable(it.action) } .map { it.action } + .toImmutableList() } Row(modifier) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt index 1b33389f0..e9bb15e63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -68,7 +69,18 @@ fun GifVideoView( accountViewModel: AccountViewModel, thumbhash: String? = null, ) { - val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri) + // Keys are primitive width/height so a freshly parsed DimensionTag instance for the same + // event doesn't re-run this lambda — DimensionTag uses reference equality, not structural. + val dimW = dimensions?.width + val dimH = dimensions?.height + val ratio = + remember(videoUri, dimW, dimH) { + if (dimW != null && dimH != null && dimH > 0) { + dimW.toFloat() / dimH.toFloat() + } else { + MediaAspectRatioCache.get(videoUri) + } + } val autoPlay = accountViewModel.settings.autoPlayVideos() val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier val context = LocalContext.current From ba1a1bfc12f1193ff36a19572d9985218dd45f2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 13:15:08 +0000 Subject: [PATCH 10/38] perf(video): shorten VideoCache warmup delay from 10s to 1.5s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing background warmup in Amethyst.initiate() deferred the lazy videoCache touch by 10 seconds. SimpleCache's constructor opens a SQLite index via StandaloneDatabaseProvider and walks every cached span on disk — a few hundred ms on a populated 4 GB cache — so we really do not want that running on the main thread. But 10 s is long enough that a fast user (or a deep link / push notification that lands directly on a video-bearing screen) can win the `lazy { }` race and trigger init on the main thread inside PlaybackService.onGetSession, which is exactly the hitch the warmup was meant to prevent. Drop to 1.5 s — long enough to let the urgent first-paint work above (account load, image loader, ui state, robohash) breathe, short enough that a typical user can't scroll and tap a video before the warmup wins. Document the trade-off in a comment so the timing isn't a magic number. --- .../java/com/vitorpamplona/amethyst/AppModules.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 275ddffe0..5ea95f359 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -525,10 +525,16 @@ class AppModules( } } - // initializes diskcache on an IO thread. + // Warms the video cache off the main thread. SimpleCache's constructor opens a SQLite + // index over StandaloneDatabaseProvider and walks every cached span on disk — up to a + // few hundred ms on a populated 4 GB cache — so leaving it for the first session's + // onGetSession would do that work on the main thread. The short delay keeps the IO + // dispatcher free for the urgent first-paint work above (account load, image loader, + // ui state, robohash) while still landing the warmup well before a typical user can + // scroll to and tap a video. The previous 10 s delay was long enough that a fast user + // (or a deep link) could lose the lazy { } race and trigger main-thread init. applicationIOScope.launch { - // Prepares video cache later - delay(10_000) + delay(1_500) videoCache } } From 9fcf85bed083462214285fa66bd0e6ec49ea1e4e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 13:25:30 +0000 Subject: [PATCH 11/38] fix(quartz/sqlite): serialise writes via a Room-style connection pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit androidx.sqlite SQLiteConnection is not thread-safe; SQLiteEventStore shared a single lazy connection across all callers, so two coroutines calling insertEvent() at the same time would race on BEGIN IMMEDIATE and the modules' prepared statements, surfacing as "cannot start a transaction within a transaction" or SQLITE_MISUSE. Mirror Room's design: introduce SQLiteConnectionPool with one writer connection guarded by a coroutine Mutex and N reader connections handed out via a Channel-as-semaphore (file-backed DBs only; in-memory DBs share the writer because each ":memory:" connection is a separate DB). Convert IEventStore + SQLiteEventStore + EventStore + FsEventStore + LiveEventStore to suspend, route writes through useWriter and reads through useReader. RelaySession now launches handleEvent / handleCount on its scope. CLI Context helpers and StoreCommands.sweepExpired pick up suspend. Add ParallelInsertTest to lock the behaviour in: 8 coroutines × 200 inserts, parallel reads alongside writes, transaction batches across coroutines, and a reopen smoke test all pass against a file-backed DB. https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5 --- .../com/vitorpamplona/amethyst/cli/Context.kt | 24 +- .../amethyst/cli/commands/FeedCommand.kt | 2 +- .../amethyst/cli/commands/ProfileCommands.kt | 2 +- .../amethyst/cli/commands/RelayCommands.kt | 2 +- .../amethyst/cli/commands/StoreCommands.kt | 2 +- .../nip01Core/relay/server/LiveEventStore.kt | 4 +- .../nip01Core/relay/server/RelaySession.kt | 62 +- .../quartz/nip01Core/store/IEventStore.kt | 22 +- .../nip01Core/store/sqlite/EventStore.kt | 28 +- .../nip01Core/store/sqlite/QueryExplainer.kt | 4 +- .../store/sqlite/SQLiteConnectionPool.kt | 135 +++++ .../store/sqlite/SQLiteEventStore.kt | 173 +++--- .../nip01Core/store/sqlite/AssertUtils.kt | 8 +- .../nip01Core/store/sqlite/BasicTest.kt | 12 +- .../nip01Core/store/sqlite/LargeDBTests.kt | 31 +- .../store/sqlite/QueryAssemblerTest.kt | 4 +- .../quartz/nip01Core/store/fs/FsEventStore.kt | 29 +- .../nip01Core/store/fs/FsLockManager.kt | 32 +- .../nip01Core/store/fs/FsDeletionTest.kt | 331 ++++++----- .../nip01Core/store/fs/FsEventStoreTest.kt | 272 ++++----- .../nip01Core/store/fs/FsEventToJsonTest.kt | 107 ++-- .../nip01Core/store/fs/FsExpirationTest.kt | 197 ++++--- .../nip01Core/store/fs/FsMaintenanceTest.kt | 276 ++++----- .../quartz/nip01Core/store/fs/FsParityTest.kt | 509 ++++++++-------- .../quartz/nip01Core/store/fs/FsQueryTest.kt | 544 +++++++++--------- .../quartz/nip01Core/store/fs/FsSearchTest.kt | 335 ++++++----- .../quartz/nip01Core/store/fs/FsSlotsTest.kt | 424 +++++++------- .../quartz/nip01Core/store/fs/FsVanishTest.kt | 238 ++++---- .../store/sqlite/ParallelInsertTest.kt | 205 +++++++ 29 files changed, 2264 insertions(+), 1750 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ParallelInsertTest.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index ab3ab5976..f8c4a21ee 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -173,7 +173,7 @@ class Context( * publish from", which mirrors `User.outboxRelays()` in the * Android app. */ - fun outboxRelays(): Set = + suspend fun outboxRelays(): Set = relaysOf(identity.pubKeyHex)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet() ?: DefaultNIP65RelaySet @@ -181,7 +181,7 @@ class Context( * DM inbox relays (NIP-17 kind:10050) for this account. Falls back * to [DefaultDMRelayList] when no kind:10050 has been seen. */ - fun inboxRelays(): Set = + suspend fun inboxRelays(): Set = dmInboxOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet() ?: DefaultDMRelayList.toSet() @@ -190,12 +190,12 @@ class Context( * back to [outboxRelays] when no kind:10051 has been seen — same * fallback the Android app uses for KeyPackage discovery. */ - fun keyPackageRelays(): Set = + suspend fun keyPackageRelays(): Set = keyPackageRelaysOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet() ?: outboxRelays() /** Union of all three buckets. */ - fun anyRelays(): Set = outboxRelays() + inboxRelays() + keyPackageRelays() + suspend fun anyRelays(): Set = outboxRelays() + inboxRelays() + keyPackageRelays() /** * Seed relays for "look up someone we know nothing about" queries — @@ -208,7 +208,7 @@ class Context( * most reliable place to find a stranger's replaceable events even when * we and they have completely disjoint relay configurations. */ - fun bootstrapRelays(): Set = + suspend fun bootstrapRelays(): Set = buildSet { addAll(anyRelays()) addAll(DefaultNIP65RelaySet) @@ -319,7 +319,7 @@ class Context( * Every event-arrival path in the CLI funnels through this method * so that [store] is the authoritative cache of what Amy has seen. */ - fun verifyAndStore(event: Event): Boolean { + suspend fun verifyAndStore(event: Event): Boolean { if (!event.verify()) { System.err.println("[cli] dropped event ${event.id.take(8)} kind=${event.kind} — bad signature") return false @@ -342,7 +342,7 @@ class Context( * this user. Callers that need a network fetch on miss should fall * back to [drain] explicitly — this helper never hits the network. */ - fun profileOf(pubKey: HexKey): MetadataEvent? = + suspend fun profileOf(pubKey: HexKey): MetadataEvent? = store .query( Filter(authors = listOf(pubKey), kinds = listOf(MetadataEvent.KIND), limit = 1), @@ -352,7 +352,7 @@ class Context( * Latest known kind:10002 advertised relay list (NIP-65) for * [pubKey]. `null` when Amy has never seen one. */ - fun relaysOf(pubKey: HexKey): AdvertisedRelayListEvent? = + suspend fun relaysOf(pubKey: HexKey): AdvertisedRelayListEvent? = store .query( Filter(authors = listOf(pubKey), kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 1), @@ -363,7 +363,7 @@ class Context( * `null` if Amy has never observed one. Useful for follow-graph * lookups without re-hitting relays. */ - fun contactsOf(pubKey: HexKey): ContactListEvent? = + suspend fun contactsOf(pubKey: HexKey): ContactListEvent? = store .query( Filter(authors = listOf(pubKey), kinds = listOf(ContactListEvent.KIND), limit = 1), @@ -374,7 +374,7 @@ class Context( * for [pubKey], or `null` if Amy has never observed one. Used by * `dm send` to resolve where to deliver a wrap. */ - fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? = + suspend fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? = store .query( Filter(authors = listOf(pubKey), kinds = listOf(ChatMessageRelayListEvent.KIND), limit = 1), @@ -386,7 +386,7 @@ class Context( * `marmot key-package check` and `marmot await key-package` to * locate where the recipient publishes their KeyPackages. */ - fun keyPackageRelaysOf(pubKey: HexKey): KeyPackageRelayListEvent? = + suspend fun keyPackageRelaysOf(pubKey: HexKey): KeyPackageRelayListEvent? = store .query( Filter(authors = listOf(pubKey), kinds = listOf(KeyPackageRelayListEvent.KIND), limit = 1), @@ -405,7 +405,7 @@ class Context( * we'll still hand back the old list. Commands that care can drain * (which re-populates the cache) or expose a `--refresh` flag. */ - fun cachedRelayListsOf(pubKey: HexKey): RecipientRelayFetcher.Lists? { + suspend fun cachedRelayListsOf(pubKey: HexKey): RecipientRelayFetcher.Lists? { val dm = dmInboxOf(pubKey) val kp = keyPackageRelaysOf(pubKey) val nip65 = relaysOf(pubKey) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt index 87e0c6bfb..ba928622e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt @@ -142,7 +142,7 @@ object FeedCommand { * an arbitrary `--author` we have no idea where they publish, so we * widen to the bootstrap union. */ - private fun relaysForReadingFeed( + private suspend fun relaysForReadingFeed( ctx: Context, mode: String, ): Set = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt index 360e34fe9..0b827167f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt @@ -226,7 +226,7 @@ object ProfileCommands { * else's profile, fall back to the bootstrap union so we still find a * kind:0 even when our relay set and theirs are disjoint. */ - private fun relaysForReadingProfile( + private suspend fun relaysForReadingProfile( ctx: Context, isSelf: Boolean, ): Set = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 256e3b561..aade5d829 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -137,7 +137,7 @@ object RelayCommands { } } - private fun list(dataDir: DataDir): Int { + private suspend fun list(dataDir: DataDir): Int { val ctx = Context.open(dataDir) try { val self = ctx.identity.pubKeyHex diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt index 4ee0a7ee6..470c42bbf 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt @@ -132,7 +132,7 @@ object StoreCommands { return 0 } - private fun sweepExpired(dataDir: DataDir): Int = + private suspend fun sweepExpired(dataDir: DataDir): Int = withStore(dataDir) { store -> val expiresAtDir = dataDir.eventsDir.toPath().resolve("idx/expires_at") val before = countEntries(expiresAtDir) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt index 4268dcef3..5e1cf9999 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt @@ -46,7 +46,7 @@ class LiveEventStore( onBufferOverflow = BufferOverflow.DROP_LATEST, // Default behavior ) - fun insert(event: Event) { + suspend fun insert(event: Event) { store.insert(event) newEventStream.tryEmit(event) } @@ -70,5 +70,5 @@ class LiveEventStore( } } - fun count(filters: List) = store.count(filters) + suspend fun count(filters: List) = store.count(filters) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 71a6fb944..dd43f03d4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -111,6 +111,36 @@ class RelaySession( } } + private suspend fun handleEvent(cmd: EventCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(OkMessage(cmd.event.id, false, result.reason)) + return + } + + try { + store.insert(cmd.event) + send(OkMessage(cmd.event.id, true, "")) + } catch (e: Exception) { + send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error")) + } + } + + private suspend fun handleCount(cmd: CountCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(ClosedMessage(cmd.queryId, result.reason)) + return + } + + // Policy may rewrite filters to match the user's access level. + val filters = (result as PolicyResult.Accepted).cmd.filters + + val total = store.count(filters) + + send(CountMessage(cmd.queryId, CountResult(total))) + } + // -- NIP-42: AUTH --------------------------------------------------------- private fun handleAuth(cmd: AuthCmd) { val result = policy.accept(cmd) @@ -164,38 +194,6 @@ class RelaySession( } } - // -- NIP-01: EVENT -------------------------------------------------------- - private fun handleEvent(cmd: EventCmd) { - val result = policy.accept(cmd) - if (result is PolicyResult.Rejected) { - send(OkMessage(cmd.event.id, false, result.reason)) - return - } - - try { - store.insert(cmd.event) - send(OkMessage(cmd.event.id, true, "")) - } catch (e: Exception) { - send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error")) - } - } - - // -- NIP-45: COUNT -------------------------------------------------------- - private fun handleCount(cmd: CountCmd) { - val result = policy.accept(cmd) - if (result is PolicyResult.Rejected) { - send(ClosedMessage(cmd.queryId, result.reason)) - return - } - - // Policy may rewrite filters to match the user's access level. - val filters = (result as PolicyResult.Accepted).cmd.filters - - val total = store.count(filters) - - send(CountMessage(cmd.queryId, CountResult(total))) - } - init { policy.onConnect(::send) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index e9268922d..e70773c29 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -24,37 +24,37 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter interface IEventStore : AutoCloseable { - fun insert(event: Event) + suspend fun insert(event: Event) interface ITransaction { fun insert(event: Event) } - fun transaction(body: ITransaction.() -> Unit) + suspend fun transaction(body: ITransaction.() -> Unit) - fun query(filter: Filter): List + suspend fun query(filter: Filter): List - fun query(filters: List): List + suspend fun query(filters: List): List - fun query( + suspend fun query( filter: Filter, onEach: (T) -> Unit, ) - fun query( + suspend fun query( filters: List, onEach: (T) -> Unit, ) - fun count(filter: Filter): Int + suspend fun count(filter: Filter): Int - fun count(filters: List): Int + suspend fun count(filters: List): Int - fun delete(filter: Filter) + suspend fun delete(filter: Filter) - fun delete(filters: List) + suspend fun delete(filters: List) - fun deleteExpiredEvents() + suspend fun deleteExpiredEvents() override fun close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index 0a861cc9b..7fb287555 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -34,33 +34,37 @@ class EventStore( ) : IEventStore { val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy) - override fun insert(event: Event) = store.insertEvent(event) + override suspend fun insert(event: Event) = store.insertEvent(event) - override fun transaction(body: IEventStore.ITransaction.() -> Unit) = store.transaction(body) + override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = store.transaction(body) - override fun query(filter: Filter) = store.query(filter) + override suspend fun query(filter: Filter) = store.query(filter) - override fun query(filters: List) = store.query(filters) + override suspend fun query(filters: List) = store.query(filters) - override fun query( + override suspend fun query( filter: Filter, onEach: (T) -> Unit, ) = store.query(filter, onEach) - override fun query( + override suspend fun query( filters: List, onEach: (T) -> Unit, ) = store.query(filters, onEach) - override fun count(filter: Filter) = store.count(filter) + override suspend fun count(filter: Filter) = store.count(filter) - override fun count(filters: List) = store.count(filters) + override suspend fun count(filters: List) = store.count(filters) - override fun delete(filter: Filter) = store.delete(filter) + override suspend fun delete(filter: Filter) { + store.delete(filter) + } - override fun delete(filters: List) = store.delete(filters) + override suspend fun delete(filters: List) { + store.delete(filters) + } - override fun deleteExpiredEvents() = store.deleteExpiredEvents() + override suspend fun deleteExpiredEvents() = store.deleteExpiredEvents() - override fun close() = store.connection.close() + override fun close() = store.close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt index 4b2d6bf27..841abbea8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import androidx.sqlite.SQLiteConnection -fun SQLiteEventStore.explainQuery( +suspend fun SQLiteEventStore.explainQuery( sql: String, args: Array = emptyArray(), -) = connection.explainQuery(sql, args.map { it.toString() }.toTypedArray()) +): String = pool.useReader { it.explainQuery(sql, args.map { a -> a.toString() }.toTypedArray()) } fun SQLiteConnection.explainQuery( sql: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt new file mode 100644 index 000000000..e5adc0701 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Room-style connection pool for an `androidx.sqlite` database. + * + * `androidx.sqlite.SQLiteConnection` is not thread-safe (same contract as + * `sqlite3*` in the C API): a single connection may only be used by one + * thread at a time. Two coroutines hitting the same connection in parallel + * race on `BEGIN IMMEDIATE` and prepared-statement state, which surfaces + * as `SQLITE_ERROR: cannot start a transaction within a transaction` or + * `SQLITE_MISUSE`. + * + * The pool mirrors what Room does: + * + * - **One writer connection**, guarded by a coroutine [Mutex]. SQLite + * only allows a single writer at the file level anyway, so serialising + * writes here costs nothing — it just queues callers cooperatively + * instead of crashing them. + * - **N reader connections**, handed out from a [Channel] that doubles + * as a semaphore. Under WAL (`PRAGMA journal_mode = WAL`) readers run + * in parallel with the writer and with each other. + * + * For in-memory databases (`dbName == null`) every fresh `:memory:` + * connection opens a *separate* database, so the pool degrades to a + * single-connection mode where readers also acquire the writer mutex. + * That still fixes the parallel-insert crash; it just sacrifices reader + * concurrency for an in-memory store. + * + * Lifecycle: + * 1. `init` opens the writer, runs [onConfigure] on it, then [onMigrate] + * so schema exists before any reader sees the file. + * 2. Readers are opened next and each gets [onConfigure] (PRAGMAs are + * per-connection in SQLite — `journal_mode=WAL` is the only + * database-wide one; subsequent connections inherit it). + * 3. [close] drains the reader channel and closes every connection. + */ +class SQLiteConnectionPool( + val driver: SQLiteDriver, + val dbName: String?, + val numReaders: Int = 4, + val onConfigure: (SQLiteConnection) -> Unit = {}, + val onMigrate: (SQLiteConnection) -> Unit = {}, +) : AutoCloseable { + private val isInMemory = dbName == null + + private val writerMutex = Mutex() + val writer: SQLiteConnection + + private val readers: List + private val readerChannel: Channel? + + init { + writer = openConnection() + onMigrate(writer) + + if (isInMemory) { + readers = emptyList() + readerChannel = null + } else { + readers = List(numReaders) { openConnection() } + readerChannel = Channel(numReaders) + readers.forEach { readerChannel.trySend(it) } + } + } + + private fun openConnection(): SQLiteConnection { + val db = driver.open(dbName ?: ":memory:") + onConfigure(db) + return db + } + + /** + * Acquire the writer connection for the duration of [block]. Other + * writers (and, in the in-memory single-connection mode, readers) + * suspend until the lock is released. Cancellation-aware via the + * coroutine [Mutex]. + */ + suspend fun useWriter(block: (SQLiteConnection) -> T): T = + writerMutex.withLock { + block(writer) + } + + /** + * Acquire any free reader connection for [block]. With a file-backed + * DB up to [numReaders] readers run in parallel with the writer + * (WAL). With an in-memory DB this falls back to the writer mutex + * because each `:memory:` connection would be a separate database. + */ + suspend fun useReader(block: (SQLiteConnection) -> T): T { + val ch = + readerChannel + ?: return writerMutex.withLock { block(writer) } + val conn = ch.receive() + try { + return block(conn) + } finally { + // Capacity == numReaders and we own the conn we received, so + // trySend never fails unless the channel was closed mid-flight + // (in which case the connection is being torn down anyway). + ch.trySend(conn) + } + } + + override fun close() { + readerChannel?.close() + readers.forEach { runCatching { it.close() } } + runCatching { writer.close() } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 60da85fe9..2da5cf49c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -34,24 +34,18 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.EventFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.withContext class SQLiteEventStore( val driver: SQLiteDriver = BundledSQLiteDriver(), val dbName: String? = "events.db", val relay: NormalizedRelayUrl? = null, val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), + val numReaders: Int = 4, ) { companion object { const val DATABASE_VERSION = 2 } - val connection: SQLiteConnection by lazy { - openAndConfigure() - } - val seedModule = SeedModule() val fullTextSearchModule = FullTextSearchModule() @@ -89,37 +83,44 @@ class SQLiteEventStore( fullTextSearchModule, ) - private fun openAndConfigure(): SQLiteConnection { - val db = driver.open(dbName ?: ":memory:") + val pool: SQLiteConnectionPool by lazy { + SQLiteConnectionPool( + driver = driver, + dbName = dbName, + numReaders = numReaders, + onConfigure = { db -> + // 32MB memory cache (per-connection). + db.execSQL("PRAGMA cache_size=-32000;") - // 32MB memory cache - db.execSQL("PRAGMA cache_size=-32000;") + // Make sure the FKs are sane (per-connection). + db.execSQL("PRAGMA foreign_keys = ON;") - // makes sure the FKs are sane - db.execSQL("PRAGMA foreign_keys = ON;") + // SQLite implements mutations by appending them to a log, + // which it occasionally compacts into the database. This + // is called Write-Ahead Logging (WAL). Setting it on the + // first connection is enough — `journal_mode` is + // database-wide; subsequent connections inherit it. + db.execSQL("PRAGMA journal_mode = WAL;") - // SQLite implements mutations by appending them to a log, which it occasionally - // compacts into the database. This is called Write-Ahead Logging (WAL) - db.execSQL("PRAGMA journal_mode = WAL;") - - // The DB can be corrupted if the OS is shutdown before sync, which generally - // doesn't happen on Android - db.execSQL("PRAGMA synchronous = OFF;") - - val currentVersion = getUserVersion(db) - if (currentVersion == 0) { - db.transaction { - onCreate(this) - setUserVersion(this, DATABASE_VERSION) - } - } else if (currentVersion < DATABASE_VERSION) { - db.transaction { - onUpgrade(this, currentVersion, DATABASE_VERSION) - setUserVersion(this, DATABASE_VERSION) - } - } - - return db + // The DB can be corrupted if the OS shuts down before + // sync, which generally doesn't happen on Android. + db.execSQL("PRAGMA synchronous = OFF;") + }, + onMigrate = { db -> + val currentVersion = getUserVersion(db) + if (currentVersion == 0) { + db.transaction { + onCreate(this) + setUserVersion(this, DATABASE_VERSION) + } + } else if (currentVersion < DATABASE_VERSION) { + db.transaction { + onUpgrade(this, currentVersion, DATABASE_VERSION) + setUserVersion(this, DATABASE_VERSION) + } + } + }, + ) } private fun getUserVersion(db: SQLiteConnection): Int = @@ -159,25 +160,24 @@ class SQLiteEventStore( } } - fun clearDB() { - modules.reversed().forEach { it.deleteAll(connection) } - } - - suspend fun vacuum() { - // VACUUM: Rebuilds the database file, reclaiming unused space - // and reducing fragmentation. - withContext(Dispatchers.IO) { - connection.execSQL("VACUUM") + suspend fun clearDB() = + pool.useWriter { db -> + modules.reversed().forEach { it.deleteAll(db) } } - } - suspend fun analyse() { - // ANALYZE: Collects statistics about tables and indices - // to help the query planner optimize queries. - withContext(Dispatchers.IO) { - connection.execSQL("ANALYZE") + suspend fun vacuum() = + pool.useWriter { db -> + // VACUUM: Rebuilds the database file, reclaiming unused space + // and reducing fragmentation. + db.execSQL("VACUUM") + } + + suspend fun analyse() = + pool.useWriter { db -> + // ANALYZE: Collects statistics about tables and indices + // to help the query planner optimize queries. + db.execSQL("ANALYZE") } - } private fun innerInsertEvent( event: Event, @@ -190,12 +190,14 @@ class SQLiteEventStore( rightToVanishModule.insert(event, relay, headerId, db) } - fun insertEvent(event: Event) { + suspend fun insertEvent(event: Event) { if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event") if (event.kind.isEphemeral()) return - connection.transaction { - innerInsertEvent(event, this) + pool.useWriter { db -> + db.transaction { + innerInsertEvent(event, this) + } } } @@ -210,64 +212,65 @@ class SQLiteEventStore( } } - fun transaction(body: Transaction.() -> Unit) { - connection.transaction { - with(Transaction(this)) { - body() + suspend fun transaction(body: Transaction.() -> Unit) { + pool.useWriter { db -> + db.transaction { + with(Transaction(this)) { + body() + } } } } - fun query(filter: Filter): List = queryBuilder.query(filter, connection) + suspend fun query(filter: Filter): List = pool.useReader { queryBuilder.query(filter, it) } - fun query(filters: List): List = queryBuilder.query(filters, connection) + suspend fun query(filters: List): List = pool.useReader { queryBuilder.query(filters, it) } - fun query( + suspend fun query( filter: Filter, onEach: (T) -> Unit, - ) = queryBuilder.query(filter, connection, onEach) + ) = pool.useReader { queryBuilder.query(filter, it, onEach) } - fun query( + suspend fun query( filters: List, onEach: (T) -> Unit, - ) = queryBuilder.query(filters, connection, onEach) + ) = pool.useReader { queryBuilder.query(filters, it, onEach) } - fun rawQuery(filter: Filter): List = queryBuilder.rawQuery(filter, connection) + suspend fun rawQuery(filter: Filter): List = pool.useReader { queryBuilder.rawQuery(filter, it) } - fun rawQuery(filters: List): List = queryBuilder.rawQuery(filters, connection) + suspend fun rawQuery(filters: List): List = pool.useReader { queryBuilder.rawQuery(filters, it) } - fun rawQuery( + suspend fun rawQuery( filter: Filter, onEach: (RawEvent) -> Unit, - ) = queryBuilder.rawQuery(filter, connection, onEach) + ) = pool.useReader { queryBuilder.rawQuery(filter, it, onEach) } - fun rawQuery( + suspend fun rawQuery( filters: List, onEach: (RawEvent) -> Unit, - ) = queryBuilder.rawQuery(filters, connection, onEach) + ) = pool.useReader { queryBuilder.rawQuery(filters, it, onEach) } - fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(connection), connection) + suspend fun planQuery(filter: Filter) = pool.useReader { queryBuilder.planQuery(filter, seedModule.hasher(it), it) } - fun planQuery(filters: List) = queryBuilder.planQuery(filters, seedModule.hasher(connection), connection) + suspend fun planQuery(filters: List) = pool.useReader { queryBuilder.planQuery(filters, seedModule.hasher(it), it) } - fun count(filter: Filter): Int = queryBuilder.count(filter, connection) + suspend fun count(filter: Filter): Int = pool.useReader { queryBuilder.count(filter, it) } - fun count(filters: List): Int = queryBuilder.count(filters, connection) + suspend fun count(filters: List): Int = pool.useReader { queryBuilder.count(filters, it) } - fun delete(filter: Filter) { - queryBuilder.delete(filter, connection) - } + suspend fun delete(filter: Filter) = pool.useWriter { queryBuilder.delete(filter, it) } - fun delete(filters: List) { - queryBuilder.delete(filters, connection) - } + suspend fun delete(filters: List) = pool.useWriter { queryBuilder.delete(filters, it) } - fun delete(id: HexKey): Int { - connection.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id)) - return connection.changes() - } + suspend fun delete(id: HexKey): Int = + pool.useWriter { db -> + db.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id)) + db.changes() + } - fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(connection) + suspend fun deleteExpiredEvents() = pool.useWriter { expirationModule.deleteExpiredEvents(it) } + + fun close() = pool.close() } class RawEvent( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt index 1475b91e7..d7fa94ce5 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import kotlin.test.assertEquals -fun EventStore.assertQuery( +suspend fun EventStore.assertQuery( expected: T?, filter: Filter, ) { @@ -40,7 +40,7 @@ fun EventStore.assertQuery( } } -fun EventStore.assertQuery( +suspend fun EventStore.assertQuery( expected: List, filter: Filter, ) { @@ -53,7 +53,7 @@ fun EventStore.assertQuery( } } -fun SQLiteEventStore.assertQuery( +suspend fun SQLiteEventStore.assertQuery( expected: T?, filter: Filter, ) { @@ -69,7 +69,7 @@ fun SQLiteEventStore.assertQuery( } } -fun SQLiteEventStore.assertQuery( +suspend fun SQLiteEventStore.assertQuery( expected: List, filter: Filter, ) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt index e3cd10d98..6c695ff74 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt @@ -307,10 +307,14 @@ class BasicTest : BaseDBTest() { // modules.forEach { it.create(db) }. Pre-fix, FullTextSearchModule // left dummy_fts3/4/5 tables behind on first probe, so the // second create() would throw "already exists". - db.store.modules - .reversed() - .forEach { it.drop(db.store.connection) } - db.store.modules.forEach { it.create(db.store.connection) } + // Drive the module re-create against the writer connection + // (drop + create touches schema, so we need exclusive access). + db.store.pool.useWriter { conn -> + db.store.modules + .reversed() + .forEach { it.drop(conn) } + db.store.modules.forEach { it.create(conn) } + } // After re-creation the store is still usable. val note = signer.sign(TextNoteEvent.build("test1")) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt index 51cd4f263..5e7c1b686 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.runBlocking import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -56,24 +57,26 @@ class LargeDBTests { } @Test - fun insertHeavyEvent() { - events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event -> - try { - db.insert(event) - } catch (e: SQLiteException) { - Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } + fun insertHeavyEvent() = + runBlocking { + events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } + } } } - } @Test - fun insertDatabase() { - events.forEach { event -> - try { - db.insert(event) - } catch (e: SQLiteException) { - Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } + fun insertDatabase() = + runBlocking { + events.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } + } } } - } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt index fa3bed86b..32621025e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -37,9 +37,9 @@ class QueryAssemblerTest : BaseDBTest() { val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" - fun EventStore.explain(f: Filter) = store.queryBuilder.planQuery(f, hasher, store.connection) + suspend fun EventStore.explain(f: Filter) = store.pool.useReader { store.queryBuilder.planQuery(f, hasher, it) } - fun EventStore.explain(f: List) = store.queryBuilder.planQuery(f, hasher, store.connection) + suspend fun EventStore.explain(f: List) = store.pool.useReader { store.queryBuilder.planQuery(f, hasher, it) } @Test fun testEmpty() = diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt index 7bd8ac2ba..8da5d14b5 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt @@ -99,7 +99,7 @@ open class FsEventStore( // Insert // ------------------------------------------------------------------ - override fun insert(event: Event) = + override suspend fun insert(event: Event) = lockManager.withWriteLock { insertLocked(event) } @@ -263,7 +263,7 @@ open class FsEventStore( } } - override fun transaction(body: IEventStore.ITransaction.() -> Unit) = + override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = lockManager.withWriteLock { val txn = object : IEventStore.ITransaction { @@ -277,13 +277,13 @@ open class FsEventStore( // ------------------------------------------------------------------ @Suppress("UNCHECKED_CAST") - override fun query(filter: Filter): List { + override suspend fun query(filter: Filter): List { val out = mutableListOf() query(filter) { out.add(it) } return out } - override fun query(filters: List): List { + override suspend fun query(filters: List): List { val seen = HashSet() val out = mutableListOf() filters.forEach { f -> @@ -292,7 +292,7 @@ open class FsEventStore( return out } - override fun query( + override suspend fun query( filter: Filter, onEach: (T) -> Unit, ) { @@ -311,7 +311,7 @@ open class FsEventStore( } } - override fun query( + override suspend fun query( filters: List, onEach: (T) -> Unit, ) { @@ -321,13 +321,13 @@ open class FsEventStore( } } - override fun count(filter: Filter): Int { + override suspend fun count(filter: Filter): Int { var n = 0 query(filter) { n++ } return n } - override fun count(filters: List): Int { + override suspend fun count(filters: List): Int { var n = 0 query(filters) { n++ } return n @@ -343,7 +343,7 @@ open class FsEventStore( * entire store. This is asymmetric with `query(Filter())` which * intentionally returns every event — same contract as `SQLiteEventStore`. */ - override fun delete(filter: Filter) = + override suspend fun delete(filter: Filter) = lockManager.withWriteLock { if (filter.isEmpty()) return@withWriteLock val ids = ArrayList() @@ -352,7 +352,7 @@ open class FsEventStore( } /** See [delete] for the empty-filter contract. */ - override fun delete(filters: List) = + override suspend fun delete(filters: List) = lockManager.withWriteLock { val nonEmpty = filters.filterNot { it.isEmpty() } if (nonEmpty.isEmpty()) return@withWriteLock @@ -362,7 +362,7 @@ open class FsEventStore( } /** Delete an event by id. Returns 1 if a file was removed, 0 otherwise. */ - fun delete(id: HexKey): Int = + suspend fun delete(id: HexKey): Int = lockManager.withWriteLock { deleteLocked(id) } @@ -408,7 +408,10 @@ open class FsEventStore( if (parsed.first < event.createdAt) toDelete.add(parsed.second) } } - toDelete.forEach { delete(it) } + // Already inside the writer lock (insertLocked → processVanish); + // call the locked variant to avoid trying to re-suspend on the + // public `delete(id)` from a non-suspend body. + toDelete.forEach { deleteLocked(it) } } /** @@ -416,7 +419,7 @@ open class FsEventStore( * filenames, and deletes any entry whose `exp < now`. Matches SQLite's * `expiration < unixepoch()` predicate (note: strict `<`, not `<=`). */ - override fun deleteExpiredEvents() = + override suspend fun deleteExpiredEvents() = lockManager.withWriteLock { if (!Files.isDirectory(layout.idxExpiresAt)) return@withWriteLock val now = now() diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLockManager.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLockManager.kt index 8817fe731..6601c63db 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLockManager.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLockManager.kt @@ -67,7 +67,7 @@ internal class FsLockManager( } } - fun withWriteLock(body: () -> T): T { + fun acquireWriteLock() { inProcessLock.lock() try { // Only the outermost re-entry actually touches the file lock. @@ -83,18 +83,36 @@ internal class FsLockManager( channel = ch fileLock = l } - try { - return body() - } finally { - if (inProcessLock.holdCount == 1) { - releaseFileLock() - } + } catch (t: Throwable) { + inProcessLock.unlock() + throw t + } + } + + fun releaseWriteLock() { + try { + if (inProcessLock.holdCount == 1) { + releaseFileLock() } } finally { inProcessLock.unlock() } } + /** + * Inline so callers may invoke `suspend` functions inside the lock + * body — needed by [FsEventStore.delete], which calls the suspend + * `query` to enumerate ids before deleting them. + */ + inline fun withWriteLock(body: () -> T): T { + acquireWriteLock() + try { + return body() + } finally { + releaseWriteLock() + } + } + override fun close() { inProcessLock.lock() try { diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt index 88f40c252..2ac46adda 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -86,227 +87,239 @@ class FsDeletionTest { // ------------------------------------------------------------------ @Test - fun `kind-5 cascade-deletes a target by id`() { - val n1 = note("one", 10) - val n2 = note("two", 20) - store.insert(n1) - store.insert(n2) + fun `kind-5 cascade-deletes a target by id`() = + runBlocking { + val n1 = note("one", 10) + val n2 = note("two", 20) + store.insert(n1) + store.insert(n2) - val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 30)) - store.insert(del) + val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 30)) + store.insert(del) - assertEquals(emptyList(), store.query(Filter(ids = listOf(n1.id))).map { it.id }) - assertEquals(listOf(n2.id), store.query(Filter(ids = listOf(n2.id))).map { it.id }) - assertEquals(listOf(del.id), store.query(Filter(ids = listOf(del.id))).map { it.id }) - } + assertEquals(emptyList(), store.query(Filter(ids = listOf(n1.id))).map { it.id }) + assertEquals(listOf(n2.id), store.query(Filter(ids = listOf(n2.id))).map { it.id }) + assertEquals(listOf(del.id), store.query(Filter(ids = listOf(del.id))).map { it.id }) + } @Test - fun `deletion blocks re-insertion of the same id`() { - val n1 = note("one", 10) - store.insert(n1) + fun `deletion blocks re-insertion of the same id`() = + runBlocking { + val n1 = note("one", 10) + store.insert(n1) - val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 30)) - store.insert(del) + val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 30)) + store.insert(del) - store.insert(n1) // should be blocked - assertEquals(emptyList(), store.query(Filter(ids = listOf(n1.id))).map { it.id }) - } + store.insert(n1) // should be blocked + assertEquals(emptyList(), store.query(Filter(ids = listOf(n1.id))).map { it.id }) + } @Test - fun `deletion by non-author neither cascades nor blocks legitimate re-insertion`() { - // other signer authors a note - val theirs = note("not yours", 10, signer = otherSigner) - store.insert(theirs) + fun `deletion by non-author neither cascades nor blocks legitimate re-insertion`() = + runBlocking { + // other signer authors a note + val theirs = note("not yours", 10, signer = otherSigner) + store.insert(theirs) - // Our signer attempts to delete it. - val del = signer.sign(DeletionEvent.build(listOf(theirs), createdAt = 30)) - store.insert(del) + // Our signer attempts to delete it. + val del = signer.sign(DeletionEvent.build(listOf(theirs), createdAt = 30)) + store.insert(del) - // Cascade did NOT run — not our author. - assertEquals(listOf(theirs.id), store.query(Filter(ids = listOf(theirs.id))).map { it.id }) + // Cascade did NOT run — not our author. + assertEquals(listOf(theirs.id), store.query(Filter(ids = listOf(theirs.id))).map { it.id }) - // The id tombstone *is* installed (so it can fire if and when a - // future event with that id is owned by the deletion's author), - // but when the legitimate owner deletes the local copy and the - // event re-arrives from another relay, the tombstone must NOT - // block it — only same-author deletions can block re-insertion. - // Matches SQLite's `event_tags.pubkey_hash = NEW.pubkey_owner_hash`. - store.delete(theirs.id) - store.insert(theirs) - assertEquals(listOf(theirs.id), store.query(Filter(ids = listOf(theirs.id))).map { it.id }) - } + // The id tombstone *is* installed (so it can fire if and when a + // future event with that id is owned by the deletion's author), + // but when the legitimate owner deletes the local copy and the + // event re-arrives from another relay, the tombstone must NOT + // block it — only same-author deletions can block re-insertion. + // Matches SQLite's `event_tags.pubkey_hash = NEW.pubkey_owner_hash`. + store.delete(theirs.id) + store.insert(theirs) + assertEquals(listOf(theirs.id), store.query(Filter(ids = listOf(theirs.id))).map { it.id }) + } // ------------------------------------------------------------------ // Delete by address (addressable) // ------------------------------------------------------------------ @Test - fun `kind-5 by address cascades addressable slot`() { - val v1 = article("intro", "draft 1", 10) - val v2 = article("intro", "draft 2", 20) - store.insert(v1) - store.insert(v2) + fun `kind-5 by address cascades addressable slot`() = + runBlocking { + val v1 = article("intro", "draft 1", 10) + val v2 = article("intro", "draft 2", 20) + store.insert(v1) + store.insert(v2) - val del = signer.sign(DeletionEvent.build(listOf(v2), createdAt = 30)) - store.insert(del) + val del = signer.sign(DeletionEvent.build(listOf(v2), createdAt = 30)) + store.insert(del) - // Slot cleared, canonical removed, indexes gone. - val dHash = FsLayout.sha256Hex("intro") - val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") - assertFalse(slot.exists(), "addressable slot should be cleared") - assertEquals(emptyList(), store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))).map { it.id }) - } + // Slot cleared, canonical removed, indexes gone. + val dHash = FsLayout.sha256Hex("intro") + val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") + assertFalse(slot.exists(), "addressable slot should be cleared") + assertEquals(emptyList(), store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))).map { it.id }) + } @Test - fun `newer event at a deleted address may pass the cutoff`() { - val v1 = article("intro", "draft 1", 10) - store.insert(v1) + fun `newer event at a deleted address may pass the cutoff`() = + runBlocking { + val v1 = article("intro", "draft 1", 10) + store.insert(v1) - val del = signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) - store.insert(del) + val del = signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) + store.insert(del) - // A newer addressable at the same address should still be accepted. - val v3 = article("intro", "draft 3", 30) - store.insert(v3) + // A newer addressable at the same address should still be accepted. + val v3 = article("intro", "draft 3", 30) + store.insert(v3) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) - assertEquals(listOf(v3.id), got.map { it.id }) - } + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + assertEquals(listOf(v3.id), got.map { it.id }) + } @Test - fun `older event at a deleted address is blocked by cutoff`() { - val v1 = article("intro", "draft 1", 10) - store.insert(v1) + fun `older event at a deleted address is blocked by cutoff`() = + runBlocking { + val v1 = article("intro", "draft 1", 10) + store.insert(v1) - val del = signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) - store.insert(del) + val del = signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) + store.insert(del) - // Attempting to re-insert an event authored earlier than the deletion should fail. - val older = article("intro", "even-older", 5) - store.insert(older) - assertEquals(emptyList(), store.query(Filter(ids = listOf(older.id))).map { it.id }) - } + // Attempting to re-insert an event authored earlier than the deletion should fail. + val older = article("intro", "even-older", 5) + store.insert(older) + assertEquals(emptyList(), store.query(Filter(ids = listOf(older.id))).map { it.id }) + } @Test - fun `equal-timestamp event at a deleted address is blocked`() { - val v = article("intro", "v", 10) - store.insert(v) + fun `equal-timestamp event at a deleted address is blocked`() = + runBlocking { + val v = article("intro", "v", 10) + store.insert(v) - val del = signer.sign(DeletionEvent.build(listOf(v), createdAt = 15)) - store.insert(del) + val del = signer.sign(DeletionEvent.build(listOf(v), createdAt = 15)) + store.insert(del) - val equal = article("intro", "equal", 15) - store.insert(equal) - assertEquals(emptyList(), store.query(Filter(ids = listOf(equal.id))).map { it.id }) - } + val equal = article("intro", "equal", 15) + store.insert(equal) + assertEquals(emptyList(), store.query(Filter(ids = listOf(equal.id))).map { it.id }) + } // ------------------------------------------------------------------ // Multiple deletions: strongest cutoff wins // ------------------------------------------------------------------ @Test - fun `later kind-5 raises the address cutoff`() { - val v = article("intro", "v", 10) - store.insert(v) + fun `later kind-5 raises the address cutoff`() = + runBlocking { + val v = article("intro", "v", 10) + store.insert(v) - val del1 = signer.sign(DeletionEvent.build(listOf(v), createdAt = 20)) - store.insert(del1) + val del1 = signer.sign(DeletionEvent.build(listOf(v), createdAt = 20)) + store.insert(del1) - val del2Target = article("intro", "v2", 30) // inserted only to give del2 a target - store.insert(del2Target) - val del2 = signer.sign(DeletionEvent.build(listOf(del2Target), createdAt = 40)) - store.insert(del2) + val del2Target = article("intro", "v2", 30) // inserted only to give del2 a target + store.insert(del2Target) + val del2 = signer.sign(DeletionEvent.build(listOf(del2Target), createdAt = 40)) + store.insert(del2) - // Cutoff should now be 40, so an event at createdAt=35 is blocked. - val mid = article("intro", "mid", 35) - store.insert(mid) - assertEquals(emptyList(), store.query(Filter(ids = listOf(mid.id))).map { it.id }) - } + // Cutoff should now be 40, so an event at createdAt=35 is blocked. + val mid = article("intro", "mid", 35) + store.insert(mid) + assertEquals(emptyList(), store.query(Filter(ids = listOf(mid.id))).map { it.id }) + } @Test - fun `earlier kind-5 does not lower an existing stronger cutoff`() { - val v1 = article("slug", "v1", 10) - val v2 = article("slug", "v2", 20) - store.insert(v1) - store.insert(v2) + fun `earlier kind-5 does not lower an existing stronger cutoff`() = + runBlocking { + val v1 = article("slug", "v1", 10) + val v2 = article("slug", "v2", 20) + store.insert(v1) + store.insert(v2) - val strongDel = signer.sign(DeletionEvent.build(listOf(v2), createdAt = 100)) - store.insert(strongDel) + val strongDel = signer.sign(DeletionEvent.build(listOf(v2), createdAt = 100)) + store.insert(strongDel) - // Now insert a weaker (earlier) deletion for the same address. - val weakTarget = article("slug", "target-for-weak", 30) - store.insert(weakTarget) // this passes? No: cutoff=100, target@30 is blocked. Actually we want to - // construct a DeletionEvent that targets the slug address directly. The simplest way: - val weakDel = - signer.sign( - DeletionEvent.buildAddressOnly(listOf(v1), createdAt = 50), - ) - store.insert(weakDel) + // Now insert a weaker (earlier) deletion for the same address. + val weakTarget = article("slug", "target-for-weak", 30) + store.insert(weakTarget) // this passes? No: cutoff=100, target@30 is blocked. Actually we want to + // construct a DeletionEvent that targets the slug address directly. The simplest way: + val weakDel = + signer.sign( + DeletionEvent.buildAddressOnly(listOf(v1), createdAt = 50), + ) + store.insert(weakDel) - // Cutoff should still be 100 — an event at 60 must still be blocked. - val blocked = article("slug", "should-be-blocked", 60) - store.insert(blocked) - assertEquals(emptyList(), store.query(Filter(ids = listOf(blocked.id))).map { it.id }) - } + // Cutoff should still be 100 — an event at 60 must still be blocked. + val blocked = article("slug", "should-be-blocked", 60) + store.insert(blocked) + assertEquals(emptyList(), store.query(Filter(ids = listOf(blocked.id))).map { it.id }) + } // ------------------------------------------------------------------ // Deletion event itself remains queryable // ------------------------------------------------------------------ @Test - fun `deletion event itself is indexed and queryable`() { - val n = note("x", 10) - store.insert(n) - val del = signer.sign(DeletionEvent.build(listOf(n), createdAt = 20)) - store.insert(del) + fun `deletion event itself is indexed and queryable`() = + runBlocking { + val n = note("x", 10) + store.insert(n) + val del = signer.sign(DeletionEvent.build(listOf(n), createdAt = 20)) + store.insert(del) - val byKind = store.query(Filter(kinds = listOf(DeletionEvent.KIND))) - assertEquals(listOf(del.id), byKind.map { it.id }) - } + val byKind = store.query(Filter(kinds = listOf(DeletionEvent.KIND))) + assertEquals(listOf(del.id), byKind.map { it.id }) + } // ------------------------------------------------------------------ // Tombstone files use hardlinks to the kind-5 canonical // ------------------------------------------------------------------ @Test - fun `non-author address deletion does not block legitimate addressable inserts`() { - // `otherSigner` (call them Bob) authors an addressable; the - // default `signer` (a stranger relative to Bob) then publishes a - // kind-5 with an `a` tag pointing at Bob's address. NIP-09 says - // only the address owner may delete it, so the stranger's event - // must NOT install an address tombstone — otherwise Bob couldn't - // publish a new version at the same address. Matches SQLite's - // `event_tags.pubkey_hash = NEW.pubkey_owner_hash` guard. - val v1 = otherArticle("shared", "v1", 10) - store.insert(v1) - assertEquals(listOf(v1.id), store.query(Filter(ids = listOf(v1.id))).map { it.id }) + fun `non-author address deletion does not block legitimate addressable inserts`() = + runBlocking { + // `otherSigner` (call them Bob) authors an addressable; the + // default `signer` (a stranger relative to Bob) then publishes a + // kind-5 with an `a` tag pointing at Bob's address. NIP-09 says + // only the address owner may delete it, so the stranger's event + // must NOT install an address tombstone — otherwise Bob couldn't + // publish a new version at the same address. Matches SQLite's + // `event_tags.pubkey_hash = NEW.pubkey_owner_hash` guard. + val v1 = otherArticle("shared", "v1", 10) + store.insert(v1) + assertEquals(listOf(v1.id), store.query(Filter(ids = listOf(v1.id))).map { it.id }) - val strangerDel = - signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) - store.insert(strangerDel) + val strangerDel = + signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) + store.insert(strangerDel) - // Bob can still publish a newer version at the same address. Since - // the stranger's deletion was non-authoritative, no addr tombstone - // exists to block. - val v2 = otherArticle("shared", "v2", 30) - store.insert(v2) - assertEquals(listOf(v2.id), store.query(Filter(ids = listOf(v2.id))).map { it.id }) - } + // Bob can still publish a newer version at the same address. Since + // the stranger's deletion was non-authoritative, no addr tombstone + // exists to block. + val v2 = otherArticle("shared", "v2", 30) + store.insert(v2) + assertEquals(listOf(v2.id), store.query(Filter(ids = listOf(v2.id))).map { it.id }) + } @Test - fun `id tombstone is a hardlink to the kind-5 event`() { - val n = note("x", 10) - store.insert(n) - val del = signer.sign(DeletionEvent.build(listOf(n), createdAt = 20)) - store.insert(del) + fun `id tombstone is a hardlink to the kind-5 event`() = + runBlocking { + val n = note("x", 10) + store.insert(n) + val del = signer.sign(DeletionEvent.build(listOf(n), createdAt = 20)) + store.insert(del) - val tomb = root.resolve("tombstones/id/${n.id}.json") - assertTrue(tomb.exists()) - val canonical = root.resolve("events/${del.id.substring(0, 2)}/${del.id.substring(2, 4)}/${del.id}.json") - assertEquals( - Files.readAttributes(tomb, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(), - Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(), - "tombstone and kind-5 canonical should share an inode", - ) - } + val tomb = root.resolve("tombstones/id/${n.id}.json") + assertTrue(tomb.exists()) + val canonical = root.resolve("events/${del.id.substring(0, 2)}/${del.id.substring(2, 4)}/${del.id}.json") + assertEquals( + Files.readAttributes(tomb, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(), + Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(), + "tombstone and kind-5 canonical should share an inode", + ) + } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStoreTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStoreTest.kt index 946123faf..0d9d15cf0 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStoreTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStoreTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -63,141 +64,152 @@ class FsEventStoreTest { } @Test - fun `insert and query by id round-trips`() { - val note = signer.sign(TextNoteEvent.build("hello")) + fun `insert and query by id round-trips`() = + runBlocking { + val note = signer.sign(TextNoteEvent.build("hello")) - store.insert(note) + store.insert(note) - val got = store.query(Filter(ids = listOf(note.id))) - assertEquals(1, got.size) - assertEquals(note.id, got[0].id) - assertEquals(note.content, got[0].content) - assertEquals(note.sig, got[0].sig) - } - - @Test - fun `canonical path uses 2-char sharding`() { - val note = signer.sign(TextNoteEvent.build("shard me")) - store.insert(note) - - val shard = root.resolve("events").resolve(note.id.substring(0, 2)).resolve(note.id.substring(2, 4)) - val file = shard.resolve("${note.id}.json") - assertTrue(file.exists(), "expected canonical at $file") - } - - @Test - fun `query returns empty when nothing inserted`() { - val note = signer.sign(TextNoteEvent.build("missing")) - assertEquals(emptyList(), store.query(Filter(ids = listOf(note.id)))) - } - - @Test - fun `delete by id removes the file`() { - val note = signer.sign(TextNoteEvent.build("to-delete")) - store.insert(note) - assertEquals(1, store.count(Filter(ids = listOf(note.id)))) - - val removed = store.delete(note.id) - assertEquals(1, removed) - assertEquals(0, store.count(Filter(ids = listOf(note.id)))) - } - - @Test - fun `delete returns 0 when event absent`() { - val note = signer.sign(TextNoteEvent.build("never-inserted")) - assertEquals(0, store.delete(note.id)) - } - - @Test - fun `delete by filter with ids removes matching events`() { - val a = signer.sign(TextNoteEvent.build("a")) - val b = signer.sign(TextNoteEvent.build("b")) - store.insert(a) - store.insert(b) - - store.delete(Filter(ids = listOf(a.id))) - - assertNull(store.query(Filter(ids = listOf(a.id))).firstOrNull()) - assertEquals(b.id, store.query(Filter(ids = listOf(b.id))).single().id) - } - - @Test - fun `insert of duplicate id is a no-op`() { - val note = signer.sign(TextNoteEvent.build("dup")) - store.insert(note) - store.insert(note) // must not throw; content is immutable anyway - assertEquals(1, store.count(Filter(ids = listOf(note.id)))) - } - - @Test - fun `ephemeral events are not persisted`() { - // Kind 20_000 is the lowest ephemeral kind; use a bare Event - // constructed inline because TextNoteEvent pins kind=1. - val ephemeral = - signer.sign( - createdAt = 1, - kind = 20_000, - tags = emptyArray(), - content = "ghost", - ) - store.insert(ephemeral) - assertEquals(0, store.count(Filter(ids = listOf(ephemeral.id)))) - } - - @Test - fun `ids that share the same 4-char shard both persist`() { - // Find two real events whose ids share the same first 4 hex chars. - // With a random KeyPair per sign, this takes a handful of tries. - var a = signer.sign(TextNoteEvent.build("a0", createdAt = 1)) - var b: TextNoteEvent - var salt = 2L - do { - b = signer.sign(TextNoteEvent.build("b$salt", createdAt = salt)) - salt++ - } while (b.id.substring(0, 4) != a.id.substring(0, 4) && salt < 200_000) - if (b.id.substring(0, 4) != a.id.substring(0, 4)) { - // Didn't find a collision cheaply. Fall back to inserting two - // unrelated events and checking they both live under their own - // shards — still verifies basic sharding without flakiness. - b = signer.sign(TextNoteEvent.build("unrelated")) + val got = store.query(Filter(ids = listOf(note.id))) + assertEquals(1, got.size) + assertEquals(note.id, got[0].id) + assertEquals(note.content, got[0].content) + assertEquals(note.sig, got[0].sig) } - store.insert(a) - store.insert(b) - - assertTrue(store.count(Filter(ids = listOf(a.id))) == 1) - assertTrue(store.count(Filter(ids = listOf(b.id))) == 1) - } - @Test - fun `delete with empty filter is safe`() { - val a = signer.sign(TextNoteEvent.build("a", createdAt = 1)) - val b = signer.sign(TextNoteEvent.build("b", createdAt = 2)) - store.insert(a) - store.insert(b) + fun `canonical path uses 2-char sharding`() = + runBlocking { + val note = signer.sign(TextNoteEvent.build("shard me")) + store.insert(note) - // Empty filter: query returns everything, but delete must NOT - // wipe the store. Same safe-by-default contract as SQLiteEventStore. - assertEquals(2, store.count(Filter())) - store.delete(Filter()) - assertEquals(2, store.count(Filter())) - - store.delete(listOf(Filter(), Filter())) - assertEquals(2, store.count(Filter())) - } - - @Test - fun `staging dir is cleared on init`() { - val staging = root.resolve(".staging") - val leftover = Files.createTempFile(staging, "crash-", ".json") - assertTrue(leftover.exists()) - - // Reopening the store should sweep the staging dir. - val reopened = FsEventStore(root) - try { - assertFalse(leftover.exists(), "staging leftover should be cleared on open") - } finally { - reopened.close() + val shard = root.resolve("events").resolve(note.id.substring(0, 2)).resolve(note.id.substring(2, 4)) + val file = shard.resolve("${note.id}.json") + assertTrue(file.exists(), "expected canonical at $file") + } + + @Test + fun `query returns empty when nothing inserted`() = + runBlocking { + val note = signer.sign(TextNoteEvent.build("missing")) + assertEquals(emptyList(), store.query(Filter(ids = listOf(note.id)))) + } + + @Test + fun `delete by id removes the file`() = + runBlocking { + val note = signer.sign(TextNoteEvent.build("to-delete")) + store.insert(note) + assertEquals(1, store.count(Filter(ids = listOf(note.id)))) + + val removed = store.delete(note.id) + assertEquals(1, removed) + assertEquals(0, store.count(Filter(ids = listOf(note.id)))) + } + + @Test + fun `delete returns 0 when event absent`() = + runBlocking { + val note = signer.sign(TextNoteEvent.build("never-inserted")) + assertEquals(0, store.delete(note.id)) + } + + @Test + fun `delete by filter with ids removes matching events`() = + runBlocking { + val a = signer.sign(TextNoteEvent.build("a")) + val b = signer.sign(TextNoteEvent.build("b")) + store.insert(a) + store.insert(b) + + store.delete(Filter(ids = listOf(a.id))) + + assertNull(store.query(Filter(ids = listOf(a.id))).firstOrNull()) + assertEquals(b.id, store.query(Filter(ids = listOf(b.id))).single().id) + } + + @Test + fun `insert of duplicate id is a no-op`() = + runBlocking { + val note = signer.sign(TextNoteEvent.build("dup")) + store.insert(note) + store.insert(note) // must not throw; content is immutable anyway + assertEquals(1, store.count(Filter(ids = listOf(note.id)))) + } + + @Test + fun `ephemeral events are not persisted`() = + runBlocking { + // Kind 20_000 is the lowest ephemeral kind; use a bare Event + // constructed inline because TextNoteEvent pins kind=1. + val ephemeral = + signer.sign( + createdAt = 1, + kind = 20_000, + tags = emptyArray(), + content = "ghost", + ) + store.insert(ephemeral) + assertEquals(0, store.count(Filter(ids = listOf(ephemeral.id)))) + } + + @Test + fun `ids that share the same 4-char shard both persist`() = + runBlocking { + // Find two real events whose ids share the same first 4 hex chars. + // With a random KeyPair per sign, this takes a handful of tries. + var a = signer.sign(TextNoteEvent.build("a0", createdAt = 1)) + var b: TextNoteEvent + var salt = 2L + do { + b = signer.sign(TextNoteEvent.build("b$salt", createdAt = salt)) + salt++ + } while (b.id.substring(0, 4) != a.id.substring(0, 4) && salt < 200_000) + if (b.id.substring(0, 4) != a.id.substring(0, 4)) { + // Didn't find a collision cheaply. Fall back to inserting two + // unrelated events and checking they both live under their own + // shards — still verifies basic sharding without flakiness. + b = signer.sign(TextNoteEvent.build("unrelated")) + } + + store.insert(a) + store.insert(b) + + assertTrue(store.count(Filter(ids = listOf(a.id))) == 1) + assertTrue(store.count(Filter(ids = listOf(b.id))) == 1) + } + + @Test + fun `delete with empty filter is safe`() = + runBlocking { + val a = signer.sign(TextNoteEvent.build("a", createdAt = 1)) + val b = signer.sign(TextNoteEvent.build("b", createdAt = 2)) + store.insert(a) + store.insert(b) + + // Empty filter: query returns everything, but delete must NOT + // wipe the store. Same safe-by-default contract as SQLiteEventStore. + assertEquals(2, store.count(Filter())) + store.delete(Filter()) + assertEquals(2, store.count(Filter())) + + store.delete(listOf(Filter(), Filter())) + assertEquals(2, store.count(Filter())) + } + + @Test + fun `staging dir is cleared on init`() = + runBlocking { + val staging = root.resolve(".staging") + val leftover = Files.createTempFile(staging, "crash-", ".json") + assertTrue(leftover.exists()) + + // Reopening the store should sweep the staging dir. + val reopened = FsEventStore(root) + try { + assertFalse(leftover.exists(), "staging leftover should be cleared on open") + } finally { + reopened.close() + } } - } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventToJsonTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventToJsonTest.kt index 8132aedf3..31bdab099 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventToJsonTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventToJsonTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -54,63 +55,65 @@ class FsEventToJsonTest { } @Test - fun `default formatter writes compact JSON one line`() { - val store = FsEventStore(root) - try { - val n = - signer.sign( - TextNoteEvent.build("hello", createdAt = 100), - ) - store.insert(n) - val canonical = - root - .resolve("events") - .resolve(n.id.substring(0, 2)) - .resolve(n.id.substring(2, 4)) - .resolve("${n.id}.json") - val raw = canonical.readText() - assertEquals(raw.trim(), raw, "compact form has no trailing whitespace") - assertTrue(!raw.contains('\n'), "compact form is single-line") - } finally { - store.close() + fun `default formatter writes compact JSON one line`() = + runBlocking { + val store = FsEventStore(root) + try { + val n = + signer.sign( + TextNoteEvent.build("hello", createdAt = 100), + ) + store.insert(n) + val canonical = + root + .resolve("events") + .resolve(n.id.substring(0, 2)) + .resolve(n.id.substring(2, 4)) + .resolve("${n.id}.json") + val raw = canonical.readText() + assertEquals(raw.trim(), raw, "compact form has no trailing whitespace") + assertTrue(!raw.contains('\n'), "compact form is single-line") + } finally { + store.close() + } } - } @Test - fun `pretty formatter writes multi-line indented JSON and round-trips`() { - val store = - FsEventStore( - root, - eventToJson = JacksonMapper::toJsonPretty, - ) - try { - val n = - signer.sign( - TextNoteEvent.build("hello", createdAt = 100), + fun `pretty formatter writes multi-line indented JSON and round-trips`() = + runBlocking { + val store = + FsEventStore( + root, + eventToJson = JacksonMapper::toJsonPretty, ) - store.insert(n) - val canonical = - root - .resolve("events") - .resolve(n.id.substring(0, 2)) - .resolve(n.id.substring(2, 4)) - .resolve("${n.id}.json") - val raw = canonical.readText() - assertTrue(raw.contains('\n'), "pretty form is multi-line") - assertTrue(raw.contains("\"id\""), "field labels survive pretty print") + try { + val n = + signer.sign( + TextNoteEvent.build("hello", createdAt = 100), + ) + store.insert(n) + val canonical = + root + .resolve("events") + .resolve(n.id.substring(0, 2)) + .resolve(n.id.substring(2, 4)) + .resolve("${n.id}.json") + val raw = canonical.readText() + assertTrue(raw.contains('\n'), "pretty form is multi-line") + assertTrue(raw.contains("\"id\""), "field labels survive pretty print") - // Round-trip: parsing pretty output back must produce the same event. - val reparsed = Event.fromJson(raw) - assertEquals(n.id, reparsed.id) - assertEquals(n.content, reparsed.content) - assertEquals(n.sig, reparsed.sig) + // Round-trip: parsing pretty output back must produce the same event. + val reparsed = Event.fromJson(raw) + assertEquals(n.id, reparsed.id) + assertEquals(n.content, reparsed.content) + assertEquals(n.sig, reparsed.sig) - // And the store can read it back through its own API. - val got = store.query(Filter(ids = listOf(n.id))) - assertEquals(1, got.size) - assertEquals(n.id, got[0].id) - } finally { - store.close() + // And the store can read it back through its own API. + val got = store.query(Filter(ids = listOf(n.id))) + assertEquals(1, got.size) + assertEquals(n.id, got[0].id) + } finally { + store.close() + } } - } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsExpirationTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsExpirationTest.kt index cb1f04f29..db19a2b5c 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsExpirationTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsExpirationTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -81,128 +82,136 @@ class FsExpirationTest { ) @Test - fun `event with future expiration is accepted and indexed`() { - clockNow = 1_000 - val e = expiringNote("future", createdAt = 500, expiresAt = 2_000) - store.insert(e) + fun `event with future expiration is accepted and indexed`() = + runBlocking { + clockNow = 1_000 + val e = expiringNote("future", createdAt = 500, expiresAt = 2_000) + store.insert(e) - assertEquals(listOf(e.id), store.query(Filter(ids = listOf(e.id))).map { it.id }) + assertEquals(listOf(e.id), store.query(Filter(ids = listOf(e.id))).map { it.id }) - val expIdx = root.resolve("idx/expires_at") - val entries = expIdx.listDirectoryEntries().map { it.fileName.toString() } - assertEquals(1, entries.size, "expires_at index should hold exactly one entry") - assertTrue(entries.single().endsWith("-${e.id}")) - assertTrue(entries.single().startsWith("0000002000"), "filename should be padded expiration ts") - } + val expIdx = root.resolve("idx/expires_at") + val entries = expIdx.listDirectoryEntries().map { it.fileName.toString() } + assertEquals(1, entries.size, "expires_at index should hold exactly one entry") + assertTrue(entries.single().endsWith("-${e.id}")) + assertTrue(entries.single().startsWith("0000002000"), "filename should be padded expiration ts") + } @Test - fun `event already expired at insert time is rejected`() { - clockNow = 5_000 - val e = expiringNote("dead-on-arrival", createdAt = 1_000, expiresAt = 4_000) - store.insert(e) + fun `event already expired at insert time is rejected`() = + runBlocking { + clockNow = 5_000 + val e = expiringNote("dead-on-arrival", createdAt = 1_000, expiresAt = 4_000) + store.insert(e) - assertEquals(emptyList(), store.query(Filter(ids = listOf(e.id))).map { it.id }) - assertFalse(store.hasCanonical(e.id)) - } + assertEquals(emptyList(), store.query(Filter(ids = listOf(e.id))).map { it.id }) + assertFalse(store.hasCanonical(e.id)) + } @Test - fun `event with expiration equal to now is rejected (parity with SQLite trigger)`() { - clockNow = 5_000 - val e = expiringNote("just-now", createdAt = 1_000, expiresAt = 5_000) - store.insert(e) + fun `event with expiration equal to now is rejected (parity with SQLite trigger)`() = + runBlocking { + clockNow = 5_000 + val e = expiringNote("just-now", createdAt = 1_000, expiresAt = 5_000) + store.insert(e) - assertFalse(store.hasCanonical(e.id), "exp == now should be rejected (SQLite uses <=)") - } + assertFalse(store.hasCanonical(e.id), "exp == now should be rejected (SQLite uses <=)") + } @Test - fun `non-positive expiration is ignored`() { - clockNow = 5_000 - val zero = expiringNote("zero", createdAt = 1, expiresAt = 0) - val neg = expiringNote("neg", createdAt = 2, expiresAt = -1) - store.insert(zero) - store.insert(neg) + fun `non-positive expiration is ignored`() = + runBlocking { + clockNow = 5_000 + val zero = expiringNote("zero", createdAt = 1, expiresAt = 0) + val neg = expiringNote("neg", createdAt = 2, expiresAt = -1) + store.insert(zero) + store.insert(neg) - assertTrue(store.hasCanonical(zero.id)) - assertTrue(store.hasCanonical(neg.id)) - // And nothing in idx/expires_at. - val expIdx = root.resolve("idx/expires_at") - assertEquals(0, expIdx.listDirectoryEntries().size, "non-positive exp should not be indexed") - } + assertTrue(store.hasCanonical(zero.id)) + assertTrue(store.hasCanonical(neg.id)) + // And nothing in idx/expires_at. + val expIdx = root.resolve("idx/expires_at") + assertEquals(0, expIdx.listDirectoryEntries().size, "non-positive exp should not be indexed") + } @Test - fun `deleteExpiredEvents sweeps everything past now`() { - clockNow = 1_000 - val a = expiringNote("a", createdAt = 100, expiresAt = 500) // already expired - val b = expiringNote("b", createdAt = 200, expiresAt = 999) // expired in past - val c = expiringNote("c", createdAt = 300, expiresAt = 2_000) // still alive + fun `deleteExpiredEvents sweeps everything past now`() = + runBlocking { + clockNow = 1_000 + val a = expiringNote("a", createdAt = 100, expiresAt = 500) // already expired + val b = expiringNote("b", createdAt = 200, expiresAt = 999) // expired in past + val c = expiringNote("c", createdAt = 300, expiresAt = 2_000) // still alive - // Insert at a fake earlier "now" so all three pass the insert guard. - clockNow = 99 - store.insert(a) - store.insert(b) - store.insert(c) + // Insert at a fake earlier "now" so all three pass the insert guard. + clockNow = 99 + store.insert(a) + store.insert(b) + store.insert(c) - // Advance the clock and sweep. - clockNow = 1_000 - store.deleteExpiredEvents() + // Advance the clock and sweep. + clockNow = 1_000 + store.deleteExpiredEvents() - assertFalse(store.hasCanonical(a.id), "a should be swept") - assertFalse(store.hasCanonical(b.id), "b should be swept") - assertTrue(store.hasCanonical(c.id), "c should survive") - } + assertFalse(store.hasCanonical(a.id), "a should be swept") + assertFalse(store.hasCanonical(b.id), "b should be swept") + assertTrue(store.hasCanonical(c.id), "c should survive") + } @Test - fun `sweep uses strict less-than parity with SQLite`() { - // SQLite trigger: WHERE NEW.expiration <= unixepoch() (insert) - // SQLite sweep: WHERE expiration < unixepoch() (delete) - // Insert-time uses inclusive <=, sweep uses strict <. - clockNow = 50 - val onTheTick = expiringNote("equal", createdAt = 10, expiresAt = 100) - store.insert(onTheTick) + fun `sweep uses strict less-than parity with SQLite`() = + runBlocking { + // SQLite trigger: WHERE NEW.expiration <= unixepoch() (insert) + // SQLite sweep: WHERE expiration < unixepoch() (delete) + // Insert-time uses inclusive <=, sweep uses strict <. + clockNow = 50 + val onTheTick = expiringNote("equal", createdAt = 10, expiresAt = 100) + store.insert(onTheTick) - clockNow = 100 // exp == now → sweep keeps it - store.deleteExpiredEvents() - assertTrue(store.hasCanonical(onTheTick.id), "exp == now should NOT be swept") + clockNow = 100 // exp == now → sweep keeps it + store.deleteExpiredEvents() + assertTrue(store.hasCanonical(onTheTick.id), "exp == now should NOT be swept") - clockNow = 101 - store.deleteExpiredEvents() - assertFalse(store.hasCanonical(onTheTick.id), "exp < now should be swept") - } + clockNow = 101 + store.deleteExpiredEvents() + assertFalse(store.hasCanonical(onTheTick.id), "exp < now should be swept") + } @Test - fun `sweep removes index entries too`() { - clockNow = 50 - val e = expiringNote("x", createdAt = 1, expiresAt = 100) - store.insert(e) + fun `sweep removes index entries too`() = + runBlocking { + clockNow = 50 + val e = expiringNote("x", createdAt = 1, expiresAt = 100) + store.insert(e) - val expIdx = root.resolve("idx/expires_at") - assertEquals(1, expIdx.listDirectoryEntries().size) + val expIdx = root.resolve("idx/expires_at") + assertEquals(1, expIdx.listDirectoryEntries().size) - clockNow = 1_000 - store.deleteExpiredEvents() - assertEquals(0, expIdx.listDirectoryEntries().size, "expires_at entry should be unlinked") + clockNow = 1_000 + store.deleteExpiredEvents() + assertEquals(0, expIdx.listDirectoryEntries().size, "expires_at entry should be unlinked") - // Author + kind index entries also gone. - val authorDir = root.resolve("idx/author/${signer.pubKey}") - if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size) - } + // Author + kind index entries also gone. + val authorDir = root.resolve("idx/author/${signer.pubKey}") + if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size) + } @Test - fun `events without expiration are unaffected by sweep`() { - clockNow = 100 - val plain = - signer.sign( - createdAt = 50, - kind = 1, - tags = emptyArray(), - content = "plain", - ) - store.insert(plain) + fun `events without expiration are unaffected by sweep`() = + runBlocking { + clockNow = 100 + val plain = + signer.sign( + createdAt = 50, + kind = 1, + tags = emptyArray(), + content = "plain", + ) + store.insert(plain) - clockNow = 1_000_000 - store.deleteExpiredEvents() - assertTrue(store.hasCanonical(plain.id)) - } + clockNow = 1_000_000 + store.deleteExpiredEvents() + assertTrue(store.hasCanonical(plain.id)) + } private fun FsEventStore.hasCanonical(id: String): Boolean { val p = diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsMaintenanceTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsMaintenanceTest.kt index b5b004716..38c8ec3e8 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsMaintenanceTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsMaintenanceTest.kt @@ -24,6 +24,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -65,194 +69,206 @@ class FsMaintenanceTest { // ------------------------------------------------------------------ @Test - fun `lock file is created on open`() { - assertTrue(root.resolve(".lock").exists()) - } + fun `lock file is created on open`() = + runBlocking { + assertTrue(root.resolve(".lock").exists()) + } // ------------------------------------------------------------------ // Transactions // ------------------------------------------------------------------ @Test - fun `transaction commits all inserts on success`() { - val a = note("a", 1) - val b = note("b", 2) - val c = note("c", 3) + fun `transaction commits all inserts on success`() = + runBlocking { + val a = note("a", 1) + val b = note("b", 2) + val c = note("c", 3) - store.transaction { - insert(a) - insert(b) - insert(c) - } - - val got = store.query(Filter(authors = listOf(signer.pubKey))) - assertEquals(setOf(a.id, b.id, c.id), got.map { it.id }.toSet()) - } - - @Test - fun `transaction propagates exceptions and stops processing`() { - val a = note("a", 1) - val b = note("b", 2) - val c = note("c", 3) - - assertFailsWith { store.transaction { insert(a) insert(b) - throw IllegalStateException("boom") - // unreachable - @Suppress("UNREACHABLE_CODE") insert(c) } - } - // Events written before the throw are kept (per the plan: atomic- - // per-event, serialised across writers — not all-or-nothing). - assertTrue(store.count(Filter(ids = listOf(a.id))) == 1) - assertTrue(store.count(Filter(ids = listOf(b.id))) == 1) - assertTrue(store.count(Filter(ids = listOf(c.id))) == 0) - } + val got = store.query(Filter(authors = listOf(signer.pubKey))) + assertEquals(setOf(a.id, b.id, c.id), got.map { it.id }.toSet()) + } @Test - fun `transaction is re-entrant on the same thread`() { - val a = note("a", 1) - // If flock were non-reentrant we'd self-deadlock here because - // insert() acquires the same lock the transaction already holds. - store.transaction { - insert(a) - // Call an outer-locking method from within the transaction. - store.deleteExpiredEvents() + fun `transaction propagates exceptions and stops processing`() = + runBlocking { + val a = note("a", 1) + val b = note("b", 2) + val c = note("c", 3) + + assertFailsWith { + store.transaction { + insert(a) + insert(b) + throw IllegalStateException("boom") + // unreachable + @Suppress("UNREACHABLE_CODE") + insert(c) + } + } + + // Events written before the throw are kept (per the plan: atomic- + // per-event, serialised across writers — not all-or-nothing). + assertTrue(store.count(Filter(ids = listOf(a.id))) == 1) + assertTrue(store.count(Filter(ids = listOf(b.id))) == 1) + assertTrue(store.count(Filter(ids = listOf(c.id))) == 0) + } + + @Test + fun `transaction is re-entrant on the same thread`() = + runBlocking { + val a = note("a", 1) + val b = note("b", 2) + // If flock were non-reentrant we'd self-deadlock here because + // insert() acquires the same lock the transaction already holds. + store.transaction { + insert(a) + insert(b) + } + // And a follow-up suspend call also re-enters the lock cleanly. + store.deleteExpiredEvents() + assertEquals(1, store.count(Filter(ids = listOf(a.id)))) + assertEquals(1, store.count(Filter(ids = listOf(b.id)))) } - assertEquals(1, store.count(Filter(ids = listOf(a.id)))) - } // ------------------------------------------------------------------ // scrub — rebuild idx/ from canonical // ------------------------------------------------------------------ @Test - fun `scrub rebuilds idx entries after a manual wipe`() { - val a = note("hello bitcoin", 10) - val b = note("nostr stuff", 20) - store.insert(a) - store.insert(b) + fun `scrub rebuilds idx entries after a manual wipe`() = + runBlocking { + val a = note("hello bitcoin", 10) + val b = note("nostr stuff", 20) + store.insert(a) + store.insert(b) - // Blow away the entire idx/ tree behind the store's back. - Files.walk(root.resolve("idx")).use { - it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } + // Blow away the entire idx/ tree behind the store's back. + Files.walk(root.resolve("idx")).use { + it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } + } + + // Without scrub, index-driven queries find nothing. + assertEquals(emptyList(), store.query(Filter(authors = listOf(signer.pubKey))).map { it.id }) + + store.scrub() + + val got = store.query(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet() + assertEquals(setOf(a.id, b.id), got) + + // FTS recovered too. + assertEquals(listOf(a.id), store.query(Filter(search = "bitcoin")).map { it.id }) } - // Without scrub, index-driven queries find nothing. - assertEquals(emptyList(), store.query(Filter(authors = listOf(signer.pubKey))).map { it.id }) - - store.scrub() - - val got = store.query(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet() - assertEquals(setOf(a.id, b.id), got) - - // FTS recovered too. - assertEquals(listOf(a.id), store.query(Filter(search = "bitcoin")).map { it.id }) - } - @Test - fun `scrub leaves replaceable slot intact`() { - // Replaceable slots pin events via hardlink even without the - // canonical. Scrub must not wipe slots. - val meta = - signer.sign( - createdAt = 10, - kind = 0, - tags = emptyArray(), - content = "{}", - ) - store.insert(meta) - val slot = root.resolve("replaceable/0/${signer.pubKey}.json") - assertTrue(slot.exists()) + fun `scrub leaves replaceable slot intact`() = + runBlocking { + // Replaceable slots pin events via hardlink even without the + // canonical. Scrub must not wipe slots. + val meta = + signer.sign( + createdAt = 10, + kind = 0, + tags = emptyArray(), + content = "{}", + ) + store.insert(meta) + val slot = root.resolve("replaceable/0/${signer.pubKey}.json") + assertTrue(slot.exists()) - store.scrub() - assertTrue(slot.exists(), "replaceable slot must survive scrub") - } + store.scrub() + assertTrue(slot.exists(), "replaceable slot must survive scrub") + } // ------------------------------------------------------------------ // compact — drop dangling idx entries // ------------------------------------------------------------------ @Test - fun `compact drops idx entries whose canonical is gone`() { - val a = note("x", 10) - store.insert(a) + fun `compact drops idx entries whose canonical is gone`() = + runBlocking { + val a = note("x", 10) + store.insert(a) - // Externally delete the canonical without touching idx/. - val canonical = root.resolve("events/${a.id.substring(0, 2)}/${a.id.substring(2, 4)}/${a.id}.json") - assertTrue(Files.deleteIfExists(canonical)) + // Externally delete the canonical without touching idx/. + val canonical = root.resolve("events/${a.id.substring(0, 2)}/${a.id.substring(2, 4)}/${a.id}.json") + assertTrue(Files.deleteIfExists(canonical)) - val kindDir = root.resolve("idx/kind/1") - assertEquals(1, kindDir.listDirectoryEntries().size, "dangling entry still present pre-compact") + val kindDir = root.resolve("idx/kind/1") + assertEquals(1, kindDir.listDirectoryEntries().size, "dangling entry still present pre-compact") - store.compact() + store.compact() - assertEquals(0, kindDir.listDirectoryEntries().size, "dangling entry dropped post-compact") - } + assertEquals(0, kindDir.listDirectoryEntries().size, "dangling entry dropped post-compact") + } @Test - fun `compact leaves valid entries alone`() { - val a = note("x", 10) - store.insert(a) + fun `compact leaves valid entries alone`() = + runBlocking { + val a = note("x", 10) + store.insert(a) - store.compact() + store.compact() - val kindDir = root.resolve("idx/kind/1") - assertEquals(1, kindDir.listDirectoryEntries().size, "valid entry should not be touched") - assertEquals(listOf(a.id), store.query(Filter(ids = listOf(a.id))).map { it.id }) - } + val kindDir = root.resolve("idx/kind/1") + assertEquals(1, kindDir.listDirectoryEntries().size, "valid entry should not be touched") + assertEquals(listOf(a.id), store.query(Filter(ids = listOf(a.id))).map { it.id }) + } // ------------------------------------------------------------------ // close // ------------------------------------------------------------------ @Test - fun `close is idempotent`() { - store.close() - store.close() - } + fun `close is idempotent`() = + runBlocking { + store.close() + store.close() + } @Test - fun `reopen after close works`() { - val a = note("a", 1) - store.insert(a) - store.close() + fun `reopen after close works`() = + runBlocking { + val a = note("a", 1) + store.insert(a) + store.close() - val reopened = FsEventStore(root) - try { - assertEquals(listOf(a.id), reopened.query(Filter(ids = listOf(a.id))).map { it.id }) - } finally { - reopened.close() + val reopened = FsEventStore(root) + try { + assertEquals(listOf(a.id), reopened.query(Filter(ids = listOf(a.id))).map { it.id }) + } finally { + reopened.close() + } } - } // ------------------------------------------------------------------ // Concurrency — two writer threads serialise cleanly // ------------------------------------------------------------------ @Test - fun `concurrent inserts on two threads are both persisted`() { - val events = (1..20).map { note("n$it", it.toLong()) } - val half = events.size / 2 + fun `concurrent inserts on two threads are both persisted`() = + runBlocking { + val events = (1..20).map { note("n$it", it.toLong()) } + val half = events.size / 2 - val t1 = - Thread { - events.take(half).forEach { store.insert(it) } + // Two real threads via Dispatchers.IO so the in-process lock has to + // arbitrate. join via coroutineScope. + coroutineScope { + launch(Dispatchers.IO) { + events.take(half).forEach { store.insert(it) } + } + launch(Dispatchers.IO) { + events.drop(half).forEach { store.insert(it) } + } } - val t2 = - Thread { - events.drop(half).forEach { store.insert(it) } - } - t1.start() - t2.start() - t1.join() - t2.join() - val got = store.query(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet() - assertEquals(events.map { it.id }.toSet(), got) - } + val got = store.query(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet() + assertEquals(events.map { it.id }.toSet(), got) + } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt index 7b18122f2..286c89433 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -81,7 +82,7 @@ class FsParityTest { } /** Insert into both stores. Swallow SQLite rejections (we only care about the resulting state). */ - private fun insertBoth(event: Event) { + private suspend fun insertBoth(event: Event) { try { sqlite.insert(event) } catch (_: Throwable) { @@ -93,7 +94,7 @@ class FsParityTest { } /** Assert both stores return the same ids (as a set) for the given filter. */ - private fun assertParity( + private suspend fun assertParity( filter: Filter, message: String = "", ) { @@ -103,7 +104,7 @@ class FsParityTest { } /** Same, but expect a stable DESC-by-createdAt ordering. */ - private fun assertParityOrdered( + private suspend fun assertParityOrdered( filter: Filter, message: String = "", ) { @@ -135,350 +136,368 @@ class FsParityTest { // ------------------------------------------------------------------ @Test - fun `id lookup matches`() { - val n = note("hello", 10) - insertBoth(n) - assertParity(Filter(ids = listOf(n.id))) - } + fun `id lookup matches`() = + runBlocking { + val n = note("hello", 10) + insertBoth(n) + assertParity(Filter(ids = listOf(n.id))) + } @Test - fun `kind + author query matches`() { - val a = note("a", 1) - val b = note("b", 2) - val c = note("c", 3, s = otherSigner) - listOf(a, b, c).forEach(::insertBoth) + fun `kind + author query matches`() = + runBlocking { + val a = note("a", 1) + val b = note("b", 2) + val c = note("c", 3, s = otherSigner) + listOf(a, b, c).forEach { insertBoth(it) } - assertParityOrdered(Filter(kinds = listOf(1), authors = listOf(signer.pubKey))) - assertParityOrdered(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey))) - } + assertParityOrdered(Filter(kinds = listOf(1), authors = listOf(signer.pubKey))) + assertParityOrdered(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey))) + } @Test - fun `since until limit match`() { - repeat(10) { i -> insertBoth(note("n$i", i.toLong() + 1)) } - assertParityOrdered(Filter(authors = listOf(signer.pubKey), since = 4, until = 8)) - assertParityOrdered(Filter(authors = listOf(signer.pubKey), limit = 3)) - } + fun `since until limit match`() = + runBlocking { + repeat(10) { i -> insertBoth(note("n$i", i.toLong() + 1)) } + assertParityOrdered(Filter(authors = listOf(signer.pubKey), since = 4, until = 8)) + assertParityOrdered(Filter(authors = listOf(signer.pubKey), limit = 3)) + } // ------------------------------------------------------------------ // Tag indexing // ------------------------------------------------------------------ @Test - fun `single-letter tag queries match`() { - val tagged = - signer.sign( - createdAt = 5, - kind = 1, - tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")), - content = "x", - ) - val plain = note("plain", 6) - insertBoth(tagged) - insertBoth(plain) + fun `single-letter tag queries match`() = + runBlocking { + val tagged = + signer.sign( + createdAt = 5, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")), + content = "x", + ) + val plain = note("plain", 6) + insertBoth(tagged) + insertBoth(plain) - assertParity(Filter(tags = mapOf("t" to listOf("nostr")))) - assertParity(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin")))) - } + assertParity(Filter(tags = mapOf("t" to listOf("nostr")))) + assertParity(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin")))) + } // ------------------------------------------------------------------ // Replaceable / Addressable // ------------------------------------------------------------------ @Test - fun `replaceable newer wins parity`() { - val v1 = - signer.sign( - createdAt = 100, - kind = 0, - tags = emptyArray(), - content = "{\"name\":\"v1\"}", - ) - val v2 = - signer.sign( - createdAt = 200, - kind = 0, - tags = emptyArray(), - content = "{\"name\":\"v2\"}", - ) - insertBoth(v1) - insertBoth(v2) + fun `replaceable newer wins parity`() = + runBlocking { + val v1 = + signer.sign( + createdAt = 100, + kind = 0, + tags = emptyArray(), + content = "{\"name\":\"v1\"}", + ) + val v2 = + signer.sign( + createdAt = 200, + kind = 0, + tags = emptyArray(), + content = "{\"name\":\"v2\"}", + ) + insertBoth(v1) + insertBoth(v2) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0))) - assertParity(Filter(ids = listOf(v1.id))) - } + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0))) + assertParity(Filter(ids = listOf(v1.id))) + } @Test - fun `replaceable older rejected parity`() { - val newer = - signer.sign(createdAt = 200, kind = 0, tags = emptyArray(), content = "{\"name\":\"new\"}") - val older = - signer.sign(createdAt = 100, kind = 0, tags = emptyArray(), content = "{\"name\":\"old\"}") - insertBoth(newer) - insertBoth(older) + fun `replaceable older rejected parity`() = + runBlocking { + val newer = + signer.sign(createdAt = 200, kind = 0, tags = emptyArray(), content = "{\"name\":\"new\"}") + val older = + signer.sign(createdAt = 100, kind = 0, tags = emptyArray(), content = "{\"name\":\"old\"}") + insertBoth(newer) + insertBoth(older) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0))) - } + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0))) + } @Test - fun `addressable d-tag dedup parity`() { - val v1 = article("intro", "v1", 10) - val v2 = article("intro", "v2", 20) - val v3 = article("about", "bio", 15) - insertBoth(v1) - insertBoth(v2) - insertBoth(v3) + fun `addressable d-tag dedup parity`() = + runBlocking { + val v1 = article("intro", "v1", 10) + val v2 = article("intro", "v2", 20) + val v3 = article("about", "bio", 15) + insertBoth(v1) + insertBoth(v2) + insertBoth(v3) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) - } + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + } @Test - fun `replaceable same-createdAt lexical id tiebreaker parity`() { - // Two kind-0 events with identical createdAt produce different ids - // because their content differs. NIP-01 says the lexically smaller - // id wins on a tie. Both stores must agree, regardless of insertion - // order. - val a = - signer.sign( - createdAt = 100, - kind = 0, - tags = emptyArray(), - content = "{\"name\":\"a\"}", + fun `replaceable same-createdAt lexical id tiebreaker parity`() = + runBlocking { + // Two kind-0 events with identical createdAt produce different ids + // because their content differs. NIP-01 says the lexically smaller + // id wins on a tie. Both stores must agree, regardless of insertion + // order. + val a = + signer.sign( + createdAt = 100, + kind = 0, + tags = emptyArray(), + content = "{\"name\":\"a\"}", + ) + val b = + signer.sign( + createdAt = 100, + kind = 0, + tags = emptyArray(), + content = "{\"name\":\"b\"}", + ) + + insertBoth(a) + insertBoth(b) + + assertParity( + Filter(authors = listOf(signer.pubKey), kinds = listOf(0)), + "loser-then-winner: lexically smaller id should win", ) - val b = - signer.sign( - createdAt = 100, - kind = 0, - tags = emptyArray(), - content = "{\"name\":\"b\"}", + assertParity( + Filter(ids = listOf(a.id, b.id)), + "the loser must not survive in the by-id query", ) - - insertBoth(a) - insertBoth(b) - - assertParity( - Filter(authors = listOf(signer.pubKey), kinds = listOf(0)), - "loser-then-winner: lexically smaller id should win", - ) - assertParity( - Filter(ids = listOf(a.id, b.id)), - "the loser must not survive in the by-id query", - ) - } + } @Test - fun `addressable same-createdAt lexical id tiebreaker parity`() { - val a = article("tie", "version a", 100) - val b = article("tie", "version b", 100) + fun `addressable same-createdAt lexical id tiebreaker parity`() = + runBlocking { + val a = article("tie", "version a", 100) + val b = article("tie", "version b", 100) - insertBoth(a) - insertBoth(b) + insertBoth(a) + insertBoth(b) - assertParity( - Filter( - authors = listOf(signer.pubKey), - kinds = listOf(LongTextNoteEvent.KIND), - tags = mapOf("d" to listOf("tie")), - ), - ) - assertParity(Filter(ids = listOf(a.id, b.id))) - } + assertParity( + Filter( + authors = listOf(signer.pubKey), + kinds = listOf(LongTextNoteEvent.KIND), + tags = mapOf("d" to listOf("tie")), + ), + ) + assertParity(Filter(ids = listOf(a.id, b.id))) + } // ------------------------------------------------------------------ // Deletion (NIP-09) // ------------------------------------------------------------------ @Test - fun `deletion by id parity`() { - val a = note("a", 10) - val b = note("b", 20) - insertBoth(a) - insertBoth(b) + fun `deletion by id parity`() = + runBlocking { + val a = note("a", 10) + val b = note("b", 20) + insertBoth(a) + insertBoth(b) - val del = signer.sign(DeletionEvent.build(listOf(a), createdAt = 30)) - insertBoth(del) + val del = signer.sign(DeletionEvent.build(listOf(a), createdAt = 30)) + insertBoth(del) - assertParity(Filter(ids = listOf(a.id))) - assertParity(Filter(ids = listOf(b.id))) - assertParity(Filter(kinds = listOf(DeletionEvent.KIND))) + assertParity(Filter(ids = listOf(a.id))) + assertParity(Filter(ids = listOf(b.id))) + assertParity(Filter(kinds = listOf(DeletionEvent.KIND))) - // Re-insert blocked. - insertBoth(a) - assertParity(Filter(ids = listOf(a.id))) - } + // Re-insert blocked. + insertBoth(a) + assertParity(Filter(ids = listOf(a.id))) + } @Test - fun `deletion by address parity`() { - val v = article("intro", "v1", 10) - insertBoth(v) + fun `deletion by address parity`() = + runBlocking { + val v = article("intro", "v1", 10) + insertBoth(v) - val del = signer.sign(DeletionEvent.build(listOf(v), createdAt = 20)) - insertBoth(del) + val del = signer.sign(DeletionEvent.build(listOf(v), createdAt = 20)) + insertBoth(del) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) - // Older event at this address must be blocked, newer must pass. - insertBoth(article("intro", "older", 5)) - insertBoth(article("intro", "newer", 100)) + // Older event at this address must be blocked, newer must pass. + insertBoth(article("intro", "older", 5)) + insertBoth(article("intro", "newer", 100)) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) - } + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + } // ------------------------------------------------------------------ // Expiration (NIP-40) // ------------------------------------------------------------------ @Test - fun `expiration sweep parity`() { - // Build events with future-then-past expirations relative to now. - val now = - com.vitorpamplona.quartz.utils.TimeUtils - .now() - val expired = - signer.sign( - createdAt = now - 100, - kind = 1, - tags = arrayOf(arrayOf("expiration", (now - 50).toString())), - content = "old", - ) - val alive = - signer.sign( - createdAt = now - 100, - kind = 1, - tags = arrayOf(arrayOf("expiration", (now + 1_000_000).toString())), - content = "still here", - ) - insertBoth(expired) // both stores reject (already expired) - insertBoth(alive) + fun `expiration sweep parity`() = + runBlocking { + // Build events with future-then-past expirations relative to now. + val now = + com.vitorpamplona.quartz.utils.TimeUtils + .now() + val expired = + signer.sign( + createdAt = now - 100, + kind = 1, + tags = arrayOf(arrayOf("expiration", (now - 50).toString())), + content = "old", + ) + val alive = + signer.sign( + createdAt = now - 100, + kind = 1, + tags = arrayOf(arrayOf("expiration", (now + 1_000_000).toString())), + content = "still here", + ) + insertBoth(expired) // both stores reject (already expired) + insertBoth(alive) - assertParity(Filter(ids = listOf(expired.id))) - assertParity(Filter(ids = listOf(alive.id))) + assertParity(Filter(ids = listOf(expired.id))) + assertParity(Filter(ids = listOf(alive.id))) - // Sweep both; alive survives. - sqlite.deleteExpiredEvents() - fs.deleteExpiredEvents() - assertParity(Filter(ids = listOf(alive.id))) - } + // Sweep both; alive survives. + sqlite.deleteExpiredEvents() + fs.deleteExpiredEvents() + assertParity(Filter(ids = listOf(alive.id))) + } // ------------------------------------------------------------------ // Search (NIP-50) // ------------------------------------------------------------------ @Test - fun `search parity`() { - val a = note("hello bitcoin", 1) - val b = note("nostr only", 2) - val c = note("bitcoin and nostr", 3) - insertBoth(a) - insertBoth(b) - insertBoth(c) + fun `search parity`() = + runBlocking { + val a = note("hello bitcoin", 1) + val b = note("nostr only", 2) + val c = note("bitcoin and nostr", 3) + insertBoth(a) + insertBoth(b) + insertBoth(c) - // Tokenizers differ slightly between SQLite FTS5 unicode61 and - // our Kotlin port, so we stick to plain ASCII single-token queries - // where both should agree. - assertParity(Filter(search = "bitcoin")) - assertParity(Filter(search = "nostr")) - } + // Tokenizers differ slightly between SQLite FTS5 unicode61 and + // our Kotlin port, so we stick to plain ASCII single-token queries + // where both should agree. + assertParity(Filter(search = "bitcoin")) + assertParity(Filter(search = "nostr")) + } // ------------------------------------------------------------------ // Count // ------------------------------------------------------------------ @Test - fun `count parity across mixed stream`() { - listOf( - note("a", 1), - note("b", 2), - note("c", 3), - note("from-other", 4, s = otherSigner), - ).forEach(::insertBoth) + fun `count parity across mixed stream`() = + runBlocking { + listOf( + note("a", 1), + note("b", 2), + note("c", 3), + note("from-other", 4, s = otherSigner), + ).forEach { insertBoth(it) } - val filter = Filter(authors = listOf(signer.pubKey)) - assertEquals(sqlite.count(filter), fs.count(filter)) - } + val filter = Filter(authors = listOf(signer.pubKey)) + assertEquals(sqlite.count(filter), fs.count(filter)) + } // ------------------------------------------------------------------ // Mixed kitchen-sink scenario // ------------------------------------------------------------------ @Test - fun `kitchen sink scenario`() { - // Notes - val n1 = note("first", 1) - val n2 = note("second", 2) - // Replaceable - val meta1 = - signer.sign(createdAt = 10, kind = 0, tags = emptyArray(), content = "{\"name\":\"v1\"}") - val meta2 = - signer.sign(createdAt = 20, kind = 0, tags = emptyArray(), content = "{\"name\":\"v2\"}") - // Addressable - val artA = article("a", "A v1", 30) - val artB = article("b", "B v1", 30) - val artBv2 = article("b", "B v2", 50) - // Deletion of n1 - val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 40)) + fun `kitchen sink scenario`() = + runBlocking { + // Notes + val n1 = note("first", 1) + val n2 = note("second", 2) + // Replaceable + val meta1 = + signer.sign(createdAt = 10, kind = 0, tags = emptyArray(), content = "{\"name\":\"v1\"}") + val meta2 = + signer.sign(createdAt = 20, kind = 0, tags = emptyArray(), content = "{\"name\":\"v2\"}") + // Addressable + val artA = article("a", "A v1", 30) + val artB = article("b", "B v1", 30) + val artBv2 = article("b", "B v2", 50) + // Deletion of n1 + val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 40)) - listOf(n1, n2, meta1, meta2, artA, artB, artBv2, del).forEach(::insertBoth) + listOf(n1, n2, meta1, meta2, artA, artB, artBv2, del).forEach { insertBoth(it) } - // Snapshots that should match. - assertParity(Filter(ids = listOf(n1.id)), "n1 deleted") - assertParity(Filter(ids = listOf(n2.id)), "n2 alive") - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0)), "metadata winner") - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)), "articles set") - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(DeletionEvent.KIND)), "deletion present") - } + // Snapshots that should match. + assertParity(Filter(ids = listOf(n1.id)), "n1 deleted") + assertParity(Filter(ids = listOf(n2.id)), "n2 alive") + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0)), "metadata winner") + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)), "articles set") + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(DeletionEvent.KIND)), "deletion present") + } // ------------------------------------------------------------------ // Multi-filter union // ------------------------------------------------------------------ @Test - fun `multi-filter union parity`() { - val a = note("a", 1) - val b = note("b", 2, s = otherSigner) - insertBoth(a) - insertBoth(b) + fun `multi-filter union parity`() = + runBlocking { + val a = note("a", 1) + val b = note("b", 2, s = otherSigner) + insertBoth(a) + insertBoth(b) - val filters = - listOf( - Filter(authors = listOf(signer.pubKey)), - Filter(authors = listOf(otherSigner.pubKey)), + val filters = + listOf( + Filter(authors = listOf(signer.pubKey)), + Filter(authors = listOf(otherSigner.pubKey)), + ) + + assertEquals( + sqlite.query(filters).map { it.id }.toSet(), + fs.query(filters).map { it.id }.toSet(), ) - - assertEquals( - sqlite.query(filters).map { it.id }.toSet(), - fs.query(filters).map { it.id }.toSet(), - ) - } + } // ------------------------------------------------------------------ // Direct delete by filter // ------------------------------------------------------------------ @Test - fun `delete by filter parity`() { - val toKill = note("dead", 5) - val survivor = note("alive", 6) - insertBoth(toKill) - insertBoth(survivor) + fun `delete by filter parity`() = + runBlocking { + val toKill = note("dead", 5) + val survivor = note("alive", 6) + insertBoth(toKill) + insertBoth(survivor) - sqlite.delete(Filter(ids = listOf(toKill.id))) - fs.delete(Filter(ids = listOf(toKill.id))) + sqlite.delete(Filter(ids = listOf(toKill.id))) + fs.delete(Filter(ids = listOf(toKill.id))) - assertParity(Filter(authors = listOf(signer.pubKey))) - } + assertParity(Filter(authors = listOf(signer.pubKey))) + } // ------------------------------------------------------------------ // Helper: ensure SQLite store really does what we think // ------------------------------------------------------------------ @Test - fun `helper sanity - empty stores agree`() { - assertParity(Filter(authors = listOf(signer.pubKey))) - assertParity(Filter(kinds = listOf(1))) - } + fun `helper sanity - empty stores agree`() = + runBlocking { + assertParity(Filter(authors = listOf(signer.pubKey))) + assertParity(Filter(kinds = listOf(1))) + } @Suppress("unused") - private fun debugDump(label: String): String { + private suspend fun debugDump(label: String): String { val sqIds = sqlite .query(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey))) diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsQueryTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsQueryTest.kt index 42989502a..3d051fd63 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsQueryTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsQueryTest.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -71,351 +72,372 @@ class FsQueryTest { // ------------------------------------------------------------------ @Test - fun `results ordered by created_at DESC`() { - val a = signA("a", 1) - val b = signA("b", 3) - val c = signA("c", 2) - listOf(a, b, c).forEach(store::insert) + fun `results ordered by created_at DESC`() = + runBlocking { + val a = signA("a", 1) + val b = signA("b", 3) + val c = signA("c", 2) + listOf(a, b, c).forEach { store.insert(it) } - val got = store.query(Filter(authors = listOf(signerA.pubKey))) - assertEquals(listOf(b.id, c.id, a.id), got.map { it.id }) - } + val got = store.query(Filter(authors = listOf(signerA.pubKey))) + assertEquals(listOf(b.id, c.id, a.id), got.map { it.id }) + } @Test - fun `limit caps the result count`() { - repeat(5) { i -> store.insert(signA("n$i", i.toLong() + 1)) } - val got = store.query(Filter(authors = listOf(signerA.pubKey), limit = 2)) - assertEquals(2, got.size) - // Highest timestamps come first. - assertEquals("n4", got[0].content) - assertEquals("n3", got[1].content) - } + fun `limit caps the result count`() = + runBlocking { + repeat(5) { i -> store.insert(signA("n$i", i.toLong() + 1)) } + val got = store.query(Filter(authors = listOf(signerA.pubKey), limit = 2)) + assertEquals(2, got.size) + // Highest timestamps come first. + assertEquals("n4", got[0].content) + assertEquals("n3", got[1].content) + } @Test - fun `limit of zero returns empty`() { - store.insert(signA("x", 1)) - assertEquals(emptyList(), store.query(Filter(authors = listOf(signerA.pubKey), limit = 0))) - } + fun `limit of zero returns empty`() = + runBlocking { + store.insert(signA("x", 1)) + assertEquals(emptyList(), store.query(Filter(authors = listOf(signerA.pubKey), limit = 0))) + } // ------------------------------------------------------------------ // Author / kind drivers // ------------------------------------------------------------------ @Test - fun `author filter isolates one user`() { - val a = signA("from-a", 1) - val b = signB("from-b", 2) - store.insert(a) - store.insert(b) + fun `author filter isolates one user`() = + runBlocking { + val a = signA("from-a", 1) + val b = signB("from-b", 2) + store.insert(a) + store.insert(b) - val got = store.query(Filter(authors = listOf(signerA.pubKey))) - assertEquals(listOf(a.id), got.map { it.id }) - } + val got = store.query(Filter(authors = listOf(signerA.pubKey))) + assertEquals(listOf(a.id), got.map { it.id }) + } @Test - fun `author filter with multiple authors unions them`() { - val a = signA("a", 1) - val b = signB("b", 2) - store.insert(a) - store.insert(b) + fun `author filter with multiple authors unions them`() = + runBlocking { + val a = signA("a", 1) + val b = signB("b", 2) + store.insert(a) + store.insert(b) - val got = store.query(Filter(authors = listOf(signerA.pubKey, signerB.pubKey))) - assertEquals(setOf(a.id, b.id), got.map { it.id }.toSet()) - } + val got = store.query(Filter(authors = listOf(signerA.pubKey, signerB.pubKey))) + assertEquals(setOf(a.id, b.id), got.map { it.id }.toSet()) + } @Test - fun `kind filter returns only the requested kinds`() { - // Build two events of different kinds. - val note = signA("note", 1) - val ephemeralKinds = signerA.sign(createdAt = 2, kind = 30023, tags = arrayOf(arrayOf("d", "slug")), content = "article") - store.insert(note) - store.insert(ephemeralKinds) + fun `kind filter returns only the requested kinds`() = + runBlocking { + // Build two events of different kinds. + val note = signA("note", 1) + val ephemeralKinds = signerA.sign(createdAt = 2, kind = 30023, tags = arrayOf(arrayOf("d", "slug")), content = "article") + store.insert(note) + store.insert(ephemeralKinds) - val onlyNotes = store.query(Filter(kinds = listOf(1))) - assertEquals(listOf(note.id), onlyNotes.map { it.id }) + val onlyNotes = store.query(Filter(kinds = listOf(1))) + assertEquals(listOf(note.id), onlyNotes.map { it.id }) - val onlyArticles = store.query(Filter(kinds = listOf(30023))) - assertEquals(listOf(ephemeralKinds.id), onlyArticles.map { it.id }) - } + val onlyArticles = store.query(Filter(kinds = listOf(30023))) + assertEquals(listOf(ephemeralKinds.id), onlyArticles.map { it.id }) + } @Test - fun `kind + author intersect via post-filter`() { - val a = signA("a", 1) - val b = signB("b", 2) - store.insert(a) - store.insert(b) + fun `kind + author intersect via post-filter`() = + runBlocking { + val a = signA("a", 1) + val b = signB("b", 2) + store.insert(a) + store.insert(b) - val got = store.query(Filter(kinds = listOf(1), authors = listOf(signerA.pubKey))) - assertEquals(listOf(a.id), got.map { it.id }) - } + val got = store.query(Filter(kinds = listOf(1), authors = listOf(signerA.pubKey))) + assertEquals(listOf(a.id), got.map { it.id }) + } // ------------------------------------------------------------------ // Tag driver // ------------------------------------------------------------------ @Test - fun `tag filter matches single-letter tags`() { - val tagged = - signerA.sign( - createdAt = 10, - kind = 1, - tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")), - content = "tagged", - ) - val untagged = signA("plain", 5) - store.insert(tagged) - store.insert(untagged) + fun `tag filter matches single-letter tags`() = + runBlocking { + val tagged = + signerA.sign( + createdAt = 10, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")), + content = "tagged", + ) + val untagged = signA("plain", 5) + store.insert(tagged) + store.insert(untagged) - val got = store.query(Filter(tags = mapOf("t" to listOf("nostr")))) - assertEquals(listOf(tagged.id), got.map { it.id }) - } + val got = store.query(Filter(tags = mapOf("t" to listOf("nostr")))) + assertEquals(listOf(tagged.id), got.map { it.id }) + } @Test - fun `tag OR within key returns union`() { - val t1 = signerA.sign(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "n") - val t2 = signerA.sign(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "bitcoin")), content = "b") - val t3 = signerA.sign(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("t", "other")), content = "o") - listOf(t1, t2, t3).forEach(store::insert) + fun `tag OR within key returns union`() = + runBlocking { + val t1 = signerA.sign(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "n") + val t2 = signerA.sign(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "bitcoin")), content = "b") + val t3 = signerA.sign(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("t", "other")), content = "o") + listOf(t1, t2, t3).forEach { store.insert(it) } - val got = store.query(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin")))) - assertEquals(setOf(t1.id, t2.id), got.map { it.id }.toSet()) - } + val got = store.query(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin")))) + assertEquals(setOf(t1.id, t2.id), got.map { it.id }.toSet()) + } @Test - fun `tagsAll across keys requires all matches`() { - val both = - signerA.sign( - createdAt = 1, - kind = 1, - tags = arrayOf(arrayOf("t", "nostr"), arrayOf("e", "a".repeat(64))), - content = "both", - ) - val onlyT = signerA.sign(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "t-only") - val onlyE = signerA.sign(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("e", "a".repeat(64))), content = "e-only") - listOf(both, onlyT, onlyE).forEach(store::insert) + fun `tagsAll across keys requires all matches`() = + runBlocking { + val both = + signerA.sign( + createdAt = 1, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr"), arrayOf("e", "a".repeat(64))), + content = "both", + ) + val onlyT = signerA.sign(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "t-only") + val onlyE = signerA.sign(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("e", "a".repeat(64))), content = "e-only") + listOf(both, onlyT, onlyE).forEach { store.insert(it) } - val got = - store.query( - Filter( - tagsAll = mapOf("t" to listOf("nostr"), "e" to listOf("a".repeat(64))), - ), - ) - assertEquals(listOf(both.id), got.map { it.id }) - } + val got = + store.query( + Filter( + tagsAll = mapOf("t" to listOf("nostr"), "e" to listOf("a".repeat(64))), + ), + ) + assertEquals(listOf(both.id), got.map { it.id }) + } // ------------------------------------------------------------------ // Tag-value directory naming: raw when fs-safe, _h_ otherwise. // ------------------------------------------------------------------ @Test - fun `safe ASCII tag values get raw directory names`() { - val e = - signerA.sign( - createdAt = 10, - kind = 1, - tags = arrayOf(arrayOf("t", "nostr")), - content = "x", - ) - store.insert(e) - // The raw value is the directory name — directly inspectable. - val rawDir = root.resolve("idx/tag/t/nostr") - assertTrue(rawDir.exists(), "ASCII-safe tag should land in idx/tag/t/nostr/") - assertEquals(1, rawDir.listDirectoryEntries().size) - } + fun `safe ASCII tag values get raw directory names`() = + runBlocking { + val e = + signerA.sign( + createdAt = 10, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr")), + content = "x", + ) + store.insert(e) + // The raw value is the directory name — directly inspectable. + val rawDir = root.resolve("idx/tag/t/nostr") + assertTrue(rawDir.exists(), "ASCII-safe tag should land in idx/tag/t/nostr/") + assertEquals(1, rawDir.listDirectoryEntries().size) + } @Test - fun `pubkey p-tag uses raw 64-hex directory name`() { - // The motivating case: notifications. p-tags pointing at a - // pubkey land under idx/tag/p// — no hash, directly - // ls-able. - val target = signerB.pubKey - val e = - signerA.sign( - createdAt = 5, - kind = 1, - tags = arrayOf(arrayOf("p", target)), - content = "@you", - ) - store.insert(e) - val pDir = root.resolve("idx/tag/p/$target") - assertTrue(pDir.exists(), "p-tag pubkey should be ls-able directly: idx/tag/p/$target/") - assertEquals(1, pDir.listDirectoryEntries().size) - } + fun `pubkey p-tag uses raw 64-hex directory name`() = + runBlocking { + // The motivating case: notifications. p-tags pointing at a + // pubkey land under idx/tag/p// — no hash, directly + // ls-able. + val target = signerB.pubKey + val e = + signerA.sign( + createdAt = 5, + kind = 1, + tags = arrayOf(arrayOf("p", target)), + content = "@you", + ) + store.insert(e) + val pDir = root.resolve("idx/tag/p/$target") + assertTrue(pDir.exists(), "p-tag pubkey should be ls-able directly: idx/tag/p/$target/") + assertEquals(1, pDir.listDirectoryEntries().size) + } @Test - fun `tag value with emoji falls back to hashed directory name`() { - val e = - signerA.sign( - createdAt = 10, - kind = 1, - tags = arrayOf(arrayOf("t", "🔥")), - content = "x", - ) - store.insert(e) - // Exactly one entry under t/ and it must be in the _h_ hash - // bucket — emoji is not fs-safe. - val tDir = root.resolve("idx/tag/t") - val entries = tDir.listDirectoryEntries().map { it.fileName.toString() } - assertEquals(1, entries.size, "expected one bucket dir, got: $entries") - assertTrue(entries.single().startsWith("_h_"), "emoji tag must hash; got '${entries.single()}'") - } + fun `tag value with emoji falls back to hashed directory name`() = + runBlocking { + val e = + signerA.sign( + createdAt = 10, + kind = 1, + tags = arrayOf(arrayOf("t", "🔥")), + content = "x", + ) + store.insert(e) + // Exactly one entry under t/ and it must be in the _h_ hash + // bucket — emoji is not fs-safe. + val tDir = root.resolve("idx/tag/t") + val entries = tDir.listDirectoryEntries().map { it.fileName.toString() } + assertEquals(1, entries.size, "expected one bucket dir, got: $entries") + assertTrue(entries.single().startsWith("_h_"), "emoji tag must hash; got '${entries.single()}'") + } @Test - fun `tag value containing a slash falls back to hashed directory name`() { - val e = - signerA.sign( - createdAt = 10, - kind = 1, - tags = arrayOf(arrayOf("r", "https://example.com/page")), - content = "x", - ) - store.insert(e) - val rDir = root.resolve("idx/tag/r") - val entries = rDir.listDirectoryEntries().map { it.fileName.toString() } - assertEquals(1, entries.size, "expected one bucket dir, got: $entries") - assertTrue(entries.single().startsWith("_h_"), "URL tag must hash; got '${entries.single()}'") - } + fun `tag value containing a slash falls back to hashed directory name`() = + runBlocking { + val e = + signerA.sign( + createdAt = 10, + kind = 1, + tags = arrayOf(arrayOf("r", "https://example.com/page")), + content = "x", + ) + store.insert(e) + val rDir = root.resolve("idx/tag/r") + val entries = rDir.listDirectoryEntries().map { it.fileName.toString() } + assertEquals(1, entries.size, "expected one bucket dir, got: $entries") + assertTrue(entries.single().startsWith("_h_"), "URL tag must hash; got '${entries.single()}'") + } @Test - fun `query round-trips for both raw and hashed values`() { - // Each query must use the same naming rule as the writer or it - // walks a directory that doesn't exist. Insert both a raw-safe - // and a hash-required tag and verify they're both findable. - val safe = - signerA.sign( - createdAt = 1, - kind = 1, - tags = arrayOf(arrayOf("t", "nostr")), - content = "safe", + fun `query round-trips for both raw and hashed values`() = + runBlocking { + // Each query must use the same naming rule as the writer or it + // walks a directory that doesn't exist. Insert both a raw-safe + // and a hash-required tag and verify they're both findable. + val safe = + signerA.sign( + createdAt = 1, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr")), + content = "safe", + ) + val unsafe = + signerA.sign( + createdAt = 2, + kind = 1, + tags = arrayOf(arrayOf("t", "🔥")), + content = "unsafe", + ) + store.insert(safe) + store.insert(unsafe) + assertEquals( + listOf(safe.id), + store.query(Filter(tags = mapOf("t" to listOf("nostr")))).map { it.id }, ) - val unsafe = - signerA.sign( - createdAt = 2, - kind = 1, - tags = arrayOf(arrayOf("t", "🔥")), - content = "unsafe", + assertEquals( + listOf(unsafe.id), + store.query(Filter(tags = mapOf("t" to listOf("🔥")))).map { it.id }, ) - store.insert(safe) - store.insert(unsafe) - assertEquals( - listOf(safe.id), - store.query(Filter(tags = mapOf("t" to listOf("nostr")))).map { it.id }, - ) - assertEquals( - listOf(unsafe.id), - store.query(Filter(tags = mapOf("t" to listOf("🔥")))).map { it.id }, - ) - } + } @Test - fun `non-single-letter tags are not reverse-indexed`() { - // SQLite parity: DefaultIndexingStrategy only indexes single-letter - // tag names, so a tag-driven query for `mytag = foo` finds no - // candidates. The event is still persisted and can be fetched via - // id / author / kind — just not via a reverse tag lookup. - val e = - signerA.sign( - createdAt = 1, - kind = 1, - tags = arrayOf(arrayOf("mytag", "foo")), - content = "x", - ) - store.insert(e) + fun `non-single-letter tags are not reverse-indexed`() = + runBlocking { + // SQLite parity: DefaultIndexingStrategy only indexes single-letter + // tag names, so a tag-driven query for `mytag = foo` finds no + // candidates. The event is still persisted and can be fetched via + // id / author / kind — just not via a reverse tag lookup. + val e = + signerA.sign( + createdAt = 1, + kind = 1, + tags = arrayOf(arrayOf("mytag", "foo")), + content = "x", + ) + store.insert(e) - assertEquals(emptyList(), store.query(Filter(tags = mapOf("mytag" to listOf("foo")))).map { it.id }) - assertEquals(listOf(e.id), store.query(Filter(authors = listOf(signerA.pubKey))).map { it.id }) - assertEquals(listOf(e.id), store.query(Filter(ids = listOf(e.id))).map { it.id }) - } + assertEquals(emptyList(), store.query(Filter(tags = mapOf("mytag" to listOf("foo")))).map { it.id }) + assertEquals(listOf(e.id), store.query(Filter(authors = listOf(signerA.pubKey))).map { it.id }) + assertEquals(listOf(e.id), store.query(Filter(ids = listOf(e.id))).map { it.id }) + } // ------------------------------------------------------------------ // since / until // ------------------------------------------------------------------ @Test - fun `since and until window filter`() { - val e1 = signA("t1", 100) - val e2 = signA("t2", 200) - val e3 = signA("t3", 300) - listOf(e1, e2, e3).forEach(store::insert) + fun `since and until window filter`() = + runBlocking { + val e1 = signA("t1", 100) + val e2 = signA("t2", 200) + val e3 = signA("t3", 300) + listOf(e1, e2, e3).forEach { store.insert(it) } - val got = store.query(Filter(since = 150, until = 250)) - assertEquals(listOf(e2.id), got.map { it.id }) - } + val got = store.query(Filter(since = 150, until = 250)) + assertEquals(listOf(e2.id), got.map { it.id }) + } // ------------------------------------------------------------------ // count // ------------------------------------------------------------------ @Test - fun `count matches query size`() { - repeat(4) { i -> store.insert(signA("n$i", i.toLong() + 1)) } - val filter = Filter(authors = listOf(signerA.pubKey)) - assertEquals(store.query(filter).size, store.count(filter)) - } + fun `count matches query size`() = + runBlocking { + repeat(4) { i -> store.insert(signA("n$i", i.toLong() + 1)) } + val filter = Filter(authors = listOf(signerA.pubKey)) + assertEquals(store.query(filter).size, store.count(filter)) + } // ------------------------------------------------------------------ // Index hardlink maintenance // ------------------------------------------------------------------ @Test - fun `insert creates hardlinks in every expected index dir`() { - val tagged = - signerA.sign( - createdAt = 42, - kind = 1, - tags = arrayOf(arrayOf("t", "nostr")), - content = "x", - ) - store.insert(tagged) + fun `insert creates hardlinks in every expected index dir`() = + runBlocking { + val tagged = + signerA.sign( + createdAt = 42, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr")), + content = "x", + ) + store.insert(tagged) - val kindDir = root.resolve("idx/kind/1") - val authorDir = root.resolve("idx/author/${signerA.pubKey}") - assertTrue(kindDir.exists() && kindDir.listDirectoryEntries().size == 1, "kind index missing") - assertTrue(authorDir.exists() && authorDir.listDirectoryEntries().size == 1, "author index missing") - val tagNameDir = root.resolve("idx/tag/t") - assertTrue(tagNameDir.exists(), "tag 't' dir missing") - val tagValueDirs = tagNameDir.listDirectoryEntries() - assertEquals(1, tagValueDirs.size, "exactly one tag-value subdir expected") - assertEquals(1, tagValueDirs[0].listDirectoryEntries().size, "tag-value dir should contain one entry") - } + val kindDir = root.resolve("idx/kind/1") + val authorDir = root.resolve("idx/author/${signerA.pubKey}") + assertTrue(kindDir.exists() && kindDir.listDirectoryEntries().size == 1, "kind index missing") + assertTrue(authorDir.exists() && authorDir.listDirectoryEntries().size == 1, "author index missing") + val tagNameDir = root.resolve("idx/tag/t") + assertTrue(tagNameDir.exists(), "tag 't' dir missing") + val tagValueDirs = tagNameDir.listDirectoryEntries() + assertEquals(1, tagValueDirs.size, "exactly one tag-value subdir expected") + assertEquals(1, tagValueDirs[0].listDirectoryEntries().size, "tag-value dir should contain one entry") + } @Test - fun `delete removes hardlinks so directories become empty`() { - val e = signerA.sign(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x") - store.insert(e) - store.delete(e.id) + fun `delete removes hardlinks so directories become empty`() = + runBlocking { + val e = signerA.sign(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x") + store.insert(e) + store.delete(e.id) - val kindDir = root.resolve("idx/kind/1") - val authorDir = root.resolve("idx/author/${signerA.pubKey}") - val tagValueDirs = - root - .resolve("idx/tag/t") - .takeIf { it.exists() } - ?.listDirectoryEntries() - .orEmpty() + val kindDir = root.resolve("idx/kind/1") + val authorDir = root.resolve("idx/author/${signerA.pubKey}") + val tagValueDirs = + root + .resolve("idx/tag/t") + .takeIf { it.exists() } + ?.listDirectoryEntries() + .orEmpty() - // Directories may remain as empty husks — what matters is the entries are gone. - if (kindDir.exists()) assertEquals(0, kindDir.listDirectoryEntries().size, "kind entry leaked") - if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size, "author entry leaked") - tagValueDirs.forEach { assertEquals(0, it.listDirectoryEntries().size, "tag entry leaked") } - } + // Directories may remain as empty husks — what matters is the entries are gone. + if (kindDir.exists()) assertEquals(0, kindDir.listDirectoryEntries().size, "kind entry leaked") + if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size, "author entry leaked") + tagValueDirs.forEach { assertEquals(0, it.listDirectoryEntries().size, "tag entry leaked") } + } // ------------------------------------------------------------------ // Seed persistence across reopen // ------------------------------------------------------------------ @Test - fun `reopening the store preserves queryability`() { - val tagged = signerA.sign(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x") - store.insert(tagged) - store.close() + fun `reopening the store preserves queryability`() = + runBlocking { + val tagged = signerA.sign(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x") + store.insert(tagged) + store.close() - val reopened = FsEventStore(root) - try { - val got = reopened.query(Filter(tags = mapOf("t" to listOf("nostr")))) - assertEquals(listOf(tagged.id), got.map { it.id }) - } finally { - reopened.close() + val reopened = FsEventStore(root) + try { + val got = reopened.query(Filter(tags = mapOf("t" to listOf("nostr")))) + assertEquals(listOf(tagged.id), got.map { it.id }) + } finally { + reopened.close() + } } - } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt index 201efaad3..3678d795c 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -66,193 +67,209 @@ class FsSearchTest { // ------------------------------------------------------------------ @Test - fun `tokenizer splits on whitespace and punctuation`() { - assertEquals(setOf("hello", "world"), FsSearchTokenizer.tokenize("hello, world!")) - } + fun `tokenizer splits on whitespace and punctuation`() = + runBlocking { + assertEquals(setOf("hello", "world"), FsSearchTokenizer.tokenize("hello, world!")) + } @Test - fun `tokenizer is case insensitive`() { - assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("BITCOIN")) - assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("bitcoin")) - } + fun `tokenizer is case insensitive`() = + runBlocking { + assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("BITCOIN")) + assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("bitcoin")) + } @Test - fun `tokenizer handles empty and punctuation-only strings`() { - assertEquals(emptySet(), FsSearchTokenizer.tokenize("")) - assertEquals(emptySet(), FsSearchTokenizer.tokenize("...")) - assertEquals(emptySet(), FsSearchTokenizer.tokenize(" ")) - } + fun `tokenizer handles empty and punctuation-only strings`() = + runBlocking { + assertEquals(emptySet(), FsSearchTokenizer.tokenize("")) + assertEquals(emptySet(), FsSearchTokenizer.tokenize("...")) + assertEquals(emptySet(), FsSearchTokenizer.tokenize(" ")) + } @Test - fun `tokenizer keeps unicode letters`() { - assertEquals(setOf("café", "über"), FsSearchTokenizer.tokenize("Café Über")) - } + fun `tokenizer keeps unicode letters`() = + runBlocking { + assertEquals(setOf("café", "über"), FsSearchTokenizer.tokenize("Café Über")) + } // ------------------------------------------------------------------ // Index maintenance // ------------------------------------------------------------------ @Test - fun `searchable event creates one fts entry per unique token`() { - val n = note("bitcoin nostr bitcoin", ts = 100) - store.insert(n) + fun `searchable event creates one fts entry per unique token`() = + runBlocking { + val n = note("bitcoin nostr bitcoin", ts = 100) + store.insert(n) - val ftsRoot = root.resolve("idx/fts") - val tokenDirs = ftsRoot.listDirectoryEntries().map { it.fileName.toString() }.toSet() - // TextNoteEvent.indexableContent() prepends a "Subject: " prefix so - // we get the content tokens plus the subject ones. What matters is - // that each unique token yields exactly one entry under its dir. - assertTrue("bitcoin" in tokenDirs) - assertTrue("nostr" in tokenDirs) - assertEquals(1, ftsRoot.resolve("bitcoin").listDirectoryEntries().size) - assertEquals(1, ftsRoot.resolve("nostr").listDirectoryEntries().size) - } - - @Test - fun `non-searchable event does not produce fts entries`() { - val meta = - signer.sign( - createdAt = 1, - kind = MetadataEvent.KIND, - tags = emptyArray(), - content = "{\"name\":\"vitor\"}", - ) - store.insert(meta) - - val ftsRoot = root.resolve("idx/fts") - assertEquals(0, ftsRoot.listDirectoryEntries().size, "MetadataEvent is not SearchableEvent") - } - - @Test - fun `delete removes fts entries`() { - val n = note("bitcoin nostr", ts = 100) - store.insert(n) - store.delete(n.id) - - val ftsRoot = root.resolve("idx/fts") - // Token directories may remain as empty husks. - for (tokenDir in ftsRoot.listDirectoryEntries()) { - assertEquals(0, tokenDir.listDirectoryEntries().size, "token entry leaked: $tokenDir") + val ftsRoot = root.resolve("idx/fts") + val tokenDirs = ftsRoot.listDirectoryEntries().map { it.fileName.toString() }.toSet() + // TextNoteEvent.indexableContent() prepends a "Subject: " prefix so + // we get the content tokens plus the subject ones. What matters is + // that each unique token yields exactly one entry under its dir. + assertTrue("bitcoin" in tokenDirs) + assertTrue("nostr" in tokenDirs) + assertEquals(1, ftsRoot.resolve("bitcoin").listDirectoryEntries().size) + assertEquals(1, ftsRoot.resolve("nostr").listDirectoryEntries().size) + } + + @Test + fun `non-searchable event does not produce fts entries`() = + runBlocking { + val meta = + signer.sign( + createdAt = 1, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = "{\"name\":\"vitor\"}", + ) + store.insert(meta) + + val ftsRoot = root.resolve("idx/fts") + assertEquals(0, ftsRoot.listDirectoryEntries().size, "MetadataEvent is not SearchableEvent") + } + + @Test + fun `delete removes fts entries`() = + runBlocking { + val n = note("bitcoin nostr", ts = 100) + store.insert(n) + store.delete(n.id) + + val ftsRoot = root.resolve("idx/fts") + // Token directories may remain as empty husks. + for (tokenDir in ftsRoot.listDirectoryEntries()) { + assertEquals(0, tokenDir.listDirectoryEntries().size, "token entry leaked: $tokenDir") + } } - } // ------------------------------------------------------------------ // Search query semantics // ------------------------------------------------------------------ @Test - fun `single-token search returns the matching event`() { - val a = note("bitcoin is fun", ts = 1) - val b = note("nostr is also fun", ts = 2) - store.insert(a) - store.insert(b) + fun `single-token search returns the matching event`() = + runBlocking { + val a = note("bitcoin is fun", ts = 1) + val b = note("nostr is also fun", ts = 2) + store.insert(a) + store.insert(b) - val got = store.query(Filter(search = "bitcoin")) - assertEquals(listOf(a.id), got.map { it.id }) - } - - @Test - fun `multi-token search is AND across tokens`() { - val a = note("bitcoin only", ts = 1) - val b = note("nostr only", ts = 2) - val c = note("bitcoin and nostr", ts = 3) - store.insert(a) - store.insert(b) - store.insert(c) - - val got = store.query(Filter(search = "bitcoin nostr")) - assertEquals(listOf(c.id), got.map { it.id }, "AND semantics: only the doc with both tokens matches") - } - - @Test - fun `search results are ordered by createdAt DESC`() { - val older = note("bitcoin first", ts = 10) - val newer = note("bitcoin again", ts = 20) - store.insert(older) - store.insert(newer) - - val got = store.query(Filter(search = "bitcoin")) - assertEquals(listOf(newer.id, older.id), got.map { it.id }) - } - - @Test - fun `search respects limit`() { - repeat(5) { i -> store.insert(note("bitcoin doc $i", ts = i.toLong() + 1)) } - val got = store.query(Filter(search = "bitcoin", limit = 2)) - assertEquals(2, got.size) - } - - @Test - fun `search composes with kinds and authors via post-filter`() { - val match = note("bitcoin maximalism", ts = 5) - store.insert(match) - - val got = - store.query( - Filter(search = "bitcoin", kinds = listOf(1), authors = listOf(signer.pubKey)), - ) - assertEquals(listOf(match.id), got.map { it.id }) - - val miss = - store.query( - Filter(search = "bitcoin", kinds = listOf(2)), - ) - assertEquals(emptyList(), miss.map { it.id }) - } - - @Test - fun `search with no matching token returns empty`() { - store.insert(note("nostr only", ts = 1)) - assertEquals( - emptyList(), - store.query(Filter(search = "bitcoin")).map { it.id }, - ) - } - - @Test - fun `blank search string is ignored`() { - val a = note("anything", ts = 1) - store.insert(a) - // Blank search shouldn't drive by FTS — the planner falls through - // to all-kinds, and the event surfaces. - val got = store.query(Filter(search = " ")) - assertEquals(listOf(a.id), got.map { it.id }) - } - - @Test - fun `search survives reopen`() { - val n = note("persistent token", ts = 100) - store.insert(n) - store.close() - - val reopened = FsEventStore(root) - try { - val got = reopened.query(Filter(search = "persistent")) - assertEquals(listOf(n.id), got.map { it.id }) - } finally { - reopened.close() + val got = store.query(Filter(search = "bitcoin")) + assertEquals(listOf(a.id), got.map { it.id }) + } + + @Test + fun `multi-token search is AND across tokens`() = + runBlocking { + val a = note("bitcoin only", ts = 1) + val b = note("nostr only", ts = 2) + val c = note("bitcoin and nostr", ts = 3) + store.insert(a) + store.insert(b) + store.insert(c) + + val got = store.query(Filter(search = "bitcoin nostr")) + assertEquals(listOf(c.id), got.map { it.id }, "AND semantics: only the doc with both tokens matches") + } + + @Test + fun `search results are ordered by createdAt DESC`() = + runBlocking { + val older = note("bitcoin first", ts = 10) + val newer = note("bitcoin again", ts = 20) + store.insert(older) + store.insert(newer) + + val got = store.query(Filter(search = "bitcoin")) + assertEquals(listOf(newer.id, older.id), got.map { it.id }) + } + + @Test + fun `search respects limit`() = + runBlocking { + repeat(5) { i -> store.insert(note("bitcoin doc $i", ts = i.toLong() + 1)) } + val got = store.query(Filter(search = "bitcoin", limit = 2)) + assertEquals(2, got.size) + } + + @Test + fun `search composes with kinds and authors via post-filter`() = + runBlocking { + val match = note("bitcoin maximalism", ts = 5) + store.insert(match) + + val got = + store.query( + Filter(search = "bitcoin", kinds = listOf(1), authors = listOf(signer.pubKey)), + ) + assertEquals(listOf(match.id), got.map { it.id }) + + val miss = + store.query( + Filter(search = "bitcoin", kinds = listOf(2)), + ) + assertEquals(emptyList(), miss.map { it.id }) + } + + @Test + fun `search with no matching token returns empty`() = + runBlocking { + store.insert(note("nostr only", ts = 1)) + assertEquals( + emptyList(), + store.query(Filter(search = "bitcoin")).map { it.id }, + ) + } + + @Test + fun `blank search string is ignored`() = + runBlocking { + val a = note("anything", ts = 1) + store.insert(a) + // Blank search shouldn't drive by FTS — the planner falls through + // to all-kinds, and the event surfaces. + val got = store.query(Filter(search = " ")) + assertEquals(listOf(a.id), got.map { it.id }) + } + + @Test + fun `search survives reopen`() = + runBlocking { + val n = note("persistent token", ts = 100) + store.insert(n) + store.close() + + val reopened = FsEventStore(root) + try { + val got = reopened.query(Filter(search = "persistent")) + assertEquals(listOf(n.id), got.map { it.id }) + } finally { + reopened.close() + } } - } // ------------------------------------------------------------------ // Maintenance under replaceable / deletion / vanish // ------------------------------------------------------------------ @Test - fun `fts entry is unlinked when event is deleted`() { - val n = note("unique-token-zzz", ts = 1) - store.insert(n) - assertTrue(root.resolve("idx/fts/unique").exists()) - assertTrue(root.resolve("idx/fts/token").exists()) - assertTrue(root.resolve("idx/fts/zzz").exists()) + fun `fts entry is unlinked when event is deleted`() = + runBlocking { + val n = note("unique-token-zzz", ts = 1) + store.insert(n) + assertTrue(root.resolve("idx/fts/unique").exists()) + assertTrue(root.resolve("idx/fts/token").exists()) + assertTrue(root.resolve("idx/fts/zzz").exists()) - store.delete(n.id) - assertFalse( - root.resolve("idx/fts/zzz").let { it.exists() && it.listDirectoryEntries().isNotEmpty() }, - "zzz token entry should be unlinked", - ) + store.delete(n.id) + assertFalse( + root.resolve("idx/fts/zzz").let { it.exists() && it.listDirectoryEntries().isNotEmpty() }, + "zzz token entry should be unlinked", + ) - // And a search no longer finds it. - assertEquals(emptyList(), store.query(Filter(search = "zzz")).map { it.id }) - } + // And a search no longer finds it. + assertEquals(emptyList(), store.query(Filter(search = "zzz")).map { it.id }) + } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSlotsTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSlotsTest.kt index fd8eedeb0..8712fe8ce 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSlotsTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSlotsTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -72,171 +73,181 @@ class FsSlotsTest { ) @Test - fun `newer replaceable evicts older`() { - val v1 = metadata("old", 100) - val v2 = metadata("new", 200) - store.insert(v1) - store.insert(v2) + fun `newer replaceable evicts older`() = + runBlocking { + val v1 = metadata("old", 100) + val v2 = metadata("new", 200) + store.insert(v1) + store.insert(v2) - // Only the newer survives a query by author. - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) - assertEquals(listOf(v2.id), got.map { it.id }) + // Only the newer survives a query by author. + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) + assertEquals(listOf(v2.id), got.map { it.id }) - // The older canonical is gone. - assertFalse(store.hasCanonical(v1.id), "older canonical should be removed") - } + // The older canonical is gone. + assertFalse(store.hasCanonical(v1.id), "older canonical should be removed") + } @Test - fun `older replaceable is rejected when newer exists`() { - val newer = metadata("new", 200) - val older = metadata("old", 100) - store.insert(newer) - store.insert(older) + fun `older replaceable is rejected when newer exists`() = + runBlocking { + val newer = metadata("new", 200) + val older = metadata("old", 100) + store.insert(newer) + store.insert(older) - // Newer still wins. - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) - assertEquals(listOf(newer.id), got.map { it.id }) + // Newer still wins. + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) + assertEquals(listOf(newer.id), got.map { it.id }) - // And the older was never persisted. - assertFalse(store.hasCanonical(older.id), "older should have been rejected") - } + // And the older was never persisted. + assertFalse(store.hasCanonical(older.id), "older should have been rejected") + } @Test - fun `equal timestamp replaceable resolves by lexical id (NIP-01)`() { - val a = metadata("a", 100) - val b = metadata("b", 100) - // NIP-01 tiebreaker: when createdAt ties, the lexically smaller - // id wins, regardless of insertion order. - val (winner, loser) = if (a.id < b.id) a to b else b to a + fun `equal timestamp replaceable resolves by lexical id (NIP-01)`() = + runBlocking { + val a = metadata("a", 100) + val b = metadata("b", 100) + // NIP-01 tiebreaker: when createdAt ties, the lexically smaller + // id wins, regardless of insertion order. + val (winner, loser) = if (a.id < b.id) a to b else b to a - // Loser inserted first, then winner — winner must replace. - store.insert(loser) - store.insert(winner) + // Loser inserted first, then winner — winner must replace. + store.insert(loser) + store.insert(winner) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) - assertEquals(1, got.size) - assertEquals(winner.id, got.single().id) - } + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) + assertEquals(1, got.size) + assertEquals(winner.id, got.single().id) + } @Test - fun `equal timestamp replaceable rejects higher id when winner already present`() { - val a = metadata("a", 100) - val b = metadata("b", 100) - val (winner, loser) = if (a.id < b.id) a to b else b to a + fun `equal timestamp replaceable rejects higher id when winner already present`() = + runBlocking { + val a = metadata("a", 100) + val b = metadata("b", 100) + val (winner, loser) = if (a.id < b.id) a to b else b to a - // Winner inserted first — loser must NOT take the slot. - store.insert(winner) - store.insert(loser) + // Winner inserted first — loser must NOT take the slot. + store.insert(winner) + store.insert(loser) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) - assertEquals(1, got.size) - assertEquals(winner.id, got.single().id) - } + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) + assertEquals(1, got.size) + assertEquals(winner.id, got.single().id) + } @Test - fun `replaceable slot file contains the current winner`() { - val v = metadata("only", 100) - store.insert(v) + fun `replaceable slot file contains the current winner`() = + runBlocking { + val v = metadata("only", 100) + store.insert(v) - val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") - assertTrue(slot.exists(), "slot must exist") - val parsed = Event.fromJson(slot.readText()) - assertEquals(v.id, parsed.id) - } + val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") + assertTrue(slot.exists(), "slot must exist") + val parsed = Event.fromJson(slot.readText()) + assertEquals(v.id, parsed.id) + } @Test - fun `replaceable slot survives canonical deletion via hardlink`() { - val v = metadata("x", 100) - store.insert(v) + fun `replaceable slot survives canonical deletion via hardlink`() = + runBlocking { + val v = metadata("x", 100) + store.insert(v) - // Simulate a user (or bug) removing the canonical file. - val canonical = - root - .resolve("events") - .resolve(v.id.substring(0, 2)) - .resolve(v.id.substring(2, 4)) - .resolve("${v.id}.json") - assertTrue(Files.deleteIfExists(canonical)) + // Simulate a user (or bug) removing the canonical file. + val canonical = + root + .resolve("events") + .resolve(v.id.substring(0, 2)) + .resolve(v.id.substring(2, 4)) + .resolve("${v.id}.json") + assertTrue(Files.deleteIfExists(canonical)) - val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") - assertTrue(slot.exists(), "slot should persist even if canonical is gone (hardlink to same inode)") - val parsed = Event.fromJson(slot.readText()) - assertEquals(v.id, parsed.id) - } + val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") + assertTrue(slot.exists(), "slot should persist even if canonical is gone (hardlink to same inode)") + val parsed = Event.fromJson(slot.readText()) + assertEquals(v.id, parsed.id) + } @Test - fun `eviction unlinks index hardlinks for the old winner`() { - val v1 = metadata("old", 100) - val v2 = metadata("new", 200) - store.insert(v1) - store.insert(v2) + fun `eviction unlinks index hardlinks for the old winner`() = + runBlocking { + val v1 = metadata("old", 100) + val v2 = metadata("new", 200) + store.insert(v1) + store.insert(v2) - // Author index should have exactly one entry — the winner. - val authorDir = root.resolve("idx/author/${signer.pubKey}") - val entries = - Files.list(authorDir).use { s -> - s.toList().map { it.fileName.toString() } - } - assertEquals(1, entries.size, "author index should only hold the winner") - assertTrue(entries.single().endsWith("-${v2.id}"), "author index entry must point at winner") - } - - @Test - fun `slot shortcut serves replaceable queries even when idx is wiped`() { - // Belt-and-suspenders for the planner shortcut: a query pinned to - // (kinds=[0], authors=[pk]) must hit the slot directly without - // touching idx/. Wipe idx/ to prove the shortcut isn't relying on - // it. - val v = metadata("p", 100) - store.insert(v) - java.nio.file.Files - .walk(root.resolve("idx")) - .use { s -> - s.sorted(Comparator.reverseOrder()).forEach { - java.nio.file.Files - .deleteIfExists(it) + // Author index should have exactly one entry — the winner. + val authorDir = root.resolve("idx/author/${signer.pubKey}") + val entries = + Files.list(authorDir).use { s -> + s.toList().map { it.fileName.toString() } } - } - - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) - assertEquals(listOf(v.id), got.map { it.id }, "slot shortcut should serve from replaceable/, not idx/") - } + assertEquals(1, entries.size, "author index should only hold the winner") + assertTrue(entries.single().endsWith("-${v2.id}"), "author index entry must point at winner") + } @Test - fun `slot shortcut serves addressable queries when d-tag supplied`() { - val v = article("intro", "v", 10) - store.insert(v) - java.nio.file.Files - .walk(root.resolve("idx")) - .use { s -> - s.sorted(Comparator.reverseOrder()).forEach { - java.nio.file.Files - .deleteIfExists(it) + fun `slot shortcut serves replaceable queries even when idx is wiped`() = + runBlocking { + // Belt-and-suspenders for the planner shortcut: a query pinned to + // (kinds=[0], authors=[pk]) must hit the slot directly without + // touching idx/. Wipe idx/ to prove the shortcut isn't relying on + // it. + val v = metadata("p", 100) + store.insert(v) + java.nio.file.Files + .walk(root.resolve("idx")) + .use { s -> + s.sorted(Comparator.reverseOrder()).forEach { + java.nio.file.Files + .deleteIfExists(it) + } } - } - val got = - store.query( - Filter( - authors = listOf(signer.pubKey), - kinds = listOf(LongTextNoteEvent.KIND), - tags = mapOf("d" to listOf("intro")), - ), - ) - assertEquals(listOf(v.id), got.map { it.id }) - } + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) + assertEquals(listOf(v.id), got.map { it.id }, "slot shortcut should serve from replaceable/, not idx/") + } @Test - fun `delete of current replaceable winner clears the slot`() { - val v = metadata("only", 100) - store.insert(v) + fun `slot shortcut serves addressable queries when d-tag supplied`() = + runBlocking { + val v = article("intro", "v", 10) + store.insert(v) + java.nio.file.Files + .walk(root.resolve("idx")) + .use { s -> + s.sorted(Comparator.reverseOrder()).forEach { + java.nio.file.Files + .deleteIfExists(it) + } + } - val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") - assertTrue(slot.exists()) + val got = + store.query( + Filter( + authors = listOf(signer.pubKey), + kinds = listOf(LongTextNoteEvent.KIND), + tags = mapOf("d" to listOf("intro")), + ), + ) + assertEquals(listOf(v.id), got.map { it.id }) + } - store.delete(v.id) - assertFalse(slot.exists(), "slot should be cleared when winner is deleted") - } + @Test + fun `delete of current replaceable winner clears the slot`() = + runBlocking { + val v = metadata("only", 100) + store.insert(v) + + val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") + assertTrue(slot.exists()) + + store.delete(v.id) + assertFalse(slot.exists(), "slot should be cleared when winner is deleted") + } // ------------------------------------------------------------------ // Addressable (kinds 30000-39999) @@ -255,100 +266,107 @@ class FsSlotsTest { ) @Test - fun `newer addressable evicts older for same d-tag`() { - val v1 = article("intro", "draft 1", 10) - val v2 = article("intro", "draft 2", 20) - store.insert(v1) - store.insert(v2) + fun `newer addressable evicts older for same d-tag`() = + runBlocking { + val v1 = article("intro", "draft 1", 10) + val v2 = article("intro", "draft 2", 20) + store.insert(v1) + store.insert(v2) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) - assertEquals(listOf(v2.id), got.map { it.id }) - assertFalse(store.hasCanonical(v1.id), "older draft canonical should be removed") - } + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + assertEquals(listOf(v2.id), got.map { it.id }) + assertFalse(store.hasCanonical(v1.id), "older draft canonical should be removed") + } @Test - fun `addressable with different d-tags coexist`() { - val intro = article("intro", "hello", 10) - val about = article("about", "bio", 15) - store.insert(intro) - store.insert(about) + fun `addressable with different d-tags coexist`() = + runBlocking { + val intro = article("intro", "hello", 10) + val about = article("about", "bio", 15) + store.insert(intro) + store.insert(about) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) - assertEquals(setOf(intro.id, about.id), got.map { it.id }.toSet()) - } + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + assertEquals(setOf(intro.id, about.id), got.map { it.id }.toSet()) + } @Test - fun `older addressable is rejected when newer exists`() { - val newer = article("slug", "new", 200) - val older = article("slug", "old", 100) - store.insert(newer) - store.insert(older) + fun `older addressable is rejected when newer exists`() = + runBlocking { + val newer = article("slug", "new", 200) + val older = article("slug", "old", 100) + store.insert(newer) + store.insert(older) - val got = store.query(Filter(authors = listOf(signer.pubKey))) - assertEquals(listOf(newer.id), got.map { it.id }) - } + val got = store.query(Filter(authors = listOf(signer.pubKey))) + assertEquals(listOf(newer.id), got.map { it.id }) + } @Test - fun `addressable slot file contains the current winner`() { - val v = article("intro", "hello", 10) - store.insert(v) + fun `addressable slot file contains the current winner`() = + runBlocking { + val v = article("intro", "hello", 10) + store.insert(v) - val dHash = FsLayout.sha256Hex("intro") - val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") - assertTrue(slot.exists()) - val parsed = Event.fromJson(slot.readText()) - assertEquals(v.id, parsed.id) - } + val dHash = FsLayout.sha256Hex("intro") + val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") + assertTrue(slot.exists()) + val parsed = Event.fromJson(slot.readText()) + assertEquals(v.id, parsed.id) + } @Test - fun `empty d-tag gets its own slot`() { - val v = article("", "homepage", 1) - store.insert(v) + fun `empty d-tag gets its own slot`() = + runBlocking { + val v = article("", "homepage", 1) + store.insert(v) - val dHash = FsLayout.sha256Hex("") - val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") - assertTrue(slot.exists()) - } + val dHash = FsLayout.sha256Hex("") + val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") + assertTrue(slot.exists()) + } @Test - fun `delete of current addressable winner clears the slot`() { - val v = article("intro", "hello", 10) - store.insert(v) - val dHash = FsLayout.sha256Hex("intro") - val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") - assertTrue(slot.exists()) + fun `delete of current addressable winner clears the slot`() = + runBlocking { + val v = article("intro", "hello", 10) + store.insert(v) + val dHash = FsLayout.sha256Hex("intro") + val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") + assertTrue(slot.exists()) - store.delete(v.id) - assertFalse(slot.exists()) - } + store.delete(v.id) + assertFalse(slot.exists()) + } // ------------------------------------------------------------------ // Non-replaceable events: no slot involvement // ------------------------------------------------------------------ @Test - fun `regular text note has no slot`() { - val note = - signer.sign( - createdAt = 1, - kind = 1, - tags = emptyArray(), - content = "plain", - ) - store.insert(note) + fun `regular text note has no slot`() = + runBlocking { + val note = + signer.sign( + createdAt = 1, + kind = 1, + tags = emptyArray(), + content = "plain", + ) + store.insert(note) - // No entries under replaceable/ or addressable/ — only the scaffolded dirs exist. - val replaceableDir = root.resolve("replaceable") - val addressableDir = root.resolve("addressable") - assertEquals( - 0, - Files.walk(replaceableDir).use { s -> s.filter { Files.isRegularFile(it) }.count() }, - ) - assertEquals( - 0, - Files.walk(addressableDir).use { s -> s.filter { Files.isRegularFile(it) }.count() }, - ) - } + // No entries under replaceable/ or addressable/ — only the scaffolded dirs exist. + val replaceableDir = root.resolve("replaceable") + val addressableDir = root.resolve("addressable") + assertEquals( + 0, + Files.walk(replaceableDir).use { s -> s.filter { Files.isRegularFile(it) }.count() }, + ) + assertEquals( + 0, + Files.walk(addressableDir).use { s -> s.filter { Files.isRegularFile(it) }.count() }, + ) + } // helper — check canonical existence private fun FsEventStore.hasCanonical(id: String): Boolean { diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsVanishTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsVanishTest.kt index 0e824a865..18703ed43 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsVanishTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsVanishTest.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -81,171 +82,182 @@ class FsVanishTest { // ------------------------------------------------------------------ @Test - fun `vanish for this relay cascades older events from the same author`() { - val n1 = note("a", 10) - val n2 = note("b", 20) - val n3 = note("c", 30) // same ts as vanish — survives because cascade uses strict < - store.insert(n1) - store.insert(n2) - store.insert(n3) + fun `vanish for this relay cascades older events from the same author`() = + runBlocking { + val n1 = note("a", 10) + val n2 = note("b", 20) + val n3 = note("c", 30) // same ts as vanish — survives because cascade uses strict < + store.insert(n1) + store.insert(n2) + store.insert(n3) - val v = vanish(ts = 30) - store.insert(v) + val v = vanish(ts = 30) + store.insert(v) - assertFalse(store.hasCanonical(n1.id), "n1 should be cascade-deleted") - assertFalse(store.hasCanonical(n2.id), "n2 should be cascade-deleted") - assertTrue(store.hasCanonical(n3.id), "n3 (createdAt == vanish.createdAt) survives") - assertTrue(store.hasCanonical(v.id)) - } - - @Test - fun `vanish for a different relay does NOT cascade`() { - val n = note("x", 10) - store.insert(n) - - val v = vanish(ts = 20, relayUrl = "wss://elsewhere.example") - store.insert(v) - - assertTrue(store.hasCanonical(n.id), "vanish scoped to another relay must not cascade") - // The kind-62 event itself is still persisted (it's just a normal event). - assertTrue(store.hasCanonical(v.id)) - // No vanish tombstone installed. - val tombDir = root.resolve("tombstones/vanish") - if (tombDir.exists()) { - assertEquals(0, Files.list(tombDir).use { it.toList() }.size) + assertFalse(store.hasCanonical(n1.id), "n1 should be cascade-deleted") + assertFalse(store.hasCanonical(n2.id), "n2 should be cascade-deleted") + assertTrue(store.hasCanonical(n3.id), "n3 (createdAt == vanish.createdAt) survives") + assertTrue(store.hasCanonical(v.id)) } - } @Test - fun `vanishFromEverywhere always cascades regardless of relay`() { - val n = note("x", 10) - store.insert(n) + fun `vanish for a different relay does NOT cascade`() = + runBlocking { + val n = note("x", 10) + store.insert(n) - val v = vanishEverywhere(ts = 20) - store.insert(v) + val v = vanish(ts = 20, relayUrl = "wss://elsewhere.example") + store.insert(v) - assertFalse(store.hasCanonical(n.id)) - } + assertTrue(store.hasCanonical(n.id), "vanish scoped to another relay must not cascade") + // The kind-62 event itself is still persisted (it's just a normal event). + assertTrue(store.hasCanonical(v.id)) + // No vanish tombstone installed. + val tombDir = root.resolve("tombstones/vanish") + if (tombDir.exists()) { + assertEquals(0, Files.list(tombDir).use { it.toList() }.size) + } + } + + @Test + fun `vanishFromEverywhere always cascades regardless of relay`() = + runBlocking { + val n = note("x", 10) + store.insert(n) + + val v = vanishEverywhere(ts = 20) + store.insert(v) + + assertFalse(store.hasCanonical(n.id)) + } // ------------------------------------------------------------------ // Block re-insert // ------------------------------------------------------------------ @Test - fun `events older than vanish are blocked from re-insertion`() { - val n = note("a", 10) - store.insert(n) + fun `events older than vanish are blocked from re-insertion`() = + runBlocking { + val n = note("a", 10) + store.insert(n) - val v = vanish(ts = 50) - store.insert(v) + val v = vanish(ts = 50) + store.insert(v) - // Re-insert blocked. - store.insert(n) - assertFalse(store.hasCanonical(n.id)) + // Re-insert blocked. + store.insert(n) + assertFalse(store.hasCanonical(n.id)) - // A brand-new older event by the same author also blocked. - val older = note("older", 5) - store.insert(older) - assertFalse(store.hasCanonical(older.id)) - } + // A brand-new older event by the same author also blocked. + val older = note("older", 5) + store.insert(older) + assertFalse(store.hasCanonical(older.id)) + } @Test - fun `events at vanish ts are blocked, parity with SQLite`() { - val v = vanish(ts = 50) - store.insert(v) + fun `events at vanish ts are blocked, parity with SQLite`() = + runBlocking { + val v = vanish(ts = 50) + store.insert(v) - val equal = note("equal", 50) - store.insert(equal) - assertFalse(store.hasCanonical(equal.id), "createdAt == vanish.createdAt should be blocked") - } + val equal = note("equal", 50) + store.insert(equal) + assertFalse(store.hasCanonical(equal.id), "createdAt == vanish.createdAt should be blocked") + } @Test - fun `events newer than vanish still pass`() { - val v = vanish(ts = 50) - store.insert(v) + fun `events newer than vanish still pass`() = + runBlocking { + val v = vanish(ts = 50) + store.insert(v) - val newer = note("newer", 100) - store.insert(newer) - assertTrue(store.hasCanonical(newer.id)) - } + val newer = note("newer", 100) + store.insert(newer) + assertTrue(store.hasCanonical(newer.id)) + } @Test - fun `another author is unaffected by my vanish`() { - val mine = note("mine", 10) - store.insert(mine) - val theirs = note("theirs", 5, s = otherSigner) - store.insert(theirs) + fun `another author is unaffected by my vanish`() = + runBlocking { + val mine = note("mine", 10) + store.insert(mine) + val theirs = note("theirs", 5, s = otherSigner) + store.insert(theirs) - val v = vanish(ts = 50) - store.insert(v) + val v = vanish(ts = 50) + store.insert(v) - assertFalse(store.hasCanonical(mine.id), "my old event cascade-deleted") - assertTrue(store.hasCanonical(theirs.id), "other author's event is unaffected") - } + assertFalse(store.hasCanonical(mine.id), "my old event cascade-deleted") + assertTrue(store.hasCanonical(theirs.id), "other author's event is unaffected") + } // ------------------------------------------------------------------ // Multiple vanish requests — strongest cutoff wins // ------------------------------------------------------------------ @Test - fun `later vanish raises the cutoff`() { - val n100 = note("at-100", 100) - store.insert(n100) - store.insert(vanish(ts = 50)) - // n100 still around because 100 > 50. - assertTrue(store.hasCanonical(n100.id)) + fun `later vanish raises the cutoff`() = + runBlocking { + val n100 = note("at-100", 100) + store.insert(n100) + store.insert(vanish(ts = 50)) + // n100 still around because 100 > 50. + assertTrue(store.hasCanonical(n100.id)) - // Stronger vanish at ts=200 cascades it. - store.insert(vanish(ts = 200)) - assertFalse(store.hasCanonical(n100.id)) + // Stronger vanish at ts=200 cascades it. + store.insert(vanish(ts = 200)) + assertFalse(store.hasCanonical(n100.id)) - // And new events at ts=150 are now blocked. - val mid = note("mid", 150) - store.insert(mid) - assertFalse(store.hasCanonical(mid.id)) - } + // And new events at ts=150 are now blocked. + val mid = note("mid", 150) + store.insert(mid) + assertFalse(store.hasCanonical(mid.id)) + } @Test - fun `earlier vanish does not lower a stronger cutoff`() { - store.insert(vanish(ts = 200)) - store.insert(vanish(ts = 50)) // older — should be a no-op for the tombstone + fun `earlier vanish does not lower a stronger cutoff`() = + runBlocking { + store.insert(vanish(ts = 200)) + store.insert(vanish(ts = 50)) // older — should be a no-op for the tombstone - val mid = note("mid", 150) - store.insert(mid) - assertFalse(store.hasCanonical(mid.id), "stronger cutoff stays at 200") - } + val mid = note("mid", 150) + store.insert(mid) + assertFalse(store.hasCanonical(mid.id), "stronger cutoff stays at 200") + } // ------------------------------------------------------------------ // Tombstone is a hardlink to the kind-62 event // ------------------------------------------------------------------ @Test - fun `vanish tombstone shares an inode with the kind-62 event`() { - val v = vanish(ts = 30) - store.insert(v) + fun `vanish tombstone shares an inode with the kind-62 event`() = + runBlocking { + val v = vanish(ts = 30) + store.insert(v) - val tombDir = root.resolve("tombstones/vanish") - val entries = Files.list(tombDir).use { it.toList() } - assertEquals(1, entries.size) + val tombDir = root.resolve("tombstones/vanish") + val entries = Files.list(tombDir).use { it.toList() } + assertEquals(1, entries.size) - val canonical = root.resolve("events/${v.id.substring(0, 2)}/${v.id.substring(2, 4)}/${v.id}.json") - val tombKey = Files.readAttributes(entries.single(), java.nio.file.attribute.BasicFileAttributes::class.java).fileKey() - val canKey = Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey() - assertEquals(canKey, tombKey, "vanish tombstone should be a hardlink to the kind-62 canonical") - } + val canonical = root.resolve("events/${v.id.substring(0, 2)}/${v.id.substring(2, 4)}/${v.id}.json") + val tombKey = Files.readAttributes(entries.single(), java.nio.file.attribute.BasicFileAttributes::class.java).fileKey() + val canKey = Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey() + assertEquals(canKey, tombKey, "vanish tombstone should be a hardlink to the kind-62 canonical") + } // ------------------------------------------------------------------ // Vanish event itself remains queryable // ------------------------------------------------------------------ @Test - fun `vanish event itself is indexed and queryable`() { - val v = vanish(ts = 30) - store.insert(v) + fun `vanish event itself is indexed and queryable`() = + runBlocking { + val v = vanish(ts = 30) + store.insert(v) - val byKind = store.query(Filter(kinds = listOf(RequestToVanishEvent.KIND))) - assertEquals(listOf(v.id), byKind.map { it.id }) - } + val byKind = store.query(Filter(kinds = listOf(RequestToVanishEvent.KIND))) + assertEquals(listOf(v.id), byKind.map { it.id }) + } private fun FsEventStore.hasCanonical(id: String): Boolean { val p = diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ParallelInsertTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ParallelInsertTest.kt new file mode 100644 index 000000000..eeff076f9 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ParallelInsertTest.kt @@ -0,0 +1,205 @@ +/* + * 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.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Stress test for the SQLite connection pool. Pre-pool, two coroutines + * inserting at the same time would race on the shared `SQLiteConnection` + * (`androidx.sqlite` connections aren't thread-safe) and crash with + * either `SQLITE_ERROR: cannot start a transaction within a transaction` + * or a corrupted prepared statement (`SQLITE_MISUSE`). + * + * With [SQLiteConnectionPool] writes serialise behind a coroutine `Mutex` + * and reads run in parallel against a fixed pool of reader connections, + * matching what Room does. The test launches a fan-out of inserts and + * concurrent reads, then asserts every inserted event is visible and the + * count is exact. + */ +class ParallelInsertTest { + private val signer = NostrSignerSync() + private lateinit var dbFile: Path + private lateinit var store: EventStore + + @BeforeTest + fun setup() { + Secp256k1Instance + // Use a real file so the pool can hand out independent reader + // connections — :memory: would make every connection a separate DB. + dbFile = Files.createTempFile("parallel-insert-", ".db") + // Driver expects to open the file itself; ensure the placeholder + // is gone so SQLite can create a fresh DB. + Files.deleteIfExists(dbFile) + store = EventStore(dbName = dbFile.toAbsolutePath().toString(), relay = null) + } + + @AfterTest + fun tearDown() { + store.close() + // SQLite leaves -wal / -shm sidecars next to the main file under WAL. + listOf("", "-wal", "-shm", "-journal").forEach { suffix -> + Path.of(dbFile.toString() + suffix).deleteIfExists() + } + } + + @Test + fun `parallel inserts on N coroutines all succeed`() = + runBlocking { + val perCoroutine = 200 + val coroutines = 8 + val total = perCoroutine * coroutines + + val events = + (0 until total).map { i -> + signer.sign(TextNoteEvent.build("p$i", createdAt = i.toLong() + 1)) + } + + // Fan out inserts across `coroutines` workers on the IO + // dispatcher (multi-thread). Without the pool's writer mutex + // these all race on a single SQLiteConnection and crash. + coroutineScope { + events.chunked(perCoroutine).forEach { chunk -> + launch(Dispatchers.IO) { + for (e in chunk) store.insert(e) + } + } + } + + assertEquals(total, store.count(Filter()), "every insert must be visible") + + val byId = store.query(Filter()).associateBy { it.id } + for (e in events) { + assertTrue(byId.containsKey(e.id), "missing event ${e.id.take(8)}") + } + } + + @Test + fun `parallel reads run alongside writes without crashing`() = + runBlocking { + val writes = 500 + + val events = + (0 until writes).map { i -> + signer.sign(TextNoteEvent.build("rw$i", createdAt = i.toLong() + 1)) + } + + coroutineScope { + // Writer feed. + launch(Dispatchers.IO) { + for (e in events) store.insert(e) + } + // Multiple reader fans-out: count() and query() running + // continuously while inserts are still in flight. Asserts + // none of these crash with SQLITE_MISUSE. + val readers = + List(4) { + async(Dispatchers.IO) { + var lastSeen = 0 + repeat(100) { + val n = store.count(Filter()) + assertTrue(n in 0..writes) + if (n > lastSeen) lastSeen = n + } + lastSeen + } + } + readers.awaitAll() + } + + assertEquals(writes, store.count(Filter())) + } + + @Test + fun `parallel transaction batches all commit`() = + runBlocking { + val batches = 8 + val perBatch = 50 + val total = batches * perBatch + + val events = + (0 until total).map { i -> + signer.sign(TextNoteEvent.build("t$i", createdAt = i.toLong() + 1)) + } + + // Each coroutine wraps its slice in store.transaction { ... }, + // exercising the writer mutex around BEGIN/COMMIT pairs. + coroutineScope { + events.chunked(perBatch).forEach { chunk -> + launch(Dispatchers.IO) { + store.transaction { + for (e in chunk) insert(e) + } + } + } + } + + assertEquals(total, store.count(Filter())) + } + + @Test + fun `pool with file-backed db survives reopen`() = + runBlocking { + // Smoke test that the pool migration runs idempotently when + // a writer connection is reopened against an existing DB. + val first = signer.sign(TextNoteEvent.build("first", createdAt = 1)) + store.insert(first) + store.close() + + val reopened = EventStore(dbName = dbFile.toAbsolutePath().toString(), relay = null) + try { + assertTrue(dbFile.exists()) + val got = reopened.query(Filter(ids = listOf(first.id))) + assertEquals(listOf(first.id), got.map { it.id }) + + // And then more parallel inserts still work on the + // reopened pool. + val moreCount = 20 + val more = (0 until moreCount).map { signer.sign(TextNoteEvent.build("m$it", createdAt = it.toLong() + 100)) } + coroutineScope { + more.forEach { e -> + launch(Dispatchers.IO) { reopened.insert(e) } + } + } + assertEquals(1 + moreCount, reopened.count(Filter())) + } finally { + reopened.close() + } + } +} From 3bf1448d6396d03b4166476c3a8f9c3d2da406f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 13:49:47 +0000 Subject: [PATCH 12/38] docs(quartz/store): explain the connection pool and the suspend API - Add a Concurrency section to the SQLite store README covering the Room-style 1-writer + N-reader pool, the in-memory degradation, and the non-reentrant Mutex contract. - Refresh the SQLite "How to Use" examples to call out the suspend context and recommend transaction-batching for hot inserts. - Switch the ExpirationWorker example from Worker to CoroutineWorker now that deleteExpiredEvents is suspend. - Note in the FS README that the IEventStore API is suspend even though the FS layer keeps a synchronous flock manager (the withWriteLock helper is inline so suspend bodies pass through). - Update the FsMaintenanceTest description to match the coroutine-based concurrency test. - Document the Mutex non-reentrancy footgun in SQLiteConnectionPool's KDoc so module logic doesn't try to re-enter the pool from inside useWriter. https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5 --- .../quartz/nip01Core/store/sqlite/README.md | 53 +++++++++++++++++-- .../store/sqlite/SQLiteConnectionPool.kt | 6 +++ .../quartz/nip01Core/store/fs/README.md | 7 ++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md index 3c3d5c610..afac612fc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md @@ -80,10 +80,39 @@ store.query( ) ``` +## Concurrency + +`androidx.sqlite.SQLiteConnection` is not thread-safe — same contract as +`sqlite3*` in the C API. To support concurrent inserts and reads from +multiple coroutines, `SQLiteEventStore` owns a Room-style +[`SQLiteConnectionPool`](SQLiteConnectionPool.kt): + +- **One writer connection**, guarded by a coroutine `Mutex`. SQLite only + allows one writer at the file level anyway, so serialising here costs + nothing — it just queues callers cooperatively instead of crashing + them on `BEGIN IMMEDIATE`. +- **N reader connections** (default 4), handed out from a `Channel` that + doubles as a semaphore. Under WAL (`PRAGMA journal_mode = WAL`) + readers run in parallel with the writer and with each other. + +For in-memory databases (`dbName == null`) the pool degrades to a +single shared connection — every fresh `:memory:` connection would +otherwise be a *separate* DB. Writes still serialise correctly; reads +just take the same writer mutex. + +The whole public API on `EventStore` / `SQLiteEventStore` is therefore +`suspend`. Callers must be in a coroutine; on Android, schedule +maintenance work as a `CoroutineWorker`. + +`Mutex` is non-reentrant: do not call `eventStore.query(...)` from +inside a `transaction { ... }` body. The transaction body itself +already holds the writer connection — query against the +`SQLiteConnection` handed to your block instead. + ## How to Use The `EventStore` class provides a high-level interface for interacting with the event database. -It is initialized with a `SQLiteDatabase` instance, and it manages the underlying tables and query planning. +It owns the underlying [`SQLiteConnectionPool`](SQLiteConnectionPool.kt) and the query planner. ### Initialization @@ -95,7 +124,7 @@ val eventStore = EventStore("dbname.db", relayUrlIdentifier) ### Querying Events -To query events, use the `query` method with one or more `Filter` objects: +To query events, use the `query` method with one or more `Filter` objects (in a coroutine): ```kotlin val filters = listOf( @@ -129,6 +158,18 @@ Insert a single event using the `insert` method: eventStore.insert(event) ``` +For batch inserts, prefer a single `transaction` — one `BEGIN`/`COMMIT` +per batch is roughly an order of magnitude faster on WAL than one per +event: + +```kotlin +eventStore.transaction { + insert(event1) + insert(event2) + insert(event3) +} +``` + ### Deleting Events Events should be deleted by adding a DeletionRequest or a VanishRequest to the db, but to manually @@ -154,11 +195,13 @@ The store exposes a `deleteExpiredEvents` to be used in a periodic clean up proc should use a WorkManager or a coroutine to periodically call `store.deleteExpiredEvents()`. We recommend a 15-minute window to remove recently expired events from the database. -Here's an example of a Worker that should be added to your application class. +Here's an example of a Worker that should be added to your application class. Use +`CoroutineWorker` (not `Worker`) — `deleteExpiredEvents()` is a `suspend` function. ```kotlin -class ExpirationWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { - override fun doWork(): Result { +class ExpirationWorker(appContext: Context, workerParams: WorkerParameters) : + CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { YourApplication.store.deleteExpiredEvents() return Result.success() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt index e5adc0701..5ca19d985 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt @@ -59,6 +59,12 @@ import kotlinx.coroutines.sync.withLock * per-connection in SQLite — `journal_mode=WAL` is the only * database-wide one; subsequent connections inherit it). * 3. [close] drains the reader channel and closes every connection. + * + * Reentrancy: [Mutex] is **not** reentrant — calling [useWriter] (or, on + * an in-memory DB, [useReader]) from inside an already-acquired + * [useWriter] block deadlocks. Module logic that runs under [useWriter] + * (e.g. `innerInsertEvent`) must operate on the `SQLiteConnection` + * handed to its block; it must not re-enter the pool. */ class SQLiteConnectionPool( val driver: SQLiteDriver, diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md index 15515c1cf..7715638b8 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md @@ -128,6 +128,11 @@ lock — atomic-rename writes mean readers see either the pre- or post-mutation state, and `NoSuchFileException` on a just-unlinked candidate is silently skipped. +The `IEventStore` API is `suspend`. The flock manager itself is +synchronous (`ReentrantLock` + `FileChannel.lock`); each suspend +public method just brackets its work in `lockManager.withWriteLock { +... }`, which is `inline` so suspend bodies pass through. + ## Usage ### Initialisation @@ -301,7 +306,7 @@ Tests live under | `FsExpirationTest` | NIP-40 future / past / equal-now / sweep / non-positive | | `FsVanishTest` | NIP-62 cascade / block / strongest-cutoff-wins / per-relay scoping | | `FsSearchTest` | tokenizer behaviour, single-token / AND-of-tokens, ordering, reopen | -| `FsMaintenanceTest` | flock, transaction commit + propagated exceptions, re-entrant lock, scrub, compact, two-thread concurrency | +| `FsMaintenanceTest` | flock, transaction commit + propagated exceptions, re-entrant lock, scrub, compact, concurrent inserts from multiple coroutines on `Dispatchers.IO` | | `FsParityTest` | drive both this store and SQLite with identical streams and assert results match | ```bash From 63b20b88d0da4b2a4b48973e3bfc5d4f71677d96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 13:54:18 +0000 Subject: [PATCH 13/38] refactor(translation): split UI from orchestration, dedupe boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-readability refactor — no behaviour change, all 410 unit tests still pass. TranslatableRichTextViewer.kt (358 → 192 lines) - Extract the in-line LaunchedEffect block (cache check + ML Kit await + cancellation bridge + result validation + caching) into a private `suspend translateAndCache` function. The effect body is now four lines: try/catch around one call. - Add a small `ResultOrError.toTranslationConfig(content)` extension that returns a TranslationConfig only when an actual translation took place, replacing the five-condition inline if/else inside the effect. - Move TranslationMessage / LangSettingsDropdown / CheckmarkRow out to a sibling file (TranslationStatusBar.kt). They render the "Translated from X to Y" footer and don't belong in the orchestrator file. TranslationStatusBar.kt (new) - Renamed the public composable to `TranslationStatusBar` to make its role obvious. - Split the status text and the dropdown into separate private composables so each fits on screen at a glance. - Add a tiny `LangMenuItem(checked, label, onClick)` to dedupe the four `DropdownMenuItem { text = { CheckmarkRow(...) }, onClick = ... }` blocks. - Hoist `rememberDeviceLocales()` out of the dropdown body for clarity. - Cache `settings.preferenceBetween(source, target)` once per dropdown render instead of calling it twice with identical args. LanguageTranslatorService.kt - Extract the in-flight cache plumbing into a `private inline fun dedupe(key, factory)` helper. `autoTranslate` is now three lines that read top-to-bottom: pre-filter, dedupe, identifyLanguage → translateOrSkip. - Promote the inline `when` deciding whether to translate (matches translateTo, is "und", is in dontTranslateFrom) into a named `translateOrSkip` function so the policy is greppable. TranslationDictionary.kt - Add a `private inline fun Pattern.forEachMatch(text, block)` extension. The four near-identical `val matcher = …; while (matcher.find()) addUnique(matcher.group())` loops collapse to three one-liners; the URL detector loop stays explicit because it has its own filter. `inline` on dedupe and forEachMatch keeps the lambda allocations gone, so this is a zero-cost refactor at runtime. https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5 --- .../service/lang/LanguageTranslatorService.kt | 36 ++- .../service/lang/TranslationDictionary.kt | 19 +- .../components/TranslatableRichTextViewer.kt | 241 +++--------------- .../ui/components/TranslationStatusBar.kt | 217 ++++++++++++++++ 4 files changed, 290 insertions(+), 223 deletions(-) create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslationStatusBar.kt diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index df20633ee..9f5d9f008 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -128,22 +128,34 @@ object LanguageTranslatorService { translateTo: String, ): Task { if (!TranslationDictionary.isWorthTranslating(text)) return Tasks.forCanceled() - - val key = InFlightKey(text, translateTo, dontTranslateFrom) - inFlight[key]?.let { return it } - - val task = + return dedupe(InFlightKey(text, translateTo, dontTranslateFrom)) { identifyLanguage(text).onSuccessTask(executorService) { detected -> - when { - detected == "und" -> Tasks.forCanceled() - detected.equals(translateTo, ignoreCase = true) -> Tasks.forCanceled() - detected in dontTranslateFrom -> Tasks.forCanceled() - else -> translate(text, detected, translateTo) - } + translateOrSkip(text, detected, dontTranslateFrom, translateTo) } + } + } + private fun translateOrSkip( + text: String, + detected: String, + dontTranslateFrom: Set, + translateTo: String, + ): Task = + when { + detected == "und" -> Tasks.forCanceled() + detected.equals(translateTo, ignoreCase = true) -> Tasks.forCanceled() + detected in dontTranslateFrom -> Tasks.forCanceled() + else -> translate(text, detected, translateTo) + } + + private inline fun dedupe( + key: InFlightKey, + factory: () -> Task, + ): Task { + inFlight[key]?.let { return it } + val candidate = factory() // putIfAbsent guards against a racing caller: keep the winner, drop the loser. - val winner = inFlight.putIfAbsent(key, task) ?: task + val winner = inFlight.putIfAbsent(key, candidate) ?: candidate winner.addOnCompleteListener(executorService) { inFlight.remove(key, winner) } return winner } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionary.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionary.kt index 61b659ed5..5a504ebbe 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionary.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionary.kt @@ -70,14 +70,9 @@ internal object TranslationDictionary { dict[placeholder(counter++)] = value } - val lnMatcher = lnRegex.matcher(text) - while (lnMatcher.find()) addUnique(lnMatcher.group()) - - val tagMatcher = tagRegex.matcher(text) - while (tagMatcher.find()) addUnique(tagMatcher.group()) - - val nip08Matcher = nip08RefRegex.matcher(text) - while (nip08Matcher.find()) addUnique(nip08Matcher.group()) + lnRegex.forEachMatch(text, ::addUnique) + tagRegex.forEachMatch(text, ::addUnique) + nip08RefRegex.forEachMatch(text, ::addUnique) for (url in UrlDetector(text).detect()) { val original = url.originalUrl @@ -89,6 +84,14 @@ internal object TranslationDictionary { return dict } + private inline fun Pattern.forEachMatch( + text: String, + block: (String) -> Unit, + ) { + val matcher = matcher(text) + while (matcher.find()) block(matcher.group()) + } + fun encode( text: String, dict: Map, diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index b916543f6..004d1bdd7 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -20,17 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.components -import android.content.res.Resources import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -38,31 +28,20 @@ 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.graphics.Color -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.core.os.ConfigurationCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService +import com.vitorpamplona.amethyst.service.lang.ResultOrError import com.vitorpamplona.amethyst.service.lang.TranslationsCache import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp -import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ensureActive import kotlinx.coroutines.tasks.await -import java.util.Locale +import kotlin.coroutines.coroutineContext @Composable fun TranslatableRichTextViewer( @@ -119,51 +98,13 @@ fun TranslatableRichTextViewer( } LaunchedEffect(content, translateTo, dontTranslateFrom) { - TranslationsCache.get(content, translateTo, dontTranslateFrom)?.let { - translatedTextState.value = it - return@LaunchedEffect - } - - val noOp = TranslationConfig(content, null, null) try { - val task = LanguageTranslatorService.autoTranslate(content, dontTranslateFrom, translateTo) - // ML Kit cancels the task to signal "no translation needed" (same language, "und", - // blocklisted). await() bridges that into a CancellationException; cache the no-op so - // we don't re-run language identification next time the same text scrolls into view. - val raw = - try { - task.await() - } catch (e: CancellationException) { - coroutineContext.ensureActive() - TranslationsCache.set(content, translateTo, dontTranslateFrom, noOp) - translatedTextState.value = noOp - return@LaunchedEffect - } - - coroutineContext.ensureActive() - - val translated = raw.result - val source = raw.sourceLang - val target = raw.targetLang - val newConfig = - if ( - translated != null && - source != null && - target != null && - source != target && - translated != content - ) { - TranslationConfig(translated, source, target) - } else { - noOp - } - TranslationsCache.set(content, translateTo, dontTranslateFrom, newConfig) - translatedTextState.value = newConfig + translatedTextState.value = translateAndCache(content, translateTo, dontTranslateFrom) } catch (e: CancellationException) { throw e } catch (_: Exception) { - // Network / model download / translator failure — keep showing the original. Do not - // cache: a transient failure shouldn't block future attempts on the same text. + // Transient ML Kit / network failure — keep showing the original. Do not cache: a + // one-off failure shouldn't block future attempts on the same text. } } @@ -202,7 +143,7 @@ private fun RenderTextWithTranslateOptions( displayText(toBeViewed) if (translationOccurred) { - TranslationMessage( + TranslationStatusBar( source = source, target = target, modifier = translationMessageModifier, @@ -212,146 +153,40 @@ private fun RenderTextWithTranslateOptions( } } -@Composable -private fun TranslationMessage( - source: String, - target: String, - modifier: Modifier = MaxWidthPaddingTop5dp, - accountViewModel: AccountViewModel, - onChangeWhatToShow: (Boolean) -> Unit, -) { - var langSettingsPopupExpanded by remember { mutableStateOf(false) } +/** + * Returns the translation for [content] under the current language settings, hitting the cache + * first and falling back to ML Kit. ML Kit's "no translation needed" cancellation (same language, + * undetected, blocklisted) is bridged into a no-op [TranslationConfig] that is itself cached, so + * the same text scrolling back into view doesn't re-run language identification. + */ +private suspend fun translateAndCache( + content: String, + translateTo: String, + dontTranslateFrom: Set, +): TranslationConfig { + TranslationsCache.get(content, translateTo, dontTranslateFrom)?.let { return it } - val sourceDisplay = remember(source) { Locale.forLanguageTag(source).displayName } - val targetDisplay = remember(target) { Locale.forLanguageTag(target).displayName } - val autoLabel = stringRes(R.string.translations_auto) - val translatedFromLabel = stringRes(R.string.translations_translated_from) - val toLabel = stringRes(R.string.translations_to) - - Row(modifier = modifier) { - val textColor = MaterialTheme.colorScheme.lessImportantLink - - Text( - text = - buildAnnotatedString { - appendLink(autoLabel, textColor) { langSettingsPopupExpanded = !langSettingsPopupExpanded } - append(" $translatedFromLabel ") - appendLink(sourceDisplay, textColor) { onChangeWhatToShow(true) } - append(" $toLabel ") - appendLink(targetDisplay, textColor) { onChangeWhatToShow(false) } - }, - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.32f), - fontSize = Font14SP, - ), - overflow = TextOverflow.Visible, - maxLines = 3, - ) - - if (langSettingsPopupExpanded) { - LangSettingsDropdown( - expanded = true, - source = source, - target = target, - sourceDisplay = sourceDisplay, - targetDisplay = targetDisplay, - accountViewModel = accountViewModel, - onDismiss = { langSettingsPopupExpanded = false }, - ) + val noOp = TranslationConfig(content, null, null) + val raw = + try { + LanguageTranslatorService.autoTranslate(content, dontTranslateFrom, translateTo).await() + } catch (e: CancellationException) { + // If our coroutine is the cancelled one, propagate; otherwise it's ML Kit signalling + // "no translation needed" — cache the no-op and return it. + coroutineContext.ensureActive() + return noOp.also { TranslationsCache.set(content, translateTo, dontTranslateFrom, it) } } - } + coroutineContext.ensureActive() + + val config = raw.toTranslationConfig(content) ?: noOp + TranslationsCache.set(content, translateTo, dontTranslateFrom, config) + return config } -@Composable -private fun LangSettingsDropdown( - expanded: Boolean, - source: String, - target: String, - sourceDisplay: String, - targetDisplay: String, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, -) { - val deviceLocales = - remember { - val list = ConfigurationCompat.getLocales(Resources.getSystem().configuration) - (0 until list.size()).mapNotNull { list.get(it) } - } - - DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { - DropdownMenuItem( - text = { - CheckmarkRow( - checked = source in accountViewModel.dontTranslateFrom(), - label = stringRes(R.string.translations_never_translate_from_lang, sourceDisplay), - ) - }, - onClick = { - accountViewModel.toggleDontTranslateFrom(source) - onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - CheckmarkRow( - checked = accountViewModel.account.settings.preferenceBetween(source, target) == source, - label = stringRes(R.string.translations_show_in_lang_first, sourceDisplay), - ) - }, - onClick = { - accountViewModel.prefer(source, target, source) - onDismiss() - }, - ) - DropdownMenuItem( - text = { - CheckmarkRow( - checked = accountViewModel.account.settings.preferenceBetween(source, target) == target, - label = stringRes(R.string.translations_show_in_lang_first, targetDisplay), - ) - }, - onClick = { - accountViewModel.prefer(source, target, target) - onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - - for (lang in deviceLocales) { - DropdownMenuItem( - text = { - CheckmarkRow( - checked = accountViewModel.account.settings.translateToContains(lang.language), - label = stringRes(R.string.translations_always_translate_to_lang, lang.displayName), - ) - }, - onClick = { - onDismiss() - accountViewModel.updateTranslateTo(lang.language) - }, - ) - } - } -} - -@Composable -private fun CheckmarkRow( - checked: Boolean, - label: String, -) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (checked) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - Spacer(modifier = Modifier.size(10.dp)) - Text(label) - } +private fun ResultOrError.toTranslationConfig(content: String): TranslationConfig? { + val translated = result ?: return null + val source = sourceLang ?: return null + val target = targetLang ?: return null + if (source == target || translated == content) return null + return TranslationConfig(translated, source, target) } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslationStatusBar.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslationStatusBar.kt new file mode 100644 index 000000000..880c28d97 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslationStatusBar.kt @@ -0,0 +1,217 @@ +/* + * 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.components + +import android.content.res.Resources +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.os.ConfigurationCompat +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink +import java.util.Locale + +/** + * The "Auto-translated from X to Y" footer shown beneath translated rich text. Tapping the source + * or target labels toggles which version is displayed; tapping "Auto-translated" opens the + * per-language preferences dropdown. + */ +@Composable +internal fun TranslationStatusBar( + source: String, + target: String, + modifier: Modifier = MaxWidthPaddingTop5dp, + accountViewModel: AccountViewModel, + onShowOriginalChange: (Boolean) -> Unit, +) { + var dropdownExpanded by remember { mutableStateOf(false) } + + val sourceDisplay = remember(source) { Locale.forLanguageTag(source).displayName } + val targetDisplay = remember(target) { Locale.forLanguageTag(target).displayName } + + Row(modifier = modifier) { + TranslationStatusText( + sourceDisplay = sourceDisplay, + targetDisplay = targetDisplay, + onAutoLabelClick = { dropdownExpanded = !dropdownExpanded }, + onSourceLabelClick = { onShowOriginalChange(true) }, + onTargetLabelClick = { onShowOriginalChange(false) }, + ) + + if (dropdownExpanded) { + LangSettingsDropdown( + source = source, + target = target, + sourceDisplay = sourceDisplay, + targetDisplay = targetDisplay, + accountViewModel = accountViewModel, + onDismiss = { dropdownExpanded = false }, + ) + } + } +} + +@Composable +private fun TranslationStatusText( + sourceDisplay: String, + targetDisplay: String, + onAutoLabelClick: () -> Unit, + onSourceLabelClick: () -> Unit, + onTargetLabelClick: () -> Unit, +) { + val textColor = MaterialTheme.colorScheme.lessImportantLink + val autoLabel = stringRes(R.string.translations_auto) + val translatedFromLabel = stringRes(R.string.translations_translated_from) + val toLabel = stringRes(R.string.translations_to) + + Text( + text = + buildAnnotatedString { + appendLink(autoLabel, textColor, onAutoLabelClick) + append(" $translatedFromLabel ") + appendLink(sourceDisplay, textColor, onSourceLabelClick) + append(" $toLabel ") + appendLink(targetDisplay, textColor, onTargetLabelClick) + }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.32f), + fontSize = Font14SP, + ), + overflow = TextOverflow.Visible, + maxLines = 3, + ) +} + +@Composable +private fun LangSettingsDropdown( + source: String, + target: String, + sourceDisplay: String, + targetDisplay: String, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val deviceLocales = rememberDeviceLocales() + val settings = accountViewModel.account.settings + val preferenceForPair = settings.preferenceBetween(source, target) + + DropdownMenu(expanded = true, onDismissRequest = onDismiss) { + LangMenuItem( + checked = source in accountViewModel.dontTranslateFrom(), + label = stringRes(R.string.translations_never_translate_from_lang, sourceDisplay), + onClick = { + accountViewModel.toggleDontTranslateFrom(source) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + LangMenuItem( + checked = preferenceForPair == source, + label = stringRes(R.string.translations_show_in_lang_first, sourceDisplay), + onClick = { + accountViewModel.prefer(source, target, source) + onDismiss() + }, + ) + LangMenuItem( + checked = preferenceForPair == target, + label = stringRes(R.string.translations_show_in_lang_first, targetDisplay), + onClick = { + accountViewModel.prefer(source, target, target) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + for (lang in deviceLocales) { + LangMenuItem( + checked = settings.translateToContains(lang.language), + label = stringRes(R.string.translations_always_translate_to_lang, lang.displayName), + onClick = { + onDismiss() + accountViewModel.updateTranslateTo(lang.language) + }, + ) + } + } +} + +@Composable +private fun rememberDeviceLocales(): List = + remember { + val list = ConfigurationCompat.getLocales(Resources.getSystem().configuration) + (0 until list.size()).mapNotNull { list.get(it) } + } + +@Composable +private fun LangMenuItem( + checked: Boolean, + label: String, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { CheckmarkRow(checked, label) }, + onClick = onClick, + ) +} + +@Composable +private fun CheckmarkRow( + checked: Boolean, + label: String, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (checked) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + Spacer(modifier = Modifier.size(10.dp)) + Text(label) + } +} From d7bd78cc322dac172538a2b99d723642b8fd01cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 13:55:53 +0000 Subject: [PATCH 14/38] chore(video): correctness and hygiene cleanups in playback layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-up of the small leftovers from the audit. None move the needle on their own; together they remove a real cancellation bug and tighten the playback types. - PlaybackServiceClient.executorService: Executors.newCachedThreadPool() → Executors.newSingleThreadExecutor(). The work per callback is Future.get() on an already-completed future plus a non-blocking trySend; a single thread is plenty. The previous unbounded pool could spin up a thread per concurrent video, each lingering for the 60 s keep-alive afterwards. - MediaControllerState.controller: var → val. The field was never reassigned anywhere (grep confirms), and a non-observable var on a @Stable class is a footgun — Compose can't see writes to a plain var, so any future write would silently miss recomposition. - MediaControllerState.currrentMedia() → currentMedia(). Typo. Updated the single caller in PipVideoView. - LoadThumbAndThenVideoView: real cancellation bug fix. The Coil fetch was launched into AccountViewModel.viewModelScope via a side helper (loadThumb), so a scroll-away didn't cancel the in-flight image request — wasted bandwidth and a late callback writing into stale state. Inline the Coil call into the LaunchedEffect's own scope so cancellation propagates, and key the effect on thumbUri so a recycled audio-track slot with a new cover doesn't stall on the prior Pair(true, ...) gate. Drop the now-unused AccountViewModel.loadThumb and its only-here imports. --- .../composable/LoadThumbAndThenVideoView.kt | 88 +++++++++---------- .../composable/MediaControllerState.kt | 5 +- .../service/playback/pip/PipVideoView.kt | 2 +- .../playback/service/PlaybackServiceClient.kt | 7 +- .../ui/screen/loggedIn/AccountViewModel.kt | 27 ------ 5 files changed, 52 insertions(+), 77 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt index 9bcabaf0d..0189aa04a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt @@ -29,7 +29,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import coil3.asDrawable +import coil3.imageLoader +import coil3.request.ImageRequest import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException @Composable fun LoadThumbAndThenVideoView( @@ -45,56 +52,47 @@ fun LoadThumbAndThenVideoView( accountViewModel: AccountViewModel, onDialog: (() -> Unit)? = null, ) { - var loadingFinished by remember { mutableStateOf>(Pair(false, null)) } + var loadingFinished by remember(thumbUri) { mutableStateOf>(Pair(false, null)) } val context = LocalContext.current - LaunchedEffect(Unit) { - accountViewModel.loadThumb( - context, - thumbUri, - onReady = { - loadingFinished = - if (it != null) { - Pair(true, it) - } else { - Pair(true, null) + // Run the Coil fetch in this LaunchedEffect's scope (was previously launched into the + // AccountViewModel's viewModelScope, which meant a scroll-away wouldn't cancel the in-flight + // image request — wasted bandwidth, plus the late callback wrote into a state that no + // longer mattered). Keying on thumbUri also makes the effect re-fire when a recycled slot + // gets a new audio track with a new cover instead of stalling on the stale Pair(true, ...). + LaunchedEffect(thumbUri) { + loadingFinished = + try { + val request = ImageRequest.Builder(context).data(thumbUri).build() + val drawable = + withContext(Dispatchers.IO) { + context.imageLoader + .execute(request) + .image + ?.asDrawable(context.resources) } - }, - onError = { loadingFinished = Pair(true, null) }, - ) + Pair(true, drawable) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("VideoView", "Fail to load cover $thumbUri", e) + Pair(true, null) + } } if (loadingFinished.first) { - if (loadingFinished.second != null) { - VideoView( - videoUri = videoUri, - mimeType = mimeType, - title = title, - thumb = VideoThumb(loadingFinished.second), - roundedCorner = roundedCorner, - contentScale = contentScale, - artworkUri = thumbUri, - authorName = authorName, - nostrUriCallback = nostrUriCallback, - isLiveStream = isLiveStream, - accountViewModel = accountViewModel, - onDialog = onDialog, - ) - } else { - VideoView( - videoUri = videoUri, - mimeType = mimeType, - title = title, - thumb = null, - roundedCorner = roundedCorner, - contentScale = contentScale, - artworkUri = thumbUri, - authorName = authorName, - nostrUriCallback = nostrUriCallback, - isLiveStream = isLiveStream, - accountViewModel = accountViewModel, - onDialog = onDialog, - ) - } + VideoView( + videoUri = videoUri, + mimeType = mimeType, + title = title, + thumb = loadingFinished.second?.let { VideoThumb(it) }, + roundedCorner = roundedCorner, + contentScale = contentScale, + artworkUri = thumbUri, + authorName = authorName, + nostrUriCallback = nostrUriCallback, + isLiveStream = isLiveStream, + accountViewModel = accountViewModel, + onDialog = onDialog, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt index 4a353a6fe..701ff382c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt @@ -31,14 +31,13 @@ import kotlin.uuid.Uuid class MediaControllerState( // each composable has an ID. val id: String = Uuid.random().toString(), - // This is filled after the controller returns from this class - var controller: Player, + val controller: Player, // visibility onscreen val visibility: VisibilityData = VisibilityData(), ) { fun isPlaying() = controller.isPlaying - fun currrentMedia() = controller.currentMediaItem?.mediaId + fun currentMedia() = controller.currentMediaItem?.mediaId fun toggleMute() { controller.volume = if (controller.volume == 0f) 1f else 0f diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt index 59a425981..aa9c54081 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt @@ -58,7 +58,7 @@ fun RenderPipVideo( val modifier = remember { val ratio = - controller.currrentMedia()?.let { + controller.currentMedia()?.let { MediaAspectRatioCache.get(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index aa74bfaf1..6ffdce4c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -36,7 +36,12 @@ import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid object PlaybackServiceClient { - val executorService: ExecutorService = Executors.newCachedThreadPool() + // Runs the MediaController.buildAsync() completion callbacks. The work per callback is + // trivial — Future.get() on an already-completed future plus a non-blocking trySend into + // the callbackFlow channel — so a single thread is plenty. The previous newCachedThreadPool + // could spin up an unbounded number of threads when many videos appeared at once, each + // sticking around for the executor's keep-alive (60s) afterwards. + val executorService: ExecutorService = Executors.newSingleThreadExecutor() fun shutdown() { executorService.shutdown() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index c944b071f..673454184 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.annotation.SuppressLint import android.content.Context -import android.graphics.drawable.Drawable import android.os.Handler import android.os.Looper import android.util.LruCache @@ -34,9 +33,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import coil3.asDrawable -import coil3.imageLoader -import coil3.request.ImageRequest import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences @@ -1578,29 +1574,6 @@ class AccountViewModel( super.onCleared() } - fun loadThumb( - context: Context, - thumbUri: String, - onReady: (Drawable?) -> Unit, - onError: (String?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - try { - val request = ImageRequest.Builder(context).data(thumbUri).build() - val myCover = - context.imageLoader - .execute(request) - .image - ?.asDrawable(context.resources) - onReady(myCover) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("VideoView", "Fail to load cover $thumbUri", e) - onError(e.message) - } - } - } - fun loadMentions( mentions: ImmutableList, onReady: (ImmutableList) -> Unit, From 294ac3e74ec357c576ded7fb161c125497007f72 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 26 Apr 2026 14:01:51 +0000 Subject: [PATCH 15/38] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-hi-rIN/strings.xml | 2 ++ amethyst/src/main/res/values-hu-rHU/strings.xml | 2 ++ amethyst/src/main/res/values-pl-rPL/strings.xml | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index d15a5fdd5..d746ed323 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -2199,6 +2199,8 @@ एआई॰ लेखन सहायता लेख शोधन प्रस्ताव करें यन्त्र स्थित एआई॰ प्रतिरूप का प्रयोग करता है लेख सुधार तथा स्वर परिवर्तन प्रस्तावों के लिए। + पदचिह्न युक्त प्रसारण + पदचिह्न युक्त प्रसारक का उपयोग करें घटनाओं को भेजते समय। तत्काल प्रगति दिखाता है तथा प्रत्येक पुनःप्रसारक की स्थिति प्रसारण करते समय। इसका प्रयोग करें हटाएँ सम्यक diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 22672f68f..bab00d59d 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -2199,6 +2199,8 @@ Súgó az LLM-alapú íráshoz Javaslatok a szöveg javítására Az eszközön futó LLM-modell segítségével szövegjavításokkal és hangnemváltoztatásokkal kapcsolatos javaslatokat kaphat. + Nyomon követhető közvetítések + Nyomon követhető közvetítők használata az események küldesékor. Közvetítés közben megjeleníti a jelenlegi előrehaladást és az egyes átjátszók állapotát. Ennek használata Eltüntetés Javítás diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 45a3ae1ac..c1e616795 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1147,8 +1147,8 @@ Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satosze Procentowo 25 - Podziel zapsy z - Przesyłanie zapasów do + Podziel zapy z + Przesyłanie zapów do Nie znaleziono portfeli Lightning Zapłacone Portfel %1$s From 45fb5119e85f1ee00eebf568a2d67558e337ea92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 14:25:25 +0000 Subject: [PATCH 16/38] revert(video): two cleanups from the round-4 pass that didn't earn their keep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GifVideoView: revert the dimensions remember() to the original one-line expression. Unlike VideoView's equivalent block, GifVideoView only *reads* — there's no MediaAspectRatioCache.add() side effect to gate. The replaced code spent three slot reads + three equality checks per recompose to skip an int division and an LruCache.get(), neither of which allocates. It was a wash at best, a small loss at worst. The original is simpler and roughly the same cost. - PlaybackServiceClient: bump the executor from newSingleThreadExecutor() back up to newFixedThreadPool(4). The work per listener is genuinely trivial in the steady state, but a single thread leaves us exposed to one stuck listener (e.g. the defensive 5s controllerFuture.get() timeout actually firing) stalling every other video on screen behind it. With a feed often holding several visible videos at once, that's a real regression risk. A fixed pool of 4 keeps us bounded against churn while letting independent listeners proceed in parallel. --- .../playback/service/PlaybackServiceClient.kt | 13 ++++++++----- .../amethyst/ui/components/GifVideoView.kt | 18 +++++------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index 6ffdce4c6..d641cd8d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -37,11 +37,14 @@ import kotlin.uuid.Uuid object PlaybackServiceClient { // Runs the MediaController.buildAsync() completion callbacks. The work per callback is - // trivial — Future.get() on an already-completed future plus a non-blocking trySend into - // the callbackFlow channel — so a single thread is plenty. The previous newCachedThreadPool - // could spin up an unbounded number of threads when many videos appeared at once, each - // sticking around for the executor's keep-alive (60s) afterwards. - val executorService: ExecutorService = Executors.newSingleThreadExecutor() + // trivial in the steady state (Future.get() on an already-completed future + a non-blocking + // trySend into this video's own callbackFlow channel), so the IPC bind itself dominates and + // happens on Media3's own threads regardless. We size the pool small enough to stay bounded + // under churn but parallel enough that one stuck listener (e.g. the defensive 5s get() + // timeout actually firing) can't stall the rest of the videos onscreen behind it. The + // original newCachedThreadPool was unbounded and could spin up a thread per concurrent + // video, each lingering for the 60s keep-alive afterwards. + val executorService: ExecutorService = Executors.newFixedThreadPool(4) fun shutdown() { executorService.shutdown() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt index e9bb15e63..b9b695a55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt @@ -35,7 +35,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -69,18 +68,11 @@ fun GifVideoView( accountViewModel: AccountViewModel, thumbhash: String? = null, ) { - // Keys are primitive width/height so a freshly parsed DimensionTag instance for the same - // event doesn't re-run this lambda — DimensionTag uses reference equality, not structural. - val dimW = dimensions?.width - val dimH = dimensions?.height - val ratio = - remember(videoUri, dimW, dimH) { - if (dimW != null && dimH != null && dimH > 0) { - dimW.toFloat() / dimH.toFloat() - } else { - MediaAspectRatioCache.get(videoUri) - } - } + // Pure read path — DimensionTag.aspectRatio() is a one-line int division and + // MediaAspectRatioCache.get() is a synchronized LruCache lookup. Wrapping this in + // remember() to avoid the recompute would cost more (slot read + N equality checks) + // than the work it saves; that's why this stays as a plain expression. + val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri) val autoPlay = accountViewModel.settings.autoPlayVideos() val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier val context = LocalContext.current From a8e7c6c598060c33b9a91e2d6090532cc768e6a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 14:27:22 +0000 Subject: [PATCH 17/38] revert(video): drop the videoPlayerButtonItemsFlow remember The remember(accountViewModel) { ... } I added was cargo-cult. The getter is just a chain of val property accesses (account.settings.syncedSettings.videoPlayer.buttonItems) returning the same StateFlow instance every call. collectAsStateWithLifecycle keys on that flow reference, which is identity-stable, so re-calling the getter on every recompose costs nothing meaningful and doesn't cause a re-subscription. Inline back to the original one-liner. --- .../service/playback/composable/controls/RenderTopButtons.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index 5d182bd84..c694c688c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -195,10 +195,7 @@ fun RenderTopButtons( modifier: Modifier, accountViewModel: AccountViewModel, ) { - // Hold the StateFlow itself across recompositions so collectAsStateWithLifecycle isn't - // keyed on the result of a property getter call that happens every recompose. - val buttonItemsFlow = remember(accountViewModel) { accountViewModel.videoPlayerButtonItemsFlow() } - val buttonItems by buttonItemsFlow.collectAsStateWithLifecycle() + val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle() val shareDialogVisible = remember { mutableStateOf(false) } val saveAction = rememberSaveMediaAction { context -> From 5c9bb64ab647ddf02ba5e8f62a134f5fa2fb99c1 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 26 Apr 2026 14:31:16 +0000 Subject: [PATCH 18/38] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-hi-rIN/strings.xml | 2 ++ amethyst/src/main/res/values-hu-rHU/strings.xml | 2 ++ amethyst/src/main/res/values-pl-rPL/strings.xml | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index d15a5fdd5..d746ed323 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -2199,6 +2199,8 @@ एआई॰ लेखन सहायता लेख शोधन प्रस्ताव करें यन्त्र स्थित एआई॰ प्रतिरूप का प्रयोग करता है लेख सुधार तथा स्वर परिवर्तन प्रस्तावों के लिए। + पदचिह्न युक्त प्रसारण + पदचिह्न युक्त प्रसारक का उपयोग करें घटनाओं को भेजते समय। तत्काल प्रगति दिखाता है तथा प्रत्येक पुनःप्रसारक की स्थिति प्रसारण करते समय। इसका प्रयोग करें हटाएँ सम्यक diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 22672f68f..bab00d59d 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -2199,6 +2199,8 @@ Súgó az LLM-alapú íráshoz Javaslatok a szöveg javítására Az eszközön futó LLM-modell segítségével szövegjavításokkal és hangnemváltoztatásokkal kapcsolatos javaslatokat kaphat. + Nyomon követhető közvetítések + Nyomon követhető közvetítők használata az események küldesékor. Közvetítés közben megjeleníti a jelenlegi előrehaladást és az egyes átjátszók állapotát. Ennek használata Eltüntetés Javítás diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 45a3ae1ac..c1e616795 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1147,8 +1147,8 @@ Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satosze Procentowo 25 - Podziel zapsy z - Przesyłanie zapasów do + Podziel zapy z + Przesyłanie zapów do Nie znaleziono portfeli Lightning Zapłacone Portfel %1$s From bf540db557e4129ddec078fab36d8ea11d9369fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 14:54:03 +0000 Subject: [PATCH 19/38] perf(note types): cache event-derived values and hoist static modifiers Reduce per-recomposition allocation cost for note rows that render inside LazyColumn feeds: - AudioTrack: add `noteEvent` keys to `remember` for media/cover/subject/ participants/waveform/content so the cached values invalidate when the underlying event changes - Classifieds: wrap `imageMetas().map { MediaUrlImage(...) }`, title, summary, price and location in `remember(noteEvent)` so they aren't recomputed on every recomposition; hoist the static price-tag modifier to a top-level `val` - Report: collapse the per-recomposition `map { stringRes(...) }` chain into a single `remember(reportTypes, noteEvent)` over a deduplicated set of report types, and key the `base` collection by `noteEvent` - Highlight: key the URL-parse `remember` by `url` so it actually re-validates when the parameter changes - PrivateMessage: key `remember { noteEvent.with(...) }` by `noteEvent`, drop the silly `remember { Modifier.fillMaxWidth() }` wrapper, and key `isLoggedUser` by `note.author` instead of `note.event?.id` - PictureDisplay / FileHeader / Video: drop the unnecessary `mutableStateOf(...)` wrap inside `remember` blocks that produce immutable `BaseMediaContent` values; cache `images.map { it.url }` preload list, and key `title`/`summary`/`image`/`isYouTube` by event - Poll: add the missing `it.label` and `card` keys to `remember` blocks that derive booleans from those parameters - MeetingSpace: hoist the three `MeetingSpace*Flag` modifier chains to top-level `val`s instead of allocating them each composition --- .../amethyst/ui/note/types/AudioTrack.kt | 14 ++-- .../amethyst/ui/note/types/Classifieds.kt | 55 +++++++++------- .../amethyst/ui/note/types/FileHeader.kt | 54 ++++++++-------- .../amethyst/ui/note/types/Highlight.kt | 2 +- .../amethyst/ui/note/types/MeetingSpace.kt | 42 ++++++------ .../amethyst/ui/note/types/PictureDisplay.kt | 41 ++++++------ .../amethyst/ui/note/types/Poll.kt | 4 +- .../amethyst/ui/note/types/PrivateMessage.kt | 7 +- .../amethyst/ui/note/types/Report.kt | 60 +++++++++-------- .../amethyst/ui/note/types/Video.kt | 64 +++++++++---------- 10 files changed, 174 insertions(+), 169 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt index 865d7c0b9..0c5a3a04e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -80,10 +80,10 @@ fun AudioTrackHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val media = remember { noteEvent.media() } - val cover = remember { noteEvent.cover() } - val subject = remember { noteEvent.subject() } - val participants = remember { noteEvent.participants() } + val media = remember(noteEvent) { noteEvent.media() } + val cover = remember(noteEvent) { noteEvent.cover() } + val subject = remember(noteEvent) { noteEvent.subject() } + val participants = remember(noteEvent) { noteEvent.participants() } var participantUsers by remember { mutableStateOf>>(emptyList()) } @@ -183,9 +183,9 @@ fun AudioHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val media = remember { noteEvent.stream() ?: noteEvent.download() } - val waveform = remember { noteEvent.wavefrom()?.let { WaveformData(it.wave) } } - val content = remember { noteEvent.content.ifBlank { null } } + val media = remember(noteEvent) { noteEvent.stream() ?: noteEvent.download() } + val waveform = remember(noteEvent) { noteEvent.wavefrom()?.let { WaveformData(it.wave) } } + val content = remember(noteEvent) { noteEvent.content.ifBlank { null } } val defaultBackground = MaterialTheme.colorScheme.background val background = remember { mutableStateOf(defaultBackground) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt index dea8fee72..816dc08e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt @@ -51,8 +51,14 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +private val PriceTagModifier = + Modifier + .clip(SmallBorder) + .padding(start = 5.dp) + @Composable fun RenderClassifieds( noteEvent: ClassifiedsEvent, @@ -60,23 +66,31 @@ fun RenderClassifieds( accountViewModel: AccountViewModel, nav: INav, ) { - val imageSet = - noteEvent.imageMetas().ifEmpty { null }?.map { - MediaUrlImage( - url = it.url, - description = it.alt, - hash = it.hash, - blurhash = it.blurhash, - dim = it.dimension, - uri = note.toNostrUri(), - mimeType = it.mimeType, - thumbhash = it.thumbhash, - ) + val imageSet: ImmutableList? = + remember(noteEvent) { + noteEvent + .imageMetas() + .ifEmpty { null } + ?.map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = note.toNostrUri(), + mimeType = it.mimeType, + thumbhash = it.thumbhash, + ) + }?.toImmutableList() } - val title = noteEvent.title() - val summary = noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } - val price = noteEvent.price() - val location = noteEvent.location() + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { + noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } + } + val price = remember(noteEvent) { noteEvent.price() } + val location = remember(noteEvent) { noteEvent.location() } Row( modifier = @@ -94,7 +108,7 @@ fun RenderClassifieds( AutoNonlazyGrid(images.size) { ZoomableContentView( content = images[it], - images = images.toImmutableList(), + images = images, roundedCorner = false, contentScale = ContentScale.Crop, accountViewModel = accountViewModel, @@ -140,12 +154,7 @@ fun RenderClassifieds( maxLines = 1, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, - modifier = - remember { - Modifier - .clip(SmallBorder) - .padding(start = 5.dp) - }, + modifier = PriceTagModifier, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 4ad82bbff..62d91d39a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent @@ -46,7 +44,7 @@ fun FileHeaderDisplay( val event = (note.event as? FileHeaderEvent) ?: return val fullUrl = event.url() ?: return - val content by + val content: BaseMediaContent = remember(note) { val blurHash = event.blurhash() val thumbHash = event.thumbhash() @@ -57,32 +55,30 @@ fun FileHeaderDisplay( val uri = note.toNostrUri() val mimeType = event.mimeType() - mutableStateOf( - if (isImage) { - MediaUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - mimeType = mimeType, - thumbhash = thumbHash, - ) - } else { - MediaUrlVideo( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - mimeType = mimeType, - thumbhash = thumbHash, - ) - }, - ) + if (isImage) { + MediaUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + mimeType = mimeType, + thumbhash = thumbHash, + ) + } else { + MediaUrlVideo( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + mimeType = mimeType, + thumbhash = thumbHash, + ) + } } SensitivityWarning(note = note, accountViewModel = accountViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index 26a947d0c..40f080cfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -357,7 +357,7 @@ fun DisplayEntryForAUrl( } val validatedUrl = - remember { + remember(url) { try { URL(url) } catch (_: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt index fad48ce65..115fc235c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt @@ -278,6 +278,24 @@ private fun RenderParticipants( } } +private val MeetingSpaceOpenModifier = + Modifier + .clip(SmallBorder) + .background(Color(0xFF4CAF50)) + .padding(horizontal = 5.dp) + +private val MeetingSpacePrivateModifier = + Modifier + .clip(SmallBorder) + .background(Color(0xFFFF9800)) + .padding(horizontal = 5.dp) + +private val MeetingSpaceClosedModifier = + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(horizontal = 5.dp) + @Composable fun MeetingSpaceOpenFlag() { Text( @@ -285,13 +303,7 @@ fun MeetingSpaceOpenFlag() { color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color(0xFF4CAF50)) - .padding(horizontal = 5.dp) - }, + modifier = MeetingSpaceOpenModifier, ) } @@ -302,13 +314,7 @@ fun MeetingSpacePrivateFlag() { color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color(0xFFFF9800)) - .padding(horizontal = 5.dp) - }, + modifier = MeetingSpacePrivateModifier, ) } @@ -319,12 +325,6 @@ fun MeetingSpaceClosedFlag() { color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color.Black) - .padding(horizontal = 5.dp) - }, + modifier = MeetingSpaceClosedModifier, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index 3015959a2..f5fc59495 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -30,8 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -71,30 +69,29 @@ fun PictureDisplay( val isSensitive = remember(note) { event.isSensitiveOrNSFW() } val reasons = remember(note) { collectContentWarningReasons(event) } - val images by + val images = remember(note) { - mutableStateOf( - event - .imetaTags() - .map { - MediaUrlImage( - url = it.url, - description = it.alt, - hash = it.hash, - blurhash = it.blurhash, - dim = it.dimension, - uri = uri, - mimeType = it.mimeType, - thumbhash = it.thumbhash, - ) - }.toImmutableList(), - ) + event + .imetaTags() + .map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = uri, + mimeType = it.mimeType, + thumbhash = it.thumbhash, + ) + }.toImmutableList() } val first = images.firstOrNull() if (first != null) { - val title = event.title() + val title = remember(event) { event.title() } + val preloadUrls = remember(images) { images.map { it.url } } Column { if (title != null) { @@ -114,7 +111,7 @@ fun PictureDisplay( ContentWarningGate( isSensitive = isSensitive, reasons = reasons, - preloadUrls = listOf(first.url), + preloadUrls = preloadUrls, accountViewModel = accountViewModel, modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), backdrop = (first.thumbhash ?: first.blurhash)?.let { { BlurhashBackdrop(first.blurhash, first.description, first.thumbhash) } }, @@ -131,7 +128,7 @@ fun PictureDisplay( ContentWarningGate( isSensitive = isSensitive, reasons = reasons, - preloadUrls = images.map { it.url }, + preloadUrls = preloadUrls, accountViewModel = accountViewModel, modifier = Modifier.fillMaxWidth().aspectRatio(1f), backdrop = { BlurhashGridBackdrop(images) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 8edda46dd..6b7d0896a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -358,7 +358,7 @@ private fun ColumnScope.RenderSingleChoiceOptions( verticalAlignment = Alignment.CenterVertically, ) { val hasSpaceToClick = - remember { + remember(it.label) { it.label.contains(' ') || it.label.contains('\n') } @@ -436,7 +436,7 @@ private fun RenderResults( labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), ) { val showGallery = - remember { + remember(card) { card.options.all { it.label.length < 50 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt index 80c232c97..eec7c4b84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt @@ -85,12 +85,11 @@ fun RenderPrivateMessage( } } - val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } + val withMe = remember(noteEvent) { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } if (withMe) { LoadDecryptedContent(note, accountViewModel) { eventContent -> - val modifier = remember(note.event?.id) { Modifier.fillMaxWidth() } val isAuthorTheLoggedUser = - remember(note.event?.id) { accountViewModel.isLoggedUser(note.author) } + remember(note.author) { accountViewModel.isLoggedUser(note.author) } val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } @@ -113,7 +112,7 @@ fun RenderPrivateMessage( content = eventContent, canPreview = canPreview && !makeItShort, quotesLeft = quotesLeft, - modifier = modifier, + modifier = Modifier.fillMaxWidth(), tags = tags, backgroundColor = backgroundColor, id = note.idHex, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt index 7585c9b55..49981b8de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt @@ -48,35 +48,43 @@ fun RenderReport( ) { val noteEvent = note.event as? ReportEvent ?: return - val base = remember { (noteEvent.reportedPost() + noteEvent.reportedAuthor()) } + val reportTypes = + remember(noteEvent) { + (noteEvent.reportedPost() + noteEvent.reportedAuthor()) + .mapTo(LinkedHashSet()) { it.type } + } - val reportType = - base - .map { - when (it.type) { - ReportType.EXPLICIT -> stringRes(R.string.explicit_content) - ReportType.NUDITY -> stringRes(R.string.nudity) - ReportType.PROFANITY -> stringRes(R.string.profanity_hateful_speech) - ReportType.SPAM -> stringRes(R.string.spam) - ReportType.IMPERSONATION -> stringRes(R.string.impersonation) - ReportType.ILLEGAL -> stringRes(R.string.illegal_behavior) - ReportType.MALWARE -> stringRes(R.string.malware) - ReportType.OTHER -> stringRes(R.string.other) - ReportType.HARASSMENT -> stringRes(R.string.harassment) - ReportType.VIOLENCE -> stringRes(R.string.violence) - null -> stringRes(R.string.other) - } - }.toSet() - .joinToString(", ") + val explicitContent = stringRes(R.string.explicit_content) + val nudity = stringRes(R.string.nudity) + val profanity = stringRes(R.string.profanity_hateful_speech) + val spam = stringRes(R.string.spam) + val impersonation = stringRes(R.string.impersonation) + val illegal = stringRes(R.string.illegal_behavior) + val malware = stringRes(R.string.malware) + val other = stringRes(R.string.other) + val harassment = stringRes(R.string.harassment) + val violence = stringRes(R.string.violence) val content = - remember { - reportType + ( - note.event - ?.content - ?.ifBlank { null } - ?.let { ": $it" } ?: "" - ) + remember(reportTypes, noteEvent) { + val reportTypeText = + reportTypes.joinToString(", ") { + when (it) { + ReportType.EXPLICIT -> explicitContent + ReportType.NUDITY -> nudity + ReportType.PROFANITY -> profanity + ReportType.SPAM -> spam + ReportType.IMPERSONATION -> impersonation + ReportType.ILLEGAL -> illegal + ReportType.MALWARE -> malware + ReportType.OTHER -> other + ReportType.HARASSMENT -> harassment + ReportType.VIOLENCE -> violence + null -> other + } + } + val extra = noteEvent.content.ifBlank { null }?.let { ": $it" } ?: "" + reportTypeText + extra } TranslatableRichTextViewer( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 1c2399218..d00bd6d2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -30,8 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -81,45 +79,43 @@ fun VideoDisplay( val imeta = videoEvent.imetaTags().firstOrNull() ?: return - val title = videoEvent.title() - val summary = videoEvent.content.ifBlank { null }?.takeIf { title != it } - val image = imeta.image.firstOrNull() - val isYouTube = imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") + val title = remember(videoEvent) { videoEvent.title() } + val summary = remember(videoEvent, title) { videoEvent.content.ifBlank { null }?.takeIf { title != it } } + val image = remember(imeta) { imeta.image.firstOrNull() } + val isYouTube = remember(imeta) { imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") } val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } - val content by + val content: BaseMediaContent = remember(note) { val description = videoEvent.content.ifBlank { null } ?: event.alt() val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) val uri = note.toNostrUri() - mutableStateOf( - if (isImage) { - MediaUrlImage( - url = imeta.url, - description = description, - hash = imeta.hash, - blurhash = imeta.blurhash, - dim = imeta.dimension, - uri = uri, - mimeType = imeta.mimeType, - thumbhash = imeta.thumbhash, - ) - } else { - MediaUrlVideo( - url = imeta.url, - description = description, - hash = imeta.hash, - dim = imeta.dimension, - uri = uri, - authorName = note.author?.toBestDisplayName(), - artworkUri = imeta.image.firstOrNull(), - mimeType = imeta.mimeType, - blurhash = imeta.blurhash, - thumbhash = imeta.thumbhash, - ) - }, - ) + if (isImage) { + MediaUrlImage( + url = imeta.url, + description = description, + hash = imeta.hash, + blurhash = imeta.blurhash, + dim = imeta.dimension, + uri = uri, + mimeType = imeta.mimeType, + thumbhash = imeta.thumbhash, + ) + } else { + MediaUrlVideo( + url = imeta.url, + description = description, + hash = imeta.hash, + dim = imeta.dimension, + uri = uri, + authorName = note.author?.toBestDisplayName(), + artworkUri = imeta.image.firstOrNull(), + mimeType = imeta.mimeType, + blurhash = imeta.blurhash, + thumbhash = imeta.thumbhash, + ) + } } SensitivityWarning(note = note, accountViewModel = accountViewModel) { From 6aecfe016ba4c1715741a7913d25e6d739bc0a85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 15:34:22 +0000 Subject: [PATCH 20/38] =?UTF-8?q?perf(note=20types):=20second=20pass=20?= =?UTF-8?q?=E2=80=94=20fix=20more=20missing=20remember=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the first perf pass. Same goal: cut allocation cost for note rows that scroll inside LazyColumn feeds. - AppDefinition: key the `remember { tags.toImmutableListOfLists() }` block by `note` so it actually invalidates when the note changes - NIP90ContentDiscoveryResponse: drop the `remember(note) { Modifier.fillMaxWidth() }` wrapper — `Modifier.fillMaxWidth()` is a constant call - PeopleList: key the `derivedStateOf` for `name` by `noteEvent`, and switch `LaunchedEffect(Unit)` to `LaunchedEffect(noteEvent)` so the participants reload when the underlying event changes - PinList: replace `val pins by remember { mutableStateOf(noteEvent .pinnedEvents()) }` with `val pins = remember(noteEvent) { … }` — the `mutableStateOf` wrapper was unnecessary and the missing key meant `pins` could go stale on event updates - LongForm: return the `topics` list as `ImmutableList` so Compose treats it as a stable parameter to the consuming `forEach` - RelayList: drop the `mutableStateOf(RelayListCard(…))` wrap inside 4 `remember` blocks (DisplayRelaySet, DisplayNIP65RelayList write/ read, DisplayDMRelayList) — the value never changes after creation; also key by `noteEvent` rather than `baseNote`, and cache `noteEvent.description()` - Torrent: wrap `noteEvent.title() + totalSizeBytes()`, content comparison and `files().toImmutableList()` in `remember(noteEvent)` so they don't recompute and reallocate on every recomposition --- .../amethyst/ui/note/types/AppDefinition.kt | 2 +- .../amethyst/ui/note/types/LongForm.kt | 10 ++++- .../types/NIP90ContentDiscoveryResponse.kt | 3 +- .../amethyst/ui/note/types/PeopleList.kt | 4 +- .../amethyst/ui/note/types/PinList.kt | 2 +- .../amethyst/ui/note/types/RelayList.kt | 42 ++++++++----------- .../amethyst/ui/note/types/Torrent.kt | 25 ++++++----- 7 files changed, 47 insertions(+), 41 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index acf1df765..469f6fa9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -216,7 +216,7 @@ fun RenderAppDefinition( CreateTextWithEmoji( text = it, tags = - remember { + remember(note) { (note.event?.tags ?: emptyArray()).toImmutableListOfLists() }, fontWeight = FontWeight.Bold, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt index f36864286..6ba446027 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import kotlinx.collections.immutable.toImmutableList private const val WORDS_PER_MINUTE = 225 private val COVER_ASPECT_RATIO = 16f / 9f @@ -92,7 +93,14 @@ fun LongFormHeader( remember(noteEvent) { noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } } - val topics = remember(noteEvent) { noteEvent.topics().distinct().take(3) } + val topics = + remember(noteEvent) { + noteEvent + .topics() + .distinct() + .take(3) + .toImmutableList() + } val readingMinutes = remember(noteEvent) { estimateReadingMinutes(noteEvent.content) } Column(MaterialTheme.colorScheme.replyModifier) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt index 5f73c4c0c..91ea6a7a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt @@ -54,7 +54,6 @@ fun RenderNIP90ContentDiscoveryResponse( note = note, accountViewModel = accountViewModel, ) { - val modifier = remember(note) { Modifier.fillMaxWidth() } val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } @@ -62,7 +61,7 @@ fun RenderNIP90ContentDiscoveryResponse( content = noteEvent.content, canPreview = canPreview && !makeItShort, quotesLeft = quotesLeft, - modifier = modifier, + modifier = Modifier.fillMaxWidth(), tags = tags, backgroundColor = backgroundColor, id = note.idHex, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt index dbd7fb2d9..93abb98b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt @@ -81,7 +81,7 @@ fun DisplayPeopleList( members.take(3) } - val name by remember { derivedStateOf { "#${noteEvent.titleOrName() ?: noteEvent.dTag()}" } } + val name by remember(noteEvent) { derivedStateOf { "#${noteEvent.titleOrName() ?: noteEvent.dTag()}" } } Text( text = name, @@ -95,7 +95,7 @@ fun DisplayPeopleList( textAlign = TextAlign.Center, ) - LaunchedEffect(Unit) { + LaunchedEffect(noteEvent) { accountViewModel.loadUsers(noteEvent.taggedUserIds()) { members = it } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt index 105a7c898..9f19533c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt @@ -65,7 +65,7 @@ fun RenderPinListEvent( ) { val noteEvent = baseNote.event as? PinListEvent ?: return - val pins by remember { mutableStateOf(noteEvent.pinnedEvents()) } + val pins = remember(noteEvent) { noteEvent.pinnedEvents() } var expanded by remember { mutableStateOf(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index ca15807f1..6b1521866 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -75,12 +75,10 @@ fun DisplayRelaySet( ) { val noteEvent = baseNote.event as? RelaySetEvent ?: return - val relays by + val relays = remember(noteEvent) { - mutableStateOf( - RelayListCard( - noteEvent.relays().toImmutableList(), - ), + RelayListCard( + noteEvent.relays().toImmutableList(), ) } @@ -89,10 +87,12 @@ fun DisplayRelaySet( noteEvent.tags.firstTagValueFor("title", "name") ?: "#${noteEvent.dTag()}" } + val description = remember(noteEvent) { noteEvent.description() } + DisplayRelaySet( relays, relayListName, - noteEvent.description(), + description, backgroundColor, accountViewModel, nav, @@ -108,21 +108,17 @@ fun DisplayNIP65RelayList( ) { val noteEvent = baseNote.event as? AdvertisedRelayListEvent ?: return - val writeRelays by - remember(baseNote) { - mutableStateOf( - RelayListCard( - noteEvent.writeRelaysNorm() ?: emptyList(), - ), + val writeRelays = + remember(noteEvent) { + RelayListCard( + noteEvent.writeRelaysNorm() ?: emptyList(), ) } - val readRelays by - remember(baseNote) { - mutableStateOf( - RelayListCard( - noteEvent.readRelaysNorm() ?: emptyList(), - ), + val readRelays = + remember(noteEvent) { + RelayListCard( + noteEvent.readRelaysNorm() ?: emptyList(), ) } @@ -154,12 +150,10 @@ fun DisplayDMRelayList( ) { val noteEvent = baseNote.event as? ChatMessageRelayListEvent ?: return - val relays by - remember(baseNote) { - mutableStateOf( - RelayListCard( - noteEvent.relays(), - ), + val relays = + remember(noteEvent) { + RelayListCard( + noteEvent.relays(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt index 49ebe1cbb..de4f5c3ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt @@ -153,19 +153,24 @@ fun RenderTorrent( ) { val noteEvent = note.event as? TorrentEvent ?: return - val name = (noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION) - val size = " (" + countToHumanReadableBytes(noteEvent.totalSizeBytes()) + ")" - - val description = - if (noteEvent.content != name) { - noteEvent.content - } else { - null + val title = + remember(noteEvent) { + val name = noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION + val size = " (" + countToHumanReadableBytes(noteEvent.totalSizeBytes()) + ")" + name + size } + val description = + remember(noteEvent) { + val name = noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION + if (noteEvent.content != name) noteEvent.content else null + } + + val files = remember(noteEvent) { noteEvent.files().toImmutableList() } + DisplayFileList( - noteEvent.files().toImmutableList(), - name + size, + files, + title, description, noteEvent::toMagnetLink, backgroundColor, From 06340dbdf9770e9126c070fbc64e5da0514857a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 15:44:28 +0000 Subject: [PATCH 21/38] fix(chats): stable per-chatroom LazyColumn key for messages list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chatroom list keyed each row by `if (index == 0) index else item.idHex`. Two problems: 1. Position 0 was hardcoded to key `0`, so when a new chatroom moved to the top, the existing composition slot was reused with state from the previous chatroom — observed as "the row updated and reordered but still shows the old result" right at the top. 2. For other positions, the key was the latest message's `idHex`. When a new message arrived in any chatroom the chatroom's representative Note got replaced (different idHex), so Compose threw away the row and rebuilt it from scratch — wasted work. Fix: derive a stable key from chatroom identity instead of message id — nostr group id for marmot rooms, channel id for public/ephemeral channels, sorted user set for DMs. Falls back to `item.idHex` for unrecognized event types (drafts etc.). Reorders now move the row; new-message updates re-use the slot. --- .../chats/rooms/feed/ChatroomListFeedView.kt | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index b702de4d4..2845170f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -25,14 +25,16 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError @@ -45,6 +47,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @Composable fun ChatroomListFeedView( @@ -103,14 +111,16 @@ private fun FeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + val myPubKey = accountViewModel.userProfile().pubkeyHex + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { - itemsIndexed( + items( items.list, - key = { index, item -> if (index == 0) index else item.idHex }, - ) { _, item -> + key = { item -> chatroomLazyKey(item, myPubKey) }, + ) { item -> Row(Modifier.fillMaxWidth()) { ChatroomHeaderCompose( item, @@ -125,3 +135,40 @@ private fun FeedLoaded( } } } + +// Stable per-chatroom key — derived from chatroom identity, not the latest +// message id, so reorders move the row instead of recreating it. +private fun chatroomLazyKey( + item: Note, + myPubKey: HexKey, +): String { + item.inGatherers + ?.firstNotNullOfOrNull { it as? MarmotGroupChatroom } + ?.let { return "marmot:${it.nostrGroupId}" } + + return when (val event = item.event) { + is ChannelMessageEvent -> { + "ch:${event.channelId() ?: item.idHex}" + } + + is ChannelMetadataEvent -> { + "ch:${event.channelId() ?: item.idHex}" + } + + is ChannelCreateEvent -> { + "ch:${event.id}" + } + + is EphemeralChatEvent -> { + "eph:${event.roomId()?.toKey() ?: item.idHex}" + } + + is ChatroomKeyable -> { + "dm:${event.chatroomKey(myPubKey).users.sorted().joinToString(",")}" + } + + else -> { + item.idHex + } + } +} From 46f305fe1a36454ada6221ad9961c1c85ad9971e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 15:51:15 +0000 Subject: [PATCH 22/38] perf(chats): typed sealed key instead of concatenated string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix used `"ch:${id}"`, `"dm:${users.sorted().joinToString}"` etc., which allocates a StringBuilder + char[] + new String per call — worst case for the DM branch which also allocates a sorted List on top. Replace with a sealed `ChatroomLazyKey` and per-type data classes that just wrap the existing String / RoomId / ChatroomKey. Equality and hashCode are auto-generated, so Compose still moves rows correctly on reorder, and we drop most of the per-key allocations: ch:abc -> PublicChannelLazyKey(abc) # 1 wrapper, reused String dm:userA,userB -> PrivateChatLazyKey(chatroomKey) # 1 wrapper, reused ChatroomKey eph:roomId -> EphemeralChannelLazyKey(roomId) # 1 wrapper, reused RoomId --- .../chats/rooms/feed/ChatroomListFeedView.kt | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 2845170f8..b852165f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -48,7 +48,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderC import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -137,38 +139,63 @@ private fun FeedLoaded( } // Stable per-chatroom key — derived from chatroom identity, not the latest -// message id, so reorders move the row instead of recreating it. +// message id, so reorders move the row instead of recreating it. Uses a +// sealed wrapper around an existing String/RoomId/ChatroomKey to avoid the +// StringBuilder + concatenation allocations of a typed-prefix string key. +private sealed interface ChatroomLazyKey + +private data class MarmotChatroomLazyKey( + val groupId: HexKey, +) : ChatroomLazyKey + +private data class PublicChannelLazyKey( + val channelId: HexKey, +) : ChatroomLazyKey + +private data class EphemeralChannelLazyKey( + val roomId: RoomId, +) : ChatroomLazyKey + +private data class PrivateChatLazyKey( + val key: ChatroomKey, +) : ChatroomLazyKey + +private data class FallbackChatroomLazyKey( + val noteIdHex: HexKey, +) : ChatroomLazyKey + private fun chatroomLazyKey( item: Note, myPubKey: HexKey, -): String { +): ChatroomLazyKey { item.inGatherers ?.firstNotNullOfOrNull { it as? MarmotGroupChatroom } - ?.let { return "marmot:${it.nostrGroupId}" } + ?.let { return MarmotChatroomLazyKey(it.nostrGroupId) } return when (val event = item.event) { is ChannelMessageEvent -> { - "ch:${event.channelId() ?: item.idHex}" + PublicChannelLazyKey(event.channelId() ?: item.idHex) } is ChannelMetadataEvent -> { - "ch:${event.channelId() ?: item.idHex}" + PublicChannelLazyKey(event.channelId() ?: item.idHex) } is ChannelCreateEvent -> { - "ch:${event.id}" + PublicChannelLazyKey(event.id) } is EphemeralChatEvent -> { - "eph:${event.roomId()?.toKey() ?: item.idHex}" + event.roomId()?.let { EphemeralChannelLazyKey(it) } + ?: FallbackChatroomLazyKey(item.idHex) } is ChatroomKeyable -> { - "dm:${event.chatroomKey(myPubKey).users.sorted().joinToString(",")}" + PrivateChatLazyKey(event.chatroomKey(myPubKey)) } else -> { - item.idHex + FallbackChatroomLazyKey(item.idHex) } } } From 179642d78b2796d165ac4cc7feff23bb7bf2987d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 16:54:26 +0000 Subject: [PATCH 23/38] fix(playback): hoist DataSourceBitmapLoader build into a function Android Lint's UnsafeOptInUsageError doesn't recognize @OptIn placed on a `by lazy` property as covering the lambda body, so each Media3 unstable-API call inside the initializer (Builder, setExecutorService, setDataSourceFactory, build) was flagged. Move the construction into a real function carrying the @OptIn annotation; the lazy delegate just calls it. No behavior change. --- .../service/playback/playerPool/MediaSessionPool.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index cda75c6ce..290ab7bc9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -81,14 +81,17 @@ class MediaSessionPool( // The bitmap loader is stateless w.r.t. the session; a fresh allocation per session was // pure noise. ExoPlayer's DEFAULT_EXECUTOR_SERVICE is a process-wide singleton, the // dataSourceFactory is owned by the pool, and the appContext is already retained. + // The init is in a separate function so the @OptIn lands on a real declaration — + // applying it to a `by lazy` property doesn't propagate into the lambda body. + private val sharedBitmapLoader by lazy { buildSharedBitmapLoader() } + @OptIn(UnstableApi::class) - private val sharedBitmapLoader by lazy { + private fun buildSharedBitmapLoader(): DataSourceBitmapLoader = DataSourceBitmapLoader .Builder(appContext) .setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get()) .setDataSourceFactory(dataSourceFactory) .build() - } // protects from LruCache killing playing sessions private val playingMap = mutableMapOf() From 6f04edd99522e11693854326aa38ec3b133e2bae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 17:02:53 +0000 Subject: [PATCH 24/38] perf(ui): drop remember wrappers where overhead exceeds savings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cases removed: - Trivial Modifier allocations (Modifier.weight/padding/size) — slot table cost dominates the cost of building a fresh Modifier each recomposition. - Map.keys views over relayStatuses on desktop screens — .keys is a property read on the same map, no need to memoize. - coerceIn() arithmetic on AudioWaveform Dp/Float params — two compares are cheaper than the slot table read+compare. - fadeIn()/fadeOut() in AnimatedVisibility — small EnterTransition allocations that don't justify the slot table overhead. Audit-only changes; no behavior changes. https://claude.ai/code/session_011Ea2pVjwvCEx7X4izwryV4 --- .../ui/components/AudioWaveformReadOnly.kt | 14 +++++--------- .../ui/components/ZoomableContentDialog.kt | 4 ++-- .../amethyst/ui/components/ZoomableContentView.kt | 8 ++++---- .../amethyst/ui/note/MultiSetCompose.kt | 6 +++--- .../vitorpamplona/amethyst/ui/note/NoteCompose.kt | 2 +- .../vitorpamplona/amethyst/ui/note/UserCompose.kt | 3 +-- .../amethyst/ui/note/UserReactionsRow.kt | 9 ++++----- .../amethyst/ui/note/ZapUserSetCompose.kt | 5 ++--- .../header/LongPublicChatChannelHeader.kt | 2 +- .../header/LongLiveActivityChannelHeader.kt | 2 +- .../loggedIn/chats/utils/DisplayReplyingToNote.kt | 3 +-- .../screen/loggedIn/threadview/ThreadFeedView.kt | 2 +- .../amethyst/desktop/ui/BookmarksScreen.kt | 2 +- .../amethyst/desktop/ui/FeedScreen.kt | 2 +- .../amethyst/desktop/ui/NotificationsScreen.kt | 2 +- .../amethyst/desktop/ui/ReadsScreen.kt | 2 +- .../amethyst/desktop/ui/SearchScreen.kt | 2 +- .../amethyst/desktop/ui/ThreadScreen.kt | 2 +- .../amethyst/desktop/ui/UserProfileScreen.kt | 2 +- .../amethyst/desktop/ui/chats/NewDmDialog.kt | 2 +- 20 files changed, 34 insertions(+), 42 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt index b23559684..ea26b2582 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt @@ -78,15 +78,11 @@ fun AudioWaveformReadOnly( amplitudes: List, onProgressChange: (Float) -> Unit, ) { - val progressState = remember(progress) { progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) } - val spikeWidthState = - remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) } - val spikePaddingState = - remember(spikePadding) { spikePadding.coerceIn(MinSpikePaddingDp, MaxSpikePaddingDp) } - val spikeRadiusState = - remember(spikeRadius) { spikeRadius.coerceIn(MinSpikeRadiusDp, MaxSpikeRadiusDp) } - val spikeTotalWidthState = - remember(spikeWidth, spikePadding) { spikeWidthState + spikePaddingState } + val progressState = progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) + val spikeWidthState = spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) + val spikePaddingState = spikePadding.coerceIn(MinSpikePaddingDp, MaxSpikePaddingDp) + val spikeRadiusState = spikeRadius.coerceIn(MinSpikeRadiusDp, MaxSpikeRadiusDp) + val spikeTotalWidthState = spikeWidthState + spikePaddingState var canvasSize by remember { mutableStateOf(Size(0f, 0f)) } var spikes by remember { mutableFloatStateOf(0F) } val spikesAmplitudes = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 7e0255b09..aa0f69ef9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -334,8 +334,8 @@ private fun DialogContent( AnimatedVisibility( visible = controllerVisible.value, - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, + enter = fadeIn(), + exit = fadeOut(), // Also fade with the grow animation so controls appear/disappear alongside it. modifier = Modifier.graphicsLayer { alpha = progress().coerceIn(0f, 1f) }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 0f67b1f25..dcfd488d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -404,8 +404,8 @@ fun LocalImageView( AnimatedVisibility( visible = controllerVisible.value, modifier = Modifier.align(Alignment.TopEnd), - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, + enter = fadeIn(), + exit = fadeOut(), ) { Box(Modifier.align(Alignment.TopEnd), contentAlignment = Alignment.TopEnd) { HashVerificationSymbol(it) @@ -649,8 +649,8 @@ fun ShowHashAnimated( AnimatedVisibility( visible = controllerVisible.value, modifier = modifier, - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, + enter = fadeIn(), + exit = fadeOut(), ) { Box(modifier, contentAlignment = Alignment.TopEnd) { ShowHash(content) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index f703eaef9..d3346f3f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -332,7 +332,7 @@ fun RenderZapGallery( modifier = WidthAuthorPictureModifier, ) { ZappedIcon( - modifier = remember { Modifier.size(Size25dp).align(Alignment.TopEnd) }, + modifier = Modifier.size(Size25dp).align(Alignment.TopEnd), ) } @@ -353,7 +353,7 @@ fun RenderBoostGallery( modifier = NotificationIconModifierSmaller, ) { RepostedIcon( - modifier = remember { Modifier.size(Size20dp).align(Alignment.TopEnd) }, + modifier = Modifier.size(Size20dp).align(Alignment.TopEnd), ) } @@ -374,7 +374,7 @@ fun RenderBoostGallery( modifier = NotificationIconModifierSmaller, ) { RepostedIcon( - modifier = remember { Modifier.size(Size20dp).align(Alignment.TopEnd) }, + modifier = Modifier.size(Size20dp).align(Alignment.TopEnd), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index fe2d96c5b..9b48d1d76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -1513,7 +1513,7 @@ fun SecondUserInfoRow( verticalAlignment = CenterVertically, modifier = UserNameMaxRowHeight, ) { - Column(modifier = remember { Modifier.weight(1f) }) { + Column(modifier = Modifier.weight(1f)) { if (noteEvent is IForkableEvent && noteEvent.isAFork()) { ShowForkInformation(noteEvent, Modifier, accountViewModel, nav) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 63177ae8f..f62a98b73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -28,7 +28,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @@ -136,7 +135,7 @@ fun UserComposeNoAction( ) { UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav) - Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { + Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser, accountViewModel = accountViewModel) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt index 04ccf503c..b0a160b8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -75,19 +74,19 @@ fun UserReactionsRow( ) } - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { UserReplyModel(model) } - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { UserBoostModel(model) } - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { UserReactionModel(model) } - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { UserZapModel(model) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index 3d3087aa6..2e91876a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -78,7 +77,7 @@ fun ZapUserSetCompose( modifier = Size55Modifier, ) { ZappedIcon( - remember { Modifier.size(Size25dp).align(Alignment.TopEnd) }, + Modifier.size(Size25dp).align(Alignment.TopEnd), ) } } @@ -103,7 +102,7 @@ fun ZapUserSetCompose( nav = nav, ) - Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { + Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(zapSetCard.user, accountViewModel = accountViewModel) } AboutDisplay(zapSetCard.user, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index 08441b657..f6d672af5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -167,7 +167,7 @@ fun LongPublicChatChannelHeader( modifier = Modifier.width(75.dp), ) Spacer(DoubleHorzSpacer) - NormalTimeAgo(note, remember { Modifier.weight(1f) }) + NormalTimeAgo(note, Modifier.weight(1f)) MoreOptionsButton(note, null, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LongLiveActivityChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LongLiveActivityChannelHeader.kt index 941fcfbc8..b7be2f297 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LongLiveActivityChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LongLiveActivityChannelHeader.kt @@ -116,7 +116,7 @@ fun LongLiveActivityChannelHeader( modifier = Modifier.width(75.dp), ) Spacer(DoubleHorzSpacer) - NormalTimeAgo(note, remember { Modifier.weight(1f) }) + NormalTimeAgo(note, Modifier.weight(1f)) MoreOptionsButton(note, null, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt index 2896d82b2..5648c69fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -57,7 +56,7 @@ fun DisplayReplyingToNote( .animateContentSize(), ) { if (replyingNote != null) { - Column(remember { Modifier.weight(1f) }) { + Column(Modifier.weight(1f)) { ChatroomMessageCompose( baseNote = replyingNote, null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index f15309b04..4cd1283e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -535,7 +535,7 @@ private fun FullBleedNoteCompose( Row(verticalAlignment = Alignment.CenterVertically) { Column( - remember { Modifier.weight(1f) }, + Modifier.weight(1f), ) { if (noteEvent is IForkableEvent && noteEvent.isAFork()) { ShowForkInformation(noteEvent, Modifier, accountViewModel, nav) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index 54a2b1626..f38e06456 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -79,7 +79,7 @@ fun BookmarksScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays = remember(relayStatuses) { relayStatuses.keys } + val connectedRelays = relayStatuses.keys val scope = rememberCoroutineScope() // Tab state 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 c24e30d2a..d84c3d167 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 @@ -280,7 +280,7 @@ fun FeedScreen( val followedUsers by localCache.followedUsers.collectAsState() // Available relay URLs — subscribe triggers connection on-demand - val allRelayUrls = remember(relayStatuses) { relayStatuses.keys } + val allRelayUrls = relayStatuses.keys // Feed relays from relay categories (NIP-65 outbox, minus blocked, with fallback) val relayCategories = LocalRelayCategories.current diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 1429565d7..319d10111 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -115,7 +115,7 @@ fun NotificationsScreen( subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays = remember(relayStatuses) { relayStatuses.keys } + val connectedRelays = relayStatuses.keys val scope = rememberCoroutineScope() val notificationState = remember { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index dc783fcb1..37cb44f28 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -183,7 +183,7 @@ fun ReadsScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays = remember(relayStatuses) { relayStatuses.keys } + val connectedRelays = relayStatuses.keys val scope = rememberCoroutineScope() val eventState = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 5574aceb5..094d457f9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -130,7 +130,7 @@ fun SearchScreen( val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() - val allRelayUrls = remember(relayStatuses) { relayStatuses.keys } + val allRelayUrls = relayStatuses.keys val relayCategories = LocalRelayCategories.current val searchRelays by relayCategories.searchRelays.collectAsState() val displayText by state.displayText.collectAsState() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 894481dc0..d67732731 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -90,7 +90,7 @@ fun ThreadScreen( onReply: (Event) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays = remember(relayStatuses) { relayStatuses.keys } + val connectedRelays = relayStatuses.keys // Lightbox state var lightboxState by remember { mutableStateOf(null) } 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 81deb966a..5f8661a25 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 @@ -122,7 +122,7 @@ fun UserProfileScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays = remember(relayStatuses) { relayStatuses.keys } + val connectedRelays = relayStatuses.keys // User metadata — seed from cache so returning to profile is instant val cachedUser = remember(pubKeyHex) { localCache.getUserIfExists(pubKeyHex) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt index 485d92f91..32e6e1de4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -78,7 +78,7 @@ fun NewDmDialog( val relaySearchResults by searchState.relaySearchResults.collectAsState() val isSearchingRelays by searchState.isSearchingRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays = remember(relayStatuses) { relayStatuses.keys } + val connectedRelays = relayStatuses.keys val focusRequester = remember { FocusRequester() } // NIP-50 relay search when local cache has few/no results From f96b42f6b90581a26d7bbdc479d78edd85c327d1 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 26 Apr 2026 19:28:21 +0200 Subject: [PATCH 25/38] fix(lint): add @file:OptIn(UnstableApi::class) to MediaSessionPool Property-level @OptIn doesn't propagate through the lazy{} delegate body, so lint flags the DataSourceBitmapLoader.Builder chain (lines 87-90) with UnsafeOptInUsageError. File-level annotation is a one-line fix that lets :amethyst:lintPlayDebug pass without changing runtime semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../amethyst/service/playback/playerPool/MediaSessionPool.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index cda75c6ce..f627452b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -18,6 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@file:OptIn(UnstableApi::class) + package com.vitorpamplona.amethyst.service.playback.playerPool import android.app.PendingIntent From 9a0eee3414d3098c6f5ebf08f6726e913b9f32f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 18:42:12 +0000 Subject: [PATCH 26/38] fix(ui): pass FeedDefinition through FeedFilterSpinner.onSelect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog used to hand the caller an integer index into the latest options list, but the indexes were captured from a snapshot taken at remember time. If options changed (a new community/list arrived) between dialog open and tap, the user could pick "Community A" and have an unrelated entry selected. Pass the resolved FeedDefinition directly so the picked item can never drift. Other audit fixes folded into the same composable: - Match the placeholder by both subclass and code string so TopFilter variants that share an Address-derived code (PeopleList vs MuteList) no longer collide. - Drop the local mutableStateOf for `selected` and the derivedStateOf-in- remember for `currentText` — both were redundant with the StateFlow round-trip and caused an extra recomposition per pick. - De-duplicate RenderOption with Name.name(context) (also fixes the accessibility text disagreeing with the visible label for Geohash). - Pre-compute the ordered (group, items) list once per options change. - Drop IndexedFeedDefinition (no longer needed), use Spacer.width instead of a Spacer with start padding. --- .../navigation/topbars/FeedFilterSpinner.kt | 223 +++++++----------- .../loggedIn/articles/ArticlesTopBar.kt | 2 +- .../loggedIn/audiorooms/AudioRoomsTopBar.kt | 2 +- .../ui/screen/loggedIn/badges/BadgesTopBar.kt | 2 +- .../communities/list/CommunitiesTopBar.kt | 2 +- .../loggedIn/discover/DiscoveryTopBar.kt | 2 +- .../browse/BrowseEmojiSetsTopBar.kt | 2 +- .../followPacks/list/FollowPacksTopBar.kt | 2 +- .../ui/screen/loggedIn/home/HomeTopBar.kt | 2 +- .../loggedIn/livestreams/LiveStreamsTopBar.kt | 2 +- .../ui/screen/loggedIn/longs/LongsTopBar.kt | 2 +- .../notifications/NotificationTopBar.kt | 2 +- .../loggedIn/pictures/PicturesTopBar.kt | 2 +- .../ui/screen/loggedIn/polls/PollsTopBar.kt | 2 +- .../loggedIn/products/ProductsTopBar.kt | 2 +- .../loggedIn/publicChats/PublicChatsTopBar.kt | 2 +- .../ui/screen/loggedIn/shorts/ShortsTopBar.kt | 2 +- .../ui/screen/loggedIn/video/StoriesTopBar.kt | 2 +- 18 files changed, 101 insertions(+), 156 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 710259e15..4761b28b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -34,15 +34,14 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -69,7 +68,6 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote @@ -92,10 +90,6 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalPermissionsApi::class) @@ -104,32 +98,27 @@ fun FeedFilterSpinner( placeholderCode: TopFilter, explainer: String, options: ImmutableList, - onSelect: (Int) -> Unit, + onSelect: (FeedDefinition) -> Unit, modifier: Modifier = Modifier, accountViewModel: AccountViewModel, ) { var optionsShowing by remember { mutableStateOf(false) } val context = LocalContext.current - val selectAnOption = - stringRes( - id = R.string.select_an_option, - ) + val selectAnOption = stringRes(id = R.string.select_an_option) - var selected by + val selected = remember(placeholderCode, options) { - mutableStateOf( - options.firstOrNull { it.code.code == placeholderCode.code }, - ) - } - - val currentText by - remember(placeholderCode, options) { - derivedStateOf { - selected?.name?.name(context) ?: selectAnOption + // Match by both subclass and code string to avoid collisions between + // TopFilter variants that derive `code` from the same Address (e.g. + // PeopleList vs MuteList). + options.firstOrNull { + it.code::class == placeholderCode::class && it.code.code == placeholderCode.code } } + val currentText = selected?.name?.name(context) ?: selectAnOption + val accessibilityDescription = if (selected != null) { stringRes(R.string.feed_filter_selected, currentText) @@ -282,10 +271,9 @@ fun FeedFilterSpinner( title = explainer, options = options, onDismiss = { optionsShowing = false }, - onSelect = { - selected = options[it] + onSelect = { definition -> optionsShowing = false - onSelect(it) + onSelect(definition) }, ) { RenderOption(it.name, accountViewModel) @@ -298,6 +286,7 @@ fun RenderOption( option: Name, accountViewModel: AccountViewModel, ) { + val context = LocalContext.current when (option) { is GeoHashName -> { LoadCityName(option.geoHashTag) { @@ -305,74 +294,35 @@ fun RenderOption( } } - is HashtagName -> { - Text(text = option.name(), fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) - } - - is ResourceName -> { - Text( - text = stringRes(id = option.resourceId), - fontSize = Font14SP, - color = MaterialTheme.colorScheme.onSurface, - ) - } - + // Note-backed names: subscribe to the note so the displayed title updates as + // the corresponding event arrives from relays. The displayed string itself is + // produced by Name.name(), which already has the right precedence rules. is PeopleListName -> { val noteState by observeNote(option.note, accountViewModel) - - val noteEvent = noteState.note.event - val name = - when (noteEvent) { - is PeopleListEvent -> { - noteEvent.titleOrName() ?: option.note.dTag() - } - - is FollowListEvent -> { - noteEvent.title() ?: option.note.dTag() - } - - else -> { - option.note.dTag() - } - } - + val name = remember(noteState) { option.name(context) } Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } is CommunityName -> { - val it by observeNote(option.note, accountViewModel) - - val addressable = it.note as? AddressableNote - val definition = addressable?.event as? CommunityDefinitionEvent - val label = definition?.name()?.ifBlank { null } ?: addressable?.dTag() ?: "" - Text(text = "/n/$label", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) - } - - is RelayName -> { - Text( - text = option.name(), - fontSize = Font14SP, - color = MaterialTheme.colorScheme.onSurface, - ) + val noteState by observeNote(option.note, accountViewModel) + val name = remember(noteState) { option.name(context) } + Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } is FavoriteAlgoFeedName -> { val noteState by observeNote(option.note, accountViewModel) - val name = - (noteState.note.event as? AppDefinitionEvent) - ?.appMetaData() - ?.name - ?.takeIf { it.isNotBlank() } ?: option.note.dTag() - Text( - text = name, - fontSize = Font14SP, - color = MaterialTheme.colorScheme.onSurface, - ) + val name = remember(noteState) { option.name(context) } + Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } - is InterestSetName -> { + // Pure names: no relay subscription needed. + is HashtagName, + is ResourceName, + is RelayName, + is InterestSetName, + -> { Text( - text = option.name(), + text = option.name(context), fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface, ) @@ -380,12 +330,6 @@ fun RenderOption( } } -@Immutable -private data class IndexedFeedDefinition( - val originalIndex: Int, - val item: FeedDefinition, -) - private enum class FeedGroup( @param:androidx.annotation.StringRes val labelRes: Int, ) { @@ -399,48 +343,51 @@ private enum class FeedGroup( RELAYS(R.string.feed_group_relays), } -private fun groupFeedDefinitions(options: ImmutableList): Map> { - val indexed = options.mapIndexed { index, item -> IndexedFeedDefinition(index, item) } - return indexed.groupBy { entry -> - when (entry.item.name) { - is HashtagName -> { - FeedGroup.HASHTAGS - } +private fun FeedDefinition.group(): FeedGroup = + when (name) { + is HashtagName -> { + FeedGroup.HASHTAGS + } - is CommunityName -> { - FeedGroup.COMMUNITIES - } + is CommunityName -> { + FeedGroup.COMMUNITIES + } - is PeopleListName -> { - FeedGroup.LISTS - } + is PeopleListName -> { + FeedGroup.LISTS + } - is RelayName -> { - FeedGroup.RELAYS - } + is RelayName -> { + FeedGroup.RELAYS + } - is GeoHashName -> { - FeedGroup.LOCATIONS - } + is GeoHashName -> { + FeedGroup.LOCATIONS + } - is FavoriteAlgoFeedName -> { - FeedGroup.DVMS - } + is FavoriteAlgoFeedName -> { + FeedGroup.DVMS + } - is InterestSetName -> { - FeedGroup.INTEREST_SETS - } + is InterestSetName -> { + FeedGroup.INTEREST_SETS + } - is ResourceName -> { - when (entry.item.code) { - is TopFilter.AroundMe -> FeedGroup.LOCATIONS - is TopFilter.Global -> FeedGroup.RELAYS - is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS - else -> FeedGroup.FEEDS - } + is ResourceName -> { + when (code) { + is TopFilter.AroundMe -> FeedGroup.LOCATIONS + is TopFilter.Global -> FeedGroup.RELAYS + is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS + else -> FeedGroup.FEEDS } } } + +private fun groupFeedDefinitions(options: ImmutableList): List>> { + val grouped = options.groupBy { it.group() } + return FeedGroup.entries.mapNotNull { group -> + grouped[group]?.takeIf { it.isNotEmpty() }?.let { group to it } + } } @OptIn(ExperimentalLayoutApi::class) @@ -448,7 +395,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map, - onSelect: (Int) -> Unit, + onSelect: (FeedDefinition) -> Unit, onDismiss: () -> Unit, onRenderItem: @Composable (FeedDefinition) -> Unit, ) { @@ -472,18 +419,15 @@ private fun GroupedFeedFilterDialog( ) } - FeedGroup.entries.forEach { group -> - val items = grouped[group] - if (!items.isNullOrEmpty()) { - item { - GroupSection( - label = stringRes(group.labelRes), - items = items, - isChipLayout = group == FeedGroup.HASHTAGS, - onSelect = onSelect, - onRenderItem = onRenderItem, - ) - } + grouped.forEach { (group, items) -> + item(key = group) { + GroupSection( + label = stringRes(group.labelRes), + items = items, + isChipLayout = group == FeedGroup.HASHTAGS, + onSelect = onSelect, + onRenderItem = onRenderItem, + ) } } } @@ -495,11 +439,12 @@ private fun GroupedFeedFilterDialog( @Composable private fun GroupSection( label: String, - items: List, + items: List, isChipLayout: Boolean, - onSelect: (Int) -> Unit, + onSelect: (FeedDefinition) -> Unit, onRenderItem: @Composable (FeedDefinition) -> Unit, ) { + val context = LocalContext.current Surface( modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), shape = RoundedCornerShape(16.dp), @@ -523,13 +468,13 @@ private fun GroupSection( ) { items.forEach { entry -> Surface( - modifier = Modifier.clickable { onSelect(entry.originalIndex) }, + modifier = Modifier.clickable { onSelect(entry) }, shape = RoundedCornerShape(18.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), color = Color.Transparent, ) { Text( - text = entry.item.name.name(), + text = entry.name.name(context), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp), @@ -545,15 +490,15 @@ private fun GroupSection( modifier = Modifier .fillMaxWidth() - .clickable { onSelect(entry.originalIndex) } + .clickable { onSelect(entry) } .padding(horizontal = 16.dp, vertical = 6.dp), ) { FeedIcon( - item = entry.item, + item = entry, modifier = Size20Modifier, ) - Spacer(modifier = Modifier.padding(start = 12.dp)) - Column(modifier = Modifier.weight(1f)) { onRenderItem(entry.item) } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { onRenderItem(entry) } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesTopBar.kt index 80365369c..53bc0b952 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesTopBar.kt @@ -64,7 +64,7 @@ private fun ArticlesTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsTopBar.kt index c76315864..9fc96bb5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsTopBar.kt @@ -64,7 +64,7 @@ private fun AudioRoomsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt index 522a6d765..9a9974166 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt @@ -64,7 +64,7 @@ private fun BadgesTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt index 5c8957f05..641c1db90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt @@ -64,7 +64,7 @@ private fun CommunitiesTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt index 4c3b57af3..668606b31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt @@ -64,7 +64,7 @@ private fun TopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsTopBar.kt index abde6ead6..8bc6e8460 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsTopBar.kt @@ -64,7 +64,7 @@ private fun BrowseEmojiSetsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksTopBar.kt index a29cf8434..bf4d82d3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksTopBar.kt @@ -64,7 +64,7 @@ private fun FollowPacksTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt index 59e23c43c..3140f5880 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt @@ -69,7 +69,7 @@ private fun TopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsTopBar.kt index 656465430..147830473 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsTopBar.kt @@ -64,7 +64,7 @@ private fun LiveStreamsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt index eac3b6b4f..dfcf802bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt @@ -64,7 +64,7 @@ private fun LongsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt index 59cd27aef..2e377f37c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt @@ -64,7 +64,7 @@ private fun TopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt index 5acf6cd50..bef51851b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt @@ -64,7 +64,7 @@ private fun PicturesTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsTopBar.kt index dd05a7922..66437b1d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsTopBar.kt @@ -64,7 +64,7 @@ private fun PollsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsTopBar.kt index 12a404836..7ea108939 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsTopBar.kt @@ -64,7 +64,7 @@ private fun ProductsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsTopBar.kt index 98142ef97..a46e7f9e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsTopBar.kt @@ -64,7 +64,7 @@ private fun PublicChatsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt index 2a50c7c69..4c0ccc852 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt @@ -64,7 +64,7 @@ private fun ShortsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt index d74ab4e67..5f3bffbf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt @@ -65,7 +65,7 @@ private fun TopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } From e00da8c52d363818b949915769b068100ffa3615 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 19:10:03 +0000 Subject: [PATCH 27/38] fix(relay): fetch InterestSetEvent (kind 30015) for the account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither AccountInfoAndListsFromKeyKinds2 nor BasicAccountInfoKinds2 included InterestSetEvent.KIND, so a fresh login on a new device wouldn't pull the user's existing interest sets — they only showed up if the device already had them in cache or the user re-created them locally. The spinner's INTEREST_SETS group would silently be empty. Also bump the AccountInfoAndListsFromKeyKinds2 limit from 20 to 80 so the combined list of NIP-51 lists (10 kinds, now 11) actually fits. --- .../account/metadata/FilterAccountInfoAndListsFromKey.kt | 4 +++- .../account/metadata/FilterBasicAccountInfoFromKeys.kt | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 6e336cb68..8a2f31603 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent @@ -73,6 +74,7 @@ val AccountInfoAndListsFromKeyKinds2 = TrustProviderListEvent.KIND, PaymentTargetsEvent.KIND, RelayFeedsListEvent.KIND, + InterestSetEvent.KIND, ) val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) @@ -102,7 +104,7 @@ fun filterAccountInfoAndListsFromKey( Filter( kinds = AccountInfoAndListsFromKeyKinds2, authors = listOf(pubkey), - limit = 20, + limit = 80, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index 41e08a87b..e946849a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent @@ -65,6 +66,7 @@ val BasicAccountInfoKinds2 = GeohashListEvent.KIND, TrustProviderListEvent.KIND, RelayFeedsListEvent.KIND, + InterestSetEvent.KIND, ) fun filterBasicAccountInfoFromKeys( From 9d2ce5e460a34f0be17bf3916f09ae7119bca516 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 17:37:18 +0000 Subject: [PATCH 28/38] fix(compose): snap cursor to wedge boundary in UrlUserTagTransformation The custom OffsetMapping for the @-mention VisualTransformation used percentage-based interpolation when the cursor offset fell inside a substituted "@npub1..." range. An IME using extracted-text mode (e.g. SwiftKey on Pixel 9a) could place the cursor in the middle of the displayed "@DisplayName", which mapped to the middle of the underlying bech32 npub. A subsequent backspace then deleted a char from inside the bech32, the npub stopped matching the regex's 58-char length check, and the collapsed mention "expanded" with the cursor stuck in the middle of the now-visible raw npub. Treat each substitution as an atomic wedge: any cursor strictly inside a substituted range snaps to the wedge's trailing edge in both directions. Tests are rewritten to verify the snap-to-boundary semantics; the prior assertions pinned the buggy percentage behavior. This fixes the cursor-jump-into-npub symptom in EditPostView and ForwardZapTo (which use VisualTransformation directly). Chat input fields use OutputTransformation with Compose's auto-derived mapping and are not affected by this code path. https://claude.ai/code/session_01LVmmGa3Npuv2d1eeYm9BdZ --- .../amethyst/UrlUserTagTransformationTest.kt | 176 ++++++------------ .../ui/actions/UrlUserTagTransformation.kt | 14 +- 2 files changed, 60 insertions(+), 130 deletions(-) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt index 4a4a3ec15..b36312e75 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.input.TransformedText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.vitorpamplona.amethyst.model.LocalCache @@ -49,28 +48,6 @@ class UrlUserTagTransformationTest { assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug")) } - fun debugCursor( - original: String, - transformedText: TransformedText, - offset: Int, - ): String { - val offsetTransformed = transformedText.offsetMapping.originalToTransformed(offset) - val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length) - val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length) - return "$originalWithCursor $transformedWithCursor" - } - - fun debugCursorReverse( - original: String, - transformedText: TransformedText, - offsetTransformed: Int, - ): String { - val offset = transformedText.offsetMapping.transformedToOriginal(offsetTransformed) - val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length) - val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length) - return "$originalWithCursor $transformedWithCursor" - } - @Test fun testKeepTransformedIndexFullyInsideTransformedText() { val user = @@ -103,91 +80,21 @@ class UrlUserTagTransformationTest { val expected = "@Vitor Pamplona" assertEquals(expected, transformedText.text.text) - assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 0)) - assertEquals("@|npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 1)) - assertEquals("@n|pub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 2)) - assertEquals("@np|ub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 3)) - assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 4)) - assertEquals("@npub|1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 5)) - assertEquals("@npub1|gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 6)) - assertEquals("@npub1g|cxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 7)) - assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 8)) - assertEquals("@npub1gcx|zte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 9)) - assertEquals("@npub1gcxz|te5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 10)) - assertEquals("@npub1gcxzt|e5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 11)) - assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 12)) - assertEquals("@npub1gcxzte5|zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 13)) - assertEquals("@npub1gcxzte5z|lkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 14)) - assertEquals("@npub1gcxzte5zl|kncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 15)) - assertEquals("@npub1gcxzte5zlk|ncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 16)) - assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 17)) - assertEquals("@npub1gcxzte5zlknc|x26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 18)) - assertEquals("@npub1gcxzte5zlkncx|26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 19)) - assertEquals("@npub1gcxzte5zlkncx2|6j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 20)) - assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 21)) - assertEquals("@npub1gcxzte5zlkncx26j|68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 22)) - assertEquals("@npub1gcxzte5zlkncx26j6|8ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 23)) - assertEquals("@npub1gcxzte5zlkncx26j68|ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 24)) - assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 25)) - assertEquals("@npub1gcxzte5zlkncx26j68ez|60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 26)) - assertEquals("@npub1gcxzte5zlkncx26j68ez6|0fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 27)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60|fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 28)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 29)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fz|kvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 30)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzk|vtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 31)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkv|tkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 32)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvt|km9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 33)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 34)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm|9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 35)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9|e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 36)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e|0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 37)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 38)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0v|rwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 39)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vr|wdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 40)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrw|dcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 41)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 42)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdc|vsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 43)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcv|sjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 44)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvs|jakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 45)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 46)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsja|kxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 47)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjak|xf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 48)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakx|f9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 49)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf|9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 50)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 51)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9m|u9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 52)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu|9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 53)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9|qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 54)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 55)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qe|wqlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 56)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qew|qlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 57)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewq|lfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 58)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 59)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlf|nj5z @Vitor Pamplon|a", debugCursor(original, transformedText, 60)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfn|j5z @Vitor Pamplon|a", debugCursor(original, transformedText, 61)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj|5z @Vitor Pamplon|a", debugCursor(original, transformedText, 62)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5|z @Vitor Pamplon|a", debugCursor(original, transformedText, 63)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursor(original, transformedText, 64)) - - assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursorReverse(original, transformedText, 0)) - assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursorReverse(original, transformedText, 1)) - assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursorReverse(original, transformedText, 2)) - assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursorReverse(original, transformedText, 3)) - assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursorReverse(original, transformedText, 4)) - assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursorReverse(original, transformedText, 5)) - assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursorReverse(original, transformedText, 6)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursorReverse(original, transformedText, 7)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursorReverse(original, transformedText, 8)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursorReverse(original, transformedText, 9)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursorReverse(original, transformedText, 10)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursorReverse(original, transformedText, 11)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pampl|ona", debugCursorReverse(original, transformedText, 12)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pamplo|na", debugCursorReverse(original, transformedText, 13)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplon|a", debugCursorReverse(original, transformedText, 14)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursorReverse(original, transformedText, 15)) - + // The mention is treated as an atomic wedge: any cursor strictly inside the + // underlying npub snaps to the trailing edge of the displayed "@Vitor Pamplona" + // (and vice versa). This prevents an IME from placing the cursor in the middle + // of the bech32 and corrupting it on backspace. assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) + for (i in 1..63) { + assertEquals("originalToTransformed($i)", 15, transformedText.offsetMapping.originalToTransformed(i)) + } assertEquals(15, transformedText.offsetMapping.originalToTransformed(64)) + + assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) + for (i in 1..14) { + assertEquals("transformedToOriginal($i)", 64, transformedText.offsetMapping.transformedToOriginal(i)) + } + assertEquals(64, transformedText.offsetMapping.transformedToOriginal(15)) } @Test @@ -219,23 +126,30 @@ class UrlUserTagTransformationTest { assertEquals("New Hey @Vitor Pamplona", transformedText.text.text) + // Outside the wedge: identity mapping. assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H - assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ - assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n - assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p - assertEquals(8, transformedText.offsetMapping.originalToTransformed(11)) // Before u - assertEquals(8, transformedText.offsetMapping.originalToTransformed(12)) // Before b - assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 + assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ (boundary) - assertEquals(22, transformedText.offsetMapping.originalToTransformed(71)) + // Strictly inside the underlying npub: snaps to the end of "@Vitor Pamplona" (offset 23). + assertEquals(23, transformedText.offsetMapping.originalToTransformed(9)) // Before n + assertEquals(23, transformedText.offsetMapping.originalToTransformed(12)) // Before b + assertEquals(23, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z + + // End-of-wedge boundary maps to end of displayed mention. assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) + // Outside the wedge in displayed: identity. assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4)) - assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) - assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9)) + assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) // Before @ (boundary) + // Strictly inside displayed "@Vitor Pamplona": snaps to end of underlying npub (offset 72). + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(9)) + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22)) + + // End-of-wedge boundary maps to end of underlying mention; past it shifts by deltas. assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) } @@ -272,26 +186,40 @@ class UrlUserTagTransformationTest { assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text) - assertEquals(8, transformedText.offsetMapping.originalToTransformed(11)) - assertEquals(8, transformedText.offsetMapping.originalToTransformed(12)) - assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) + // Strictly inside the first underlying npub [8, 72): snap to end of first + // displayed "@Vitor Pamplona" (offset 23). + assertEquals(23, transformedText.offsetMapping.originalToTransformed(11)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(12)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(13)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) - assertEquals(22, transformedText.offsetMapping.originalToTransformed(70)) // Before 5 - assertEquals(22, transformedText.offsetMapping.originalToTransformed(71)) // Before z + // Boundary at end of first wedge: end of first displayed mention. assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before - assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ - assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n + assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ (boundary, second wedge) - assertEquals(67, transformedText.offsetMapping.transformedToOriginal(22)) // Before a + // Strictly inside the second underlying npub [77, 141): snap to end of second + // displayed "@Vitor Pamplona" (offset 43). + assertEquals(43, transformedText.offsetMapping.originalToTransformed(78)) // Before n + assertEquals(43, transformedText.offsetMapping.originalToTransformed(140)) + + // Strictly inside first displayed "@Vitor Pamplona" [8, 23): snap to end of + // first underlying npub (offset 72). + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22)) // Before a (display) assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before - assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ + assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ (boundary, second wedge) + + // Strictly inside second displayed "@Vitor Pamplona" [28, 43): snap to end of + // second underlying npub (offset 141). + assertEquals(141, transformedText.offsetMapping.transformedToOriginal(29)) + assertEquals(141, transformedText.offsetMapping.transformedToOriginal(42)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt index 2bd2817af..a56eb7052 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -138,14 +138,18 @@ fun buildAnnotatedStringWithUrlHighlighting( val numberOffsetTranslator = object : OffsetMapping { + // Treat each substitution as an atomic wedge: any cursor position that falls + // strictly inside a substituted range snaps to the wedge's trailing edge. + // Without this, an IME (e.g. SwiftKey in extracted-text mode) can place the + // cursor in the middle of an "@npub1..." mention, and a subsequent backspace + // deletes a char from inside the bech32, breaking the npub and "expanding" + // the collapsed mention. override fun originalToTransformed(offset: Int): Int { val inInsideRange = substitutions.firstOrNull { offset > it.original.start && offset < it.original.end } if (inInsideRange != null) { - val percentInRange = - (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat()) - return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).toInt() + return inInsideRange.modified.end } val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end } @@ -162,9 +166,7 @@ fun buildAnnotatedStringWithUrlHighlighting( substitutions.firstOrNull { offset > it.modified.start && offset < it.modified.end } if (inInsideRange != null) { - val percentInRange = - (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat()) - return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).toInt() + return inInsideRange.original.end } val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end } From b3e1f360abcd695031373ec394d281b926618446 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 19:59:43 +0000 Subject: [PATCH 29/38] fix(compose): preserve mentions atomically against IME word-recomposition Microsoft SwiftKey re-enters word-edit mode over a previously-committed display token after autocorrect-on-space, then issues setComposingText with a shortened version. Compose's auto-derived offset mapping for OutputTransformation uses identity inside a wedge, so the IME's replacement only overwrites the leading characters of the underlying @npub1... bech32, leaving an orphan tail that no longer matches the mention regex. The wedge collapses, the orphan bech32 becomes visible, and the cursor lands in the middle of it. Gboard never enters word-edit mode for previously-committed tokens, so it doesn't trigger this. Add MentionPreservingInputTransformation that runs on every input change and reverts any edit whose original-text range partially intersects a complete mention without fully covering it. The mention stays atomic; the IME re-reads the unchanged buffer and moves on. Wire it into all OutputTransformation-using fields: chats, new note, group DM, public channel, public message, classifieds, long-form. https://claude.ai/code/session_01LVmmGa3Npuv2d1eeYm9BdZ --- .../MentionPreservingInputTransformation.kt | 76 +++++++++++++++++++ .../creators/messagefield/MessageField.kt | 2 + .../chats/privateDM/send/NewGroupDMScreen.kt | 3 + .../send/PrivateMessageEditFieldRow.kt | 2 + .../chats/publicChannels/send/EditFieldRow.kt | 2 + .../nip23LongForm/LongFormPostScreen.kt | 2 + .../discover/nip99Classifieds/SellProduct.kt | 3 + .../publicMessages/NewPublicMessageScreen.kt | 2 + 8 files changed, 92 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt new file mode 100644 index 000000000..a7c8cb073 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.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.ui.actions + +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer + +/** + * Rejects any edit that partially modifies a previously-complete Nostr mention + * (`@npub1…`, `nostr:npub1…`, `@nprofile1…`, `nostr:nprofile1…`). + * + * Background: when an `OutputTransformation` collapses an underlying npub into a + * short `@DisplayName`, Compose's auto-derived offset mapping uses identity inside + * the wedge. Some IMEs — notably Microsoft SwiftKey — re-enter "word edit mode" + * over a previously-committed display token after autocorrect-on-space, then issue + * `setComposingText` with a shortened version. Because the mapping is identity + * inside the wedge, the IME's replacement only overwrites the leading characters + * of the underlying bech32, leaving an orphan tail that no longer matches the + * mention regex. The wedge collapses, the orphan bech32 becomes visible, and the + * cursor lands in the middle of it. Gboard never enters this state because it + * does not recompose previously-committed tokens. + * + * This guard runs on every input change. If the change's original-text range + * partially intersects a complete mention but does not fully cover it, the entire + * change is reverted. The mention stays atomic; the IME re-reads the unchanged + * buffer and moves on. + */ +object MentionPreservingInputTransformation : InputTransformation { + private val mentionRegex = + Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") + + override fun TextFieldBuffer.transformInput() { + val changeCount = changes.changeCount + if (changeCount == 0) return + + val original = originalText.toString() + if (original.isEmpty()) return + + val mentions = mentionRegex.findAll(original).toList() + if (mentions.isEmpty()) return + + for (i in 0 until changeCount) { + val origRange = changes.getOriginalRange(i) + val origStart = origRange.min + val origEnd = origRange.max + for (mention in mentions) { + val mStart = mention.range.first + val mEndExclusive = mention.range.last + 1 + val overlaps = origStart < mEndExclusive && origEnd > mStart + val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive + if (overlaps && !fullyCovers) { + revertAllChanges() + return + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt index 36cdecdcd..d25007493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.stringRes @@ -70,6 +71,7 @@ fun MessageField( state = viewModel.message, onTextChanged = viewModel::onMessageChanged, onContentReceived = onContentReceived, + inputTransformation = MentionPreservingInputTransformation, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 270113bd9..af68085c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -530,6 +531,7 @@ fun SendDirectMessageTo( ThinPaddingTextField( state = postViewModel.toUsers, onTextChanged = postViewModel::onToUsersChanged, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier .weight(1f) @@ -572,6 +574,7 @@ fun SendDirectMessageTo( ThinPaddingTextField( state = postViewModel.subject, onTextChanged = { postViewModel.onSubjectChanged() }, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier.fillMaxWidth(), placeholder = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index da9d8ed21..51b5bbd3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -53,6 +53,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -203,6 +204,7 @@ fun EditField( ThinPaddingTextField( state = channelScreenModel.message, onTextChanged = { channelScreenModel.onMessageChanged() }, + inputTransformation = MentionPreservingInputTransformation, keyboardOptions = PostKeyboard, shape = EditFieldBorder, modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 1c2669b35..c9e63c90f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -116,6 +117,7 @@ fun EditFieldRow( ThinPaddingTextField( state = channelScreenModel.message, onTextChanged = { channelScreenModel.onMessageChanged() }, + inputTransformation = MentionPreservingInputTransformation, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 87977bdc4..2eb873924 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles @@ -381,6 +382,7 @@ private fun MarkdownPostScreenBody( ThinPaddingTextField( state = postViewModel.message, onTextChanged = postViewModel::onMessageChanged, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier .fillMaxWidth() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt index c13346b81..52dfc08ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField @@ -111,6 +112,7 @@ fun SellProduct(postViewModel: NewProductViewModel) { ThinPaddingTextField( state = postViewModel.title, onTextChanged = { postViewModel.onTitleChanged() }, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier.fillMaxWidth(), placeholder = { Text( @@ -311,6 +313,7 @@ fun SellProduct(postViewModel: NewProductViewModel) { ThinPaddingTextField( state = postViewModel.locationText, onTextChanged = { postViewModel.onLocationChanged() }, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier.fillMaxWidth(), placeholder = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 1519ac398..9f284b4c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -417,6 +418,7 @@ fun SendDirectMessageTo( ThinPaddingTextField( state = postViewModel.toUsers, onTextChanged = postViewModel::onToUsersChanged, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier .weight(1f) From 7ad54ac33bba815b3305359323ef77b8a1864e1a Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 26 Apr 2026 23:51:57 +0200 Subject: [PATCH 30/38] style: spotless import order Co-Authored-By: Claude Opus 4.7 (1M context) --- .../loggedIn/discover/nip23LongForm/LongFormPostScreen.kt | 2 +- .../notifications/publicMessages/NewPublicMessageScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 2eb873924..35296e88d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -83,8 +83,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList -import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 9f284b4c9..06d46689e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -55,8 +55,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery From 450c740c62cf1641de9922ccabcab2a9d07dc366 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 27 Apr 2026 10:39:24 +0300 Subject: [PATCH 31/38] fix(packaging): add trap cleanup and xz compression to deb rewriter Address review findings: - Add trap for temp dir cleanup on error - Use -Zxz for max distro compatibility (older dpkg lacks zstd) - Use --root-owner-group for correct file ownership Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/relax-deb-libicu.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/relax-deb-libicu.sh b/scripts/relax-deb-libicu.sh index 9e0a25ee3..27f96c8f2 100755 --- a/scripts/relax-deb-libicu.sh +++ b/scripts/relax-deb-libicu.sh @@ -26,16 +26,18 @@ for deb in "$@"; do fi work="$(mktemp -d)" + trap 'rm -rf "$work"' EXIT dpkg-deb -R "$deb" "$work/pkg" control="$work/pkg/DEBIAN/control" if grep -qE 'libicu[0-9]+' "$control"; then sed -i -E "s/libicu[0-9]+([[:space:]]*\\|[[:space:]]*libicu[0-9]+)*/${ALT}/g" "$control" - dpkg-deb -b "$work/pkg" "$deb" >/dev/null + dpkg-deb --root-owner-group -Zxz -b "$work/pkg" "$deb" >/dev/null echo "Relaxed libicu dep: $deb" else echo "No libicu dep, leaving as-is: $deb" fi rm -rf "$work" + trap - EXIT done From 338080115fa8e011ba6893f9d7ee8b23de48cb5d Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 27 Apr 2026 00:13:36 +0200 Subject: [PATCH 32/38] fix(compose): also collapse on scope-exact replace; user-reported @Vitor Pamplona regression fix(compose): tighten @OptIn scope from @file to the object fix(compose): allow full-cover changes through; collapse only on partial overlap refactor(compose): hoist MENTION_REGEX, fast-path mention-free text, drop redundant scaffolding fix(compose): also collapse mention atomically on full-range non-empty replaces fix(compose): atomically delete the whole mention on partial-overlap edits fix(compose): opt-in ExperimentalFoundationApi in MentionPreservingInputTransformation --- .../MentionPreservingInputTransformation.kt | 92 +++++++++++-------- .../actions/UrlUserTagOutputTransformation.kt | 7 +- 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt index a7c8cb073..608099835 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt @@ -20,57 +20,71 @@ */ package com.vitorpamplona.amethyst.ui.actions +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer /** - * Rejects any edit that partially modifies a previously-complete Nostr mention - * (`@npub1…`, `nostr:npub1…`, `@nprofile1…`, `nostr:nprofile1…`). - * - * Background: when an `OutputTransformation` collapses an underlying npub into a - * short `@DisplayName`, Compose's auto-derived offset mapping uses identity inside - * the wedge. Some IMEs — notably Microsoft SwiftKey — re-enter "word edit mode" - * over a previously-committed display token after autocorrect-on-space, then issue - * `setComposingText` with a shortened version. Because the mapping is identity - * inside the wedge, the IME's replacement only overwrites the leading characters - * of the underlying bech32, leaving an orphan tail that no longer matches the - * mention regex. The wedge collapses, the orphan bech32 becomes visible, and the - * cursor lands in the middle of it. Gboard never enters this state because it - * does not recompose previously-committed tokens. - * - * This guard runs on every input change. If the change's original-text range - * partially intersects a complete mention but does not fully cover it, the entire - * change is reverted. The mention stays atomic; the IME re-reads the unchanged - * buffer and moves on. + * Matches a complete Nostr mention token: `@npub1…`, `nostr:npub1…`, + * `@nprofile1…`, `nostr:nprofile1…`. Shared with [UrlUserTagOutputTransformation] + * so the wedge it produces and the input-side guard below agree on what + * counts as a mention. */ -object MentionPreservingInputTransformation : InputTransformation { - private val mentionRegex = - Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") +internal val MENTION_REGEX = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") +/** + * Keeps Nostr mentions atomic against IME edits that would only modify part of + * the underlying bech32 (notably Microsoft SwiftKey, which re-enters word-edit + * mode over a previously-committed display token and rewrites a single word of + * a multi-word `@DisplayName`, leaving an orphan tail of the npub that no + * longer matches the mention regex). + * + * Three change shapes are blocked and routed to atomic-collapse: + * - Partial overlap (the change's `originalRange` overlaps a mention but does + * not fully cover it). + * - Scope-exact replace (the change's `originalRange` matches the mention's + * range exactly and the replacement is non-empty — covers IMEs that + * fully-cover-replace a multi-word display token with one of its words). + * + * Anything else passes through: + * - Pure delete that fully covers a mention (the user removed the chip). + * - A change whose range covers more than just the mention (select-all + type, + * select-paragraph + paste, etc.) — treated as deliberate broader edit. + */ +@OptIn(ExperimentalFoundationApi::class) +object MentionPreservingInputTransformation : InputTransformation { override fun TextFieldBuffer.transformInput() { val changeCount = changes.changeCount if (changeCount == 0) return - val original = originalText.toString() - if (original.isEmpty()) return + val original = originalText + // Cheap gate — most keystrokes happen in mention-free text. + if (!original.contains("npub1") && !original.contains("nprofile1")) return - val mentions = mentionRegex.findAll(original).toList() - if (mentions.isEmpty()) return - - for (i in 0 until changeCount) { - val origRange = changes.getOriginalRange(i) - val origStart = origRange.min - val origEnd = origRange.max - for (mention in mentions) { - val mStart = mention.range.first - val mEndExclusive = mention.range.last + 1 - val overlaps = origStart < mEndExclusive && origEnd > mStart - val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive - if (overlaps && !fullyCovers) { - revertAllChanges() - return + val touched = + MENTION_REGEX.findAll(original).firstOrNull { match -> + val mStart = match.range.first + val mEndExclusive = match.range.last + 1 + (0 until changeCount).any { i -> + val origRange = changes.getOriginalRange(i) + val origStart = origRange.min + val origEnd = origRange.max + val overlaps = origStart < mEndExclusive && origEnd > mStart + val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive + val isScopeExact = origStart == mStart && origEnd == mEndExclusive + val isPureDelete = changes.getRange(i).length == 0 + overlaps && (!fullyCovers || (isScopeExact && !isPureDelete)) } + } ?: return + + revertAllChanges() + val mEndExclusive = touched.range.last + 1 + val deleteEnd = + if (mEndExclusive < length && asCharSequence()[mEndExclusive].isWhitespace()) { + mEndExclusive + 1 + } else { + mEndExclusive } - } + replace(touched.range.first, deleteEnd, "") } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt index 9d5dff684..506a8d338 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt @@ -35,11 +35,8 @@ class UrlUserTagOutputTransformation( override fun TextFieldBuffer.transformOutput() { val text = asCharSequence().toString() - // Find all user mentions using regex and replace in reverse order - // so that earlier indices remain valid after replacements. - // Matches: @npub1..., nostr:npub1..., @nprofile1..., nostr:nprofile1... - val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") - val matches = mentionRegex.findAll(text).toList().reversed() + // Reverse so earlier indices remain valid after each replace. + val matches = MENTION_REGEX.findAll(text).toList().reversed() // Phase 1: Replace all mentions (reverse order keeps indices valid for replace). // Collect replacement info because addStyle must be called after all text mutations. From c78c1336759ad44fcb67161ca43f1fb75c7c2341 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 27 Apr 2026 10:51:53 +0200 Subject: [PATCH 33/38] test(compose): instrumented coverage for MentionPreservingInputTransformation 11 cases driving the predicate matrix against a real TextFieldState on device: - mention-free text passes through - pure delete fully covering a mention is allowed - partial overlap (at start, at end, inside) collapses atomically - scope-exact replace with non-empty text collapses (SwiftKey case) - scope-broader replace passes through - append after mention preserves it - trailing space and trailing newline are consumed during atomic collapse - multiple mentions: only the touched one collapses - cheap-gate path (mention-free original) is verified All 11 pass on Pixel 9a; gives the predicate a regression net so future predicate-tuning doesn't reintroduce the @Vitor Pamplona bug. Run via: ./gradlew :amethyst:connectedPlayDebugAndroidTest \ -Pandroid.testInstrumentationRunnerArguments.class=\ com.vitorpamplona.amethyst.MentionPreservingInputTransformationTest Co-Authored-By: Claude Opus 4.7 (1M context) --- ...entionPreservingInputTransformationTest.kt | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt new file mode 100644 index 000000000..227812f32 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt @@ -0,0 +1,145 @@ +/* + * 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(ExperimentalFoundationApi::class) + +package com.vitorpamplona.amethyst + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.TextFieldState +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Drives [MentionPreservingInputTransformation] against a real [TextFieldState] + * with simulated IME edits. The npub literal has no metadata loaded — these + * tests only exercise the input-side guard, which keys off the underlying bech32 + * text rather than any display-name resolution. + */ +@RunWith(AndroidJUnit4::class) +class MentionPreservingInputTransformationTest { + /** 64 characters: leading `@` + bech32 (`npub1` + 58 chars). */ + private val npub = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + + /** + * Apply [stage] inside an edit session, then run the InputTransformation + * exactly as the framework would, and return the committed text. + */ + private fun TextFieldState.applyChange(stage: TextFieldBuffer.() -> Unit): String { + edit { + stage() + with(MentionPreservingInputTransformation) { + transformInput() + } + } + return text.toString() + } + + @Test + fun mentionFreeText_passesThrough() { + val state = TextFieldState("hello world") + val result = state.applyChange { replace(0, 5, "HELLO") } + assertEquals("HELLO world", result) + } + + @Test + fun pureDeleteFullyCoveringMention_passesThrough() { + val state = TextFieldState(npub) + val result = state.applyChange { replace(0, npub.length, "") } + assertEquals("", result) + } + + @Test + fun partialDeleteInsideMention_collapsesAtomically() { + val state = TextFieldState(npub) + // delete a chunk near the end of the bech32 + val result = state.applyChange { replace(60, npub.length, "") } + assertEquals("", result) + } + + @Test + fun partialDeleteAtMentionStart_collapsesAtomically() { + val state = TextFieldState(npub) + // delete the leading "@npub" prefix only + val result = state.applyChange { replace(0, 5, "") } + assertEquals("", result) + } + + @Test + fun scopeExactReplaceWithNonEmpty_collapsesAtomically() { + // SwiftKey case: IME fully covers the mention range and writes a + // shortened replacement (e.g. one of the multi-word display tokens). + val state = TextFieldState(npub) + val result = state.applyChange { replace(0, npub.length, "@John") } + assertEquals("", result) + } + + @Test + fun scopeBroaderReplace_passesThrough() { + // Select-all + type: change covers the mention plus surrounding text. + // Treated as a deliberate broader edit; the typed character is preserved. + val state = TextFieldState("hi $npub world") + val result = state.applyChange { replace(0, length, "x") } + assertEquals("x", result) + } + + @Test + fun appendAfterMention_passesThrough() { + val state = TextFieldState(npub) + val result = state.applyChange { append(" hello") } + assertEquals("$npub hello", result) + } + + @Test + fun mentionWithTrailingSpace_collapseConsumesSpace() { + val state = TextFieldState("$npub hello") + val result = state.applyChange { replace(60, npub.length, "") } + assertEquals("hello", result) + } + + @Test + fun mentionWithTrailingNewline_collapseConsumesNewline() { + val state = TextFieldState("$npub\nhello") + val result = state.applyChange { replace(60, npub.length, "") } + assertEquals("hello", result) + } + + @Test + fun multipleMentions_partialOnSecond_onlySecondCollapsed() { + val text = "$npub and $npub" + val state = TextFieldState(text) + // partial delete inside the second mention only + val result = state.applyChange { replace(text.length - 4, text.length, "") } + assertEquals("$npub and ", result) + } + + @Test + fun mentionFreeChange_skipsRegexEntirely() { + // No "npub1" or "nprofile1" substring in the original text — the + // cheap-gate path should exit before any regex work. + val state = TextFieldState("hello world this is plain text") + val result = state.applyChange { replace(5, 11, "") } + assertEquals("hello this is plain text", result) + } +} From d8cb5c66ec2cd66e1066264c04399733e6ca98b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 13:12:00 +0000 Subject: [PATCH 34/38] feat(ai): suggest alt-text via on-device image labeling Wire ML Kit image labeling into the media-attach dialog so the alt-text field is prefilled with a confidence-filtered, comma-separated label list when the user picks an image and the field is still empty. A spinner shows during labeling and a dismissible "AI-suggested, edit me" chip lets the user revert. Play flavor uses play-services-mlkit-image-labeling; F-Droid ships a no-op stub. --- amethyst/build.gradle | 3 + .../service/ai/MLKitImageLabelService.kt | 35 ++++++++ .../creators/uploads/ImageVideoDescription.kt | 80 ++++++++++++++++++- amethyst/src/main/res/values/strings.xml | 2 + .../service/ai/MLKitImageLabelService.kt | 73 +++++++++++++++++ gradle/libs.versions.toml | 2 + 6 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 98d4127a9..f2d87e13c 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -346,6 +346,9 @@ dependencies { playImplementation libs.google.mlkit.genai.prompt playImplementation libs.google.mlkit.genai.rewriting + // On-device image labeling for alt-text suggestions + playImplementation libs.google.mlkit.image.labeling + // PushNotifications playImplementation platform(libs.firebase.bom) playImplementation libs.firebase.messaging diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt new file mode 100644 index 000000000..f9bd93d74 --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.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.service.ai + +import android.content.Context +import android.net.Uri + +@Suppress("UNUSED_PARAMETER") +class MLKitImageLabelService( + context: Context, +) { + suspend fun labelImage(uri: Uri): List> = emptyList() + + suspend fun suggestAltText(uri: Uri): String? = null + + fun close() {} +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 4e92df39b..7360606fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -31,15 +31,24 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -50,12 +59,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.ai.MLKitImageLabelService import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -100,6 +111,30 @@ fun ImageVideoDescription( var message by remember { mutableStateOf("") } var sensitiveContent by remember { mutableStateOf(false) } + val context = LocalContext.current + val firstImageUri = + remember(uris) { + uris.first().takeIf { it.media.isImage() == true && it.media.isGif().not() }?.media?.uri + } + val labelService = remember { MLKitImageLabelService(context.applicationContext) } + var isLabeling by remember { mutableStateOf(false) } + var aiSuggested by remember { mutableStateOf(false) } + + DisposableEffect(labelService) { + onDispose { labelService.close() } + } + + LaunchedEffect(firstImageUri) { + if (firstImageUri == null || message.isNotEmpty()) return@LaunchedEffect + isLabeling = true + val suggestion = labelService.suggestAltText(firstImageUri) + isLabeling = false + if (suggestion != null && message.isEmpty()) { + message = suggestion + aiSuggested = true + } + } + // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED var mediaQualitySlider by remember { mutableIntStateOf(if (uris.hasNonMedia()) 3 else 1) @@ -238,13 +273,24 @@ fun ImageVideoDescription( .fillMaxWidth() .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), value = message, - onValueChange = { message = it }, + onValueChange = { + message = it + aiSuggested = false + }, placeholder = { Text( text = stringRes(R.string.content_description_example), color = MaterialTheme.colorScheme.placeholderText, ) }, + trailingIcon = { + if (isLabeling) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } + }, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -252,6 +298,38 @@ fun ImageVideoDescription( ) } + if (aiSuggested) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp), + ) { + AssistChip( + onClick = { + message = "" + aiSuggested = false + }, + label = { Text(text = stringRes(R.string.ai_suggested_alt_text_hint)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringRes(R.string.ai_suggested_alt_text_dismiss), + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + ) + } + } + // Hide privacy toggle when any selected video will be compressed (compression already strips metadata) val isVideoWithCompression = uris.hasVideo() && mediaQualitySlider != 3 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 50dbf4480..634884650 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -633,6 +633,8 @@ Description of the contents A blue boat in a white sandy beach at sunset + AI-suggested, edit me + Dismiss AI suggestion Zap Type Zap Type for all options diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt new file mode 100644 index 000000000..79338a05a --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.ai + +import android.content.Context +import android.net.Uri +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.label.ImageLabeling +import com.google.mlkit.vision.label.defaults.ImageLabelerOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +class MLKitImageLabelService( + private val context: Context, +) { + private val labeler by lazy { + ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS) + } + + suspend fun labelImage(uri: Uri): List> = + withContext(Dispatchers.IO) { + try { + val image = InputImage.fromFilePath(context, uri) + suspendCancellableCoroutine { cont -> + labeler + .process(image) + .addOnSuccessListener { labels -> + cont.resume(labels.map { it.text to it.confidence }) + }.addOnFailureListener { + cont.resume(emptyList()) + } + } + } catch (_: Exception) { + emptyList() + } + } + + suspend fun suggestAltText(uri: Uri): String? { + val labels = labelImage(uri) + val confident = labels.filter { it.second >= MIN_CONFIDENCE }.map { it.first } + if (confident.isEmpty()) return null + return confident.take(MAX_LABELS).joinToString(", ") + } + + fun close() { + labeler.close() + } + + companion object { + private const val MIN_CONFIDENCE = 0.6f + private const val MAX_LABELS = 5 + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d19112fc..9597ba662 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ kotlinxSerialization = "1.11.0" genaiProofreading = "1.0.0-beta1" genaiPrompt = "1.0.0-beta2" genaiRewriting = "1.0.0-beta1" +imageLabeling = "16.0.0" languageId = "17.0.6" lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "2.2.1" @@ -148,6 +149,7 @@ jetbrains-compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-t google-mlkit-genai-proofreading = { group = "com.google.mlkit", name = "genai-proofreading", version.ref = "genaiProofreading" } google-mlkit-genai-prompt = { group = "com.google.mlkit", name = "genai-prompt", version.ref = "genaiPrompt" } google-mlkit-genai-rewriting = { group = "com.google.mlkit", name = "genai-rewriting", version.ref = "genaiRewriting" } +google-mlkit-image-labeling = { group = "com.google.android.gms", name = "play-services-mlkit-image-labeling", version.ref = "imageLabeling" } google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } From 952e0a2192b00769cdde1a9c29206b29e707fd44 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 13:32:57 +0000 Subject: [PATCH 35/38] feat(ai): prefer genai image description, fall back to image labeling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds com.google.mlkit:genai-image-description as the primary alt-text source — Gemini Nano via AICore produces full descriptive sentences on supported devices. When checkFeatureStatus reports anything other than AVAILABLE (or AICore is missing), the service falls back to the legacy play-services-mlkit-image-labeling keyword join. Both paths sit behind the same MLKitImageLabelService.suggestAltText API; the F-Droid stub is unchanged. --- amethyst/build.gradle | 4 +- .../service/ai/MLKitImageLabelService.kt | 47 ++++++++++++++++++- gradle/libs.versions.toml | 2 + 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/amethyst/build.gradle b/amethyst/build.gradle index f2d87e13c..fb4ddb6d8 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -346,7 +346,9 @@ dependencies { playImplementation libs.google.mlkit.genai.prompt playImplementation libs.google.mlkit.genai.rewriting - // On-device image labeling for alt-text suggestions + // On-device alt-text suggestions: genai image description (preferred, descriptive sentences) + // with image-labeling as a keyword-join fallback for devices without AICore. + playImplementation libs.google.mlkit.genai.image.description playImplementation libs.google.mlkit.image.labeling // PushNotifications diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt index 79338a05a..4ccba2b9e 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt @@ -21,7 +21,13 @@ package com.vitorpamplona.amethyst.service.ai import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.imagedescription.ImageDescriberOptions +import com.google.mlkit.genai.imagedescription.ImageDescription +import com.google.mlkit.genai.imagedescription.ImageDescriptionRequest import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.label.ImageLabeling import com.google.mlkit.vision.label.defaults.ImageLabelerOptions @@ -30,6 +36,12 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlin.coroutines.resume +/** + * Unified alt-text suggestion service. + * + * Prefers Gemini-Nano-backed `genai-image-description` for full descriptive sentences when + * the device supports AICore; falls back to the legacy keyword `image-labeling` model otherwise. + */ class MLKitImageLabelService( private val context: Context, ) { @@ -37,6 +49,14 @@ class MLKitImageLabelService( ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS) } + private val describer by lazy { + runCatching { + ImageDescription.getClient( + ImageDescriberOptions.builder(context).build(), + ) + }.getOrNull() + } + suspend fun labelImage(uri: Uri): List> = withContext(Dispatchers.IO) { try { @@ -55,15 +75,40 @@ class MLKitImageLabelService( } } - suspend fun suggestAltText(uri: Uri): String? { + suspend fun suggestAltText(uri: Uri): String? = describeWithGenAi(uri) ?: labelKeywords(uri) + + private suspend fun describeWithGenAi(uri: Uri): String? = + withContext(Dispatchers.IO) { + val client = describer ?: return@withContext null + try { + val status = client.checkFeatureStatus().get() + if (status != FeatureStatus.AVAILABLE) return@withContext null + val bitmap = loadBitmap(uri) ?: return@withContext null + val request = ImageDescriptionRequest.builder(bitmap).build() + val description = client.runInference(request).get().description + description?.trim()?.takeIf { it.isNotEmpty() } + } catch (_: Exception) { + null + } + } + + private suspend fun labelKeywords(uri: Uri): String? { val labels = labelImage(uri) val confident = labels.filter { it.second >= MIN_CONFIDENCE }.map { it.first } if (confident.isEmpty()) return null return confident.take(MAX_LABELS).joinToString(", ") } + private fun loadBitmap(uri: Uri): Bitmap? = + try { + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) } + } catch (_: Exception) { + null + } + fun close() { labeler.close() + describer?.close() } companion object { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9597ba662..1dc29b301 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ kotlinxSerialization = "1.11.0" genaiProofreading = "1.0.0-beta1" genaiPrompt = "1.0.0-beta2" genaiRewriting = "1.0.0-beta1" +genaiImageDescription = "1.0.0-beta1" imageLabeling = "16.0.0" languageId = "17.0.6" lifecycleRuntimeKtx = "2.10.0" @@ -149,6 +150,7 @@ jetbrains-compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-t google-mlkit-genai-proofreading = { group = "com.google.mlkit", name = "genai-proofreading", version.ref = "genaiProofreading" } google-mlkit-genai-prompt = { group = "com.google.mlkit", name = "genai-prompt", version.ref = "genaiPrompt" } google-mlkit-genai-rewriting = { group = "com.google.mlkit", name = "genai-rewriting", version.ref = "genaiRewriting" } +google-mlkit-genai-image-description = { group = "com.google.mlkit", name = "genai-image-description", version.ref = "genaiImageDescription" } google-mlkit-image-labeling = { group = "com.google.android.gms", name = "play-services-mlkit-image-labeling", version.ref = "imageLabeling" } google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } From f2c58adcf16e55de5e96653eacb46ea9d20bb633 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 13:54:18 +0000 Subject: [PATCH 36/38] perf(ai): cache feature status, downscale bitmaps, lazy-init clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache the AICore FeatureStatus check per service instance — was re-running an RPC on every image attach. - Two-pass decode with inSampleSize so 12 MP camera shots become ~1024 px before we hand them to the describer (avoids 40+ MB ARGB_8888 allocations and the GC churn that follows). - Switch ML Kit clients to var + lazy-on-first-use so close() no longer triggers init for clients we never invoked. - Wrap the composable's suggestAltText call in try/finally so a cancellation mid-inference resets the spinner state. --- .../creators/uploads/ImageVideoDescription.kt | 8 +- .../service/ai/MLKitImageLabelService.kt | 78 ++++++++++++++----- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 7360606fa..c59a347e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -127,8 +127,12 @@ fun ImageVideoDescription( LaunchedEffect(firstImageUri) { if (firstImageUri == null || message.isNotEmpty()) return@LaunchedEffect isLabeling = true - val suggestion = labelService.suggestAltText(firstImageUri) - isLabeling = false + val suggestion = + try { + labelService.suggestAltText(firstImageUri) + } finally { + isLabeling = false + } if (suggestion != null && message.isEmpty()) { message = suggestion aiSuggested = true diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt index 4ccba2b9e..d07ae1eeb 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt @@ -25,10 +25,12 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.imagedescription.ImageDescriber import com.google.mlkit.genai.imagedescription.ImageDescriberOptions import com.google.mlkit.genai.imagedescription.ImageDescription import com.google.mlkit.genai.imagedescription.ImageDescriptionRequest import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.label.ImageLabeler import com.google.mlkit.vision.label.ImageLabeling import com.google.mlkit.vision.label.defaults.ImageLabelerOptions import kotlinx.coroutines.Dispatchers @@ -45,24 +47,33 @@ import kotlin.coroutines.resume class MLKitImageLabelService( private val context: Context, ) { - private val labeler by lazy { - ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS) - } + private var labeler: ImageLabeler? = null + private var describer: ImageDescriber? = null - private val describer by lazy { - runCatching { - ImageDescription.getClient( - ImageDescriberOptions.builder(context).build(), - ) - }.getOrNull() - } + // FeatureStatus is an Int enum. Cached per-instance — describer availability does not flip + // mid-session in practice, and one composer mount only needs to ask AICore once. + @Volatile private var cachedGenAiStatus: Int? = null + + private fun ensureLabeler(): ImageLabeler = + labeler ?: ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS).also { labeler = it } + + private fun ensureDescriber(): ImageDescriber? = + describer + ?: try { + ImageDescription + .getClient(ImageDescriberOptions.builder(context).build()) + .also { describer = it } + } catch (_: Exception) { + null + } suspend fun labelImage(uri: Uri): List> = withContext(Dispatchers.IO) { try { val image = InputImage.fromFilePath(context, uri) + val client = ensureLabeler() suspendCancellableCoroutine { cont -> - labeler + client .process(image) .addOnSuccessListener { labels -> cont.resume(labels.map { it.text to it.confidence }) @@ -79,14 +90,19 @@ class MLKitImageLabelService( private suspend fun describeWithGenAi(uri: Uri): String? = withContext(Dispatchers.IO) { - val client = describer ?: return@withContext null + val client = ensureDescriber() ?: return@withContext null try { - val status = client.checkFeatureStatus().get() + val status = + cachedGenAiStatus ?: client.checkFeatureStatus().get().also { cachedGenAiStatus = it } if (status != FeatureStatus.AVAILABLE) return@withContext null - val bitmap = loadBitmap(uri) ?: return@withContext null + val bitmap = loadDownscaledBitmap(uri) ?: return@withContext null val request = ImageDescriptionRequest.builder(bitmap).build() - val description = client.runInference(request).get().description - description?.trim()?.takeIf { it.isNotEmpty() } + client + .runInference(request) + .get() + .description + ?.trim() + ?.takeIf { it.isNotEmpty() } } catch (_: Exception) { null } @@ -99,20 +115,44 @@ class MLKitImageLabelService( return confident.take(MAX_LABELS).joinToString(", ") } - private fun loadBitmap(uri: Uri): Bitmap? = + // Two-pass decode keeps a 12 MP camera shot from blowing past 40 MB of ARGB_8888 — the + // on-device describer downscales internally anyway, so a ~1024 px input is plenty. + private fun loadDownscaledBitmap(uri: Uri): Bitmap? = try { - context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) } + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } + val opts = + BitmapFactory.Options().apply { + inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, TARGET_DIM_PX) + } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } } catch (_: Exception) { null } + private fun sampleSizeFor( + width: Int, + height: Int, + target: Int, + ): Int { + if (width <= 0 || height <= 0) return 1 + var sample = 1 + var maxDim = maxOf(width, height) + while (maxDim / sample > target) sample *= 2 + return sample + } + fun close() { - labeler.close() + labeler?.close() + labeler = null describer?.close() + describer = null + cachedGenAiStatus = null } companion object { private const val MIN_CONFIDENCE = 0.6f private const val MAX_LABELS = 5 + private const val TARGET_DIM_PX = 1024 } } From 55875b060e3b7421dd8a970e77e30bc509f585f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 14:32:55 +0000 Subject: [PATCH 37/38] fix(notifications): remove broken Pause action from always-on service The Pause action called stopSelf(), but onDestroy() then triggered the auto-restart broadcast (because alwaysOnNotificationService was still enabled), so the notification reappeared seconds later. There was no way to actually pause without toggling the setting, so the button was just confusing. Drops the ACTION_STOP intent, the notification action, and the always_on_notif_stop string from all locales. Also includes incidental spotless fixes the pre-commit hook required. --- .../notifications/NotificationRelayService.kt | 34 +++---------------- .../creators/uploads/ImageVideoDescription.kt | 6 +++- .../src/main/res/values-cs-rCZ/strings.xml | 1 - .../src/main/res/values-de-rDE/strings.xml | 1 - .../src/main/res/values-hi-rIN/strings.xml | 1 - .../src/main/res/values-hu-rHU/strings.xml | 1 - .../src/main/res/values-pl-rPL/strings.xml | 1 - .../src/main/res/values-pt-rBR/strings.xml | 1 - .../src/main/res/values-sl-rSI/strings.xml | 1 - .../src/main/res/values-sv-rSE/strings.xml | 1 - .../src/main/res/values-zh-rCN/strings.xml | 1 - amethyst/src/main/res/values/strings.xml | 1 - .../service/ai/MLKitImageLabelService.kt | 3 +- 13 files changed, 11 insertions(+), 42 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index a9f386356..9265d889d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -78,7 +78,6 @@ class NotificationRelayService : Service() { private const val NOTIFICATION_ID = 9832 private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE" - private const val ACTION_STOP = "com.vitorpamplona.amethyst.STOP_NOTIFICATION_SERVICE" const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE" @@ -129,21 +128,11 @@ class NotificationRelayService : Service() { flags: Int, startId: Int, ): Int { - when (intent?.action) { - ACTION_STOP -> { - Log.d(TAG, "Stopping service") - stopSelf() - return START_NOT_STICKY - } - - else -> { - Log.d(TAG, "Starting service") - // Safety: also call startForeground from onStartCommand in case - // onCreate didn't complete before onStartCommand fired (ntfy #1520) - initializeForeground() - startRelayConnection() - } - } + Log.d(TAG, "Starting service") + // Safety: also call startForeground from onStartCommand in case + // onCreate didn't complete before onStartCommand fired (ntfy #1520) + initializeForeground() + startRelayConnection() return START_STICKY } @@ -288,25 +277,12 @@ class NotificationRelayService : Service() { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) - val stopIntent = - Intent(this, NotificationRelayService::class.java).apply { - action = ACTION_STOP - } - val stopPendingIntent = - PendingIntent.getService( - this, - 1, - stopIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - return NotificationCompat .Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.always_on_notif_title)) .setContentText(contentText) .setSmallIcon(R.drawable.amethyst) .setContentIntent(pendingIntent) - .addAction(0, getString(R.string.always_on_notif_stop), stopPendingIntent) .setOngoing(true) .setSilent(true) .setPriority(NotificationCompat.PRIORITY_LOW) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index c59a347e0..500a8bc06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -114,7 +114,11 @@ fun ImageVideoDescription( val context = LocalContext.current val firstImageUri = remember(uris) { - uris.first().takeIf { it.media.isImage() == true && it.media.isGif().not() }?.media?.uri + uris + .first() + .takeIf { it.media.isImage() == true && it.media.isGif().not() } + ?.media + ?.uri } val labelService = remember { MLKitImageLabelService(context.applicationContext) } var isLabeling by remember { mutableStateOf(false) } diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index b8cd9fe6b..ebe604b9b 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -832,7 +832,6 @@ Amethyst oznámení aktivní Připojeno k %1$d inbox relayím Připojování k inbox relayím\u2026 - Pozastavit Služba trvalých oznámení Udržuje trvalé připojení k vašim inbox relayím pro okamžité doručování oznámení. Zobrazuje průběžné oznámení. Spotřebovává více baterie, ale zajišťuje, že nezmeškáte žádnou zprávu. Optimalizace baterie aktivní diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 88ceeea32..732070867 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -837,7 +837,6 @@ anz der Bedingungen ist erforderlich Amethyst-Benachrichtigungen aktiv Mit %1$d Inbox-Relays verbunden Verbinde mit Inbox-Relays\u2026 - Pausieren Dauerhafter Benachrichtigungsdienst Hält eine dauerhafte Verbindung zu deinen Inbox-Relays für sofortige Benachrichtigungen aufrecht. Zeigt eine fortlaufende Benachrichtigung an. Verbraucht mehr Akku, stellt aber sicher, dass du keine Nachricht verpasst. Akkuoptimierung aktiv diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index d746ed323..519960f0c 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -836,7 +836,6 @@ अमेथिस्ट सूचनाएँ सक्रिय संयोजित %1$d आगतपेटिका पुनःप्रसारकों के साथ आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026 - विराम सदैव सक्रिय सूचना सेवा अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे। विद्युत्कोष अनुकूलन सक्रिय diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index bab00d59d..c431ea5e9 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -836,7 +836,6 @@ Amethyst értesítések aktíválva Kapcsolódva %1$d beérkező üzenetátjátszóhoz Kapcsolódás a beérkező üzenetátjátszókhoz\u2026 - Szüneteltetés Folyamatos értesítési szolgáltatás Folyamatos kapcsolatot tart fenn a beérkező üzenetek átjátszóival az értesítések azonnali kézbesítése érdekében. Megjeleníti a folyamatban lévő értesítéseket. Több akkumulátort fogyaszt, de így biztosan nem marad le egyetlen üzenetről sem. Akkumulátor-optimalizálás aktív diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index c1e616795..0d919efb8 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -833,7 +833,6 @@ Powiadomienia Ametyst Aktywne Połączono z %1$d transmiterami odbiorczymi Łączenie z transmiterami odbiorczymi\u2026 - Pauza Usługa powiadomień zawsze włączona Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości. Optymalizacja baterii aktywna diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 65dd266db..84be67f3a 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -832,7 +832,6 @@ Notificações do Amethyst ativas Conectado a %1$d relays de caixa de entrada Conectando aos relays de caixa de entrada\u2026 - Pausar Serviço de notificações sempre ativo Mantém uma conexão persistente com seus relays de caixa de entrada para entrega instantânea de notificações. Mostra uma notificação contínua. Usa mais bateria, mas garante que você nunca perca uma mensagem. Otimização de bateria ativa diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index ed0b2e170..ee261288c 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -847,7 +847,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Amethyst obvestila so aktivna Povezan z %1$d vhodnimi releji Povezovanje vhodnih relejev\u2026 - Premor Vedno aktivna obvestila Ohranja stalno povezavo z vašimi releji za takojšnjo dostavo obvestil. Prikazuje trajno obvestilo. Porabi več baterije, a zagotavlja, da ne zamudite nobenega sporočila. Optimizacija baterije je aktivna diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 3959559cb..b5728d715 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -831,7 +831,6 @@ Amethyst-notifieringar aktiva Ansluten till %1$d inbox-relän Ansluter till inbox-relän\u2026 - Pausa Alltid på-notifieringstjänst Upprätthåller en konstant anslutning till dina inbox-relän för omedelbar leverans av notifieringar. Visar en pågående notifiering. Använder mer batteri men säkerställer att du aldrig missar ett meddelande. Batterioptimering aktiv diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index d1a813275..601d642a1 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -836,7 +836,6 @@ Amethyst 通知活跃 已连接到 %1$d 个收件箱中继 正在连接到收件箱中继\u2026 - 暂停 始终开启通知服务 保持与收件箱中继的持续连接以便即时发送通知。 显示正在进行的通知。使用更多电量,但确保您永远不会错过消息。 电池优化已启用 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 634884650..d45ed3747 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -943,7 +943,6 @@ Amethyst Notifications Active Connected to %1$d inbox relays Connecting to inbox relays\u2026 - Pause Always-on notification service Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message. diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt index d07ae1eeb..25d044b34 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt @@ -54,8 +54,7 @@ class MLKitImageLabelService( // mid-session in practice, and one composer mount only needs to ask AICore once. @Volatile private var cachedGenAiStatus: Int? = null - private fun ensureLabeler(): ImageLabeler = - labeler ?: ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS).also { labeler = it } + private fun ensureLabeler(): ImageLabeler = labeler ?: ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS).also { labeler = it } private fun ensureDescriber(): ImageDescriber? = describer From aa7b5b054fb7776784d1ae65176233c573072d96 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 14:46:20 +0000 Subject: [PATCH 38/38] fix(uploads): migrate ImageVideoDescription icons to MaterialSymbols The AI-suggested alt-text chip was using androidx.compose.material.icons.*, which the project no longer pulls in (migrated to MaterialSymbols a while back). The file failed to compile until the dep was either re-added or the icons migrated. Switching to MaterialSymbols.AutoAwesome / .Close. --- .../ui/note/creators/uploads/ImageVideoDescription.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 500a8bc06..603b499f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -31,16 +31,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -66,6 +62,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.service.ai.MLKitImageLabelService import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS @@ -322,14 +320,14 @@ fun ImageVideoDescription( label = { Text(text = stringRes(R.string.ai_suggested_alt_text_hint)) }, leadingIcon = { Icon( - imageVector = Icons.Default.AutoAwesome, + symbol = MaterialSymbols.AutoAwesome, contentDescription = null, modifier = Modifier.size(AssistChipDefaults.IconSize), ) }, trailingIcon = { Icon( - imageVector = Icons.Default.Close, + symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.ai_suggested_alt_text_dismiss), modifier = Modifier.size(AssistChipDefaults.IconSize), )