ReviewOS

also looking at this

craft-native/craft

feat(android): a main-thread trampoline, and the orientation pair to prove it — ratchet 72 → 70

#173
Merged glennmichael123 wants to merge feat/android-main-thread into main
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.

packages/android/templates/CraftBridge.kt.templatemodified+6-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2592,6 +2592,10 @@ class CraftBridge(
25922592
25932593 @JavascriptInterface
25942594 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
25952599 activity.runOnUiThread {
25962600 activity.requestedOrientation = when (orientation) {
25972601 "portrait" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
@@ -2606,6 +2610,8 @@ class CraftBridge(
26062610
26072611 @JavascriptInterface
26082612 fun unlockOrientation(): Boolean {
2613 if (CraftNative.unlockOrientation(activity)) return true
2614
26092615 activity.runOnUiThread {
26102616 activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
26112617 }
packages/android/templates/CraftNative.kt.templatemodified+64-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -1,6 +1,8 @@
11package com.craft.runtime
22
33import android.app.Activity
4import android.os.Handler
5import android.os.Looper
46import android.content.SharedPreferences
57import android.database.sqlite.SQLiteDatabase
68import org.json.JSONObject
@@ -123,6 +125,9 @@ object CraftNative {
123125 private external fun nativeSetShortcuts(activity: Activity, shortcutsJson: String): Boolean
124126 private external fun nativeClearShortcuts(activity: Activity): Boolean
125127 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
126131
127132 /**
128133 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -555,4 +560,63 @@ object CraftNative {
555560 false
556561 }
557562 }
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 }
558622}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -473,6 +473,9 @@ pub fn build(b: *std.Build) void {
473473 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_shortcuts.zig", .{
474474 .root_source_file = b.path("src/bridge_android_shortcuts.zig"),
475475 });
476 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_orientation.zig", .{
477 .root_source_file = b.path("src/bridge_android_orientation.zig"),
478 });
476479 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
477480
478481 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+76-1
Changes to packages/zig/src/android_dispatch.zig
@@ -52,6 +52,8 @@ const shareditem = ;
5252const contacts = ;
5353const widgets = ;
5454const shortcuts = ;
55const orientation = ;
56const main_thread = ;
5557
5658const Jni = jni.Jni;
5759
@@ -323,6 +325,23 @@ const natives = [_]jni.JNINativeMethod{
323325 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
324326 .fnPtr = @ptrCast(&nativeScheduleNotification),
325327 },
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 },
326345};
327346
328347/// How binding went. A value rather than a log line, so every path is
@@ -1301,6 +1320,62 @@ fn nativeScheduleNotification(
13011320 return jni.JNI_TRUE;
13021321}
13031322
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.
1332fn 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.
1345fn 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)`.
1365fn 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
13041379// =============================================================================
13051380// Tests
13061381// =============================================================================
@@ -1412,7 +1487,7 @@ test "the registered natives name methods the Kotlin actually declares" {
14121487 // A descriptor is checked by the JVM at registration, so a wrong one fails
14131488 // at load rather than at call — but only if the *name* matches something.
14141489 // 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);
14161491 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
14171492
14181493 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/android_main_thread.zigadded+265-0
Changes to packages/zig/src/android_main_thread.zig
@@ -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
36const std = ;
37const jni = ;
38
39const Jni = jni.Jni;
40const 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.
48pub const Task = union(enum) {
49 /// `activity.setRequestedOrientation(mode)`.
50 set_requested_orientation: i32,
51};
52
53const slot_count = 32;
54
55const Slot = struct {
56 task: Task = undefined,
57 generation: u32 = 0,
58 occupied: bool = false,
59};
60
61var 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.
72var table_locked: std.atomic.Value(bool) = .init(false);
73
74fn lockTable() void {
75 while (table_locked.cmpxchgWeak(false, true, .acquire, .monotonic) != null) {
76 std.atomic.spinLoopHint();
77 }
78}
79
80fn unlockTable() void {
81 table_locked.store(false, .release);
82}
83
84pub 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.
90pub 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.
107pub 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.
133pub 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.
143pub 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.
156const holder_class = "com/craft/runtime/CraftNative";
157
158/// Run the task a token names. Called from `nativeRunTask`, on the main thread.
159pub 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
166fn 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
181const testing = std.testing;
182
183fn clearTable() void {
184 lockTable();
185 defer unlockTable();
186 for (&slots) |*slot| slot.occupied = false;
187}
188
189test "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
201test "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
216test "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
227test "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
246test "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
260test "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}
packages/zig/src/bridge_android_orientation.zigadded+149-0
Changes to packages/zig/src/bridge_android_orientation.zig
@@ -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
34const std = ;
35const jni = ;
36const main_thread = ;
37
38const Jni = jni.Jni;
39const jobject = jni.jobject;
40
41pub 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.
47pub 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`.
65pub 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>`.
74pub 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.
83pub 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
92const testing = std.testing;
93
94test "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
104test "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
114test "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
123test "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
146test "the actions match the shim exactly" {
147 try testing.expectEqualStrings("lockOrientation", A.lock_orientation);
148 try testing.expectEqualStrings("unlockOrientation", A.unlock_orientation);
149}
packages/zig/test/android_conformance_test.zigmodified+5-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -53,6 +53,7 @@ const zig_sources = [_][]const u8{
5353 @embedFile("src/bridge_android_contacts.zig"),
5454 @embedFile("src/bridge_android_widgets.zig"),
5555 @embedFile("src/bridge_android_shortcuts.zig"),
56 @embedFile("src/bridge_android_orientation.zig"),
5657};
5758
5859/// How many spec actions Zig does not serve yet.
@@ -77,8 +78,10 @@ const zig_sources = [_][]const u8{
7778/// deriving it here would be silently wrong under an applicationIdSuffix; 73
7879/// with the shortcut pair, whose two halves disagree about what to do below
7980/// API 25 and are ported disagreeing; 72 with the immediate half of
80/// scheduleNotification, whose delayed half needs a Runnable.
81const 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.
84const max_not_yet_migrated: usize = 70;
8285
8386/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
8487///