From b189c0e7f6ac89a2446f183fc302d76d7d6990c5 Mon Sep 17 00:00:00 2001 From: Nodonisko Date: Wed, 13 Mar 2024 19:25:58 +0100 Subject: [PATCH] BT transport + discovery --- .../transport-native/src/api/bluetoothApi.ts | 522 ++++++++++++++++++ packages/transport-native/src/api/char.json | 142 +++++ packages/transport-native/src/index.ts | 2 + .../transport-native/src/nativeBluetooth.ts | 36 ++ suite-native/app/app.config.ts | 1 + suite-native/app/package.json | 1 + .../src/components/ConnectDeviceSreenView.tsx | 40 +- .../screens/ConnectAndUnlockDeviceScreen.tsx | 78 ++- suite-native/state/src/extraDependencies.ts | 20 +- yarn.lock | 15 +- 10 files changed, 839 insertions(+), 18 deletions(-) create mode 100644 packages/transport-native/src/api/bluetoothApi.ts create mode 100644 packages/transport-native/src/api/char.json create mode 100644 packages/transport-native/src/nativeBluetooth.ts diff --git a/packages/transport-native/src/api/bluetoothApi.ts b/packages/transport-native/src/api/bluetoothApi.ts new file mode 100644 index 000000000000..a2d5466499e7 --- /dev/null +++ b/packages/transport-native/src/api/bluetoothApi.ts @@ -0,0 +1,522 @@ +/* eslint-disable require-await */ +import { + BleErrorCode, + BleManager, + Device, + LogLevel, + ScanOptions, + Characteristic, +} from 'react-native-ble-plx'; + +import { AbstractApi, AbstractApiConstructorParams } from '@trezor/transport/src/api/abstract'; +import * as ERRORS from '@trezor/transport/src/errors'; +import { AsyncResultWithTypedError } from '@trezor/transport/src/types'; + +interface ConstructorParams extends AbstractApiConstructorParams {} + +interface TransportInterfaceDevice { + session?: null | string; + path: string; + device: USBDevice; +} + +const DEBUG_LOGS = true; + +const debugLog = (...args: any[]) => { + if (DEBUG_LOGS) { + // eslint-disable-next-line no-console + console.log(...args); + } +}; + +const bluetoothInfoCache: { [deviceUuid: string]: any } = {}; // Allows us to give more granulary error messages. + +// connectOptions is actually used by react-native-ble-plx even if comment above ConnectionOptions says it's not used +let connectOptions: Record = { + // 156 bytes to max the iOS < 10 limit (158 bytes) + // (185 bytes for iOS >= 10)(up to 512 bytes for Android, but could be blocked at 23 bytes) + requestMTU: 247, + // Priority 1 = high. TODO: Check firmware update over BLE PR before merging + connectionPriority: 1, +}; + +/** + * Local error. We cast it to "device disconnected during action" from bridge as it means the same + */ +const INTERFACE_DEVICE_DISCONNECTED = 'The device was disconnected.' as const; + +/** + * Returns the instance of the Bluetooth Low Energy Manager. It initializes it only + * when it's first needed, preventing the permission prompt happening prematurely. + * Important: Do NOT access the _bleManager variable directly. + * Use this function instead. + * @returns {BleManager} - The instance of the BleManager. + */ +let _bleManager: BleManager | null = null; +export const bleManagerInstance = (): BleManager => { + if (!_bleManager) { + _bleManager = new BleManager(); + _bleManager.setLogLevel(LogLevel.Verbose); + } + + return _bleManager; +}; + +export const NUS_SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'; +export const NUS_CHARACTERISTIC_NOTIFY = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'; +export const NUS_CHARACTERISTIC_TX = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; +export const devicesUUIDs: Array = [NUS_SERVICE_UUID]; +const READ_TIMEOUT_MS = 10000; +const READ_FREQUENCY_MS = 10; + +const scanOptions: ScanOptions = {}; + +type Base64String = string; + +class NativeBleManager { + bleManager: BleManager; + private devicesScanList: Device[] = []; + public onconnect?: (device: Device) => void; + public ondisconnect?: (device: Device) => void; + private devicesReadOutput: { + [deviceId: string]: { value: Base64String; timestamp: number }[]; + } = {}; + + // There could be devices that are connected to system but not to our app + appConnectedDevices: Device[] = []; + + constructor() { + this.bleManager = bleManagerInstance(); + } + + public scanDevices = (scanDevicesCallback: (scannedDevices: Device[]) => void) => { + this.bleManager.startDeviceScan(devicesUUIDs, scanOptions, (error, scannedDevice) => { + if (error) { + debugLog('Scan error'); + console.error(error); + // TODO: is scan stopped automatically if error occurs? + } + if (scannedDevice) { + debugLog('Scanned device: ', scannedDevice.id, scannedDevice.localName); + + if (this.devicesScanList.find(d => d.id === scannedDevice.id)) { + return; + } + + this.devicesScanList.push(scannedDevice); + scanDevicesCallback(this.devicesScanList); + } + }); + }; + + public stopDeviceScan = () => { + this.bleManager.stopDeviceScan(); + }; + + public openDevice = async ({ + deviceOrId, + // needsReconnect, + timeoutMs, + }: { + deviceOrId: Device | string; + // needsReconnect: boolean; + timeoutMs?: number; + }): Promise => { + let device: Device; + debugLog(`Opening ${deviceOrId}`); + + if (typeof deviceOrId === 'string') { + debugLog(`Trying to open device: ${deviceOrId}`); + // await awaitsBleOn(bleManagerInstance()); + + // Returns a list of known devices by their identifiers + const devices = await bleManagerInstance().devices([deviceOrId]); + debugLog(`Found ${devices.length} already known device(s) with given id`, { + deviceOrId, + }); + [device] = devices; + + if (!device) { + // Returns a list of the peripherals currently connected to the system + // which have discovered services, connected to system doesn't mean + // connected to our app, we check that below. + const connectedDevices = await bleManagerInstance().connectedDevices(devicesUUIDs); + const connectedDevicesFiltered = connectedDevices.filter(d => d.id === deviceOrId); + debugLog( + `No known device with given id. Found ${connectedDevicesFiltered.length} devices from already connected devices`, + { deviceOrId }, + ); + [device] = connectedDevicesFiltered; + } + + if (!device) { + // We still don't have a device, so we attempt to connect to it. + debugLog( + `No known nor connected devices with given id. Trying to connect to device`, + { + deviceOrId, + timeoutMs, + }, + ); + + // Nb ConnectionOptions dropped since it's not used internally by ble-plx. + try { + device = await bleManagerInstance().connectToDevice(deviceOrId, connectOptions); + } catch (e: any) { + debugLog(`Error code: ${e.errorCode}`); + if (e.errorCode === BleErrorCode.DeviceMTUChangeFailed) { + // If the MTU update did not work, we try to connect without requesting for a specific MTU + connectOptions = {}; + device = await bleManagerInstance().connectToDevice(deviceOrId); + } else { + throw e; + } + } + } + + if (!device) { + throw new Error(`Can't open device ${device}`); + } + } else { + // It was already a Device + device = deviceOrId; + } + + if (!(await device.isConnected())) { + debugLog(`Device found but not connected. connecting...`, { + timeoutMs, + connectOptions, + }); + try { + await device.connect({ ...connectOptions }); + } catch (error: any) { + debugLog(`Connect error`, { error }); + if (error.errorCode === BleErrorCode.DeviceMTUChangeFailed) { + debugLog(`Device mtu=${device.mtu}, reconnecting`); + connectOptions = {}; + await device.connect(); + } else if ( + error.iosErrorCode === 14 || + error.reason === 'Peer removed pairing information' + ) { + debugLog(`iOS broken pairing`, { + device, + bluetoothInfoCache: bluetoothInfoCache[device.id], + }); + const { deviceModel } = bluetoothInfoCache[device.id] || {}; + const { productName } = deviceModel || {}; + throw new Error( + `Peer removed pairing ${{ + deviceName: device.name, + productName, + }}`, + ); + } else { + throw error; + } + } + } + + this.bleManager.onDeviceDisconnected(device.id, async (error, disconnectedDevice) => { + if (error) { + console.error('Device disconnected error', error); + } + if (disconnectedDevice) { + this.removeDeviceFromAppConnectedDevices(disconnectedDevice); + } + }); + + await device.discoverAllServicesAndCharacteristics(); + + let characteristics: Characteristic[] = + await device.characteristicsForService(NUS_SERVICE_UUID); + + debugLog('Characteristics: ', JSON.stringify(characteristics)); + + device.monitorCharacteristicForService( + NUS_SERVICE_UUID, + NUS_CHARACTERISTIC_NOTIFY, + (error, characteristic) => { + if (error) { + console.error('Error monitoring characteristic: ', error); + + return; + } + if (characteristic) { + debugLog('Received data: ', characteristic.value); + if (characteristic?.value) { + this.addDeviceReadOutput(device.id, characteristic.value); + } + } else { + console.error('No characteristic received'); + } + }, + ); + + this.addDeviceToAppConnectedDevices(device); + + debugLog(`Device connected: ${device.id}`); + debugLog(`Device manufacturerData: ${device.manufacturerData}`); + + return device; + + // TODO: handle needsReconnect + }; + + public findDevice = (deviceId: string) => { + return this.devicesScanList.find(d => d.id === deviceId); + }; + + public closeDevice = async (deviceId: string) => { + debugLog(`Closing ${deviceId}`); + const device = this.findDevice(deviceId); + if (device === undefined) { + console.error(`Device ${deviceId} not found for closing`); + } + await device?.cancelConnection(); + }; + + private addDeviceToAppConnectedDevices = (device: Device) => { + debugLog(`Adding device to app connected devices: ${device.id}`); + if (!this.appConnectedDevices.find(d => d.id === device.id)) { + this.appConnectedDevices.push(device); + if (this.onconnect) { + this.onconnect(device); + } + } + }; + + private removeDeviceFromAppConnectedDevices = (device: Device) => { + debugLog(`Removing device from app connected devices: ${device.id}`); + if (this.appConnectedDevices.find(d => d.id === device.id)) { + this.appConnectedDevices = this.appConnectedDevices.filter(d => d.id !== device.id); + if (this.ondisconnect) { + this.ondisconnect(device); + } + } + }; + + private addDeviceReadOutput = (deviceId: string, value: string) => { + debugLog(`Adding device read output: ${deviceId} ${value}`); + if (!this.devicesReadOutput[deviceId]) { + this.devicesReadOutput[deviceId] = []; + } + this.devicesReadOutput[deviceId].push({ value, timestamp: Date.now() }); + // sort that oldest are last so when we read we can use pop + this.devicesReadOutput[deviceId].sort((a, b) => b.timestamp - a.timestamp); + debugLog( + `Device read output: ${deviceId} ${JSON.stringify(this.devicesReadOutput[deviceId], null, 2)}`, + ); + }; + + public read = (deviceId: string): Promise => { + debugLog(`Reading from ${deviceId}`); + + return new Promise((resolve, reject) => { + const startTime = Date.now(); + + // Define a function that tries to read the last element of the array + const tryRead = () => { + if ( + !this.devicesReadOutput[deviceId] || + this.devicesReadOutput[deviceId].length === 0 + ) { + // If the array is empty and we have not exceeded 10 seconds, we try again + if (Date.now() - startTime < READ_TIMEOUT_MS) { + setTimeout(tryRead, READ_FREQUENCY_MS); // Wait for 10ms before trying again + } else { + // If we've waited more than 10 seconds, we reject the promise + reject(new Error('Failed to read from the array within 10 seconds.')); + } + } else { + const lastElement = this.devicesReadOutput[deviceId].pop(); + debugLog("We're reading from the array.", JSON.stringify(lastElement, null, 2)); + + // If the array is not empty, we resolve the promise with the last element + resolve(lastElement?.value); + } + }; + + tryRead(); + }); + }; + + public write = async (deviceId: string, message: Base64String) => { + const device = this.findDevice(deviceId); + if (!device) { + console.error(`Device ${deviceId} not found for writing`); + } + try { + const messageBuffer = Buffer.from(message, 'base64'); + console.log('Buffer: ', messageBuffer); + console.log('Buffer size: ', messageBuffer.length); + const characteristic = await device.writeCharacteristicWithResponseForService( + NUS_SERVICE_UUID, + NUS_CHARACTERISTIC_TX, + message, + ); + debugLog('Write successful'); + } catch (e) { + console.error('Error writing: ', JSON.stringify(e)); + } + }; +} + +export const nativeBleManager = new NativeBleManager(); + +export class BluetoothApi extends AbstractApi { + devices: TransportInterfaceDevice[] = []; + + constructor({ logger }: ConstructorParams) { + super({ logger }); + + nativeBleManager.onconnect = device => { + this.devices = [...this.devices, ...this.createDevices([device])]; + this.emit( + 'transport-interface-change', + this.devices.map(d => d.path), + ); + }; + + nativeBleManager.ondisconnect = device => { + const index = this.devices.findIndex(d => d.path === device.id); + if (index > -1) { + this.devices.splice(index, 1); + this.emit( + 'transport-interface-change', + this.devices.map(d => d.path), + ); + } else { + this.emit('transport-interface-error', ERRORS.DEVICE_NOT_FOUND); + this.logger.error('device that should be removed does not exist in state'); + } + }; + } + + private createDevices(devices: Device[]): TransportInterfaceDevice[] { + return devices.map(device => ({ + path: device.id, + device: { + usbVersionMajor: 1, + usbVersionMinor: 1, + usbVersionSubminor: 1, + deviceClass: 0, + deviceSubclass: 0, + deviceProtocol: 0, + vendorId: 1234, + productId: 1234, + deviceVersionMajor: 1, + deviceVersionMinor: 0, + deviceVersionSubminor: 0, + manufacturerName: 'Trezor', + productName: 'Trezor Device', + serialNumber: 'serialNumber', + configuration: undefined, + configurations: [], + opened: true, + open: async () => {}, + close: () => nativeBleManager.closeDevice(device.id), + forget: async () => {}, + } as any, + })); + } + + public async enumerate() { + try { + const devices = nativeBleManager.appConnectedDevices; + + this.devices = this.createDevices(devices); + + return this.success(this.devices.map(d => d.path)); + } catch (err) { + // this shouldn't throw + return this.unknownError(err, []); + } + } + + public async read( + path: string, + ): AsyncResultWithTypedError< + ArrayBuffer, + | typeof ERRORS.DEVICE_NOT_FOUND + | typeof ERRORS.INTERFACE_UNABLE_TO_OPEN_DEVICE + | typeof ERRORS.INTERFACE_DATA_TRANSFER + | typeof ERRORS.DEVICE_DISCONNECTED_DURING_ACTION + | typeof ERRORS.UNEXPECTED_ERROR + > { + const device = nativeBleManager.findDevice(path); + if (!device) { + return this.error({ error: ERRORS.DEVICE_NOT_FOUND }); + } + + try { + const res = await nativeBleManager.read(path); + + if (!res) { + return this.error({ error: ERRORS.INTERFACE_DATA_TRANSFER }); + } + + // convert base64 string to ArrayBuffer + const data = Buffer.from(res, 'base64'); + + return this.success(data); + } catch (err) { + if (err.message === INTERFACE_DEVICE_DISCONNECTED) { + return this.error({ error: ERRORS.DEVICE_DISCONNECTED_DURING_ACTION }); + } + + return this.error({ error: ERRORS.INTERFACE_DATA_TRANSFER, message: err.message }); + } + } + + public async write(path: string, buffer: Buffer) { + const device = nativeBleManager.findDevice(path); + if (!device) { + return this.error({ error: ERRORS.DEVICE_NOT_FOUND }); + } + + // Set the target chunk size + const chunkSize = 244; + const chunks = []; + + for (let i = 0; i < buffer.length; i += chunkSize) { + let chunk = buffer.slice(i, i + chunkSize); + + // Check if the chunk is smaller than the target size and pad it with zeros + if (chunk.length < chunkSize) { + const paddingLength = chunkSize - chunk.length; + const padding = Buffer.alloc(paddingLength, 0); // Create a buffer filled with zeros for padding + chunk = Buffer.concat([chunk, padding]); // Concatenate the original chunk with the padding + } + + // Convert the chunk to a base64 string + const base64Chunk = chunk.toString('base64'); + chunks.push(base64Chunk); + } + + try { + for (const chunk of chunks) { + await nativeBleManager.write(path, chunk); + } + + return this.success(undefined); + } catch (err) { + return this.error({ error: ERRORS.INTERFACE_DATA_TRANSFER, message: err.message }); + } + } + + public async openDevice(path: string, first: boolean) { + // BT does not need to be opened, it opened when connected + return this.success(undefined); + } + + public async openInternal(path: string, first: boolean) { + return this.success(undefined); + } + + public async closeDevice(path: string) { + // BT does not need to be closed, it closed when disconnected + + return this.success(undefined); + } +} diff --git a/packages/transport-native/src/api/char.json b/packages/transport-native/src/api/char.json new file mode 100644 index 000000000000..62fd3477b6e0 --- /dev/null +++ b/packages/transport-native/src/api/char.json @@ -0,0 +1,142 @@ +[ + { + "value": null, + "isIndicatable": false, + "isNotifiable": false, + "isWritableWithoutResponse": true, + "isWritableWithResponse": true, + "serviceUUID": "6e400001-b5a3-f393-e0a9-e50e24dcca9e", + "deviceID": "6D:CB:AD:D4:70:CA", + "isReadable": false, + "serviceID": 9, + "isNotifying": false, + "uuid": "6e400002-b5a3-f393-e0a9-e50e24dcca9e", + "id": 10, + "_manager": { + "_eventEmitter": { + "_nativeModule": { + "StateChangeEvent": "StateChangeEvent", + "RestoreStateEvent": "RestoreStateEvent", + "ScanEvent": "ScanEvent", + "DisconnectionEvent": "DisconnectionEvent", + "ReadEvent": "ReadEvent" + } + }, + "_uniqueId": 7, + "_activePromises": {}, + "_activeSubscriptions": { "4": {} }, + "_errorCodesToMessagesMapping": { + "0": "Unknown error occurred. This is probably a bug! Check reason property.", + "1": "BleManager was destroyed", + "2": "Operation was cancelled", + "3": "Operation timed out", + "4": "Operation was rejected", + "5": "Invalid UUIDs or IDs were passed: {internalMessage}", + "100": "BluetoothLE is unsupported on this device", + "101": "Device is not authorized to use BluetoothLE", + "102": "BluetoothLE is powered off", + "103": "BluetoothLE is in unknown state", + "104": "BluetoothLE is resetting", + "105": "Bluetooth state change failed", + "200": "Device {deviceID} connection failed", + "201": "Device {deviceID} was disconnected", + "202": "RSSI read failed for device {deviceID}", + "203": "Device {deviceID} is already connected", + "204": "Device {deviceID} not found", + "205": "Device {deviceID} is not connected", + "206": "Device {deviceID} could not change MTU size", + "300": "Services discovery failed for device {deviceID}", + "301": "Included services discovery failed for device {deviceID} and service: {serviceUUID}", + "302": "Service {serviceUUID} for device {deviceID} not found", + "303": "Services not discovered for device {deviceID}", + "400": "Characteristic discovery failed for device {deviceID} and service {serviceUUID}", + "401": "Characteristic {characteristicUUID} write failed for device {deviceID} and service {serviceUUID}", + "402": "Characteristic {characteristicUUID} read failed for device {deviceID} and service {serviceUUID}", + "403": "Characteristic {characteristicUUID} notify change failed for device {deviceID} and service {serviceUUID}", + "404": "Characteristic {characteristicUUID} not found", + "405": "Characteristics not discovered for device {deviceID} and service {serviceUUID}", + "406": "Cannot write to characteristic {characteristicUUID} with invalid data format: {internalMessage}", + "500": "Descriptor {descriptorUUID} discovery failed for device {deviceID}, service {serviceUUID} and characteristic {characteristicUUID}", + "501": "Descriptor {descriptorUUID} write failed for device {deviceID}, service {serviceUUID} and characteristic {characteristicUUID}", + "502": "Descriptor {descriptorUUID} read failed for device {deviceID}, service {serviceUUID} and characteristic {characteristicUUID}", + "503": "Descriptor {descriptorUUID} not found", + "504": "Descriptors not discovered for device {deviceID}, service {serviceUUID} and characteristic {characteristicUUID}", + "505": "Cannot write to descriptor {descriptorUUID} with invalid data format: {internalMessage}", + "506": "Cannot write to descriptor {descriptorUUID}. It's not allowed by iOS and therefore forbidden on Android as well.", + "600": "Cannot start scanning operation", + "601": "Location services are disabled" + }, + "_scanEventSubscription": {} + } + }, + { + "value": null, + "isIndicatable": false, + "isNotifiable": true, + "isWritableWithoutResponse": false, + "isWritableWithResponse": false, + "serviceUUID": "6e400001-b5a3-f393-e0a9-e50e24dcca9e", + "deviceID": "6D:CB:AD:D4:70:CA", + "isReadable": false, + "serviceID": 9, + "isNotifying": false, + "uuid": "6e400003-b5a3-f393-e0a9-e50e24dcca9e", + "id": 11, + "_manager": { + "_eventEmitter": { + "_nativeModule": { + "StateChangeEvent": "StateChangeEvent", + "RestoreStateEvent": "RestoreStateEvent", + "ScanEvent": "ScanEvent", + "DisconnectionEvent": "DisconnectionEvent", + "ReadEvent": "ReadEvent" + } + }, + "_uniqueId": 7, + "_activePromises": {}, + "_activeSubscriptions": { "4": {} }, + "_errorCodesToMessagesMapping": { + "0": "Unknown error occurred. This is probably a bug! Check reason property.", + "1": "BleManager was destroyed", + "2": "Operation was cancelled", + "3": "Operation timed out", + "4": "Operation was rejected", + "5": "Invalid UUIDs or IDs were passed: {internalMessage}", + "100": "BluetoothLE is unsupported on this device", + "101": "Device is not authorized to use BluetoothLE", + "102": "BluetoothLE is powered off", + "103": "BluetoothLE is in unknown state", + "104": "BluetoothLE is resetting", + "105": "Bluetooth state change failed", + "200": "Device {deviceID} connection failed", + "201": "Device {deviceID} was disconnected", + "202": "RSSI read failed for device {deviceID}", + "203": "Device {deviceID} is already connected", + "204": "Device {deviceID} not found", + "205": "Device {deviceID} is not connected", + "206": "Device {deviceID} could not change MTU size", + "300": "Services discovery failed for device {deviceID}", + "301": "Included services discovery failed for device {deviceID} and service: {serviceUUID}", + "302": "Service {serviceUUID} for device {deviceID} not found", + "303": "Services not discovered for device {deviceID}", + "400": "Characteristic discovery failed for device {deviceID} and service {serviceUUID}", + "401": "Characteristic {characteristicUUID} write failed for device {deviceID} and service {serviceUUID}", + "402": "Characteristic {characteristicUUID} read failed for device {deviceID} and service {serviceUUID}", + "403": "Characteristic {characteristicUUID} notify change failed for device {deviceID} and service {serviceUUID}", + "404": "Characteristic {characteristicUUID} not found", + "405": "Characteristics not discovered for device {deviceID} and service {serviceUUID}", + "406": "Cannot write to characteristic {characteristicUUID} with invalid data format: {internalMessage}", + "500": "Descriptor {descriptorUUID} discovery failed for device {deviceID}, service {serviceUUID} and characteristic {characteristicUUID}", + "501": "Descriptor {descriptorUUID} write failed for device {deviceID}, service {serviceUUID} and characteristic {characteristicUUID}", + "502": "Descriptor {descriptorUUID} read failed for device {deviceID}, service {serviceUUID} and characteristic {characteristicUUID}", + "503": "Descriptor {descriptorUUID} not found", + "504": "Descriptors not discovered for device {deviceID}, service {serviceUUID} and characteristic {characteristicUUID}", + "505": "Cannot write to descriptor {descriptorUUID} with invalid data format: {internalMessage}", + "506": "Cannot write to descriptor {descriptorUUID}. It's not allowed by iOS and therefore forbidden on Android as well.", + "600": "Cannot start scanning operation", + "601": "Location services are disabled" + }, + "_scanEventSubscription": {} + } + } +] diff --git a/packages/transport-native/src/index.ts b/packages/transport-native/src/index.ts index 4e4a4fc9ef0f..9810eb332724 100644 --- a/packages/transport-native/src/index.ts +++ b/packages/transport-native/src/index.ts @@ -1 +1,3 @@ export { NativeUsbTransport } from './nativeUsb'; +export { NativeBluetoothTransport } from './nativeBluetooth'; +export { nativeBleManager } from './api/bluetoothApi'; diff --git a/packages/transport-native/src/nativeBluetooth.ts b/packages/transport-native/src/nativeBluetooth.ts new file mode 100644 index 000000000000..fd2d2f0a2dec --- /dev/null +++ b/packages/transport-native/src/nativeBluetooth.ts @@ -0,0 +1,36 @@ +import { + Transport as AbstractTransport, + AbstractApiTransport, + SessionsClient, + SessionsBackground, +} from '@trezor/transport'; + +import { BluetoothApi } from './api/bluetoothApi'; + +export class NativeBluetoothTransport extends AbstractApiTransport { + // TODO: Not sure how to solve this type correctly. + public name = 'NativeUsbTransport' as any; + + constructor(params?: ConstructorParameters[0]) { + const { messages, logger } = params || {}; + const sessionsBackground = new SessionsBackground(); + + const sessionsClient = new SessionsClient({ + requestFn: args => sessionsBackground.handleMessage(args), + registerBackgroundCallbacks: () => {}, + }); + + sessionsBackground.on('descriptors', descriptors => { + sessionsClient.emit('descriptors', descriptors); + }); + + super({ + messages, + api: new BluetoothApi({ + logger, + }), + + sessionsClient, + }); + } +} diff --git a/suite-native/app/app.config.ts b/suite-native/app/app.config.ts index 1b77d17470ae..512947a2653c 100644 --- a/suite-native/app/app.config.ts +++ b/suite-native/app/app.config.ts @@ -154,6 +154,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { }, }, ], + ['react-native-ble-plx', {}], '@trezor/react-native-usb/plugins/withUSBDevice.js', // Define FLIPPER_VERSION './plugins/withGradleProperties.js', diff --git a/suite-native/app/package.json b/suite-native/app/package.json index 6d449b458fd5..451de82481fb 100644 --- a/suite-native/app/package.json +++ b/suite-native/app/package.json @@ -87,6 +87,7 @@ "react": "18.2.0", "react-intl": "^6.6.2", "react-native": "0.73.2", + "react-native-ble-plx": "^3.1.2", "react-native-flipper": "^0.212.0", "react-native-gesture-handler": "2.15.0", "react-native-keyboard-aware-scroll-view": "0.9.5", diff --git a/suite-native/module-connect-device/src/components/ConnectDeviceSreenView.tsx b/suite-native/module-connect-device/src/components/ConnectDeviceSreenView.tsx index 25f5d50da069..68de2e6b7836 100644 --- a/suite-native/module-connect-device/src/components/ConnectDeviceSreenView.tsx +++ b/suite-native/module-connect-device/src/components/ConnectDeviceSreenView.tsx @@ -1,8 +1,10 @@ -import { ReactNode } from 'react'; +import { ReactNode, useState } from 'react'; +import { Device, BleError } from 'react-native-ble-plx'; import { Box } from '@suite-native/atoms'; import { Screen } from '@suite-native/navigation'; import { prepareNativeStyle, useNativeStyles, NativeStyleObject } from '@trezor/styles'; +import { nativeBleManager } from '@trezor/transport-native'; import { ConnectDeviceScreenHeader } from './ConnectDeviceScreenHeader'; @@ -22,6 +24,42 @@ export const ConnectDeviceSreenView = ({ shouldDisplayCancelButton, }: ConnectDeviceSreenViewProps) => { const { applyStyle } = useNativeStyles(); + const [devices, setDevices] = useState([]); + const [scanError, setScanError] = useState(); + const [isScanRunning, setIsScanRunning] = useState(false); + + const scanDevices = async () => { + setScanError(null); + setIsScanRunning(true); + setDevices([]); + + nativeBle; + + console.log('Starting device scan'); + const bleManager = bleManagerInstance(); + bleManager.startDeviceScan(devicesUUIDs, scanOptions, (error, scannedDevice) => { + if (error) { + console.log('Scan error'); + console.error(error); + setScanError(error); + // TODO: is scan stopped automatically if error occurs? + setIsScanRunning(false); + + return; + } + if (scannedDevice) { + console.log('Scanned device: ', scannedDevice.id, scannedDevice.localName); + + setDevices(devices => { + if (devices.find(d => d.id === scannedDevice.id)) { + return devices; + } + + return [...devices, scannedDevice]; + }); + } + }); + }; return ( { } }, [isDeviceAuthorized, device, dispatch, isFocused]); + const [devices, setDevices] = useState([]); + const [scanError, setScanError] = useState(); + const [isScanRunning, setIsScanRunning] = useState(false); + + const scanDevices = async () => { + setScanError(null); + setIsScanRunning(true); + setDevices([]); + + nativeBleManager.scanDevices(scannedDevices => { + setDevices(scannedDevices); + }); + }; + + const stopScanning = async () => { + nativeBleManager.stopDeviceScan(); + setIsScanRunning(false); + }; + + useEffect(() => { + return () => { + nativeBleManager.stopDeviceScan(); + }; + }, []); + return ( } @@ -57,7 +84,44 @@ export const ConnectAndUnlockDeviceScreen = () => { {translate('moduleConnectDevice.connectAndUnlockScreen.title')} - + {!isScanRunning ? ( + + ) : ( + + )} + {isScanRunning && ( + + Scanning for devices... + + + )} + {scanError && ( + + Scan error: {scanError.message} + + )} + + {devices.map(device => ( + + {device.name} + + + ))} + ); diff --git a/suite-native/state/src/extraDependencies.ts b/suite-native/state/src/extraDependencies.ts index b7d607f1207b..8b6939b98fa5 100644 --- a/suite-native/state/src/extraDependencies.ts +++ b/suite-native/state/src/extraDependencies.ts @@ -9,19 +9,21 @@ import { selectDevices } from '@suite-common/wallet-core'; import { selectFiatCurrencyCode, setFiatCurrency } from '@suite-native/module-settings'; import { PROTO } from '@trezor/connect'; import { mergeDeepObject } from '@trezor/utils'; -import { NativeUsbTransport } from '@trezor/transport-native'; +import { NativeBluetoothTransport, NativeUsbTransport } from '@trezor/transport-native'; const deviceType = Device.isDevice ? 'device' : 'emulator'; -const transportsPerDeviceType = { - device: Platform.select({ - ios: ['BridgeTransport', 'UdpTransport'], - android: [new NativeUsbTransport()], - }), - emulator: ['BridgeTransport', 'UdpTransport'], -} as const; +// const transportsPerDeviceType = { +// device: Platform.select({ +// ios: ['BridgeTransport', 'UdpTransport'], +// android: [new NativeUsbTransport()], +// }), +// emulator: ['BridgeTransport', 'UdpTransport'], +// } as const; -const transports = transportsPerDeviceType[deviceType]; +// const transports = transportsPerDeviceType[deviceType]; + +const transports = [new NativeBluetoothTransport()]; export const extraDependencies: ExtraDependencies = mergeDeepObject(extraDependenciesMock, { selectors: { diff --git a/yarn.lock b/yarn.lock index 2da9b5f4c73f..95e1d6003d77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3409,7 +3409,7 @@ __metadata: languageName: node linkType: hard -"@expo/config-plugins@npm:7.8.4, @expo/config-plugins@npm:~7.8.0, @expo/config-plugins@npm:~7.8.2": +"@expo/config-plugins@npm:7.8.4, @expo/config-plugins@npm:^7.2.5, @expo/config-plugins@npm:~7.8.0, @expo/config-plugins@npm:~7.8.2": version: 7.8.4 resolution: "@expo/config-plugins@npm:7.8.4" dependencies: @@ -8785,6 +8785,7 @@ __metadata: react: "npm:18.2.0" react-intl: "npm:^6.6.2" react-native: "npm:0.73.2" + react-native-ble-plx: "npm:^3.1.2" react-native-flipper: "npm:^0.212.0" react-native-gesture-handler: "npm:2.15.0" react-native-keyboard-aware-scroll-view: "npm:0.9.5" @@ -32715,6 +32716,18 @@ __metadata: languageName: node linkType: hard +"react-native-ble-plx@npm:^3.1.2": + version: 3.1.2 + resolution: "react-native-ble-plx@npm:3.1.2" + dependencies: + "@expo/config-plugins": "npm:^7.2.5" + peerDependencies: + react: "*" + react-native: "*" + checksum: 75e3960fcc8c236d0e39fe07a5abd10f617ae8e1cd4114e2179d0e33876aad0932ce229a39e768914f859b5029445b4e44e21e03d5f031cb680f9eee060e9f43 + languageName: node + linkType: hard + "react-native-flipper@npm:^0.212.0": version: 0.212.0 resolution: "react-native-flipper@npm:0.212.0"