feat(android): og:image thumbnails on link preview cards (#101396)

This commit is contained in:
Peter Steinberger 2026-07-07 10:42:03 +01:00 committed by GitHub
parent 0f775fa25e
commit 6125dd95e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 356 additions and 68 deletions

View file

@ -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",

View file

@ -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<String>,
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<String, LinkPreviewImageResult>(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,

View file

@ -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<ImageBitmap?>(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,
)
}
}
}
}

View file

@ -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("<svg/>"))
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()