diff --git a/packages/device-utils/src/firmwareUtils.ts b/packages/device-utils/src/firmwareUtils.ts index bf38d80c73..6d43810a0c 100644 --- a/packages/device-utils/src/firmwareUtils.ts +++ b/packages/device-utils/src/firmwareUtils.ts @@ -1,21 +1,34 @@ -import { FirmwareType, Device } from '@trezor/connect'; +import { FirmwareType, Device, VersionArray } from '@trezor/connect'; import { isDeviceInBootloaderMode } from './modeUtils'; export const getFirmwareRevision = (device?: Device) => device?.features?.revision || ''; +export const getFirmwareVersionArray = (device?: Device): VersionArray | null => { + if (!device?.features) { + return null; + } + const { features } = device; + + if (isDeviceInBootloaderMode(device)) { + return features.fw_major && features.fw_minor && features.fw_patch + ? ([features.fw_major, features.fw_minor, features.fw_patch] as VersionArray) + : null; + } + + return [features.major_version, features.minor_version, features.patch_version]; +}; + export const getFirmwareVersion = (device?: Device) => { if (!device?.features) { return ''; } const { features } = device; - if (isDeviceInBootloaderMode(device)) { return features.fw_major ? `${features.fw_major}.${features.fw_minor}.${features.fw_patch}` : ''; } - return `${features.major_version}.${features.minor_version}.${features.patch_version}`; }; diff --git a/suite-common/wallet-core/src/device/deviceActions.ts b/suite-common/wallet-core/src/device/deviceActions.ts index 4436b19548..727046bde3 100644 --- a/suite-common/wallet-core/src/device/deviceActions.ts +++ b/suite-common/wallet-core/src/device/deviceActions.ts @@ -77,6 +77,13 @@ export const removeButtonRequests = createAction( }), ); +export const forgetAndDisconnectDevice = createAction( + `${MODULE_PREFIX}/forgetAndDisconnectDevice`, + (payload: TrezorDevice) => ({ + payload, + }), +); + export const deviceActions = { connectDevice, connectUnacquiredDevice, @@ -94,4 +101,5 @@ export const deviceActions = { selectDevice, updateSelectedDevice, removeButtonRequests, + forgetAndDisconnectDevice, }; diff --git a/suite-common/wallet-core/src/device/deviceReducer.ts b/suite-common/wallet-core/src/device/deviceReducer.ts index d789465fe3..a813f6f241 100644 --- a/suite-common/wallet-core/src/device/deviceReducer.ts +++ b/suite-common/wallet-core/src/device/deviceReducer.ts @@ -1,8 +1,10 @@ +import { memoize } from 'proxy-memoize'; + import * as deviceUtils from '@suite-common/suite-utils'; import { getStatus } from '@suite-common/suite-utils'; import { Device, Features, AuthenticateDeviceResult } from '@trezor/connect'; import { DiscoveryStatus } from '@suite-common/wallet-constants'; -import { getFirmwareVersion } from '@trezor/device-utils'; +import { getFirmwareVersion, getFirmwareVersionArray } from '@trezor/device-utils'; import { Network, networks } from '@suite-common/wallet-config'; import { versionUtils } from '@trezor/utils'; import { createReducerWithExtraDeps } from '@suite-common/redux-utils'; @@ -416,6 +418,11 @@ const forget = (draft: State, device: TrezorDevice) => { } }; +const forgetAndDisconnect = (draft: State, device: TrezorDevice) => { + forget(draft, device); + disconnectDevice(draft, device); +}; + const addButtonRequest = ( draft: State, device: TrezorDevice | undefined, @@ -498,7 +505,10 @@ export const prepareDeviceReducer = createReducerWithExtraDeps(initialState, (bu setDeviceAuthenticity(state, payload.device, payload.result); }) .addCase(extra.actionTypes.setDeviceMetadata, extra.reducers.setDeviceMetadataReducer) - .addCase(extra.actionTypes.storageLoad, extra.reducers.storageLoadDevices); + .addCase(extra.actionTypes.storageLoad, extra.reducers.storageLoadDevices) + .addCase(deviceActions.forgetAndDisconnectDevice, (state, { payload }) => { + forgetAndDisconnect(state, payload); + }); }); export const selectDevices = (state: DeviceRootState) => state.device?.devices; @@ -687,13 +697,22 @@ export const selectSelectedDeviceName = (state: DeviceRootState) => { return selectDeviceName(state, selectedDevice?.id); }; -export const selectDeviceFirmwareVersion = (state: DeviceRootState) => { - const device = selectDevice(state); - - return device?.firmwareRelease?.release.version ?? null; +export const selectSelectedDeviceId = (state: DeviceRootState) => { + const selectedDevice = selectDevice(state); + return selectedDevice?.id ?? null; }; export const selectDeviceModel = (state: DeviceRootState) => { const features = selectDeviceFeatures(state); return features?.internal_model ?? null; }; + +export const selectDeviceReleaseInfo = (state: DeviceRootState) => { + const device = selectDevice(state); + return device?.firmwareRelease ?? null; +}; + +export const selectDeviceFirmwareVersion = memoize((state: DeviceRootState) => { + const device = selectDevice(state); + return getFirmwareVersionArray(device); +}); diff --git a/suite-native/device-manager/src/components/DeviceControlButtons.tsx b/suite-native/device-manager/src/components/DeviceControlButtons.tsx index 6f8fc62fbd..6f0ba8b537 100644 --- a/suite-native/device-manager/src/components/DeviceControlButtons.tsx +++ b/suite-native/device-manager/src/components/DeviceControlButtons.tsx @@ -32,7 +32,7 @@ export const DeviceControlButtons = () => { if (!selectedDevice) return null; const handleEject = () => { - dispatch(deviceActions.forgetDevice(selectedDevice)); + dispatch(deviceActions.deviceDisconnect(selectedDevice)); }; const handleDeviceRedirect = () => { diff --git a/suite-native/device-manager/src/components/DeviceManagerContent.tsx b/suite-native/device-manager/src/components/DeviceManagerContent.tsx index 2d2c4b4d56..6b0b763b5b 100644 --- a/suite-native/device-manager/src/components/DeviceManagerContent.tsx +++ b/suite-native/device-manager/src/components/DeviceManagerContent.tsx @@ -3,7 +3,11 @@ import { useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; import { Button, Text, VStack } from '@suite-native/atoms'; -import { selectDevices, selectIsSelectedDeviceImported } from '@suite-common/wallet-core'; +import { + selectDevices, + selectIsSelectedDeviceImported, + selectSelectedDeviceId, +} from '@suite-common/wallet-core'; import { ConnectDeviceStackRoutes, RootStackParamList, @@ -29,6 +33,7 @@ export const DeviceManagerContent = () => { const { translate } = useTranslate(); const devices = useSelector(selectDevices); + const selectedDeviceId = useSelector(selectSelectedDeviceId); const isPortfolioTrackerDevice = useSelector(selectIsSelectedDeviceImported); const { setIsDeviceManagerVisible } = useDeviceManager(); @@ -47,9 +52,12 @@ export const DeviceManagerContent = () => { - {devices.map(device => ( - - ))} + {devices.map(device => { + if (device.id !== selectedDeviceId) { + return ; + } + return null; + })} {isPortfolioTrackerDevice && ( diff --git a/suite-native/device/package.json b/suite-native/device/package.json index 2514c54c1b..5e999c69ed 100644 --- a/suite-native/device/package.json +++ b/suite-native/device/package.json @@ -21,11 +21,15 @@ "@suite-native/feature-flags": "workspace:*", "@suite-native/helpers": "workspace:*", "@suite-native/intl": "workspace:*", + "@suite-native/link": "workspace:*", "@suite-native/module-settings": "workspace:*", "@suite-native/navigation": "workspace:*", "@trezor/connect": "workspace:*", + "@trezor/device-utils": "workspace:*", "@trezor/styles": "workspace:*", "react": "18.2.0", - "react-redux": "8.0.7" + "react-native": "0.71.8", + "react-redux": "8.0.7", + "semver": "^7.5.4" } } diff --git a/suite-native/device/src/components/HowToUpdateBottomSheet.tsx b/suite-native/device/src/components/HowToUpdateBottomSheet.tsx new file mode 100644 index 0000000000..51595c7fd5 --- /dev/null +++ b/suite-native/device/src/components/HowToUpdateBottomSheet.tsx @@ -0,0 +1,53 @@ +import { BottomSheet, VStack, Box, Button, Text } from '@suite-native/atoms'; +import { Translation, useTranslate } from '@suite-native/intl'; +import { useOpenLink } from '@suite-native/link'; + +export type HowToUpdateBottomSheetProps = { + isVisible: boolean; + onClose: (isVisible: boolean) => void; + title?: string; +}; + +export const HowToUpdateBottomSheet = ({ + isVisible, + onClose, + title, +}: HowToUpdateBottomSheetProps) => { + const { translate } = useTranslate(); + const openLink = useOpenLink(); + + const handleHelpClick = () => { + openLink('https://trezor.io/learn/a/update-trezor-device-firmware'); + onClose(false); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/suite-native/device/src/hooks/useDeviceConnect.tsx b/suite-native/device/src/hooks/useDeviceConnect.tsx index 56fbfc8fc8..15698f4b73 100644 --- a/suite-native/device/src/hooks/useDeviceConnect.tsx +++ b/suite-native/device/src/hooks/useDeviceConnect.tsx @@ -18,10 +18,10 @@ import { selectIsDeviceConnectedAndAuthorized, selectIsSelectedDeviceImported, selectIsUnacquiredDevice, - selectDeviceFirmwareVersion, selectDeviceModel, selectDevice, deviceActions, + selectDeviceFirmwareVersion, } from '@suite-common/wallet-core'; import { useAlert } from '@suite-native/alerts'; import { Translation, useTranslate } from '@suite-native/intl'; @@ -56,15 +56,15 @@ type NavigationProps = StackToStackCompositeNavigationProps< >; export const useDeviceConnect = () => { + const deviceModel = useSelector(selectDeviceModel); + const currentDevice = useSelector(selectDevice); const hasDeviceRequestedPin = useSelector(selectDeviceRequestedPin); const isDeviceConnectedAndAuthorized = useSelector(selectIsDeviceConnectedAndAuthorized); const isUnacquiredDevice = useSelector(selectIsUnacquiredDevice); const isConnectedDeviceUninitialized = useSelector(selectIsConnectedDeviceUninitialized); const isSelectedDeviceImported = useSelector(selectIsSelectedDeviceImported); - const firmwareVersion = useSelector(selectDeviceFirmwareVersion); - const deviceModel = useSelector(selectDeviceModel); - const currentDevice = useSelector(selectDevice); const isOnboardingFinished = useSelector(selectIsOnboardingFinished); + const deviceFwVersion = useSelector(selectDeviceFirmwareVersion); const dispatch = useDispatch(); @@ -80,6 +80,8 @@ export const useDeviceConnect = () => { } }, [currentDevice, dispatch]); + const isFirmwareSupported = isFirmwareVersionSupported(deviceFwVersion, deviceModel); + useEffect(() => { if (hasDeviceRequestedPin) { navigation.navigate(RootStackRoutes.ConnectDevice, { @@ -106,7 +108,7 @@ export const useDeviceConnect = () => { }, [dispatch, hideAlert, isOnboardingFinished, isUnacquiredDevice, showAlert, translate]); useEffect(() => { - if (isOnboardingFinished && !isFirmwareVersionSupported(firmwareVersion, deviceModel)) { + if (isOnboardingFinished && !isFirmwareSupported && !isSelectedDeviceImported) { showAlert({ title: translate('moduleDevice.unsupportedFirmware.title'), description: translate('moduleDevice.unsupportedFirmware.description'), @@ -120,12 +122,12 @@ export const useDeviceConnect = () => { } }, [ dispatch, - firmwareVersion, - deviceModel, + isFirmwareSupported, showAlert, translate, handleDisconnect, isOnboardingFinished, + isSelectedDeviceImported, ]); useEffect(() => { diff --git a/suite-native/device/src/middlewares/deviceMiddleware.ts b/suite-native/device/src/middlewares/deviceMiddleware.ts index 6cac1226ba..61ada9c918 100644 --- a/suite-native/device/src/middlewares/deviceMiddleware.ts +++ b/suite-native/device/src/middlewares/deviceMiddleware.ts @@ -53,7 +53,10 @@ export const prepareDeviceMiddleware = createMiddlewareWithExtraDeps( dispatch(authorizeDevice({ isUseEmptyPassphraseForced: true })); } - if (deviceActions.forgetDevice.match(action)) { + if ( + deviceActions.forgetDevice.match(action) || + deviceActions.forgetAndDisconnectDevice.match(action) + ) { dispatch(handleDeviceDisconnect(action.payload)); } diff --git a/suite-native/device/src/screens/DeviceInfoModalScreen.tsx b/suite-native/device/src/screens/DeviceInfoModalScreen.tsx index 7180114b50..42994f8fc6 100644 --- a/suite-native/device/src/screens/DeviceInfoModalScreen.tsx +++ b/suite-native/device/src/screens/DeviceInfoModalScreen.tsx @@ -1,6 +1,7 @@ import { useSelector } from 'react-redux'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import { G } from '@mobily/ts-belt'; import { CommonActions, useNavigation } from '@react-navigation/native'; import { DeviceModelInternal } from '@trezor/connect'; @@ -9,19 +10,26 @@ import { Box, Card, HStack, + VStack, + Button, IconButton, ScreenHeaderWrapper, Text, } from '@suite-native/atoms'; import { HomeStackRoutes, RootStackRoutes, Screen } from '@suite-native/navigation'; import { - selectDeviceFirmwareVersion, + selectDevice, selectDeviceModel, + selectDeviceReleaseInfo, selectIsSelectedDeviceImported, selectSelectedDeviceName, } from '@suite-common/wallet-core'; import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; import { useTranslate } from '@suite-native/intl'; +import { getFirmwareVersion } from '@trezor/device-utils'; +import { useOpenLink } from '@suite-native/link'; + +import { HowToUpdateBottomSheet } from '../components/HowToUpdateBottomSheet'; const deviceImage = { [DeviceModelInternal.T1B1]: require('../assets/t1.png'), @@ -33,18 +41,44 @@ const emptyBoxStyle = prepareNativeStyle(() => ({ width: 48, })); +const contentStyle = prepareNativeStyle(() => ({ + flexGrow: 1, +})); + export const DeviceInfoModalScreen = () => { const navigation = useNavigation(); - const { translate } = useTranslate(); + const openLink = useOpenLink(); const deviceModel = useSelector(selectDeviceModel); const deviceName = useSelector(selectSelectedDeviceName); - const deviceFirmwareVersion = useSelector(selectDeviceFirmwareVersion); + const device = useSelector(selectDevice); const isPortfolioTrackerDevice = useSelector(selectIsSelectedDeviceImported); - + const deviceReleaseInfo = useSelector(selectDeviceReleaseInfo); const { applyStyle } = useNativeStyles(); + const [isUpdateSheetOpen, setIsUpdateSheetOpen] = useState(false); + + const isUpgradable = deviceReleaseInfo?.isNewer ?? false; + + const getCardAlertProps = () => { + if (G.isNotNullable(deviceReleaseInfo)) { + if (isUpgradable) { + return { + alertTitle: translate('deviceInfo.outdatedFw'), + alertVariant: 'warning', + } as const; + } + return { + alertTitle: translate('deviceInfo.upToDateFw'), + alertVariant: 'success', + } as const; + } + + return { alertTitle: undefined, alertVariant: undefined } as const; + }; + const cardAlertProps = getCardAlertProps(); + useEffect(() => { if (isPortfolioTrackerDevice) { // Should be part of useDeviceConnect hook @@ -67,12 +101,17 @@ export const DeviceInfoModalScreen = () => { if (!deviceModel) return null; + const currentFwVersion = getFirmwareVersion(device); + const handleGoBack = () => { navigation.goBack(); }; - const stringifiedFirmwareVersion = deviceFirmwareVersion?.join('.'); + const handleAccessoriesClick = () => { + openLink('https://trezor.io/accessories'); + }; + const handleUpdateClick = () => setIsUpdateSheetOpen(true); return ( { } > - - - - - {deviceName} - - {translate('deviceInfo.installedFw', { stringifiedFirmwareVersion })} - - - - + + + + + + {deviceName} + + {translate('deviceInfo.installedFw', { + version: currentFwVersion, + })} + + + + + + + + {isUpgradable && ( + + )} + + ); }; diff --git a/suite-native/device/src/utils.ts b/suite-native/device/src/utils.ts index d8a12a46bb..7e98f7d251 100644 --- a/suite-native/device/src/utils.ts +++ b/suite-native/device/src/utils.ts @@ -1,4 +1,5 @@ -import { A, G } from '@mobily/ts-belt'; +import { G } from '@mobily/ts-belt'; +import * as semver from 'semver'; import { DeviceModelInternal, VersionArray } from '@trezor/connect'; @@ -18,8 +19,8 @@ export const isFirmwareVersionSupported = ( if (!minimalVersion) return true; - return A.every( - version.map((v, i) => v >= minimalVersion[i]), - val => val, - ); + const versionString = version.join('.'); + const minimalVersionString = minimalVersion.join('.'); + + return semver.satisfies(versionString, `>=${minimalVersionString}`); }; diff --git a/suite-native/device/tsconfig.json b/suite-native/device/tsconfig.json index fd5b6895ec..a8c6fc60fd 100644 --- a/suite-native/device/tsconfig.json +++ b/suite-native/device/tsconfig.json @@ -13,9 +13,11 @@ { "path": "../feature-flags" }, { "path": "../helpers" }, { "path": "../intl" }, + { "path": "../link" }, { "path": "../module-settings" }, { "path": "../navigation" }, { "path": "../../packages/connect" }, + { "path": "../../packages/device-utils" }, { "path": "../../packages/styles" } ] } diff --git a/suite-native/discovery/package.json b/suite-native/discovery/package.json index 1a9ca83261..4e6cbc7328 100644 --- a/suite-native/discovery/package.json +++ b/suite-native/discovery/package.json @@ -22,6 +22,7 @@ "@suite-native/accounts": "workspace:*", "@suite-native/config": "workspace:*", "@suite-native/device": "workspace:*", - "@trezor/connect": "workspace:*" + "@trezor/connect": "workspace:*", + "@trezor/device-utils": "workspace:*" } } diff --git a/suite-native/discovery/src/discoveryMiddleware.ts b/suite-native/discovery/src/discoveryMiddleware.ts index 167ec301b6..17491883f7 100644 --- a/suite-native/discovery/src/discoveryMiddleware.ts +++ b/suite-native/discovery/src/discoveryMiddleware.ts @@ -2,8 +2,8 @@ import { deviceActions, selectDevice, discoveryActions, - selectDeviceFirmwareVersion, selectDeviceModel, + selectDeviceFirmwareVersion, } from '@suite-common/wallet-core'; import { createMiddlewareWithExtraDeps } from '@suite-common/redux-utils'; import { isFirmwareVersionSupported } from '@suite-native/device'; @@ -19,12 +19,12 @@ export const prepareDiscoveryMiddleware = createMiddlewareWithExtraDeps( const device = selectDevice(getState()); const deviceModel = selectDeviceModel(getState()); - const deviceFirmwareVersion = selectDeviceFirmwareVersion(getState()); + const deviceFwVersion = selectDeviceFirmwareVersion(getState()); const areTestnetsEnabled = selectAreTestnetsEnabled(getState()); const isDeviceFirmwareVersionSupported = isFirmwareVersionSupported( - deviceFirmwareVersion, + deviceFwVersion, deviceModel, ); diff --git a/suite-native/discovery/tsconfig.json b/suite-native/discovery/tsconfig.json index 1d1fe6e4eb..adcb4d1f67 100644 --- a/suite-native/discovery/tsconfig.json +++ b/suite-native/discovery/tsconfig.json @@ -26,6 +26,7 @@ { "path": "../accounts" }, { "path": "../config" }, { "path": "../device" }, - { "path": "../../packages/connect" } + { "path": "../../packages/connect" }, + { "path": "../../packages/device-utils" } ] } diff --git a/suite-native/intl/src/en.ts b/suite-native/intl/src/en.ts index d1c0ce0ba4..08c3708794 100644 --- a/suite-native/intl/src/en.ts +++ b/suite-native/intl/src/en.ts @@ -305,6 +305,19 @@ export const en = { }, deviceInfo: { installedFw: 'Installed firmware: {version}', + upToDateFw: 'The firmware is up to date.', + outdatedFw: 'The firmware is outdated.', + goToAccessories: 'Get accessories @ Trezor Shop', + updateHowTo: { + title: 'How to update firmware', + subtitle: 'Follow these steps:', + lines: { + 1: '1. Connect Trezor to Desktop Suite', + 2: '2. Navigate to Settings menu', + 3: '3. Install new firmware', + }, + button: 'Learn more @ Trezor.io', + }, }, qrCode: { addressCopied: 'Address copied', diff --git a/yarn.lock b/yarn.lock index c92d828db8..67b1e067ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7385,12 +7385,16 @@ __metadata: "@suite-native/feature-flags": "workspace:*" "@suite-native/helpers": "workspace:*" "@suite-native/intl": "workspace:*" + "@suite-native/link": "workspace:*" "@suite-native/module-settings": "workspace:*" "@suite-native/navigation": "workspace:*" "@trezor/connect": "workspace:*" + "@trezor/device-utils": "workspace:*" "@trezor/styles": "workspace:*" react: "npm:18.2.0" + react-native: "npm:0.71.8" react-redux: "npm:8.0.7" + semver: "npm:^7.5.4" languageName: unknown linkType: soft @@ -7411,6 +7415,7 @@ __metadata: "@suite-native/config": "workspace:*" "@suite-native/device": "workspace:*" "@trezor/connect": "workspace:*" + "@trezor/device-utils": "workspace:*" languageName: unknown linkType: soft