fix(android): bound link preview image cache (#101560)

This commit is contained in:
Peter Steinberger
2026-07-07 11:22:52 +01:00
committed by GitHub
parent 42e2390b31
commit 903a09ab85
2 changed files with 94 additions and 2 deletions
@@ -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<String, LinkPreviewImageResult>(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<String, LinkPreviewImageResult>(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 }
@@ -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<String, Int>()
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<String, Int>()
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<String, Int>()
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 ->