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 3c19c522e49..5b11dafb325 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9400,6 +9400,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"