also looking at this
feat(android): getCurrentPosition in Zig — ratchet 52 → 51
#188
6 files
+466
-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.
| @@ -1633,6 +1633,12 @@ class CraftBridge( | ||
| 1633 | 1633 | |
| 1634 | 1634 | @JavascriptInterface |
| 1635 | 1635 | fun getCurrentPosition(optionsJson: String) { |
| 1636 | // The task listeners and the fresh-location callback are | |
| 1637 | // CraftNative's, because they are Java interfaces. fusedLocationClient | |
| 1638 | // below stays null, which is safe: watchPosition assigns it before | |
| 1639 | // every use, so clearWatch is unaffected. | |
| 1640 | if (CraftNative.getCurrentPosition(activity)) return | |
| 1641 | ||
| 1636 | 1642 | if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) |
| 1637 | 1643 | != PackageManager.PERMISSION_GRANTED) { |
| 1638 | 1644 | ActivityCompat.requestPermissions( |
| @@ -1,6 +1,14 @@ | ||
| 1 | 1 | package com.craft.runtime |
| 2 | 2 | |
| 3 | 3 | import android.app.Activity |
| 4 | import com.google.android.gms.location.Priority | |
| 5 | import com.google.android.gms.location.LocationServices | |
| 6 | import com.google.android.gms.location.LocationResult | |
| 7 | import com.google.android.gms.location.LocationRequest | |
| 8 | import com.google.android.gms.location.LocationCallback | |
| 9 | import com.google.android.gms.location.FusedLocationProviderClient | |
| 10 | import android.location.Location | |
| 11 | import android.annotation.SuppressLint | |
| 4 | 12 | import android.hardware.SensorManager |
| 5 | 13 | import android.hardware.SensorEventListener |
| 6 | 14 | import android.hardware.SensorEvent |
| @@ -169,6 +177,17 @@ object CraftNative { | ||
| 169 | 177 | private external fun nativeMotionSample(isAccelerometer: Boolean, x: Float, y: Float, z: Float) |
| 170 | 178 | private external fun nativeStartMotionUpdates(activity: Activity, intervalMs: Int): Boolean |
| 171 | 179 | private external fun nativeStopMotionUpdates(activity: Activity): Boolean |
| 180 | private external fun nativeLocationResult( | |
| 181 | latitude: Double, | |
| 182 | longitude: Double, | |
| 183 | accuracy: Double, | |
| 184 | altitude: Double, | |
| 185 | speed: Double, | |
| 186 | bearing: Double, | |
| 187 | time: Long | |
| 188 | ) | |
| 189 | private external fun nativeLocationFailed(message: String?) | |
| 190 | private external fun nativeGetCurrentPosition(activity: Activity): Boolean | |
| 172 | 191 | |
| 173 | 192 | /** |
| 174 | 193 | * `getDeviceInfo`, or null to use the Kotlin implementation. |
| @@ -1092,4 +1111,109 @@ object CraftNative { | ||
| 1092 | 1111 | false |
| 1093 | 1112 | } |
| 1094 | 1113 | } |
| 1114 | ||
| 1115 | // ==================== Current position ==================== | |
| 1116 | // | |
| 1117 | // The fifth listener shim, and the first with two Java interfaces in one | |
| 1118 | // flow: `OnSuccessListener` and `OnFailureListener` on the Play Services | |
| 1119 | // task, plus a `LocationCallback` for the fresh-location fallback. | |
| 1120 | // | |
| 1121 | // Every value crosses as a Double, including the three the framework types | |
| 1122 | // as Float. That is not a convenience: `CraftBridge` writes them with | |
| 1123 | // `put(name, value)`, and the overload Kotlin picks there is | |
| 1124 | // `put(String, double)` — primitive widening beats boxing — so they print | |
| 1125 | // as doubles. Passing them as Float and boxing them on the other side | |
| 1126 | // would print `0.1` where the shim prints `0.10000000149011612`. | |
| 1127 | ||
| 1128 | @JvmStatic | |
| 1129 | fun requestLocationPermissions(activity: Activity) { | |
| 1130 | ActivityCompat.requestPermissions( | |
| 1131 | activity, | |
| 1132 | arrayOf( | |
| 1133 | android.Manifest.permission.ACCESS_FINE_LOCATION, | |
| 1134 | android.Manifest.permission.ACCESS_COARSE_LOCATION | |
| 1135 | ), | |
| 1136 | REQUEST_LOCATION | |
| 1137 | ) | |
| 1138 | } | |
| 1139 | ||
| 1140 | /** `CraftBridge.REQUEST_LOCATION`, which this has to match. */ | |
| 1141 | private const val REQUEST_LOCATION = 1003 | |
| 1142 | ||
| 1143 | @JvmStatic | |
| 1144 | @SuppressLint("MissingPermission") | |
| 1145 | fun requestCurrentPosition(activity: Activity) { | |
| 1146 | val client = LocationServices.getFusedLocationProviderClient(activity) | |
| 1147 | client.lastLocation | |
| 1148 | .addOnSuccessListener { location: Location? -> | |
| 1149 | if (location != null) { | |
| 1150 | deliverLocation(location) | |
| 1151 | } else { | |
| 1152 | requestFreshPosition(activity, client) | |
| 1153 | } | |
| 1154 | } | |
| 1155 | .addOnFailureListener { e -> failLocation(e.message) } | |
| 1156 | } | |
| 1157 | ||
| 1158 | /** | |
| 1159 | * The fallback when there is no cached fix. | |
| 1160 | * | |
| 1161 | * One update, then the callback removes itself — as `CraftBridge`'s does. | |
| 1162 | * If it never fires, nothing settles the page's promise, here or there. | |
| 1163 | */ | |
| 1164 | @SuppressLint("MissingPermission") | |
| 1165 | private fun requestFreshPosition( | |
| 1166 | activity: Activity, | |
| 1167 | client: FusedLocationProviderClient | |
| 1168 | ) { | |
| 1169 | val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000) | |
| 1170 | .setWaitForAccurateLocation(false) | |
| 1171 | .setMinUpdateIntervalMillis(500) | |
| 1172 | .setMaxUpdateDelayMillis(1000) | |
| 1173 | .setMaxUpdates(1) | |
| 1174 | .build() | |
| 1175 | ||
| 1176 | client.requestLocationUpdates( | |
| 1177 | request, | |
| 1178 | object : LocationCallback() { | |
| 1179 | override fun onLocationResult(result: LocationResult) { | |
| 1180 | client.removeLocationUpdates(this) | |
| 1181 | result.lastLocation?.let { deliverLocation(it) } | |
| 1182 | } | |
| 1183 | }, | |
| 1184 | activity.mainLooper | |
| 1185 | ) | |
| 1186 | } | |
| 1187 | ||
| 1188 | private fun deliverLocation(location: Location) { | |
| 1189 | try { | |
| 1190 | nativeLocationResult( | |
| 1191 | location.latitude, | |
| 1192 | location.longitude, | |
| 1193 | location.accuracy.toDouble(), | |
| 1194 | location.altitude, | |
| 1195 | location.speed.toDouble(), | |
| 1196 | location.bearing.toDouble(), | |
| 1197 | location.time | |
| 1198 | ) | |
| 1199 | } catch (e: UnsatisfiedLinkError) { | |
| 1200 | // The library went away mid-flight. Nothing to answer to. | |
| 1201 | } | |
| 1202 | } | |
| 1203 | ||
| 1204 | private fun failLocation(message: String?) { | |
| 1205 | try { | |
| 1206 | nativeLocationFailed(message) | |
| 1207 | } catch (e: UnsatisfiedLinkError) { | |
| 1208 | } | |
| 1209 | } | |
| 1210 | ||
| 1211 | fun getCurrentPosition(activity: Activity): Boolean { | |
| 1212 | if (!isAvailable) return false | |
| 1213 | return try { | |
| 1214 | nativeGetCurrentPosition(activity) | |
| 1215 | } catch (e: UnsatisfiedLinkError) { | |
| 1216 | false | |
| 1217 | } | |
| 1218 | } | |
| 1095 | 1219 | } |
| @@ -491,6 +491,9 @@ pub fn build(b: *std.Build) void { | ||
| 491 | 491 | android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_motion.zig", .{ |
| 492 | 492 | .root_source_file = b.path("src/bridge_android_motion.zig"), |
| 493 | 493 | }); |
| 494 | android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_position.zig", .{ | |
| 495 | .root_source_file = b.path("src/bridge_android_position.zig"), | |
| 496 | }); | |
| 494 | 497 | const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests); |
| 495 | 498 | |
| 496 | 499 | // The page surface, across both bridges. Not folded into the iOS |
| @@ -45,6 +45,7 @@ const network = ; | ||
| 45 | 45 | const appstate = ; |
| 46 | 46 | const bluetooth = ; |
| 47 | 47 | const motion = ; |
| 48 | const position = ; | |
| 48 | 49 | const json_number = ; |
| 49 | 50 | const securestore = ; |
| 50 | 51 | const haptics = ; |
| @@ -467,6 +468,23 @@ const natives = [_]jni.JNINativeMethod{ | ||
| 467 | 468 | .signature = "(Landroid/app/Activity;)Z", |
| 468 | 469 | .fnPtr = @ptrCast(&nativeStopMotionUpdates), |
| 469 | 470 | }, |
| 471 | // Not actions: the two ends of the Play Services task listeners the | |
| 472 | // holder owns. | |
| 473 | .{ | |
| 474 | .name = "nativeLocationResult", | |
| 475 | .signature = "(DDDDDDJ)V", | |
| 476 | .fnPtr = @ptrCast(&nativeLocationResult), | |
| 477 | }, | |
| 478 | .{ | |
| 479 | .name = "nativeLocationFailed", | |
| 480 | .signature = "(Ljava/lang/String;)V", | |
| 481 | .fnPtr = @ptrCast(&nativeLocationFailed), | |
| 482 | }, | |
| 483 | .{ | |
| 484 | .name = "nativeGetCurrentPosition", | |
| 485 | .signature = "(Landroid/app/Activity;)Z", | |
| 486 | .fnPtr = @ptrCast(&nativeGetCurrentPosition), | |
| 487 | }, | |
| 470 | 488 | }; |
| 471 | 489 | |
| 472 | 490 | /// How binding went. A value rather than a log line, so every path is |
| @@ -2096,6 +2114,109 @@ fn callHolderInt(j: Jni, comptime name: [:0]const u8, activity: jni.jobject, val | ||
| 2096 | 2114 | ); |
| 2097 | 2115 | } |
| 2098 | 2116 | |
| 2117 | /// `nativeLocationResult(...)` — a fix, from either the cached or fresh path. | |
| 2118 | /// | |
| 2119 | /// Every value arrives as a `double`, including the three the framework types | |
| 2120 | /// as `float`: the shim writes them with `put(name, value)`, and the overload | |
| 2121 | /// Kotlin picks is `put(String, double)` because widening beats boxing. So | |
| 2122 | /// they print as doubles, and the holder widens them for the same reason. | |
| 2123 | fn nativeLocationResult( | |
| 2124 | env: jni.JNIEnv, | |
| 2125 | _: jni.jobject, | |
| 2126 | latitude: jni.jdouble, | |
| 2127 | longitude: jni.jdouble, | |
| 2128 | accuracy: jni.jdouble, | |
| 2129 | altitude: jni.jdouble, | |
| 2130 | speed: jni.jdouble, | |
| 2131 | bearing: jni.jdouble, | |
| 2132 | time: jni.jlong, | |
| 2133 | ) callconv(.c) void { | |
| 2134 | const j = Jni.init(env); | |
| 2135 | var arena = std.heap.ArenaAllocator.init(backing); | |
| 2136 | defer arena.deinit(); | |
| 2137 | const allocator = arena.allocator(); | |
| 2138 | ||
| 2139 | var timestamp: [24]u8 = undefined; | |
| 2140 | const printed: position.Printed = .{ | |
| 2141 | .latitude = json_number.fromDouble(j, allocator, latitude) catch return, | |
| 2142 | .longitude = json_number.fromDouble(j, allocator, longitude) catch return, | |
| 2143 | .accuracy = json_number.fromDouble(j, allocator, accuracy) catch return, | |
| 2144 | .altitude = json_number.fromDouble(j, allocator, altitude) catch return, | |
| 2145 | .speed = json_number.fromDouble(j, allocator, speed) catch return, | |
| 2146 | .heading = json_number.fromDouble(j, allocator, bearing) catch return, | |
| 2147 | // A long, not a double: `put(String, long)` boxes a Long, and | |
| 2148 | // `numberToString` prints one with `Long.toString` — which needs no | |
| 2149 | // JNI call, because there is no float format to reproduce. | |
| 2150 | .timestamp = std.fmt.bufPrint(×tamp, "{d}", .{time}) catch return, | |
| 2151 | }; | |
| 2152 | ||
| 2153 | const json = position.renderPosition(allocator, printed) catch return; | |
| 2154 | events.settle(allocator, position.resolve_global, json) catch {}; | |
| 2155 | } | |
| 2156 | ||
| 2157 | /// `nativeLocationFailed(message)` — the failure listener. | |
| 2158 | fn nativeLocationFailed( | |
| 2159 | env: jni.JNIEnv, | |
| 2160 | _: jni.jobject, | |
| 2161 | message: jni.jstring, | |
| 2162 | ) callconv(.c) void { | |
| 2163 | const j = Jni.init(env); | |
| 2164 | var arena = std.heap.ArenaAllocator.init(backing); | |
| 2165 | defer arena.deinit(); | |
| 2166 | const allocator = arena.allocator(); | |
| 2167 | ||
| 2168 | // `${jsQuote(e.message)}` — a null message reaches the page as the four | |
| 2169 | // characters "null", which is what `Any?.toString()` produced there. | |
| 2170 | const text: []const u8 = if (message == null) | |
| 2171 | "null" | |
| 2172 | else | |
| 2173 | j.stringToUtf8(allocator, message) catch return; | |
| 2174 | ||
| 2175 | const json = position.renderRejection(allocator, position.code_client_failure, text) catch return; | |
| 2176 | events.settle(allocator, position.reject_global, json) catch {}; | |
| 2177 | } | |
| 2178 | ||
| 2179 | /// `nativeGetCurrentPosition(activity)`. | |
| 2180 | fn nativeGetCurrentPosition( | |
| 2181 | env: jni.JNIEnv, | |
| 2182 | _: jni.jobject, | |
| 2183 | activity: jni.jobject, | |
| 2184 | ) callconv(.c) jni.jboolean { | |
| 2185 | const j = Jni.init(env); | |
| 2186 | var arena = std.heap.ArenaAllocator.init(backing); | |
| 2187 | defer arena.deinit(); | |
| 2188 | const allocator = arena.allocator(); | |
| 2189 | ||
| 2190 | const granted = permissions.isGranted(j, activity, permissions.access_fine_location) catch |err| { | |
| 2191 | std.log.warn("craft: getCurrentPosition fell through to the shim ({s})", .{@errorName(err)}); | |
| 2192 | return jni.JNI_FALSE; | |
| 2193 | }; | |
| 2194 | ||
| 2195 | if (!granted) { | |
| 2196 | // The shim asks for both the fine and the coarse permission, so the | |
| 2197 | // holder does too — a request for one where the shim asked for two | |
| 2198 | // would leave a page permanently unable to get a coarse fix. | |
| 2199 | callHolderVoid(j, "requestLocationPermissions", activity) catch |err| { | |
| 2200 | std.log.warn("craft: getCurrentPosition fell through to the shim ({s})", .{@errorName(err)}); | |
| 2201 | return jni.JNI_FALSE; | |
| 2202 | }; | |
| 2203 | ||
| 2204 | const json = position.renderRejection( | |
| 2205 | allocator, | |
| 2206 | position.code_permission_denied, | |
| 2207 | position.permission_denied_message, | |
| 2208 | ) catch return jni.JNI_FALSE; | |
| 2209 | events.settle(allocator, position.reject_global, json) catch return jni.JNI_FALSE; | |
| 2210 | return jni.JNI_TRUE; | |
| 2211 | } | |
| 2212 | ||
| 2213 | callHolderVoid(j, "requestCurrentPosition", activity) catch |err| { | |
| 2214 | std.log.warn("craft: getCurrentPosition fell through to the shim ({s})", .{@errorName(err)}); | |
| 2215 | return jni.JNI_FALSE; | |
| 2216 | }; | |
| 2217 | return jni.JNI_TRUE; | |
| 2218 | } | |
| 2219 | ||
| 2099 | 2220 | // ============================================================================= |
| 2100 | 2221 | // Tests |
| 2101 | 2222 | // ============================================================================= |
| @@ -2207,7 +2328,7 @@ test "the registered natives name methods the Kotlin actually declares" { | ||
| 2207 | 2328 | // A descriptor is checked by the JVM at registration, so a wrong one fails |
| 2208 | 2329 | // at load rather than at call — but only if the *name* matches something. |
| 2209 | 2330 | // These two strings are the contract with CraftBridge.kt. |
| 2210 | try testing.expectEqual(@as(usize, 56), natives.len); | |
| 2331 | try testing.expectEqual(@as(usize, 59), natives.len); | |
| 2211 | 2332 | try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name)); |
| 2212 | 2333 | |
| 2213 | 2334 | // And the class they bind to is the fixed one, not the templated bridge. |
| @@ -0,0 +1,207 @@ | ||
| 1 | //! `getCurrentPosition` on Android. | |
| 2 | //! | |
| 3 | //! The fifth listener shim, and the second to need `android_json_number` — | |
| 4 | //! seven numbers, six of which are doubles. | |
| 5 | //! | |
| 6 | //! ## Six doubles, and three of them started as floats | |
| 7 | //! | |
| 8 | //! `Location.accuracy`, `.speed` and `.bearing` are `Float`, but the shim | |
| 9 | //! writes them with `put(name, value)` and the overload Kotlin picks is | |
| 10 | //! `put(String, double)` — primitive widening beats boxing. So they are | |
| 11 | //! `Double`s by the time `numberToString` sees them, and print as doubles. | |
| 12 | //! | |
| 13 | //! That matters because the same float prints differently in the two places | |
| 14 | //! this bridge sends one. `bridge_android_motion` puts its floats into a | |
| 15 | //! `Map`, which boxes them as `Float`, so an accuracy of `0.1f` reads "0.1" | |
| 16 | //! there and "0.10000000149011612" here. Neither is wrong; they are different | |
| 17 | //! calls. It is the reason `android_json_number` has two functions rather than | |
| 18 | //! one widening one. | |
| 19 | //! | |
| 20 | //! ## Two rejections with different codes and different shapes | |
| 21 | //! | |
| 22 | //! `{code: 1, message: 'Permission denied'}` is a fixed object. `{code: 2, | |
| 23 | //! message: <exception>}` carries whatever Play Services said. Both are JS | |
| 24 | //! object literals in the shim with unquoted keys; here they are JSON, which | |
| 25 | //! parses to the same object. | |
| 26 | //! | |
| 27 | //! There is no code 3, and no rejection at all for the case where the fresh | |
| 28 | //! location request never produces a result — the promise simply never | |
| 29 | //! settles. That is the shim's behaviour and is left alone here; see #157 for | |
| 30 | //! the same shape elsewhere. | |
| 31 | ||
| 32 | const std = ; | |
| 33 | const bridge_error = ; | |
| 34 | ||
| 35 | pub const A = struct { | |
| 36 | pub const get_current_position = "getCurrentPosition"; | |
| 37 | }; | |
| 38 | ||
| 39 | pub const resolve_global = "_craftLocationResolve"; | |
| 40 | pub const reject_global = "_craftLocationReject"; | |
| 41 | ||
| 42 | /// `{code: 1, ...}` — the permission refusal. | |
| 43 | pub const code_permission_denied: i32 = 1; | |
| 44 | ||
| 45 | /// `{code: 2, ...}` — whatever the location client failed with. | |
| 46 | pub const code_client_failure: i32 = 2; | |
| 47 | ||
| 48 | pub const permission_denied_message = "Permission denied"; | |
| 49 | ||
| 50 | /// The seven values a position reply carries, already printed. | |
| 51 | /// | |
| 52 | /// Strings rather than numbers, because printing them is `org.json`'s job — | |
| 53 | /// see `android_json_number`. What this file owns is which keys they go under | |
| 54 | /// and in what order. | |
| 55 | pub const Printed = struct { | |
| 56 | latitude: []const u8, | |
| 57 | longitude: []const u8, | |
| 58 | accuracy: []const u8, | |
| 59 | altitude: []const u8, | |
| 60 | speed: []const u8, | |
| 61 | /// `location.bearing`, under the key `heading`. | |
| 62 | heading: []const u8, | |
| 63 | timestamp: []const u8, | |
| 64 | }; | |
| 65 | ||
| 66 | /// A position, as the shim's `JSONObject` prints it. | |
| 67 | /// | |
| 68 | /// Insertion order, because `JSONObject` is a `LinkedHashMap` — and the key | |
| 69 | /// for `location.bearing` is `heading`, which is the one name here that does | |
| 70 | /// not match the field it came from. | |
| 71 | pub fn renderPosition(allocator: std.mem.Allocator, values: Printed) ![]u8 { | |
| 72 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 73 | errdefer out.deinit(allocator); | |
| 74 | ||
| 75 | try out.appendSlice(allocator, "{\"latitude\":"); | |
| 76 | try out.appendSlice(allocator, values.latitude); | |
| 77 | try out.appendSlice(allocator, ",\"longitude\":"); | |
| 78 | try out.appendSlice(allocator, values.longitude); | |
| 79 | try out.appendSlice(allocator, ",\"accuracy\":"); | |
| 80 | try out.appendSlice(allocator, values.accuracy); | |
| 81 | try out.appendSlice(allocator, ",\"altitude\":"); | |
| 82 | try out.appendSlice(allocator, values.altitude); | |
| 83 | try out.appendSlice(allocator, ",\"speed\":"); | |
| 84 | try out.appendSlice(allocator, values.speed); | |
| 85 | try out.appendSlice(allocator, ",\"heading\":"); | |
| 86 | try out.appendSlice(allocator, values.heading); | |
| 87 | try out.appendSlice(allocator, ",\"timestamp\":"); | |
| 88 | try out.appendSlice(allocator, values.timestamp); | |
| 89 | try out.append(allocator, '}'); | |
| 90 | return out.toOwnedSlice(allocator); | |
| 91 | } | |
| 92 | ||
| 93 | /// `{code: n, message: "…"}`. | |
| 94 | pub fn renderRejection(allocator: std.mem.Allocator, code: i32, message: []const u8) ![]u8 { | |
| 95 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 96 | errdefer out.deinit(allocator); | |
| 97 | ||
| 98 | try out.appendSlice(allocator, "{\"code\":"); | |
| 99 | try out.print(allocator, "{d}", .{code}); | |
| 100 | try out.appendSlice(allocator, ",\"message\":\""); | |
| 101 | try bridge_error.appendJsonEscaped(allocator, &out, message); | |
| 102 | try out.appendSlice(allocator, "\"}"); | |
| 103 | return out.toOwnedSlice(allocator); | |
| 104 | } | |
| 105 | ||
| 106 | // ============================================================================= | |
| 107 | // Tests | |
| 108 | // ============================================================================= | |
| 109 | ||
| 110 | const testing = std.testing; | |
| 111 | ||
| 112 | test "a position prints seven keys, in the shim's order" { | |
| 113 | const json = try renderPosition(testing.allocator, .{ | |
| 114 | .latitude = "51.5074", | |
| 115 | .longitude = "-0.1278", | |
| 116 | .accuracy = "12.5", | |
| 117 | .altitude = "35.2", | |
| 118 | .speed = "0", | |
| 119 | .heading = "0", | |
| 120 | .timestamp = "1700000000000", | |
| 121 | }); | |
| 122 | defer testing.allocator.free(json); | |
| 123 | ||
| 124 | try testing.expectEqualStrings( | |
| 125 | \\{"latitude":51.5074,"longitude":-0.1278,"accuracy":12.5,"altitude":35.2,"speed":0,"heading":0,"timestamp":1700000000000} | |
| 126 | , json); | |
| 127 | } | |
| 128 | ||
| 129 | test "bearing is called heading, which is the one name that changes" { | |
| 130 | // `put("heading", location.bearing)`. Every other key matches its field, | |
| 131 | // so this is the one a rewrite would silently get right-looking and wrong. | |
| 132 | const json = try renderPosition(testing.allocator, .{ | |
| 133 | .latitude = "0", | |
| 134 | .longitude = "0", | |
| 135 | .accuracy = "0", | |
| 136 | .altitude = "0", | |
| 137 | .speed = "0", | |
| 138 | .heading = "271.5", | |
| 139 | .timestamp = "0", | |
| 140 | }); | |
| 141 | defer testing.allocator.free(json); | |
| 142 | ||
| 143 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 144 | defer parsed.deinit(); | |
| 145 | ||
| 146 | try testing.expect(parsed.value.object.get("bearing") == null); | |
| 147 | try testing.expectEqual(@as(f64, 271.5), parsed.value.object.get("heading").?.float); | |
| 148 | try testing.expectEqual(@as(usize, 7), parsed.value.object.count()); | |
| 149 | } | |
| 150 | ||
| 151 | test "org.json's integral form parses as a number, not a string" { | |
| 152 | // A stationary device reports speed and bearing of exactly zero, and | |
| 153 | // `numberToString` prints those as `0` rather than `0.0`. A page doing | |
| 154 | // arithmetic on them needs them to be numbers, which they are. | |
| 155 | const json = try renderPosition(testing.allocator, .{ | |
| 156 | .latitude = "0", | |
| 157 | .longitude = "-0", | |
| 158 | .accuracy = "9", | |
| 159 | .altitude = "0", | |
| 160 | .speed = "0", | |
| 161 | .heading = "0", | |
| 162 | .timestamp = "1700000000000", | |
| 163 | }); | |
| 164 | defer testing.allocator.free(json); | |
| 165 | ||
| 166 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 167 | defer parsed.deinit(); | |
| 168 | try testing.expectEqual(@as(i64, 9), parsed.value.object.get("accuracy").?.integer); | |
| 169 | try testing.expectEqual(@as(i64, 1700000000000), parsed.value.object.get("timestamp").?.integer); | |
| 170 | } | |
| 171 | ||
| 172 | test "the two rejections carry different codes" { | |
| 173 | // A page distinguishes "the user said no" from "the location client | |
| 174 | // failed" by the code alone, so the numbers are API. | |
| 175 | const denied = try renderRejection(testing.allocator, code_permission_denied, permission_denied_message); | |
| 176 | defer testing.allocator.free(denied); | |
| 177 | try testing.expectEqualStrings( | |
| 178 | \\{"code":1,"message":"Permission denied"} | |
| 179 | , denied); | |
| 180 | ||
| 181 | const failed = try renderRejection(testing.allocator, code_client_failure, "GoogleApiClient is not connected yet"); | |
| 182 | defer testing.allocator.free(failed); | |
| 183 | try testing.expectEqualStrings( | |
| 184 | \\{"code":2,"message":"GoogleApiClient is not connected yet"} | |
| 185 | , failed); | |
| 186 | ||
| 187 | try testing.expectEqual(@as(i32, 1), code_permission_denied); | |
| 188 | try testing.expectEqual(@as(i32, 2), code_client_failure); | |
| 189 | } | |
| 190 | ||
| 191 | test "a failure message carrying a quote survives as JSON" { | |
| 192 | // The message is whatever Play Services threw, which is not text this | |
| 193 | // repository controls — and it goes to the page through the same channel | |
| 194 | // that hung the promise before #154. | |
| 195 | const json = try renderRejection(testing.allocator, code_client_failure, "it's \"broken\"\n"); | |
| 196 | defer testing.allocator.free(json); | |
| 197 | ||
| 198 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 199 | defer parsed.deinit(); | |
| 200 | try testing.expectEqualStrings("it's \"broken\"\n", parsed.value.object.get("message").?.string); | |
| 201 | } | |
| 202 | ||
| 203 | test "the action and globals match the shim exactly" { | |
| 204 | try testing.expectEqualStrings("getCurrentPosition", A.get_current_position); | |
| 205 | try testing.expectEqualStrings("_craftLocationResolve", resolve_global); | |
| 206 | try testing.expectEqualStrings("_craftLocationReject", reject_global); | |
| 207 | } | |
| @@ -59,6 +59,7 @@ const zig_sources = [_][]const u8{ | ||
| 59 | 59 | @embedFile("src/bridge_android_appstate.zig"), |
| 60 | 60 | @embedFile("src/bridge_android_bluetooth.zig"), |
| 61 | 61 | @embedFile("src/bridge_android_motion.zig"), |
| 62 | @embedFile("src/bridge_android_position.zig"), | |
| 62 | 63 | }; |
| 63 | 64 | |
| 64 | 65 | /// How many spec actions Zig does not serve yet. |
| @@ -94,8 +95,9 @@ const zig_sources = [_][]const u8{ | ||
| 94 | 95 | /// first listener shim — the callback object is the holder's, the work is |
| 95 | 96 | /// Zig's; 56 with the app-state trio, the first migration where the state |
| 96 | 97 | /// itself moved rather than being worked around; 54 with the Bluetooth scan |
| 97 | /// pair; 52 with the motion pair, once org.json agreed to print the floats. | |
| 98 | const max_not_yet_migrated: usize = 52; | |
| 98 | /// pair; 52 with the motion pair, once org.json agreed to print the floats; | |
| 99 | /// 51 with getCurrentPosition, which needed the same and two Java interfaces. | |
| 100 | const max_not_yet_migrated: usize = 51; | |
| 99 | 101 | |
| 100 | 102 | /// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge. |
| 101 | 103 | /// |