From 2371f8fd69947d57e620bae5cb947c0a3696ba18 Mon Sep 17 00:00:00 2001 From: wabicai Date: Tue, 8 Sep 2026 21:03:59 +0900 Subject: [PATCH 1/3] fix: report Android bond failure reasons in 0.1.7 --- .../src/main/java/so/onekey/lib/ble/utils/BleUtilsModule.kt | 5 +++++ package.json | 2 +- src/type.ts | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) 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..598d9d8 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 @@ -262,6 +262,11 @@ 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() diff --git a/package.json b/package.json index e037dc9..012eda9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ble-utils", - "version": "0.1.6", + "version": "0.1.7", "description": "ble uilts", "source": "./src/index.tsx", "main": "./dist/commonjs/index.js", diff --git a/src/type.ts b/src/type.ts index 1f6d920..5ee4faa 100644 --- a/src/type.ts +++ b/src/type.ts @@ -39,6 +39,8 @@ export interface Peripheral { export interface BondState { state: string; preState: string; + /** Android EXTRA_UNBOND_REASON, when supplied by the system. */ + reason?: number; } export interface AdvertisingData { From 922139eb7a69c81a110a9663db071ec13aa1c830 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 21 Sep 2026 01:29:12 +0800 Subject: [PATCH 2/3] feat(android): report ACTION_KEY_MISSING as onDeviceKeyMissing in 0.1.8 --- .../so/onekey/lib/ble/utils/BleUtilsModule.kt | 39 ++++++++++++ package.json | 2 +- src/index.ts | 61 ++++++++++++++++++- src/type.ts | 5 ++ 4 files changed, 104 insertions(+), 3 deletions(-) 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 598d9d8..1f5fdd7 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,10 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : override fun getName() = NAME + // Lets JS skip waiting for an event this OS version never broadcasts. + override fun getConstants(): MutableMap = + hashMapOf("supportsKeyMissingEvent" to supportsKeyMissingEvent()) + private fun getBluetoothManager(): BluetoothManager? { if (bluetoothManager == null) { bluetoothManager = @@ -53,6 +57,9 @@ 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) + } val intentFilter = IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST) intentFilter.priority = IntentFilter.SYSTEM_HIGH_PRIORITY if (Build.VERSION.SDK_INT >= 34) { @@ -78,6 +85,12 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : .emit("onDeviceBondState", params) } + fun emitOnDeviceKeyMissing(params: WritableMap) { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("onDeviceKeyMissing", params) + } + // 事件监听管理 @ReactMethod fun addListener(eventName: String) { @@ -138,6 +151,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 +170,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}") @@ -273,6 +291,20 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : map.putMap("bondState", bond) Log.d(LOG_TAG, "onReceive BluetoothDevice BondState Change ${map}") module.emitOnDeviceBondState(map) + } else if (action == ACTION_KEY_MISSING) { + val device: BluetoothDevice? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra( + BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java + ) + } else { + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + } + if (device == null) return + + val map = Arguments.createMap() + map.putString("id", device.address) + Log.d(LOG_TAG, "onReceive BluetoothDevice KeyMissing") + module.emitOnDeviceKeyMissing(map) } } } @@ -280,5 +312,12 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : companion object { const val NAME = "BleUtilsModule" const val LOG_TAG: String = "RNBleUtils" + + // BluetoothDevice.ACTION_KEY_MISSING is public from API 36. The literal keeps this + // module building against older compileSdk versions. + const val ACTION_KEY_MISSING = "android.bluetooth.device.action.KEY_MISSING" + private const val KEY_MISSING_MIN_SDK = 36 + + fun supportsKeyMissingEvent(): Boolean = Build.VERSION.SDK_INT >= KEY_MISSING_MIN_SDK } } diff --git a/package.json b/package.json index 012eda9..ea43402 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ble-utils", - "version": "0.1.7", + "version": "0.1.8", "description": "ble uilts", "source": "./src/index.tsx", "main": "./dist/commonjs/index.js", diff --git a/src/index.ts b/src/index.ts index 6704659..db7d069 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,33 @@ import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; -import type { BleState, Peripheral, BondState, AdvertisingData } from './type'; +import type { + BleState, + Peripheral, + BondState, + AdvertisingData, + KeyMissingPeripheral, +} 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; +}; + +const readSupportsKeyMissingEvent = (): 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?.supportsKeyMissingEvent === true; }; class BleUtils { @@ -115,7 +138,41 @@ 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 readSupportsKeyMissingEvent(); + } + + /** + * [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(); + }; + } } export default new BleUtils(); -export type { BleState, Peripheral, BondState, AdvertisingData }; +export type { + BleState, + Peripheral, + BondState, + AdvertisingData, + KeyMissingPeripheral, + PairDeviceResult, +}; diff --git a/src/type.ts b/src/type.ts index 5ee4faa..5cd9fc5 100644 --- a/src/type.ts +++ b/src/type.ts @@ -43,6 +43,11 @@ export interface BondState { reason?: number; } +export interface KeyMissingPeripheral { + /** Device MAC address. */ + id: string; +} + export interface AdvertisingData { isConnectable?: boolean; localName?: string; From cc86db82b150d5a649b3268d83d02a043b0a75c0 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 21 Sep 2026 12:38:09 +0800 Subject: [PATCH 3/3] feat(android): report link encryption results and ACL disconnects in 0.1.9 --- .../so/onekey/lib/ble/utils/BleUtilsModule.kt | 82 +++++++++++++++---- package.json | 2 +- src/index.ts | 62 +++++++++++++- src/type.ts | 14 ++++ 4 files changed, 142 insertions(+), 18 deletions(-) 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 1f5fdd7..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,9 +29,12 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : override fun getName() = NAME - // Lets JS skip waiting for an event this OS version never broadcasts. + // Lets JS skip waiting for events this OS version never broadcasts. override fun getConstants(): MutableMap = - hashMapOf("supportsKeyMissingEvent" to supportsKeyMissingEvent()) + hashMapOf( + "supportsKeyMissingEvent" to supportsKeyMissingEvent(), + "supportsEncryptionChangeEvent" to supportsEncryptionChangeEvent() + ) private fun getBluetoothManager(): BluetoothManager? { if (bluetoothManager == null) { @@ -60,6 +63,11 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : 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) { @@ -91,6 +99,18 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : .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) { @@ -292,20 +312,45 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : Log.d(LOG_TAG, "onReceive BluetoothDevice BondState Change ${map}") module.emitOnDeviceBondState(map) } else if (action == ACTION_KEY_MISSING) { - val device: BluetoothDevice? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra( - BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java - ) - } else { - intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) - } - if (device == null) return + 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 } } @@ -313,11 +358,20 @@ class BleUtilsModule(private val reactContext: ReactApplicationContext) : const val NAME = "BleUtilsModule" const val LOG_TAG: String = "RNBleUtils" - // BluetoothDevice.ACTION_KEY_MISSING is public from API 36. The literal keeps this + // 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" - private const val KEY_MISSING_MIN_SDK = 36 - - fun supportsKeyMissingEvent(): Boolean = Build.VERSION.SDK_INT >= KEY_MISSING_MIN_SDK + 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 ea43402..f10e6f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ble-utils", - "version": "0.1.8", + "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 db7d069..229b741 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ import type { BondState, AdvertisingData, KeyMissingPeripheral, + EncryptionChangePeripheral, + AclDisconnectedPeripheral, } from './type'; const { BleUtilsModule } = NativeModules; @@ -19,7 +21,11 @@ type PairDeviceResult = { initiated?: boolean; }; -const readSupportsKeyMissingEvent = (): 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. @@ -27,7 +33,7 @@ const readSupportsKeyMissingEvent = (): boolean => { typeof BleUtilsModule.getConstants === 'function' ? BleUtilsModule.getConstants() : BleUtilsModule; - return constants?.supportsKeyMissingEvent === true; + return constants?.[name] === true; }; class BleUtils { @@ -145,7 +151,7 @@ class BleUtils { * the event never fires, so callers should not wait for it. */ supportsDeviceKeyMissing() { - return readSupportsKeyMissingEvent(); + return readNativeFlag('supportsKeyMissingEvent'); } /** @@ -165,6 +171,54 @@ class BleUtils { 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(); @@ -174,5 +228,7 @@ export type { BondState, AdvertisingData, KeyMissingPeripheral, + EncryptionChangePeripheral, + AclDisconnectedPeripheral, PairDeviceResult, }; diff --git a/src/type.ts b/src/type.ts index 5cd9fc5..31af36a 100644 --- a/src/type.ts +++ b/src/type.ts @@ -48,6 +48,20 @@ export interface KeyMissingPeripheral { 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 { isConnectable?: boolean; localName?: string;