also looking at this
feat(android): the motion pair, and org.json's numbers — ratchet 54 → 52
#187
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.
| @@ -2866,6 +2866,11 @@ class CraftBridge( | ||
| 2866 | 2866 | |
| 2867 | 2867 | @JavascriptInterface |
| 2868 | 2868 | 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 | ||
| 2869 | 2874 | sensorManager = activity.getSystemService(Context.SENSOR_SERVICE) as SensorManager |
| 2870 | 2875 | accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) |
| 2871 | 2876 | gyroscope = sensorManager?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) |
| @@ -2907,6 +2912,8 @@ class CraftBridge( | ||
| 2907 | 2912 | |
| 2908 | 2913 | @JavascriptInterface |
| 2909 | 2914 | fun stopMotionUpdates() { |
| 2915 | if (CraftNative.stopMotionUpdates(activity)) return | |
| 2916 | ||
| 2910 | 2917 | sensorListener?.let { sensorManager?.unregisterListener(it) } |
| 2911 | 2918 | sensorListener = null |
| 2912 | 2919 | } |
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | package com.craft.runtime |
| 2 | 2 | |
| 3 | 3 | import android.app.Activity |
| 4 | import android.hardware.SensorManager | |
| 5 | import android.hardware.SensorEventListener | |
| 6 | import android.hardware.SensorEvent | |
| 7 | import android.hardware.Sensor | |
| 4 | 8 | import androidx.core.app.ActivityCompat |
| 5 | 9 | import android.os.Build |
| 6 | 10 | import android.bluetooth.le.ScanResult |
| @@ -162,6 +166,9 @@ object CraftNative { | ||
| 162 | 166 | private external fun nativeBluetoothDevice(address: String, name: String?, rssi: Int) |
| 163 | 167 | private external fun nativeStartBluetoothScan(activity: Activity): Boolean |
| 164 | 168 | 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 | |
| 165 | 172 | |
| 166 | 173 | /** |
| 167 | 174 | * `getDeviceInfo`, or null to use the Kotlin implementation. |
| @@ -1017,4 +1024,72 @@ object CraftNative { | ||
| 1017 | 1024 | false |
| 1018 | 1025 | } |
| 1019 | 1026 | } |
| 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 | } | |
| 1020 | 1095 | } |
| @@ -488,6 +488,9 @@ pub fn build(b: *std.Build) void { | ||
| 488 | 488 | android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_bluetooth.zig", .{ |
| 489 | 489 | .root_source_file = b.path("src/bridge_android_bluetooth.zig"), |
| 490 | 490 | }); |
| 491 | android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_motion.zig", .{ | |
| 492 | .root_source_file = b.path("src/bridge_android_motion.zig"), | |
| 493 | }); | |
| 491 | 494 | const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests); |
| 492 | 495 | |
| 493 | 496 | // The page surface, across both bridges. Not folded into the iOS |
| @@ -44,6 +44,8 @@ const intents = ; | ||
| 44 | 44 | const network = ; |
| 45 | 45 | const appstate = ; |
| 46 | 46 | const bluetooth = ; |
| 47 | const motion = ; | |
| 48 | const json_number = ; | |
| 47 | 49 | const securestore = ; |
| 48 | 50 | const haptics = ; |
| 49 | 51 | const notifications = ; |
| @@ -449,6 +451,22 @@ const natives = [_]jni.JNINativeMethod{ | ||
| 449 | 451 | .signature = "(Landroid/app/Activity;)Z", |
| 450 | 452 | .fnPtr = @ptrCast(&nativeStopBluetoothScan), |
| 451 | 453 | }, |
| 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 | }, | |
| 452 | 470 | }; |
| 453 | 471 | |
| 454 | 472 | /// How binding went. A value rather than a log line, so every path is |
| @@ -1975,6 +1993,109 @@ fn nativeStopBluetoothScan( | ||
| 1975 | 1993 | return jni.JNI_TRUE; |
| 1976 | 1994 | } |
| 1977 | 1995 | |
| 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. | |
| 2001 | fn 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. | |
| 2038 | fn 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)`. | |
| 2063 | fn 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>`. | |
| 2078 | fn 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. | |
| 2087 | fn 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 | ||
| 1978 | 2099 | // ============================================================================= |
| 1979 | 2100 | // Tests |
| 1980 | 2101 | // ============================================================================= |
| @@ -2086,7 +2207,7 @@ test "the registered natives name methods the Kotlin actually declares" { | ||
| 2086 | 2207 | // A descriptor is checked by the JVM at registration, so a wrong one fails |
| 2087 | 2208 | // at load rather than at call — but only if the *name* matches something. |
| 2088 | 2209 | // 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); | |
| 2090 | 2211 | try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name)); |
| 2091 | 2212 | |
| 2092 | 2213 | // And the class they bind to is the fixed one, not the templated bridge. |
| @@ -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 | ||
| 36 | const std = ; | |
| 37 | const jni = ; | |
| 38 | ||
| 39 | const Jni = jni.Jni; | |
| 40 | ||
| 41 | /// `JSONObject.numberToString(Float.valueOf(value))`. | |
| 42 | pub 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. | |
| 61 | pub 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 | ||
| 74 | fn 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. | |
| @@ -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 | } | |
| @@ -58,6 +58,7 @@ const zig_sources = [_][]const u8{ | ||
| 58 | 58 | @embedFile("src/bridge_android_locationstore.zig"), |
| 59 | 59 | @embedFile("src/bridge_android_appstate.zig"), |
| 60 | 60 | @embedFile("src/bridge_android_bluetooth.zig"), |
| 61 | @embedFile("src/bridge_android_motion.zig"), | |
| 61 | 62 | }; |
| 62 | 63 | |
| 63 | 64 | /// How many spec actions Zig does not serve yet. |
| @@ -93,8 +94,8 @@ const zig_sources = [_][]const u8{ | ||
| 93 | 94 | /// first listener shim — the callback object is the holder's, the work is |
| 94 | 95 | /// Zig's; 56 with the app-state trio, the first migration where the state |
| 95 | 96 | /// itself moved rather than being worked around; 54 with the Bluetooth scan |
| 96 | /// pair. | |
| 97 | const max_not_yet_migrated: usize = 54; | |
| 97 | /// pair; 52 with the motion pair, once org.json agreed to print the floats. | |
| 98 | const max_not_yet_migrated: usize = 52; | |
| 98 | 99 | |
| 99 | 100 | /// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge. |
| 100 | 101 | /// |