refactor(commons): unify the rich-text parser cache across Android + Desktop

Replaces the two forked cached parsers (amethyst CachedRichTextParser on
android.util.LruCache + desktop DesktopCachedRichTextParser on ConcurrentLruCache
with a naive isMarkdown) with one shared object in commons/jvmAndroid/richtext,
built on quartz ConcurrentLruCache and keeping amethyst's CommonMark-aware
computeIsMarkdown and content-addressed key (content+tags+callbackUri+authorPubKey).

- Add ConcurrentLruCache.trimToSize(maxItems) (+ tests) for the onTrimMemory path.
- Repoint all amethyst callers (incl. the markdown unit test) and both desktop
  callers; delete both forks.

Verified: :commons JVM, :desktopApp, :amethyst play debug + unit tests compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
This commit is contained in:
Claude
2026-07-16 21:56:12 +00:00
parent 269ae95b4b
commit 51f005d22f
14 changed files with 83 additions and 85 deletions
@@ -28,6 +28,7 @@ import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
@@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.service.cast.CastRegistry
@@ -35,6 +35,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
import com.vitorpamplona.amethyst.commons.richtext.ConcordInviteLinkSegment
@@ -54,7 +55,6 @@ import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextInteractions
import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextSegmentRenderer
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextInteractions
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextSegmentRenderer
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
import com.vitorpamplona.amethyst.commons.richtext.ConcordInviteLinkSegment
@@ -102,7 +103,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
@@ -35,6 +35,7 @@ import coil3.imageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -69,11 +69,11 @@ import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsScore
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.note.creators.previews
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.CachedUrlParser
import com.vitorpamplona.amethyst.commons.richtext.CachedUrlParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,9 +27,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -18,21 +18,27 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
package com.vitorpamplona.amethyst.commons.richtext
import android.util.LruCache
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.quartz.utils.cache.ConcurrentLruCache
/**
* The shared, cross-platform cache in front of [RichTextParser]. Both Amethyst
* Android and Amethyst Desktop render the same parsed [RichTextViewerState] from
* one place, so the same content quoted in multiple notes is only parsed once.
*
* Lives in `jvmAndroid` because it depends on [ConcurrentLruCache] (a JCA-free,
* lock-free-read LRU that is not in `commonMain`). iOS/`commonMain` callers use
* the uncached [RichTextParser] directly until a KMP cache is available.
*/
object CachedRichTextParser {
// Global across every feed. Sized to hold the active feed's visible + prefetched
// (see PrefetchFeedMedia) working set plus a few other feeds' recent entries, so
// pre-parsed bodies survive until the render reads them and feed switches don't
// thrash. Each entry is one note's parsed segments — typically single-digit KB.
private val richTextCache = LruCache<Int, RichTextViewerState>(500)
private val isMarkdownCache = LruCache<Int, Boolean>(200)
// working set plus a few other feeds' recent entries, so pre-parsed bodies survive
// until the render reads them and feed switches don't thrash. Each entry is one
// note's parsed segments — typically single-digit KB.
private val richTextCache = ConcurrentLruCache<Int, RichTextViewerState>(500)
private val isMarkdownCache = ConcurrentLruCache<Int, Boolean>(200)
private fun hashCodeCache(
content: String,
@@ -69,7 +75,7 @@ object CachedRichTextParser {
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
authorPubKey: String? = null,
): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri, authorPubKey)]
): RichTextViewerState? = richTextCache.get(hashCodeCache(content, tags, callbackUri, authorPubKey))
fun parseText(
content: String,
@@ -78,13 +84,13 @@ object CachedRichTextParser {
authorPubKey: String? = null,
): RichTextViewerState {
val key = hashCodeCache(content, tags, callbackUri, authorPubKey)
val cached = richTextCache[key]
val cached = richTextCache.get(key)
return if (cached != null) {
cached
} else {
val newUrls = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
richTextCache.put(key, newUrls)
newUrls
val newState = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
richTextCache.put(key, newState)
newState
}
}
@@ -92,7 +98,7 @@ object CachedRichTextParser {
// notes only pays for the scan once. The decision is purely a function of `content`.
fun isMarkdown(content: String): Boolean {
val key = content.hashCode()
isMarkdownCache[key]?.let { return it }
isMarkdownCache.get(key)?.let { return it }
val result = computeIsMarkdown(content)
isMarkdownCache.put(key, result)
return result
@@ -296,15 +302,15 @@ object CachedRichTextParser {
}
object CachedUrlParser {
private val parsedUrlsCache = LruCache<Int, List<String>>(10)
private val parsedUrlsCache = ConcurrentLruCache<Int, List<String>>(10)
fun cachedParseValidUrls(content: String): List<String> = parsedUrlsCache[content.hashCode()]
fun cachedParseValidUrls(content: String): List<String>? = parsedUrlsCache.get(content.hashCode())
fun parseValidUrls(content: String): List<String> {
if (content.isEmpty()) return emptyList()
val key = content.hashCode()
val cached = parsedUrlsCache[key]
val cached = parsedUrlsCache.get(key)
return if (cached != null) {
cached
} else {
@@ -1,54 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.quartz.utils.cache.ConcurrentLruCache
object DesktopCachedRichTextParser {
private const val MAX_CACHE_SIZE = 50
// Lock-free get on the feed rich-text render path; the previous access-order
// synchronizedMap took a monitor even on reads.
private val cache = ConcurrentLruCache<String, RichTextViewerState>(MAX_CACHE_SIZE)
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
): RichTextViewerState {
cache.get(content)?.let { return it }
val state = RichTextParser().parseText(content, tags, callbackUri)
cache.put(content, state)
return state
}
fun isMarkdown(content: String): Boolean =
content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("**") ||
content.contains("```") ||
content.contains("](")
}
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph
import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
@@ -75,7 +76,6 @@ import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextSegmentRender
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextInteractions
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextSegmentRenderer
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
@@ -112,7 +112,7 @@ fun DesktopRichText(
callbacks: RichTextCallbacks = RichTextCallbacks(),
modifier: Modifier = Modifier,
) {
if (DesktopCachedRichTextParser.isMarkdown(content)) {
if (CachedRichTextParser.isMarkdown(content)) {
RenderMarkdown(
content = content,
onLinkClick = { url -> handleDesktopLinkClick(url, callbacks) },
@@ -56,12 +56,12 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext
import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser
import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
@@ -293,7 +293,7 @@ fun NoteCard(
if (strippedContent.isNotBlank()) {
val richState =
remember(strippedContent, note.tags) {
DesktopCachedRichTextParser.parseText(strippedContent, note.tags)
CachedRichTextParser.parseText(strippedContent, note.tags)
}
DesktopRichText(
content = strippedContent,
@@ -87,5 +87,19 @@ class ConcurrentLruCache<K : Any, V : Any>(
}
}
/**
* Evicts the oldest entries until at most [maxItems] remain. Used to shed
* memory on pressure (e.g. `onTrimMemory`). A [maxItems] of 0 or less clears
* the cache; values above the current size are a no-op.
*/
fun trimToSize(maxItems: Int) {
synchronized(writeLock) {
while (order.size > maxItems) {
val oldest = order.removeFirstOrNull() ?: break
map.remove(oldest)
}
}
}
fun size(): Int = map.size
}
@@ -100,6 +100,38 @@ class ConcurrentLruCacheTest {
assertNull(cache.get("a"))
}
@Test
fun `trimToSize evicts oldest down to the target`() {
val cache = ConcurrentLruCache<String, Int>(10)
cache.put("a", 1)
cache.put("b", 2)
cache.put("c", 3)
cache.trimToSize(1)
assertEquals(1, cache.size())
// Oldest (a, b) evicted; newest (c) kept.
assertNull(cache.get("a"))
assertNull(cache.get("b"))
assertEquals(3, cache.get("c"))
}
@Test
fun `trimToSize above current size is a no-op`() {
val cache = ConcurrentLruCache<String, Int>(10)
cache.put("a", 1)
cache.put("b", 2)
cache.trimToSize(5)
assertEquals(2, cache.size())
}
@Test
fun `trimToSize to zero clears`() {
val cache = ConcurrentLruCache<String, Int>(10)
cache.put("a", 1)
cache.trimToSize(0)
assertEquals(0, cache.size())
assertNull(cache.get("a"))
}
@Test
fun `size never exceeds capacity under concurrent puts`() {
val cap = 100