ReviewOS

also looking at this

craft-native/craft

feat(android): the Bluetooth scan pair in Zig — ratchet 56 → 54

#186
Merged glennmichael123 wants to merge feat/android-bluetooth into main
7 files +377 -3
packages/zig/src/bridge_android_bluetooth.zigadded+154-0
Changes to packages/zig/src/bridge_android_bluetooth.zig
@@ -0,0 +1,154 @@
1//! `startBluetoothScan` and `stopBluetoothScan` on Android.
2//!
3//! The third listener shim. `ScanCallback` is an abstract Java class, so the
4//! object lives in `CraftNative` and every result forwards here.
5//!
6//! ## The scan can not start and still resolve true
7//!
8//! ```kotlin
9//! bluetoothScanner?.startScan(bleScanCallback)
10//! activity.runOnUiThread { ...craftBleResolve(true)... }
11//! ```
12//!
13//! `bluetoothScanner` is null when the device has no Bluetooth adapter or when
14//! Bluetooth is switched off — and the `?.` then skips the scan while the
15//! resolve fires anyway. A page is told scanning started, no
16//! `craftBluetoothDevice` events ever arrive, and there is nothing to
17//! distinguish that from an empty room.
18//!
19//! Reproduced rather than corrected, because the reply is what a page acts on.
20//! See #185.
21//!
22//! ## The name default is a decision, so it is made here
23//!
24//! `result.device.name ?: "Unknown"` — a BLE advertisement often carries no
25//! name at all, so this is the common case rather than the edge one. The
26//! holder passes the nullable name across and this module applies the default,
27//! which keeps the one judgement in the file that has tests.
28
29const std = ;
30const events = ;
31const bridge_error = ;
32
33pub const A = struct {
34 pub const start_bluetooth_scan = "startBluetoothScan";
35 pub const stop_bluetooth_scan = "stopBluetoothScan";
36};
37
38pub const resolve_global = "_craftBleResolve";
39pub const reject_global = "_craftBleReject";
40
41/// The event each scan result becomes.
42pub const event_name = "craftBluetoothDevice";
43
44/// What a device with no advertised name is called.
45pub const unknown_name = "Unknown";
46
47/// `CraftBridge.REQUEST_BLUETOOTH`.
48pub const request_bluetooth: i32 = 1009;
49
50/// `{"id":"…","name":"…","rssi":-70}`.
51///
52/// Key order is the shim's `mapOf`, which is a `LinkedHashMap` — so id, name,
53/// rssi, and not alphabetical.
54pub fn renderDevice(
55 allocator: std.mem.Allocator,
56 address: []const u8,
57 name: ?[]const u8,
58 rssi: i32,
59) ![]u8 {
60 var out: std.ArrayListUnmanaged(u8) = .empty;
61 errdefer out.deinit(allocator);
62
63 try out.appendSlice(allocator, "{\"id\":\"");
64 try bridge_error.appendJsonEscaped(allocator, &out, address);
65 try out.appendSlice(allocator, "\",\"name\":\"");
66 try bridge_error.appendJsonEscaped(allocator, &out, name orelse unknown_name);
67 try out.appendSlice(allocator, "\",\"rssi\":");
68 try out.print(allocator, "{d}", .{rssi});
69 try out.append(allocator, '}');
70 return out.toOwnedSlice(allocator);
71}
72
73/// Send one scan result as `craftBluetoothDevice`.
74pub fn announce(
75 allocator: std.mem.Allocator,
76 address: []const u8,
77 name: ?[]const u8,
78 rssi: i32,
79) !void {
80 const detail = try renderDevice(allocator, address, name, rssi);
81 defer allocator.free(detail);
82 try events.emitEvent(allocator, event_name, detail);
83}
84
85// =============================================================================
86// Tests
87// =============================================================================
88
89const testing = std.testing;
90
91test "a named device prints the shim's three keys, in its order" {
92 const json = try renderDevice(testing.allocator, "AA:BB:CC:DD:EE:FF", "Pixel Buds", -63);
93 defer testing.allocator.free(json);
94
95 // `mapOf` is a LinkedHashMap and `JSONObject(Map)` keeps its iteration
96 // order, so this is insertion order rather than alphabetical.
97 try testing.expectEqualStrings(
98 \\{"id":"AA:BB:CC:DD:EE:FF","name":"Pixel Buds","rssi":-63}
99 , json);
100}
101
102test "a device with no name is Unknown, not empty and not absent" {
103 // `result.device.name ?: "Unknown"`. A BLE advertisement often carries no
104 // name, so this is the common case — and "Unknown" is a string a page may
105 // well be filtering on.
106 const json = try renderDevice(testing.allocator, "AA:BB", null, -90);
107 defer testing.allocator.free(json);
108
109 try testing.expectEqualStrings(
110 \\{"id":"AA:BB","name":"Unknown","rssi":-90}
111 , json);
112
113 // An empty name is not the absent case: the device advertised one, and it
114 // is empty.
115 const empty = try renderDevice(testing.allocator, "AA:BB", "", -90);
116 defer testing.allocator.free(empty);
117 try testing.expectEqualStrings(
118 \\{"id":"AA:BB","name":"","rssi":-90}
119 , empty);
120}
121
122test "rssi is a number, and it is usually negative" {
123 // Signal strength in dBm — a bare JSON number, not a string, so a page can
124 // compare it. Zero and positive values are legal and do occur very close
125 // to a transmitter.
126 for ([_]i32{ -100, -63, 0, 7 }) |rssi| {
127 const json = try renderDevice(testing.allocator, "AA", "x", rssi);
128 defer testing.allocator.free(json);
129
130 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
131 defer parsed.deinit();
132 try testing.expectEqual(@as(i64, rssi), parsed.value.object.get("rssi").?.integer);
133 }
134}
135
136test "a device name carrying a quote survives as JSON" {
137 // A BLE name is whatever the peripheral advertises — arbitrary bytes the
138 // app has no say over, arriving through the event channel.
139 const json = try renderDevice(testing.allocator, "AA", "Bob's \"speaker\"\n", -50);
140 defer testing.allocator.free(json);
141
142 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
143 defer parsed.deinit();
144 try testing.expectEqualStrings("Bob's \"speaker\"\n", parsed.value.object.get("name").?.string);
145}
146
147test "the actions, globals and event name match the shim exactly" {
148 try testing.expectEqualStrings("startBluetoothScan", A.start_bluetooth_scan);
149 try testing.expectEqualStrings("stopBluetoothScan", A.stop_bluetooth_scan);
150 try testing.expectEqualStrings("_craftBleResolve", resolve_global);
151 try testing.expectEqualStrings("_craftBleReject", reject_global);
152 try testing.expectEqualStrings("craftBluetoothDevice", event_name);
153 try testing.expectEqualStrings("Unknown", unknown_name);
154}