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
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}