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