mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
feat(android): show agent avatar instead of first-letter badge (#103248)
* feat(android): render configured agent avatars Co-authored-by: Igor Guarisma <igor.guarisma@gmail.com> * style(android): satisfy avatar ktlint * style(android): fix avatar test formatting * style(android): align avatar test indentation * fix(android): initialize safe image client first * chore(android): sync avatar i18n inventory --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
+279
-279
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@ Requires a clear in-app disclosure and fresh consent before Installed Apps can s
|
||||
|
||||
Adds an Android system share target that stages bounded text and image shares for review without losing existing composer drafts. Thanks @NianJiuZst.
|
||||
|
||||
Displays configured agent avatars across Android overview, settings, and chat, with bounded data and public remote image loading. Thanks @guarismo.
|
||||
|
||||
## 2026.7.1 - 2026-07-08
|
||||
|
||||
Adds multi-gateway switching with isolated credentials, history, queues, and notification routing.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
Coil
|
||||
Artifacts:
|
||||
- io.coil-kt.coil3:coil-compose
|
||||
- io.coil-kt.coil3:coil-svg
|
||||
License: Apache License 2.0
|
||||
|
||||
Copyright 2026 Coil Contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -330,6 +330,8 @@ dependencies {
|
||||
implementation(libs.androidx.exifinterface)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.bcprov)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.svg)
|
||||
implementation(libs.commonmark)
|
||||
implementation(libs.commonmark.ext.autolink)
|
||||
implementation(libs.commonmark.ext.gfm.strikethrough)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.node.asObjectOrNull
|
||||
import ai.openclaw.app.node.asStringOrNull
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
data class GatewayAgentSummary(
|
||||
val id: String,
|
||||
val name: String?,
|
||||
val emoji: String?,
|
||||
val avatar: String? = null,
|
||||
val avatarUrl: String? = null,
|
||||
val workspaceGit: Boolean = false,
|
||||
)
|
||||
|
||||
/** Parses validated agents.list rows into the smaller Android display model. */
|
||||
internal fun parseGatewayAgentSummaries(root: JsonObject): List<GatewayAgentSummary> = (root["agents"] as? JsonArray)?.mapNotNull(::parseGatewayAgentSummary) ?: emptyList()
|
||||
|
||||
private fun parseGatewayAgentSummary(item: JsonElement): GatewayAgentSummary? {
|
||||
val agent = item.asObjectOrNull() ?: return null
|
||||
val id = agent["id"].asStringOrNull()?.trim().orEmpty()
|
||||
if (id.isEmpty()) return null
|
||||
val identity = agent["identity"].asObjectOrNull()
|
||||
return GatewayAgentSummary(
|
||||
id = id,
|
||||
name = agent["name"].asStringOrNull().normalizedAgentValue(),
|
||||
emoji = identity?.get("emoji").asStringOrNull().normalizedAgentValue(),
|
||||
avatar = identity?.get("avatar").asStringOrNull().normalizedAgentValue(),
|
||||
avatarUrl = identity?.get("avatarUrl").asStringOrNull().normalizedAgentValue(),
|
||||
workspaceGit = (agent["workspaceGit"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() == true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun String?.normalizedAgentValue(): String? = this?.trim()?.takeIf { it.isNotEmpty() }
|
||||
@@ -3790,25 +3790,7 @@ class NodeRuntime private constructor(
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return
|
||||
val defaultAgentId = root["defaultId"].asStringOrNull()?.trim().orEmpty()
|
||||
val mainKey = normalizeMainKey(root["mainKey"].asStringOrNull())
|
||||
val agents =
|
||||
(root["agents"] as? JsonArray)?.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
|
||||
if (id.isEmpty()) return@mapNotNull null
|
||||
val name = obj["name"].asStringOrNull()?.trim()
|
||||
val emoji =
|
||||
obj["identity"]
|
||||
.asObjectOrNull()
|
||||
?.get("emoji")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
GatewayAgentSummary(
|
||||
id = id,
|
||||
name = name?.takeIf { it.isNotEmpty() },
|
||||
emoji = emoji?.takeIf { it.isNotEmpty() },
|
||||
workspaceGit = (obj["workspaceGit"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() == true,
|
||||
)
|
||||
} ?: emptyList()
|
||||
val agents = parseGatewayAgentSummaries(root)
|
||||
|
||||
publishGatewayData(gatewayScope) {
|
||||
_gatewayDefaultAgentId.value = defaultAgentId.ifEmpty { null }
|
||||
@@ -5651,13 +5633,6 @@ private enum class HomeCanvasGatewayState {
|
||||
Offline,
|
||||
}
|
||||
|
||||
data class GatewayAgentSummary(
|
||||
val id: String,
|
||||
val name: String?,
|
||||
val emoji: String?,
|
||||
val workspaceGit: Boolean = false,
|
||||
)
|
||||
|
||||
data class GatewayModelSummary(
|
||||
val id: String,
|
||||
val name: String,
|
||||
|
||||
@@ -37,6 +37,7 @@ import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import ai.openclaw.app.photoReadPermissionsForRequest
|
||||
import ai.openclaw.app.reconcileRestoredAction
|
||||
import ai.openclaw.app.setAppLanguage
|
||||
import ai.openclaw.app.ui.design.ClawAgentAvatar
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawIconBadge
|
||||
import ai.openclaw.app.ui.design.ClawListItem
|
||||
@@ -56,6 +57,7 @@ import ai.openclaw.app.ui.design.ClawTheme
|
||||
import ai.openclaw.app.ui.design.OpenClawMascot
|
||||
import ai.openclaw.app.ui.design.TalkWaveform
|
||||
import ai.openclaw.app.ui.design.TalkWaveformPhase
|
||||
import ai.openclaw.app.ui.design.agentAvatarSource
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
@@ -2253,7 +2255,11 @@ private fun AgentListRow(
|
||||
ClawDetailRow(
|
||||
title = agent.name?.takeIf { it.isNotBlank() } ?: agent.id,
|
||||
subtitle = if (isDefault) "Default assistant" else "Ready",
|
||||
leading = { ClawTextBadge(text = agentBadge(agent)) },
|
||||
leading = {
|
||||
ClawAgentAvatar(source = agentAvatarSource(agent), size = 30.dp) {
|
||||
ClawTextBadge(text = agentBadge(agent))
|
||||
}
|
||||
},
|
||||
trailing = { ClawStatusPill(text = if (isDefault) "Default" else "Ready", status = ClawStatus.Success) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.currentAppLanguage
|
||||
import ai.openclaw.app.node.CanvasController
|
||||
import ai.openclaw.app.ui.chat.ChatScreen
|
||||
import ai.openclaw.app.ui.design.AgentAvatarSource
|
||||
import ai.openclaw.app.ui.design.ClawAgentAvatar
|
||||
import ai.openclaw.app.ui.design.ClawBottomNav
|
||||
import ai.openclaw.app.ui.design.ClawDesignTheme
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
@@ -31,6 +33,7 @@ import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import ai.openclaw.app.ui.design.OpenClawMascot
|
||||
import ai.openclaw.app.ui.design.agentAvatarSource
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
@@ -436,6 +439,7 @@ private fun OverviewScreen(
|
||||
val headerRoute = overviewHeaderRoute(attentionRows)
|
||||
val activeAgentName = overviewAgentName(agents = agents, defaultAgentId = defaultAgentId)
|
||||
val activeAgentBadge = overviewAgentBadgeText(agents = agents, defaultAgentId = defaultAgentId)
|
||||
val activeAgentAvatar = overviewAgentAvatar(agents = agents, defaultAgentId = defaultAgentId)
|
||||
val overviewSessions = overviewRecentSessions(sessions)
|
||||
val overviewSessionCount = overviewSessions.size
|
||||
val candidateRecentRows =
|
||||
@@ -495,6 +499,7 @@ private fun OverviewScreen(
|
||||
OverviewPrimaryPanel(
|
||||
agentName = activeAgentName,
|
||||
agentBadge = activeAgentBadge,
|
||||
agentAvatarSource = activeAgentAvatar,
|
||||
statusText = gatewaySummary(gatewayConnectionDisplay),
|
||||
isConnected = gatewayConnectionDisplay.isConnected,
|
||||
pendingRunCount = pendingRunCount,
|
||||
@@ -626,6 +631,7 @@ private fun OverviewStatusPill(
|
||||
private fun OverviewPrimaryPanel(
|
||||
agentName: String,
|
||||
agentBadge: String,
|
||||
agentAvatarSource: AgentAvatarSource?,
|
||||
statusText: String,
|
||||
isConnected: Boolean,
|
||||
pendingRunCount: Int,
|
||||
@@ -640,7 +646,7 @@ private fun OverviewPrimaryPanel(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(ClawTheme.spacing.xs)) {
|
||||
Text(text = "ACTIVE AGENT", style = ClawTheme.type.caption.copy(fontSize = 12.sp, lineHeight = 15.sp), color = ClawTheme.colors.textMuted)
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
OverviewAgentBadge(text = agentBadge, active = isConnected)
|
||||
OverviewAgentBadge(text = agentBadge, active = isConnected, avatarSource = agentAvatarSource)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(text = if (pendingRunCount > 0) "$agentName is working" else agentName, style = ClawTheme.type.title.copy(fontSize = 19.sp, lineHeight = 23.sp), color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
@@ -729,6 +735,7 @@ private fun OverviewLayeredPanel(
|
||||
private fun OverviewAgentBadge(
|
||||
text: String,
|
||||
active: Boolean,
|
||||
avatarSource: AgentAvatarSource?,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(42.dp),
|
||||
@@ -738,12 +745,14 @@ private fun OverviewAgentBadge(
|
||||
tonalElevation = if (active) 3.dp else 1.dp,
|
||||
shadowElevation = if (active) 5.dp else 1.dp,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = text,
|
||||
style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp),
|
||||
maxLines = 1,
|
||||
)
|
||||
ClawAgentAvatar(source = avatarSource, size = 42.dp) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = text,
|
||||
style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1106,6 +1115,11 @@ internal fun overviewAgentBadgeText(
|
||||
return agentInitials(source)
|
||||
}
|
||||
|
||||
internal fun overviewAgentAvatar(
|
||||
agents: List<GatewayAgentSummary>,
|
||||
defaultAgentId: String?,
|
||||
): AgentAvatarSource? = overviewAgent(agents = agents, defaultAgentId = defaultAgentId)?.let(::agentAvatarSource)
|
||||
|
||||
private fun overviewAgent(
|
||||
agents: List<GatewayAgentSummary>,
|
||||
defaultAgentId: String?,
|
||||
|
||||
@@ -1,54 +1,25 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.takeUtf16Safe
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.LruCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Call
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Dns
|
||||
import ai.openclaw.app.ui.image.SafeWebFetcher
|
||||
import ai.openclaw.app.ui.image.isPubliclyRoutableHost
|
||||
import ai.openclaw.app.ui.image.safePublicHttpClient
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okio.Buffer
|
||||
import org.commonmark.node.Code
|
||||
import org.commonmark.node.FencedCodeBlock
|
||||
import org.commonmark.node.IndentedCodeBlock
|
||||
import org.commonmark.node.Link
|
||||
import org.commonmark.node.Node
|
||||
import java.io.IOException
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.Proxy
|
||||
import java.net.URI
|
||||
import java.net.UnknownHostException
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
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_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/*"
|
||||
private val LINK_PREVIEW_IMAGE_CONTENT_TYPES = setOf("image/jpeg", "image/png", "image/webp")
|
||||
|
||||
internal data class LinkPreviewMetadata(
|
||||
val url: String,
|
||||
@@ -65,14 +36,6 @@ 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)
|
||||
|
||||
@@ -128,23 +91,15 @@ internal fun parseOpenGraph(
|
||||
}
|
||||
|
||||
internal class LinkPreviewFetcher(
|
||||
private val client: OkHttpClient = defaultLinkPreviewClient,
|
||||
client: OkHttpClient = safePublicHttpClient,
|
||||
private val timeoutMillis: Long = LINK_PREVIEW_TIMEOUT_MILLIS,
|
||||
private val hostPolicy: (HttpUrl) -> Boolean = ::isPubliclyRoutableHost,
|
||||
) {
|
||||
suspend fun fetch(url: String): LinkPreviewResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchBlocking(url)
|
||||
}
|
||||
private val webFetcher = SafeWebFetcher(client, timeoutMillis, hostPolicy)
|
||||
|
||||
suspend fun fetchImage(url: String): LinkPreviewImageResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchImageBlocking(url)
|
||||
}
|
||||
|
||||
private suspend fun fetchBlocking(originalUrl: String): LinkPreviewResult {
|
||||
suspend fun fetch(originalUrl: String): LinkPreviewResult {
|
||||
val response =
|
||||
fetchBody(
|
||||
webFetcher.fetch(
|
||||
originalUrl = originalUrl,
|
||||
accept = LINK_PREVIEW_ACCEPT,
|
||||
allowedContentTypes = setOf("text/html"),
|
||||
@@ -157,188 +112,6 @@ internal class LinkPreviewFetcher(
|
||||
LinkPreviewResult.Failed -> LinkPreviewResult.Failed
|
||||
}
|
||||
}
|
||||
|
||||
private suspend 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 suspend fun fetchBody(
|
||||
originalUrl: String,
|
||||
accept: String,
|
||||
allowedContentTypes: Set<String>,
|
||||
maxBytes: Int,
|
||||
rejectOversizedBody: Boolean,
|
||||
): LinkPreviewFetchedBody? {
|
||||
var currentUrl =
|
||||
originalUrl
|
||||
.toHttpUrlOrNull()
|
||||
?.takeIf(::isSafeWebUrl)
|
||||
?.takeIf(hostPolicy)
|
||||
?: return null
|
||||
val deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis)
|
||||
var redirects = 0
|
||||
|
||||
while (true) {
|
||||
val remainingNanos = deadlineNanos - System.nanoTime()
|
||||
if (remainingNanos <= 0L) return null
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(currentUrl)
|
||||
.header("Accept", accept)
|
||||
.get()
|
||||
.build()
|
||||
val call = client.newCall(request)
|
||||
call.timeout().timeout(remainingNanos, TimeUnit.NANOSECONDS)
|
||||
|
||||
val response = call.executeCancellable() ?: return null
|
||||
response.use {
|
||||
if (it.isRedirect) {
|
||||
if (redirects >= LINK_PREVIEW_MAX_REDIRECTS) return null
|
||||
currentUrl = resolveRedirect(currentUrl, it.header("Location"), hostPolicy) ?: return null
|
||||
redirects += 1
|
||||
continue
|
||||
}
|
||||
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 = call.awaitBodyRead { readBody(it.body, maxBytes, rejectOversizedBody) } ?: return null
|
||||
return LinkPreviewFetchedBody(
|
||||
url = currentUrl,
|
||||
bytes = bytes,
|
||||
charset = contentType.charset(Charsets.UTF_8) ?: Charsets.UTF_8,
|
||||
contentType = contentTypeName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun Call.executeCancellable(): Response? =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
// execute() blocks this IO worker, so cancellation must cancel the Call from the cancelling thread.
|
||||
continuation.invokeOnCancellation { cancel() }
|
||||
val response =
|
||||
try {
|
||||
execute()
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
}
|
||||
if (response != null) {
|
||||
continuation.resume(response) { _, cancelledResponse, _ ->
|
||||
cancelledResponse.close()
|
||||
}
|
||||
} else if (continuation.isActive) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> Call.awaitBodyRead(block: () -> T?): T? =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
continuation.invokeOnCancellation { cancel() }
|
||||
try {
|
||||
val result = block()
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
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(
|
||||
@@ -358,150 +131,8 @@ internal class LinkPreviewStore(
|
||||
}
|
||||
}
|
||||
|
||||
internal class LinkPreviewImageStore(
|
||||
private val fetcher: suspend (String) -> LinkPreviewImageResult,
|
||||
maxBytes: Int = LINK_PREVIEW_IMAGE_CACHE_MAX_BYTES,
|
||||
) {
|
||||
// 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 }
|
||||
val result = fetcher(url)
|
||||
cache.put(url, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private val defaultLinkPreviewClient: OkHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.retryOnConnectionFailure(false)
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.authenticator(Authenticator.NONE)
|
||||
.proxyAuthenticator(Authenticator.NONE)
|
||||
.proxy(Proxy.NO_PROXY)
|
||||
// Validate inside Dns so the approved address is the one OkHttp connects to, preventing rebinding/TOCTOU.
|
||||
.dns(PublicOnlyDns())
|
||||
.build()
|
||||
|
||||
private val chatLinkPreviewFetcher = LinkPreviewFetcher()
|
||||
internal val chatLinkPreviewStore = LinkPreviewStore(fetcher = chatLinkPreviewFetcher::fetch)
|
||||
internal val chatLinkPreviewImageStore = LinkPreviewImageStore(fetcher = chatLinkPreviewFetcher::fetchImage)
|
||||
|
||||
internal fun resolveRedirect(
|
||||
baseUrl: HttpUrl,
|
||||
location: String?,
|
||||
hostPolicy: (HttpUrl) -> Boolean = ::isPubliclyRoutableHost,
|
||||
): HttpUrl? =
|
||||
location
|
||||
?.let(baseUrl::resolve)
|
||||
?.takeIf { isSafeWebUrl(it) && hostPolicy(it) }
|
||||
|
||||
private fun isSafeWebUrl(url: HttpUrl): Boolean = url.scheme == "http" || url.scheme == "https"
|
||||
|
||||
internal fun isPubliclyRoutableHost(url: HttpUrl): Boolean {
|
||||
val host = url.host.trimEnd('.').lowercase(Locale.US)
|
||||
if (host == "localhost" || host.endsWith(".local")) return false
|
||||
val address = parseLiteralAddress(host) ?: return true
|
||||
return isPubliclyRoutableAddress(address)
|
||||
}
|
||||
|
||||
private fun parseLiteralAddress(host: String): InetAddress? {
|
||||
if (host.contains(':')) return runCatching { InetAddress.getByName(host) }.getOrNull()
|
||||
val octets = host.split('.')
|
||||
if (octets.size != 4) return null
|
||||
val bytes =
|
||||
octets.map { octet ->
|
||||
val value = octet.toIntOrNull()?.takeIf { it in 0..255 } ?: return null
|
||||
value.toByte()
|
||||
}
|
||||
return InetAddress.getByAddress(bytes.toByteArray())
|
||||
}
|
||||
|
||||
private fun isPubliclyRoutableAddress(address: InetAddress): Boolean =
|
||||
!address.isAnyLocalAddress &&
|
||||
!address.isLoopbackAddress &&
|
||||
!address.isSiteLocalAddress &&
|
||||
!address.isLinkLocalAddress &&
|
||||
!address.isMulticastAddress &&
|
||||
!address.isUniqueLocalAddress() &&
|
||||
!address.isLimitedBroadcastAddress() &&
|
||||
!address.isSpecialPurposeAddress()
|
||||
|
||||
private fun InetAddress.isUniqueLocalAddress(): Boolean {
|
||||
val bytes = address
|
||||
return bytes.size == 16 && (bytes[0].toInt() and 0xfe) == 0xfc
|
||||
}
|
||||
|
||||
private fun InetAddress.isLimitedBroadcastAddress(): Boolean = this is Inet4Address && address.all { byte -> byte.toInt() and 0xff == 0xff }
|
||||
|
||||
private fun InetAddress.isSpecialPurposeAddress(): Boolean =
|
||||
when (this) {
|
||||
is Inet4Address -> {
|
||||
val octets = address.map { it.toInt() and 0xff }
|
||||
val first = octets[0]
|
||||
val second = octets[1]
|
||||
val third = octets[2]
|
||||
first == 0 ||
|
||||
first == 10 ||
|
||||
(first == 100 && second in 64..127) ||
|
||||
first == 127 ||
|
||||
(first == 169 && second == 254) ||
|
||||
(first == 172 && second in 16..31) ||
|
||||
(first == 192 && second == 0 && (third == 0 || third == 2)) ||
|
||||
(first == 192 && second == 88 && third == 99) ||
|
||||
(first == 192 && second == 168) ||
|
||||
(first == 198 && second in 18..19) ||
|
||||
(first == 198 && second == 51 && third == 100) ||
|
||||
(first == 203 && second == 0 && third == 113) ||
|
||||
first >= 224
|
||||
}
|
||||
is Inet6Address -> {
|
||||
val bytes = address
|
||||
val first = bytes[0].toInt() and 0xff
|
||||
val globalUnicast = first and 0xe0 == 0x20
|
||||
val special2001Prefix = bytes.matchesPrefix(0x20, 0x01, 0x00)
|
||||
val fourthHighNibble = bytes[3].toInt() and 0xf0
|
||||
val orchid = special2001Prefix && (fourthHighNibble == 0x10 || fourthHighNibble == 0x20)
|
||||
!globalUnicast ||
|
||||
bytes.matchesPrefix(0x20, 0x01, 0x00, 0x00) ||
|
||||
bytes.matchesPrefix(0x20, 0x01, 0x00, 0x02) ||
|
||||
orchid ||
|
||||
bytes.matchesPrefix(0x20, 0x01, 0x0d, 0xb8) ||
|
||||
bytes.matchesPrefix(0x20, 0x02) ||
|
||||
(bytes.matchesPrefix(0x3f, 0xff) && (bytes[2].toInt() and 0xf0) == 0)
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
|
||||
private fun ByteArray.matchesPrefix(vararg prefix: Int): Boolean = matchesAt(0, *prefix)
|
||||
|
||||
internal class PublicOnlyDns(
|
||||
private val delegate: Dns = Dns.SYSTEM,
|
||||
) : Dns {
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
val addresses = delegate.lookup(hostname)
|
||||
if (addresses.any { !isPubliclyRoutableAddress(it) }) {
|
||||
throw UnknownHostException("$hostname resolved to a non-public address")
|
||||
}
|
||||
return addresses
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveSafeWebUrl(
|
||||
baseUrl: String,
|
||||
|
||||
@@ -11,6 +11,8 @@ 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.image.RemoteImageResult
|
||||
import ai.openclaw.app.ui.image.safeRemoteImageStore
|
||||
import ai.openclaw.app.ui.mobileAccent
|
||||
import ai.openclaw.app.ui.mobileAccentSoft
|
||||
import ai.openclaw.app.ui.mobileBorder
|
||||
@@ -295,9 +297,9 @@ private fun ChatLinkPreview(
|
||||
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
|
||||
when (val image = imageUrl?.let { safeRemoteImageStore.get(it) }) {
|
||||
is RemoteImageResult.Raster -> image.bitmap.asImageBitmap()
|
||||
is RemoteImageResult.Svg, RemoteImageResult.Failed, null -> null
|
||||
}
|
||||
}
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
@@ -17,6 +17,8 @@ import ai.openclaw.app.chat.MessageSpeechState
|
||||
import ai.openclaw.app.chat.VoiceNoteRecorderState
|
||||
import ai.openclaw.app.resolveAgentIdFromMainSessionKey
|
||||
import ai.openclaw.app.ui.copyGatewayDiagnosticsReport
|
||||
import ai.openclaw.app.ui.design.AgentAvatarSource
|
||||
import ai.openclaw.app.ui.design.ClawAgentAvatar
|
||||
import ai.openclaw.app.ui.design.ClawListItem
|
||||
import ai.openclaw.app.ui.design.ClawLoadingState
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
@@ -27,6 +29,7 @@ import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import ai.openclaw.app.ui.design.OpenClawMascot
|
||||
import ai.openclaw.app.ui.design.agentAvatarSource
|
||||
import ai.openclaw.app.ui.gatewayDiagnosticsEndpoint
|
||||
import ai.openclaw.app.ui.gatewayStatusForDisplay
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -499,6 +502,7 @@ private fun ChatAgentSelector(
|
||||
agents.forEach { agent ->
|
||||
ChatSessionChip(
|
||||
text = chatAgentChipText(agent),
|
||||
avatarSource = agentAvatarSource(agent),
|
||||
active = agent.id == activeAgentId,
|
||||
onClick = { onSelectAgent(agent.id) },
|
||||
)
|
||||
@@ -577,6 +581,7 @@ private fun ChatSessionSwitcher(
|
||||
@Composable
|
||||
private fun ChatSessionChip(
|
||||
text: String,
|
||||
avatarSource: AgentAvatarSource? = null,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
@@ -588,13 +593,25 @@ private fun ChatSessionChip(
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.7f)),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||
style = ClawTheme.type.caption,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
horizontal = if (avatarSource == null) 11.dp else 8.dp,
|
||||
vertical = if (avatarSource == null) 7.dp else 5.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (avatarSource != null) {
|
||||
ClawAgentAvatar(source = avatarSource, size = 20.dp) {}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = ClawTheme.type.caption,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
import ai.openclaw.app.ui.image.RemoteImageResult
|
||||
import ai.openclaw.app.ui.image.decodeRemoteImageBitmap
|
||||
import ai.openclaw.app.ui.image.safeRemoteImageStore
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.compose.rememberAsyncImagePainter
|
||||
import coil3.request.ImageRequest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
private const val AGENT_AVATAR_MAX_DIMENSION = 256
|
||||
private const val AGENT_AVATAR_MAX_BYTES = 2 * 1024 * 1024
|
||||
private const val AGENT_AVATAR_MAX_DATA_URL_PREFIX_CHARS = 26
|
||||
|
||||
// Keep Android decoding inside the Gateway's shared avatar payload boundary.
|
||||
private const val AGENT_AVATAR_MAX_DATA_URL_CHARS =
|
||||
((AGENT_AVATAR_MAX_BYTES + 2) / 3) * 4 + AGENT_AVATAR_MAX_DATA_URL_PREFIX_CHARS
|
||||
private val dataImageBase64Prefix =
|
||||
Regex("^data:(image/[a-z0-9.+-]+);base64,", RegexOption.IGNORE_CASE)
|
||||
private val remoteImagePrefix = Regex("^https?://", RegexOption.IGNORE_CASE)
|
||||
|
||||
internal sealed interface AgentAvatarSource {
|
||||
data class Data(
|
||||
val mimeType: String,
|
||||
val base64: String,
|
||||
) : AgentAvatarSource
|
||||
|
||||
data class Remote(
|
||||
val url: String,
|
||||
) : AgentAvatarSource
|
||||
}
|
||||
|
||||
/** Returns the authoritative Android-renderable agent avatar source, if present. */
|
||||
internal fun agentAvatarSource(agent: GatewayAgentSummary): AgentAvatarSource? {
|
||||
val candidate =
|
||||
agent.avatarUrl?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: agent.avatar?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: return null
|
||||
if (remoteImagePrefix.containsMatchIn(candidate)) {
|
||||
return AgentAvatarSource.Remote(candidate)
|
||||
}
|
||||
if (candidate.length > AGENT_AVATAR_MAX_DATA_URL_CHARS) return null
|
||||
val prefix = dataImageBase64Prefix.find(candidate) ?: return null
|
||||
val base64 = candidate.substring(prefix.range.last + 1).trim().takeIf { it.isNotEmpty() } ?: return null
|
||||
return AgentAvatarSource.Data(
|
||||
mimeType = prefix.groupValues[1].lowercase(Locale.US),
|
||||
base64 = base64,
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders an agent image when loading succeeds, otherwise the caller-owned fallback. */
|
||||
@Composable
|
||||
internal fun ClawAgentAvatar(
|
||||
source: AgentAvatarSource?,
|
||||
size: Dp,
|
||||
shape: Shape = CircleShape,
|
||||
fallback: @Composable () -> Unit,
|
||||
) {
|
||||
when (source) {
|
||||
is AgentAvatarSource.Data ->
|
||||
if (source.mimeType == "image/svg+xml") {
|
||||
SvgAgentAvatar(base64 = source.base64, size = size, shape = shape, fallback = fallback)
|
||||
} else {
|
||||
RasterDataAgentAvatar(source = source, size = size, shape = shape, fallback = fallback)
|
||||
}
|
||||
is AgentAvatarSource.Remote -> RemoteAgentAvatar(source.url, size, shape, fallback)
|
||||
null -> fallback()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RasterDataAgentAvatar(
|
||||
source: AgentAvatarSource.Data,
|
||||
size: Dp,
|
||||
shape: Shape,
|
||||
fallback: @Composable () -> Unit,
|
||||
) {
|
||||
var bitmap by remember(source) { mutableStateOf<Bitmap?>(null) }
|
||||
LaunchedEffect(source) {
|
||||
bitmap =
|
||||
withContext(Dispatchers.Default) {
|
||||
val bytes = decodeAgentAvatarBase64(source.base64) ?: return@withContext null
|
||||
decodeRemoteImageBitmap(
|
||||
bytes = bytes,
|
||||
maxDimension = AGENT_AVATAR_MAX_DIMENSION,
|
||||
expectedContentType = source.mimeType,
|
||||
)
|
||||
}
|
||||
}
|
||||
val resolved = bitmap
|
||||
if (resolved == null) {
|
||||
fallback()
|
||||
} else {
|
||||
Image(
|
||||
bitmap = resolved.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(size).clip(shape),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteAgentAvatar(
|
||||
url: String,
|
||||
size: Dp,
|
||||
shape: Shape,
|
||||
fallback: @Composable () -> Unit,
|
||||
) {
|
||||
var result by remember(url) { mutableStateOf<RemoteImageResult?>(null) }
|
||||
LaunchedEffect(url) {
|
||||
result = safeRemoteImageStore.get(url)
|
||||
}
|
||||
when (val image = result) {
|
||||
is RemoteImageResult.Raster ->
|
||||
Image(
|
||||
bitmap = image.bitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(size).clip(shape),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
is RemoteImageResult.Svg -> SvgAgentAvatar(bytes = image.bytes, size = size, shape = shape, fallback = fallback)
|
||||
RemoteImageResult.Failed, null -> fallback()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SvgAgentAvatar(
|
||||
base64: String,
|
||||
size: Dp,
|
||||
shape: Shape,
|
||||
fallback: @Composable () -> Unit,
|
||||
) {
|
||||
var bytes by remember(base64) { mutableStateOf<ByteArray?>(null) }
|
||||
LaunchedEffect(base64) {
|
||||
bytes =
|
||||
withContext(Dispatchers.Default) {
|
||||
decodeAgentAvatarBase64(base64)
|
||||
}
|
||||
}
|
||||
val resolved = bytes
|
||||
if (resolved == null) {
|
||||
fallback()
|
||||
} else {
|
||||
SvgAgentAvatar(bytes = resolved, size = size, shape = shape, fallback = fallback)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SvgAgentAvatar(
|
||||
bytes: ByteArray,
|
||||
size: Dp,
|
||||
shape: Shape,
|
||||
fallback: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalPlatformContext.current
|
||||
val request =
|
||||
remember(bytes, context) {
|
||||
ImageRequest
|
||||
.Builder(context)
|
||||
.data(bytes)
|
||||
.size(AGENT_AVATAR_MAX_DIMENSION)
|
||||
.build()
|
||||
}
|
||||
val painter = rememberAsyncImagePainter(model = request, contentScale = ContentScale.Crop)
|
||||
val painterState by painter.state.collectAsState()
|
||||
if (painterState !is AsyncImagePainter.State.Success) {
|
||||
fallback()
|
||||
return
|
||||
}
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(size).clip(shape),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodeAgentAvatarBase64(base64: String): ByteArray? =
|
||||
runCatching { Base64.decode(base64, Base64.DEFAULT) }
|
||||
.getOrNull()
|
||||
?.takeIf { it.isNotEmpty() && it.size <= AGENT_AVATAR_MAX_BYTES }
|
||||
@@ -0,0 +1,409 @@
|
||||
package ai.openclaw.app.ui.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.LruCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Call
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Dns
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okio.Buffer
|
||||
import java.io.IOException
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.Proxy
|
||||
import java.net.UnknownHostException
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.max
|
||||
|
||||
internal const val REMOTE_IMAGE_BODY_MAX_BYTES = 1024 * 1024
|
||||
internal const val REMOTE_IMAGE_MAX_DIMENSION = 600
|
||||
private const val SAFE_WEB_MAX_REDIRECTS = 3
|
||||
private const val SAFE_WEB_TIMEOUT_MILLIS = 6_000L
|
||||
private const val REMOTE_IMAGE_CACHE_MAX_BYTES = 8 * 1024 * 1024
|
||||
private const val REMOTE_IMAGE_CACHE_ENTRIES = 32
|
||||
private const val REMOTE_IMAGE_ACCEPT = "image/*"
|
||||
private val remoteImageContentTypes =
|
||||
setOf("image/gif", "image/jpeg", "image/png", "image/svg+xml", "image/webp")
|
||||
|
||||
internal data class SafeWebBody(
|
||||
val url: HttpUrl,
|
||||
val bytes: ByteArray,
|
||||
val charset: java.nio.charset.Charset,
|
||||
val contentType: String,
|
||||
)
|
||||
|
||||
internal class SafeWebFetcher(
|
||||
private val client: OkHttpClient = safePublicHttpClient,
|
||||
private val timeoutMillis: Long = SAFE_WEB_TIMEOUT_MILLIS,
|
||||
private val hostPolicy: (HttpUrl) -> Boolean = ::isPubliclyRoutableHost,
|
||||
) {
|
||||
suspend fun fetch(
|
||||
originalUrl: String,
|
||||
accept: String,
|
||||
allowedContentTypes: Set<String>,
|
||||
maxBytes: Int,
|
||||
rejectOversizedBody: Boolean,
|
||||
): SafeWebBody? =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchBlocking(
|
||||
originalUrl = originalUrl,
|
||||
accept = accept,
|
||||
allowedContentTypes = allowedContentTypes,
|
||||
maxBytes = maxBytes,
|
||||
rejectOversizedBody = rejectOversizedBody,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchBlocking(
|
||||
originalUrl: String,
|
||||
accept: String,
|
||||
allowedContentTypes: Set<String>,
|
||||
maxBytes: Int,
|
||||
rejectOversizedBody: Boolean,
|
||||
): SafeWebBody? {
|
||||
var currentUrl =
|
||||
originalUrl
|
||||
.toHttpUrlOrNull()
|
||||
?.takeIf(::isSafeWebUrl)
|
||||
?.takeIf(hostPolicy)
|
||||
?: return null
|
||||
val deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis)
|
||||
var redirects = 0
|
||||
|
||||
while (true) {
|
||||
val remainingNanos = deadlineNanos - System.nanoTime()
|
||||
if (remainingNanos <= 0L) return null
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(currentUrl)
|
||||
.header("Accept", accept)
|
||||
.get()
|
||||
.build()
|
||||
val call = client.newCall(request)
|
||||
call.timeout().timeout(remainingNanos, TimeUnit.NANOSECONDS)
|
||||
|
||||
val response = call.executeCancellable() ?: return null
|
||||
response.use {
|
||||
if (it.isRedirect) {
|
||||
if (redirects >= SAFE_WEB_MAX_REDIRECTS) return null
|
||||
currentUrl = resolveRedirect(currentUrl, it.header("Location"), hostPolicy) ?: return null
|
||||
redirects += 1
|
||||
continue
|
||||
}
|
||||
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 = call.awaitBodyRead { readBody(it.body, maxBytes, rejectOversizedBody) } ?: return null
|
||||
return SafeWebBody(
|
||||
url = currentUrl,
|
||||
bytes = bytes,
|
||||
charset = contentType.charset(Charsets.UTF_8) ?: Charsets.UTF_8,
|
||||
contentType = contentTypeName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface RemoteImageResult {
|
||||
data class Raster(
|
||||
val bitmap: Bitmap,
|
||||
) : RemoteImageResult
|
||||
|
||||
data class Svg(
|
||||
val bytes: ByteArray,
|
||||
) : RemoteImageResult
|
||||
|
||||
data object Failed : RemoteImageResult
|
||||
}
|
||||
|
||||
internal class SafeRemoteImageFetcher(
|
||||
private val webFetcher: SafeWebFetcher = SafeWebFetcher(),
|
||||
) {
|
||||
suspend fun fetch(url: String): RemoteImageResult {
|
||||
val response =
|
||||
webFetcher.fetch(
|
||||
originalUrl = url,
|
||||
accept = REMOTE_IMAGE_ACCEPT,
|
||||
allowedContentTypes = remoteImageContentTypes,
|
||||
maxBytes = REMOTE_IMAGE_BODY_MAX_BYTES,
|
||||
rejectOversizedBody = true,
|
||||
) ?: return RemoteImageResult.Failed
|
||||
if (response.contentType == "image/svg+xml") {
|
||||
return RemoteImageResult.Svg(response.bytes)
|
||||
}
|
||||
val bitmap =
|
||||
decodeRemoteImageBitmap(
|
||||
bytes = response.bytes,
|
||||
expectedContentType = response.contentType,
|
||||
) ?: return RemoteImageResult.Failed
|
||||
return RemoteImageResult.Raster(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
internal class SafeRemoteImageStore(
|
||||
private val fetcher: suspend (String) -> RemoteImageResult,
|
||||
maxBytes: Int = REMOTE_IMAGE_CACHE_MAX_BYTES,
|
||||
) {
|
||||
private val minimumResultBytes = max(1, maxBytes / REMOTE_IMAGE_CACHE_ENTRIES)
|
||||
private val cache =
|
||||
object : LruCache<String, RemoteImageResult>(maxBytes) {
|
||||
override fun sizeOf(
|
||||
key: String,
|
||||
value: RemoteImageResult,
|
||||
): Int =
|
||||
when (value) {
|
||||
is RemoteImageResult.Raster -> value.bitmap.allocationByteCount.coerceAtLeast(minimumResultBytes)
|
||||
is RemoteImageResult.Svg -> value.bytes.size.coerceAtLeast(minimumResultBytes)
|
||||
RemoteImageResult.Failed -> minimumResultBytes
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(url: String): RemoteImageResult {
|
||||
cache.get(url)?.let { return it }
|
||||
val result = fetcher(url)
|
||||
cache.put(url, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
internal fun decodeRemoteImageBitmap(
|
||||
bytes: ByteArray,
|
||||
maxDimension: Int = REMOTE_IMAGE_MAX_DIMENSION,
|
||||
expectedContentType: String? = null,
|
||||
): Bitmap? {
|
||||
if (bytes.isEmpty() || maxDimension <= 0) return null
|
||||
val encodedContentType = remoteImageContentType(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 = remoteImageSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
},
|
||||
)
|
||||
} catch (_: RuntimeException) {
|
||||
null
|
||||
} catch (_: OutOfMemoryError) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun remoteImageContentType(bytes: ByteArray): String? =
|
||||
when {
|
||||
bytes.matchesPrefix(0x47, 0x49, 0x46, 0x38) -> "image/gif"
|
||||
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 remoteImageSampleSize(
|
||||
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 fun resolveRedirect(
|
||||
baseUrl: HttpUrl,
|
||||
location: String?,
|
||||
hostPolicy: (HttpUrl) -> Boolean = ::isPubliclyRoutableHost,
|
||||
): HttpUrl? =
|
||||
location
|
||||
?.let(baseUrl::resolve)
|
||||
?.takeIf { isSafeWebUrl(it) && hostPolicy(it) }
|
||||
|
||||
private fun isSafeWebUrl(url: HttpUrl): Boolean = url.scheme == "http" || url.scheme == "https"
|
||||
|
||||
internal fun isPubliclyRoutableHost(url: HttpUrl): Boolean {
|
||||
val host = url.host.trimEnd('.').lowercase(Locale.US)
|
||||
if (host == "localhost" || host.endsWith(".local")) return false
|
||||
val address = parseLiteralAddress(host) ?: return true
|
||||
return isPubliclyRoutableAddress(address)
|
||||
}
|
||||
|
||||
private fun parseLiteralAddress(host: String): InetAddress? {
|
||||
if (host.contains(':')) return runCatching { InetAddress.getByName(host) }.getOrNull()
|
||||
val octets = host.split('.')
|
||||
if (octets.size != 4) return null
|
||||
val bytes =
|
||||
octets.map { octet ->
|
||||
val value = octet.toIntOrNull()?.takeIf { it in 0..255 } ?: return null
|
||||
value.toByte()
|
||||
}
|
||||
return InetAddress.getByAddress(bytes.toByteArray())
|
||||
}
|
||||
|
||||
private fun isPubliclyRoutableAddress(address: InetAddress): Boolean =
|
||||
!address.isAnyLocalAddress &&
|
||||
!address.isLoopbackAddress &&
|
||||
!address.isSiteLocalAddress &&
|
||||
!address.isLinkLocalAddress &&
|
||||
!address.isMulticastAddress &&
|
||||
!address.isUniqueLocalAddress() &&
|
||||
!address.isLimitedBroadcastAddress() &&
|
||||
!address.isSpecialPurposeAddress()
|
||||
|
||||
private fun InetAddress.isUniqueLocalAddress(): Boolean {
|
||||
val bytes = address
|
||||
return bytes.size == 16 && (bytes[0].toInt() and 0xfe) == 0xfc
|
||||
}
|
||||
|
||||
private fun InetAddress.isLimitedBroadcastAddress(): Boolean = this is Inet4Address && address.all { byte -> byte.toInt() and 0xff == 0xff }
|
||||
|
||||
private fun InetAddress.isSpecialPurposeAddress(): Boolean =
|
||||
when (this) {
|
||||
is Inet4Address -> {
|
||||
val octets = address.map { it.toInt() and 0xff }
|
||||
val first = octets[0]
|
||||
val second = octets[1]
|
||||
val third = octets[2]
|
||||
first == 0 ||
|
||||
first == 10 ||
|
||||
(first == 100 && second in 64..127) ||
|
||||
first == 127 ||
|
||||
(first == 169 && second == 254) ||
|
||||
(first == 172 && second in 16..31) ||
|
||||
(first == 192 && second == 0 && (third == 0 || third == 2)) ||
|
||||
(first == 192 && second == 88 && third == 99) ||
|
||||
(first == 192 && second == 168) ||
|
||||
(first == 198 && second in 18..19) ||
|
||||
(first == 198 && second == 51 && third == 100) ||
|
||||
(first == 203 && second == 0 && third == 113) ||
|
||||
first >= 224
|
||||
}
|
||||
is Inet6Address -> {
|
||||
val bytes = address
|
||||
val first = bytes[0].toInt() and 0xff
|
||||
val globalUnicast = first and 0xe0 == 0x20
|
||||
val special2001Prefix = bytes.matchesPrefix(0x20, 0x01, 0x00)
|
||||
val fourthHighNibble = bytes[3].toInt() and 0xf0
|
||||
val orchid = special2001Prefix && (fourthHighNibble == 0x10 || fourthHighNibble == 0x20)
|
||||
!globalUnicast ||
|
||||
bytes.matchesPrefix(0x20, 0x01, 0x00, 0x00) ||
|
||||
bytes.matchesPrefix(0x20, 0x01, 0x00, 0x02) ||
|
||||
orchid ||
|
||||
bytes.matchesPrefix(0x20, 0x01, 0x0d, 0xb8) ||
|
||||
bytes.matchesPrefix(0x20, 0x02) ||
|
||||
(bytes.matchesPrefix(0x3f, 0xff) && (bytes[2].toInt() and 0xf0) == 0)
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
|
||||
private fun ByteArray.matchesPrefix(vararg prefix: Int): Boolean = matchesAt(0, *prefix)
|
||||
|
||||
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] }
|
||||
|
||||
internal class PublicOnlyDns(
|
||||
private val delegate: Dns = Dns.SYSTEM,
|
||||
) : Dns {
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
val addresses = delegate.lookup(hostname)
|
||||
if (addresses.any { !isPubliclyRoutableAddress(it) }) {
|
||||
throw UnknownHostException("$hostname resolved to a non-public address")
|
||||
}
|
||||
return addresses
|
||||
}
|
||||
}
|
||||
|
||||
internal val safePublicHttpClient: OkHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.retryOnConnectionFailure(false)
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.authenticator(Authenticator.NONE)
|
||||
.proxyAuthenticator(Authenticator.NONE)
|
||||
.proxy(Proxy.NO_PROXY)
|
||||
// Pin the validated DNS answer to the connection and reject rebinding into private ranges.
|
||||
.dns(PublicOnlyDns())
|
||||
.build()
|
||||
|
||||
// Build the shared stores only after their public-only client; top-level initialization can
|
||||
// otherwise re-enter the client field and expose a null default to preview/avatar callers.
|
||||
private val safeRemoteImageFetcher = SafeRemoteImageFetcher()
|
||||
internal val safeRemoteImageStore = SafeRemoteImageStore(fetcher = safeRemoteImageFetcher::fetch)
|
||||
|
||||
private suspend fun Call.executeCancellable(): Response? =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
continuation.invokeOnCancellation { cancel() }
|
||||
val response =
|
||||
try {
|
||||
execute()
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
}
|
||||
if (response != null) {
|
||||
continuation.resume(response) { _, cancelledResponse, _ ->
|
||||
cancelledResponse.close()
|
||||
}
|
||||
} else if (continuation.isActive) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> Call.awaitBodyRead(block: () -> T?): T? =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
continuation.invokeOnCancellation { cancel() }
|
||||
try {
|
||||
val result = block()
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBody(
|
||||
body: ResponseBody,
|
||||
maxBytes: Int,
|
||||
rejectOversizedBody: Boolean,
|
||||
): ByteArray? {
|
||||
if (rejectOversizedBody && body.contentLength() > maxBytes) return null
|
||||
val buffer = Buffer()
|
||||
val source = body.source()
|
||||
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()
|
||||
}
|
||||
@@ -38,6 +38,7 @@ class AndroidLicenseNoticesTest {
|
||||
listOf(
|
||||
"AndroidX Room",
|
||||
"Bouncy Castle Provider",
|
||||
"Coil",
|
||||
"CommonMark Java",
|
||||
"dnsjava",
|
||||
"KaTeX",
|
||||
@@ -56,5 +57,6 @@ class AndroidLicenseNoticesTest {
|
||||
assertTrue(licenses.any { license -> license.text.contains("BSD 3-Clause") })
|
||||
assertTrue(licenses.any { license -> license.text.contains("MIT License") })
|
||||
assertTrue(licenses.any { license -> license.text.contains("Bouncy Castle Licence") })
|
||||
assertTrue(licenses.any { license -> license.title == "Coil" && license.text.contains("Coil Contributors") })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayAgentSummaryTest {
|
||||
@Test
|
||||
fun parsesAvatarAndResolvedAvatarUrlFromAgentsListRow() {
|
||||
val agent =
|
||||
parse(
|
||||
"""{"id":"main","name":" Main ","identity":{"emoji":" 🦞 ","avatar":" raw ","avatarUrl":" resolved "},"workspaceGit":true}""",
|
||||
)
|
||||
|
||||
assertEquals("main", agent?.id)
|
||||
assertEquals("Main", agent?.name)
|
||||
assertEquals("🦞", agent?.emoji)
|
||||
assertEquals("raw", agent?.avatar)
|
||||
assertEquals("resolved", agent?.avatarUrl)
|
||||
assertTrue(agent?.workspaceGit == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalizesMissingAndBlankIdentityValues() {
|
||||
val missing = parse("""{"id":"main"}""")
|
||||
val blank =
|
||||
parse(
|
||||
"""{"id":"blank","identity":{"emoji":" ","avatar":"\n","avatarUrl":"\t"},"workspaceGit":false}""",
|
||||
)
|
||||
|
||||
assertNull(missing?.name)
|
||||
assertNull(missing?.avatar)
|
||||
assertNull(missing?.avatarUrl)
|
||||
assertFalse(missing?.workspaceGit == true)
|
||||
assertNull(blank?.emoji)
|
||||
assertNull(blank?.avatar)
|
||||
assertNull(blank?.avatarUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresMalformedIdentityShapesAndRowsWithoutIds() {
|
||||
val malformedIdentity = parse("""{"id":"main","identity":["not-an-object"]}""")
|
||||
val malformedAvatarFields =
|
||||
parse(
|
||||
"""{"id":"main","identity":{"avatar":{"data":"x"},"avatarUrl":["x"]}}""",
|
||||
)
|
||||
|
||||
assertNull(malformedIdentity?.avatar)
|
||||
assertNull(malformedIdentity?.avatarUrl)
|
||||
assertNull(malformedAvatarFields?.avatar)
|
||||
assertNull(malformedAvatarFields?.avatarUrl)
|
||||
assertNull(parse("""{"name":"missing id"}"""))
|
||||
assertNull(parse("""{"id":" "}"""))
|
||||
assertNull(parse("[]"))
|
||||
}
|
||||
|
||||
private fun parse(value: String): GatewayAgentSummary? =
|
||||
parseGatewayAgentSummaries(
|
||||
Json.parseToJsonElement("""{"agents":[$value]}""").jsonObject,
|
||||
).singleOrNull()
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.ui.image.PublicOnlyDns
|
||||
import ai.openclaw.app.ui.image.REMOTE_IMAGE_BODY_MAX_BYTES
|
||||
import ai.openclaw.app.ui.image.REMOTE_IMAGE_MAX_DIMENSION
|
||||
import ai.openclaw.app.ui.image.RemoteImageResult
|
||||
import ai.openclaw.app.ui.image.SafeRemoteImageFetcher
|
||||
import ai.openclaw.app.ui.image.SafeRemoteImageStore
|
||||
import ai.openclaw.app.ui.image.SafeWebFetcher
|
||||
import ai.openclaw.app.ui.image.decodeRemoteImageBitmap
|
||||
import ai.openclaw.app.ui.image.isPubliclyRoutableHost
|
||||
import ai.openclaw.app.ui.image.resolveRedirect
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -303,7 +313,7 @@ class ChatLinkPreviewTest {
|
||||
.setBody(Buffer().write(pngBytes(width = 10, height = 10))),
|
||||
)
|
||||
|
||||
val imageFetch = async { fetcher(timeoutMillis = 60_000).fetchImage(server.url("/slow-image.png").toString()) }
|
||||
val imageFetch = async { imageFetcher(timeoutMillis = 60_000).fetch(server.url("/slow-image.png").toString()) }
|
||||
assertTrue(withContext(Dispatchers.IO) { server.takeRequest(1, TimeUnit.SECONDS) } != null)
|
||||
delay(100)
|
||||
|
||||
@@ -373,12 +383,12 @@ class ChatLinkPreviewTest {
|
||||
fun imageFetchStartsOnlyWhenStoreIsRequestedAndCacheHitAvoidsSecondRequest() =
|
||||
withServer { server ->
|
||||
server.enqueue(imageResponse(pngBytes(width = 120, height = 80)))
|
||||
val store = LinkPreviewImageStore(fetcher = fetcher()::fetchImage)
|
||||
val store = SafeRemoteImageStore(fetcher = imageFetcher()::fetch)
|
||||
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)
|
||||
assertTrue(store.get(imageUrl) is RemoteImageResult.Raster)
|
||||
assertTrue(store.get(imageUrl) is RemoteImageResult.Raster)
|
||||
assertEquals(1, server.requestCount)
|
||||
assertEquals("image/*", server.takeRequest().getHeader("Accept"))
|
||||
}
|
||||
@@ -390,18 +400,18 @@ class ChatLinkPreviewTest {
|
||||
val second = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888)
|
||||
val fetchCounts = mutableMapOf<String, Int>()
|
||||
val store =
|
||||
LinkPreviewImageStore(
|
||||
SafeRemoteImageStore(
|
||||
fetcher = { url ->
|
||||
fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1
|
||||
LinkPreviewImageResult.Loaded(if (url == "first") first else second)
|
||||
RemoteImageResult.Raster(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)
|
||||
assertTrue(store.get("first") is RemoteImageResult.Raster)
|
||||
assertTrue(store.get("second") is RemoteImageResult.Raster)
|
||||
assertTrue(store.get("first") is RemoteImageResult.Raster)
|
||||
|
||||
assertEquals(2, fetchCounts["first"])
|
||||
assertEquals(1, fetchCounts["second"])
|
||||
@@ -416,18 +426,18 @@ class ChatLinkPreviewTest {
|
||||
runBlocking {
|
||||
val fetchCounts = mutableMapOf<String, Int>()
|
||||
val store =
|
||||
LinkPreviewImageStore(
|
||||
SafeRemoteImageStore(
|
||||
fetcher = { url ->
|
||||
fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1
|
||||
LinkPreviewImageResult.Failed
|
||||
RemoteImageResult.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"))
|
||||
assertSame(RemoteImageResult.Failed, store.get("first"))
|
||||
assertSame(RemoteImageResult.Failed, store.get("second"))
|
||||
assertSame(RemoteImageResult.Failed, store.get("third"))
|
||||
assertSame(RemoteImageResult.Failed, store.get("first"))
|
||||
|
||||
assertEquals(2, fetchCounts["first"])
|
||||
assertEquals(1, fetchCounts["second"])
|
||||
@@ -441,19 +451,19 @@ class ChatLinkPreviewTest {
|
||||
val maxEntries = 32
|
||||
val fetchCounts = mutableMapOf<String, Int>()
|
||||
val store =
|
||||
LinkPreviewImageStore(
|
||||
SafeRemoteImageStore(
|
||||
fetcher = { url ->
|
||||
fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1
|
||||
LinkPreviewImageResult.Loaded(tiny)
|
||||
RemoteImageResult.Raster(tiny)
|
||||
},
|
||||
maxBytes = tiny.allocationByteCount * maxEntries * 2,
|
||||
)
|
||||
|
||||
try {
|
||||
repeat(maxEntries + 1) { index ->
|
||||
assertTrue(store.get("image-$index") is LinkPreviewImageResult.Loaded)
|
||||
assertTrue(store.get("image-$index") is RemoteImageResult.Raster)
|
||||
}
|
||||
assertTrue(store.get("image-0") is LinkPreviewImageResult.Loaded)
|
||||
assertTrue(store.get("image-0") is RemoteImageResult.Raster)
|
||||
|
||||
assertEquals(2, fetchCounts["image-0"])
|
||||
} finally {
|
||||
@@ -470,13 +480,13 @@ class ChatLinkPreviewTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "image/png")
|
||||
.setBody(Buffer().write(ByteArray(LINK_PREVIEW_IMAGE_BODY_MAX_BYTES + 1))),
|
||||
.setBody(Buffer().write(ByteArray(REMOTE_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()))
|
||||
assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/animated.gif").toString()))
|
||||
assertTrue(imageFetcher().fetch(server.url("/vector.svg").toString()) is RemoteImageResult.Svg)
|
||||
assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/spoofed.png").toString()))
|
||||
assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/oversized.png").toString()))
|
||||
assertEquals(4, server.requestCount)
|
||||
}
|
||||
|
||||
@@ -485,7 +495,7 @@ class ChatLinkPreviewTest {
|
||||
withServer { server ->
|
||||
server.enqueue(imageResponse(pngBytes(width = 10, height = 10)))
|
||||
|
||||
assertSame(LinkPreviewImageResult.Failed, realPolicyFetcher().fetchImage(server.url("/private.png").toString()))
|
||||
assertSame(RemoteImageResult.Failed, realPolicyImageFetcher().fetch(server.url("/private.png").toString()))
|
||||
assertEquals(0, server.requestCount)
|
||||
}
|
||||
|
||||
@@ -495,7 +505,7 @@ class ChatLinkPreviewTest {
|
||||
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)
|
||||
assertTrue(imageFetcher().fetch(server.url("/image-start").toString()) is RemoteImageResult.Raster)
|
||||
assertEquals(4, server.requestCount)
|
||||
}
|
||||
|
||||
@@ -503,36 +513,36 @@ class ChatLinkPreviewTest {
|
||||
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()))
|
||||
assertSame(RemoteImageResult.Failed, imageFetcher().fetch(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()))
|
||||
assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/image-start").toString()))
|
||||
assertEquals(1, server.requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageDecodeDownsamplesLargeSource() {
|
||||
val decoded = decodeLinkPreviewBitmap(pngBytes(width = 2_400, height = 1_200))
|
||||
val decoded = decodeRemoteImageBitmap(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)
|
||||
assertTrue(checkNotNull(decoded).width <= REMOTE_IMAGE_MAX_DIMENSION)
|
||||
assertTrue(decoded.height <= REMOTE_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 store = SafeRemoteImageStore(fetcher = imageFetcher()::fetch)
|
||||
val imageUrl = server.url("/corrupt.webp").toString()
|
||||
|
||||
assertSame(LinkPreviewImageResult.Failed, store.get(imageUrl))
|
||||
assertSame(LinkPreviewImageResult.Failed, store.get(imageUrl))
|
||||
assertSame(RemoteImageResult.Failed, store.get(imageUrl))
|
||||
assertSame(RemoteImageResult.Failed, store.get(imageUrl))
|
||||
assertEquals(1, server.requestCount)
|
||||
}
|
||||
|
||||
@@ -540,6 +550,10 @@ class ChatLinkPreviewTest {
|
||||
|
||||
private fun realPolicyFetcher(): LinkPreviewFetcher = LinkPreviewFetcher(baseClient().build())
|
||||
|
||||
private fun imageFetcher(timeoutMillis: Long = 6_000): SafeRemoteImageFetcher = SafeRemoteImageFetcher(SafeWebFetcher(baseClient().build(), timeoutMillis, permissiveHostPolicy))
|
||||
|
||||
private fun realPolicyImageFetcher(): SafeRemoteImageFetcher = SafeRemoteImageFetcher(SafeWebFetcher(baseClient().build()))
|
||||
|
||||
private fun baseClient(): OkHttpClient.Builder =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class AgentAvatarTest {
|
||||
@Test
|
||||
fun prefersResolvedAvatarUrl() {
|
||||
val agent = agent(avatar = dataUrl("image/png", "raw"), avatarUrl = dataUrl("image/jpeg", "resolved"))
|
||||
|
||||
assertEquals(
|
||||
AgentAvatarSource.Data(mimeType = "image/jpeg", base64 = "resolved"),
|
||||
agentAvatarSource(agent),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackToRawAvatarOnlyWhenResolvedAvatarIsMissing() {
|
||||
val raw = AgentAvatarSource.Data(mimeType = "image/png", base64 = "raw")
|
||||
|
||||
assertEquals(raw, agentAvatarSource(agent(avatar = dataUrl("image/png", "raw"))))
|
||||
assertEquals(raw, agentAvatarSource(agent(avatar = dataUrl("image/png", "raw"), avatarUrl = " ")))
|
||||
assertNull(agentAvatarSource(agent(avatar = dataUrl("image/png", "raw"), avatarUrl = "not an image")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesRasterAndSvgMimeTypes() {
|
||||
assertEquals(
|
||||
AgentAvatarSource.Data(mimeType = "image/png", base64 = "body"),
|
||||
agentAvatarSource(agent(avatarUrl = "DATA:IMAGE/PNG;BASE64, body ")),
|
||||
)
|
||||
assertEquals(
|
||||
AgentAvatarSource.Data(mimeType = "image/svg+xml", base64 = "PHN2Zy8+"),
|
||||
agentAvatarSource(agent(avatarUrl = dataUrl("image/svg+xml", "PHN2Zy8+"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recognizesRemoteHttpSources() {
|
||||
assertEquals(
|
||||
AgentAvatarSource.Remote("https://example.com/avatar.png"),
|
||||
agentAvatarSource(agent(avatarUrl = "https://example.com/avatar.png")),
|
||||
)
|
||||
assertEquals(
|
||||
AgentAvatarSource.Remote("HTTP://example.com/avatar.svg"),
|
||||
agentAvatarSource(agent(avatarUrl = "HTTP://example.com/avatar.svg")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMalformedOrUnsupportedAvatarValues() {
|
||||
assertNull(agentAvatarSource(agent(avatarUrl = "data:image/png,raw")))
|
||||
assertNull(agentAvatarSource(agent(avatarUrl = "data:text/plain;base64,dGV4dA==")))
|
||||
assertNull(agentAvatarSource(agent(avatarUrl = "data:image/png;base64,")))
|
||||
assertNull(agentAvatarSource(agent(avatar = "avatars/openclaw.png")))
|
||||
assertNull(agentAvatarSource(agent(avatar = "🦞")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMissingAvatarValues() {
|
||||
assertNull(agentAvatarSource(agent()))
|
||||
assertNull(agentAvatarSource(agent(avatar = " ", avatarUrl = "\n")))
|
||||
}
|
||||
|
||||
private fun agent(
|
||||
avatar: String? = null,
|
||||
avatarUrl: String? = null,
|
||||
) = GatewayAgentSummary(
|
||||
id = "main",
|
||||
name = "Main",
|
||||
emoji = null,
|
||||
avatar = avatar,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
|
||||
private fun dataUrl(
|
||||
mimeType: String,
|
||||
body: String,
|
||||
): String = "data:$mimeType;base64,$body"
|
||||
}
|
||||
@@ -15,6 +15,7 @@ androidx-uiautomator = "2.4.0"
|
||||
androidx-webkit = "1.15.0"
|
||||
bcprov = "1.84"
|
||||
commonmark = "0.29.0"
|
||||
coil = "3.5.0"
|
||||
coroutines = "1.11.0"
|
||||
dnsjava = "3.6.5"
|
||||
junit = "4.13.2"
|
||||
@@ -62,6 +63,8 @@ commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", v
|
||||
commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
|
||||
commonmark-ext-gfm-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" }
|
||||
commonmark-ext-task-list-items = { module = "org.commonmark:commonmark-ext-task-list-items", version.ref = "commonmark" }
|
||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
|
||||
dnsjava = { module = "dnsjava:dnsjava", version.ref = "dnsjava" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-vintage" }
|
||||
|
||||
Reference in New Issue
Block a user