also looking at this
feat(android): the Bluetooth scan pair in Zig — ratchet 56 → 54
#186
7 files
+377
-3
| @@ -2993,6 +2993,11 @@ class CraftBridge( | ||
| 2993 | 2993 | |
| 2994 | 2994 | @JavascriptInterface |
| 2995 | 2995 | 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 | ||
| 2996 | 3001 | if (ContextCompat.checkSelfPermission(activity, Manifest.permission.BLUETOOTH_SCAN) |
| 2997 | 3002 | != PackageManager.PERMISSION_GRANTED) { |
| 2998 | 3003 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
| @@ -3026,6 +3031,8 @@ class CraftBridge( | ||
| 3026 | 3031 | |
| 3027 | 3032 | @JavascriptInterface |
| 3028 | 3033 | fun stopBluetoothScan() { |
| 3034 | if (CraftNative.stopBluetoothScan(activity)) return | |
| 3035 | ||
| 3029 | 3036 | bleScanCallback?.let { bluetoothScanner?.stopScan(it) } |
| 3030 | 3037 | bleScanCallback = null |
| 3031 | 3038 | } |
| @@ -1,6 +1,12 @@ | ||
| 1 | 1 | package com.craft.runtime |
| 2 | 2 | |
| 3 | 3 | import android.app.Activity |
| 4 | import androidx.core.app.ActivityCompat | |
| 5 | import android.os.Build | |
| 6 | import android.bluetooth.le.ScanResult | |
| 7 | import android.bluetooth.le.ScanCallback | |
| 8 | import android.bluetooth.le.BluetoothLeScanner | |
| 9 | import android.bluetooth.BluetoothManager | |
| 4 | 10 | import androidx.lifecycle.ProcessLifecycleOwner |
| 5 | 11 | import androidx.lifecycle.LifecycleEventObserver |
| 6 | 12 | import android.net.NetworkRequest |
| @@ -153,6 +159,9 @@ object CraftNative { | ||
| 153 | 159 | private external fun nativeStartAppStateMonitoring(activity: Activity): Boolean |
| 154 | 160 | private external fun nativeStopAppStateMonitoring(activity: Activity): Boolean |
| 155 | 161 | 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 | |
| 156 | 165 | |
| 157 | 166 | /** |
| 158 | 167 | * `getDeviceInfo`, or null to use the Kotlin implementation. |
| @@ -917,4 +926,95 @@ object CraftNative { | ||
| 917 | 926 | null |
| 918 | 927 | } |
| 919 | 928 | } |
| 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 | } | |
| 920 | 1020 | } |
| @@ -485,6 +485,9 @@ pub fn build(b: *std.Build) void { | ||
| 485 | 485 | android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_appstate.zig", .{ |
| 486 | 486 | .root_source_file = b.path("src/bridge_android_appstate.zig"), |
| 487 | 487 | }); |
| 488 | android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_bluetooth.zig", .{ | |
| 489 | .root_source_file = b.path("src/bridge_android_bluetooth.zig"), | |
| 490 | }); | |
| 488 | 491 | const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests); |
| 489 | 492 | |
| 490 | 493 | // The page surface, across both bridges. Not folded into the iOS |
| @@ -43,6 +43,7 @@ const clipboard = ; | ||
| 43 | 43 | const intents = ; |
| 44 | 44 | const network = ; |
| 45 | 45 | const appstate = ; |
| 46 | const bluetooth = ; | |
| 46 | 47 | const securestore = ; |
| 47 | 48 | const haptics = ; |
| 48 | 49 | const notifications = ; |
| @@ -431,6 +432,23 @@ const natives = [_]jni.JNINativeMethod{ | ||
| 431 | 432 | .signature = "()Ljava/lang/String;", |
| 432 | 433 | .fnPtr = @ptrCast(&nativeGetAppState), |
| 433 | 434 | }, |
| 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 | }, | |
| 434 | 452 | }; |
| 435 | 453 | |
| 436 | 454 | /// 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 { | ||
| 1872 | 1890 | return j.newStringUtf8(arena.allocator(), appstate.state().name()) catch null; |
| 1873 | 1891 | } |
| 1874 | 1892 | |
| 1893 | /// `nativeBluetoothDevice(address, name, rssi)` — one scan result. | |
| 1894 | fn 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. | |
| 1923 | fn 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`. | |
| 1964 | fn 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 | ||
| 1875 | 1978 | // ============================================================================= |
| 1876 | 1979 | // Tests |
| 1877 | 1980 | // ============================================================================= |
| @@ -1983,7 +2086,7 @@ test "the registered natives name methods the Kotlin actually declares" { | ||
| 1983 | 2086 | // A descriptor is checked by the JVM at registration, so a wrong one fails |
| 1984 | 2087 | // at load rather than at call — but only if the *name* matches something. |
| 1985 | 2088 | // 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); | |
| 1987 | 2090 | try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name)); |
| 1988 | 2091 | |
| 1989 | 2092 | // And the class they bind to is the fixed one, not the templated bridge. |
| @@ -42,6 +42,10 @@ pub const read_contacts = "android.permission.READ_CONTACTS"; | ||
| 42 | 42 | pub const write_contacts = "android.permission.WRITE_CONTACTS"; |
| 43 | 43 | pub const access_fine_location = "android.permission.ACCESS_FINE_LOCATION"; |
| 44 | 44 | |
| 45 | /// API 31 and later only — see `CraftNative.requestBluetoothScanPermission`, | |
| 46 | /// which is where the version check lives. | |
| 47 | pub const bluetooth_scan = "android.permission.BLUETOOTH_SCAN"; | |
| 48 | ||
| 45 | 49 | /// `activity.checkSelfPermission(permission) == PERMISSION_GRANTED`. |
| 46 | 50 | pub fn isGranted(j: Jni, activity: jobject, permission: [*:0]const u8) !bool { |
| 47 | 51 | try j.pushLocalFrame(8); |
| @@ -94,6 +98,7 @@ test "the permission names match Manifest.permission exactly" { | ||
| 94 | 98 | try testing.expectEqualStrings("android.permission.READ_CONTACTS", read_contacts); |
| 95 | 99 | try testing.expectEqualStrings("android.permission.WRITE_CONTACTS", write_contacts); |
| 96 | 100 | try testing.expectEqualStrings("android.permission.ACCESS_FINE_LOCATION", access_fine_location); |
| 101 | try testing.expectEqualStrings("android.permission.BLUETOOTH_SCAN", bluetooth_scan); | |
| 97 | 102 | } |
| 98 | 103 | |
| 99 | 104 | test "PERMISSION_GRANTED is zero, and PERMISSION_DENIED is not" { |
| @@ -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 | ||
| 29 | const std = ; | |
| 30 | const events = ; | |
| 31 | const bridge_error = ; | |
| 32 | ||
| 33 | pub const A = struct { | |
| 34 | pub const start_bluetooth_scan = "startBluetoothScan"; | |
| 35 | pub const stop_bluetooth_scan = "stopBluetoothScan"; | |
| 36 | }; | |
| 37 | ||
| 38 | pub const resolve_global = "_craftBleResolve"; | |
| 39 | pub const reject_global = "_craftBleReject"; | |
| 40 | ||
| 41 | /// The event each scan result becomes. | |
| 42 | pub const event_name = "craftBluetoothDevice"; | |
| 43 | ||
| 44 | /// What a device with no advertised name is called. | |
| 45 | pub const unknown_name = "Unknown"; | |
| 46 | ||
| 47 | /// `CraftBridge.REQUEST_BLUETOOTH`. | |
| 48 | pub 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. | |
| 54 | pub 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`. | |
| 74 | pub 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 | ||
| 89 | const testing = std.testing; | |
| 90 | ||
| 91 | test "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 | ||
| 102 | test "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 | ||
| 122 | test "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 | ||
| 136 | test "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 | ||
| 147 | test "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 | } | |
| @@ -57,6 +57,7 @@ const zig_sources = [_][]const u8{ | ||
| 57 | 57 | @embedFile("src/bridge_android_files.zig"), |
| 58 | 58 | @embedFile("src/bridge_android_locationstore.zig"), |
| 59 | 59 | @embedFile("src/bridge_android_appstate.zig"), |
| 60 | @embedFile("src/bridge_android_bluetooth.zig"), | |
| 60 | 61 | }; |
| 61 | 62 | |
| 62 | 63 | /// How many spec actions Zig does not serve yet. |
| @@ -91,8 +92,9 @@ const zig_sources = [_][]const u8{ | ||
| 91 | 92 | /// to name its own service class; 59 with the network monitoring pair, the |
| 92 | 93 | /// first listener shim — the callback object is the holder's, the work is |
| 93 | 94 | /// Zig's; 56 with the app-state trio, the first migration where the state |
| 94 | /// itself moved rather than being worked around. | |
| 95 | const max_not_yet_migrated: usize = 56; | |
| 95 | /// itself moved rather than being worked around; 54 with the Bluetooth scan | |
| 96 | /// pair. | |
| 97 | const max_not_yet_migrated: usize = 54; | |
| 96 | 98 | |
| 97 | 99 | /// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge. |
| 98 | 100 | /// |