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 ->