Skip to content

Commit

Permalink
feat: move reducers into seperate common package, create shared state…
Browse files Browse the repository at this point in the history
… for mobile
  • Loading branch information
peter-sanderson committed Feb 25, 2025
1 parent 4f94bc9 commit d4b3ed4
Show file tree
Hide file tree
Showing 10 changed files with 544 additions and 0 deletions.
6 changes: 6 additions & 0 deletions suite-common/bluetooth/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const baseConfig = require('../../jest.config.base');

module.exports = {
...baseConfig,
roots: ['<rootDir>/src', '<rootDir>/tests', '<rootDir>/../test-utils/__mocks__'],
};
18 changes: 18 additions & 0 deletions suite-common/bluetooth/package.json
Original file line number Diff line number Diff line change
@@ -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:*"
}
}
64 changes: 64 additions & 0 deletions suite-common/bluetooth/src/bluetoothActions.ts
Original file line number Diff line number Diff line change
@@ -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,
};
146 changes: 146 additions & 0 deletions suite-common/bluetooth/src/bluetoothReducer.ts
Original file line number Diff line number Diff line change
@@ -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<T extends BluetoothDeviceCommon> = {
device: T;
status: DeviceBluetoothStatus | null;
};

export type BluetoothState<T extends BluetoothDeviceCommon> = {
adapterStatus: 'unknown' | 'enabled' | 'disabled';
scanStatus: BluetoothScanStatus;
nearbyDevices: BluetoothDeviceState<T>[];

// 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 = <T extends BluetoothDeviceCommon>() => {
const initialState: BluetoothState<T> = {
adapterStatus: 'unknown',
scanStatus: 'idle',
nearbyDevices: [] as BluetoothDeviceState<T>[],
knownDevices: [] as T[],
};

return createReducerWithExtraDeps<BluetoothState<T>>(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<BluetoothDeviceState<T>> => ({
device: device as Draft<T>,
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<T>[];
},
)
.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 ?? [];
},
),
);
};
47 changes: 47 additions & 0 deletions suite-common/bluetooth/src/bluetoothSelectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createWeakMapSelector } from '@suite-common/redux-utils';

import { BluetoothDeviceCommon, BluetoothDeviceState, BluetoothState } from './bluetoothReducer';

export type WithBluetoothState<T extends BluetoothDeviceCommon> = {
bluetooth: BluetoothState<T>;
};

export const selectAdapterStatus = <T extends BluetoothDeviceCommon>(
state: WithBluetoothState<T>,
) => state.bluetooth.adapterStatus;

export const selectKnownDevices = <T extends BluetoothDeviceCommon>(state: WithBluetoothState<T>) =>
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<ConcreteBluetoothDevice>();`
*/
export const prepareSelectAllDevices = <T extends BluetoothDeviceCommon>() =>
createWeakMapSelector.withTypes<WithBluetoothState<T>>()(
[state => state.bluetooth.nearbyDevices, state => state.bluetooth.knownDevices],
(nearbyDevices, knownDevices) => {
const map = new Map<string, BluetoothDeviceState<T>>();

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 = <T extends BluetoothDeviceCommon>(state: WithBluetoothState<T>) =>
state.bluetooth.scanStatus;
15 changes: 15 additions & 0 deletions suite-common/bluetooth/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit d4b3ed4

Please sign in to comment.