From 1d4c17073cbef6b4c20df029b4dcbdbcf5ba99ca Mon Sep 17 00:00:00 2001
From: "Ben.Li"
Date: Fri, 10 Jul 2026 03:58:18 +0800
Subject: [PATCH] fix(android): keep link preview metadata UTF-16 safe
(#102988)
* fix(android): keep link preview metadata UTF-16 safe
* refactor(android): share UTF-16-safe truncation
---------
Co-authored-by: Peter Steinberger
---
.../src/main/java/ai/openclaw/app/Utf16Text.kt | 8 ++++++++
.../app/node/DeviceNotificationListenerService.kt | 12 +-----------
.../ai/openclaw/app/ui/chat/ChatLinkPreview.kt | 3 ++-
.../openclaw/app/ui/chat/ChatLinkPreviewTest.kt | 15 +++++++++++++++
4 files changed, 26 insertions(+), 12 deletions(-)
create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/Utf16Text.kt
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/Utf16Text.kt b/apps/android/app/src/main/java/ai/openclaw/app/Utf16Text.kt
new file mode 100644
index 000000000000..f475a2c564ca
--- /dev/null
+++ b/apps/android/app/src/main/java/ai/openclaw/app/Utf16Text.kt
@@ -0,0 +1,8 @@
+package ai.openclaw.app
+
+internal fun String.takeUtf16Safe(maxChars: Int): String {
+ if (length <= maxChars) return this
+ // Keep the code-unit cap without leaving a high surrogate at its boundary.
+ val endsOnHighSurrogate = maxChars > 0 && Character.isHighSurrogate(this[maxChars - 1])
+ return take(if (endsOnHighSurrogate) maxChars - 1 else maxChars)
+}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt
index ebdcfd9f8cc0..c4f734c0dee8 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt
@@ -4,6 +4,7 @@ import ai.openclaw.app.NotificationBurstLimiter
import ai.openclaw.app.SecurePrefs
import ai.openclaw.app.allowsPackage
import ai.openclaw.app.isWithinQuietHours
+import ai.openclaw.app.takeUtf16Safe
import android.app.Notification
import android.app.NotificationManager
import android.app.RemoteInput
@@ -30,17 +31,6 @@ internal fun sanitizeNotificationText(value: CharSequence?): String? {
return normalized.takeUtf16Safe(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null }
}
-private fun String.takeUtf16Safe(maxChars: Int): String {
- if (length <= maxChars) return this
- val end =
- if (maxChars > 0 && Character.isHighSurrogate(this[maxChars - 1])) {
- maxChars - 1
- } else {
- maxChars
- }
- return take(end)
-}
-
/**
* Stable notification snapshot entry exposed through the Android notifications command.
*/
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt
index 52f4c4d5b93b..4bb196a4aea8 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt
@@ -1,5 +1,6 @@
package ai.openclaw.app.ui.chat
+import ai.openclaw.app.takeUtf16Safe
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.LruCache
@@ -524,7 +525,7 @@ private fun sanitizeMetadataText(
.filterNot(Character::isISOControl)
.replace(Regex("\\s+"), " ")
.trim()
- return sanitized.take(maxChars).takeIf(String::isNotEmpty)
+ return sanitized.takeUtf16Safe(maxChars).takeIf(String::isNotEmpty)
}
private fun findTitle(html: String): String? =
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt
index e4fc845ab89f..5a92ada8743b 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt
@@ -164,6 +164,21 @@ class ChatLinkPreviewTest {
)
}
+ @Test
+ fun metadataTruncationPreservesUtf16Boundaries() {
+ val titlePrefix = "t".repeat(LINK_PREVIEW_TITLE_MAX_CHARS - 1)
+ val descriptionPrefix = "d".repeat(LINK_PREVIEW_DESCRIPTION_MAX_CHARS - 2)
+ val result =
+ parseOpenGraph(
+ "" +
+ "",
+ "https://example.com",
+ ) as LinkPreviewResult.Loaded
+
+ assertEquals(titlePrefix, result.metadata.title)
+ assertEquals("$descriptionPrefix\uD83D\uDE80", result.metadata.description)
+ }
+
@Test
fun fetchesHtmlWithoutAmbientHeaders() =
withServer { server ->