diff --git a/android/src/main/java/so/onekey/lib/ble/utils/BleUtilsModule.kt b/android/src/main/java/so/onekey/lib/ble/utils/BleUtilsModule.kt index bbf63f9..74dacdd 100644 --- a/android/src/main/java/so/onekey/lib/ble/utils/BleUtilsModule.kt +++ b/android/src/main/java/so/onekey/lib/ble/utils/BleUtilsModule.kt @@ -29,6 +29,13 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : override fun getName() = NAME + // Lets JS skip waiting for events this OS version never broadcasts. + override fun getConstants(): MutableMap = + hashMapOf( + "supportsKeyMissingEvent" to supportsKeyMissingEvent(), + "supportsEncryptionChangeEvent" to supportsEncryptionChangeEvent() + ) + private fun getBluetoothManager(): BluetoothManager? { if (bluetoothManager == null) { bluetoothManager = @@ -53,6 +60,14 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + if (supportsKeyMissingEvent()) { + filter.addAction(ACTION_KEY_MISSING) + } + if (supportsEncryptionChangeEvent()) { + filter.addAction(ACTION_ENCRYPTION_CHANGE) + // Tells JS when an earlier encryption result stops describing the current link. + filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED) + } val intentFilter = IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST) intentFilter.priority = IntentFilter.SYSTEM_HIGH_PRIORITY if (Build.VERSION.SDK_INT >= 34) { @@ -78,6 +93,24 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : .emit("onDeviceBondState", params) } + fun emitOnDeviceKeyMissing(params: WritableMap) { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("onDeviceKeyMissing", params) + } + + fun emitOnDeviceEncryptionChange(params: WritableMap) { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("onDeviceEncryptionChange", params) + } + + fun emitOnDeviceAclDisconnected(params: WritableMap) { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("onDeviceAclDisconnected", params) + } + // 事件监听管理 @ReactMethod fun addListener(eventName: String) { @@ -138,6 +171,9 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : var bonded = false var bonding = false + // False while bonding means the system (or another app) started it, e.g. the + // re-pairing Android runs by itself after it detects a lost bond. + var initiated = false when (device.bondState) { BluetoothDevice.BOND_BONDED -> { @@ -154,12 +190,14 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : val started = device.createBond() bonded = false bonding = started + initiated = started } } val map: WritableMap = Arguments.createMap() map.putBoolean("bonded", bonded) map.putBoolean("bonding", bonding) + map.putBoolean("initiated", initiated) callback.invoke(null, map) } catch (e: Exception) { Log.e(LOG_TAG, "pairDevice error: ${e.message}") @@ -262,18 +300,78 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : val bond = Arguments.createMap() bond.putString("state", bondStateStr) bond.putString("preState", prevBondStateStr) + // EXTRA_UNBOND_REASON is not exposed by the public Android SDK. + val reasonExtra = "android.bluetooth.device.extra.REASON" + if (bondState == BluetoothDevice.BOND_NONE && intent.hasExtra(reasonExtra)) { + bond.putInt("reason", intent.getIntExtra(reasonExtra, BluetoothDevice.ERROR)) + } val peripheral = Peripheral(device!!) val map = peripheral.asWritableMap() map.putMap("bondState", bond) Log.d(LOG_TAG, "onReceive BluetoothDevice BondState Change ${map}") module.emitOnDeviceBondState(map) + } else if (action == ACTION_KEY_MISSING) { + val device = deviceExtra(intent) ?: return + + val map = Arguments.createMap() + map.putString("id", device.address) + Log.d(LOG_TAG, "onReceive BluetoothDevice KeyMissing") + module.emitOnDeviceKeyMissing(map) + } else if (action == ACTION_ENCRYPTION_CHANGE) { + val device = deviceExtra(intent) ?: return + if (!isLeTransport(intent)) return + + val map = Arguments.createMap() + map.putString("id", device.address) + // HCI status: 0 on success, 6 (PIN or key missing) when the peer lost the bond. + map.putInt("status", intent.getIntExtra(EXTRA_ENCRYPTION_STATUS, BluetoothDevice.ERROR)) + map.putBoolean("enabled", intent.getBooleanExtra(EXTRA_ENCRYPTION_ENABLED, false)) + Log.d(LOG_TAG, "onReceive BluetoothDevice EncryptionChange ${map}") + module.emitOnDeviceEncryptionChange(map) + } else if (action == BluetoothDevice.ACTION_ACL_DISCONNECTED) { + val device = deviceExtra(intent) ?: return + if (!isLeTransport(intent)) return + + val map = Arguments.createMap() + map.putString("id", device.address) + Log.d(LOG_TAG, "onReceive BluetoothDevice AclDisconnected") + module.emitOnDeviceAclDisconnected(map) } } + + private fun deviceExtra(intent: Intent): BluetoothDevice? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) + } else { + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + } + + // A dual-mode peer reports BR/EDR events too; only the LE link carries GATT. + private fun isLeTransport(intent: Intent): Boolean { + if (!intent.hasExtra(EXTRA_TRANSPORT)) return true + return intent.getIntExtra(EXTRA_TRANSPORT, TRANSPORT_LE) == TRANSPORT_LE + } } companion object { const val NAME = "BleUtilsModule" const val LOG_TAG: String = "RNBleUtils" + + // These BluetoothDevice constants are public from API 36. The literals keep this + // module building against older compileSdk versions. + const val ACTION_KEY_MISSING = "android.bluetooth.device.action.KEY_MISSING" + const val ACTION_ENCRYPTION_CHANGE = "android.bluetooth.device.action.ENCRYPTION_CHANGE" + private const val EXTRA_ENCRYPTION_STATUS = "android.bluetooth.device.extra.ENCRYPTION_STATUS" + private const val EXTRA_ENCRYPTION_ENABLED = "android.bluetooth.device.extra.ENCRYPTION_ENABLED" + // BluetoothDevice.EXTRA_TRANSPORT and TRANSPORT_LE, public from API 33 and 23. + private const val EXTRA_TRANSPORT = "android.bluetooth.device.extra.TRANSPORT" + private const val TRANSPORT_LE = 2 + private const val LINK_SECURITY_EVENTS_MIN_SDK = 36 + + fun supportsKeyMissingEvent(): Boolean = Build.VERSION.SDK_INT >= LINK_SECURITY_EVENTS_MIN_SDK + + fun supportsEncryptionChangeEvent(): Boolean = + Build.VERSION.SDK_INT >= LINK_SECURITY_EVENTS_MIN_SDK } } diff --git a/package.json b/package.json index e037dc9..f10e6f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ble-utils", - "version": "0.1.6", + "version": "0.1.9", "description": "ble uilts", "source": "./src/index.tsx", "main": "./dist/commonjs/index.js", diff --git a/src/index.ts b/src/index.ts index 6704659..229b741 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,39 @@ import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; -import type { BleState, Peripheral, BondState, AdvertisingData } from './type'; +import type { + BleState, + Peripheral, + BondState, + AdvertisingData, + KeyMissingPeripheral, + EncryptionChangePeripheral, + AclDisconnectedPeripheral, +} from './type'; const { BleUtilsModule } = NativeModules; type PairDeviceResult = { bonded: boolean; bonding: boolean; + /** + * [Android only] True when this call started the bonding. False while `bonding` is + * true means the system started it, e.g. its own re-pairing after a lost bond. + * Undefined on native builds that predate the field. + */ + initiated?: boolean; +}; + +type NativeConstantName = + | 'supportsKeyMissingEvent' + | 'supportsEncryptionChangeEvent'; + +const readNativeFlag = (name: NativeConstantName): boolean => { + if (Platform.OS !== 'android' || !BleUtilsModule) return false; + // Interop exposes legacy constants through getConstants(); the bridge exposes them + // as plain properties. + const constants = + typeof BleUtilsModule.getConstants === 'function' + ? BleUtilsModule.getConstants() + : BleUtilsModule; + return constants?.[name] === true; }; class BleUtils { @@ -115,7 +144,91 @@ class BleUtils { this.UiEventEmitter?.removeAllListeners('onDeviceBondState'); }; } + + /** + * [Android only] + * Whether this OS version and native build report `onDeviceKeyMissing`. When false + * the event never fires, so callers should not wait for it. + */ + supportsDeviceKeyMissing() { + return readNativeFlag('supportsKeyMissingEvent'); + } + + /** + * [Android 16+ only] + * A bonded device could not provide its keys when the link was encrypted: the device + * was wiped or removed the bond. Android keeps its side of the bond, so the device + * stays unusable until the user forgets it in system Bluetooth settings. + * Each subscription is removed on its own, so several callers can listen at once. + * @param callback + */ + onDeviceKeyMissing(callback: (peripheral: KeyMissingPeripheral) => void) { + const subscription = this.UiEventEmitter?.addListener( + 'onDeviceKeyMissing', + callback + ); + return () => { + subscription?.remove(); + }; + } + + /** + * [Android only] + * Whether this OS version and native build report `onDeviceEncryptionChange` and + * `onDeviceAclDisconnected`. When false those events never fire. + */ + supportsDeviceEncryptionChange() { + return readNativeFlag('supportsEncryptionChangeEvent'); + } + + /** + * [Android 16+ only] + * The LE link to a device finished an encryption attempt. Android starts it on its own + * right after connecting to a bonded device, so this reports whether the stored bond + * still works before any request that needs encryption is sent. + * Each subscription is removed on its own, so several callers can listen at once. + * @param callback + */ + onDeviceEncryptionChange( + callback: (peripheral: EncryptionChangePeripheral) => void + ) { + const subscription = this.UiEventEmitter?.addListener( + 'onDeviceEncryptionChange', + callback + ); + return () => { + subscription?.remove(); + }; + } + + /** + * [Android 16+ only] + * The LE link to a device is gone, so earlier encryption results no longer apply. + * Unlike a GATT disconnect, this does not fire while the system keeps the link alive + * for another client. + * @param callback + */ + onDeviceAclDisconnected( + callback: (peripheral: AclDisconnectedPeripheral) => void + ) { + const subscription = this.UiEventEmitter?.addListener( + 'onDeviceAclDisconnected', + callback + ); + return () => { + subscription?.remove(); + }; + } } export default new BleUtils(); -export type { BleState, Peripheral, BondState, AdvertisingData }; +export type { + BleState, + Peripheral, + BondState, + AdvertisingData, + KeyMissingPeripheral, + EncryptionChangePeripheral, + AclDisconnectedPeripheral, + PairDeviceResult, +}; diff --git a/src/type.ts b/src/type.ts index 1f6d920..31af36a 100644 --- a/src/type.ts +++ b/src/type.ts @@ -39,6 +39,27 @@ export interface Peripheral { export interface BondState { state: string; preState: string; + /** Android EXTRA_UNBOND_REASON, when supplied by the system. */ + reason?: number; +} + +export interface KeyMissingPeripheral { + /** Device MAC address. */ + id: string; +} + +export interface EncryptionChangePeripheral { + /** Device MAC address. */ + id: string; + /** HCI status of the encryption procedure: 0 on success, 6 when the peer lost the bond. */ + status: number; + /** Whether the LE link is encrypted after the change. */ + enabled: boolean; +} + +export interface AclDisconnectedPeripheral { + /** Device MAC address. */ + id: string; } export interface AdvertisingData {