also looking at this
test(mobile): run the generated apps on a simulator and an emulator
#199
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.
| @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFil | ||
| 2 | 2 | import { tmpdir } from 'node:os' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { describe, expect, it } from 'bun:test' |
| 5 | import { build, init, renderAndroidDeepLinks, renderAndroidPermissions, syncAndroidWebAssets } from './index' | |
| 5 | import { build, init, installRuntime, renderAndroidDeepLinks, renderAndroidPermissions, resolveRuntimeDir, syncAndroidWebAssets } from './index' | |
| 6 | 6 | |
| 7 | 7 | function generatedFiles(path: string): string[] { |
| 8 | 8 | return readdirSync(path, { withFileTypes: true }).flatMap((entry) => { |
| @@ -1188,3 +1188,114 @@ describe('Craft Android builder', () => { | ||
| 1188 | 1188 | } |
| 1189 | 1189 | }) |
| 1190 | 1190 | }) |
| 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 | */ | |
| 1199 | function 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 | ||
| 1208 | describe('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 | }) | |
| @@ -77,6 +77,14 @@ export interface InitOptions { | ||
| 77 | 77 | packageName?: string |
| 78 | 78 | output: string |
| 79 | 79 | 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 | |
| 80 | 88 | } |
| 81 | 89 | |
| 82 | 90 | export interface BuildOptions { |
| @@ -85,6 +93,8 @@ export interface BuildOptions { | ||
| 85 | 93 | output: string |
| 86 | 94 | release?: boolean |
| 87 | 95 | compile?: boolean |
| 96 | /** Same meaning as `InitOptions.runtimeDir`; refreshes what init installed. */ | |
| 97 | runtimeDir?: string | null | |
| 88 | 98 | } |
| 89 | 99 | |
| 90 | 100 | export interface OpenOptions { |
| @@ -341,6 +351,96 @@ export function renderAndroidDeepLinks(config: CraftAndroidConfig): string { | ||
| 341 | 351 | /** |
| 342 | 352 | * Initialize a new Android project |
| 343 | 353 | */ |
| 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 | */ | |
| 367 | const 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 | */ | |
| 378 | export 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 | */ | |
| 406 | export 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 | ||
| 344 | 444 | export async function init(options: InitOptions): Promise<void> { |
| 345 | 445 | const { name, packageName, output } = options |
| 346 | 446 | |
| @@ -670,6 +770,15 @@ zipStorePath=wrapper/dists | ||
| 670 | 770 | |
| 671 | 771 | writeFileSync(join(output, 'app/src/main/assets/index.html'), placeholderHtml) |
| 672 | 772 | |
| 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 | ||
| 673 | 782 | console.log('✅ Project initialized') |
| 674 | 783 | console.log('') |
| 675 | 784 | console.log('Next steps:') |
| @@ -696,6 +805,20 @@ export async function build(options: BuildOptions): Promise<void> { | ||
| 696 | 805 | |
| 697 | 806 | const config: CraftAndroidConfig = JSON.parse(readFileSync(configPath, 'utf-8')) |
| 698 | 807 | |
| 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 | ||
| 699 | 822 | // Update dev server URL if provided |
| 700 | 823 | if (devServer) { |
| 701 | 824 | const url = androidWebUrl(devServer, 'Android dev server URL') |
| @@ -36,6 +36,7 @@ | ||
| 36 | 36 | const std = ; |
| 37 | 37 | const builtin = ; |
| 38 | 38 | const jni = ; |
| 39 | const android_log = ; | |
| 39 | 40 | const permissions = ; |
| 40 | 41 | const device = ; |
| 41 | 42 | const system = ; |
| @@ -97,6 +98,17 @@ const backing = std.heap.page_allocator; | ||
| 97 | 98 | const request_calendar: i32 = 1005; |
| 98 | 99 | |
| 99 | 100 | /// 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. | |
| 108 | pub const std_options: std.Options = .{ | |
| 109 | .logFn = android_log.logFn, | |
| 110 | }; | |
| 111 | ||
| 100 | 112 | const one_hour_ms: i64 = 60 * 60 * 1000; |
| 101 | 113 | |
| 102 | 114 | /// `CraftBridge.REQUEST_CONTACTS`. |
| @@ -137,13 +149,27 @@ pub const holder_class = "com/craft/runtime/CraftNative"; | ||
| 137 | 149 | /// linked. |
| 138 | 150 | pub export fn JNI_OnLoad(vm_handle: jni.JavaVM, _: ?*anyopaque) callconv(.c) jni.jint { |
| 139 | 151 | 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); | |
| 140 | 156 | // The reply channel needs the same handle, and needs it before any action |
| 141 | 157 | // runs: a callback that fires between load and the first native call has |
| 142 | 158 | // nowhere to deliver otherwise. |
| 143 | 159 | events.setVm(vm_handle); |
| 144 | 160 | |
| 145 | 161 | 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 | ), | |
| 147 | 173 | .no_env => std.log.err( |
| 148 | 174 | "craft: no JNIEnv at load; every action stays on the Kotlin shim", |
| 149 | 175 | .{}, |
| @@ -26,9 +26,13 @@ | ||
| 26 | 26 | //! which is the normal case for a callback arriving on a framework thread, |
| 27 | 27 | //! exactly like a `CLLocationManager` delegate on iOS. |
| 28 | 28 | //! |
| 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. | |
| 32 | 36 | |
| 33 | 37 | const std = ; |
| 34 | 38 | const jni = ; |
| @@ -66,34 +70,6 @@ pub fn resetDroppedForTest() void { | ||
| 66 | 70 | dropped = 0; |
| 67 | 71 | } |
| 68 | 72 | |
| 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. | |
| 74 | const Attachment = struct { | |
| 75 | env: jni.JNIEnv, | |
| 76 | owned: bool, | |
| 77 | }; | |
| 78 | ||
| 79 | fn 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 | ||
| 91 | fn 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 | ||
| 97 | 73 | /// Hand `script` to `CraftNative.deliver`, from any thread. |
| 98 | 74 | /// |
| 99 | 75 | /// 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 { | ||
| 105 | 81 | return; |
| 106 | 82 | }; |
| 107 | 83 | |
| 108 | const attachment = attach(vm) orelse { | |
| 84 | const attachment = jni.attachCurrentThread(vm) orelse { | |
| 109 | 85 | dropped += 1; |
| 110 | 86 | return; |
| 111 | 87 | }; |
| 112 | defer if (attachment.owned) detach(vm); | |
| 88 | defer attachment.release(vm); | |
| 113 | 89 | |
| 114 | 90 | deliverThrough(allocator, attachment.env, script) catch { |
| 115 | 91 | dropped += 1; |
| @@ -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 | ||
| 21 | const std = ; | |
| 22 | const 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. | |
| 26 | pub 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. | |
| 30 | const max_line = 1024; | |
| 31 | ||
| 32 | var java_vm: ?jni.JavaVM = null; | |
| 33 | var 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. | |
| 39 | threadlocal 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. | |
| 47 | pub 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. | |
| 53 | pub fn droppedCount() usize { | |
| 54 | return dropped.load(.monotonic); | |
| 55 | } | |
| 56 | ||
| 57 | pub fn resetForTest() void { | |
| 58 | java_vm = null; | |
| 59 | dropped.store(0, .monotonic); | |
| 60 | } | |
| 61 | ||
| 62 | /// `std.Options.logFn`, installed by `android_dispatch.zig`. | |
| 63 | pub 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 | ||
| 87 | fn 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 | ||
| 104 | fn 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. | |
| 134 | pub 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 | ||
| 143 | const testing = std.testing; | |
| 144 | ||
| 145 | test "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 | ||
| 155 | test "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 | ||
| 168 | test "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 | ||
| 177 | test "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 | } | |
| @@ -437,6 +437,42 @@ pub fn envForThisThread(vm: JavaVM) ?JNIEnv { | ||
| 437 | 437 | return @ptrCast(@alignCast(env orelse return null)); |
| 438 | 438 | } |
| 439 | 439 | |
| 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. | |
| 446 | pub 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. | |
| 464 | pub 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 | ||
| 440 | 476 | /// Bind native implementations to a Java class's `external`/`native` methods. |
| 441 | 477 | /// |
| 442 | 478 | /// The alternative is exporting a symbol named |