ReviewOS

also looking at this

craft-native/craft

feat(android): getCurrentPosition in Zig — ratchet 52 → 51

#188
Merged glennmichael123 wants to merge feat/android-position into main
6 files +466 -3
packages/android/templates/CraftBridge.kt.templatemodified+6-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -1633,6 +1633,12 @@ class CraftBridge(
16331633
16341634 @JavascriptInterface
16351635 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
16361642 if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION)
16371643 != PackageManager.PERMISSION_GRANTED) {
16381644 ActivityCompat.requestPermissions(
packages/android/templates/CraftNative.kt.templatemodified+124-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -1,6 +1,14 @@
11package com.craft.runtime
22
33import android.app.Activity
4import com.google.android.gms.location.Priority
5import com.google.android.gms.location.LocationServices
6import com.google.android.gms.location.LocationResult
7import com.google.android.gms.location.LocationRequest
8import com.google.android.gms.location.LocationCallback
9import com.google.android.gms.location.FusedLocationProviderClient
10import android.location.Location
11import android.annotation.SuppressLint
412import android.hardware.SensorManager
513import android.hardware.SensorEventListener
614import android.hardware.SensorEvent
@@ -169,6 +177,17 @@ object CraftNative {
169177 private external fun nativeMotionSample(isAccelerometer: Boolean, x: Float, y: Float, z: Float)
170178 private external fun nativeStartMotionUpdates(activity: Activity, intervalMs: Int): Boolean
171179 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
172191
173192 /**
174193 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -1092,4 +1111,109 @@ object CraftNative {
10921111 false
10931112 }
10941113 }
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 }
10951219}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -491,6 +491,9 @@ pub fn build(b: *std.Build) void {
491491 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_motion.zig", .{
492492 .root_source_file = b.path("src/bridge_android_motion.zig"),
493493 });
494 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_position.zig", .{
495 .root_source_file = b.path("src/bridge_android_position.zig"),
496 });
494497 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
495498
496499 // 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
@@ -45,6 +45,7 @@ const network = ;
4545const appstate = ;
4646const bluetooth = ;
4747const motion = ;
48const position = ;
4849const json_number = ;
4950const securestore = ;
5051const haptics = ;
@@ -467,6 +468,23 @@ const natives = [_]jni.JNINativeMethod{
467468 .signature = "(Landroid/app/Activity;)Z",
468469 .fnPtr = @ptrCast(&nativeStopMotionUpdates),
469470 },
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 },
470488};
471489
472490/// 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
20962114 );
20972115}
20982116
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.
2123fn 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(&timestamp, "{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.
2158fn 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)`.
2180fn 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
20992220// =============================================================================
21002221// Tests
21012222// =============================================================================
@@ -2207,7 +2328,7 @@ test "the registered natives name methods the Kotlin actually declares" {
22072328 // A descriptor is checked by the JVM at registration, so a wrong one fails
22082329 // at load rather than at call — but only if the *name* matches something.
22092330 // 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);
22112332 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
22122333
22132334 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_position.zigadded+207-0
Changes to packages/zig/src/bridge_android_position.zig
@@ -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
32const std = ;
33const bridge_error = ;
34
35pub const A = struct {
36 pub const get_current_position = "getCurrentPosition";
37};
38
39pub const resolve_global = "_craftLocationResolve";
40pub const reject_global = "_craftLocationReject";
41
42/// `{code: 1, ...}` — the permission refusal.
43pub const code_permission_denied: i32 = 1;
44
45/// `{code: 2, ...}` — whatever the location client failed with.
46pub const code_client_failure: i32 = 2;
47
48pub 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.
55pub 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.
71pub 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: "…"}`.
94pub 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
110const testing = std.testing;
111
112test "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
129test "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
151test "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
172test "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
191test "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
203test "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}
packages/zig/test/android_conformance_test.zigmodified+4-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -59,6 +59,7 @@ const zig_sources = [_][]const u8{
5959 @embedFile("src/bridge_android_appstate.zig"),
6060 @embedFile("src/bridge_android_bluetooth.zig"),
6161 @embedFile("src/bridge_android_motion.zig"),
62 @embedFile("src/bridge_android_position.zig"),
6263};
6364
6465/// How many spec actions Zig does not serve yet.
@@ -94,8 +95,9 @@ const zig_sources = [_][]const u8{
9495/// first listener shim — the callback object is the holder's, the work is
9596/// Zig's; 56 with the app-state trio, the first migration where the state
9697/// 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.
98const 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.
100const max_not_yet_migrated: usize = 51;
99101
100102/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
101103///