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