From 6125dd95e682dd693e5f32a8d05161688ceb2c50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Jul 2026 10:42:03 +0100 Subject: [PATCH] feat(android): og:image thumbnails on link preview cards (#101396) --- apps/.i18n/native-source.json | 36 ++-- .../openclaw/app/ui/chat/ChatLinkPreview.kt | 192 +++++++++++++++--- .../openclaw/app/ui/chat/ChatMessageViews.kt | 79 ++++--- .../app/ui/chat/ChatLinkPreviewTest.kt | 117 +++++++++++ 4 files changed, 356 insertions(+), 68 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index e61639a4a05..a4e24bf477a 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -5987,7 +5987,7 @@ }, { "kind": "conditional-branch", - "line": 154, + "line": 159, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Preparing audio…", "surface": "android", @@ -5995,7 +5995,7 @@ }, { "kind": "conditional-branch", - "line": 154, + "line": 159, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Speaking…", "surface": "android", @@ -6003,7 +6003,7 @@ }, { "kind": "ui-named-argument", - "line": 261, + "line": 266, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Preview · $domain", "surface": "android", @@ -6011,7 +6011,7 @@ }, { "kind": "ui-named-argument", - "line": 270, + "line": 275, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Expand link preview", "surface": "android", @@ -6019,7 +6019,7 @@ }, { "kind": "ui-call", - "line": 294, + "line": 318, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Loading preview…", "surface": "android", @@ -6027,7 +6027,7 @@ }, { "kind": "ui-call", - "line": 295, + "line": 319, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "No preview available", "surface": "android", @@ -6035,7 +6035,7 @@ }, { "kind": "ui-call", - "line": 340, + "line": 365, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Thinking...", "surface": "android", @@ -6043,7 +6043,7 @@ }, { "kind": "ui-named-argument", - "line": 356, + "line": 381, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Tools", "surface": "android", @@ -6051,7 +6051,7 @@ }, { "kind": "ui-call", - "line": 359, + "line": 384, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Running tools...", "surface": "android", @@ -6059,7 +6059,7 @@ }, { "kind": "ui-call", - "line": 362, + "line": 387, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "${display.emoji} ${display.label}", "surface": "android", @@ -6067,7 +6067,7 @@ }, { "kind": "ui-named-argument", - "line": 380, + "line": 405, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "... +${toolCalls.size - 6} more", "surface": "android", @@ -6075,7 +6075,7 @@ }, { "kind": "ui-named-argument", - "line": 411, + "line": 436, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "You", "surface": "android", @@ -6083,7 +6083,7 @@ }, { "kind": "ui-named-argument", - "line": 425, + "line": 450, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Retry", "surface": "android", @@ -6091,7 +6091,7 @@ }, { "kind": "ui-named-argument", - "line": 428, + "line": 453, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Delete", "surface": "android", @@ -6099,7 +6099,7 @@ }, { "kind": "ui-named-argument", - "line": 460, + "line": 485, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "OpenClaw · Live", "surface": "android", @@ -6107,7 +6107,7 @@ }, { "kind": "conditional-branch", - "line": 497, + "line": 522, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "System", "surface": "android", @@ -6115,7 +6115,7 @@ }, { "kind": "conditional-branch", - "line": 498, + "line": 523, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "OpenClaw", "surface": "android", @@ -6123,7 +6123,7 @@ }, { "kind": "ui-call", - "line": 524, + "line": 549, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Unsupported attachment", "surface": "android", 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 73cb2de61d5..68ff430c527 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 @@ -1,5 +1,8 @@ package ai.openclaw.app.ui.chat +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.LruCache import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.Authenticator @@ -25,14 +28,20 @@ import java.net.URI import java.net.UnknownHostException import java.util.Locale import java.util.concurrent.TimeUnit +import kotlin.math.max internal const val LINK_PREVIEW_TITLE_MAX_CHARS = 120 internal const val LINK_PREVIEW_DESCRIPTION_MAX_CHARS = 200 internal const val LINK_PREVIEW_BODY_MAX_BYTES = 512 * 1024 +internal const val LINK_PREVIEW_IMAGE_BODY_MAX_BYTES = 1024 * 1024 +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_ENTRIES = 32 private const val LINK_PREVIEW_ACCEPT = "text/html, application/xhtml+xml;q=0.9" +private const val LINK_PREVIEW_IMAGE_ACCEPT = "image/*" +private val LINK_PREVIEW_IMAGE_CONTENT_TYPES = setOf("image/jpeg", "image/png", "image/webp") internal data class LinkPreviewMetadata( val url: String, @@ -49,6 +58,14 @@ internal sealed interface LinkPreviewResult { data object Failed : LinkPreviewResult } +internal sealed interface LinkPreviewImageResult { + data class Loaded( + val bitmap: Bitmap, + ) : LinkPreviewImageResult + + data object Failed : LinkPreviewImageResult +} + /** Returns the first safe web link outside inline and block code. */ internal fun extractFirstBareUrl(markdown: String): String? = findFirstLink(parseChatMarkdown(markdown).firstChild) @@ -113,68 +130,181 @@ internal class LinkPreviewFetcher( fetchBlocking(url) } + suspend fun fetchImage(url: String): LinkPreviewImageResult = + withContext(Dispatchers.IO) { + fetchImageBlocking(url) + } + private fun fetchBlocking(originalUrl: String): LinkPreviewResult { + val response = + fetchBody( + originalUrl = originalUrl, + accept = LINK_PREVIEW_ACCEPT, + allowedContentTypes = setOf("text/html"), + maxBytes = LINK_PREVIEW_BODY_MAX_BYTES, + rejectOversizedBody = false, + ) ?: return LinkPreviewResult.Failed + val html = response.bytes.toString(response.charset) + return when (val parsed = parseOpenGraph(html, response.url.toString())) { + is LinkPreviewResult.Loaded -> parsed.copy(metadata = parsed.metadata.copy(url = originalUrl)) + LinkPreviewResult.Failed -> LinkPreviewResult.Failed + } + } + + private fun fetchImageBlocking(url: String): LinkPreviewImageResult { + val response = + fetchBody( + originalUrl = url, + accept = LINK_PREVIEW_IMAGE_ACCEPT, + allowedContentTypes = LINK_PREVIEW_IMAGE_CONTENT_TYPES, + maxBytes = LINK_PREVIEW_IMAGE_BODY_MAX_BYTES, + rejectOversizedBody = true, + ) ?: return LinkPreviewImageResult.Failed + val bitmap = + decodeLinkPreviewBitmap( + bytes = response.bytes, + expectedContentType = response.contentType, + ) ?: return LinkPreviewImageResult.Failed + return LinkPreviewImageResult.Loaded(bitmap) + } + + private fun fetchBody( + originalUrl: String, + accept: String, + allowedContentTypes: Set, + maxBytes: Int, + rejectOversizedBody: Boolean, + ): LinkPreviewFetchedBody? { var currentUrl = originalUrl .toHttpUrlOrNull() ?.takeIf(::isSafeWebUrl) ?.takeIf(hostPolicy) - ?: return LinkPreviewResult.Failed + ?: return null val deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) var redirects = 0 while (true) { val remainingNanos = deadlineNanos - System.nanoTime() - if (remainingNanos <= 0L) return LinkPreviewResult.Failed + if (remainingNanos <= 0L) return null val request = Request .Builder() .url(currentUrl) - .header("Accept", LINK_PREVIEW_ACCEPT) + .header("Accept", accept) .get() .build() val call = client.newCall(request) call.timeout().timeout(remainingNanos, TimeUnit.NANOSECONDS) - val response = runCatching { call.execute() }.getOrElse { return LinkPreviewResult.Failed } + val response = runCatching { call.execute() }.getOrElse { return null } response.use { if (it.isRedirect) { - if (redirects >= LINK_PREVIEW_MAX_REDIRECTS) return LinkPreviewResult.Failed - currentUrl = resolveRedirect(currentUrl, it.header("Location"), hostPolicy) ?: return LinkPreviewResult.Failed + if (redirects >= LINK_PREVIEW_MAX_REDIRECTS) return null + currentUrl = resolveRedirect(currentUrl, it.header("Location"), hostPolicy) ?: return null redirects += 1 continue } - if (!it.isSuccessful) return LinkPreviewResult.Failed - val contentType = it.body.contentType() ?: return LinkPreviewResult.Failed - if (contentType.type != "text" || contentType.subtype != "html") return LinkPreviewResult.Failed + if (!it.isSuccessful) return null + val contentType = it.body.contentType() ?: return null + val contentTypeName = "${contentType.type}/${contentType.subtype}".lowercase(Locale.US) + if (contentTypeName !in allowedContentTypes) return null val bytes = try { - readBodyPrefix(it.body) + readBody(it.body, maxBytes, rejectOversizedBody) } catch (_: IOException) { - return LinkPreviewResult.Failed - } - val html = bytes.toString(contentType.charset(Charsets.UTF_8) ?: Charsets.UTF_8) - return when (val parsed = parseOpenGraph(html, currentUrl.toString())) { - is LinkPreviewResult.Loaded -> - parsed.copy(metadata = parsed.metadata.copy(url = originalUrl)) - LinkPreviewResult.Failed -> LinkPreviewResult.Failed - } + return null + } ?: return null + return LinkPreviewFetchedBody( + url = currentUrl, + bytes = bytes, + charset = contentType.charset(Charsets.UTF_8) ?: Charsets.UTF_8, + contentType = contentTypeName, + ) } } } } -private fun readBodyPrefix(body: ResponseBody): ByteArray { +private data class LinkPreviewFetchedBody( + val url: HttpUrl, + val bytes: ByteArray, + val charset: java.nio.charset.Charset, + val contentType: String, +) + +private fun readBody( + body: ResponseBody, + maxBytes: Int, + rejectOversizedBody: Boolean, +): ByteArray? { + if (rejectOversizedBody && body.contentLength() > maxBytes) return null val buffer = Buffer() val source = body.source() - while (buffer.size < LINK_PREVIEW_BODY_MAX_BYTES) { - val remaining = LINK_PREVIEW_BODY_MAX_BYTES - buffer.size + val readLimit = maxBytes.toLong() + if (rejectOversizedBody) 1L else 0L + while (buffer.size < readLimit) { + val remaining = readLimit - buffer.size if (source.read(buffer, remaining) == -1L) break } + if (rejectOversizedBody && buffer.size > maxBytes) return null return buffer.readByteArray() } +internal fun decodeLinkPreviewBitmap( + bytes: ByteArray, + maxDimension: Int = LINK_PREVIEW_IMAGE_MAX_DIMENSION, + expectedContentType: String? = null, +): Bitmap? { + if (bytes.isEmpty() || maxDimension <= 0) return null + val encodedContentType = linkPreviewImageContentType(bytes) ?: return null + if (expectedContentType != null && encodedContentType != expectedContentType) return null + return try { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + BitmapFactory.decodeByteArray( + bytes, + 0, + bytes.size, + BitmapFactory.Options().apply { + inSampleSize = linkPreviewImageSampleSize(bounds.outWidth, bounds.outHeight, maxDimension) + inPreferredConfig = Bitmap.Config.ARGB_8888 + }, + ) + } catch (_: RuntimeException) { + null + } catch (_: OutOfMemoryError) { + null + } +} + +private fun linkPreviewImageContentType(bytes: ByteArray): String? = + when { + bytes.matchesPrefix(0xff, 0xd8, 0xff) -> "image/jpeg" + bytes.matchesPrefix(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a) -> "image/png" + bytes.matchesPrefix(0x52, 0x49, 0x46, 0x46) && bytes.matchesAt(8, 0x57, 0x45, 0x42, 0x50) -> "image/webp" + else -> null + } + +private fun ByteArray.matchesAt( + offset: Int, + vararg expected: Int, +): Boolean = size >= offset + expected.size && expected.indices.all { index -> (this[offset + index].toInt() and 0xff) == expected[index] } + +private fun linkPreviewImageSampleSize( + width: Int, + height: Int, + maxDimension: Int, +): Int { + var sample = 1 + while (max(width / sample, height / sample) > maxDimension && sample <= Int.MAX_VALUE / 2) { + sample *= 2 + } + return sample +} + internal class LinkPreviewStore( private val fetcher: suspend (String) -> LinkPreviewResult, private val maxEntries: Int = LINK_PREVIEW_CACHE_ENTRIES, @@ -192,6 +322,20 @@ internal class LinkPreviewStore( } } +internal class LinkPreviewImageStore( + private val fetcher: suspend (String) -> LinkPreviewImageResult, + maxEntries: Int = LINK_PREVIEW_IMAGE_CACHE_ENTRIES, +) { + private val cache = LruCache(maxEntries) + + suspend fun get(url: String): LinkPreviewImageResult { + cache.get(url)?.let { return it } + val result = fetcher(url) + cache.put(url, result) + return result + } +} + private val defaultLinkPreviewClient: OkHttpClient = OkHttpClient .Builder() @@ -206,7 +350,9 @@ private val defaultLinkPreviewClient: OkHttpClient = .dns(PublicOnlyDns()) .build() -internal val chatLinkPreviewStore = LinkPreviewStore(fetcher = LinkPreviewFetcher()::fetch) +private val chatLinkPreviewFetcher = LinkPreviewFetcher() +internal val chatLinkPreviewStore = LinkPreviewStore(fetcher = chatLinkPreviewFetcher::fetch) +internal val chatLinkPreviewImageStore = LinkPreviewImageStore(fetcher = chatLinkPreviewFetcher::fetchImage) internal fun resolveRedirect( baseUrl: HttpUrl, @@ -294,7 +440,7 @@ private fun InetAddress.isSpecialPurposeAddress(): Boolean = else -> true } -private fun ByteArray.matchesPrefix(vararg prefix: Int): Boolean = prefix.indices.all { index -> (this[index].toInt() and 0xff) == prefix[index] } +private fun ByteArray.matchesPrefix(vararg prefix: Int): Boolean = matchesAt(0, *prefix) internal class PublicOnlyDns( private val delegate: Dns = Dns.SYSTEM, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt index 53535933c40..2ea8649ccb9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt @@ -10,6 +10,7 @@ import ai.openclaw.app.chat.MessageSpeechState import ai.openclaw.app.chat.normalizeVisibleChatMessageRole import ai.openclaw.app.tools.ToolDisplayRegistry import ai.openclaw.app.ui.MobileColorsAccessor +import ai.openclaw.app.ui.design.ClawTheme import ai.openclaw.app.ui.mobileAccent import ai.openclaw.app.ui.mobileAccentSoft import ai.openclaw.app.ui.mobileBorder @@ -32,6 +33,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -53,7 +55,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler @@ -278,39 +283,59 @@ private fun ChatLinkPreview( LaunchedEffect(messageId, url) { result = chatLinkPreviewStore.get(url) } + val imageUrl = (result as? LinkPreviewResult.Loaded)?.metadata?.imageUrl + var previewImage by remember(messageId, url, imageUrl) { mutableStateOf(null) } + LaunchedEffect(imageUrl) { + previewImage = + when (val image = imageUrl?.let { chatLinkPreviewImageStore.get(it) }) { + is LinkPreviewImageResult.Loaded -> image.bitmap.asImageBitmap() + LinkPreviewImageResult.Failed, null -> null + } + } val uriHandler = LocalUriHandler.current + val cardShape = RoundedCornerShape(ClawTheme.radii.sheet) Surface( onClick = { uriHandler.openUri(url) }, - shape = RoundedCornerShape(10.dp), + shape = cardShape, color = mobileCardSurface, border = BorderStroke(1.dp, mobileBorder), ) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(3.dp), - ) { - Text(domain, style = mobileCaption2, color = mobileTextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) - when (val preview = result) { - null -> Text("Loading preview…", style = mobileCaption1, color = mobileTextSecondary) - LinkPreviewResult.Failed -> Text("No preview available", style = mobileCallout, color = mobileTextSecondary) - is LinkPreviewResult.Loaded -> { - preview.metadata.title?.let { title -> - Text( - text = title, - style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), - color = mobileText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - preview.metadata.description?.let { description -> - Text( - text = description, - style = mobileCaption1, - color = mobileTextSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column(modifier = Modifier.fillMaxWidth()) { + previewImage?.let { image -> + Image( + bitmap = image, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().heightIn(max = 120.dp).clip(cardShape), + ) + } + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text(domain, style = mobileCaption2, color = mobileTextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) + when (val preview = result) { + null -> Text("Loading preview…", style = mobileCaption1, color = mobileTextSecondary) + LinkPreviewResult.Failed -> Text("No preview available", style = mobileCallout, color = mobileTextSecondary) + is LinkPreviewResult.Loaded -> { + preview.metadata.title?.let { title -> + Text( + text = title, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = mobileText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + preview.metadata.description?.let { description -> + Text( + text = description, + style = mobileCaption1, + color = mobileTextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } 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 3f8b6eb595d..bdccfb05c70 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 @@ -1,5 +1,7 @@ package ai.openclaw.app.ui.chat +import android.graphics.Bitmap +import android.graphics.Color import kotlinx.coroutines.runBlocking import okhttp3.Dns import okhttp3.HttpUrl.Companion.toHttpUrl @@ -11,15 +13,20 @@ import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.SocketPolicy +import okio.Buffer import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.ByteArrayOutputStream import java.net.InetAddress import java.net.UnknownHostException import java.util.concurrent.TimeUnit +@RunWith(RobolectricTestRunner::class) class ChatLinkPreviewTest { @Test fun extractsFirstHttpLinkOutsideCode() { @@ -297,6 +304,95 @@ class ChatLinkPreviewTest { assertEquals(2, server.requestCount) } + @Test + fun imageFetchStartsOnlyWhenStoreIsRequestedAndCacheHitAvoidsSecondRequest() = + withServer { server -> + server.enqueue(imageResponse(pngBytes(width = 120, height = 80))) + val store = LinkPreviewImageStore(fetcher = fetcher()::fetchImage) + val imageUrl = server.url("/card.png").toString() + + assertEquals(0, server.requestCount) + assertTrue(store.get(imageUrl) is LinkPreviewImageResult.Loaded) + assertTrue(store.get(imageUrl) is LinkPreviewImageResult.Loaded) + assertEquals(1, server.requestCount) + assertEquals("image/*", server.takeRequest().getHeader("Accept")) + } + + @Test + fun imageContentTypeAllowlistAndBodyCapAreEnforced() = + withServer { server -> + server.enqueue(MockResponse().setHeader("Content-Type", "image/gif").setBody("GIF89a")) + server.enqueue(MockResponse().setHeader("Content-Type", "image/svg+xml").setBody("")) + server.enqueue(MockResponse().setHeader("Content-Type", "image/png").setBody("GIF89a")) + server.enqueue( + MockResponse() + .setHeader("Content-Type", "image/png") + .setBody(Buffer().write(ByteArray(LINK_PREVIEW_IMAGE_BODY_MAX_BYTES + 1))), + ) + + assertSame(LinkPreviewImageResult.Failed, fetcher().fetchImage(server.url("/animated.gif").toString())) + assertSame(LinkPreviewImageResult.Failed, fetcher().fetchImage(server.url("/vector.svg").toString())) + assertSame(LinkPreviewImageResult.Failed, fetcher().fetchImage(server.url("/spoofed.png").toString())) + assertSame(LinkPreviewImageResult.Failed, fetcher().fetchImage(server.url("/oversized.png").toString())) + assertEquals(4, server.requestCount) + } + + @Test + fun privateLiteralImageUrlFailsWithoutNetworkCall() = + withServer { server -> + server.enqueue(imageResponse(pngBytes(width = 10, height = 10))) + + assertSame(LinkPreviewImageResult.Failed, realPolicyFetcher().fetchImage(server.url("/private.png").toString())) + assertEquals(0, server.requestCount) + } + + @Test + fun imageRedirectsFollowThreeHopsAndRejectUnsafeOrFourthHop() { + withServer { server -> + repeat(3) { index -> server.enqueue(redirect("/image-hop${index + 1}")) } + server.enqueue(imageResponse(pngBytes(width = 12, height = 8))) + + assertTrue(fetcher().fetchImage(server.url("/image-start").toString()) is LinkPreviewImageResult.Loaded) + assertEquals(4, server.requestCount) + } + + withServer { server -> + repeat(4) { index -> server.enqueue(redirect("/image-hop${index + 1}")) } + server.enqueue(imageResponse(pngBytes(width = 12, height = 8))) + + assertSame(LinkPreviewImageResult.Failed, fetcher().fetchImage(server.url("/image-start").toString())) + assertEquals(4, server.requestCount) + } + + withServer { server -> + server.enqueue(redirect("file:///tmp/private.png")) + + assertSame(LinkPreviewImageResult.Failed, fetcher().fetchImage(server.url("/image-start").toString())) + assertEquals(1, server.requestCount) + } + } + + @Test + fun imageDecodeDownsamplesLargeSource() { + val decoded = decodeLinkPreviewBitmap(pngBytes(width = 2_400, height = 1_200)) + + assertTrue(decoded != null) + assertTrue(checkNotNull(decoded).width <= LINK_PREVIEW_IMAGE_MAX_DIMENSION) + assertTrue(decoded.height <= LINK_PREVIEW_IMAGE_MAX_DIMENSION) + } + + @Test + fun corruptImageIsNegativeCachedWithoutRefetch() = + withServer { server -> + server.enqueue(MockResponse().setHeader("Content-Type", "image/webp").setBody("not an image")) + val store = LinkPreviewImageStore(fetcher = fetcher()::fetchImage) + val imageUrl = server.url("/corrupt.webp").toString() + + assertSame(LinkPreviewImageResult.Failed, store.get(imageUrl)) + assertSame(LinkPreviewImageResult.Failed, store.get(imageUrl)) + assertEquals(1, server.requestCount) + } + private fun fetcher(timeoutMillis: Long = 6_000): LinkPreviewFetcher = LinkPreviewFetcher(baseClient().build(), timeoutMillis, permissiveHostPolicy) private fun realPolicyFetcher(): LinkPreviewFetcher = LinkPreviewFetcher(baseClient().build()) @@ -312,6 +408,27 @@ class ChatLinkPreviewTest { .setResponseCode(302) .setHeader("Location", location) + private fun imageResponse(bytes: ByteArray): MockResponse = + MockResponse() + .setHeader("Content-Type", "image/png") + .setBody(Buffer().write(bytes)) + + private fun pngBytes( + width: Int, + height: Int, + ): ByteArray { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + return try { + bitmap.eraseColor(Color.rgb(24, 96, 192)) + ByteArrayOutputStream().use { output -> + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) + output.toByteArray() + } + } finally { + bitmap.recycle() + } + } + private fun withServer(block: suspend (MockWebServer) -> Unit) { MockWebServer().use { server -> server.start()