ReviewOS

also looking at this

craft-native/craft

test(mobile): run the generated apps on a simulator and an emulator

#199
Merged glennmichael123 wants to merge fix/mobile-e2e-real-assertions into main
26 files +3009 -293

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/android/src/index.test.tsmodified+112-1
Changes to packages/android/src/index.test.ts
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFil
22import { tmpdir } from 'node:os'
33import { join } from 'node:path'
44import { describe, expect, it } from 'bun:test'
5import { build, init, renderAndroidDeepLinks, renderAndroidPermissions, syncAndroidWebAssets } from './index'
5import { build, init, installRuntime, renderAndroidDeepLinks, renderAndroidPermissions, resolveRuntimeDir, syncAndroidWebAssets } from './index'
66
77function generatedFiles(path: string): string[] {
88 return readdirSync(path, { withFileTypes: true }).flatMap((entry) => {
@@ -1188,3 +1188,114 @@ describe('Craft Android builder', () => {
11881188 }
11891189 })
11901190})
1191
1192/**
1193 * A runtime directory shaped like `zig build build-android-all`'s output.
1194 *
1195 * The contents are not a real ELF — nothing here loads it — but the layout is
1196 * exactly `zig-out/android/<abi>/libcraft.so`, because the layout is the part
1197 * the installer depends on.
1198 */
1199function fakeRuntime(abis: string[]): string {
1200 const dir = mkdtempSync(join(tmpdir(), 'craft-android-runtime-'))
1201 for (const abi of abis) {
1202 mkdirSync(join(dir, abi), { recursive: true })
1203 writeFileSync(join(dir, abi, 'libcraft.so'), `not-really-${abi}`)
1204 }
1205 return dir
1206}
1207
1208describe('Zig runtime installation', () => {
1209 it('lands the library where AGP already looks for it', () => {
1210 // app/src/main/jniLibs is the default jniLibs.srcDirs, which is why no
1211 // Gradle template has to know this happened.
1212 const output = mkdtempSync(join(tmpdir(), 'craft-android-jnilibs-'))
1213 installRuntime(output, fakeRuntime(['arm64-v8a', 'x86_64']))
1214
1215 expect(readFileSync(join(output, 'app/src/main/jniLibs/arm64-v8a/libcraft.so'), 'utf8')).toBe('not-really-arm64-v8a')
1216 expect(readFileSync(join(output, 'app/src/main/jniLibs/x86_64/libcraft.so'), 'utf8')).toBe('not-really-x86_64')
1217 })
1218
1219 it('installs what it has when an architecture is missing', () => {
1220 // A one-ABI dev loop is legitimate; it just cannot run on the other kind
1221 // of device, which the installer warns about rather than refusing.
1222 const output = mkdtempSync(join(tmpdir(), 'craft-android-one-abi-'))
1223 expect(installRuntime(output, fakeRuntime(['x86_64']))).toBe(true)
1224
1225 expect(existsSync(join(output, 'app/src/main/jniLibs/x86_64/libcraft.so'))).toBe(true)
1226 expect(existsSync(join(output, 'app/src/main/jniLibs/arm64-v8a'))).toBe(false)
1227 })
1228
1229 it('refuses a directory with no library at all, and names the build step', () => {
1230 const output = mkdtempSync(join(tmpdir(), 'craft-android-no-abi-'))
1231 const empty = mkdtempSync(join(tmpdir(), 'craft-android-empty-'))
1232
1233 expect(() => installRuntime(output, empty)).toThrow(/zig build build-android-all/)
1234 // Nothing written on the way to throwing.
1235 expect(existsSync(join(output, 'app/src/main/jniLibs'))).toBe(false)
1236 })
1237
1238 it('does not leave an architecture behind that a later build dropped', () => {
1239 // A stale .so still ships in the APK and still loads, so the app would run
1240 // an architecture nobody built for this version.
1241 const output = mkdtempSync(join(tmpdir(), 'craft-android-stale-'))
1242 installRuntime(output, fakeRuntime(['arm64-v8a', 'x86_64']))
1243 installRuntime(output, fakeRuntime(['x86_64']))
1244
1245 expect(existsSync(join(output, 'app/src/main/jniLibs/arm64-v8a'))).toBe(false)
1246 expect(existsSync(join(output, 'app/src/main/jniLibs/x86_64/libcraft.so'))).toBe(true)
1247 })
1248
1249 it('generates a shim-only app when no runtime is configured', async () => {
1250 // The default, and the state every generated app was in before this
1251 // existed: System.loadLibrary throws, isAvailable is false, Kotlin serves.
1252 const output = mkdtempSync(join(tmpdir(), 'craft-android-shim-only-'))
1253 await init({ name: 'ShimOnly', packageName: 'dev.craft.shimonly', output, runtimeDir: null })
1254
1255 expect(existsSync(join(output, 'app/src/main/jniLibs'))).toBe(false)
1256 })
1257
1258 it('installs the runtime from init when one is given', async () => {
1259 const output = mkdtempSync(join(tmpdir(), 'craft-android-with-runtime-'))
1260 await init({
1261 name: 'WithRuntime',
1262 packageName: 'dev.craft.withruntime',
1263 output,
1264 runtimeDir: fakeRuntime(['arm64-v8a', 'x86_64']),
1265 })
1266
1267 expect(existsSync(join(output, 'app/src/main/jniLibs/arm64-v8a/libcraft.so'))).toBe(true)
1268 expect(existsSync(join(output, 'app/src/main/jniLibs/x86_64/libcraft.so'))).toBe(true)
1269 })
1270
1271 it('reads CRAFT_ANDROID_RUNTIME only when the caller says nothing', () => {
1272 const dir = fakeRuntime(['x86_64'])
1273 const previous = process.env.CRAFT_ANDROID_RUNTIME
1274 process.env.CRAFT_ANDROID_RUNTIME = dir
1275
1276 try {
1277 expect(resolveRuntimeDir()).toBe(dir)
1278 // null is a declaration, not a gap: no runtime whatever the environment
1279 // says, which is what the E2E suite's shim leg depends on.
1280 expect(resolveRuntimeDir(null)).toBe(null)
1281 }
1282 finally {
1283 if (previous === undefined) delete process.env.CRAFT_ANDROID_RUNTIME
1284 else process.env.CRAFT_ANDROID_RUNTIME = previous
1285 }
1286 })
1287
1288 it('names which knob pointed at a directory that is not there', () => {
1289 const previous = process.env.CRAFT_ANDROID_RUNTIME
1290 process.env.CRAFT_ANDROID_RUNTIME = '/craft/definitely/not/here'
1291
1292 try {
1293 expect(() => resolveRuntimeDir()).toThrow(/CRAFT_ANDROID_RUNTIME points at/)
1294 expect(() => resolveRuntimeDir('/craft/also/not/here')).toThrow(/runtimeDir is/)
1295 }
1296 finally {
1297 if (previous === undefined) delete process.env.CRAFT_ANDROID_RUNTIME
1298 else process.env.CRAFT_ANDROID_RUNTIME = previous
1299 }
1300 })
1301})
packages/android/src/index.tsmodified+123-0
Changes to packages/android/src/index.ts
@@ -77,6 +77,14 @@ export interface InitOptions {
7777 packageName?: string
7878 output: string
7979 config?: Partial<CraftAndroidConfig>
80 /**
81 * Where to find the Zig runtime's `<abi>/libcraft.so`.
82 *
83 * `null` means no runtime regardless of the environment; `undefined` falls
84 * through to `CRAFT_ANDROID_RUNTIME`. Same shape as the iOS builder's
85 * `runtimeDir`.
86 */
87 runtimeDir?: string | null
8088}
8189
8290export interface BuildOptions {
@@ -85,6 +93,8 @@ export interface BuildOptions {
8593 output: string
8694 release?: boolean
8795 compile?: boolean
96 /** Same meaning as `InitOptions.runtimeDir`; refreshes what init installed. */
97 runtimeDir?: string | null
8898}
8999
90100export interface OpenOptions {
@@ -341,6 +351,96 @@ export function renderAndroidDeepLinks(config: CraftAndroidConfig): string {
341351/**
342352 * Initialize a new Android project
343353 */
354/**
355 * The ABI directories a runtime directory is expected to hold.
356 *
357 * These are Android's own names for the two architectures craft builds, and
358 * they are also `zig build build-android-all`'s output layout — `zig-out/android/`
359 * already contains `arm64-v8a/libcraft.so` and `x86_64/libcraft.so`, which is
360 * exactly the shape `app/src/main/jniLibs/` wants. So the install below is a
361 * copy and not a rearrangement.
362 *
363 * x86_64 is not optional-in-practice: every Android emulator anyone tests on
364 * is x86_64, so a runtime shipped for arm64 alone is one that never runs in
365 * CI and never runs on a developer's machine either.
366 */
367const RUNTIME_ABIS = ['arm64-v8a', 'x86_64'] as const
368
369/**
370 * Where the runtime directory comes from when the caller does not say.
371 *
372 * The same shape as `CRAFT_BIN` and the iOS builder's `CRAFT_IOS_RUNTIME`: an
373 * explicit override for the monorepo dev loop, not a lookup path. Shipping the
374 * runtime to real apps means putting these libraries in the pantry package
375 * beside the `craft` binary, which is a distribution decision this function
376 * does not make.
377 */
378export function resolveRuntimeDir(override?: string | null): string | null {
379 if (override === null) return null
380 const dir = override ?? process.env.CRAFT_ANDROID_RUNTIME
381 if (!dir) return null
382 if (!existsSync(dir)) {
383 const source = override === undefined ? 'CRAFT_ANDROID_RUNTIME points at' : 'runtimeDir is'
384 throw new Error(`${source} ${dir}, which does not exist.`)
385 }
386 return dir
387}
388
389/**
390 * Copy the Zig runtime into the generated project as
391 * `app/src/main/jniLibs/<abi>/libcraft.so`.
392 *
393 * That path is AGP's default `jniLibs.srcDirs`, so nothing in the Gradle
394 * templates has to know about it — the library is packaged into the APK and
395 * `System.loadLibrary("craft")` finds it because the file is named for the
396 * `craft` it asks for.
397 *
398 * Until this existed, nothing put the library anywhere. `CraftNative` caught
399 * the `UnsatisfiedLinkError`, set `isAvailable = false`, and every action fell
400 * through to the Kotlin shim — by design, so an app with no runtime still
401 * works, which is also why nobody noticed that *every* generated app was in
402 * that state and the whole Android half of the Zig bridge had never run.
403 *
404 * Returns true when a runtime was installed.
405 */
406export function installRuntime(output: string, runtimeDir: string): boolean {
407 // Every ABI resolved before anything is written, so a half-populated
408 // runtime directory cannot leave a project carrying one architecture and
409 // claiming both. A single-ABI APK installs fine and then fails to load on
410 // the other kind of device, which is the break that only shows up on
411 // somebody else's phone.
412 const found = RUNTIME_ABIS.map(abi => ({ abi, source: join(runtimeDir, abi, 'libcraft.so') }))
413 .filter(entry => existsSync(entry.source))
414
415 if (found.length === 0) {
416 throw new Error(
417 `${runtimeDir} has no <abi>/libcraft.so for any of ${RUNTIME_ABIS.join(', ')}. `
418 + 'Run `zig build build-android-all -Doptimize=ReleaseSafe` in packages/zig and point at its zig-out/android.',
419 )
420 }
421
422 if (found.length < RUNTIME_ABIS.length) {
423 const missing = RUNTIME_ABIS.filter(abi => !found.some(entry => entry.abi === abi))
424 console.warn(
425 ` ⚠ only ${found.map(entry => entry.abi).join(', ')} was found; ${missing.join(', ')} is missing. `
426 + 'The app will fall back to the Kotlin shim on those devices.',
427 )
428 }
429
430 const dest = join(output, 'app/src/main/jniLibs')
431 // Wiped rather than merged, so an ABI dropped from a later build does not
432 // linger in the project as a stale library the APK still ships.
433 rmSync(dest, { force: true, recursive: true })
434
435 for (const { abi, source } of found) {
436 const abiDir = join(dest, abi)
437 mkdirSync(abiDir, { recursive: true })
438 cpSync(source, join(abiDir, 'libcraft.so'))
439 }
440
441 return true
442}
443
344444export async function init(options: InitOptions): Promise<void> {
345445 const { name, packageName, output } = options
346446
@@ -670,6 +770,15 @@ zipStorePath=wrapper/dists
670770
671771 writeFileSync(join(output, 'app/src/main/assets/index.html'), placeholderHtml)
672772
773 // The Zig runtime, when one was configured. Absent, the generated app is the
774 // Kotlin shim and behaves exactly as it always has — which is the fallback
775 // `CraftNative` was written for, not a failure.
776 const runtimeDir = resolveRuntimeDir(options.runtimeDir)
777 if (runtimeDir) {
778 installRuntime(output, runtimeDir)
779 console.log(' Installed the Zig runtime from', runtimeDir)
780 }
781
673782 console.log('✅ Project initialized')
674783 console.log('')
675784 console.log('Next steps:')
@@ -696,6 +805,20 @@ export async function build(options: BuildOptions): Promise<void> {
696805
697806 const config: CraftAndroidConfig = JSON.parse(readFileSync(configPath, 'utf-8'))
698807
808 // Refresh the runtime the same way the iOS builder does: only for a project
809 // that already has one, so `craft android build` in a shim-only project does
810 // not quietly acquire a native library it was never generated with.
811 if (existsSync(join(output, 'app/src/main/jniLibs'))) {
812 const runtimeDir = resolveRuntimeDir(options.runtimeDir)
813 if (runtimeDir) {
814 installRuntime(output, runtimeDir)
815 console.log(' Refreshed the Zig runtime from', runtimeDir)
816 }
817 else {
818 console.log(' Keeping the Zig runtime installed at init (no runtime directory configured)')
819 }
820 }
821
699822 // Update dev server URL if provided
700823 if (devServer) {
701824 const url = androidWebUrl(devServer, 'Android dev server URL')
packages/zig/src/android_dispatch.zigmodified+27-1
Changes to packages/zig/src/android_dispatch.zig
@@ -36,6 +36,7 @@
3636const std = ;
3737const builtin = ;
3838const jni = ;
39const android_log = ;
3940const permissions = ;
4041const device = ;
4142const system = ;
@@ -97,6 +98,17 @@ const backing = std.heap.page_allocator;
9798const request_calendar: i32 = 1005;
9899
99100/// The shim's default event length: `System.currentTimeMillis() + 3600000`.
101/// The log handler, installed here because this file is the root module of the
102/// JNI library and `std_options` only takes effect from a root.
103///
104/// Without it every `std.log` line below goes to stderr, which ART discards —
105/// including `JNI_OnLoad`'s report of a refused registration, which is the one
106/// message worth having. `android_log.zig` says more about why it routes
107/// through `android.util.Log` rather than liblog.
108pub const std_options: std.Options = .{
109 .logFn = android_log.logFn,
110};
111
100112const one_hour_ms: i64 = 60 * 60 * 1000;
101113
102114/// `CraftBridge.REQUEST_CONTACTS`.
@@ -137,13 +149,27 @@ pub const holder_class = "com/craft/runtime/CraftNative";
137149/// linked.
138150pub export fn JNI_OnLoad(vm_handle: jni.JavaVM, _: ?*anyopaque) callconv(.c) jni.jint {
139151 java_vm = vm_handle;
152 // The log channel first, so the registration report below has somewhere to
153 // go. It is the only diagnosis available for a library that loaded and
154 // failed to bind.
155 android_log.setVm(vm_handle);
140156 // The reply channel needs the same handle, and needs it before any action
141157 // runs: a callback that fires between load and the first native call has
142158 // nowhere to deliver otherwise.
143159 events.setVm(vm_handle);
144160
145161 switch (bindNatives(vm_handle)) {
146 .registered => {},
162 // Said out loud on the happy path, not only on the failures below.
163 // "The library is loaded and bound" and "the library was never
164 // shipped" are otherwise both silent, and they are the two states
165 // anyone debugging this needs to tell apart first. It is also the only
166 // line that proves the log channel itself works: a diagnostic that
167 // only speaks when something is wrong cannot be trusted when it says
168 // nothing.
169 .registered => std.log.info(
170 "craft: registered {d} natives on {s}",
171 .{ natives.len, holder_class },
172 ),
147173 .no_env => std.log.err(
148174 "craft: no JNIEnv at load; every action stays on the Kotlin shim",
149175 .{},
packages/zig/src/android_events.zigmodified+9-33
Changes to packages/zig/src/android_events.zig
@@ -26,9 +26,13 @@
2626//! which is the normal case for a callback arriving on a framework thread,
2727//! exactly like a `CLLocationManager` delegate on iOS.
2828//!
29//! A thread this file attaches, this file detaches. Leaving one attached keeps
30//! the JVM's thread record alive after the OS thread is gone, and the crash
31//! surfaces somewhere unrelated much later.
29//! A thread this file attaches, this file detaches: `jni.attachCurrentThread`
30//! reports whether the attachment was ours, and `Attachment.release` gives
31//! back only those. Leaving one attached keeps the JVM's thread record alive
32//! after the OS thread is gone, and the crash surfaces somewhere unrelated
33//! much later. That dance lives in `jni_runtime.zig` rather than here because
34//! the log channel needs it too, and a second copy of it is how the two would
35//! drift apart.
3236
3337const std = ;
3438const jni = ;
@@ -66,34 +70,6 @@ pub fn resetDroppedForTest() void {
6670 dropped = 0;
6771}
6872
69/// This thread's env, attaching if it has none.
70///
71/// Returns whether the caller must detach. A thread the JVM created — the UI
72/// thread, a binder thread — is already attached and must **not** be detached
73/// by us; a thread Zig or a native library created must be.
74const Attachment = struct {
75 env: jni.JNIEnv,
76 owned: bool,
77};
78
79fn attach(vm: jni.JavaVM) ?Attachment {
80 if (jni.envForThisThread(vm)) |env| return .{ .env = env, .owned = false };
81
82 const attach_fn: *const fn (jni.JavaVM, *?*anyopaque, ?*anyopaque) callconv(.c) jni.jint =
83 @ptrCast(vm.*.AttachCurrentThread orelse return null);
84
85 var env_ptr: ?*anyopaque = null;
86 if (attach_fn(vm, &env_ptr, null) != jni.JNI_OK) return null;
87 const env: jni.JNIEnv = @ptrCast(@alignCast(env_ptr orelse return null));
88 return .{ .env = env, .owned = true };
89}
90
91fn detach(vm: jni.JavaVM) void {
92 const detach_fn: *const fn (jni.JavaVM) callconv(.c) jni.jint =
93 @ptrCast(vm.*.DetachCurrentThread orelse return);
94 _ = detach_fn(vm);
95}
96
9773/// Hand `script` to `CraftNative.deliver`, from any thread.
9874///
9975/// Silent on every failure, and deliberately: the caller is a device callback
@@ -105,11 +81,11 @@ pub fn evaluate(allocator: std.mem.Allocator, script: []const u8) void {
10581 return;
10682 };
10783
108 const attachment = attach(vm) orelse {
84 const attachment = jni.attachCurrentThread(vm) orelse {
10985 dropped += 1;
11086 return;
11187 };
112 defer if (attachment.owned) detach(vm);
88 defer attachment.release(vm);
11389
11490 deliverThrough(allocator, attachment.env, script) catch {
11591 dropped += 1;
packages/zig/src/android_log.zigadded+181-0
Changes to packages/zig/src/android_log.zig
@@ -0,0 +1,181 @@
1//! Zig's `std.log` output, routed to logcat through `android.util.Log`.
2//!
3//! Before this, every `std.log` line in the Android build went to stderr, and
4//! ART discards stderr. `JNI_OnLoad` reports a refused `RegisterNatives` that
5//! way, so three quite different states were indistinguishable from outside:
6//! the library was never shipped, the library loaded but a descriptor was
7//! wrong, or the library loaded and bound and the action simply declined. All
8//! three end with the Kotlin shim answering and nothing anywhere saying which
9//! happened.
10//!
11//! Through Java rather than `__android_log_write`, because this library
12//! deliberately links no libc — `android_dispatch.zig` records why, and the
13//! same reasoning rules out `dlopen`ing liblog, since libdl is not linked
14//! either. JNI calls are the one thing it can already do, and `android.util.Log`
15//! is reachable that way with no new link dependency at all.
16//!
17//! iOS has never needed this: `ios_dispatch.zig`'s lines reach the device
18//! console as they are, which is what the mobile E2E suite reads to prove the
19//! Zig runtime served a call. This is the Android counterpart.
20
21const std = ;
22const jni = ;
23
24/// The logcat tag. Distinct from the Kotlin shim's `CraftBridge`, so a reader
25/// can tell which side of the hand-off produced a line.
26pub const tag = "CraftNative";
27
28/// Longest line that survives intact. Longer ones are replaced by their own
29/// format string, which is comptime-known and still names the log site.
30const max_line = 1024;
31
32var java_vm: ?jni.JavaVM = null;
33var dropped: std.atomic.Value(usize) = .init(0);
34
35/// Guards against a log inside a log. Everything below can fail, and the
36/// failure paths must stay silent — one bad JNI call would otherwise become an
37/// unbounded stack of them. Thread-local because two threads logging at once
38/// is normal and neither should mute the other.
39threadlocal var writing: bool = false;
40
41/// Give the log channel the VM, from `JNI_OnLoad`.
42///
43/// Until this is called there is nowhere to write, which covers the window
44/// before the library is loaded. Lines in that window are counted, not queued:
45/// a queue would have to be bounded, and a bound is another thing to get wrong
46/// for output nobody is reading yet.
47pub fn setVm(vm: jni.JavaVM) void {
48 java_vm = vm;
49}
50
51/// Lines that could not be written. A diagnosis reads this rather than
52/// wondering whether the code was silent or the channel was.
53pub fn droppedCount() usize {
54 return dropped.load(.monotonic);
55}
56
57pub fn resetForTest() void {
58 java_vm = null;
59 dropped.store(0, .monotonic);
60}
61
62/// `std.Options.logFn`, installed by `android_dispatch.zig`.
63pub fn logFn(
64 comptime message_level: std.log.Level,
65 comptime scope: @EnumLiteral(),
66 comptime format: []const u8,
67 args: anytype,
68) void {
69 if (writing) return;
70 writing = true;
71 defer writing = false;
72
73 // The scope is prefixed rather than dropped, the same choice
74 // `src/minimal.zig` makes: `std.log.scoped(...)` is used throughout craft
75 // and the scope is most of what makes a line findable.
76 const prefix = if (scope == .default) "" else "[" ++ @tagName(scope) ++ "] ";
77
78 var buffer: [max_line]u8 = undefined;
79 // On overflow, the format string itself — comptime-known, no arguments to
80 // render, and it still says which log site fired. A truncated line would
81 // be better still, but `bufPrint` does not report how far it got.
82 const text = std.fmt.bufPrint(&buffer, prefix ++ format, args) catch prefix ++ format;
83
84 write(message_level, text);
85}
86
87fn write(level: std.log.Level, text: []const u8) void {
88 const vm = java_vm orelse {
89 _ = dropped.fetchAdd(1, .monotonic);
90 return;
91 };
92
93 const attachment = jni.attachCurrentThread(vm) orelse {
94 _ = dropped.fetchAdd(1, .monotonic);
95 return;
96 };
97 defer attachment.release(vm);
98
99 writeThrough(attachment.env, level, text) catch {
100 _ = dropped.fetchAdd(1, .monotonic);
101 };
102}
103
104fn writeThrough(env: jni.JNIEnv, level: std.log.Level, text: []const u8) !void {
105 const j = jni.Jni.init(env);
106
107 // A frame around the class and the two strings. Logging is the one thing
108 // that can happen on any thread at any rate, so leaking a local ref per
109 // line would overflow the JVM's sixteen slots and abort the process —
110 // turning a diagnostic into the fault it was meant to diagnose.
111 try j.pushLocalFrame(8);
112 defer _ = j.popLocalFrame(null);
113
114 // Stack, not the heap: a logger that allocates is a logger that cannot
115 // report an allocation failure. Sized for the tag plus a `max_line`
116 // message re-encoded to modified UTF-8.
117 var storage: [4 * max_line]u8 = undefined;
118 var fba = std.heap.FixedBufferAllocator.init(&storage);
119 const allocator = fba.allocator();
120
121 const cls = try j.findClass("android/util/Log");
122 const tag_string = try j.newStringUtf8(allocator, tag);
123 const message = try j.newStringUtf8(allocator, text);
124
125 const method = try j.staticMethodId(cls, methodFor(level), "(Ljava/lang/String;Ljava/lang/String;)I");
126 _ = try j.callStaticIntMethodA(cls, method, &.{ .{ .l = tag_string }, .{ .l = message } });
127}
128
129/// `android.util.Log`'s one-letter method for each Zig level.
130///
131/// Named methods rather than `Log.println(priority, …)` so the priority
132/// constants do not have to be mirrored here: getting `WARN` wrong by one
133/// would misfile every warning under a level that reads as routine.
134pub fn methodFor(level: std.log.Level) [*:0]const u8 {
135 return switch (level) {
136 .err => "e",
137 .warn => "w",
138 .info => "i",
139 .debug => "d",
140 };
141}
142
143const testing = std.testing;
144
145test "every level maps to a real android.util.Log method" {
146 // The four one-letter statics on android.util.Log, and nothing else. A
147 // typo here fails at `GetStaticMethodID` on a device and nowhere before.
148 for ([_]std.log.Level{ .err, .warn, .info, .debug }) |level| {
149 const name = std.mem.span(methodFor(level));
150 try testing.expectEqual(@as(usize, 1), name.len);
151 try testing.expect(std.mem.indexOfScalar(u8, "ewid", name[0]) != null);
152 }
153}
154
155test "levels do not share a method" {
156 const levels = [_]std.log.Level{ .err, .warn, .info, .debug };
157 for (levels, 0..) |left, i| {
158 for (levels[i + 1 ..]) |right| {
159 try testing.expect(!std.mem.eql(
160 u8,
161 std.mem.span(methodFor(left)),
162 std.mem.span(methodFor(right)),
163 ));
164 }
165 }
166}
167
168test "a line with no VM is counted rather than written" {
169 resetForTest();
170 defer resetForTest();
171
172 try testing.expectEqual(@as(usize, 0), droppedCount());
173 write(.warn, "nowhere to put this");
174 try testing.expectEqual(@as(usize, 1), droppedCount());
175}
176
177test "the tag is not the shim's" {
178 // Both reach logcat, and telling Zig's output from Kotlin's is the whole
179 // point of the Android half of the E2E suite's attribution.
180 try testing.expect(!std.mem.eql(u8, tag, "CraftBridge"));
181}
packages/zig/src/jni_runtime.zigmodified+36-0
Changes to packages/zig/src/jni_runtime.zig
@@ -437,6 +437,42 @@ pub fn envForThisThread(vm: JavaVM) ?JNIEnv {
437437 return @ptrCast(@alignCast(env orelse return null));
438438}
439439
440/// A `JNIEnv` for this thread, and whether this code owes the JVM a detach.
441///
442/// A thread the JVM created — the UI thread, a binder thread — is already
443/// attached and must **not** be detached by us; a thread Zig or a native
444/// library created must be. Getting that backwards either leaks the attachment
445/// or detaches a thread still using it, and neither fails at the call site.
446pub const Attachment = struct {
447 env: JNIEnv,
448 owned: bool,
449
450 /// Give the attachment back, if it was ours to give.
451 pub fn release(self: Attachment, vm: JavaVM) void {
452 if (!self.owned) return;
453 const detach: *const fn (JavaVM) callconv(.c) jint =
454 @ptrCast(vm.*.DetachCurrentThread orelse return);
455 _ = detach(vm);
456 }
457};
458
459/// This thread's `JNIEnv`, attaching the thread if it has none.
460///
461/// Lives here rather than beside either caller because there are two of them —
462/// the reply channel and the log channel — and a second copy of a JNI dance
463/// this subtle is how the two drift apart.
464pub fn attachCurrentThread(vm: JavaVM) ?Attachment {
465 if (envForThisThread(vm)) |env| return .{ .env = env, .owned = false };
466
467 const attach: *const fn (JavaVM, *?*anyopaque, ?*anyopaque) callconv(.c) jint =
468 @ptrCast(vm.*.AttachCurrentThread orelse return null);
469
470 var env_ptr: ?*anyopaque = null;
471 if (attach(vm, &env_ptr, null) != JNI_OK) return null;
472 const env: JNIEnv = @ptrCast(@alignCast(env_ptr orelse return null));
473 return .{ .env = env, .owned = true };
474}
475
440476/// Bind native implementations to a Java class's `external`/`native` methods.
441477///
442478/// The alternative is exporting a symbol named