The third listener shim. ScanCallback is an abstract Java class, so the object lives in CraftNative and every result forwards to Zig — with the device name still nullable, so the ?: "Unknown" default stays one decision in one place rather than two that have to agree.
Why Bluetooth and not motion
The motion pair looked like the easier next step and is not. sendEvent("craftMotionUpdate", ...) carries FloatArray values, and JSONObject prints a Float through numberToString — which falls through to Float.toString for anything non-integral. Reproducing that exactly is the Double.toString problem the calendar, database and widget modules all decline to guess at, and getting it wrong would be silently wrong numbers rather than a failure.
Bluetooth's payload is two strings and an int: {"id": address, "name": name ?: "Unknown", "rssi": rssi}.
The scan can not start and still resolve true (#185)
bluetoothScanner?.startScan(bleScanCallback)
activity.runOnUiThread { ...craftBleResolve(true)... }bluetoothScanner is null when the device has no adapter or when Bluetooth is switched off. The ?. skips the scan, the resolve fires anyway, and nothing distinguishes "scanning, nothing found" from "never started". Bluetooth being off is not an edge case, and the page has no way to tell the user to turn it on.
Reproduced rather than corrected: rejecting on the Zig path alone would mean the same call succeeds or fails depending on which implementation served it, and the difference would only show up as a missing rejection.
The version check lives next to the call that needs it
BLUETOOTH_SCAN arrived in API 31, and below that the shim does not ask for it at all. requestBluetoothScanPermission is in the holder because that is where ActivityCompat.requestPermissions is called — putting the guard on the Zig side would leave a call that throws on older releases one refactor away.
One deliberate difference, written down
result.device.name requires BLUETOOTH_CONNECT on API 31+, which this scan does not request — so it can throw SecurityException inside the callback. The shim lets that escape a framework callback; the holder catches it and drops the result.
That is the only place the two implementations differ, and it is the smaller of the two behaviours.
Testing
zig build test:android passes with the ratchet at 54. The mutation fired: replacing the "Unknown" default with an empty string fails a named test. Both libcraft.so targets still build with no NDK.