Skip to content

Decode non-BMP Java strings correctly and convert Swift strings with NewStringUTF - #24

Open
piercifani wants to merge 1 commit into
swiftlang:mainfrom
piercifani:fix/string-utf-conversion
Open

piercifani wants to merge 1 commit into
swiftlang:mainfrom
piercifani:fix/string-utf-conversion

Conversation

@piercifani

Copy link
Copy Markdown

Summary

Two fixes to String's JavaValue conformance in JavaValue+String.swift.

Java → Swift: strings with emoji or NUL came back corrupted. String(fromJNI:) decoded GetStringUTFChars with String(cString:). GetStringUTFChars returns modified UTF-8: NUL is encoded as 0xC0 0x80, and on HotSpot characters outside the BMP are encoded as surrogate pairs. Neither is valid UTF-8, and String(cString:) silently replaces them with U+FFFD, so "🎵🎶", "Fed 🇺🇸 📉" and "before\0after" did not round-trip. Strings that fail String(validatingCString:) are now decoded from UTF-16 via GetStringLength + GetStringRegion into a temporary buffer. Valid strings take the same path as before.

ART encodes non-BMP characters as standard 4-byte UTF-8, so emoji were fine on Android (verified on a Pixel); the emoji case shows up on desktop JVMs (macOS, Linux, Windows, and Robolectric). The embedded-NUL case follows the JNI spec's 0xC0 0x80 encoding everywhere.

Swift → Java: every string was copied to UTF-16. getJNIValue always built Array(utf16) and called NewString (the FIXME this removes). Strings whose UTF-8 is also valid modified UTF-8 (no NUL, no 4-byte sequence) now go straight to NewStringUTF through withCString, which uses the native buffer without a copy. The check scans the contiguous UTF-8 storage; strings without it, and strings that fail the check, keep the existing UTF-16 path.

Numbers

Release build, macOS (Apple silicon), HotSpot, best of 9 runs of 100,000 iterations each:

String Swift → Java before after Java → Swift before after
3 chars 80.8 ns 48.9 ns 1.65× 35.2 ns 37.2 ns
35 chars 152.4 ns 70.7 ns 2.16× 79.8 ns 81.4 ns
258 chars 713.8 ns 213.8 ns 3.34× 266.3 ns 269.6 ns

Java → Swift for valid strings is unchanged within noise. The same change in Skip's swift-jni (skiptools/swift-jni#11) measured 1.56× on String getters on a Pixel 10 Pro Fold (ART, release build).

Tests

New JavaStringTests, run against an empty string, ASCII, Latin-1, CJK, emoji, a flag sequence, mixed BMP/non-BMP and an embedded NUL:

  • roundTrip: String(fromJNI: s.getJNIValue(in:)) equals s. On main it fails for the three emoji strings and the embedded NUL; with this change all eight pass.
  • javaLengthMatchesUTF16: the Java string's length equals s.utf16.count, checking the NewStringUTF fast path never produces a different Java string.

swift test: 21/21. swift format lint --strict is clean on the changed files.

…NewStringUTF

String(fromJNI:) decoded GetStringUTFChars with String(cString:), which
repairs invalid UTF-8. GetStringUTFChars returns modified UTF-8: NUL is
0xC0 0x80 and, on HotSpot, characters outside the BMP are surrogate pairs,
so those strings came back with U+FFFD replacement characters. Strings that
fail UTF-8 validation are now decoded from UTF-16 via GetStringRegion.

getJNIValue always copied the string into a UTF-16 array for NewString.
Strings whose UTF-8 is also valid modified UTF-8 (no NUL, no 4-byte
sequence) now go straight to NewStringUTF; the rest keep the UTF-16 path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant