ReviewOS

also looking at this

craft-native/craft

feat(android): a JNI runtime layer, and the discipline it has to carry

#136
Merged glennmichael123 wants to merge feat/android-jni-runtime into main
2 files +864 -0
packages/zig/build.zigmodified+19-0
Changes to packages/zig/build.zig
@@ -399,6 +399,20 @@ pub fn build(b: *std.Build) void {
399399 .optimize = optimize,
400400 }),
401401 });
402 // The JNI layer, on its own artifact because nothing on the iOS side
403 // reaches it. It has no target gate: `JNIEnv` is a pointer to a table of
404 // pointers, so the tests build one out of Zig functions and run anywhere —
405 // no JVM, no emulator, no NDK. That is the whole reason the layer is shaped
406 // the way it is.
407 const jni_tests = b.addTest(.{
408 .root_module = b.createModule(.{
409 .root_source_file = b.path("src/jni_runtime.zig"),
410 .target = target,
411 .optimize = optimize,
412 }),
413 });
414 const run_jni_tests = b.addRunArtifact(jni_tests);
415
402416 ios_conformance_tests.root_module.addAnonymousImport("CraftApp.swift", .{
403417 .root_source_file = b.path("../ios/templates/CraftApp.swift"),
404418 });
@@ -1508,6 +1522,7 @@ pub fn build(b: *std.Build) void {
15081522 test_step.dependOn(&run_ios_module_tests.step);
15091523 }
15101524 test_step.dependOn(&run_ios_conformance_tests.step);
1525 test_step.dependOn(&run_jni_tests.step);
15111526 test_step.dependOn(&run_menubar_tests.step);
15121527 test_step.dependOn(&run_components_tests.step);
15131528 test_step.dependOn(&run_gpu_tests.step);
@@ -2180,6 +2195,10 @@ pub fn build(b: *std.Build) void {
21802195
21812196 const test_android_step = b.step("test:android", "Run Android tests");
21822197 test_android_step.dependOn(&run_android_tests.step);
2198 // The JNI layer rides the existing Android step rather than adding a
2199 // second one: `jni_runtime.zig` is the only way anything here reaches Java,
2200 // so a run that skipped it would not be an Android test run.
2201 test_android_step.dependOn(&run_jni_tests.step);
21832202
21842203 // Add Android tests to the main test step
21852204 test_step.dependOn(&run_android_tests.step);
packages/zig/src/jni_runtime.zigadded+845-0
Changes to packages/zig/src/jni_runtime.zig
@@ -0,0 +1,845 @@
1//! Reaching Java from Zig, on Android.
2//!
3//! The counterpart to `objc_runtime.zig`, and structurally nothing like it.
4//! Objective-C is a handful of C symbols — `objc_msgSend`, `objc_getClass` —
5//! that the linker resolves. JNI exports no such symbols. Everything goes
6//! through a **function table** the JVM hands the native method:
7//!
8//! JNIEnv == const struct JNINativeInterface *
9//! the argument == JNIEnv * (so: a pointer to that pointer)
10//! a call == (*env)->FindClass(env, "android/os/Build")
11//!
12//! So `JNIEnv` here is `*const *const JNINativeInterface`, and every call is
13//! two dereferences and an index into a table.
14//!
15//! ## The index is the whole correctness problem
16//!
17//! A field's *position* in `JNINativeInterface` is its memory offset. Get one
18//! wrong and the call still compiles, still runs, and invokes a different
19//! function with arguments meant for another signature — a crash if you are
20//! lucky, silent corruption if not. There is no name check at runtime.
21//!
22//! The order below is transcribed from OpenJDK's `jni.h`, member by member,
23//! not from memory. Each tenth entry carries its index so a reader can audit
24//! the transcription by counting rather than by trusting it, and
25//! `jniIndexOf` + the offset test at the bottom assert it.
26//!
27//! ## Why the struct stops at 170
28//!
29//! It is deliberately truncated after `ReleaseStringUTFChars`. Zig never
30//! *allocates* one of these — the table always belongs to the JVM — so
31//! reading a field at offset ≤ 170 out of the real, longer table is correct,
32//! and every entry not declared is one that cannot be transcribed wrongly.
33//! Adding a call means extending the list in order, from the header.
34//!
35//! ## Every field is an opaque pointer, on purpose
36//!
37//! All 171 entries are `?*const anyopaque`, and each wrapper casts to its own
38//! typed signature at the call site — the same shape `objc_runtime.msgSendId1`
39//! uses for `objc_msgSend`. Two reasons. The struct layout becomes trivially
40//! correct, because every member is one pointer wide whatever its real
41//! signature. And a mistyped signature is then a local bug in one wrapper
42//! rather than a shift in every offset after it.
43//!
44//! ## This layer is host-testable, and that is not an accident of the design
45//!
46//! `objc_runtime.zig` can only be exercised where a real Objective-C runtime
47//! exists. A `JNIEnv` is just a pointer to a table of pointers, so a test can
48//! build one out of Zig functions and drive the wrappers against it with no
49//! JVM, no emulator and no NDK. The tests at the bottom do exactly that: they
50//! are what checks that a wrapper reads the slot it names.
51
52const std = ;
53const builtin = ;
54
55// =============================================================================
56// The primitive types, from jni.h
57// =============================================================================
58
59pub const jint = i32;
60pub const jlong = i64;
61pub const jbyte = i8;
62pub const jboolean = u8;
63pub const jchar = u16;
64pub const jshort = i16;
65pub const jfloat = f32;
66pub const jdouble = f64;
67pub const jsize = jint;
68
69pub const JNI_FALSE: jboolean = 0;
70pub const JNI_TRUE: jboolean = 1;
71
72/// Every Java reference is an opaque pointer. They are deliberately distinct
73/// names rather than one alias: `jclass` and `jstring` are both `jobject` to
74/// the C compiler, and keeping the names apart is the only thing documenting
75/// which a given wrapper expects.
76pub const jobject = ?*anyopaque;
77pub const jclass = jobject;
78pub const jstring = jobject;
79pub const jthrowable = jobject;
80pub const jmethodID = ?*anyopaque;
81pub const jfieldID = ?*anyopaque;
82
83/// `JNIEnv*` as the JVM passes it to a native method.
84pub const JNIEnv = *const *const JNINativeInterface;
85
86// =============================================================================
87// The function table
88//
89// Transcribed in declaration order from OpenJDK's jni.h. The index comments
90// are load-bearing documentation: they are how a reader audits this against
91// the header without re-deriving it.
92// =============================================================================
93
94/// Every slot in the table is a function pointer, so they are all declared as
95/// one — a nullary C function — and each wrapper casts to its real signature at
96/// the call site.
97///
98/// Not `?*const anyopaque`: a data pointer is one-byte-aligned, a function
99/// pointer is not, and casting between them needs an `@alignCast` at every
100/// single call. Typing the slot correctly once removes that from 20 places and
101/// leaves the struct layout untouched — a function pointer is exactly as wide.
102pub const JniFn = ?*const fn () callconv(.c) void;
103
104pub const JNINativeInterface = extern struct {
105 reserved0: JniFn, // 0
106 reserved1: JniFn,
107 reserved2: JniFn,
108 reserved3: JniFn,
109
110 GetVersion: JniFn, // 4
111 DefineClass: JniFn,
112 FindClass: JniFn, // 6
113 FromReflectedMethod: JniFn,
114 FromReflectedField: JniFn,
115 ToReflectedMethod: JniFn,
116
117 GetSuperclass: JniFn, // 10
118 IsAssignableFrom: JniFn,
119 ToReflectedField: JniFn,
120 Throw: JniFn,
121 ThrowNew: JniFn,
122 ExceptionOccurred: JniFn, // 15
123 ExceptionDescribe: JniFn, // 16
124 ExceptionClear: JniFn, // 17
125 FatalError: JniFn,
126 PushLocalFrame: JniFn, // 19
127
128 PopLocalFrame: JniFn, // 20
129 NewGlobalRef: JniFn, // 21
130 DeleteGlobalRef: JniFn,
131 DeleteLocalRef: JniFn, // 23
132 IsSameObject: JniFn,
133 NewLocalRef: JniFn,
134 EnsureLocalCapacity: JniFn,
135 AllocObject: JniFn,
136 NewObject: JniFn,
137 NewObjectV: JniFn,
138
139 NewObjectA: JniFn, // 30
140 GetObjectClass: JniFn, // 31
141 IsInstanceOf: JniFn,
142 GetMethodID: JniFn, // 33
143 CallObjectMethod: JniFn, // 34
144 CallObjectMethodV: JniFn,
145 CallObjectMethodA: JniFn,
146 CallBooleanMethod: JniFn, // 37
147 CallBooleanMethodV: JniFn,
148 CallBooleanMethodA: JniFn,
149
150 CallByteMethod: JniFn, // 40
151 CallByteMethodV: JniFn,
152 CallByteMethodA: JniFn,
153 CallCharMethod: JniFn,
154 CallCharMethodV: JniFn,
155 CallCharMethodA: JniFn,
156 CallShortMethod: JniFn,
157 CallShortMethodV: JniFn,
158 CallShortMethodA: JniFn,
159 CallIntMethod: JniFn, // 49
160
161 CallIntMethodV: JniFn, // 50
162 CallIntMethodA: JniFn,
163 CallLongMethod: JniFn, // 52
164 CallLongMethodV: JniFn,
165 CallLongMethodA: JniFn,
166 CallFloatMethod: JniFn,
167 CallFloatMethodV: JniFn,
168 CallFloatMethodA: JniFn,
169 CallDoubleMethod: JniFn,
170 CallDoubleMethodV: JniFn,
171
172 CallDoubleMethodA: JniFn, // 60
173 CallVoidMethod: JniFn, // 61
174 CallVoidMethodV: JniFn,
175 CallVoidMethodA: JniFn,
176 CallNonvirtualObjectMethod: JniFn, // 64
177 CallNonvirtualObjectMethodV: JniFn,
178 CallNonvirtualObjectMethodA: JniFn,
179 CallNonvirtualBooleanMethod: JniFn,
180 CallNonvirtualBooleanMethodV: JniFn,
181 CallNonvirtualBooleanMethodA: JniFn,
182
183 CallNonvirtualByteMethod: JniFn, // 70
184 CallNonvirtualByteMethodV: JniFn,
185 CallNonvirtualByteMethodA: JniFn,
186 CallNonvirtualCharMethod: JniFn,
187 CallNonvirtualCharMethodV: JniFn,
188 CallNonvirtualCharMethodA: JniFn,
189 CallNonvirtualShortMethod: JniFn,
190 CallNonvirtualShortMethodV: JniFn,
191 CallNonvirtualShortMethodA: JniFn,
192 CallNonvirtualIntMethod: JniFn,
193
194 CallNonvirtualIntMethodV: JniFn, // 80
195 CallNonvirtualIntMethodA: JniFn,
196 CallNonvirtualLongMethod: JniFn,
197 CallNonvirtualLongMethodV: JniFn,
198 CallNonvirtualLongMethodA: JniFn,
199 CallNonvirtualFloatMethod: JniFn,
200 CallNonvirtualFloatMethodV: JniFn,
201 CallNonvirtualFloatMethodA: JniFn,
202 CallNonvirtualDoubleMethod: JniFn,
203 CallNonvirtualDoubleMethodV: JniFn,
204
205 CallNonvirtualDoubleMethodA: JniFn, // 90
206 CallNonvirtualVoidMethod: JniFn,
207 CallNonvirtualVoidMethodV: JniFn,
208 CallNonvirtualVoidMethodA: JniFn,
209 GetFieldID: JniFn, // 94
210 GetObjectField: JniFn, // 95
211 GetBooleanField: JniFn,
212 GetByteField: JniFn,
213 GetCharField: JniFn,
214 GetShortField: JniFn,
215
216 GetIntField: JniFn, // 100
217 GetLongField: JniFn,
218 GetFloatField: JniFn,
219 GetDoubleField: JniFn,
220 SetObjectField: JniFn, // 104
221 SetBooleanField: JniFn,
222 SetByteField: JniFn,
223 SetCharField: JniFn,
224 SetShortField: JniFn,
225 SetIntField: JniFn,
226
227 SetLongField: JniFn, // 110
228 SetFloatField: JniFn,
229 SetDoubleField: JniFn,
230 GetStaticMethodID: JniFn, // 113
231 CallStaticObjectMethod: JniFn, // 114
232 CallStaticObjectMethodV: JniFn,
233 CallStaticObjectMethodA: JniFn,
234 CallStaticBooleanMethod: JniFn,
235 CallStaticBooleanMethodV: JniFn,
236 CallStaticBooleanMethodA: JniFn,
237
238 CallStaticByteMethod: JniFn, // 120
239 CallStaticByteMethodV: JniFn,
240 CallStaticByteMethodA: JniFn,
241 CallStaticCharMethod: JniFn,
242 CallStaticCharMethodV: JniFn,
243 CallStaticCharMethodA: JniFn,
244 CallStaticShortMethod: JniFn,
245 CallStaticShortMethodV: JniFn,
246 CallStaticShortMethodA: JniFn,
247 CallStaticIntMethod: JniFn,
248
249 CallStaticIntMethodV: JniFn, // 130
250 CallStaticIntMethodA: JniFn,
251 CallStaticLongMethod: JniFn,
252 CallStaticLongMethodV: JniFn,
253 CallStaticLongMethodA: JniFn,
254 CallStaticFloatMethod: JniFn,
255 CallStaticFloatMethodV: JniFn,
256 CallStaticFloatMethodA: JniFn,
257 CallStaticDoubleMethod: JniFn,
258 CallStaticDoubleMethodV: JniFn,
259
260 CallStaticDoubleMethodA: JniFn, // 140
261 CallStaticVoidMethod: JniFn, // 141
262 CallStaticVoidMethodV: JniFn,
263 CallStaticVoidMethodA: JniFn,
264 GetStaticFieldID: JniFn, // 144
265 GetStaticObjectField: JniFn, // 145
266 GetStaticBooleanField: JniFn,
267 GetStaticByteField: JniFn,
268 GetStaticCharField: JniFn,
269 GetStaticShortField: JniFn,
270
271 GetStaticIntField: JniFn, // 150
272 GetStaticLongField: JniFn,
273 GetStaticFloatField: JniFn,
274 GetStaticDoubleField: JniFn,
275 SetStaticObjectField: JniFn, // 154
276 SetStaticBooleanField: JniFn,
277 SetStaticByteField: JniFn,
278 SetStaticCharField: JniFn,
279 SetStaticShortField: JniFn,
280 SetStaticIntField: JniFn,
281
282 SetStaticLongField: JniFn, // 160
283 SetStaticFloatField: JniFn,
284 SetStaticDoubleField: JniFn,
285 NewString: JniFn, // 163
286 GetStringLength: JniFn,
287 GetStringChars: JniFn,
288 ReleaseStringChars: JniFn,
289 NewStringUTF: JniFn, // 167
290 GetStringUTFLength: JniFn, // 168
291 GetStringUTFChars: JniFn, // 169
292
293 ReleaseStringUTFChars: JniFn, // 170 — the table is cut here
294};
295
296/// The declared index of a member, for the offset audit below.
297///
298/// `@offsetOf` divided by the pointer size only means something because every
299/// member is one pointer wide — which is the reason they are all opaque.
300pub fn jniIndexOf(comptime name: []const u8) usize {
301 return @offsetOf(JNINativeInterface, name) / @sizeOf(JniFn);
302}
303
304// =============================================================================
305// Errors
306// =============================================================================
307
308pub const JniError = error{
309 /// A Java exception was pending. Already described and cleared — see
310 /// `Jni.check`.
311 JavaException,
312 /// `FindClass` / `GetMethodID` / `GetFieldID` returned null without an
313 /// exception, which the spec allows for a genuinely absent name.
314 NotFound,
315 /// A `jstring` that was null where a string was required.
316 NullReference,
317 /// `GetStringUTFChars` returned null — the JVM could not allocate.
318 StringUnavailable,
319 /// The JVM handed back bytes that are not well-formed modified UTF-8.
320 MalformedString,
321 OutOfMemory,
322};
323
324// =============================================================================
325// The wrapper
326// =============================================================================
327
328/// A `JNIEnv` plus the discipline every call to it needs.
329///
330/// The discipline is the point of this type. JNI does not signal failure by
331/// returning an error: a failing call sets a *pending exception* on the thread
332/// and returns whatever it returns. Calling almost any other JNI function
333/// while one is pending is undefined behaviour — so "check after every call
334/// that can throw" is not defensive style here, it is the calling convention.
335/// Objective-C has no equivalent: a message to nil is simply nil.
336///
337/// Every method below that can throw ends in `check`, which describes the
338/// exception to logcat and clears it before returning `JavaException`. Clearing
339/// is mandatory rather than polite: leaving it pending poisons the next call,
340/// so a wrapper that reported the error without clearing would turn one failure
341/// into an unpredictable second one.
342pub const Jni = struct {
343 env: JNIEnv,
344
345 const Self = @This();
346
347 pub fn init(env: JNIEnv) Self {
348 return .{ .env = env };
349 }
350
351 fn table(self: Self) *const JNINativeInterface {
352 return self.env.*;
353 }
354
355 /// Was an exception left pending? Describe it, clear it, and say so.
356 ///
357 /// `ExceptionOccurred` rather than `ExceptionCheck`: both answer the
358 /// question, and this one is at a verified index in the table above.
359 /// It returns a local reference, so the throwable is deleted here — a
360 /// leaked local ref per failed call is exactly how the 16-slot local frame
361 /// overflows in a loop.
362 pub fn check(self: Self) JniError!void {
363 const occurred: *const fn (JNIEnv) callconv(.c) jthrowable =
364 @ptrCast(self.table().ExceptionOccurred orelse return);
365 const pending = occurred(self.env);
366 if (pending == null) return;
367
368 // Describe before clear: `ExceptionDescribe` prints the stack trace to
369 // logcat, and it is the only place that information exists. `BridgeError`
370 // carries a code to the page, never a Java stack.
371 if (self.table().ExceptionDescribe) |raw| {
372 const describe: *const fn (JNIEnv) callconv(.c) void = @ptrCast(raw);
373 describe(self.env);
374 }
375 if (self.table().ExceptionClear) |raw| {
376 const clear: *const fn (JNIEnv) callconv(.c) void = @ptrCast(raw);
377 clear(self.env);
378 }
379 if (self.table().DeleteLocalRef) |raw| {
380 const del: *const fn (JNIEnv, jobject) callconv(.c) void = @ptrCast(raw);
381 del(self.env, pending);
382 }
383 return JniError.JavaException;
384 }
385
386 /// `FindClass("android/os/Build")` — slashes, not dots.
387 ///
388 /// The returned reference is **local**: valid until the native method
389 /// returns, and not shareable between threads. A class looked up once and
390 /// kept must go through `newGlobalRef`.
391 pub fn findClass(self: Self, name: [*:0]const u8) JniError!jclass {
392 const find: *const fn (JNIEnv, [*:0]const u8) callconv(.c) jclass =
393 @ptrCast(self.table().FindClass orelse return JniError.NotFound);
394 const cls = find(self.env, name);
395 try self.check();
396 return cls orelse JniError.NotFound;
397 }
398
399 pub fn staticFieldId(self: Self, cls: jclass, name: [*:0]const u8, sig: [*:0]const u8) JniError!jfieldID {
400 const get: *const fn (JNIEnv, jclass, [*:0]const u8, [*:0]const u8) callconv(.c) jfieldID =
401 @ptrCast(self.table().GetStaticFieldID orelse return JniError.NotFound);
402 const id = get(self.env, cls, name, sig);
403 try self.check();
404 return id orelse JniError.NotFound;
405 }
406
407 pub fn staticObjectField(self: Self, cls: jclass, id: jfieldID) JniError!jobject {
408 const get: *const fn (JNIEnv, jclass, jfieldID) callconv(.c) jobject =
409 @ptrCast(self.table().GetStaticObjectField orelse return JniError.NotFound);
410 const value = get(self.env, cls, id);
411 try self.check();
412 return value;
413 }
414
415 pub fn staticIntField(self: Self, cls: jclass, id: jfieldID) JniError!jint {
416 const get: *const fn (JNIEnv, jclass, jfieldID) callconv(.c) jint =
417 @ptrCast(self.table().GetStaticIntField orelse return JniError.NotFound);
418 const value = get(self.env, cls, id);
419 try self.check();
420 return value;
421 }
422
423 pub fn methodId(self: Self, cls: jclass, name: [*:0]const u8, sig: [*:0]const u8) JniError!jmethodID {
424 const get: *const fn (JNIEnv, jclass, [*:0]const u8, [*:0]const u8) callconv(.c) jmethodID =
425 @ptrCast(self.table().GetMethodID orelse return JniError.NotFound);
426 const id = get(self.env, cls, name, sig);
427 try self.check();
428 return id orelse JniError.NotFound;
429 }
430
431 /// A no-argument instance call returning an object.
432 ///
433 /// Only the nullary form is offered, and that is a deliberate limit rather
434 /// than an unfinished one. `CallObjectMethod` is variadic, and a variadic
435 /// call through a `callconv(.c)` pointer has to get the platform's
436 /// argument-passing rules exactly right — arm64 and x86-64 Android disagree
437 /// about where the eighth integer argument lives. `CallObjectMethodA`,
438 /// which takes a `jvalue` array, is the form to add when arguments are
439 /// needed: same table, no variadic ABI to reproduce.
440 pub fn callObjectMethod(self: Self, obj: jobject, id: jmethodID) JniError!jobject {
441 const call: *const fn (JNIEnv, jobject, jmethodID) callconv(.c) jobject =
442 @ptrCast(self.table().CallObjectMethod orelse return JniError.NotFound);
443 const result = call(self.env, obj, id);
444 try self.check();
445 return result;
446 }
447
448 pub fn callIntMethod(self: Self, obj: jobject, id: jmethodID) JniError!jint {
449 const call: *const fn (JNIEnv, jobject, jmethodID) callconv(.c) jint =
450 @ptrCast(self.table().CallIntMethod orelse return JniError.NotFound);
451 const result = call(self.env, obj, id);
452 try self.check();
453 return result;
454 }
455
456 pub fn objectClass(self: Self, obj: jobject) JniError!jclass {
457 const get: *const fn (JNIEnv, jobject) callconv(.c) jclass =
458 @ptrCast(self.table().GetObjectClass orelse return JniError.NotFound);
459 return get(self.env, obj) orelse JniError.NotFound;
460 }
461
462 pub fn deleteLocalRef(self: Self, obj: jobject) void {
463 if (obj == null) return;
464 const del: *const fn (JNIEnv, jobject) callconv(.c) void =
465 @ptrCast(self.table().DeleteLocalRef orelse return);
466 del(self.env, obj);
467 }
468
469 /// Promote a local reference to one that survives the native method and
470 /// can cross threads. Must be paired with `deleteGlobalRef`.
471 pub fn newGlobalRef(self: Self, obj: jobject) JniError!jobject {
472 const new: *const fn (JNIEnv, jobject) callconv(.c) jobject =
473 @ptrCast(self.table().NewGlobalRef orelse return JniError.NotFound);
474 return new(self.env, obj) orelse JniError.NotFound;
475 }
476
477 pub fn deleteGlobalRef(self: Self, obj: jobject) void {
478 if (obj == null) return;
479 const del: *const fn (JNIEnv, jobject) callconv(.c) void =
480 @ptrCast(self.table().DeleteGlobalRef orelse return);
481 del(self.env, obj);
482 }
483
484 /// Read a `jstring` as standard UTF-8. Caller owns the result.
485 ///
486 /// What the JVM hands back is **modified** UTF-8, which is not UTF-8 — see
487 /// `decodeModifiedUtf8`. Converting here rather than at each call site is
488 /// the point: a bridge that puts these bytes straight into JSON would emit
489 /// a document no standard parser accepts.
490 pub fn stringToUtf8(self: Self, allocator: std.mem.Allocator, str: jstring) JniError![]u8 {
491 if (str == null) return JniError.NullReference;
492
493 const get: *const fn (JNIEnv, jstring, ?*jboolean) callconv(.c) ?[*:0]const u8 =
494 @ptrCast(self.table().GetStringUTFChars orelse return JniError.StringUnavailable);
495 const raw = get(self.env, str, null) orelse {
496 try self.check();
497 return JniError.StringUnavailable;
498 };
499 defer if (self.table().ReleaseStringUTFChars) |rel| {
500 const release: *const fn (JNIEnv, jstring, [*:0]const u8) callconv(.c) void = @ptrCast(rel);
501 release(self.env, str, raw);
502 };
503
504 // `std.mem.span` is safe here for a reason worth stating: modified
505 // UTF-8 encodes U+0000 as the two bytes C0 80, so a NUL byte never
506 // appears inside the payload and the terminator is unambiguous.
507 return decodeModifiedUtf8(allocator, std.mem.span(raw));
508 }
509};
510
511// =============================================================================
512// Modified UTF-8
513// =============================================================================
514
515/// Convert Java's modified UTF-8 to the standard encoding.
516///
517/// Two differences, both of which corrupt a JSON payload if ignored:
518///
519/// - **U+0000 is encoded as `C0 80`**, a two-byte form standard UTF-8 forbids
520/// as overlong. This is what makes a Java string safely NUL-terminated in C,
521/// and it is why `std.mem.span` can be trusted on the buffer above.
522/// - **Characters outside the BMP are two three-byte sequences**, one per
523/// UTF-16 surrogate, rather than one four-byte sequence. So an emoji in a
524/// device name arrives as a CESU-8 surrogate pair, which every standard
525/// decoder rejects.
526///
527/// Everything else is byte-identical, so the common path copies straight
528/// through and only the two special forms are rewritten.
529/// The code unit a three-byte UTF-8 sequence encodes.
530///
531/// Split out because the surrogate path needs the *value*, not the character:
532/// `std.unicode.utf8Decode` rejects a lone surrogate, which is exactly what
533/// each half of a CESU-8 pair is.
534fn decodeThreeByte(bytes: *const [3]u8) u32 {
535 return (@as(u32, bytes[0] & 0x0F) << 12) |
536 (@as(u32, bytes[1] & 0x3F) << 6) |
537 @as(u32, bytes[2] & 0x3F);
538}
539
540pub fn decodeModifiedUtf8(allocator: std.mem.Allocator, input: []const u8) JniError![]u8 {
541 var out: std.ArrayListUnmanaged(u8) = .empty;
542 errdefer out.deinit(allocator);
543
544 var i: usize = 0;
545 while (i < input.len) {
546 const b0 = input[i];
547
548 // C0 80 is the NUL escape, and the only overlong form that is legal.
549 if (b0 == 0xC0 and i + 1 < input.len and input[i + 1] == 0x80) {
550 try out.append(allocator, 0);
551 i += 2;
552 continue;
553 }
554
555 // A high surrogate begins ED A0..AF. Paired with a following low
556 // surrogate it is one astral character in two three-byte pieces.
557 if (b0 == 0xED and i + 5 < input.len and
558 input[i + 1] >= 0xA0 and input[i + 1] <= 0xAF and
559 input[i + 3] == 0xED and input[i + 4] >= 0xB0 and input[i + 4] <= 0xBF)
560 {
561 // Each surrogate is a whole three-byte sequence, so decode both to
562 // their real UTF-16 code units before combining. Folding only the
563 // trailing bits together instead drops the 0xD000 the leading ED
564 // contributes, and lands several planes away from the character.
565 const high = decodeThreeByte(input[i..][0..3]);
566 const low = decodeThreeByte(input[i + 3 ..][0..3]);
567 if (high < 0xD800 or high > 0xDBFF or low < 0xDC00 or low > 0xDFFF) {
568 return JniError.MalformedString;
569 }
570 const code_point = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00);
571
572 var buf: [4]u8 = undefined;
573 const len = std.unicode.utf8Encode(@intCast(code_point), &buf) catch
574 return JniError.MalformedString;
575 try out.appendSlice(allocator, buf[0..len]);
576 i += 6;
577 continue;
578 }
579
580 // Everything else is already standard UTF-8; copy the whole sequence
581 // so a multi-byte character is never split by the checks above.
582 const len = std.unicode.utf8ByteSequenceLength(b0) catch
583 return JniError.MalformedString;
584 if (i + len > input.len) return JniError.MalformedString;
585 try out.appendSlice(allocator, input[i .. i + len]);
586 i += len;
587 }
588
589 return out.toOwnedSlice(allocator);
590}
591
592// =============================================================================
593// Tests
594//
595// A `JNIEnv` is a pointer to a table of pointers, so a test can build one out
596// of Zig functions and drive every wrapper against it. No JVM, no emulator, no
597// NDK. This is the property that makes the layer checkable at all — the
598// Objective-C equivalent needs a real runtime and so has no counterpart to
599// these.
600// =============================================================================
601
602const testing = std.testing;
603
604test "the transcribed table puts every member at the index the header gives it" {
605 // The one bug this file can have that nothing else would catch. A member
606 // dropped or duplicated shifts every entry after it, and the call still
607 // compiles — it just invokes a different function. These indices are from
608 // OpenJDK's jni.h, and they are the reason the struct is auditable.
609 try testing.expectEqual(@as(usize, 4), jniIndexOf("GetVersion"));
610 try testing.expectEqual(@as(usize, 6), jniIndexOf("FindClass"));
611 try testing.expectEqual(@as(usize, 15), jniIndexOf("ExceptionOccurred"));
612 try testing.expectEqual(@as(usize, 17), jniIndexOf("ExceptionClear"));
613 try testing.expectEqual(@as(usize, 21), jniIndexOf("NewGlobalRef"));
614 try testing.expectEqual(@as(usize, 23), jniIndexOf("DeleteLocalRef"));
615 try testing.expectEqual(@as(usize, 31), jniIndexOf("GetObjectClass"));
616 try testing.expectEqual(@as(usize, 33), jniIndexOf("GetMethodID"));
617 try testing.expectEqual(@as(usize, 34), jniIndexOf("CallObjectMethod"));
618 try testing.expectEqual(@as(usize, 49), jniIndexOf("CallIntMethod"));
619 try testing.expectEqual(@as(usize, 94), jniIndexOf("GetFieldID"));
620 try testing.expectEqual(@as(usize, 113), jniIndexOf("GetStaticMethodID"));
621 try testing.expectEqual(@as(usize, 144), jniIndexOf("GetStaticFieldID"));
622 try testing.expectEqual(@as(usize, 145), jniIndexOf("GetStaticObjectField"));
623 try testing.expectEqual(@as(usize, 150), jniIndexOf("GetStaticIntField"));
624 try testing.expectEqual(@as(usize, 167), jniIndexOf("NewStringUTF"));
625 try testing.expectEqual(@as(usize, 169), jniIndexOf("GetStringUTFChars"));
626 try testing.expectEqual(@as(usize, 170), jniIndexOf("ReleaseStringUTFChars"));
627
628 // And the table is exactly as long as it claims — 171 entries, cut after
629 // ReleaseStringUTFChars. A member appended without a reason would make the
630 // truncation comment a lie.
631 try testing.expectEqual(
632 @as(usize, 171 * @sizeOf(JniFn)),
633 @sizeOf(JNINativeInterface),
634 );
635}
636
637// --- A fake JVM ------------------------------------------------------------
638
639var fake_pending_exception: jobject = null;
640var fake_describe_calls: usize = 0;
641var fake_clear_calls: usize = 0;
642var fake_deleted_locals: usize = 0;
643
644fn fakeExceptionOccurred(_: JNIEnv) callconv(.c) jthrowable {
645 return fake_pending_exception;
646}
647fn fakeExceptionDescribe(_: JNIEnv) callconv(.c) void {
648 fake_describe_calls += 1;
649}
650fn fakeExceptionClear(_: JNIEnv) callconv(.c) void {
651 fake_clear_calls += 1;
652 fake_pending_exception = null;
653}
654fn fakeDeleteLocalRef(_: JNIEnv, _: jobject) callconv(.c) void {
655 fake_deleted_locals += 1;
656}
657
658var fake_class: u8 = 0;
659fn fakeFindClass(_: JNIEnv, _: [*:0]const u8) callconv(.c) jclass {
660 return @ptrCast(&fake_class);
661}
662fn fakeFindClassMissing(_: JNIEnv, _: [*:0]const u8) callconv(.c) jclass {
663 return null;
664}
665
666var fake_field: u8 = 0;
667fn fakeStaticFieldId(_: JNIEnv, _: jclass, _: [*:0]const u8, _: [*:0]const u8) callconv(.c) jfieldID {
668 return @ptrCast(&fake_field);
669}
670fn fakeStaticIntField(_: JNIEnv, _: jclass, _: jfieldID) callconv(.c) jint {
671 return 34;
672}
673
674var fake_string_bytes: [*:0]const u8 = "Pixel";
675fn fakeGetStringUTFChars(_: JNIEnv, _: jstring, _: ?*jboolean) callconv(.c) ?[*:0]const u8 {
676 return fake_string_bytes;
677}
678var fake_released: usize = 0;
679fn fakeReleaseStringUTFChars(_: JNIEnv, _: jstring, _: [*:0]const u8) callconv(.c) void {
680 fake_released += 1;
681}
682
683fn emptyTable() JNINativeInterface {
684 return std.mem.zeroes(JNINativeInterface);
685}
686
687fn resetFakes() void {
688 fake_pending_exception = null;
689 fake_describe_calls = 0;
690 fake_clear_calls = 0;
691 fake_deleted_locals = 0;
692 fake_released = 0;
693}
694
695test "a pending exception is described, cleared, and reported" {
696 resetFakes();
697 var table = emptyTable();
698 table.ExceptionOccurred = @ptrCast(&fakeExceptionOccurred);
699 table.ExceptionDescribe = @ptrCast(&fakeExceptionDescribe);
700 table.ExceptionClear = @ptrCast(&fakeExceptionClear);
701 table.DeleteLocalRef = @ptrCast(&fakeDeleteLocalRef);
702
703 const ptr: *const JNINativeInterface = &table;
704 const jni = Jni.init(&ptr);
705
706 // Nothing pending: check is a no-op and clears nothing.
707 try jni.check();
708 try testing.expectEqual(@as(usize, 0), fake_clear_calls);
709
710 // Pending: described before cleared, and the throwable's local ref freed.
711 var throwable: u8 = 0;
712 fake_pending_exception = @ptrCast(&throwable);
713 try testing.expectError(JniError.JavaException, jni.check());
714 try testing.expectEqual(@as(usize, 1), fake_describe_calls);
715 try testing.expectEqual(@as(usize, 1), fake_clear_calls);
716
717 // The leak that overflows a 16-slot local frame in a loop.
718 try testing.expectEqual(@as(usize, 1), fake_deleted_locals);
719
720 // And it really was cleared, so the next call is not poisoned.
721 try jni.check();
722}
723
724test "a throwing call reports the exception rather than its return value" {
725 resetFakes();
726 var table = emptyTable();
727 table.FindClass = @ptrCast(&fakeFindClass);
728 table.ExceptionOccurred = @ptrCast(&fakeExceptionOccurred);
729 table.ExceptionDescribe = @ptrCast(&fakeExceptionDescribe);
730 table.ExceptionClear = @ptrCast(&fakeExceptionClear);
731 table.DeleteLocalRef = @ptrCast(&fakeDeleteLocalRef);
732
733 const ptr: *const JNINativeInterface = &table;
734 const jni = Jni.init(&ptr);
735
736 // The JVM's contract: a failed FindClass may still hand back a non-null
737 // value with an exception pending. The exception wins.
738 var throwable: u8 = 0;
739 fake_pending_exception = @ptrCast(&throwable);
740 try testing.expectError(JniError.JavaException, jni.findClass("android/os/Build"));
741}
742
743test "an absent class with no exception is NotFound, not a crash" {
744 resetFakes();
745 var table = emptyTable();
746 table.FindClass = @ptrCast(&fakeFindClassMissing);
747 table.ExceptionOccurred = @ptrCast(&fakeExceptionOccurred);
748
749 const ptr: *const JNINativeInterface = &table;
750 const jni = Jni.init(&ptr);
751 try testing.expectError(JniError.NotFound, jni.findClass("android/os/Nope"));
752}
753
754test "static field reads go through the slots they name" {
755 resetFakes();
756 var table = emptyTable();
757 table.FindClass = @ptrCast(&fakeFindClass);
758 table.GetStaticFieldID = @ptrCast(&fakeStaticFieldId);
759 table.GetStaticIntField = @ptrCast(&fakeStaticIntField);
760 table.ExceptionOccurred = @ptrCast(&fakeExceptionOccurred);
761
762 const ptr: *const JNINativeInterface = &table;
763 const jni = Jni.init(&ptr);
764
765 const cls = try jni.findClass("android/os/Build$VERSION");
766 const id = try jni.staticFieldId(cls, "SDK_INT", "I");
767 try testing.expectEqual(@as(jint, 34), try jni.staticIntField(cls, id));
768}
769
770test "a jstring is read as UTF-8 and the JVM's buffer is always released" {
771 resetFakes();
772 var table = emptyTable();
773 table.GetStringUTFChars = @ptrCast(&fakeGetStringUTFChars);
774 table.ReleaseStringUTFChars = @ptrCast(&fakeReleaseStringUTFChars);
775 table.ExceptionOccurred = @ptrCast(&fakeExceptionOccurred);
776
777 const ptr: *const JNINativeInterface = &table;
778 const jni = Jni.init(&ptr);
779
780 var str: u8 = 0;
781 const text = try jni.stringToUtf8(testing.allocator, @ptrCast(&str));
782 defer testing.allocator.free(text);
783 try testing.expectEqualStrings("Pixel", text);
784
785 // Releasing is not optional: GetStringUTFChars may have handed back a copy,
786 // and the JVM frees it on release or never.
787 try testing.expectEqual(@as(usize, 1), fake_released);
788}
789
790test "a null jstring is an error, not an empty string" {
791 resetFakes();
792 var table = emptyTable();
793 const ptr: *const JNINativeInterface = &table;
794 const jni = Jni.init(&ptr);
795 try testing.expectError(JniError.NullReference, jni.stringToUtf8(testing.allocator, null));
796}
797
798test "modified UTF-8 decodes to the standard encoding" {
799 const alloc = testing.allocator;
800
801 // Plain ASCII and ordinary multi-byte text pass straight through.
802 for ([_][]const u8{ "Pixel 8 Pro", "Xiaomi", "é", "日本語", "" }) |plain| {
803 const out = try decodeModifiedUtf8(alloc, plain);
804 defer alloc.free(out);
805 try testing.expectEqualStrings(plain, out);
806 }
807
808 // U+0000 arrives as C0 80 and must become one NUL byte. A decoder that
809 // passed this through would put an overlong sequence into the JSON reply.
810 {
811 const out = try decodeModifiedUtf8(alloc, "a\xC0\x80b");
812 defer alloc.free(out);
813 try testing.expectEqualSlices(u8, &[_]u8{ 'a', 0, 'b' }, out);
814 }
815
816 // U+1F600 as a CESU-8 surrogate pair — ED A0 BD ED B8 80 — is one
817 // four-byte character in standard UTF-8. This is the form an emoji in a
818 // device name actually arrives in.
819 {
820 const out = try decodeModifiedUtf8(alloc, "\xED\xA0\xBD\xED\xB8\x80");
821 defer alloc.free(out);
822 try testing.expectEqualStrings("\u{1F600}", out);
823 try testing.expectEqual(@as(usize, 4), out.len);
824 }
825
826 // Surrounded by text, so the pair is found at a non-zero offset and the
827 // bytes on either side survive.
828 {
829 const out = try decodeModifiedUtf8(alloc, "hi \xED\xA0\xBD\xED\xB8\x80!");
830 defer alloc.free(out);
831 try testing.expectEqualStrings("hi \u{1F600}!", out);
832 }
833
834 // A three-byte sequence starting ED that is *not* a surrogate pair is
835 // ordinary text and must not be mangled: U+D7FF's neighbour U+FFFD.
836 {
837 const out = try decodeModifiedUtf8(alloc, "\u{FFFD}");
838 defer alloc.free(out);
839 try testing.expectEqualStrings("\u{FFFD}", out);
840 }
841
842 // Truncated input is refused rather than read past the end.
843 try testing.expectError(JniError.MalformedString, decodeModifiedUtf8(alloc, "\xE6\x97"));
844 try testing.expectError(JniError.MalformedString, decodeModifiedUtf8(alloc, "\xFF"));
845}