also looking at this
feat(android): the motion pair, and org.json's numbers — ratchet 54 → 52
#187
7 files
+577
-3
| @@ -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 | ||
| 30 | const std = ; | |
| 31 | const events = ; | |
| 32 | ||
| 33 | pub const A = struct { | |
| 34 | pub const start_motion_updates = "startMotionUpdates"; | |
| 35 | pub const stop_motion_updates = "stopMotionUpdates"; | |
| 36 | }; | |
| 37 | ||
| 38 | pub const event_name = "craftMotionUpdate"; | |
| 39 | ||
| 40 | /// The `SensorManager` delay the shim's `when` picks. | |
| 41 | pub 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. | |
| 64 | pub 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. | |
| 72 | const Reading = struct { | |
| 73 | values: [3]f32 = .{ 0, 0, 0 }, | |
| 74 | seen: bool = false, | |
| 75 | }; | |
| 76 | ||
| 77 | var accelerometer: Reading = .{}; | |
| 78 | var gyroscope: Reading = .{}; | |
| 79 | ||
| 80 | /// Forget both sensors, as creating a new listener object does. | |
| 81 | pub 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. | |
| 91 | pub 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 | ||
| 97 | pub fn lastAcceleration() [3]f32 { | |
| 98 | return accelerometer.values; | |
| 99 | } | |
| 100 | ||
| 101 | pub 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. | |
| 111 | pub 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`. | |
| 136 | pub 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 | ||
| 150 | const testing = std.testing; | |
| 151 | ||
| 152 | test "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 | ||
| 170 | test "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 | ||
| 187 | test "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 | ||
| 201 | test "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 | ||
| 216 | test "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 | ||
| 226 | test "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 | ||
| 243 | test "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 | ||
| 265 | test "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 | } | |