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:
Igor Guarisma
2026-07-11 20:07:46 -04:00
committed by GitHub
parent dbe53d6212
commit 4147345bcd
18 changed files with 1217 additions and 733 deletions
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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.
+2
View File
@@ -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"
}
+3
View File
@@ -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" }