diff --git a/.changeset/truncate-bytes-surrogate-pair.md b/.changeset/truncate-bytes-surrogate-pair.md new file mode 100644 index 000000000..63967c3c8 --- /dev/null +++ b/.changeset/truncate-bytes-surrogate-pair.md @@ -0,0 +1,7 @@ +--- +"client-sdk-android": patch +--- + +Stop `truncateBytes` from splitting a surrogate pair. The helper binary searches for the longest prefix that fits a byte budget, counting in UTF-16 code units. When the budget fell between the two halves of a surrogate pair, it cut the pair in half and returned a string ending in an unpaired high surrogate, which UTF-8 encodes as a replacement character. An RPC error message or data payload trimmed at such a boundary therefore reached the remote peer corrupted. The cut point now steps back one code unit when it lands on a high surrogate. + +This matches the JavaScript SDK (livekit/client-sdk-js#2126). diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/util/StringByteUtils.kt b/livekit-android-sdk/src/main/java/io/livekit/android/util/StringByteUtils.kt index 778a46b53..c4fafc38d 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/util/StringByteUtils.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/util/StringByteUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit, Inc. + * Copyright 2025-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,5 +43,11 @@ internal fun String.truncateBytes(maxBytes: Int): String { } } + // Never end on the first half of a surrogate pair: it would be encoded as a replacement + // character, and the truncated string would no longer round trip. + if (low > 0 && this[low - 1].isHighSurrogate()) { + low-- + } + return substring(0, low) } diff --git a/livekit-android-test/src/test/java/io/livekit/android/util/StringByteUtilsTest.kt b/livekit-android-test/src/test/java/io/livekit/android/util/StringByteUtilsTest.kt new file mode 100644 index 000000000..7d813261f --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/util/StringByteUtilsTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * 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 + * + * http://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. + */ + +package io.livekit.android.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StringByteUtilsTest { + + @Test + fun `returns string unchanged when already within the budget`() { + assertEquals("hello", "hello".truncateBytes(5)) + assertEquals("hello", "hello".truncateBytes(100)) + assertEquals("", "".truncateBytes(5)) + } + + @Test + fun `truncates ascii at the byte budget`() { + assertEquals("hel", "hello".truncateBytes(3)) + } + + @Test + fun `keeps a multi-byte character whole when it fits`() { + // U+1F600 encodes to 4 UTF-8 bytes. + assertEquals("πŸ˜€", "πŸ˜€hello".truncateBytes(4)) + } + + @Test + fun `does not split a surrogate pair when the budget falls mid-pair`() { + // "a" is 1 byte, U+1F600 is 4 UTF-8 bytes / 2 UTF-16 code units. + // A 3-byte budget cannot fit the emoji, so only "a" may survive. + val truncated = "aπŸ˜€".truncateBytes(3) + + assertEquals("a", truncated) + } + + @Test + fun `never emits an unpaired surrogate`() { + // Any budget that cuts into the emoji must drop it entirely rather than + // leaving a lone high surrogate, which does not round trip through UTF-8. + for (maxBytes in 1..4) { + val truncated = "aπŸ˜€".truncateBytes(maxBytes) + + assertFalse( + "truncateBytes($maxBytes) ended on a high surrogate", + truncated.lastOrNull()?.isHighSurrogate() == true, + ) + assertTrue( + "truncateBytes($maxBytes) exceeded the budget", + truncated.byteLength() <= maxBytes, + ) + // An unpaired surrogate becomes '?' on the way out, so round tripping + // through UTF-8 is what actually proves the result is well formed. + assertEquals( + "truncateBytes($maxBytes) did not round trip through UTF-8", + truncated, + String(truncated.toByteArray(Charsets.UTF_8), Charsets.UTF_8), + ) + } + } +}