also looking at this
feat(android): a main-thread trampoline, and the orientation pair to prove it — ratchet 72 → 70
#173
7 files
+568
-3
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.
| @@ -2592,6 +2592,10 @@ class CraftBridge( | ||
| 2592 | 2592 | |
| 2593 | 2593 | @JavascriptInterface |
| 2594 | 2594 | fun lockOrientation(orientation: String): Boolean { |
| 2595 | // Zig queues the same work on the same looper, through a Runnable | |
| 2596 | // this file's holder owns — see CraftNative.runOnMain. | |
| 2597 | if (CraftNative.lockOrientation(activity, orientation)) return true | |
| 2598 | ||
| 2595 | 2599 | activity.runOnUiThread { |
| 2596 | 2600 | activity.requestedOrientation = when (orientation) { |
| 2597 | 2601 | "portrait" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT |
| @@ -2606,6 +2610,8 @@ class CraftBridge( | ||
| 2606 | 2610 | |
| 2607 | 2611 | @JavascriptInterface |
| 2608 | 2612 | fun unlockOrientation(): Boolean { |
| 2613 | if (CraftNative.unlockOrientation(activity)) return true | |
| 2614 | ||
| 2609 | 2615 | activity.runOnUiThread { |
| 2610 | 2616 | activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED |
| 2611 | 2617 | } |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | package com.craft.runtime |
| 2 | 2 | |
| 3 | 3 | import android.app.Activity |
| 4 | import android.os.Handler | |
| 5 | import android.os.Looper | |
| 4 | 6 | import android.content.SharedPreferences |
| 5 | 7 | import android.database.sqlite.SQLiteDatabase |
| 6 | 8 | import org.json.JSONObject |
| @@ -123,6 +125,9 @@ object CraftNative { | ||
| 123 | 125 | private external fun nativeSetShortcuts(activity: Activity, shortcutsJson: String): Boolean |
| 124 | 126 | private external fun nativeClearShortcuts(activity: Activity): Boolean |
| 125 | 127 | private external fun nativeScheduleNotification(activity: Activity, notificationJson: String): Boolean |
| 128 | private external fun nativeRunTask(activity: Activity, token: Long) | |
| 129 | private external fun nativeLockOrientation(activity: Activity, orientation: String): Boolean | |
| 130 | private external fun nativeUnlockOrientation(activity: Activity): Boolean | |
| 126 | 131 | |
| 127 | 132 | /** |
| 128 | 133 | * `getDeviceInfo`, or null to use the Kotlin implementation. |
| @@ -555,4 +560,63 @@ object CraftNative { | ||
| 555 | 560 | false |
| 556 | 561 | } |
| 557 | 562 | } |
| 563 | ||
| 564 | /** | |
| 565 | * How Zig reaches the main looper. | |
| 566 | * | |
| 567 | * `Window.addFlags` and `setRequestedOrientation` have to run there, and | |
| 568 | * the shim wraps each in `runOnUiThread { ... }` — which takes a | |
| 569 | * `Runnable`, a Java object implementing a Java interface. JNI cannot make | |
| 570 | * one: `RegisterNatives` binds methods onto a class that already exists in | |
| 571 | * the APK, so a native can be *called* from Java but cannot be handed to | |
| 572 | * Java as an object. | |
| 573 | * | |
| 574 | * So the `Runnable` is here, the work is there, and a token says which | |
| 575 | * work. The Activity goes across with it rather than being held by Zig | |
| 576 | * over the hop, which is what would need a global reference and a matching | |
| 577 | * release. | |
| 578 | * | |
| 579 | * Called from Zig, never from the Kotlin below. | |
| 580 | */ | |
| 581 | @JvmStatic | |
| 582 | fun runOnMain(activity: Activity, token: Long, delayMs: Long) { | |
| 583 | val block = Runnable { | |
| 584 | try { | |
| 585 | nativeRunTask(activity, token) | |
| 586 | } catch (e: UnsatisfiedLinkError) { | |
| 587 | // The library went away between the post and the looper. There | |
| 588 | // is nothing to answer to here and nothing to retry. | |
| 589 | } | |
| 590 | } | |
| 591 | if (delayMs > 0) { | |
| 592 | Handler(Looper.getMainLooper()).postDelayed(block, delayMs) | |
| 593 | } else { | |
| 594 | activity.runOnUiThread(block) | |
| 595 | } | |
| 596 | } | |
| 597 | ||
| 598 | /** | |
| 599 | * Returns whether Zig queued the work, not whether the device has turned. | |
| 600 | * | |
| 601 | * The shim returns true after `runOnUiThread` for the same reason: the | |
| 602 | * JavaBridge thread is never the main thread, so the block always runs | |
| 603 | * later than the answer. | |
| 604 | */ | |
| 605 | fun lockOrientation(activity: Activity, orientation: String): Boolean { | |
| 606 | if (!isAvailable) return false | |
| 607 | return try { | |
| 608 | nativeLockOrientation(activity, orientation) | |
| 609 | } catch (e: UnsatisfiedLinkError) { | |
| 610 | false | |
| 611 | } | |
| 612 | } | |
| 613 | ||
| 614 | fun unlockOrientation(activity: Activity): Boolean { | |
| 615 | if (!isAvailable) return false | |
| 616 | return try { | |
| 617 | nativeUnlockOrientation(activity) | |
| 618 | } catch (e: UnsatisfiedLinkError) { | |
| 619 | false | |
| 620 | } | |
| 621 | } | |
| 558 | 622 | } |
| @@ -473,6 +473,9 @@ pub fn build(b: *std.Build) void { | ||
| 473 | 473 | android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_shortcuts.zig", .{ |
| 474 | 474 | .root_source_file = b.path("src/bridge_android_shortcuts.zig"), |
| 475 | 475 | }); |
| 476 | android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_orientation.zig", .{ | |
| 477 | .root_source_file = b.path("src/bridge_android_orientation.zig"), | |
| 478 | }); | |
| 476 | 479 | const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests); |
| 477 | 480 | |
| 478 | 481 | // The page surface, across both bridges. Not folded into the iOS |
| @@ -52,6 +52,8 @@ const shareditem = ; | ||
| 52 | 52 | const contacts = ; |
| 53 | 53 | const widgets = ; |
| 54 | 54 | const shortcuts = ; |
| 55 | const orientation = ; | |
| 56 | const main_thread = ; | |
| 55 | 57 | |
| 56 | 58 | const Jni = jni.Jni; |
| 57 | 59 | |
| @@ -323,6 +325,23 @@ const natives = [_]jni.JNINativeMethod{ | ||
| 323 | 325 | .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z", |
| 324 | 326 | .fnPtr = @ptrCast(&nativeScheduleNotification), |
| 325 | 327 | }, |
| 328 | // Not an action: the other end of `CraftNative.runOnMain`, which is how | |
| 329 | // anything here reaches the main looper at all. | |
| 330 | .{ | |
| 331 | .name = "nativeRunTask", | |
| 332 | .signature = "(Landroid/app/Activity;J)V", | |
| 333 | .fnPtr = @ptrCast(&nativeRunTask), | |
| 334 | }, | |
| 335 | .{ | |
| 336 | .name = "nativeLockOrientation", | |
| 337 | .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z", | |
| 338 | .fnPtr = @ptrCast(&nativeLockOrientation), | |
| 339 | }, | |
| 340 | .{ | |
| 341 | .name = "nativeUnlockOrientation", | |
| 342 | .signature = "(Landroid/app/Activity;)Z", | |
| 343 | .fnPtr = @ptrCast(&nativeUnlockOrientation), | |
| 344 | }, | |
| 326 | 345 | }; |
| 327 | 346 | |
| 328 | 347 | /// How binding went. A value rather than a log line, so every path is |
| @@ -1301,6 +1320,62 @@ fn nativeScheduleNotification( | ||
| 1301 | 1320 | return jni.JNI_TRUE; |
| 1302 | 1321 | } |
| 1303 | 1322 | |
| 1323 | /// `nativeRunTask(activity, token)` — the main thread, arrived at. | |
| 1324 | /// | |
| 1325 | /// The only native here that is not an action. `CraftNative.runOnMain` posted | |
| 1326 | /// a `Runnable` that calls this, so by the time it runs the looper is ours and | |
| 1327 | /// the Activity came back with it rather than being held across the hop. | |
| 1328 | /// | |
| 1329 | /// Void and silent: there is nothing to answer to. A token that no longer | |
| 1330 | /// names a task is dropped by `main_thread.run`, which is the case a | |
| 1331 | /// `Runnable` outliving whatever queued it would otherwise crash on. | |
| 1332 | fn nativeRunTask( | |
| 1333 | env: jni.JNIEnv, | |
| 1334 | _: jni.jobject, | |
| 1335 | activity: jni.jobject, | |
| 1336 | token: jni.jlong, | |
| 1337 | ) callconv(.c) void { | |
| 1338 | main_thread.run(Jni.init(env), activity, @bitCast(token)); | |
| 1339 | } | |
| 1340 | ||
| 1341 | /// `nativeLockOrientation(activity, orientation)`. | |
| 1342 | /// | |
| 1343 | /// True means the work is queued, not that the device has turned — which is | |
| 1344 | /// what the shim's `return true` after `runOnUiThread` means too. | |
| 1345 | fn nativeLockOrientation( | |
| 1346 | env: jni.JNIEnv, | |
| 1347 | _: jni.jobject, | |
| 1348 | activity: jni.jobject, | |
| 1349 | name: jni.jstring, | |
| 1350 | ) callconv(.c) jni.jboolean { | |
| 1351 | const j = Jni.init(env); | |
| 1352 | var arena = std.heap.ArenaAllocator.init(backing); | |
| 1353 | defer arena.deinit(); | |
| 1354 | ||
| 1355 | const text = j.stringToUtf8(arena.allocator(), name) catch return jni.JNI_FALSE; | |
| 1356 | ||
| 1357 | orientation.apply(j, activity, orientation.modeFor(text)) catch |err| { | |
| 1358 | std.log.warn("craft: lockOrientation fell through to the shim ({s})", .{@errorName(err)}); | |
| 1359 | return jni.JNI_FALSE; | |
| 1360 | }; | |
| 1361 | return jni.JNI_TRUE; | |
| 1362 | } | |
| 1363 | ||
| 1364 | /// `nativeUnlockOrientation(activity)`. | |
| 1365 | fn nativeUnlockOrientation( | |
| 1366 | env: jni.JNIEnv, | |
| 1367 | _: jni.jobject, | |
| 1368 | activity: jni.jobject, | |
| 1369 | ) callconv(.c) jni.jboolean { | |
| 1370 | const j = Jni.init(env); | |
| 1371 | ||
| 1372 | orientation.apply(j, activity, .unspecified) catch |err| { | |
| 1373 | std.log.warn("craft: unlockOrientation fell through to the shim ({s})", .{@errorName(err)}); | |
| 1374 | return jni.JNI_FALSE; | |
| 1375 | }; | |
| 1376 | return jni.JNI_TRUE; | |
| 1377 | } | |
| 1378 | ||
| 1304 | 1379 | // ============================================================================= |
| 1305 | 1380 | // Tests |
| 1306 | 1381 | // ============================================================================= |
| @@ -1412,7 +1487,7 @@ test "the registered natives name methods the Kotlin actually declares" { | ||
| 1412 | 1487 | // A descriptor is checked by the JVM at registration, so a wrong one fails |
| 1413 | 1488 | // at load rather than at call — but only if the *name* matches something. |
| 1414 | 1489 | // These two strings are the contract with CraftBridge.kt. |
| 1415 | try testing.expectEqual(@as(usize, 31), natives.len); | |
| 1490 | try testing.expectEqual(@as(usize, 34), natives.len); | |
| 1416 | 1491 | try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name)); |
| 1417 | 1492 | |
| 1418 | 1493 | // And the class they bind to is the fixed one, not the templated bridge. |
| @@ -0,0 +1,265 @@ | ||
| 1 | //! Getting onto the main thread, which Zig cannot do by itself. | |
| 2 | //! | |
| 3 | //! Several Android calls must run on the main looper — `Window.addFlags` | |
| 4 | //! touches the view hierarchy, `setRequestedOrientation` goes through the | |
| 5 | //! Activity — and the shim wraps each in `activity.runOnUiThread { ... }`. | |
| 6 | //! That takes a `Runnable`, a Java object implementing a Java interface, and | |
| 7 | //! JNI cannot make one: `RegisterNatives` binds methods on a class that | |
| 8 | //! already exists in the APK, so a native can *be* called from Java but cannot | |
| 9 | //! be handed to Java as an object. | |
| 10 | //! | |
| 11 | //! This is the same wall `android_events.zig` gets around for replies, and the | |
| 12 | //! same answer: Kotlin owns the object, Zig owns the work. `CraftNative.runOnMain` | |
| 13 | //! posts a `Runnable` that calls straight back into `nativeRunTask`, carrying a | |
| 14 | //! token — and the Activity, so nothing here has to hold a reference across | |
| 15 | //! the hop. | |
| 16 | //! | |
| 17 | //! ## Why a token and not a pointer | |
| 18 | //! | |
| 19 | //! The obvious shape is to pass Zig a pointer and cast it back. It is also the | |
| 20 | //! shape where a stale `Runnable` — one the looper still holds after whatever | |
| 21 | //! queued it is gone — reads freed memory and the crash lands somewhere else | |
| 22 | //! entirely. | |
| 23 | //! | |
| 24 | //! A token is checkable. Each slot carries a generation that increments when | |
| 25 | //! the slot is reused, so a token from a previous occupant does not match and | |
| 26 | //! the task is dropped rather than run. `nativeRunTask` cannot be made to run | |
| 27 | //! the wrong task by anything the JVM does with timing. | |
| 28 | //! | |
| 29 | //! ## The table is fixed and small | |
| 30 | //! | |
| 31 | //! Thirty-two slots, no allocator. These tasks live between a page's call and | |
| 32 | //! the next turn of the looper — microseconds — so the table being full means | |
| 33 | //! something is wrong rather than busy, and the caller falls through to the | |
| 34 | //! shim rather than growing. | |
| 35 | ||
| 36 | const std = ; | |
| 37 | const jni = ; | |
| 38 | ||
| 39 | const Jni = jni.Jni; | |
| 40 | const jobject = jni.jobject; | |
| 41 | ||
| 42 | /// The work to do once the main thread is reached. | |
| 43 | /// | |
| 44 | /// A tagged union rather than a function pointer and an opaque payload: every | |
| 45 | /// variant is small, the set is closed, and a reader can see what can be | |
| 46 | /// queued without following an indirection. A variant needing heap memory | |
| 47 | /// would carry an owned slice and gain a `free` arm below. | |
| 48 | pub const Task = union(enum) { | |
| 49 | /// `activity.setRequestedOrientation(mode)`. | |
| 50 | set_requested_orientation: i32, | |
| 51 | }; | |
| 52 | ||
| 53 | const slot_count = 32; | |
| 54 | ||
| 55 | const Slot = struct { | |
| 56 | task: Task = undefined, | |
| 57 | generation: u32 = 0, | |
| 58 | occupied: bool = false, | |
| 59 | }; | |
| 60 | ||
| 61 | var slots: [slot_count]Slot = @splat(.{}); | |
| 62 | ||
| 63 | /// The table is written from the JavaBridge thread and read from the main | |
| 64 | /// looper, which are different threads by construction — that is the entire | |
| 65 | /// point of the hop. | |
| 66 | /// | |
| 67 | /// A spin lock rather than a mutex, because `std.Io.Mutex` on this toolchain | |
| 68 | /// wants an `Io` and a JNI native has nowhere to get one. The critical | |
| 69 | /// sections are a handful of instructions over a 32-entry array, and at most | |
| 70 | /// two threads ever contend, so spinning is the cheap answer rather than the | |
| 71 | /// lazy one. | |
| 72 | var table_locked: std.atomic.Value(bool) = .init(false); | |
| 73 | ||
| 74 | fn lockTable() void { | |
| 75 | while (table_locked.cmpxchgWeak(false, true, .acquire, .monotonic) != null) { | |
| 76 | std.atomic.spinLoopHint(); | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | fn unlockTable() void { | |
| 81 | table_locked.store(false, .release); | |
| 82 | } | |
| 83 | ||
| 84 | pub const PostError = error{TableFull}; | |
| 85 | ||
| 86 | /// Claim a slot and return its token, or fail if none is free. | |
| 87 | /// | |
| 88 | /// Split from `post` so the reservation is testable without a JVM: this is | |
| 89 | /// where every property worth checking lives. | |
| 90 | pub fn reserve(task: Task) PostError!u64 { | |
| 91 | lockTable(); | |
| 92 | defer unlockTable(); | |
| 93 | ||
| 94 | for (&slots, 0..) |*slot, index| { | |
| 95 | if (slot.occupied) continue; | |
| 96 | slot.task = task; | |
| 97 | slot.occupied = true; | |
| 98 | return (@as(u64, @intCast(index)) << 32) | slot.generation; | |
| 99 | } | |
| 100 | return error.TableFull; | |
| 101 | } | |
| 102 | ||
| 103 | /// Take the task a token names, or null if the token is stale or unknown. | |
| 104 | /// | |
| 105 | /// Taking rather than reading: a slot is released here, so a `Runnable` that | |
| 106 | /// somehow fires twice finds nothing the second time. | |
| 107 | pub fn claim(token: u64) ?Task { | |
| 108 | const index: usize = @intCast(token >> 32); | |
| 109 | const generation: u32 = @truncate(token); | |
| 110 | ||
| 111 | if (index >= slot_count) return null; | |
| 112 | ||
| 113 | lockTable(); | |
| 114 | defer unlockTable(); | |
| 115 | ||
| 116 | const slot = &slots[index]; | |
| 117 | if (!slot.occupied or slot.generation != generation) return null; | |
| 118 | ||
| 119 | const task = slot.task; | |
| 120 | slot.occupied = false; | |
| 121 | // Wrapping, because the only thing that matters is that a token minted | |
| 122 | // before this point stops matching. Sixty-four thousand reuses of one slot | |
| 123 | // between a post and its Runnable is not a thing that happens. | |
| 124 | slot.generation +%= 1; | |
| 125 | return task; | |
| 126 | } | |
| 127 | ||
| 128 | /// Give a reserved slot back, for a caller that could not schedule it. | |
| 129 | /// | |
| 130 | /// Without this a failed `runOnMain` would leak the slot until the table | |
| 131 | /// filled, and the failure would show up as an unrelated action falling | |
| 132 | /// through much later. | |
| 133 | pub fn release(token: u64) void { | |
| 134 | _ = claim(token); | |
| 135 | } | |
| 136 | ||
| 137 | /// `CraftNative.runOnMain(activity, token, delayMs)`. | |
| 138 | /// | |
| 139 | /// `delay_ms` above zero posts through a `Handler`; zero goes through | |
| 140 | /// `runOnUiThread`, which runs inline when the caller is already on the main | |
| 141 | /// thread. The shim's `runOnUiThread` has that same property, so an action | |
| 142 | /// this serves keeps the shim's ordering. | |
| 143 | pub fn post(j: Jni, activity: jobject, task: Task, delay_ms: i64) !void { | |
| 144 | const token = try reserve(task); | |
| 145 | errdefer release(token); | |
| 146 | ||
| 147 | const holder = try j.findClass(holder_class); | |
| 148 | try j.callStaticVoidMethodA( | |
| 149 | holder, | |
| 150 | try j.staticMethodId(holder, "runOnMain", "(Landroid/app/Activity;JJ)V"), | |
| 151 | &.{ .{ .l = activity }, .{ .j = @bitCast(token) }, .{ .j = delay_ms } }, | |
| 152 | ); | |
| 153 | } | |
| 154 | ||
| 155 | /// The fixed-package holder the natives are registered on. | |
| 156 | const holder_class = "com/craft/runtime/CraftNative"; | |
| 157 | ||
| 158 | /// Run the task a token names. Called from `nativeRunTask`, on the main thread. | |
| 159 | pub fn run(j: Jni, activity: jobject, token: u64) void { | |
| 160 | const task = claim(token) orelse return; | |
| 161 | switch (task) { | |
| 162 | .set_requested_orientation => |mode| setRequestedOrientation(j, activity, mode) catch {}, | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | fn setRequestedOrientation(j: Jni, activity: jobject, mode: i32) !void { | |
| 167 | try j.pushLocalFrame(4); | |
| 168 | defer _ = j.popLocalFrame(null); | |
| 169 | ||
| 170 | try j.callVoidMethodA( | |
| 171 | activity, | |
| 172 | try j.methodId(try j.objectClass(activity), "setRequestedOrientation", "(I)V"), | |
| 173 | &.{.{ .i = mode }}, | |
| 174 | ); | |
| 175 | } | |
| 176 | ||
| 177 | // ============================================================================= | |
| 178 | // Tests | |
| 179 | // ============================================================================= | |
| 180 | ||
| 181 | const testing = std.testing; | |
| 182 | ||
| 183 | fn clearTable() void { | |
| 184 | lockTable(); | |
| 185 | defer unlockTable(); | |
| 186 | for (&slots) |*slot| slot.occupied = false; | |
| 187 | } | |
| 188 | ||
| 189 | test "a reserved task comes back exactly once" { | |
| 190 | clearTable(); | |
| 191 | ||
| 192 | const token = try reserve(.{ .set_requested_orientation = 1 }); | |
| 193 | const claimed = claim(token).?; | |
| 194 | try testing.expectEqual(@as(i32, 1), claimed.set_requested_orientation); | |
| 195 | ||
| 196 | // The second claim is the one that matters: a Runnable the looper somehow | |
| 197 | // ran twice must not set the orientation twice. | |
| 198 | try testing.expect(claim(token) == null); | |
| 199 | } | |
| 200 | ||
| 201 | test "a stale token does not run the slot's new occupant" { | |
| 202 | clearTable(); | |
| 203 | ||
| 204 | const first = try reserve(.{ .set_requested_orientation = 1 }); | |
| 205 | _ = claim(first); | |
| 206 | ||
| 207 | // The same slot, a new task. Without the generation, `first` would still | |
| 208 | // index it and would run this. | |
| 209 | const second = try reserve(.{ .set_requested_orientation = 8 }); | |
| 210 | try testing.expect(claim(first) == null); | |
| 211 | ||
| 212 | const claimed = claim(second).?; | |
| 213 | try testing.expectEqual(@as(i32, 8), claimed.set_requested_orientation); | |
| 214 | } | |
| 215 | ||
| 216 | test "an unknown token is dropped rather than indexing out of bounds" { | |
| 217 | clearTable(); | |
| 218 | ||
| 219 | // A token naming a slot past the end, which is what a corrupted or | |
| 220 | // fabricated long would look like arriving from Java. | |
| 221 | try testing.expect(claim(0xFFFF_FFFF_0000_0000) == null); | |
| 222 | try testing.expect(claim(std.math.maxInt(u64)) == null); | |
| 223 | // And one naming a real slot that holds nothing. | |
| 224 | try testing.expect(claim(0) == null); | |
| 225 | } | |
| 226 | ||
| 227 | test "the table fills and says so rather than overwriting" { | |
| 228 | clearTable(); | |
| 229 | ||
| 230 | var tokens: [slot_count]u64 = undefined; | |
| 231 | for (&tokens) |*token| token.* = try reserve(.{ .set_requested_orientation = 0 }); | |
| 232 | ||
| 233 | try testing.expectError(error.TableFull, reserve(.{ .set_requested_orientation = 0 })); | |
| 234 | ||
| 235 | // Every token is distinct — the failure above is a full table and not a | |
| 236 | // slot handed out twice. | |
| 237 | for (tokens, 0..) |a, i| { | |
| 238 | for (tokens[i + 1 ..]) |b| try testing.expect(a != b); | |
| 239 | } | |
| 240 | ||
| 241 | // And releasing one makes room again. | |
| 242 | release(tokens[3]); | |
| 243 | _ = try reserve(.{ .set_requested_orientation = 0 }); | |
| 244 | } | |
| 245 | ||
| 246 | test "releasing a token a caller could not schedule frees its slot" { | |
| 247 | clearTable(); | |
| 248 | ||
| 249 | const token = try reserve(.{ .set_requested_orientation = 1 }); | |
| 250 | release(token); | |
| 251 | try testing.expect(claim(token) == null); | |
| 252 | ||
| 253 | // The slot is genuinely free rather than merely unreadable. | |
| 254 | var count: usize = 0; | |
| 255 | while (count < slot_count) : (count += 1) { | |
| 256 | _ = try reserve(.{ .set_requested_orientation = 0 }); | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | test "the holder class is the fixed one, not the templated bridge" { | |
| 261 | // A `{{` here would mean the library had been made app-specific, which is | |
| 262 | // the thing RegisterNatives exists to avoid. | |
| 263 | try testing.expectEqualStrings("com/craft/runtime/CraftNative", holder_class); | |
| 264 | try testing.expect(std.mem.indexOf(u8, holder_class, "{") == null); | |
| 265 | } | |
| @@ -0,0 +1,149 @@ | ||
| 1 | //! `lockOrientation` and `unlockOrientation` on Android. | |
| 2 | //! | |
| 3 | //! The first pair to go through `android_main_thread`: both do their whole job | |
| 4 | //! on the main looper and neither touches state the shim keeps, which is what | |
| 5 | //! makes them the right thing to prove the trampoline with. | |
| 6 | //! | |
| 7 | //! ## They answer by returning, not through the reply channel | |
| 8 | //! | |
| 9 | //! Both are `Boolean` methods that `return true` after *queueing* the work. | |
| 10 | //! So the page's promise resolves before the orientation has changed, on the | |
| 11 | //! shim as much as here — `runOnUiThread` returns immediately when the caller | |
| 12 | //! is not the main thread, which the JavaBridge thread never is. Nothing here | |
| 13 | //! makes that worse and nothing here fixes it. | |
| 14 | //! | |
| 15 | //! ## The constants are read, not written down | |
| 16 | //! | |
| 17 | //! `ActivityInfo.SCREEN_ORIENTATION_*` are compile-time constants, so the | |
| 18 | //! shim's DEX holds the numbers and literals here would be faithful. They are | |
| 19 | //! read anyway, for the reason `addContact` reads its MIME types: a wrong | |
| 20 | //! column name throws, and a wrong orientation number silently locks the | |
| 21 | //! device the wrong way round. | |
| 22 | //! | |
| 23 | //! ## Two names mean the same thing, and an unknown name unlocks | |
| 24 | //! | |
| 25 | //! `landscape` and `landscapeLeft` both map to `SCREEN_ORIENTATION_LANDSCAPE` | |
| 26 | //! — so a page asking for the left-hand landscape and a page asking for either | |
| 27 | //! landscape get the same lock. `landscapeRight` is the only one that reaches | |
| 28 | //! `REVERSE_LANDSCAPE`. | |
| 29 | //! | |
| 30 | //! And the `else` branch is `SCREEN_ORIENTATION_UNSPECIFIED`, which is what | |
| 31 | //! `unlockOrientation` sets. So `craft.lockOrientation("portrat")` does not | |
| 32 | //! fail — it silently *unlocks*, and still returns true. | |
| 33 | ||
| 34 | const std = ; | |
| 35 | const jni = ; | |
| 36 | const main_thread = ; | |
| 37 | ||
| 38 | const Jni = jni.Jni; | |
| 39 | const jobject = jni.jobject; | |
| 40 | ||
| 41 | pub const A = struct { | |
| 42 | pub const lock_orientation = "lockOrientation"; | |
| 43 | pub const unlock_orientation = "unlockOrientation"; | |
| 44 | }; | |
| 45 | ||
| 46 | /// Which `ActivityInfo` constant a page's string names. | |
| 47 | pub const Mode = enum { | |
| 48 | unspecified, | |
| 49 | landscape, | |
| 50 | portrait, | |
| 51 | reverse_landscape, | |
| 52 | ||
| 53 | /// The field on `android.content.pm.ActivityInfo`. | |
| 54 | pub fn field(self: Mode) [:0]const u8 { | |
| 55 | return switch (self) { | |
| 56 | .unspecified => "SCREEN_ORIENTATION_UNSPECIFIED", | |
| 57 | .landscape => "SCREEN_ORIENTATION_LANDSCAPE", | |
| 58 | .portrait => "SCREEN_ORIENTATION_PORTRAIT", | |
| 59 | .reverse_landscape => "SCREEN_ORIENTATION_REVERSE_LANDSCAPE", | |
| 60 | }; | |
| 61 | } | |
| 62 | }; | |
| 63 | ||
| 64 | /// The shim's `when (orientation)`, including its `else`. | |
| 65 | pub fn modeFor(orientation: []const u8) Mode { | |
| 66 | if (std.mem.eql(u8, orientation, "portrait")) return .portrait; | |
| 67 | if (std.mem.eql(u8, orientation, "landscape")) return .landscape; | |
| 68 | if (std.mem.eql(u8, orientation, "landscapeLeft")) return .landscape; | |
| 69 | if (std.mem.eql(u8, orientation, "landscapeRight")) return .reverse_landscape; | |
| 70 | return .unspecified; | |
| 71 | } | |
| 72 | ||
| 73 | /// `ActivityInfo.<field>`. | |
| 74 | pub fn constantFor(j: Jni, mode: Mode) !i32 { | |
| 75 | try j.pushLocalFrame(4); | |
| 76 | defer _ = j.popLocalFrame(null); | |
| 77 | ||
| 78 | const info_cls = try j.findClass("android/content/pm/ActivityInfo"); | |
| 79 | return j.staticIntField(info_cls, try j.staticFieldId(info_cls, mode.field(), "I")); | |
| 80 | } | |
| 81 | ||
| 82 | /// Queue `activity.setRequestedOrientation(...)` for the main thread. | |
| 83 | pub fn apply(j: Jni, activity: jobject, mode: Mode) !void { | |
| 84 | const constant = try constantFor(j, mode); | |
| 85 | try main_thread.post(j, activity, .{ .set_requested_orientation = constant }, 0); | |
| 86 | } | |
| 87 | ||
| 88 | // ============================================================================= | |
| 89 | // Tests | |
| 90 | // ============================================================================= | |
| 91 | ||
| 92 | const testing = std.testing; | |
| 93 | ||
| 94 | test "every name the shim knows maps where the shim maps it" { | |
| 95 | try testing.expectEqual(Mode.portrait, modeFor("portrait")); | |
| 96 | try testing.expectEqual(Mode.landscape, modeFor("landscape")); | |
| 97 | ||
| 98 | // Both of these are SCREEN_ORIENTATION_LANDSCAPE in the shim. Written as | |
| 99 | // two rows rather than one, because they read like they should differ. | |
| 100 | try testing.expectEqual(Mode.landscape, modeFor("landscapeLeft")); | |
| 101 | try testing.expectEqual(Mode.reverse_landscape, modeFor("landscapeRight")); | |
| 102 | } | |
| 103 | ||
| 104 | test "an unknown name unlocks rather than failing" { | |
| 105 | // The shim's `else`. `craft.lockOrientation("portrat")` returns true and | |
| 106 | // leaves the device unlocked, which is worth knowing before debugging it. | |
| 107 | try testing.expectEqual(Mode.unspecified, modeFor("portrat")); | |
| 108 | try testing.expectEqual(Mode.unspecified, modeFor("")); | |
| 109 | try testing.expectEqual(Mode.unspecified, modeFor("PORTRAIT")); | |
| 110 | try testing.expectEqual(Mode.unspecified, modeFor("portrait ")); | |
| 111 | try testing.expectEqual(Mode.unspecified, modeFor("upsideDown")); | |
| 112 | } | |
| 113 | ||
| 114 | test "unlocking is the same constant an unknown name reaches" { | |
| 115 | // `unlockOrientation` sets SCREEN_ORIENTATION_UNSPECIFIED, which is why | |
| 116 | // the typo above is not a harmless no-op: it actively unlocks. | |
| 117 | try testing.expectEqualStrings( | |
| 118 | "SCREEN_ORIENTATION_UNSPECIFIED", | |
| 119 | Mode.unspecified.field(), | |
| 120 | ); | |
| 121 | } | |
| 122 | ||
| 123 | test "each mode names a real ActivityInfo field" { | |
| 124 | // The names are what `GetStaticFieldID` looks up, so a typo is a | |
| 125 | // NoSuchFieldError at the call rather than a compile error here. | |
| 126 | try testing.expectEqualStrings("SCREEN_ORIENTATION_LANDSCAPE", Mode.landscape.field()); | |
| 127 | try testing.expectEqualStrings("SCREEN_ORIENTATION_PORTRAIT", Mode.portrait.field()); | |
| 128 | try testing.expectEqualStrings( | |
| 129 | "SCREEN_ORIENTATION_REVERSE_LANDSCAPE", | |
| 130 | Mode.reverse_landscape.field(), | |
| 131 | ); | |
| 132 | ||
| 133 | // All four are distinct, which a copy-paste in the switch above would | |
| 134 | // quietly break. | |
| 135 | const fields = [_][]const u8{ | |
| 136 | Mode.unspecified.field(), | |
| 137 | Mode.landscape.field(), | |
| 138 | Mode.portrait.field(), | |
| 139 | Mode.reverse_landscape.field(), | |
| 140 | }; | |
| 141 | for (fields, 0..) |a, i| { | |
| 142 | for (fields[i + 1 ..]) |b| try testing.expect(!std.mem.eql(u8, a, b)); | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | test "the actions match the shim exactly" { | |
| 147 | try testing.expectEqualStrings("lockOrientation", A.lock_orientation); | |
| 148 | try testing.expectEqualStrings("unlockOrientation", A.unlock_orientation); | |
| 149 | } | |
| @@ -53,6 +53,7 @@ const zig_sources = [_][]const u8{ | ||
| 53 | 53 | @embedFile("src/bridge_android_contacts.zig"), |
| 54 | 54 | @embedFile("src/bridge_android_widgets.zig"), |
| 55 | 55 | @embedFile("src/bridge_android_shortcuts.zig"), |
| 56 | @embedFile("src/bridge_android_orientation.zig"), | |
| 56 | 57 | }; |
| 57 | 58 | |
| 58 | 59 | /// How many spec actions Zig does not serve yet. |
| @@ -77,8 +78,10 @@ const zig_sources = [_][]const u8{ | ||
| 77 | 78 | /// deriving it here would be silently wrong under an applicationIdSuffix; 73 |
| 78 | 79 | /// with the shortcut pair, whose two halves disagree about what to do below |
| 79 | 80 | /// API 25 and are ported disagreeing; 72 with the immediate half of |
| 80 | /// scheduleNotification, whose delayed half needs a Runnable. | |
| 81 | const max_not_yet_migrated: usize = 72; | |
| 81 | /// scheduleNotification, whose delayed half needs a Runnable; 70 with the | |
| 82 | /// orientation pair, the first to run on the main looper — through a Runnable | |
| 83 | /// the Kotlin holder owns and a token that says what to do. | |
| 84 | const max_not_yet_migrated: usize = 70; | |
| 82 | 85 | |
| 83 | 86 | /// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge. |
| 84 | 87 | /// |