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/android/templates/CraftBridge.kt.templatemodified+7-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2993,6 +2993,11 @@ class CraftBridge(
29932993
29942994 @JavascriptInterface
29952995 fun startBluetoothScan() {
2996 // The ScanCallback object is CraftNative's, and so are the scanner and
2997 // callback fields — which is why stopBluetoothScan has to reach the
2998 // same way, or its own fields stay null and stopScan is never called.
2999 if (CraftNative.startBluetoothScan(activity)) return
3000
29963001 if (ContextCompat.checkSelfPermission(activity, Manifest.permission.BLUETOOTH_SCAN)
29973002 != PackageManager.PERMISSION_GRANTED) {
29983003 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -3026,6 +3031,8 @@ class CraftBridge(
30263031
30273032 @JavascriptInterface
30283033 fun stopBluetoothScan() {
3034 if (CraftNative.stopBluetoothScan(activity)) return
3035
30293036 bleScanCallback?.let { bluetoothScanner?.stopScan(it) }
30303037 bleScanCallback = null
30313038 }
packages/android/templates/CraftNative.kt.templatemodified+100-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -1,6 +1,12 @@
11package com.craft.runtime
22
33import android.app.Activity
4import androidx.core.app.ActivityCompat
5import android.os.Build
6import android.bluetooth.le.ScanResult
7import android.bluetooth.le.ScanCallback
8import android.bluetooth.le.BluetoothLeScanner
9import android.bluetooth.BluetoothManager
410import androidx.lifecycle.ProcessLifecycleOwner
511import androidx.lifecycle.LifecycleEventObserver
612import android.net.NetworkRequest
@@ -153,6 +159,9 @@ object CraftNative {
153159 private external fun nativeStartAppStateMonitoring(activity: Activity): Boolean
154160 private external fun nativeStopAppStateMonitoring(activity: Activity): Boolean
155161 private external fun nativeGetAppState(): String?
162 private external fun nativeBluetoothDevice(address: String, name: String?, rssi: Int)
163 private external fun nativeStartBluetoothScan(activity: Activity): Boolean
164 private external fun nativeStopBluetoothScan(activity: Activity): Boolean
156165
157166 /**
158167 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -917,4 +926,95 @@ object CraftNative {
917926 null
918927 }
919928 }
929
930 // ==================== Bluetooth scanning ====================
931 //
932 // The third listener shim. `ScanCallback` is an abstract Java class, so
933 // the object is here and every result forwards to Zig — with the device
934 // name still nullable, so the "Unknown" default stays one decision in one
935 // place rather than two that have to agree.
936
937 private var bleScanner: BluetoothLeScanner? = null
938 private var bleScanCallback: ScanCallback? = null
939
940 /**
941 * Start scanning, including the part that does nothing.
942 *
943 * `bleScanner` is null when the device has no adapter or Bluetooth is off,
944 * and the `?.` then skips the scan — while the caller resolves true
945 * regardless, exactly as `CraftBridge` does. Reproduced rather than
946 * guarded, because the reply is what a page acts on. See #185.
947 */
948 @JvmStatic
949 fun startBluetoothWatch(activity: Activity) {
950 val manager = activity.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
951 bleScanner = manager.adapter?.bluetoothLeScanner
952
953 val callback = object : ScanCallback() {
954 override fun onScanResult(callbackType: Int, result: ScanResult) {
955 try {
956 nativeBluetoothDevice(
957 result.device.address,
958 result.device.name,
959 result.rssi
960 )
961 } catch (e: UnsatisfiedLinkError) {
962 // The library went away. Nothing is waiting on this.
963 } catch (e: SecurityException) {
964 // `device.name` needs BLUETOOTH_CONNECT on API 31+, which
965 // this scan does not require. The shim would throw out of
966 // the callback here; dropping the result is the smaller
967 // difference, and it is the only one.
968 }
969 }
970 }
971
972 bleScanner?.startScan(callback)
973 bleScanCallback = callback
974 }
975
976 @JvmStatic
977 fun stopBluetoothWatch(activity: Activity) {
978 bleScanCallback?.let { bleScanner?.stopScan(it) }
979 bleScanCallback = null
980 }
981
982 /**
983 * Ask for BLUETOOTH_SCAN, but only where it exists.
984 *
985 * The permission arrived in API 31. Below that the shim does not ask at
986 * all — it rejects and leaves it — so the version check lives here, next
987 * to the call that would throw without it.
988 */
989 @JvmStatic
990 fun requestBluetoothScanPermission(activity: Activity) {
991 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
992 ActivityCompat.requestPermissions(
993 activity,
994 arrayOf(android.Manifest.permission.BLUETOOTH_SCAN),
995 REQUEST_BLUETOOTH
996 )
997 }
998 }
999
1000 /** `CraftBridge.REQUEST_BLUETOOTH`, which this has to match. */
1001 private const val REQUEST_BLUETOOTH = 1009
1002
1003 fun startBluetoothScan(activity: Activity): Boolean {
1004 if (!isAvailable) return false
1005 return try {
1006 nativeStartBluetoothScan(activity)
1007 } catch (e: UnsatisfiedLinkError) {
1008 false
1009 }
1010 }
1011
1012 fun stopBluetoothScan(activity: Activity): Boolean {
1013 if (!isAvailable) return false
1014 return try {
1015 nativeStopBluetoothScan(activity)
1016 } catch (e: UnsatisfiedLinkError) {
1017 false
1018 }
1019 }
9201020}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -485,6 +485,9 @@ pub fn build(b: *std.Build) void {
485485 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_appstate.zig", .{
486486 .root_source_file = b.path("src/bridge_android_appstate.zig"),
487487 });
488 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_bluetooth.zig", .{
489 .root_source_file = b.path("src/bridge_android_bluetooth.zig"),
490 });
488491 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
489492
490493 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+104-1
Changes to packages/zig/src/android_dispatch.zig
@@ -43,6 +43,7 @@ const clipboard = ;
4343const intents = ;
4444const network = ;
4545const appstate = ;
46const bluetooth = ;
4647const securestore = ;
4748const haptics = ;
4849const notifications = ;
@@ -431,6 +432,23 @@ const natives = [_]jni.JNINativeMethod{
431432 .signature = "()Ljava/lang/String;",
432433 .fnPtr = @ptrCast(&nativeGetAppState),
433434 },
435 // Not an action: the other end of the ScanCallback the holder owns. The
436 // name arrives nullable, so the "Unknown" default stays a Zig decision.
437 .{
438 .name = "nativeBluetoothDevice",
439 .signature = "(Ljava/lang/String;Ljava/lang/String;I)V",
440 .fnPtr = @ptrCast(&nativeBluetoothDevice),
441 },
442 .{
443 .name = "nativeStartBluetoothScan",
444 .signature = "(Landroid/app/Activity;)Z",
445 .fnPtr = @ptrCast(&nativeStartBluetoothScan),
446 },
447 .{
448 .name = "nativeStopBluetoothScan",
449 .signature = "(Landroid/app/Activity;)Z",
450 .fnPtr = @ptrCast(&nativeStopBluetoothScan),
451 },
434452};
435453
436454/// How binding went. A value rather than a log line, so every path is
@@ -1872,6 +1890,91 @@ fn nativeGetAppState(env: jni.JNIEnv, _: jni.jobject) callconv(.c) jni.jstring {
18721890 return j.newStringUtf8(arena.allocator(), appstate.state().name()) catch null;
18731891}
18741892
1893/// `nativeBluetoothDevice(address, name, rssi)` — one scan result.
1894fn nativeBluetoothDevice(
1895 env: jni.JNIEnv,
1896 _: jni.jobject,
1897 address: jni.jstring,
1898 name: jni.jstring,
1899 rssi: jni.jint,
1900) callconv(.c) void {
1901 const j = Jni.init(env);
1902 var arena = std.heap.ArenaAllocator.init(backing);
1903 defer arena.deinit();
1904 const allocator = arena.allocator();
1905
1906 const address_text = j.stringToUtf8(allocator, address) catch return;
1907
1908 // Null is a device advertising no name, which is the common case rather
1909 // than the edge one — `bluetooth.announce` applies the shim's "Unknown".
1910 const name_text: ?[]const u8 = if (name == null)
1911 null
1912 else
1913 j.stringToUtf8(allocator, name) catch return;
1914
1915 bluetooth.announce(allocator, address_text, name_text, rssi) catch {};
1916}
1917
1918/// `nativeStartBluetoothScan(activity)`.
1919///
1920/// Resolves `true` whether or not a scan actually started, because the shim
1921/// does: `bluetoothScanner?.startScan(...)` is skipped when there is no
1922/// adapter or Bluetooth is off, and the resolve fires regardless. See #185.
1923fn nativeStartBluetoothScan(
1924 env: jni.JNIEnv,
1925 _: jni.jobject,
1926 activity: jni.jobject,
1927) callconv(.c) jni.jboolean {
1928 const j = Jni.init(env);
1929 var arena = std.heap.ArenaAllocator.init(backing);
1930 defer arena.deinit();
1931 const allocator = arena.allocator();
1932
1933 const granted = permissions.isGranted(j, activity, permissions.bluetooth_scan) catch |err| {
1934 std.log.warn("craft: startBluetoothScan fell through to the shim ({s})", .{@errorName(err)});
1935 return jni.JNI_FALSE;
1936 };
1937
1938 if (!granted) {
1939 // The shim only asks on API 31+, where the permission exists at all;
1940 // the holder makes that check, because it is the one that knows how to
1941 // ask without throwing on older releases.
1942 callHolderVoid(j, "requestBluetoothScanPermission", activity) catch |err| {
1943 std.log.warn("craft: startBluetoothScan fell through to the shim ({s})", .{@errorName(err)});
1944 return jni.JNI_FALSE;
1945 };
1946 calendar.rejectOn(allocator, bluetooth.reject_global, "Permission denied") catch {};
1947 return jni.JNI_TRUE;
1948 }
1949
1950 callHolderVoid(j, "startBluetoothWatch", activity) catch |err| {
1951 std.log.warn("craft: startBluetoothScan fell through to the shim ({s})", .{@errorName(err)});
1952 return jni.JNI_FALSE;
1953 };
1954
1955 events.settle(allocator, bluetooth.resolve_global, "true") catch return jni.JNI_FALSE;
1956 return jni.JNI_TRUE;
1957}
1958
1959/// `nativeStopBluetoothScan(activity)`.
1960///
1961/// Cannot decline after a successful start, for the reason the network watch
1962/// cannot: the shim's own `bleScanCallback` is null, so its stop finds nothing
1963/// to hand to `stopScan`.
1964fn nativeStopBluetoothScan(
1965 env: jni.JNIEnv,
1966 _: jni.jobject,
1967 activity: jni.jobject,
1968) callconv(.c) jni.jboolean {
1969 const j = Jni.init(env);
1970
1971 callHolderVoid(j, "stopBluetoothWatch", activity) catch |err| {
1972 std.log.warn("craft: stopBluetoothScan failed ({s})", .{@errorName(err)});
1973 return jni.JNI_FALSE;
1974 };
1975 return jni.JNI_TRUE;
1976}
1977
18751978// =============================================================================
18761979// Tests
18771980// =============================================================================
@@ -1983,7 +2086,7 @@ test "the registered natives name methods the Kotlin actually declares" {
19832086 // A descriptor is checked by the JVM at registration, so a wrong one fails
19842087 // at load rather than at call — but only if the *name* matches something.
19852088 // These two strings are the contract with CraftBridge.kt.
1986 try testing.expectEqual(@as(usize, 50), natives.len);
2089 try testing.expectEqual(@as(usize, 53), natives.len);
19872090 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
19882091
19892092 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/android_permissions.zigmodified+5-0
Changes to packages/zig/src/android_permissions.zig
@@ -42,6 +42,10 @@ pub const read_contacts = "android.permission.READ_CONTACTS";
4242pub const write_contacts = "android.permission.WRITE_CONTACTS";
4343pub const access_fine_location = "android.permission.ACCESS_FINE_LOCATION";
4444
45/// API 31 and later only — see `CraftNative.requestBluetoothScanPermission`,
46/// which is where the version check lives.
47pub const bluetooth_scan = "android.permission.BLUETOOTH_SCAN";
48
4549/// `activity.checkSelfPermission(permission) == PERMISSION_GRANTED`.
4650pub fn isGranted(j: Jni, activity: jobject, permission: [*:0]const u8) !bool {
4751 try j.pushLocalFrame(8);
@@ -94,6 +98,7 @@ test "the permission names match Manifest.permission exactly" {
9498 try testing.expectEqualStrings("android.permission.READ_CONTACTS", read_contacts);
9599 try testing.expectEqualStrings("android.permission.WRITE_CONTACTS", write_contacts);
96100 try testing.expectEqualStrings("android.permission.ACCESS_FINE_LOCATION", access_fine_location);
101 try testing.expectEqualStrings("android.permission.BLUETOOTH_SCAN", bluetooth_scan);
97102}
98103
99104test "PERMISSION_GRANTED is zero, and PERMISSION_DENIED is not" {
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}
packages/zig/test/android_conformance_test.zigmodified+4-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -57,6 +57,7 @@ const zig_sources = [_][]const u8{
5757 @embedFile("src/bridge_android_files.zig"),
5858 @embedFile("src/bridge_android_locationstore.zig"),
5959 @embedFile("src/bridge_android_appstate.zig"),
60 @embedFile("src/bridge_android_bluetooth.zig"),
6061};
6162
6263/// How many spec actions Zig does not serve yet.
@@ -91,8 +92,9 @@ const zig_sources = [_][]const u8{
9192/// to name its own service class; 59 with the network monitoring pair, the
9293/// first listener shim — the callback object is the holder's, the work is
9394/// Zig's; 56 with the app-state trio, the first migration where the state
94/// itself moved rather than being worked around.
95const max_not_yet_migrated: usize = 56;
95/// itself moved rather than being worked around; 54 with the Bluetooth scan
96/// pair.
97const max_not_yet_migrated: usize = 54;
9698
9799/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
98100///