diff --git a/packages/suite/src/actions/bluetooth/__tests__/remapKnownDevicesForLinux.test.ts b/packages/suite/src/actions/bluetooth/__tests__/remapKnownDevicesForLinux.test.ts new file mode 100644 index 00000000000..18a23db3a45 --- /dev/null +++ b/packages/suite/src/actions/bluetooth/__tests__/remapKnownDevicesForLinux.test.ts @@ -0,0 +1,70 @@ +import { BluetoothDevice } from '@trezor/transport-bluetooth'; + +import { remapKnownDevicesForLinux } from '../remapKnownDevicesForLinux'; + +const nearbyDeviceA: BluetoothDevice = { + id: 'New-Id-A', + data: [], + name: 'Trezor A', + lastUpdatedTimestamp: 1, + address: 'Address-Trezor-A-Staying-Same', + connected: false, + paired: false, + rssi: 0, +}; + +const nearbyDeviceC: BluetoothDevice = { + id: 'C', + data: [], + name: 'Trezor C', + lastUpdatedTimestamp: 1, + address: 'Address-Trezor-C', + connected: false, + paired: false, + rssi: 0, +}; + +const knownDeviceB: BluetoothDevice = { + id: 'B', + data: [], + name: 'Trezor A', + lastUpdatedTimestamp: 1, + address: 'Address-Trezor-B', + connected: false, + paired: false, + rssi: 0, +}; + +const knownDeviceA: BluetoothDevice = { + id: 'Original-Id A', + data: [], + name: 'Trezor B', + lastUpdatedTimestamp: 2, + address: 'Address-Trezor-A-Staying-Same', + connected: false, + paired: false, + rssi: 0, +}; + +describe(remapKnownDevicesForLinux.name, () => { + it('remaps the changed id of the device, while leaving the others intact', () => { + const result = remapKnownDevicesForLinux({ + nearbyDevices: [nearbyDeviceA, nearbyDeviceC], + knownDevices: [knownDeviceA, knownDeviceB], + }); + + expect(result).toEqual([ + { + address: 'Address-Trezor-A-Staying-Same', + connected: false, + data: [], + id: 'New-Id-A', + lastUpdatedTimestamp: 2, + name: 'Trezor B', + paired: false, + rssi: 0, + }, + knownDeviceB, // Is kept as it is + ]); + }); +}); diff --git a/packages/suite/src/actions/bluetooth/bluetoothActions.ts b/packages/suite/src/actions/bluetooth/bluetoothActions.ts deleted file mode 100644 index 94457659b17..00000000000 --- a/packages/suite/src/actions/bluetooth/bluetoothActions.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { createAction } from '@reduxjs/toolkit'; - -import { BluetoothDevice } from '@trezor/transport-bluetooth'; - -import { - BluetoothScanStatus, - DeviceBluetoothStatus, -} from '../../reducers/bluetooth/bluetoothReducer'; - -export const BLUETOOTH_PREFIX = '@suite/bluetooth'; - -export const bluetoothAdapterEventAction = createAction( - `${BLUETOOTH_PREFIX}/adapter-event`, - ({ isPowered }: { isPowered: boolean }) => ({ payload: { isPowered } }), -); - -export const bluetoothDeviceListUpdate = createAction( - `${BLUETOOTH_PREFIX}/device-list-update`, - ({ devices }: { devices: BluetoothDevice[] }) => ({ payload: { devices } }), -); - -export const bluetoothConnectDeviceEventAction = createAction( - `${BLUETOOTH_PREFIX}/device-connection-status`, - ({ connectionStatus, id }: { id: string; connectionStatus: DeviceBluetoothStatus }) => ({ - payload: { id, connectionStatus }, - }), -); - -export const bluetoothScanStatusAction = createAction( - `${BLUETOOTH_PREFIX}/scan-status`, - ({ status }: { status: BluetoothScanStatus }) => ({ payload: { status } }), -); - -export const allBluetoothActions = { - bluetoothAdapterEventAction, - bluetoothDeviceListUpdate, - bluetoothConnectDeviceEventAction, - bluetoothScanStatusAction, -}; diff --git a/packages/suite/src/actions/bluetooth/bluetoothConnectDeviceThunk.ts b/packages/suite/src/actions/bluetooth/bluetoothConnectDeviceThunk.ts index b5bd33d7d3f..79457ffae86 100644 --- a/packages/suite/src/actions/bluetooth/bluetoothConnectDeviceThunk.ts +++ b/packages/suite/src/actions/bluetooth/bluetoothConnectDeviceThunk.ts @@ -1,15 +1,43 @@ +import { BLUETOOTH_PREFIX, bluetoothActions } from '@suite-common/bluetooth'; import { createThunk } from '@suite-common/redux-utils'; +import { notificationsActions } from '@suite-common/toast-notifications'; import { bluetoothIpc } from '@trezor/transport-bluetooth'; -import { BLUETOOTH_PREFIX } from './bluetoothActions'; +type BluetoothConnectDeviceThunkResult = { + success: boolean; +}; -type ThunkResponse = ReturnType; - -export const bluetoothConnectDeviceThunk = createThunk( +export const bluetoothConnectDeviceThunk = createThunk< + BluetoothConnectDeviceThunkResult, + { id: string }, + void +>( `${BLUETOOTH_PREFIX}/bluetoothConnectDeviceThunk`, - async ({ id }, { fulfillWithValue }) => { + async ({ id }, { fulfillWithValue, dispatch }) => { const result = await bluetoothIpc.connectDevice(id); - return fulfillWithValue(result); + if (!result.success) { + dispatch( + bluetoothActions.connectDeviceEventAction({ + id, + connectionStatus: { type: 'error', error: result.error }, + }), + ); + dispatch( + notificationsActions.addToast({ + type: 'error', + error: result.error, + }), + ); + } else { + dispatch( + bluetoothActions.connectDeviceEventAction({ + id, + connectionStatus: { type: 'connected' }, + }), + ); + } + + return fulfillWithValue({ success: result.success }); }, ); diff --git a/packages/suite/src/actions/bluetooth/bluetoothStartScanningThunk.ts b/packages/suite/src/actions/bluetooth/bluetoothStartScanningThunk.ts index a1a389033df..62d222f9562 100644 --- a/packages/suite/src/actions/bluetooth/bluetoothStartScanningThunk.ts +++ b/packages/suite/src/actions/bluetooth/bluetoothStartScanningThunk.ts @@ -1,11 +1,11 @@ +import { BLUETOOTH_PREFIX, bluetoothActions } from '@suite-common/bluetooth'; import { createThunk } from '@suite-common/redux-utils'; import { bluetoothIpc } from '@trezor/transport-bluetooth'; -import { BLUETOOTH_PREFIX } from './bluetoothActions'; - export const bluetoothStartScanningThunk = createThunk( `${BLUETOOTH_PREFIX}/bluetoothStartScanningThunk`, - _ => { + (_, { dispatch }) => { + dispatch(bluetoothActions.scanStatusAction({ status: 'running' })); // This can fail, but if there is an error we already got it from `adapter-event` // and user is informed about it (bluetooth turned-off, ...) bluetoothIpc.startScan(); diff --git a/packages/suite/src/actions/bluetooth/bluetoothStopScanningThunk.ts b/packages/suite/src/actions/bluetooth/bluetoothStopScanningThunk.ts index 086f3a21959..9599c1d0a2c 100644 --- a/packages/suite/src/actions/bluetooth/bluetoothStopScanningThunk.ts +++ b/packages/suite/src/actions/bluetooth/bluetoothStopScanningThunk.ts @@ -1,11 +1,11 @@ +import { BLUETOOTH_PREFIX, bluetoothActions } from '@suite-common/bluetooth'; import { createThunk } from '@suite-common/redux-utils'; import { bluetoothIpc } from '@trezor/transport-bluetooth'; -import { BLUETOOTH_PREFIX } from './bluetoothActions'; - export const bluetoothStopScanningThunk = createThunk( `${BLUETOOTH_PREFIX}/bluetoothStopScanningThunk`, - _ => { + (_, { dispatch }) => { + dispatch(bluetoothActions.scanStatusAction({ status: 'idle' })); // This can fail, but there is nothing we can do about it bluetoothIpc.stopScan(); }, diff --git a/packages/suite/src/actions/bluetooth/initBluetoothThunk.ts b/packages/suite/src/actions/bluetooth/initBluetoothThunk.ts index 05512b97dcc..f9e16b2bed3 100644 --- a/packages/suite/src/actions/bluetooth/initBluetoothThunk.ts +++ b/packages/suite/src/actions/bluetooth/initBluetoothThunk.ts @@ -1,13 +1,9 @@ +import { BLUETOOTH_PREFIX, bluetoothActions, selectKnownDevices } from '@suite-common/bluetooth'; import { createThunk } from '@suite-common/redux-utils/'; -import { DeviceConnectionStatus, bluetoothIpc } from '@trezor/transport-bluetooth'; +import { BluetoothDevice, DeviceConnectionStatus, bluetoothIpc } from '@trezor/transport-bluetooth'; import { Without } from '@trezor/type-utils'; -import { - BLUETOOTH_PREFIX, - bluetoothAdapterEventAction, - bluetoothConnectDeviceEventAction, - bluetoothDeviceListUpdate, -} from './bluetoothActions'; +import { remapKnownDevicesForLinux } from './remapKnownDevicesForLinux'; import { selectSuiteFlags } from '../../reducers/suite/suiteReducer'; type DeviceConnectionStatusWithOptionalId = Without & { @@ -25,12 +21,23 @@ export const initBluetoothThunk = createThunk( bluetoothIpc.on('adapter-event', isPowered => { console.warn('adapter-event', isPowered); - dispatch(bluetoothAdapterEventAction({ isPowered })); + dispatch(bluetoothActions.adapterEventAction({ isPowered })); }); - bluetoothIpc.on('device-list-update', devices => { - console.warn('device-list-update', devices); - dispatch(bluetoothDeviceListUpdate({ devices })); + bluetoothIpc.on('device-list-update', nearbyDevices => { + console.warn('device-list-update', nearbyDevices); + + const knownDevices = selectKnownDevices(getState()); + + const remappedKnownDevices = remapKnownDevicesForLinux({ + knownDevices, + nearbyDevices, + }); + + dispatch( + bluetoothActions.knownDevicesUpdateAction({ knownDevices: remappedKnownDevices }), + ); + dispatch(bluetoothActions.nearbyDevicesUpdateAction({ nearbyDevices })); }); bluetoothIpc.on('device-connection-status', connectionStatus => { @@ -41,7 +48,7 @@ export const initBluetoothThunk = createThunk( delete copyConnectionStatus.id; // So we dont pollute redux store dispatch( - bluetoothConnectDeviceEventAction({ + bluetoothActions.connectDeviceEventAction({ id: connectionStatus.id, connectionStatus: copyConnectionStatus, }), @@ -49,7 +56,7 @@ export const initBluetoothThunk = createThunk( }); // TODO: this should be called after trezor/connect init? - const knownDevices = getState().bluetooth.pairedDevices; + const knownDevices = selectKnownDevices(getState()); await bluetoothIpc.init({ knownDevices }); }, ); diff --git a/packages/suite/src/actions/bluetooth/remapKnownDevicesForLinux.ts b/packages/suite/src/actions/bluetooth/remapKnownDevicesForLinux.ts new file mode 100644 index 00000000000..6ff5b3c91a0 --- /dev/null +++ b/packages/suite/src/actions/bluetooth/remapKnownDevicesForLinux.ts @@ -0,0 +1,27 @@ +import { BluetoothDevice } from '@trezor/transport-bluetooth'; + +type RemapKnownDevicesForLinuxParams = { + knownDevices: BluetoothDevice[]; + nearbyDevices: BluetoothDevice[]; +}; + +/** + * On linux, when bluetooth adapter is turned off/on again, the paired + * devices will get different `id`, but `address` will remain the same. + * + * Therefore, we have to remap the knownDevices to change the `id`. + */ +export const remapKnownDevicesForLinux = ({ + knownDevices, + nearbyDevices, +}: RemapKnownDevicesForLinuxParams): BluetoothDevice[] => + knownDevices.map(knownDevice => { + const nearbyDeviceWithSameAddress = nearbyDevices.find( + nearbyDevice => + nearbyDevice.address === knownDevice.address && nearbyDevice.id !== knownDevice.id, + ); + + return nearbyDeviceWithSameAddress + ? { ...knownDevice, id: nearbyDeviceWithSameAddress.id } + : knownDevice; + }); diff --git a/packages/suite/src/actions/suite/storageActions.ts b/packages/suite/src/actions/suite/storageActions.ts index 1ec655fb545..85d641a29fe 100644 --- a/packages/suite/src/actions/suite/storageActions.ts +++ b/packages/suite/src/actions/suite/storageActions.ts @@ -104,8 +104,8 @@ export const saveCoinjoinDebugSettings = () => async (_dispatch: Dispatch, getSt export const saveKnownDevices = () => async (_dispatch: Dispatch, getState: GetState) => { if (!(await db.isAccessible())) return; - const { pairedDevices } = getState().bluetooth; - db.addItem('knownDevices', { bluetooth: pairedDevices }, 'devices', true); + const { knownDevices } = getState().bluetooth; + db.addItem('knownDevices', { bluetooth: knownDevices }, 'devices', true); }; export const saveFormDraft = async (key: string, draft: FieldValues) => { diff --git a/packages/suite/src/actions/wallet/addWalletThunk.ts b/packages/suite/src/actions/wallet/addWalletThunk.ts index b4f699af5e7..01c10179813 100644 --- a/packages/suite/src/actions/wallet/addWalletThunk.ts +++ b/packages/suite/src/actions/wallet/addWalletThunk.ts @@ -44,7 +44,13 @@ export const addWalletThunk = createThunk< const discovery = selectDiscoveryByDeviceState(getState(), device?.state); const discoveryStatus = getDiscoveryStatus({ device, discovery }); - if (discoveryStatus !== undefined && discoveryStatus.status === 'loading') { + if ( + discoveryStatus !== undefined && + discoveryStatus.status === 'loading' && + // There are situations where we are in the "state" of discovery, but we want to allow for adding a wallet. + // See: https://github.com/trezor/trezor-suite/issues/17114 for example + discoveryStatus.type === 'discovery' + ) { return; } diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothConnect.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothConnect.tsx index de9f0f7fa69..c95c6f85f72 100644 --- a/packages/suite/src/components/suite/bluetooth/BluetoothConnect.tsx +++ b/packages/suite/src/components/suite/bluetooth/BluetoothConnect.tsx @@ -1,9 +1,16 @@ import { useCallback, useEffect, useState } from 'react'; -import { notificationsActions } from '@suite-common/toast-notifications'; +import { + bluetoothActions, + prepareSelectAllDevices, + selectAdapterStatus, + selectKnownDevices, + selectScanStatus, +} from '@suite-common/bluetooth'; +import { selectDevices } from '@suite-common/wallet-core'; import { Card, Column, ElevationUp } from '@trezor/components'; -import TrezorConnect from '@trezor/connect'; import { spacings } from '@trezor/theme'; +import { BluetoothDevice } from '@trezor/transport-bluetooth'; import { TimerId } from '@trezor/type-utils'; import { BluetoothDeviceList } from './BluetoothDeviceList'; @@ -14,38 +21,61 @@ import { BluetoothSelectedDevice } from './BluetoothSelectedDevice'; import { BluetoothTips } from './BluetoothTips'; import { BluetoothNotEnabled } from './errors/BluetoothNotEnabled'; import { BluetoothVersionNotCompatible } from './errors/BluetoothVersionNotCompatible'; -import { - bluetoothConnectDeviceEventAction, - bluetoothScanStatusAction, -} from '../../../actions/bluetooth/bluetoothActions'; import { bluetoothConnectDeviceThunk } from '../../../actions/bluetooth/bluetoothConnectDeviceThunk'; import { bluetoothStartScanningThunk } from '../../../actions/bluetooth/bluetoothStartScanningThunk'; import { bluetoothStopScanningThunk } from '../../../actions/bluetooth/bluetoothStopScanningThunk'; +import { closeModalApp } from '../../../actions/suite/routerActions'; import { useDispatch, useSelector } from '../../../hooks/suite'; -import { - selectBluetoothDeviceList, - selectBluetoothEnabled, - selectBluetoothScanStatus, -} from '../../../reducers/bluetooth/bluetoothSelectors'; const SCAN_TIMEOUT = 30_000; +const UNPAIRED_DEVICES_LAST_UPDATED_LIMIT_SECONDS = 30; type BluetoothConnectProps = { onClose: () => void; uiMode: 'spatial' | 'card'; }; +const selectAllDevices = prepareSelectAllDevices(); + export const BluetoothConnect = ({ onClose, uiMode }: BluetoothConnectProps) => { const dispatch = useDispatch(); + const [selectedDeviceId, setSelectedDeviceId] = useState(null); const [scannerTimerId, setScannerTimerId] = useState(null); - const isBluetoothEnabled = useSelector(selectBluetoothEnabled); - const scanStatus = useSelector(selectBluetoothScanStatus); - const deviceList = useSelector(selectBluetoothDeviceList); - const devices = Object.values(deviceList); + const trezorDevices = useSelector(selectDevices); + + const bluetoothAdapterStatus = useSelector(selectAdapterStatus); + const scanStatus = useSelector(selectScanStatus); + const allDevices = useSelector(selectAllDevices); + const knownDevices = useSelector(selectKnownDevices); - const selectedDevice = selectedDeviceId !== null ? deviceList[selectedDeviceId] ?? null : null; + const lasUpdatedBoundaryTimestamp = + Date.now() / 1000 - UNPAIRED_DEVICES_LAST_UPDATED_LIMIT_SECONDS; + + const devices = allDevices.filter(it => { + const isDeviceAlreadyConnected = + trezorDevices.find(trezorDevice => trezorDevice.bluetoothProps?.id === it.device.id) !== + undefined; + + if (isDeviceAlreadyConnected) { + return false; + } + + const isDeviceUnresponsiveForTooLong = + it.device.lastUpdatedTimestamp < lasUpdatedBoundaryTimestamp; + + if (isDeviceUnresponsiveForTooLong) { + return knownDevices.find(knownDevice => knownDevice.id === it.device.id) !== undefined; + } + + return true; + }); + + const selectedDevice = + selectedDeviceId !== null + ? devices.find(device => device.device.id === selectedDeviceId) + : undefined; useEffect(() => { dispatch(bluetoothStartScanningThunk()); @@ -64,7 +94,7 @@ export const BluetoothConnect = ({ onClose, uiMode }: BluetoothConnectProps) => useEffect(() => { // Intentionally no `clearScamTimer`, this is first run and if we use this we would create infinite re-render const timerId = setTimeout(() => { - dispatch(bluetoothScanStatusAction({ status: 'done' })); + dispatch(bluetoothActions.scanStatusAction({ status: 'idle' })); }, SCAN_TIMEOUT); setScannerTimerId(timerId); @@ -72,55 +102,25 @@ export const BluetoothConnect = ({ onClose, uiMode }: BluetoothConnectProps) => const onReScanClick = () => { setSelectedDeviceId(null); - dispatch(bluetoothScanStatusAction({ status: 'running' })); + dispatch(bluetoothActions.scanStatusAction({ status: 'running' })); clearScamTimer(); const timerId = setTimeout(() => { - dispatch(bluetoothScanStatusAction({ status: 'done' })); + dispatch(bluetoothActions.scanStatusAction({ status: 'idle' })); }, SCAN_TIMEOUT); setScannerTimerId(timerId); }; const onSelect = async (id: string) => { setSelectedDeviceId(id); - const result = await dispatch(bluetoothConnectDeviceThunk({ id })).unwrap(); - if (!result.success) { - dispatch( - bluetoothConnectDeviceEventAction({ - id, - connectionStatus: { type: 'error', error: result.error }, - }), - ); - dispatch( - notificationsActions.addToast({ - type: 'error', - error: result.error, - }), - ); - } else { - // Todo: What to do with error in this flow? UI-Wise - - dispatch( - bluetoothConnectDeviceEventAction({ - id, - connectionStatus: { type: 'connected' }, - }), - ); - - // WAIT for connect event, TODO: figure out better way - const closePopupAfterConnection = () => { - TrezorConnect.off('device-connect', closePopupAfterConnection); - TrezorConnect.off('device-connect_unacquired', closePopupAfterConnection); - // setSelectedDeviceStatus({ type: 'error', id }); // Todo: what here? - }; - TrezorConnect.on('device-connect', closePopupAfterConnection); - TrezorConnect.on('device-connect_unacquired', closePopupAfterConnection); + if (uiMode === 'card' && result.success) { + dispatch(closeModalApp()); } }; - if (!isBluetoothEnabled) { + if (bluetoothAdapterStatus === 'disabled') { return ; } @@ -133,8 +133,8 @@ export const BluetoothConnect = ({ onClose, uiMode }: BluetoothConnectProps) => console.log('selectedDevice', selectedDevice); // This is fake, we scan for devices all the time - const isScanning = scanStatus !== 'done'; - const scanFailed = devices.length === 0 && scanStatus === 'done'; + const isScanning = scanStatus === 'running'; + const scanFailed = devices.length === 0 && scanStatus === 'idle'; const handlePairingCancel = () => { setSelectedDeviceId(null); @@ -142,7 +142,8 @@ export const BluetoothConnect = ({ onClose, uiMode }: BluetoothConnectProps) => }; if ( - selectedDevice !== null && + selectedDevice !== undefined && + selectedDevice.status !== null && selectedDevice.status.type === 'pairing' && (selectedDevice.status.pin?.length ?? 0) > 0 ) { @@ -155,7 +156,7 @@ export const BluetoothConnect = ({ onClose, uiMode }: BluetoothConnectProps) => ); } - if (selectedDevice !== null) { + if (selectedDevice !== undefined) { return ; } diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothDevice.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothDeviceComponent.tsx similarity index 87% rename from packages/suite/src/components/suite/bluetooth/BluetoothDevice.tsx rename to packages/suite/src/components/suite/bluetooth/BluetoothDeviceComponent.tsx index 5d093829364..37fa30d4605 100644 --- a/packages/suite/src/components/suite/bluetooth/BluetoothDevice.tsx +++ b/packages/suite/src/components/suite/bluetooth/BluetoothDeviceComponent.tsx @@ -3,10 +3,10 @@ import { DeviceModelInternal } from '@trezor/connect'; import { models } from '@trezor/connect/src/data/models'; // Todo: solve this import issue import { RotateDeviceImage } from '@trezor/product-components'; import { spacings } from '@trezor/theme'; -import { BluetoothDevice as BluetoothDeviceType } from '@trezor/transport-bluetooth'; +import { BluetoothDevice } from '@trezor/transport-bluetooth'; type BluetoothDeviceProps = { - device: BluetoothDeviceType; + device: BluetoothDevice; flex?: FlexProps['flex']; margin?: FlexProps['margin']; }; @@ -18,7 +18,9 @@ const getModelEnumFromBytesUtil = (_id: number) => DeviceModelInternal.T3W1; // discuss final format of it const getColorEnumFromVariantBytesUtil = (variant: number) => variant; -export const BluetoothDevice = ({ device, flex, margin }: BluetoothDeviceProps) => { +export const BluetoothDeviceComponent = ({ device, flex, margin }: BluetoothDeviceProps) => { + console.log('____ BluetoothDeviceComponent :: device', device); + const model = getModelEnumFromBytesUtil(device.data[2]); const color = getColorEnumFromVariantBytesUtil(device.data[1]); const colorName = models[model].colors[color.toString()]; diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothDeviceItem.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothDeviceItem.tsx index e1234e1394b..082fabdac1f 100644 --- a/packages/suite/src/components/suite/bluetooth/BluetoothDeviceItem.tsx +++ b/packages/suite/src/components/suite/bluetooth/BluetoothDeviceItem.tsx @@ -2,7 +2,7 @@ import { Button, Row } from '@trezor/components'; import { spacings } from '@trezor/theme'; import { BluetoothDevice as BluetoothDeviceType } from '@trezor/transport-bluetooth'; -import { BluetoothDevice } from './BluetoothDevice'; +import { BluetoothDeviceComponent } from './BluetoothDeviceComponent'; type BluetoothDeviceItemProps = { device: BluetoothDeviceType; @@ -12,7 +12,7 @@ type BluetoothDeviceItemProps = { export const BluetoothDeviceItem = ({ device, onClick, isDisabled }: BluetoothDeviceItemProps) => ( - + diff --git a/suite-common/bluetooth/jest.config.js b/suite-common/bluetooth/jest.config.js new file mode 100644 index 00000000000..2ef9dc76f5c --- /dev/null +++ b/suite-common/bluetooth/jest.config.js @@ -0,0 +1,6 @@ +const baseConfig = require('../../jest.config.base'); + +module.exports = { + ...baseConfig, + roots: ['/src', '/tests', '/../test-utils/__mocks__'], +}; diff --git a/suite-common/bluetooth/package.json b/suite-common/bluetooth/package.json new file mode 100644 index 00000000000..dbf7271bc6f --- /dev/null +++ b/suite-common/bluetooth/package.json @@ -0,0 +1,18 @@ +{ + "name": "@suite-common/bluetooth", + "version": "1.0.0", + "private": true, + "license": "See LICENSE.md in repo root", + "sideEffects": false, + "main": "src/index", + "scripts": { + "test:unit": "yarn g:jest -c ./jest.config.js", + "depcheck": "yarn g:depcheck", + "type-check": "yarn g:tsc --build" + }, + "dependencies": { + "@reduxjs/toolkit": "1.9.5", + "@suite-common/redux-utils": "workspace:*", + "@trezor/connect": "workspace:*" + } +} diff --git a/suite-common/bluetooth/src/bluetoothActions.ts b/suite-common/bluetooth/src/bluetoothActions.ts new file mode 100644 index 00000000000..bf43e7a7ec2 --- /dev/null +++ b/suite-common/bluetooth/src/bluetoothActions.ts @@ -0,0 +1,64 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { + BluetoothDeviceCommon, + BluetoothScanStatus, + DeviceBluetoothStatus, +} from './bluetoothReducer'; + +export const BLUETOOTH_PREFIX = '@suite/bluetooth'; + +const adapterEventAction = createAction( + `${BLUETOOTH_PREFIX}/adapter-event`, + ({ isPowered }: { isPowered: boolean }) => ({ payload: { isPowered } }), +); + +type BluetoothNearbyDevicesUpdateActionPayload = { + nearbyDevices: BluetoothDeviceCommon[]; +}; + +const nearbyDevicesUpdateAction = createAction( + `${BLUETOOTH_PREFIX}/nearby-devices-update`, + ({ nearbyDevices }: BluetoothNearbyDevicesUpdateActionPayload) => ({ + payload: { nearbyDevices }, + }), +); + +type BluetoothKnownDevicesUpdateActionPayload = { + knownDevices: BluetoothDeviceCommon[]; +}; + +const knownDevicesUpdateAction = createAction( + `${BLUETOOTH_PREFIX}/known-devices-update`, + ({ knownDevices }: BluetoothKnownDevicesUpdateActionPayload) => ({ + payload: { knownDevices }, + }), +); + +const removeKnownDeviceAction = createAction( + `${BLUETOOTH_PREFIX}/remove-known-device`, + ({ id }: { id: string }) => ({ + payload: { id }, + }), +); + +const connectDeviceEventAction = createAction( + `${BLUETOOTH_PREFIX}/connect-device-event`, + ({ connectionStatus, id }: { id: string; connectionStatus: DeviceBluetoothStatus }) => ({ + payload: { id, connectionStatus }, + }), +); + +const scanStatusAction = createAction( + `${BLUETOOTH_PREFIX}/scan-status`, + ({ status }: { status: BluetoothScanStatus }) => ({ payload: { status } }), +); + +export const bluetoothActions = { + adapterEventAction, + nearbyDevicesUpdateAction, + connectDeviceEventAction, + scanStatusAction, + knownDevicesUpdateAction, + removeKnownDeviceAction, +}; diff --git a/suite-common/bluetooth/src/bluetoothReducer.ts b/suite-common/bluetooth/src/bluetoothReducer.ts new file mode 100644 index 00000000000..b092ad39093 --- /dev/null +++ b/suite-common/bluetooth/src/bluetoothReducer.ts @@ -0,0 +1,146 @@ +import { AnyAction, Draft } from '@reduxjs/toolkit'; + +import { createReducerWithExtraDeps } from '@suite-common/redux-utils'; +import { deviceActions } from '@suite-common/wallet-core'; + +import { bluetoothActions } from './bluetoothActions'; + +export type BluetoothScanStatus = 'idle' | 'running' | 'error'; + +export type DeviceBluetoothStatus = + | { type: 'pairing'; pin?: string } + | { type: 'paired' } + | { type: 'connecting' } + | { type: 'connected' } + | { + type: 'error'; + error: string; + }; + +// Do not export this outside of this suite-common package, Suite uses ist own type +// from the '@trezor/transport-bluetooth' and mobile (native) have its own type as well. +export type BluetoothDeviceCommon = { + id: string; + name: string; + data: number[]; // Todo: consider typed data-structure for this + lastUpdatedTimestamp: number; +}; + +export type DeviceBluetoothStatusType = DeviceBluetoothStatus['type']; + +export type BluetoothDeviceState = { + device: T; + status: DeviceBluetoothStatus | null; +}; + +export type BluetoothState = { + adapterStatus: 'unknown' | 'enabled' | 'disabled'; + scanStatus: BluetoothScanStatus; + nearbyDevices: BluetoothDeviceState[]; + + // This will be persisted, those are devices we believed that are paired + // (because we already successfully paired them in the Suite) in the Operating System + knownDevices: T[]; +}; + +export const prepareBluetoothReducerCreator = () => { + const initialState: BluetoothState = { + adapterStatus: 'unknown', + scanStatus: 'idle', + nearbyDevices: [] as BluetoothDeviceState[], + knownDevices: [] as T[], + }; + + return createReducerWithExtraDeps>(initialState, (builder, extra) => + builder + .addCase(bluetoothActions.adapterEventAction, (state, { payload: { isPowered } }) => { + state.adapterStatus = isPowered ? 'enabled' : 'disabled'; + if (!isPowered) { + state.nearbyDevices = []; + state.scanStatus = 'idle'; + } + }) + .addCase( + bluetoothActions.nearbyDevicesUpdateAction, + (state, { payload: { nearbyDevices } }) => { + state.nearbyDevices = nearbyDevices + .sort((a, b) => b.lastUpdatedTimestamp - a.lastUpdatedTimestamp) + .map( + (device): Draft> => ({ + device: device as Draft, + status: + state.nearbyDevices.find(it => it.device.id === device.id) + ?.status ?? null, + }), + ); + }, + ) + .addCase( + bluetoothActions.connectDeviceEventAction, + (state, { payload: { id, connectionStatus } }) => { + const device = state.nearbyDevices.find(it => it.device.id === id); + + if (device !== undefined) { + device.status = connectionStatus; + } + }, + ) + .addCase( + bluetoothActions.knownDevicesUpdateAction, + (state, { payload: { knownDevices } }) => { + state.knownDevices = knownDevices as Draft[]; + }, + ) + .addCase(bluetoothActions.removeKnownDeviceAction, (state, { payload: { id } }) => { + state.knownDevices = state.knownDevices.filter( + knownDevice => knownDevice.id !== id, + ); + }) + .addCase(bluetoothActions.scanStatusAction, (state, { payload: { status } }) => { + state.scanStatus = status; + }) + .addCase(deviceActions.deviceDisconnect, (state, { payload: { bluetoothProps } }) => { + if (bluetoothProps !== undefined) { + state.nearbyDevices = state.nearbyDevices.filter( + it => it.device.id !== bluetoothProps.id, + ); + } + }) + .addCase( + deviceActions.connectDevice, + ( + state, + { + payload: { + device: { bluetoothProps }, + }, + }, + ) => { + if (bluetoothProps === undefined) { + return; + } + + const deviceState = state.nearbyDevices.find( + it => it.device.id === bluetoothProps.id, + ); + + if (deviceState !== undefined) { + // Once device is fully connected, we save it to the list of known devices + // so next time user opens suite we can automatically connect to it. + const foundKnownDevice = state.knownDevices.find( + it => it.id === bluetoothProps.id, + ); + if (foundKnownDevice === undefined) { + state.knownDevices.push(deviceState.device); + } + } + }, + ) + .addMatcher( + action => action.type === extra.actionTypes.storageLoad, + (state, action: AnyAction) => { + state.knownDevices = action.payload.knownDevices?.bluetooth ?? []; + }, + ), + ); +}; diff --git a/suite-common/bluetooth/src/bluetoothSelectors.ts b/suite-common/bluetooth/src/bluetoothSelectors.ts new file mode 100644 index 00000000000..64cd49cad38 --- /dev/null +++ b/suite-common/bluetooth/src/bluetoothSelectors.ts @@ -0,0 +1,47 @@ +import { createWeakMapSelector } from '@suite-common/redux-utils'; + +import { BluetoothDeviceCommon, BluetoothDeviceState, BluetoothState } from './bluetoothReducer'; + +export type WithBluetoothState = { + bluetooth: BluetoothState; +}; + +export const selectAdapterStatus = ( + state: WithBluetoothState, +) => state.bluetooth.adapterStatus; + +export const selectKnownDevices = (state: WithBluetoothState) => + state.bluetooth.knownDevices; + +/** + * We need to have generic `createWeakMapSelector.withTypes` so we need to wrap it into Higher Order Function, + * but we need to make sure it is called only **once** to not break the memoization. So it is named + * `prepareSelectAllDevices` and shall be called **outside** of the component to create a selector with + * concrete type: + * + * For example: `const selectAllDevices = prepareSelectAllDevices();` + */ +export const prepareSelectAllDevices = () => + createWeakMapSelector.withTypes>()( + [state => state.bluetooth.nearbyDevices, state => state.bluetooth.knownDevices], + (nearbyDevices, knownDevices) => { + const map = new Map>(); + + nearbyDevices.forEach(nearbyDevice => { + map.set(nearbyDevice.device.id, nearbyDevice); + }); + + knownDevices.forEach(knownDevice => { + if (!map.has(knownDevice.id)) { + map.set(knownDevice.id, { device: knownDevice, status: null }); + } + }); + + return Array.from(map.values()).sort( + (a, b) => b.device.lastUpdatedTimestamp - a.device.lastUpdatedTimestamp, + ); + }, + ); + +export const selectScanStatus = (state: WithBluetoothState) => + state.bluetooth.scanStatus; diff --git a/suite-common/bluetooth/src/index.ts b/suite-common/bluetooth/src/index.ts new file mode 100644 index 00000000000..7fe15e21c01 --- /dev/null +++ b/suite-common/bluetooth/src/index.ts @@ -0,0 +1,15 @@ +export { BLUETOOTH_PREFIX, bluetoothActions } from './bluetoothActions'; + +export { prepareBluetoothReducerCreator } from './bluetoothReducer'; +export type { + BluetoothDeviceState, + BluetoothScanStatus, + DeviceBluetoothStatusType, +} from './bluetoothReducer'; + +export { + prepareSelectAllDevices, + selectKnownDevices, + selectAdapterStatus, + selectScanStatus, +} from './bluetoothSelectors'; diff --git a/suite-common/bluetooth/tests/bluetoothReducer.test.ts b/suite-common/bluetooth/tests/bluetoothReducer.test.ts new file mode 100644 index 00000000000..b6a0af53c30 --- /dev/null +++ b/suite-common/bluetooth/tests/bluetoothReducer.test.ts @@ -0,0 +1,182 @@ +import { combineReducers } from '@reduxjs/toolkit'; + +import { TrezorDevice } from '@suite-common/suite-types'; +import { configureMockStore, extraDependenciesMock } from '@suite-common/test-utils'; +import { deviceActions } from '@suite-common/wallet-core'; +import { Device } from '@trezor/connect'; + +import { BluetoothDeviceState, bluetoothActions, prepareBluetoothReducerCreator } from '../src'; +import { BluetoothDeviceCommon, BluetoothState } from '../src/bluetoothReducer'; + +const bluetoothReducer = + prepareBluetoothReducerCreator()(extraDependenciesMock); + +const initialState: BluetoothState = { + adapterStatus: 'unknown', + scanStatus: 'idle', + nearbyDevices: [] as BluetoothDeviceState[], + knownDevices: [] as BluetoothDeviceCommon[], +}; + +const bluetoothStateDeviceA: BluetoothDeviceState = { + device: { + id: 'A', + data: [], + name: 'Trezor A', + lastUpdatedTimestamp: 1, + }, + status: { type: 'pairing' }, +}; + +const bluetoothStateDeviceB: BluetoothDeviceState = { + device: { + id: 'B', + data: [], + name: 'Trezor B', + lastUpdatedTimestamp: 2, + }, + status: null, +}; + +const bluetoothStateDeviceC: BluetoothDeviceState = { + device: { + id: 'C', + data: [], + name: 'Trezor C', + lastUpdatedTimestamp: 3, + }, + status: null, +}; + +describe('bluetoothReducer', () => { + it('sets the bluetooth adapter as enabled/disabled when powered/unpowered', () => { + const store = configureMockStore({ + extra: {}, + reducer: combineReducers({ bluetooth: bluetoothReducer }), + preloadedState: { bluetooth: initialState }, + }); + + expect(store.getState().bluetooth.adapterStatus).toEqual('unknown'); + store.dispatch(bluetoothActions.adapterEventAction({ isPowered: true })); + expect(store.getState().bluetooth.adapterStatus).toEqual('enabled'); + store.dispatch(bluetoothActions.adapterEventAction({ isPowered: false })); + expect(store.getState().bluetooth.adapterStatus).toEqual('disabled'); + }); + + it('sorts the devices based on the `lastUpdatedTimestamp` and keeps the status for already existing device', () => { + const store = configureMockStore({ + extra: {}, + reducer: combineReducers({ bluetooth: bluetoothReducer }), + preloadedState: { + bluetooth: { + ...initialState, + nearbyDevices: [bluetoothStateDeviceB, bluetoothStateDeviceA], + }, + }, + }); + + const nearbyDevices: BluetoothDeviceCommon[] = [ + bluetoothStateDeviceA.device, + bluetoothStateDeviceC.device, + ]; + + store.dispatch(bluetoothActions.nearbyDevicesUpdateAction({ nearbyDevices })); + expect(store.getState().bluetooth.nearbyDevices).toEqual([ + bluetoothStateDeviceC, + // No `B` device present, it was dropped + { + device: bluetoothStateDeviceA.device, + status: { type: 'pairing' }, // Keeps the pairing status + }, + ]); + }); + + it('changes the status of the given device during pairing process', () => { + const store = configureMockStore({ + extra: {}, + reducer: combineReducers({ bluetooth: bluetoothReducer }), + preloadedState: { + bluetooth: { ...initialState, nearbyDevices: [bluetoothStateDeviceA] }, + }, + }); + + store.dispatch( + bluetoothActions.connectDeviceEventAction({ + id: 'A', + connectionStatus: { type: 'pairing', pin: '12345' }, + }), + ); + expect(store.getState().bluetooth.nearbyDevices).toEqual([ + { + device: bluetoothStateDeviceA.device, + status: { type: 'pairing', pin: '12345' }, + }, + ]); + }); + + it('updates and removes known devices', () => { + const store = configureMockStore({ + extra: {}, + reducer: combineReducers({ bluetooth: bluetoothReducer }), + preloadedState: { bluetooth: initialState }, + }); + + const knownDeviceToAdd: BluetoothDeviceCommon[] = [ + bluetoothStateDeviceA.device, + bluetoothStateDeviceB.device, + ]; + + store.dispatch( + bluetoothActions.knownDevicesUpdateAction({ knownDevices: knownDeviceToAdd }), + ); + expect(store.getState().bluetooth.knownDevices).toEqual(knownDeviceToAdd); + + store.dispatch(bluetoothActions.removeKnownDeviceAction({ id: 'A' })); + + expect(store.getState().bluetooth.knownDevices).toEqual([bluetoothStateDeviceB.device]); + }); + + it('removes device from nearbyDevices when the device is disconnected by TrezorConnect', () => { + const store = configureMockStore({ + extra: {}, + reducer: combineReducers({ bluetooth: bluetoothReducer }), + preloadedState: { + bluetooth: { ...initialState, nearbyDevices: [bluetoothStateDeviceA] }, + }, + }); + + const trezorDevice: Pick = { + bluetoothProps: { id: 'A' }, + }; + + store.dispatch(deviceActions.deviceDisconnect(trezorDevice as TrezorDevice)); + expect(store.getState().bluetooth.nearbyDevices).toEqual([]); + }); + + it('stores a device in `knownDevices` when device is connected by TrezorConnect', () => { + const nearbyDevice: BluetoothDeviceState = { + device: bluetoothStateDeviceA.device, + status: { type: 'connected' }, + }; + + const store = configureMockStore({ + extra: {}, + reducer: combineReducers({ bluetooth: bluetoothReducer }), + preloadedState: { + bluetooth: { ...initialState, nearbyDevices: [nearbyDevice] }, + }, + }); + + const trezorDevice: Pick = { + bluetoothProps: { id: 'A' }, + }; + + store.dispatch( + deviceActions.connectDevice({ + device: trezorDevice as Device, + settings: { defaultWalletLoading: 'passphrase' }, + }), + ); + expect(store.getState().bluetooth.knownDevices).toEqual([nearbyDevice.device]); + }); +}); diff --git a/suite-common/bluetooth/tests/bluetoothSelectors.test.ts b/suite-common/bluetooth/tests/bluetoothSelectors.test.ts new file mode 100644 index 00000000000..375dddd75c4 --- /dev/null +++ b/suite-common/bluetooth/tests/bluetoothSelectors.test.ts @@ -0,0 +1,48 @@ +import { BluetoothDeviceState, prepareSelectAllDevices } from '../src'; +import { BluetoothDeviceCommon, BluetoothState } from '../src/bluetoothReducer'; +import { WithBluetoothState } from '../src/bluetoothSelectors'; + +const initialState: BluetoothState = { + adapterStatus: 'unknown', + scanStatus: 'idle', + nearbyDevices: [] as BluetoothDeviceState[], + knownDevices: [] as BluetoothDeviceCommon[], +}; + +const pairingDeviceStateA: BluetoothDeviceState = { + device: { + id: 'A', + data: [], + name: 'Trezor A', + lastUpdatedTimestamp: 1, + }, + status: { type: 'pairing' }, +}; + +const deviceB: BluetoothDeviceCommon = { + id: 'B', + data: [], + name: 'Trezor B', + lastUpdatedTimestamp: 2, +}; + +describe('bluetoothSelectors', () => { + it('selects knownDevices and nearbyDevices in one list fot the UI', () => { + const selectAllDevices = prepareSelectAllDevices(); + + const state: WithBluetoothState = { + bluetooth: { + ...initialState, + nearbyDevices: [pairingDeviceStateA], + knownDevices: [pairingDeviceStateA.device, deviceB], + }, + }; + + const devices = selectAllDevices(state); + + expect(devices).toEqual([{ device: deviceB, status: null }, pairingDeviceStateA]); + + const devicesSecondTime = selectAllDevices(state); + expect(devices === devicesSecondTime).toBe(true); // Asserts that `reselect` memoization works + }); +}); diff --git a/suite-common/bluetooth/tsconfig.json b/suite-common/bluetooth/tsconfig.json new file mode 100644 index 00000000000..67f8eef9919 --- /dev/null +++ b/suite-common/bluetooth/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "libDev" }, + "references": [ + { "path": "../redux-utils" }, + { "path": "../suite-types" }, + { "path": "../../packages/connect" } + ] +} diff --git a/yarn.lock b/yarn.lock index 75a46007774..37f0e112dd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9604,6 +9604,16 @@ __metadata: languageName: unknown linkType: soft +"@suite-common/bluetooth@workspace:suite-common/bluetooth": + version: 0.0.0-use.local + resolution: "@suite-common/bluetooth@workspace:suite-common/bluetooth" + dependencies: + "@reduxjs/toolkit": "npm:1.9.5" + "@suite-common/redux-utils": "workspace:*" + "@trezor/connect": "workspace:*" + languageName: unknown + linkType: soft + "@suite-common/connect-init@workspace:*, @suite-common/connect-init@workspace:suite-common/connect-init": version: 0.0.0-use.local resolution: "@suite-common/connect-init@workspace:suite-common/connect-init"