mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(android): og:image thumbnails on link preview cards (#101396)
This commit is contained in:
parent
0f775fa25e
commit
6125dd95e6
4 changed files with 356 additions and 68 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue