ReviewOS

also looking at this

craft-native/craft

feat(android): the motion pair, and org.json's numbers — ratchet 54 → 52

#187
Merged glennmichael123 wants to merge feat/android-motion into main
7 files +577 -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+7-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2866,6 +2866,11 @@ class CraftBridge(
28662866
28672867 @JavascriptInterface
28682868 fun startMotionUpdates(intervalMs: Int): Boolean {
2869 // The SensorEventListener object is CraftNative's, and so is the
2870 // last-value bookkeeping — which is why stopMotionUpdates has to reach
2871 // the same way, or its own fields stay null and nothing unregisters.
2872 if (CraftNative.startMotionUpdates(activity, intervalMs)) return true
2873
28692874 sensorManager = activity.getSystemService(Context.SENSOR_SERVICE) as SensorManager
28702875 accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
28712876 gyroscope = sensorManager?.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
@@ -2907,6 +2912,8 @@ class CraftBridge(
29072912
29082913 @JavascriptInterface
29092914 fun stopMotionUpdates() {
2915 if (CraftNative.stopMotionUpdates(activity)) return
2916
29102917 sensorListener?.let { sensorManager?.unregisterListener(it) }
29112918 sensorListener = null
29122919 }
packages/android/templates/CraftNative.kt.templatemodified+75-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -1,6 +1,10 @@
11package com.craft.runtime
22
33import android.app.Activity
4import android.hardware.SensorManager
5import android.hardware.SensorEventListener
6import android.hardware.SensorEvent
7import android.hardware.Sensor
48import androidx.core.app.ActivityCompat
59import android.os.Build
610import android.bluetooth.le.ScanResult
@@ -162,6 +166,9 @@ object CraftNative {
162166 private external fun nativeBluetoothDevice(address: String, name: String?, rssi: Int)
163167 private external fun nativeStartBluetoothScan(activity: Activity): Boolean
164168 private external fun nativeStopBluetoothScan(activity: Activity): Boolean
169 private external fun nativeMotionSample(isAccelerometer: Boolean, x: Float, y: Float, z: Float)
170 private external fun nativeStartMotionUpdates(activity: Activity, intervalMs: Int): Boolean
171 private external fun nativeStopMotionUpdates(activity: Activity): Boolean
165172
166173 /**
167174 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -1017,4 +1024,72 @@ object CraftNative {
10171024 false
10181025 }
10191026 }
1027
1028 // ==================== Motion sensors ====================
1029 //
1030 // The fourth listener shim. `SensorEventListener` is a Java interface, so
1031 // the object is here — but only the object: which sensor a sample came
1032 // from is passed across as a boolean and everything else, including the
1033 // last-value bookkeeping and the delay the interval maps to, is Zig's.
1034 //
1035 // `registerListener` without a Handler delivers on the main looper, so
1036 // every sample arrives on one thread and the state on the other side
1037 // needs no lock.
1038
1039 private var sensorManager: SensorManager? = null
1040 private var motionListener: SensorEventListener? = null
1041
1042 @JvmStatic
1043 fun startMotionWatch(activity: Activity, delay: Int) {
1044 val manager = activity.getSystemService(Context.SENSOR_SERVICE) as SensorManager
1045 sensorManager = manager
1046
1047 val accelerometer = manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
1048 val gyroscope = manager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
1049
1050 val listener = object : SensorEventListener {
1051 override fun onSensorChanged(event: SensorEvent) {
1052 try {
1053 nativeMotionSample(
1054 event.sensor.type == Sensor.TYPE_ACCELEROMETER,
1055 event.values[0],
1056 event.values[1],
1057 event.values[2]
1058 )
1059 } catch (e: UnsatisfiedLinkError) {
1060 // The library went away. Nothing is waiting on this.
1061 }
1062 }
1063
1064 override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
1065 }
1066
1067 accelerometer?.let { manager.registerListener(listener, it, delay) }
1068 gyroscope?.let { manager.registerListener(listener, it, delay) }
1069 motionListener = listener
1070 }
1071
1072 @JvmStatic
1073 fun stopMotionWatch(activity: Activity) {
1074 motionListener?.let { sensorManager?.unregisterListener(it) }
1075 motionListener = null
1076 }
1077
1078 fun startMotionUpdates(activity: Activity, intervalMs: Int): Boolean {
1079 if (!isAvailable) return false
1080 return try {
1081 nativeStartMotionUpdates(activity, intervalMs)
1082 } catch (e: UnsatisfiedLinkError) {
1083 false
1084 }
1085 }
1086
1087 fun stopMotionUpdates(activity: Activity): Boolean {
1088 if (!isAvailable) return false
1089 return try {
1090 nativeStopMotionUpdates(activity)
1091 } catch (e: UnsatisfiedLinkError) {
1092 false
1093 }
1094 }
10201095}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -488,6 +488,9 @@ pub fn build(b: *std.Build) void {
488488 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_bluetooth.zig", .{
489489 .root_source_file = b.path("src/bridge_android_bluetooth.zig"),
490490 });
491 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_motion.zig", .{
492 .root_source_file = b.path("src/bridge_android_motion.zig"),
493 });
491494 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
492495
493496 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+122-1
Changes to packages/zig/src/android_dispatch.zig
@@ -44,6 +44,8 @@ const intents = ;
4444const network = ;
4545const appstate = ;
4646const bluetooth = ;
47const motion = ;
48const json_number = ;
4749const securestore = ;
4850const haptics = ;
4951const notifications = ;
@@ -449,6 +451,22 @@ const natives = [_]jni.JNINativeMethod{
449451 .signature = "(Landroid/app/Activity;)Z",
450452 .fnPtr = @ptrCast(&nativeStopBluetoothScan),
451453 },
454 // Not an action: the other end of the SensorEventListener the holder owns.
455 .{
456 .name = "nativeMotionSample",
457 .signature = "(ZFFF)V",
458 .fnPtr = @ptrCast(&nativeMotionSample),
459 },
460 .{
461 .name = "nativeStartMotionUpdates",
462 .signature = "(Landroid/app/Activity;I)Z",
463 .fnPtr = @ptrCast(&nativeStartMotionUpdates),
464 },
465 .{
466 .name = "nativeStopMotionUpdates",
467 .signature = "(Landroid/app/Activity;)Z",
468 .fnPtr = @ptrCast(&nativeStopMotionUpdates),
469 },
452470};
453471
454472/// How binding went. A value rather than a log line, so every path is
@@ -1975,6 +1993,109 @@ fn nativeStopBluetoothScan(
19751993 return jni.JNI_TRUE;
19761994}
19771995
1996/// `nativeMotionSample(isAccelerometer, x, y, z)` — one sensor reading.
1997///
1998/// Every sample sends a complete event: the sensor that just reported and the
1999/// last value the other one gave, which is what the shim does and is why a
2000/// device with both sensors produces two events per cycle.
2001fn nativeMotionSample(
2002 env: jni.JNIEnv,
2003 _: jni.jobject,
2004 is_accelerometer: jni.jboolean,
2005 x: jni.jfloat,
2006 y: jni.jfloat,
2007 z: jni.jfloat,
2008) callconv(.c) void {
2009 const j = Jni.init(env);
2010 var arena = std.heap.ArenaAllocator.init(backing);
2011 defer arena.deinit();
2012 const allocator = arena.allocator();
2013
2014 motion.record(is_accelerometer != jni.JNI_FALSE, .{ x, y, z });
2015
2016 const acceleration = motion.lastAcceleration();
2017 const rotation = motion.lastRotation();
2018
2019 // Formatted by org.json, because these are the numbers a page reads and
2020 // `Float.toString` is not a format string — see `android_json_number`.
2021 var printed: [6][]const u8 = undefined;
2022 inline for (0..3) |i| {
2023 printed[i] = json_number.fromFloat(j, allocator, acceleration[i]) catch return;
2024 printed[i + 3] = json_number.fromFloat(j, allocator, rotation[i]) catch return;
2025 }
2026
2027 motion.announce(
2028 allocator,
2029 .{ printed[0], printed[1], printed[2] },
2030 .{ printed[3], printed[4], printed[5] },
2031 ) catch {};
2032}
2033
2034/// `nativeStartMotionUpdates(activity, intervalMs)`.
2035///
2036/// The delay constant is read here and handed across, so the shim's `when`
2037/// stays a Zig decision while the field it names stays the platform's number.
2038fn nativeStartMotionUpdates(
2039 env: jni.JNIEnv,
2040 _: jni.jobject,
2041 activity: jni.jobject,
2042 interval_ms: jni.jint,
2043) callconv(.c) jni.jboolean {
2044 const j = Jni.init(env);
2045
2046 const delay = sensorDelay(j, motion.delayFor(interval_ms)) catch |err| {
2047 std.log.warn("craft: startMotionUpdates fell through to the shim ({s})", .{@errorName(err)});
2048 return jni.JNI_FALSE;
2049 };
2050
2051 // The shim builds a new listener object, whose lastAccel and lastGyro
2052 // start null — so a restart forgets what the last one saw.
2053 motion.reset();
2054
2055 callHolderInt(j, "startMotionWatch", activity, delay) catch |err| {
2056 std.log.warn("craft: startMotionUpdates fell through to the shim ({s})", .{@errorName(err)});
2057 return jni.JNI_FALSE;
2058 };
2059 return jni.JNI_TRUE;
2060}
2061
2062/// `nativeStopMotionUpdates(activity)`.
2063fn nativeStopMotionUpdates(
2064 env: jni.JNIEnv,
2065 _: jni.jobject,
2066 activity: jni.jobject,
2067) callconv(.c) jni.jboolean {
2068 const j = Jni.init(env);
2069
2070 callHolderVoid(j, "stopMotionWatch", activity) catch |err| {
2071 std.log.warn("craft: stopMotionUpdates failed ({s})", .{@errorName(err)});
2072 return jni.JNI_FALSE;
2073 };
2074 return jni.JNI_TRUE;
2075}
2076
2077/// `SensorManager.<field>`.
2078fn sensorDelay(j: Jni, delay: motion.Delay) !jni.jint {
2079 try j.pushLocalFrame(4);
2080 defer _ = j.popLocalFrame(null);
2081
2082 const manager_cls = try j.findClass("android/hardware/SensorManager");
2083 return j.staticIntField(manager_cls, try j.staticFieldId(manager_cls, delay.field(), "I"));
2084}
2085
2086/// Call an `(Activity, int) -> void` static on the holder.
2087fn callHolderInt(j: Jni, comptime name: [:0]const u8, activity: jni.jobject, value: jni.jint) !void {
2088 try j.pushLocalFrame(8);
2089 defer _ = j.popLocalFrame(null);
2090
2091 const holder = try j.findClass(holder_class);
2092 try j.callStaticVoidMethodA(
2093 holder,
2094 try j.staticMethodId(holder, name, "(Landroid/app/Activity;I)V"),
2095 &.{ .{ .l = activity }, .{ .i = value } },
2096 );
2097}
2098
19782099// =============================================================================
19792100// Tests
19802101// =============================================================================
@@ -2086,7 +2207,7 @@ test "the registered natives name methods the Kotlin actually declares" {
20862207 // A descriptor is checked by the JVM at registration, so a wrong one fails
20872208 // at load rather than at call — but only if the *name* matches something.
20882209 // These two strings are the contract with CraftBridge.kt.
2089 try testing.expectEqual(@as(usize, 53), natives.len);
2210 try testing.expectEqual(@as(usize, 56), natives.len);
20902211 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
20912212
20922213 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/android_json_number.zigadded+98-0
Changes to packages/zig/src/android_json_number.zig
@@ -0,0 +1,98 @@
1//! Numbers, printed the way `org.json` prints them.
2//!
3//! Four migrated modules have declined to format a float, and the reason is
4//! always the same: `JSONObject` does not print numbers with a format string.
5//! Android's `numberToString` is
6//!
7//! ```java
8//! double doubleValue = number.doubleValue();
9//! JSON.checkDouble(doubleValue); // throws on NaN, ±Inf
10//! if (number.equals(NEGATIVE_ZERO)) return "-0";
11//! long longValue = number.longValue();
12//! if (doubleValue == (double) longValue) return Long.toString(longValue);
13//! return number.toString(); // Float or Double
14//! ```
15//!
16//! Three of those five lines are decisions a reimplementation would have to
17//! rediscover — an integral double prints as an integer, negative zero prints
18//! as `-0`, and the fallback is `Float.toString` or `Double.toString`
19//! depending on which box the number arrived in, which are *different*
20//! functions for the same value.
21//!
22//! So this asks Java. It is the same judgement `bridge_android_notifications`
23//! makes about `hashCode` and `bridge_android_files` about `Base64.decode`:
24//! where the platform already has the thing, ask it rather than rebuild it —
25//! and here rebuilding means reproducing the shortest-round-trip algorithm for
26//! two float widths, where being wrong is a silently different number rather
27//! than a failure.
28//!
29//! ## What it costs
30//!
31//! Three JNI calls per number: box, format, read back. `getCurrentPosition`
32//! makes seven of those once. A motion stream at 50 Hz makes six per event,
33//! which is 900 calls a second — real, and still far below what the sensor
34//! delivery itself costs.
35
36const std = ;
37const jni = ;
38
39const Jni = jni.Jni;
40
41/// `JSONObject.numberToString(Float.valueOf(value))`.
42pub fn fromFloat(j: Jni, allocator: std.mem.Allocator, value: f32) ![]u8 {
43 try j.pushLocalFrame(8);
44 defer _ = j.popLocalFrame(null);
45
46 const float_cls = try j.findClass("java/lang/Float");
47 const boxed = try j.callStaticObjectMethodA(
48 float_cls,
49 try j.staticMethodId(float_cls, "valueOf", "(F)Ljava/lang/Float;"),
50 &.{.{ .f = value }},
51 );
52 return numberToString(j, allocator, boxed);
53}
54
55/// `JSONObject.numberToString(Double.valueOf(value))`.
56///
57/// A separate function rather than a widened `fromFloat`, because the two do
58/// not agree: `Float.toString(0.1f)` is "0.1" and `Double.toString(0.1f)` is
59/// "0.10000000149011612". Which box a number arrives in is part of what the
60/// shim's output means.
61pub fn fromDouble(j: Jni, allocator: std.mem.Allocator, value: f64) ![]u8 {
62 try j.pushLocalFrame(8);
63 defer _ = j.popLocalFrame(null);
64
65 const double_cls = try j.findClass("java/lang/Double");
66 const boxed = try j.callStaticObjectMethodA(
67 double_cls,
68 try j.staticMethodId(double_cls, "valueOf", "(D)Ljava/lang/Double;"),
69 &.{.{ .d = value }},
70 );
71 return numberToString(j, allocator, boxed);
72}
73
74fn numberToString(j: Jni, allocator: std.mem.Allocator, boxed: jni.jobject) ![]u8 {
75 const json_cls = try j.findClass("org/json/JSONObject");
76 const text = try j.callStaticObjectMethodA(
77 json_cls,
78 try j.staticMethodId(
79 json_cls,
80 "numberToString",
81 "(Ljava/lang/Number;)Ljava/lang/String;",
82 ),
83 &.{.{ .l = boxed }},
84 );
85 return j.stringToUtf8(allocator, text);
86}
87
88// No tests, deliberately.
89//
90// Everything here is three JNI calls and a descriptor; there is no formatting
91// to check, because not formatting is the point. A host test would have to
92// stand up a fake `numberToString` and would then be asserting that a fake
93// agrees with itself.
94//
95// What *is* checked lives with the callers: `bridge_android_motion` tests the
96// shape of the object these numbers go into, with the formatted strings
97// injected, so the part this file does not own is tested and the part it does
98// own is Java's.
packages/zig/src/bridge_android_motion.zigadded+269-0
Changes to packages/zig/src/bridge_android_motion.zig
@@ -0,0 +1,269 @@
1//! `startMotionUpdates` and `stopMotionUpdates` on Android.
2//!
3//! The fourth listener shim, and the one that needed `android_json_number`
4//! first: the payload is six floats printed by `org.json`, which is why this
5//! pair was passed over three times.
6//!
7//! ## One event per sample, carrying both sensors
8//!
9//! The accelerometer and the gyroscope are registered separately and arrive
10//! separately, but every arrival sends *both* — the one that just changed and
11//! the last value the other reported. So a device with both sensors at
12//! `SENSOR_DELAY_GAME` produces two `craftMotionUpdate` events per cycle, each
13//! a complete reading.
14//!
15//! A sensor that has not reported yet reads as zeroes rather than being
16//! absent: `lastAccel ?: floatArrayOf(0f, 0f, 0f)`. So the first event after
17//! `startMotionUpdates` always claims one of the two is perfectly still, and a
18//! page cannot tell that from a device lying on a table.
19//!
20//! ## The state is per-registration
21//!
22//! The shim keeps `lastAccel` and `lastGyro` inside the listener object it
23//! creates in `startMotionUpdates`, so stopping and starting forgets them.
24//! Reproduced: `reset` runs on start.
25//!
26//! Both sensors deliver on the main looper — `registerListener` without a
27//! `Handler` uses it — so the samples arrive on one thread and the last-value
28//! state needs no lock. `stopMotionUpdates` does not touch it.
29
30const std = ;
31const events = ;
32
33pub const A = struct {
34 pub const start_motion_updates = "startMotionUpdates";
35 pub const stop_motion_updates = "stopMotionUpdates";
36};
37
38pub const event_name = "craftMotionUpdate";
39
40/// The `SensorManager` delay the shim's `when` picks.
41pub const Delay = enum {
42 fastest,
43 game,
44 ui,
45 normal,
46
47 /// The field on `android.hardware.SensorManager`.
48 pub fn field(self: Delay) [:0]const u8 {
49 return switch (self) {
50 .fastest => "SENSOR_DELAY_FASTEST",
51 .game => "SENSOR_DELAY_GAME",
52 .ui => "SENSOR_DELAY_UI",
53 .normal => "SENSOR_DELAY_NORMAL",
54 };
55 }
56};
57
58/// The shim's `when`, boundaries included.
59///
60/// Every comparison is `<=`, so 20 is fastest and 21 is game — and the
61/// boundaries are the part worth writing down, because "20ms" reading as
62/// `SENSOR_DELAY_GAME` would be a plausible off-by-one that nobody would
63/// notice except as a slightly wrong sample rate.
64pub fn delayFor(interval_ms: i32) Delay {
65 if (interval_ms <= 20) return .fastest;
66 if (interval_ms <= 60) return .game;
67 if (interval_ms <= 200) return .ui;
68 return .normal;
69}
70
71/// The last reading from each sensor, and whether it has reported at all.
72const Reading = struct {
73 values: [3]f32 = .{ 0, 0, 0 },
74 seen: bool = false,
75};
76
77var accelerometer: Reading = .{};
78var gyroscope: Reading = .{};
79
80/// Forget both sensors, as creating a new listener object does.
81pub fn reset() void {
82 accelerometer = .{};
83 gyroscope = .{};
84}
85
86/// Record a sample. `is_accelerometer` false means the gyroscope.
87///
88/// The shim's `when` has only those two arms and no else, so a sample from any
89/// other sensor updates nothing and still sends an event — which cannot happen
90/// today, because only those two are registered.
91pub fn record(is_accelerometer: bool, values: [3]f32) void {
92 const slot = if (is_accelerometer) &accelerometer else &gyroscope;
93 slot.values = values;
94 slot.seen = true;
95}
96
97pub fn lastAcceleration() [3]f32 {
98 return accelerometer.values;
99}
100
101pub fn lastRotation() [3]f32 {
102 return gyroscope.values;
103}
104
105/// `{"acceleration":{"x":…,"y":…,"z":…},"rotation":{"alpha":…,"beta":…,"gamma":…}}`
106///
107/// The numbers arrive already formatted, because formatting them is
108/// `org.json`'s job and not this file's — see `android_json_number`. What is
109/// this file's job is the shape: two nested objects, six keys, and the fact
110/// that the rotation's keys are Euler angle names rather than x/y/z.
111pub fn renderDetail(
112 allocator: std.mem.Allocator,
113 acceleration: [3][]const u8,
114 rotation: [3][]const u8,
115) ![]u8 {
116 var out: std.ArrayListUnmanaged(u8) = .empty;
117 errdefer out.deinit(allocator);
118
119 try out.appendSlice(allocator, "{\"acceleration\":{\"x\":");
120 try out.appendSlice(allocator, acceleration[0]);
121 try out.appendSlice(allocator, ",\"y\":");
122 try out.appendSlice(allocator, acceleration[1]);
123 try out.appendSlice(allocator, ",\"z\":");
124 try out.appendSlice(allocator, acceleration[2]);
125 try out.appendSlice(allocator, "},\"rotation\":{\"alpha\":");
126 try out.appendSlice(allocator, rotation[0]);
127 try out.appendSlice(allocator, ",\"beta\":");
128 try out.appendSlice(allocator, rotation[1]);
129 try out.appendSlice(allocator, ",\"gamma\":");
130 try out.appendSlice(allocator, rotation[2]);
131 try out.appendSlice(allocator, "}}");
132 return out.toOwnedSlice(allocator);
133}
134
135/// Send one reading as `craftMotionUpdate`.
136pub fn announce(
137 allocator: std.mem.Allocator,
138 acceleration: [3][]const u8,
139 rotation: [3][]const u8,
140) !void {
141 const detail = try renderDetail(allocator, acceleration, rotation);
142 defer allocator.free(detail);
143 try events.emitEvent(allocator, event_name, detail);
144}
145
146// =============================================================================
147// Tests
148// =============================================================================
149
150const testing = std.testing;
151
152test "every interval lands on the delay the shim's when picks" {
153 // The boundaries, which are all `<=`. An off-by-one here is a slightly
154 // wrong sample rate and nothing else — no error, no warning.
155 try testing.expectEqual(Delay.fastest, delayFor(0));
156 try testing.expectEqual(Delay.fastest, delayFor(20));
157 try testing.expectEqual(Delay.game, delayFor(21));
158 try testing.expectEqual(Delay.game, delayFor(60));
159 try testing.expectEqual(Delay.ui, delayFor(61));
160 try testing.expectEqual(Delay.ui, delayFor(200));
161 try testing.expectEqual(Delay.normal, delayFor(201));
162 try testing.expectEqual(Delay.normal, delayFor(100_000));
163
164 // A negative interval is fastest, because the first `<=` catches it. The
165 // injected JS sends `interval || 100`, so zero already arrives as 100 —
166 // but nothing stops a page calling the interface directly.
167 try testing.expectEqual(Delay.fastest, delayFor(-1));
168}
169
170test "each delay names a real SensorManager field" {
171 try testing.expectEqualStrings("SENSOR_DELAY_FASTEST", Delay.fastest.field());
172 try testing.expectEqualStrings("SENSOR_DELAY_GAME", Delay.game.field());
173 try testing.expectEqualStrings("SENSOR_DELAY_UI", Delay.ui.field());
174 try testing.expectEqualStrings("SENSOR_DELAY_NORMAL", Delay.normal.field());
175
176 // All four distinct, which a copy-paste in the switch would break without
177 // changing anything a compiler could see.
178 const fields = [_][]const u8{
179 Delay.fastest.field(), Delay.game.field(),
180 Delay.ui.field(), Delay.normal.field(),
181 };
182 for (fields, 0..) |a, i| {
183 for (fields[i + 1 ..]) |b| try testing.expect(!std.mem.eql(u8, a, b));
184 }
185}
186
187test "a sensor that has not reported reads as zero, not as absent" {
188 reset();
189 try testing.expectEqual([3]f32{ 0, 0, 0 }, lastAcceleration());
190 try testing.expectEqual([3]f32{ 0, 0, 0 }, lastRotation());
191
192 // And after one accelerometer sample the gyroscope is still zeroes — so
193 // the first event claims the device is not rotating, which a page cannot
194 // tell from a device lying still.
195 record(true, .{ 1, 2, 3 });
196 try testing.expectEqual([3]f32{ 1, 2, 3 }, lastAcceleration());
197 try testing.expectEqual([3]f32{ 0, 0, 0 }, lastRotation());
198 reset();
199}
200
201test "each sensor keeps its own last value" {
202 reset();
203 record(true, .{ 1, 2, 3 });
204 record(false, .{ 4, 5, 6 });
205 try testing.expectEqual([3]f32{ 1, 2, 3 }, lastAcceleration());
206 try testing.expectEqual([3]f32{ 4, 5, 6 }, lastRotation());
207
208 // A second accelerometer sample leaves the gyroscope alone, which is what
209 // makes every event a complete reading rather than a partial one.
210 record(true, .{ 7, 8, 9 });
211 try testing.expectEqual([3]f32{ 7, 8, 9 }, lastAcceleration());
212 try testing.expectEqual([3]f32{ 4, 5, 6 }, lastRotation());
213 reset();
214}
215
216test "starting again forgets what the last registration saw" {
217 // The shim's lastAccel/lastGyro live on the listener object, which
218 // startMotionUpdates creates fresh — so a stop and start does not carry
219 // stale readings across.
220 reset();
221 record(true, .{ 1, 2, 3 });
222 reset();
223 try testing.expectEqual([3]f32{ 0, 0, 0 }, lastAcceleration());
224}
225
226test "the detail nests two objects, and the rotation is not x y z" {
227 // The numbers are already formatted — that is org.json's job, not this
228 // file's — so this is about the shape: the rotation's keys are Euler
229 // angles, and swapping them for x/y/z would be an easy and invisible
230 // mistake.
231 const json = try renderDetail(
232 testing.allocator,
233 .{ "9.81", "0", "-0.5" },
234 .{ "0.01", "0", "-0" },
235 );
236 defer testing.allocator.free(json);
237
238 try testing.expectEqualStrings(
239 \\{"acceleration":{"x":9.81,"y":0,"z":-0.5},"rotation":{"alpha":0.01,"beta":0,"gamma":-0}}
240 , json);
241}
242
243test "the detail parses, including org.json's integral and negative-zero forms" {
244 // `numberToString` prints an integral float as an integer and negative
245 // zero as `-0`. Both are legal JSON numbers, and a page parsing this must
246 // not choke on them — which is worth checking, because they look wrong.
247 const json = try renderDetail(
248 testing.allocator,
249 .{ "0", "-0", "9" },
250 .{ "1.5", "-2", "0" },
251 );
252 defer testing.allocator.free(json);
253
254 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
255 defer parsed.deinit();
256
257 const acceleration = parsed.value.object.get("acceleration").?.object;
258 const rotation = parsed.value.object.get("rotation").?.object;
259 try testing.expectEqual(@as(usize, 3), acceleration.count());
260 try testing.expectEqual(@as(usize, 3), rotation.count());
261 try testing.expect(acceleration.get("x") != null);
262 try testing.expect(rotation.get("alpha") != null);
263}
264
265test "the actions and event name match the shim exactly" {
266 try testing.expectEqualStrings("startMotionUpdates", A.start_motion_updates);
267 try testing.expectEqualStrings("stopMotionUpdates", A.stop_motion_updates);
268 try testing.expectEqualStrings("craftMotionUpdate", event_name);
269}
packages/zig/test/android_conformance_test.zigmodified+3-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -58,6 +58,7 @@ const zig_sources = [_][]const u8{
5858 @embedFile("src/bridge_android_locationstore.zig"),
5959 @embedFile("src/bridge_android_appstate.zig"),
6060 @embedFile("src/bridge_android_bluetooth.zig"),
61 @embedFile("src/bridge_android_motion.zig"),
6162};
6263
6364/// How many spec actions Zig does not serve yet.
@@ -93,8 +94,8 @@ const zig_sources = [_][]const u8{
9394/// first listener shim — the callback object is the holder's, the work is
9495/// Zig's; 56 with the app-state trio, the first migration where the state
9596/// itself moved rather than being worked around; 54 with the Bluetooth scan
96/// pair.
97const max_not_yet_migrated: usize = 54;
97/// pair; 52 with the motion pair, once org.json agreed to print the floats.
98const max_not_yet_migrated: usize = 52;
9899
99100/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
100101///