-
-
Notifications
You must be signed in to change notification settings - Fork 248
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(mobile): implement BLE in mobile app
- Loading branch information
Showing
21 changed files
with
610 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
49 changes: 49 additions & 0 deletions
49
suite-native/bluetooth/src/components/BluetoothAdapterStateManager.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
33
suite-native/bluetooth/src/components/BluetoothPermissionError.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
126
suite-native/bluetooth/src/components/DevicesScanner.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
111
suite-native/bluetooth/src/components/ScannedDeviceItem.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
Oops, something went wrong.