Skip to content

Commit

Permalink
feat(mobile): implement BLE in mobile app
Browse files Browse the repository at this point in the history
  • Loading branch information
Nodonisko committed Apr 24, 2024
1 parent 8c19aee commit 450bb2a
Show file tree
Hide file tree
Showing 21 changed files with 610 additions and 18 deletions.
2 changes: 2 additions & 0 deletions suite-native/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ const getPlugins = (): ExpoPlugins => {
},
],
]),

['react-native-ble-plx', {}],
// These should come last
'./plugins/withRemoveXcodeLocalEnv.js',
['./plugins/withEnvFile.js', { buildType }],
Expand Down
2 changes: 2 additions & 0 deletions suite-native/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,12 @@
"react": "18.2.0",
"react-intl": "^6.6.2",
"react-native": "0.73.6",
"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",
"react-native-mmkv": "2.11.0",
"react-native-permissions": "^4.1.5",
"react-native-reanimated": "3.8.1",
"react-native-restart": "0.0.27",
"react-native-safe-area-context": "4.9.0",
Expand Down
24 changes: 24 additions & 0 deletions suite-native/bluetooth/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@suite-native/bluetooth",
"version": "1.0.0",
"private": true,
"license": "See LICENSE.md in repo root",
"sideEffects": false,
"main": "src/index",
"scripts": {
"lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'",
"test:unit": "jest -c ../../jest.config.base.js",
"type-check": "yarn g:tsc --build"
},
"dependencies": {
"@suite-native/atoms": "workspace:*",
"@suite-native/intl": "workspace:*",
"@trezor/styles": "workspace:*",
"@trezor/transport-native-ble": "workspace:*",
"expo-constants": "15.4.5",
"react": "18.2.0",
"react-native": "0.73.6",
"react-native-ble-plx": "^3.1.2",
"react-native-permissions": "^4.1.5"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { State as AdapterState } from 'react-native-ble-plx';

import { AlertBox, Button, Loader, VStack } from '@suite-native/atoms';

import { useBluetoothAdapterState } from '../hooks/useBluetoothAdapterState';
import { BluetoothPermissionErrors } from '../hooks/useBluetoothPermissions';
import { BluetoothPermissionError } from './BluetoothPermissionError';

export const BluetoothAdapterStateManager = () => {
const { bluetoothState, turnOnBluetooth } = useBluetoothAdapterState();

if (bluetoothState === AdapterState.PoweredOn) {
// We are good to go
return null;
}

if (bluetoothState === AdapterState.Unknown || bluetoothState === AdapterState.Resetting) {
return <Loader title="Loading Bluetooth" />;
}

if (bluetoothState === AdapterState.Unsupported) {
return <AlertBox title={'Bluetooth Unsupported on this device'} variant="error" />;
}

if (bluetoothState === AdapterState.Unauthorized) {
return (
<BluetoothPermissionError
error={BluetoothPermissionErrors.BluetoothAccessBlocked}
></BluetoothPermissionError>
);
}

if (bluetoothState === AdapterState.PoweredOff) {
return (
<VStack spacing="small">
<AlertBox
title={'Bluetooth is turned off. Please turn of Bluetooth to continue.'}
variant="error"
/>
<Button onPress={turnOnBluetooth}>Turn on Bluetooth</Button>
</VStack>
);
}

// Exhaustive check - this should never happen
const _exhaustiveCheck: never = bluetoothState;

return _exhaustiveCheck;
};
33 changes: 33 additions & 0 deletions suite-native/bluetooth/src/components/BluetoothPermissionError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { openSettings } from 'react-native-permissions';

import { AlertBox, Button, VStack } from '@suite-native/atoms';

import { BluetoothPermissionErrors } from '../hooks/useBluetoothPermissions';

type BluetoothPermissionErrorProps = {
error: BluetoothPermissionErrors;
};

const ERROR_MESSAGES: Record<BluetoothPermissionErrors, string> = {
[BluetoothPermissionErrors.BluetoothAccessBlocked]:
'Please enable Bluetooth permission for the app in your phone settings.',
[BluetoothPermissionErrors.LocationAccessBlocked]: 'Please enable Bluetooth on your phone',
[BluetoothPermissionErrors.NearbyDevicesAccessBlocked]:
'Please enable Nearby Devices permission for the app in your phone settings.',
};

export const BluetoothPermissionError = ({ error }: BluetoothPermissionErrorProps) => {
const handleOpenSettings = async () => {
await openSettings();
};

return (
<VStack spacing="small">
<AlertBox
title={`Bluetooth Permission Error - ${ERROR_MESSAGES[error]}`}
variant="error"
></AlertBox>
<Button onPress={handleOpenSettings}>Open Settings</Button>
</VStack>
);
};
126 changes: 126 additions & 0 deletions suite-native/bluetooth/src/components/DevicesScanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react';
import { State as AdapterState, BleError } from 'react-native-ble-plx';

import { AlertBox, Box, Button, Loader, Text, VStack } from '@suite-native/atoms';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { BLEScannedDevice, nativeBleManager } from '@trezor/transport-native-ble';

import { useBluetoothAdapterState } from '../hooks/useBluetoothAdapterState';
import { useBluetoothPermissions } from '../hooks/useBluetoothPermissions';
import { BluetoothAdapterStateManager } from './BluetoothAdapterStateManager';
import { BluetoothPermissionError } from './BluetoothPermissionError';
import { ScannedDeviceItem } from './ScannedDeviceItem';

const containerStyle = prepareNativeStyle(utils => ({
flex: 1,
width: '100%',
paddingHorizontal: utils.spacings.medium,
}));

export const DevicesScanner = () => {
const { applyStyle } = useNativeStyles();

const [scannedDevices, setScannedDevices] = useState<BLEScannedDevice[]>([]);
const [scanError, setScanError] = useState<BleError | null>();
const [isScanRunning, setIsScanRunning] = useState<boolean>(false);
const { hasBluetoothPermissions, requestBluetoothPermissions, bluetoothPermissionError } =
useBluetoothPermissions();
const { bluetoothState } = useBluetoothAdapterState();

const stopScanning = async () => {
nativeBleManager.stopDeviceScan();
setIsScanRunning(false);
setScannedDevices([]);
};

const scanDevices = () => {
setScanError(null);
setIsScanRunning(true);
setScannedDevices([]);

nativeBleManager.scanDevices(
newlyScannedDevices => {
setScannedDevices(newlyScannedDevices);
},
error => {
setScanError(error);
stopScanning();
setScannedDevices([]);
},
);
};

const requestPermissions = () => {
return requestBluetoothPermissions();
};

useEffect(() => {
return () => {
stopScanning();
};
}, []);

const shouldShowRequestPermission = !hasBluetoothPermissions;
const shouldShowScanDevicesButton =
!isScanRunning && hasBluetoothPermissions && bluetoothState === AdapterState.PoweredOn;
const shouldShowBluetoothAdapterManager =
bluetoothState !== AdapterState.PoweredOn && !isScanRunning && hasBluetoothPermissions;
const shouldShowBluetoothPermissionError = !!bluetoothPermissionError;

return (
<VStack style={applyStyle(containerStyle)} spacing="large">
<Text>
hasBluetoothPermissions: {hasBluetoothPermissions ? 'true' : 'false'} {'\n'}
bluetoothPermissionError: {bluetoothPermissionError} {'\n'}
bluetoothState: {bluetoothState} {'\n'}
isScanRunning: {isScanRunning ? 'true' : 'false'} {'\n'}
shouldShowRequestPermission: {shouldShowRequestPermission ? 'true' : 'false'} {'\n'}
</Text>
{shouldShowRequestPermission && (
<VStack>
<AlertBox
variant="warning"
title="We need Bluetooth permissions to scan for devices."
/>
<Button onPress={requestPermissions}>Request permissions</Button>
</VStack>
)}
{shouldShowBluetoothAdapterManager && <BluetoothAdapterStateManager />}

{shouldShowBluetoothPermissionError && (
<BluetoothPermissionError error={bluetoothPermissionError} />
)}
{shouldShowScanDevicesButton && <Button onPress={scanDevices}>Scan devices</Button>}
{isScanRunning && (
<VStack>
<Box
marginTop="large"
flexDirection="row"
alignItems="center"
justifyContent="center"
>
<Text>Scanning for devices...</Text>
<Loader />
</Box>
<Button onPress={stopScanning}>Stop scanning</Button>
</VStack>
)}
{scanError && (
<AlertBox
variant="error"
title={`Error while scanning for devices: ${scanError}`}
/>
)}
{isScanRunning && (
<VStack>
{scannedDevices.map(scannedDevice => (
<ScannedDeviceItem
key={scannedDevice.bleDevice.id}
device={scannedDevice}
/>
))}
</VStack>
)}
</VStack>
);
};
111 changes: 111 additions & 0 deletions suite-native/bluetooth/src/components/ScannedDeviceItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import { BleError, State as AdapterState } from 'react-native-ble-plx';
import { Alert } from 'react-native';

import { AlertBox, Box, Button, HStack, Loader, Text, VStack } from '@suite-native/atoms';
import { Translation } from '@suite-native/intl';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { BLEScannedDevice, nativeBleManager } from '@trezor/transport-native-ble';
import { Icon, IconName } from '@suite-common/icons';
import { useActiveColorScheme } from '@suite-native/theme';

import {
BluetoothPermissionErrors,
useBluetoothPermissions,
} from '../hooks/useBluetoothPermissions';
import { BluetoothPermissionError } from './BluetoothPermissionError';
import { useBluetoothAdapterState } from '../hooks/useBluetoothAdapterState';
import { BluetoothAdapterStateManager } from './BluetoothAdapterStateManager';

type ContainerStylePayload = {
seenQuiteLongAgo: boolean;
};
const containerStyle = prepareNativeStyle<ContainerStylePayload>((_, { seenQuiteLongAgo }) => ({
flexDirection: 'row',
borderWidth: 1,
width: '100%',
justifyContent: 'space-between',
alignItems: 'center',
extend: {
condition: seenQuiteLongAgo,
style: {
opacity: 0.5,
},
},
}));

const deviceItemStyle = prepareNativeStyle(utils => ({
flexDirection: 'row',
alignItems: 'center',
padding: utils.spacings.extraSmall,
backgroundColor: utils.colors.backgroundSurfaceElevation2,
width: 50,
height: 50,
borderRadius: 25,
justifyContent: 'center',
}));

const DeviceIcon = () => {
const activeColorScheme = useActiveColorScheme();
const { applyStyle } = useNativeStyles();

const connectedDeviceIcon: IconName =
activeColorScheme === 'standard' ? 'trezorConnectedLight' : 'trezorConnectedDark';

return (
<Box style={applyStyle(deviceItemStyle)}>
<Icon name={connectedDeviceIcon} color="svgSource" />
</Box>
);
};

export const ScannedDeviceItem = ({ device }: { device: BLEScannedDevice }) => {
const { bleDevice } = device;
const { applyStyle } = useNativeStyles();
const [isConnecting, setIsConnecting] = useState(false);

const connectDevice = async () => {
setIsConnecting(true);
try {
await nativeBleManager.connectDevice({
deviceOrId: bleDevice,
});
} catch (error) {
alert('Error connecting to device');
Alert.alert('Error connecting to device', error?.message, [{ text: 'OK' }]);
}
setIsConnecting(false);
};

const lastSeenInSec = Math.floor((Date.now() - device.lastSeenTimestamp) / 1000);
const seenQuiteLongAgo = lastSeenInSec > 10;

if (lastSeenInSec > 30) {
// This device is probably not in range anymore or it's not advertising anymore
return null;
}

return (
<Box style={applyStyle(containerStyle, { seenQuiteLongAgo })}>
<HStack alignItems="center">
<DeviceIcon />
<Box>
<Text variant="highlight">{bleDevice.name}</Text>
<Text variant="hint">{bleDevice.id}</Text>
{seenQuiteLongAgo && (
<Text variant="label">Last seen: {lastSeenInSec}s ago</Text>
)}
</Box>
</HStack>
{isConnecting ? (
<Box paddingRight="extraLarge">
<Loader size="small" />
</Box>
) : (
<Button onPress={connectDevice} isDisabled={isConnecting}>
Connect
</Button>
)}
</Box>
);
};
Loading

0 comments on commit 450bb2a

Please sign in to comment.