From 903a09ab858d40409c829e9133729ee8afc4e511 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Jul 2026 11:22:52 +0100 Subject: [PATCH] fix(android): bound link preview image cache (#101560) --- .../openclaw/app/ui/chat/ChatLinkPreview.kt | 18 ++++- .../app/ui/chat/ChatLinkPreviewTest.kt | 78 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt index 68ff430c527f..54173e68b2db 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt @@ -38,6 +38,7 @@ internal const val LINK_PREVIEW_IMAGE_MAX_DIMENSION = 600 private const val LINK_PREVIEW_MAX_REDIRECTS = 3 private const val LINK_PREVIEW_TIMEOUT_MILLIS = 6_000L private const val LINK_PREVIEW_CACHE_ENTRIES = 64 +private const val LINK_PREVIEW_IMAGE_CACHE_MAX_BYTES = 8 * 1024 * 1024 private const val LINK_PREVIEW_IMAGE_CACHE_ENTRIES = 32 private const val LINK_PREVIEW_ACCEPT = "text/html, application/xhtml+xml;q=0.9" private const val LINK_PREVIEW_IMAGE_ACCEPT = "image/*" @@ -324,9 +325,22 @@ internal class LinkPreviewStore( internal class LinkPreviewImageStore( private val fetcher: suspend (String) -> LinkPreviewImageResult, - maxEntries: Int = LINK_PREVIEW_IMAGE_CACHE_ENTRIES, + maxBytes: Int = LINK_PREVIEW_IMAGE_CACHE_MAX_BYTES, ) { - private val cache = LruCache(maxEntries) + // Every result pays at least one entry share, preserving the entry cap while loaded bitmaps + // also pay their full backing allocation. + private val minimumResultBytes = max(1, maxBytes / LINK_PREVIEW_IMAGE_CACHE_ENTRIES) + private val cache = + object : LruCache(maxBytes) { + override fun sizeOf( + key: String, + value: LinkPreviewImageResult, + ): Int = + when (value) { + is LinkPreviewImageResult.Loaded -> value.bitmap.allocationByteCount.coerceAtLeast(minimumResultBytes) + LinkPreviewImageResult.Failed -> minimumResultBytes + } + } suspend fun get(url: String): LinkPreviewImageResult { cache.get(url)?.let { return it } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt index bdccfb05c700..8b3ea139ec99 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt @@ -318,6 +318,84 @@ class ChatLinkPreviewTest { assertEquals("image/*", server.takeRequest().getHeader("Accept")) } + @Test + fun imageCacheEvictsLeastRecentlyUsedBitmapByAllocatedBytes() = + runBlocking { + val first = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888) + val second = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888) + val fetchCounts = mutableMapOf() + val store = + LinkPreviewImageStore( + fetcher = { url -> + fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1 + LinkPreviewImageResult.Loaded(if (url == "first") first else second) + }, + maxBytes = first.allocationByteCount, + ) + + try { + assertTrue(store.get("first") is LinkPreviewImageResult.Loaded) + assertTrue(store.get("second") is LinkPreviewImageResult.Loaded) + assertTrue(store.get("first") is LinkPreviewImageResult.Loaded) + + assertEquals(2, fetchCounts["first"]) + assertEquals(1, fetchCounts["second"]) + } finally { + first.recycle() + second.recycle() + } + } + + @Test + fun imageCacheBoundsNegativeResults() = + runBlocking { + val fetchCounts = mutableMapOf() + val store = + LinkPreviewImageStore( + fetcher = { url -> + fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1 + LinkPreviewImageResult.Failed + }, + maxBytes = 2, + ) + + assertSame(LinkPreviewImageResult.Failed, store.get("first")) + assertSame(LinkPreviewImageResult.Failed, store.get("second")) + assertSame(LinkPreviewImageResult.Failed, store.get("third")) + assertSame(LinkPreviewImageResult.Failed, store.get("first")) + + assertEquals(2, fetchCounts["first"]) + assertEquals(1, fetchCounts["second"]) + assertEquals(1, fetchCounts["third"]) + } + + @Test + fun imageCacheBoundsTinyLoadedResultsByEntryCount() = + runBlocking { + val tiny = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + val maxEntries = 32 + val fetchCounts = mutableMapOf() + val store = + LinkPreviewImageStore( + fetcher = { url -> + fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1 + LinkPreviewImageResult.Loaded(tiny) + }, + maxBytes = tiny.allocationByteCount * maxEntries * 2, + ) + + try { + repeat(maxEntries + 1) { index -> + assertTrue(store.get("image-$index") is LinkPreviewImageResult.Loaded) + } + assertTrue(store.get("image-0") is LinkPreviewImageResult.Loaded) + + assertEquals(2, fetchCounts["image-0"]) + } finally { + tiny.recycle() + } + } + @Test fun imageContentTypeAllowlistAndBodyCapAreEnforced() = withServer { server ->