From b870aa9ff159888c2d813cd5d1aa318b7f7d9f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Tue, 3 Dec 2024 18:49:14 +0100 Subject: [PATCH] feat(mobile): firmware upgrade (#15184) * fix(connect): make fetch work in react native * refactor(suite): move FW upgrade logic to common package * feat(mobile): make some atoms easily animatable * feat(mobile): firmware upgrade * fix: formatting and deps * refactor(suite): map operation to translation ID * refactor(suite): add transport selectors * refactor(suite): remove useFirmware hook * chore: bug fixes --------- Co-authored-by: Jan Komarek --- .../src/data/downloadReleasesMetadata.ts | 10 +- packages/connect/src/utils/assets-browser.ts | 18 +- packages/connect/src/utils/assets.native.ts | 31 +- packages/connect/src/utils/assets.ts | 41 +-- packages/connect/src/utils/assetsTypes.ts | 13 + packages/eslint/src/index.mjs | 1 + packages/suite/package.json | 1 + .../firmware/__fixtures__/firmwareActions.ts | 2 +- .../__tests__/firmwareActions.test.ts | 3 +- .../actions/settings/deviceSettingsActions.ts | 8 +- .../suite/__tests__/suiteActions.test.ts | 2 +- .../components/firmware/FirmwareInitial.tsx | 10 +- .../firmware/FirmwareInstallation.tsx | 9 +- .../src/components/firmware/FirmwareOffer.tsx | 5 +- .../firmware/FirmwareProgressBar.tsx | 22 +- .../firmware/FirmwareUpdateHashCheckError.tsx | 2 +- .../firmware/ReconnectDevicePrompt.tsx | 9 +- .../src/components/settings/DeviceBanner.tsx | 7 +- .../components/suite/Preloader/Preloader.tsx | 3 +- .../PrerequisitesGuide/PrerequisitesGuide.tsx | 7 +- .../src/components/suite/UdevDownload.tsx | 3 +- .../banners/SuiteBanners/SuiteBanners.tsx | 3 +- .../suite/troubleshooting/tips/BridgeTip.tsx | 3 +- packages/suite/src/hooks/suite/index.ts | 1 - .../src/hooks/suite/useOpenSuiteDesktop.ts | 6 +- .../middlewares/suite/analyticsMiddleware.ts | 4 +- .../middlewares/wallet/storageMiddleware.ts | 2 +- packages/suite/src/reducers/store.ts | 2 +- .../suite/src/reducers/suite/suiteReducer.ts | 18 +- packages/suite/src/types/suite/index.ts | 8 +- .../src/views/firmware/FirmwareCustom.tsx | 6 +- .../src/views/firmware/FirmwareModal.tsx | 5 +- .../src/views/firmware/FirmwareUpdate.tsx | 8 +- .../views/onboarding/steps/FirmwareStep.tsx | 20 +- .../views/settings/SettingsDebug/Devkit.tsx | 2 +- .../settings/SettingsDebug/GithubIssue.tsx | 3 +- .../settings/SettingsDebug/Transport.tsx | 4 +- .../SettingsDebug/TransportBackends.tsx | 3 +- .../SettingsDevice/SettingsDevice.tsx | 3 +- .../SwitchDevice/DeviceItem/DeviceHeader.tsx | 5 +- .../suite/src/views/suite/bridge/index.tsx | 3 +- packages/suite/tsconfig.json | 1 + suite-common/firmware/package.json | 23 ++ suite-common/firmware/redux.d.ts | 7 + .../src}/firmwareActions.ts | 0 .../src}/firmwareReducer.ts | 2 +- .../src}/firmwareThunks.ts | 17 +- .../src}/getBinFilesBaseUrlThunk.ts | 0 .../src/hooks/useFirmwareInstallation.ts | 78 ++--- suite-common/firmware/src/index.ts | 5 + suite-common/firmware/tsconfig.json | 12 + .../wallet-core/src/device/deviceReducer.ts | 13 +- .../src/discovery/discoveryReducer.ts | 4 +- suite-common/wallet-core/src/index.ts | 4 - suite-native/app/app.config.ts | 2 +- suite-native/atoms/src/AlertBox.tsx | 3 + suite-native/atoms/src/Box.tsx | 14 +- suite-native/atoms/src/DebugView.tsx | 10 +- suite-native/atoms/src/Text.tsx | 55 ++-- .../src/components/DeviceList.tsx | 2 +- .../src/components/ConfirmOnTrezorImage.tsx | 13 +- suite-native/device/src/deviceThunks.ts | 27 ++ .../src/hooks/useHandleDeviceConnection.ts | 8 + suite-native/device/src/index.ts | 1 + .../src/middlewares/deviceMiddleware.ts | 15 +- suite-native/device/src/utils.ts | 14 +- suite-native/firmware/package.json | 28 ++ suite-native/firmware/redux.d.ts | 7 + .../components/UpdateProgressIndicator.tsx | 289 ++++++++++++++++++ .../UpdateProgressIndicatorDemo.tsx | 72 +++++ .../firmware/src/hooks/useFirmware.tsx | 146 +++++++++ suite-native/firmware/src/index.ts | 3 + .../firmware/src/nativeFirmwareSlice.ts | 29 ++ .../FirmwareUpdateInProgressScreen.tsx | 209 +++++++++++++ suite-native/firmware/tsconfig.json | 11 + suite-native/intl/src/en.ts | 33 +- .../src/hooks/useOnDeviceReadyNavigation.ts | 5 + suite-native/module-dev-utils/package.json | 1 + .../src/screens/DemoScreen.tsx | 44 +-- suite-native/module-dev-utils/tsconfig.json | 1 + .../module-device-settings/package.json | 2 + .../module-device-settings/redux.d.ts | 7 + .../src/components/DeviceFirmwareCard.tsx | 43 ++- .../DeviceSettingsStackNavigator.tsx | 18 +- .../src/screens/ContinueOnTrezorScreen.tsx | 7 +- .../src/screens/DeviceSettingsModalScreen.tsx | 30 +- .../FirmwareUpdateScreen.tsx | 73 +++++ .../FirmwareVersionCard.tsx | 116 +++++++ .../module-device-settings/tsconfig.json | 1 + .../src/screens/HomeScreen/HomeScreen.tsx | 9 +- suite-native/navigation/src/navigators.ts | 27 +- suite-native/navigation/src/routes.ts | 3 + suite-native/state/package.json | 6 + suite-native/state/src/extraDependencies.ts | 4 +- suite-native/state/src/reducers.ts | 5 + suite-native/state/src/store.ts | 6 + suite-native/state/tsconfig.json | 2 + yarn.lock | 46 ++- 98 files changed, 1630 insertions(+), 299 deletions(-) create mode 100644 packages/connect/src/utils/assetsTypes.ts create mode 100644 suite-common/firmware/package.json create mode 100644 suite-common/firmware/redux.d.ts rename suite-common/{wallet-core/src/firmware => firmware/src}/firmwareActions.ts (100%) rename suite-common/{wallet-core/src/firmware => firmware/src}/firmwareReducer.ts (98%) rename suite-common/{wallet-core/src/firmware => firmware/src}/firmwareThunks.ts (92%) rename suite-common/{wallet-core/src/firmware => firmware/src}/getBinFilesBaseUrlThunk.ts (100%) rename packages/suite/src/hooks/suite/useFirmware.ts => suite-common/firmware/src/hooks/useFirmwareInstallation.ts (78%) create mode 100644 suite-common/firmware/src/index.ts create mode 100644 suite-common/firmware/tsconfig.json create mode 100644 suite-native/device/src/deviceThunks.ts create mode 100644 suite-native/firmware/package.json create mode 100644 suite-native/firmware/redux.d.ts create mode 100644 suite-native/firmware/src/components/UpdateProgressIndicator.tsx create mode 100644 suite-native/firmware/src/components/UpdateProgressIndicatorDemo.tsx create mode 100644 suite-native/firmware/src/hooks/useFirmware.tsx create mode 100644 suite-native/firmware/src/index.ts create mode 100644 suite-native/firmware/src/nativeFirmwareSlice.ts create mode 100644 suite-native/firmware/src/screens/FirmwareUpdateInProgressScreen.tsx create mode 100644 suite-native/firmware/tsconfig.json create mode 100644 suite-native/module-device-settings/redux.d.ts create mode 100644 suite-native/module-device-settings/src/screens/FirmwareUpdateScreen/FirmwareUpdateScreen.tsx create mode 100644 suite-native/module-device-settings/src/screens/FirmwareUpdateScreen/FirmwareVersionCard.tsx diff --git a/packages/connect/src/data/downloadReleasesMetadata.ts b/packages/connect/src/data/downloadReleasesMetadata.ts index 7f70ae2a0c9..648911b7411 100644 --- a/packages/connect/src/data/downloadReleasesMetadata.ts +++ b/packages/connect/src/data/downloadReleasesMetadata.ts @@ -11,12 +11,10 @@ export const downloadReleasesMetadata = async ({ }: DownloadReleasesMetadataParams): Promise => { const url = `https://data.trezor.io/firmware/${internal_model.toLowerCase()}/releases.json`; - const response = await httpRequest( - url, - 'json', - { signal: AbortSignal.timeout(10000) }, - true, // skipLocalForceDownload=true - ); + const response = await httpRequest(url, 'json', { + signal: AbortSignal.timeout(10000), + skipLocalForceDownload: true, + }); if (isValidReleases(response)) { return response; diff --git a/packages/connect/src/utils/assets-browser.ts b/packages/connect/src/utils/assets-browser.ts index fc4c5409643..95e51d1afcf 100644 --- a/packages/connect/src/utils/assets-browser.ts +++ b/packages/connect/src/utils/assets-browser.ts @@ -1,12 +1,12 @@ -// origin https://github.com/trezor/connect/blob/develop/src/js/env/browser/networkUtils.js - import fetch from 'cross-fetch'; -export const httpRequest = async ( +import { HttpRequestType, HttpRequestReturnType, HttpRequestOptions } from './assetsTypes'; + +export const httpRequest = async ( url: string, - type: 'text' | 'binary' | 'json' = 'text', - options?: RequestInit, -) => { + type: T = 'text' as T, + options?: HttpRequestOptions, +): Promise> => { const init: RequestInit = { ...options, credentials: 'same-origin' }; const response = await fetch(url, init); @@ -14,13 +14,13 @@ export const httpRequest = async ( if (type === 'json') { const txt = await response.text(); - return JSON.parse(txt); + return JSON.parse(txt) as HttpRequestReturnType; } if (type === 'binary') { - return response.arrayBuffer(); + return response.arrayBuffer() as Promise>; } - return response.text(); + return response.text() as Promise>; } throw new Error(`httpRequest error: ${url} ${response.statusText}`); diff --git a/packages/connect/src/utils/assets.native.ts b/packages/connect/src/utils/assets.native.ts index d5564403e9d..bb786314fb3 100644 --- a/packages/connect/src/utils/assets.native.ts +++ b/packages/connect/src/utils/assets.native.ts @@ -1,3 +1,32 @@ +import { HttpRequestType, HttpRequestReturnType, HttpRequestOptions } from './assetsTypes'; import { tryLocalAssetRequire } from './assetUtils'; -export const httpRequest = (url: string, _type: string): any => tryLocalAssetRequire(url); +export function httpRequest( + url: string, + type: T, + options?: HttpRequestOptions, +): Promise> { + const asset = options?.skipLocalForceDownload ? null : tryLocalAssetRequire(url); + + if (!asset) { + return fetch(url, { + ...options, + }) + .then(response => { + if (type === 'binary') { + return response.arrayBuffer() as unknown as HttpRequestReturnType; + } + if (type === 'json') { + return response.json() as unknown as HttpRequestReturnType; + } + + return response.text() as unknown as HttpRequestReturnType; + }) + .catch(error => { + console.error('httpRequest native error', error); + throw error; + }); + } + + return asset as Promise>; +} diff --git a/packages/connect/src/utils/assets.ts b/packages/connect/src/utils/assets.ts index 457970ada86..9a97f5fb7a5 100644 --- a/packages/connect/src/utils/assets.ts +++ b/packages/connect/src/utils/assets.ts @@ -1,47 +1,26 @@ -// https://github.com/trezor/connect/blob/develop/src/js/env/node/networkUtils.js - import fetch from 'cross-fetch'; import { promises as fs } from 'fs'; import { httpRequest as browserHttpRequest } from './assets-browser'; +import { HttpRequestOptions, HttpRequestReturnType, HttpRequestType } from './assetsTypes'; import { tryLocalAssetRequire } from './assetUtils'; if (global && typeof global.fetch !== 'function') { global.fetch = fetch; } -export function httpRequest( - url: string, - type: 'text', - options?: RequestInit, - skipLocalForceDownload?: boolean, -): Promise; - -export function httpRequest( +export function httpRequest( url: string, - type: 'binary', - options?: RequestInit, - skipLocalForceDownload?: boolean, -): Promise; - -export function httpRequest( - url: string, - type: 'json', - options?: RequestInit, - skipLocalForceDownload?: boolean, -): Promise>; - -export function httpRequest( - url: any, - type: any, - options?: RequestInit, - skipLocalForceDownload?: boolean, -) { - const asset = skipLocalForceDownload ? null : tryLocalAssetRequire(url); + type: T, + options?: HttpRequestOptions, +): Promise> { + const asset = options?.skipLocalForceDownload ? null : tryLocalAssetRequire(url); if (!asset) { - return /^https?/.test(url) ? browserHttpRequest(url, type, options) : fs.readFile(url); + return /^https?/.test(url) + ? browserHttpRequest(url, type, options) + : (fs.readFile(url) as Promise>); } - return asset; + return asset as Promise>; } diff --git a/packages/connect/src/utils/assetsTypes.ts b/packages/connect/src/utils/assetsTypes.ts new file mode 100644 index 00000000000..c1f6c31d04a --- /dev/null +++ b/packages/connect/src/utils/assetsTypes.ts @@ -0,0 +1,13 @@ +export type HttpRequestType = 'text' | 'binary' | 'json'; + +export type HttpRequestReturnType = T extends 'text' + ? string + : T extends 'binary' + ? ArrayBuffer | Buffer + : T extends 'json' + ? Record + : never; + +export interface HttpRequestOptions extends RequestInit { + skipLocalForceDownload?: boolean; +} diff --git a/packages/eslint/src/index.mjs b/packages/eslint/src/index.mjs index 5d36b2de05b..3a748e2293c 100644 --- a/packages/eslint/src/index.mjs +++ b/packages/eslint/src/index.mjs @@ -25,6 +25,7 @@ export const eslint = [ '**/node_modules/*', '**/public/*', '**/ci/', + '**/.expo/*', 'eslint-local-rules/*', ], }, diff --git a/packages/suite/package.json b/packages/suite/package.json index ec97d9b27ca..06a49b28158 100644 --- a/packages/suite/package.json +++ b/packages/suite/package.json @@ -32,6 +32,7 @@ "@suite-common/connect-init": "workspace:*", "@suite-common/device-authenticity": "workspace:*", "@suite-common/fiat-services": "workspace:*", + "@suite-common/firmware": "workspace:*", "@suite-common/formatters": "workspace:*", "@suite-common/icons": "workspace:*", "@suite-common/intl-types": "workspace:*", diff --git a/packages/suite/src/actions/firmware/__fixtures__/firmwareActions.ts b/packages/suite/src/actions/firmware/__fixtures__/firmwareActions.ts index 42ad562d36b..fb738f59d49 100644 --- a/packages/suite/src/actions/firmware/__fixtures__/firmwareActions.ts +++ b/packages/suite/src/actions/firmware/__fixtures__/firmwareActions.ts @@ -1,5 +1,5 @@ import { testMocks } from '@suite-common/test-utils'; -import { firmwareUpdate, firmwareActions } from '@suite-common/wallet-core'; +import { firmwareUpdate, firmwareActions } from '@suite-common/firmware'; import { UI, DeviceModelInternal, FirmwareType } from '@trezor/connect'; const { getSuiteDevice, getFirmwareRelease } = testMocks; diff --git a/packages/suite/src/actions/firmware/__tests__/firmwareActions.test.ts b/packages/suite/src/actions/firmware/__tests__/firmwareActions.test.ts index f62312f636f..f1054433cac 100644 --- a/packages/suite/src/actions/firmware/__tests__/firmwareActions.test.ts +++ b/packages/suite/src/actions/firmware/__tests__/firmwareActions.test.ts @@ -1,6 +1,7 @@ import { testMocks } from '@suite-common/test-utils'; -import { prepareFirmwareReducer, State as DeviceState } from '@suite-common/wallet-core'; +import { State as DeviceState } from '@suite-common/wallet-core'; import { DeviceModelInternal } from '@trezor/connect'; +import { prepareFirmwareReducer } from '@suite-common/firmware'; import { configureStore, filterThunkActionTypes } from 'src/support/tests/configureStore'; import suiteReducer from 'src/reducers/suite/suiteReducer'; diff --git a/packages/suite/src/actions/settings/deviceSettingsActions.ts b/packages/suite/src/actions/settings/deviceSettingsActions.ts index fd9e7a22fc9..e1fdb50bd6f 100644 --- a/packages/suite/src/actions/settings/deviceSettingsActions.ts +++ b/packages/suite/src/actions/settings/deviceSettingsActions.ts @@ -1,9 +1,5 @@ -import { - selectDevices, - selectDevice, - deviceActions, - FIRMWARE_MODULE_PREFIX, -} from '@suite-common/wallet-core'; +import { selectDevices, selectDevice, deviceActions } from '@suite-common/wallet-core'; +import { FIRMWARE_MODULE_PREFIX } from '@suite-common/firmware'; import * as deviceUtils from '@suite-common/suite-utils'; import TrezorConnect, { ERRORS } from '@trezor/connect'; import { analytics, EventType } from '@trezor/suite-analytics'; diff --git a/packages/suite/src/actions/suite/__tests__/suiteActions.test.ts b/packages/suite/src/actions/suite/__tests__/suiteActions.test.ts index 04d612f739c..23876ab0a6c 100644 --- a/packages/suite/src/actions/suite/__tests__/suiteActions.test.ts +++ b/packages/suite/src/actions/suite/__tests__/suiteActions.test.ts @@ -6,7 +6,6 @@ import { selectDevice, selectDevices, selectDevicesCount, - prepareFirmwareReducer, deviceActions, acquireDevice, authConfirm, @@ -20,6 +19,7 @@ import { createDeviceInstanceThunk, ConnectDeviceSettings, } from '@suite-common/wallet-core'; +import { prepareFirmwareReducer } from '@suite-common/firmware'; import { connectInitThunk } from '@suite-common/connect-init'; import { DEVICE } from '@trezor/connect'; diff --git a/packages/suite/src/components/firmware/FirmwareInitial.tsx b/packages/suite/src/components/firmware/FirmwareInitial.tsx index ffdeac9c2ba..dcd85fd6b88 100644 --- a/packages/suite/src/components/firmware/FirmwareInitial.tsx +++ b/packages/suite/src/components/firmware/FirmwareInitial.tsx @@ -10,9 +10,10 @@ import { getFirmwareVersion, isBitcoinOnlyDevice } from '@trezor/device-utils'; import { FirmwareType } from '@trezor/connect'; import { selectDevices } from '@suite-common/wallet-core'; import { spacingsPx } from '@trezor/theme'; +import { useFirmwareInstallation } from '@suite-common/firmware'; import { OnboardingStepBox, OnboardingButtonSkip } from 'src/components/onboarding'; -import { useDevice, useFirmware, useOnboarding, useSelector } from 'src/hooks/suite'; +import { useDevice, useOnboarding, useSelector } from 'src/hooks/suite'; import { FirmwareInstallButton, FirmwareOffer } from 'src/components/firmware'; import { PrerequisitesGuide, Translation } from '../suite'; @@ -126,9 +127,10 @@ export const FirmwareInitial = ({ }: FirmwareInitialProps) => { const [bitcoinOnlyOffer, setBitcoinOnlyOffer] = useState(false); const { device } = useDevice(); - const { deviceWillBeWiped, firmwareUpdate, setStatus, targetFirmwareType } = useFirmware({ - shouldSwitchFirmwareType, - }); + const { deviceWillBeWiped, firmwareUpdate, setStatus, targetFirmwareType } = + useFirmwareInstallation({ + shouldSwitchFirmwareType, + }); const { goToNextStep, updateAnalytics } = useOnboarding(); const devices = useSelector(selectDevices); diff --git a/packages/suite/src/components/firmware/FirmwareInstallation.tsx b/packages/suite/src/components/firmware/FirmwareInstallation.tsx index bb6232c3b38..26a825d17e8 100644 --- a/packages/suite/src/components/firmware/FirmwareInstallation.tsx +++ b/packages/suite/src/components/firmware/FirmwareInstallation.tsx @@ -3,13 +3,13 @@ import styled from 'styled-components'; import { Button } from '@trezor/components'; import { UI } from '@trezor/connect'; import { spacingsPx } from '@trezor/theme'; +import { useFirmwareInstallation } from '@suite-common/firmware'; import { Translation, WebUsbButton } from 'src/components/suite'; -import { useFirmware } from 'src/hooks/suite'; import { FirmwareOffer, FirmwareProgressBar, ReconnectDevicePrompt } from 'src/components/firmware'; import { OnboardingStepBox } from 'src/components/onboarding'; import { TrezorDevice } from 'src/types/suite'; -import { selectIsActionAbortable } from 'src/reducers/suite/suiteReducer'; +import { selectIsActionAbortable, selectIsWebUsb } from 'src/reducers/suite/suiteReducer'; import { useSelector } from 'src/hooks/suite/useSelector'; const SelectDevice = styled.div` @@ -40,12 +40,13 @@ export const FirmwareInstallation = ({ onPromptClose, onSuccess, }: FirmwareInstallationProps) => { - const { status, isWebUSB, showReconnectPrompt, uiEvent, targetType } = useFirmware(); + const { status, showReconnectPrompt, uiEvent, targetType } = useFirmwareInstallation(); const isActionAbortable = useSelector(selectIsActionAbortable); + const isWebUsbTransport = useSelector(selectIsWebUsb); const getInnerActionComponent = () => { if ( - isWebUSB && + isWebUsbTransport && uiEvent?.type === UI.FIRMWARE_RECONNECT && uiEvent.payload.disconnected && uiEvent.payload.i > 2 && // Add some latency for cases when the device is already paired or is restarting. diff --git a/packages/suite/src/components/firmware/FirmwareOffer.tsx b/packages/suite/src/components/firmware/FirmwareOffer.tsx index 43e6d38a78b..9bbc1f7f41a 100644 --- a/packages/suite/src/components/firmware/FirmwareOffer.tsx +++ b/packages/suite/src/components/firmware/FirmwareOffer.tsx @@ -9,9 +9,10 @@ import { Icon, Markdown, Tooltip, variables } from '@trezor/components'; import { getFirmwareVersion } from '@trezor/device-utils'; import { FirmwareType } from '@trezor/connect'; import { spacingsPx } from '@trezor/theme'; +import { useFirmwareInstallation } from '@suite-common/firmware'; import { Translation, TrezorLink } from 'src/components/suite'; -import { useFirmware, useTranslation, useSelector } from 'src/hooks/suite'; +import { useTranslation, useSelector } from 'src/hooks/suite'; import { getSuiteFirmwareTypeString } from 'src/utils/firmware'; const FwVersionWrapper = styled.div` @@ -59,7 +60,7 @@ interface FirmwareOfferProps { export const FirmwareOffer = ({ customFirmware, targetFirmwareType }: FirmwareOfferProps) => { const useDevkit = useSelector(state => state.firmware.useDevkit); - const { originalDevice } = useFirmware(); + const { originalDevice } = useFirmwareInstallation(); const { translationString } = useTranslation(); if (!originalDevice?.firmwareRelease) { diff --git a/packages/suite/src/components/firmware/FirmwareProgressBar.tsx b/packages/suite/src/components/firmware/FirmwareProgressBar.tsx index e27f541b66e..97ba8eb8afd 100644 --- a/packages/suite/src/components/firmware/FirmwareProgressBar.tsx +++ b/packages/suite/src/components/firmware/FirmwareProgressBar.tsx @@ -2,8 +2,10 @@ import styled, { useTheme } from 'styled-components'; import { Icon, ProgressBar, variables } from '@trezor/components'; import { borders, spacingsPx } from '@trezor/theme'; +import { TranslationKey } from '@suite-common/intl-types'; +import { FirmwareOperationStatus, useFirmwareInstallation } from '@suite-common/firmware'; -import { useFirmware } from 'src/hooks/suite'; +import { Translation } from '../suite'; const Wrapper = styled.div` display: flex; @@ -49,15 +51,29 @@ const Percentage = styled.div` height: ${spacingsPx.xl}; `; +const mapOperationToTransaltionId: Record< + NonNullable, + TranslationKey +> = { + installing: 'TR_INSTALLING', + validating: 'TR_VALIDATION', + restarting: 'TR_WAIT_FOR_REBOOT', + completed: 'TR_FIRMWARE_STATUS_INSTALLATION_COMPLETED', +}; + export const FirmwareProgressBar = () => { const theme = useTheme(); - const { operation, progress } = useFirmware(); + const { operation, progress } = useFirmwareInstallation(); const isDone = progress === 100; return ( - + {operation && ( + + )} {isDone ? ( diff --git a/packages/suite/src/components/firmware/FirmwareUpdateHashCheckError.tsx b/packages/suite/src/components/firmware/FirmwareUpdateHashCheckError.tsx index 14e2ebb6263..7da046db5ae 100644 --- a/packages/suite/src/components/firmware/FirmwareUpdateHashCheckError.tsx +++ b/packages/suite/src/components/firmware/FirmwareUpdateHashCheckError.tsx @@ -1,4 +1,4 @@ -import { INVALID_HASH_ERROR } from '@suite-common/wallet-core'; +import { INVALID_HASH_ERROR } from '@suite-common/firmware'; import { OnboardingStepBox } from '../onboarding'; import { Translation } from '../suite'; diff --git a/packages/suite/src/components/firmware/ReconnectDevicePrompt.tsx b/packages/suite/src/components/firmware/ReconnectDevicePrompt.tsx index 6276ef71eaf..67be4171740 100644 --- a/packages/suite/src/components/firmware/ReconnectDevicePrompt.tsx +++ b/packages/suite/src/components/firmware/ReconnectDevicePrompt.tsx @@ -7,10 +7,12 @@ import { ConfirmOnDevice } from '@trezor/product-components'; import { TranslationKey } from '@suite-common/intl-types'; import { spacings } from '@trezor/theme'; import { selectDeviceLabelOrName } from '@suite-common/wallet-core'; +import { useFirmwareInstallation } from '@suite-common/firmware'; -import { useDevice, useFirmware, useSelector } from 'src/hooks/suite'; +import { useDevice, useSelector } from 'src/hooks/suite'; import { DeviceConfirmImage } from 'src/components/suite/DeviceConfirmImage'; import { Translation, WebUsbButton } from 'src/components/suite'; +import { selectIsWebUsb } from 'src/reducers/suite/suiteReducer'; const RebootDeviceGraphics = ({ device, @@ -62,7 +64,8 @@ interface ReconnectDevicePromptProps { export const ReconnectDevicePrompt = ({ onClose, onSuccess }: ReconnectDevicePromptProps) => { const deviceLabel = useSelector(selectDeviceLabelOrName); - const { showManualReconnectPrompt, isWebUSB, status, uiEvent } = useFirmware(); + const isWebUsbTransport = useSelector(selectIsWebUsb); + const { showManualReconnectPrompt, status, uiEvent } = useFirmwareInstallation(); const { device } = useDevice(); const isManualRebootRequired = @@ -93,7 +96,7 @@ export const ReconnectDevicePrompt = ({ onClose, onSuccess }: ReconnectDevicePro const deviceModelInternal = device?.features?.internal_model; const isAbortable = onClose !== undefined && isManualRebootRequired && rebootPhase == 'waiting-for-reboot'; - const showWebUsbButton = rebootPhase === 'disconnected' && isWebUSB; + const showWebUsbButton = rebootPhase === 'disconnected' && isWebUsbTransport; const toNormal = uiEvent?.type === UI.FIRMWARE_RECONNECT && diff --git a/packages/suite/src/components/settings/DeviceBanner.tsx b/packages/suite/src/components/settings/DeviceBanner.tsx index 5b3ef7c8fb6..386c3a86a06 100644 --- a/packages/suite/src/components/settings/DeviceBanner.tsx +++ b/packages/suite/src/components/settings/DeviceBanner.tsx @@ -6,8 +6,8 @@ import { Card, LottieAnimation, Paragraph, Row, variables, Text } from '@trezor/ import { spacings } from '@trezor/theme'; import { useDevice, useSelector } from 'src/hooks/suite'; -import { isWebUsb } from 'src/utils/suite/transport'; import { WebUsbButton } from 'src/components/suite/WebUsbButton'; +import { selectIsWebUsb } from 'src/reducers/suite/suiteReducer'; // eslint-disable-next-line local-rules/no-override-ds-component const StyledLottieAnimation = styled(LottieAnimation)` @@ -45,10 +45,7 @@ interface DeviceBannerProps { export const DeviceBanner = ({ title, description }: DeviceBannerProps) => { const { device } = useDevice(); - - const transport = useSelector(state => state.suite.transport); - - const isWebUsbTransport = isWebUsb(transport); + const isWebUsbTransport = useSelector(selectIsWebUsb); return ( // Decides which content should be displayed basing on route and prerequisites. export const Preloader = ({ children }: PropsWithChildren) => { const lifecycle = useSelector(state => state.suite.lifecycle); - const transport = useSelector(state => state.suite.transport); + const transport = useSelector(selectTransport); const router = useSelector(state => state.router); const prerequisite = useSelector(selectPrerequisite); const isLoggedOut = useSelector(selectIsLoggedOut); diff --git a/packages/suite/src/components/suite/PrerequisitesGuide/PrerequisitesGuide.tsx b/packages/suite/src/components/suite/PrerequisitesGuide/PrerequisitesGuide.tsx index b19368df188..509705e0b1e 100644 --- a/packages/suite/src/components/suite/PrerequisitesGuide/PrerequisitesGuide.tsx +++ b/packages/suite/src/components/suite/PrerequisitesGuide/PrerequisitesGuide.tsx @@ -8,9 +8,8 @@ import { Button, motionEasing } from '@trezor/components'; import { selectDevices, selectDevice } from '@suite-common/wallet-core'; import { ConnectDevicePrompt, Translation } from 'src/components/suite'; -import { isWebUsb } from 'src/utils/suite/transport'; import { useDispatch, useSelector } from 'src/hooks/suite'; -import { selectPrerequisite } from 'src/reducers/suite/suiteReducer'; +import { selectIsWebUsb, selectPrerequisite } from 'src/reducers/suite/suiteReducer'; import { goto } from 'src/actions/suite/routerActions'; import { Transport } from './Transport'; @@ -53,11 +52,9 @@ export const PrerequisitesGuide = ({ allowSwitchDevice }: PrerequisitesGuideProp const device = useSelector(selectDevice); const devices = useSelector(selectDevices); const connectedDevicesCount = devices.filter(d => d.connected === true).length; - const transport = useSelector(state => state.suite.transport); + const isWebUsbTransport = useSelector(selectIsWebUsb); const prerequisite = useSelector(selectPrerequisite); - const isWebUsbTransport = isWebUsb(transport); - const TipComponent = useMemo( () => (): React.JSX.Element => { switch (prerequisite) { diff --git a/packages/suite/src/components/suite/UdevDownload.tsx b/packages/suite/src/components/suite/UdevDownload.tsx index bade558f127..c39dab878f0 100644 --- a/packages/suite/src/components/suite/UdevDownload.tsx +++ b/packages/suite/src/components/suite/UdevDownload.tsx @@ -8,6 +8,7 @@ import { spacings, typography } from '@trezor/theme'; import { Translation } from 'src/components/suite'; import { useSelector } from 'src/hooks/suite'; +import { selectTransport } from 'src/reducers/suite/suiteReducer'; import { LearnMoreButton } from './LearnMoreButton'; @@ -50,7 +51,7 @@ interface Installer { } export const UdevDownload = () => { - const transport = useSelector(state => state.suite.transport); + const transport = useSelector(selectTransport); const installers: Installer[] = transport && transport.udev diff --git a/packages/suite/src/components/suite/banners/SuiteBanners/SuiteBanners.tsx b/packages/suite/src/components/suite/banners/SuiteBanners/SuiteBanners.tsx index 81f9a444be4..7c8f2fe2b73 100644 --- a/packages/suite/src/components/suite/banners/SuiteBanners/SuiteBanners.tsx +++ b/packages/suite/src/components/suite/banners/SuiteBanners/SuiteBanners.tsx @@ -13,6 +13,7 @@ import { MAX_CONTENT_WIDTH } from 'src/constants/suite/layout'; import { selectFirmwareHashCheckErrorIfEnabled, selectFirmwareRevisionCheckErrorIfEnabled, + selectTransport, } from 'src/reducers/suite/suiteReducer'; import { MessageSystemBanner } from '../MessageSystemBanner'; @@ -36,7 +37,7 @@ const Container = styled.div<{ $isVisible?: boolean }>` `; export const SuiteBanners = () => { - const transport = useSelector(state => state.suite.transport); + const transport = useSelector(selectTransport); const device = useSelector(selectDevice); const isOnline = useSelector(state => state.suite.online); const firmwareHashInvalid = useSelector(state => state.firmware.firmwareHashInvalid); diff --git a/packages/suite/src/components/suite/troubleshooting/tips/BridgeTip.tsx b/packages/suite/src/components/suite/troubleshooting/tips/BridgeTip.tsx index 0e3180a70ee..4369fd7c1dc 100644 --- a/packages/suite/src/components/suite/troubleshooting/tips/BridgeTip.tsx +++ b/packages/suite/src/components/suite/troubleshooting/tips/BridgeTip.tsx @@ -7,6 +7,7 @@ import { Translation } from 'src/components/suite/Translation'; import { useOpenSuiteDesktop } from 'src/hooks/suite/useOpenSuiteDesktop'; import { useBridgeDesktopApi } from 'src/hooks/suite/useBridgeDesktopApi'; import { useSelector } from 'src/hooks/suite'; +import { selectTransport } from 'src/reducers/suite/suiteReducer'; export const Wrapper = styled.div` a { @@ -50,7 +51,7 @@ export const BridgeStatus = () => ( export const BridgeToggle = () => { const { changeBridgeSettings, bridgeSettings } = useBridgeDesktopApi(); - const transport = useSelector(state => state.suite.transport); + const transport = useSelector(selectTransport); if (!bridgeSettings) return null; diff --git a/packages/suite/src/hooks/suite/index.ts b/packages/suite/src/hooks/suite/index.ts index 5aa871ce27e..a05963bf99c 100644 --- a/packages/suite/src/hooks/suite/index.ts +++ b/packages/suite/src/hooks/suite/index.ts @@ -6,7 +6,6 @@ export { useLayout } from './useLayout'; export { useLayoutSize } from './useLayoutSize'; export { useGraph } from './useGraph'; export { useAccountSearch } from './useAccountSearch'; -export { useFirmware } from './useFirmware'; export { useSelector } from './useSelector'; export { useLoadingSkeleton } from './useLoadingSkeleton'; export { useTranslation } from './useTranslation'; diff --git a/packages/suite/src/hooks/suite/useOpenSuiteDesktop.ts b/packages/suite/src/hooks/suite/useOpenSuiteDesktop.ts index 8075c23e508..71a0708449c 100644 --- a/packages/suite/src/hooks/suite/useOpenSuiteDesktop.ts +++ b/packages/suite/src/hooks/suite/useOpenSuiteDesktop.ts @@ -3,14 +3,14 @@ import type TrezorConnectWeb from '@trezor/connect-web'; import { useWindowFocus } from '@trezor/react-utils'; import { SUITE_BRIDGE_DEEPLINK, SUITE_URL } from '@trezor/urls'; -import { isWebUsb } from 'src/utils/suite/transport'; import { useSelector } from 'src/hooks/suite'; +import { selectIsWebUsb } from 'src/reducers/suite/suiteReducer'; export const useOpenSuiteDesktop = () => { - const transport = useSelector(state => state.suite.transport); + const isWebUsbTransport = useSelector(selectIsWebUsb); const windowFocused = useWindowFocus(); const handleOpenSuite = () => { - if (isWebUsb(transport)) { + if (isWebUsbTransport) { (TrezorConnect as typeof TrezorConnectWeb).disableWebUSB(); } diff --git a/packages/suite/src/middlewares/suite/analyticsMiddleware.ts b/packages/suite/src/middlewares/suite/analyticsMiddleware.ts index 084841ef2ed..ba751a7019f 100644 --- a/packages/suite/src/middlewares/suite/analyticsMiddleware.ts +++ b/packages/suite/src/middlewares/suite/analyticsMiddleware.ts @@ -9,11 +9,13 @@ import { selectDevicesCount, authorizeDeviceThunk, deviceActions, +} from '@suite-common/wallet-core'; +import { firmwareActions, handleFwHashError, INVALID_HASH_ERROR, firmwareUpdate, -} from '@suite-common/wallet-core'; +} from '@suite-common/firmware'; import { analytics, EventType } from '@trezor/suite-analytics'; import { TRANSPORT, DEVICE } from '@trezor/connect'; import { diff --git a/packages/suite/src/middlewares/wallet/storageMiddleware.ts b/packages/suite/src/middlewares/wallet/storageMiddleware.ts index 5b20e730e92..653e3e436e9 100644 --- a/packages/suite/src/middlewares/wallet/storageMiddleware.ts +++ b/packages/suite/src/middlewares/wallet/storageMiddleware.ts @@ -4,7 +4,6 @@ import { isAnyOf } from '@reduxjs/toolkit'; import { selectDevices, selectDevice, - firmwareActions, selectDiscoveryByDeviceState, discoveryActions, accountsActions, @@ -17,6 +16,7 @@ import { selectDeviceByStaticSessionId, sendFormActions, } from '@suite-common/wallet-core'; +import { firmwareActions } from '@suite-common/firmware'; import { isDeviceRemembered } from '@suite-common/suite-utils'; import { messageSystemActions } from '@suite-common/message-system'; import { findAccountDevice } from '@suite-common/wallet-utils'; diff --git a/packages/suite/src/reducers/store.ts b/packages/suite/src/reducers/store.ts index ab0405fa75c..27a652447bb 100644 --- a/packages/suite/src/reducers/store.ts +++ b/packages/suite/src/reducers/store.ts @@ -4,11 +4,11 @@ import { configureStore, combineReducers } from '@reduxjs/toolkit'; import thunkMiddleware from 'redux-thunk'; import { createLogger } from 'redux-logger'; -import { prepareFirmwareReducer } from '@suite-common/wallet-core'; import { addLog } from '@suite-common/logger'; import { isCodesignBuild } from '@trezor/env-utils'; import { mergeDeepObject } from '@trezor/utils'; import { prepareTokenDefinitionsReducer } from '@suite-common/token-definitions'; +import { prepareFirmwareReducer } from '@suite-common/firmware'; import suiteMiddlewares from 'src/middlewares/suite'; import walletMiddlewares from 'src/middlewares/wallet'; diff --git a/packages/suite/src/reducers/suite/suiteReducer.ts b/packages/suite/src/reducers/suite/suiteReducer.ts index 5a526f08168..6ade3ad22ad 100644 --- a/packages/suite/src/reducers/suite/suiteReducer.ts +++ b/packages/suite/src/reducers/suite/suiteReducer.ts @@ -25,6 +25,7 @@ import { isSkippedHashCheckError, revisionCheckErrorScenarios, } from 'src/constants/suite/firmware'; +import { isWebUsb } from 'src/utils/suite/transport'; import { RouterRootState, selectRouter } from './routerReducer'; @@ -397,10 +398,21 @@ export const selectIsRouterLocked = (state: SuiteRootState) => export const selectIsRouterOrUiLocked = (state: SuiteRootState) => !!state.suite.locks[SUITE.LOCK_TYPE.ROUTER] || !!state.suite.locks[SUITE.LOCK_TYPE.UI]; -export const selectIsActionAbortable = (state: SuiteRootState) => - state.suite.transport?.type === 'BridgeTransport' - ? versionUtils.isNewerOrEqual(state.suite.transport?.version as string, '2.0.31') +export const selectTransport = (state: SuiteRootState) => state.suite.transport; + +export const selectIsWebUsb = (state: SuiteRootState) => { + const transport = selectTransport(state); + + return isWebUsb(transport); +}; + +export const selectIsActionAbortable = (state: SuiteRootState) => { + const transport = selectTransport(state); + + return transport?.type === 'BridgeTransport' + ? versionUtils.isNewerOrEqual(transport?.version as string, '2.0.31') : true; // WebUSB +}; export const selectPrerequisite = (state: SuiteRootState & RouterRootState & DeviceRootState) => { const { transport } = state.suite; diff --git a/packages/suite/src/types/suite/index.ts b/packages/suite/src/types/suite/index.ts index 6ee34acdfbf..b8a8cc4e233 100644 --- a/packages/suite/src/types/suite/index.ts +++ b/packages/suite/src/types/suite/index.ts @@ -1,12 +1,8 @@ import type { ThunkDispatch, ThunkAction as TAction } from 'redux-thunk'; import type { Store as ReduxStore } from 'redux'; -import { - deviceActions, - firmwareActions, - discoveryActions, - transactionsActions, -} from '@suite-common/wallet-core'; +import { deviceActions, discoveryActions, transactionsActions } from '@suite-common/wallet-core'; +import { firmwareActions } from '@suite-common/firmware'; import { analyticsActions } from '@suite-common/analytics'; import type { UiEvent, TransportEvent, BlockchainEvent } from '@trezor/connect'; import { notificationsActions } from '@suite-common/toast-notifications'; diff --git a/packages/suite/src/views/firmware/FirmwareCustom.tsx b/packages/suite/src/views/firmware/FirmwareCustom.tsx index 7eb42c2ebfc..fa2d198d1ac 100644 --- a/packages/suite/src/views/firmware/FirmwareCustom.tsx +++ b/packages/suite/src/views/firmware/FirmwareCustom.tsx @@ -1,13 +1,15 @@ import { useState } from 'react'; -import { useDevice, useFirmware } from 'src/hooks/suite'; +import { useFirmwareInstallation } from '@suite-common/firmware'; + +import { useDevice } from 'src/hooks/suite'; import { SelectCustomFirmware } from 'src/components/firmware'; import { FirmwareModal } from './FirmwareModal'; export const FirmwareCustom = () => { const [firmwareBinary, setFirmwareBinary] = useState(); - const { setStatus, firmwareUpdate } = useFirmware(); + const { setStatus, firmwareUpdate } = useFirmwareInstallation(); const { device } = useDevice(); const installCustomFirmware = () => { diff --git a/packages/suite/src/views/firmware/FirmwareModal.tsx b/packages/suite/src/views/firmware/FirmwareModal.tsx index 74414bbb916..b9cb0fe4b82 100644 --- a/packages/suite/src/views/firmware/FirmwareModal.tsx +++ b/packages/suite/src/views/firmware/FirmwareModal.tsx @@ -8,6 +8,7 @@ import { acquireDevice, selectDevice } from '@suite-common/wallet-core'; import { variables } from '@trezor/components'; import TrezorConnect from '@trezor/connect'; import { ConfirmOnDevice } from '@trezor/product-components'; +import { useFirmwareInstallation } from '@suite-common/firmware'; import { closeModalApp } from 'src/actions/suite/routerActions'; import { @@ -18,7 +19,7 @@ import { } from 'src/components/firmware'; import { Translation, Modal, PrerequisitesGuide } from 'src/components/suite'; import { OnboardingStepBox } from 'src/components/onboarding'; -import { useDispatch, useFirmware, useSelector } from 'src/hooks/suite'; +import { useDispatch, useSelector } from 'src/hooks/suite'; import messages from 'src/support/messages'; const Wrapper = styled.div<{ $isWithTopPadding: boolean }>` @@ -64,7 +65,7 @@ export const FirmwareModal = ({ uiEvent, confirmOnDevice, showConfirmationPill, - } = useFirmware({ shouldSwitchFirmwareType }); + } = useFirmwareInstallation({ shouldSwitchFirmwareType }); const device = useSelector(selectDevice); const dispatch = useDispatch(); const intl = useIntl(); diff --git a/packages/suite/src/views/firmware/FirmwareUpdate.tsx b/packages/suite/src/views/firmware/FirmwareUpdate.tsx index 35032387178..aaff620a515 100644 --- a/packages/suite/src/views/firmware/FirmwareUpdate.tsx +++ b/packages/suite/src/views/firmware/FirmwareUpdate.tsx @@ -1,6 +1,8 @@ +import { useFirmwareInstallation } from '@suite-common/firmware'; + import { FirmwareInitial } from 'src/components/firmware'; import { closeModalApp } from 'src/actions/suite/routerActions'; -import { useDispatch, useFirmware } from 'src/hooks/suite'; +import { useDispatch } from 'src/hooks/suite'; import { FirmwareModal } from './FirmwareModal'; @@ -9,7 +11,9 @@ type FirmwareUpdateProps = { }; export const FirmwareUpdate = ({ shouldSwitchFirmwareType }: FirmwareUpdateProps) => { - const { firmwareUpdate, targetFirmwareType } = useFirmware({ shouldSwitchFirmwareType }); + const { firmwareUpdate, targetFirmwareType } = useFirmwareInstallation({ + shouldSwitchFirmwareType, + }); const dispatch = useDispatch(); const close = () => dispatch(closeModalApp()); diff --git a/packages/suite/src/views/onboarding/steps/FirmwareStep.tsx b/packages/suite/src/views/onboarding/steps/FirmwareStep.tsx index e27795ef63b..1436daebb03 100644 --- a/packages/suite/src/views/onboarding/steps/FirmwareStep.tsx +++ b/packages/suite/src/views/onboarding/steps/FirmwareStep.tsx @@ -1,6 +1,8 @@ import { getFirmwareVersion } from '@trezor/device-utils'; import { selectDevice } from '@suite-common/wallet-core'; +import { useFirmwareInstallation } from '@suite-common/firmware'; +import { MODAL } from 'src/actions/suite/constants'; import { OnboardingButtonBack, OnboardingStepBox } from 'src/components/onboarding'; import { PrerequisitesGuide, Translation } from 'src/components/suite'; import { @@ -11,21 +13,15 @@ import { FirmwareUpdateHashCheckError, Fingerprint, } from 'src/components/firmware'; -import { useSelector, useFirmware, useOnboarding } from 'src/hooks/suite'; +import { useSelector, useOnboarding } from 'src/hooks/suite'; import { getSuiteFirmwareTypeString } from 'src/utils/firmware'; export const FirmwareStep = () => { const device = useSelector(selectDevice); + const modal = useSelector(state => state.modal); const { goToNextStep, updateAnalytics } = useOnboarding(); - const { - status, - error, - resetReducer, - firmwareUpdate, - showFingerprintCheck, - firmwareHashInvalid, - targetType, - } = useFirmware(); + const { status, error, resetReducer, firmwareUpdate, firmwareHashInvalid, targetType } = + useFirmwareInstallation(); const install = () => firmwareUpdate({ firmwareType: targetType }); const goToNextStepAndResetReducer = () => { @@ -38,6 +34,10 @@ export const FirmwareStep = () => { return ; } + const showFingerprintCheck = + modal.context === MODAL.CONTEXT_DEVICE && + modal.windowType === 'ButtonRequest_FirmwareCheck'; + if (showFingerprintCheck && device) { // Some old firmwares ask for verifying firmware fingerprint by dispatching ButtonRequest_FirmwareCheck return ( diff --git a/packages/suite/src/views/settings/SettingsDebug/Devkit.tsx b/packages/suite/src/views/settings/SettingsDebug/Devkit.tsx index 0b7dd96e765..02936d8ce37 100644 --- a/packages/suite/src/views/settings/SettingsDebug/Devkit.tsx +++ b/packages/suite/src/views/settings/SettingsDebug/Devkit.tsx @@ -1,5 +1,5 @@ -import { firmwareActions, selectUseDevkit } from '@suite-common/wallet-core'; import { Switch } from '@trezor/components'; +import { firmwareActions, selectUseDevkit } from '@suite-common/firmware'; import { ActionColumn, SectionItem, TextColumn } from 'src/components/suite'; import { useSelector, useDispatch } from 'src/hooks/suite'; diff --git a/packages/suite/src/views/settings/SettingsDebug/GithubIssue.tsx b/packages/suite/src/views/settings/SettingsDebug/GithubIssue.tsx index 886d1590466..68332367849 100644 --- a/packages/suite/src/views/settings/SettingsDebug/GithubIssue.tsx +++ b/packages/suite/src/views/settings/SettingsDebug/GithubIssue.tsx @@ -3,9 +3,10 @@ import { SettingsSectionItem } from 'src/components/settings'; import { ActionButton, ActionColumn, TextColumn } from 'src/components/suite'; import { useDevice, useSelector } from 'src/hooks/suite'; import { openGithubIssue } from 'src/services/github'; +import { selectTransport } from 'src/reducers/suite/suiteReducer'; export const GithubIssue = () => { - const transport = useSelector(state => state.suite.transport); + const transport = useSelector(selectTransport); const { device } = useDevice(); const handleClick = () => openGithubIssue({ device, transport }); diff --git a/packages/suite/src/views/settings/SettingsDebug/Transport.tsx b/packages/suite/src/views/settings/SettingsDebug/Transport.tsx index 3720ec05465..145a4b37e2d 100644 --- a/packages/suite/src/views/settings/SettingsDebug/Transport.tsx +++ b/packages/suite/src/views/settings/SettingsDebug/Transport.tsx @@ -6,7 +6,7 @@ import { ArrayElement } from '@trezor/type-utils'; import { useDispatch, useSelector } from 'src/hooks/suite'; import { setDebugMode } from 'src/actions/suite/suiteActions'; -import { DebugModeOptions } from 'src/reducers/suite/suiteReducer'; +import { DebugModeOptions, selectTransport } from 'src/reducers/suite/suiteReducer'; import { ActionColumn, SectionItem, TextColumn } from 'src/components/suite'; type TransportMenuItem = { @@ -18,7 +18,7 @@ type TransportMenuItem = { export const Transport = () => { const debug = useSelector(state => state.suite.settings.debug); - const transport = useSelector(state => state.suite.transport); + const transport = useSelector(selectTransport); const dispatch = useDispatch(); // fallback [] to avoid need of migration. diff --git a/packages/suite/src/views/settings/SettingsDebug/TransportBackends.tsx b/packages/suite/src/views/settings/SettingsDebug/TransportBackends.tsx index cffd204e01d..72bc095c91a 100644 --- a/packages/suite/src/views/settings/SettingsDebug/TransportBackends.tsx +++ b/packages/suite/src/views/settings/SettingsDebug/TransportBackends.tsx @@ -4,6 +4,7 @@ import { isDevEnv } from '@suite-common/suite-utils'; import { ActionColumn, SectionItem, TextColumn } from 'src/components/suite'; import { useSelector } from 'src/hooks/suite'; +import { selectTransport } from 'src/reducers/suite/suiteReducer'; import { useBridgeDesktopApi } from '../../../hooks/suite/useBridgeDesktopApi'; @@ -12,7 +13,7 @@ const NEW_BRIDGE_ROLLOUT_THRESHOLD = 0.01; export const TransportBackends = () => { const allowPrerelease = useSelector(state => state.desktopUpdate.allowPrerelease); - const transport = useSelector(state => state.suite.transport); + const transport = useSelector(selectTransport); const { bridgeProcess, bridgeSettings, changeBridgeSettings, bridgeDesktopApiError } = useBridgeDesktopApi(); diff --git a/packages/suite/src/views/settings/SettingsDevice/SettingsDevice.tsx b/packages/suite/src/views/settings/SettingsDevice/SettingsDevice.tsx index 2667df1a7e1..1a7901a5cd7 100644 --- a/packages/suite/src/views/settings/SettingsDevice/SettingsDevice.tsx +++ b/packages/suite/src/views/settings/SettingsDevice/SettingsDevice.tsx @@ -8,6 +8,7 @@ import { Translation } from 'src/components/suite'; import { useDevice, useSelector } from 'src/hooks/suite'; import type { TrezorDevice } from 'src/types/suite'; import { isRecoveryInProgress } from 'src/utils/device/isRecoveryInProgress'; +import { selectTransport } from 'src/reducers/suite/suiteReducer'; import { AuthenticateDevice } from './AuthenticateDevice'; import { AutoLock } from './AutoLock'; @@ -47,7 +48,7 @@ const deviceSettingsUnavailable = (device?: TrezorDevice, transport?: Partial { const { device, isLocked } = useDevice(); - const transport = useSelector(state => state.suite.transport); + const transport = useSelector(selectTransport); const deviceUnavailable = !device?.features; const isDeviceLocked = isLocked(); const bootloaderMode = device?.mode === 'bootloader'; diff --git a/packages/suite/src/views/suite/SwitchDevice/DeviceItem/DeviceHeader.tsx b/packages/suite/src/views/suite/SwitchDevice/DeviceItem/DeviceHeader.tsx index a3499d69d0a..71abe9343b8 100644 --- a/packages/suite/src/views/suite/SwitchDevice/DeviceItem/DeviceHeader.tsx +++ b/packages/suite/src/views/suite/SwitchDevice/DeviceItem/DeviceHeader.tsx @@ -6,11 +6,11 @@ import { spacings, spacingsPx } from '@trezor/theme'; import { selectDevice } from '@suite-common/wallet-core'; import { DeviceStatus } from 'src/components/suite/layouts/SuiteLayout/DeviceSelector/DeviceStatus'; -import { isWebUsb } from 'src/utils/suite/transport'; import { Translation, WebUsbButton } from 'src/components/suite'; import { useSelector } from 'src/hooks/suite'; import { ForegroundAppProps, TrezorDevice } from 'src/types/suite'; import { WebUsbIconButton } from 'src/components/suite/WebUsbButton'; +import { selectIsWebUsb } from 'src/reducers/suite/suiteReducer'; const Container = styled.div<{ $isFullHeaderVisible: boolean }>` display: flex; @@ -46,8 +46,7 @@ export const DeviceHeader = ({ icon = 'caretCircleDown', }: DeviceHeaderProps) => { const selectedDevice = useSelector(selectDevice); - const transport = useSelector(state => state.suite.transport); - const isWebUsbTransport = isWebUsb(transport); + const isWebUsbTransport = useSelector(selectIsWebUsb); const isDeviceConnected = selectedDevice?.connected === true; const deviceModelInternal = device.features?.internal_model; diff --git a/packages/suite/src/views/suite/bridge/index.tsx b/packages/suite/src/views/suite/bridge/index.tsx index eea739753a7..52260960ec1 100644 --- a/packages/suite/src/views/suite/bridge/index.tsx +++ b/packages/suite/src/views/suite/bridge/index.tsx @@ -8,6 +8,7 @@ import { goto } from 'src/actions/suite/routerActions'; import { useDispatch, useSelector } from 'src/hooks/suite'; import { isWebUsb } from 'src/utils/suite/transport'; import { useOpenSuiteDesktop } from 'src/hooks/suite/useOpenSuiteDesktop'; +import { selectTransport } from 'src/reducers/suite/suiteReducer'; // eslint-disable-next-line local-rules/no-override-ds-component const StyledButton = styled(Button)` @@ -37,7 +38,7 @@ const DownloadStandalone = ({ target }: { target: string }) => { // it actually changes to "Install suite desktop" export const BridgeUnavailable = () => { - const transport = useSelector(state => state.suite.transport); + const transport = useSelector(selectTransport); const dispatch = useDispatch(); const preferredTarget = transport?.bridge?.packages?.find(i => i.preferred === true); diff --git a/packages/suite/tsconfig.json b/packages/suite/tsconfig.json index 384e5e6006f..9d9fe32a368 100644 --- a/packages/suite/tsconfig.json +++ b/packages/suite/tsconfig.json @@ -25,6 +25,7 @@ { "path": "../../suite-common/fiat-services" }, + { "path": "../../suite-common/firmware" }, { "path": "../../suite-common/formatters" }, diff --git a/suite-common/firmware/package.json b/suite-common/firmware/package.json new file mode 100644 index 00000000000..36cc93d8f0c --- /dev/null +++ b/suite-common/firmware/package.json @@ -0,0 +1,23 @@ +{ + "name": "@suite-common/firmware", + "version": "1.0.0", + "private": true, + "license": "See LICENSE.md in repo root", + "sideEffects": false, + "main": "src/index", + "scripts": { + "depcheck": "yarn g:depcheck", + "type-check": "yarn g:tsc --build" + }, + "dependencies": { + "@reduxjs/toolkit": "1.9.5", + "@suite-common/redux-utils": "workspace:*", + "@suite-common/suite-types": "workspace:*", + "@suite-common/wallet-core": "workspace:*", + "@trezor/connect": "workspace:*", + "@trezor/device-utils": "workspace:*", + "@trezor/env-utils": "workspace:*", + "react": "18.2.0", + "react-redux": "8.0.7" + } +} diff --git a/suite-common/firmware/redux.d.ts b/suite-common/firmware/redux.d.ts new file mode 100644 index 00000000000..df9a0c3f969 --- /dev/null +++ b/suite-common/firmware/redux.d.ts @@ -0,0 +1,7 @@ +import { AsyncThunkAction } from '@reduxjs/toolkit'; + +declare module 'redux' { + export interface Dispatch { + >(thunk: TThunk): ReturnType; + } +} diff --git a/suite-common/wallet-core/src/firmware/firmwareActions.ts b/suite-common/firmware/src/firmwareActions.ts similarity index 100% rename from suite-common/wallet-core/src/firmware/firmwareActions.ts rename to suite-common/firmware/src/firmwareActions.ts diff --git a/suite-common/wallet-core/src/firmware/firmwareReducer.ts b/suite-common/firmware/src/firmwareReducer.ts similarity index 98% rename from suite-common/wallet-core/src/firmware/firmwareReducer.ts rename to suite-common/firmware/src/firmwareReducer.ts index 7029b294de7..746ca580e17 100644 --- a/suite-common/wallet-core/src/firmware/firmwareReducer.ts +++ b/suite-common/firmware/src/firmwareReducer.ts @@ -8,9 +8,9 @@ import { DeviceButtonRequest, } from '@trezor/connect'; import { createReducerWithExtraDeps } from '@suite-common/redux-utils'; +import { deviceActions } from '@suite-common/wallet-core'; import { firmwareActions } from './firmwareActions'; -import { deviceActions } from '../device/deviceActions'; type FirmwareUpdateCommon = { // Device before installation begun. Used to display the original firmware type and version during the installation. diff --git a/suite-common/wallet-core/src/firmware/firmwareThunks.ts b/suite-common/firmware/src/firmwareThunks.ts similarity index 92% rename from suite-common/wallet-core/src/firmware/firmwareThunks.ts rename to suite-common/firmware/src/firmwareThunks.ts index f410ffdf848..1f64318154c 100644 --- a/suite-common/wallet-core/src/firmware/firmwareThunks.ts +++ b/suite-common/firmware/src/firmwareThunks.ts @@ -50,13 +50,16 @@ const handleFwHashValid = createThunk( type FirmwareUpdateProps = { firmwareType?: FirmwareType; binary?: ArrayBuffer; + // used on mobile, we don't have any FWs locally + ignoreBaseUrl?: boolean; }; -type FirmwareUpdateResult = { +export type FirmwareUpdateResult = { device?: TrezorDevice; toFwVersion?: string; toBtcOnly?: boolean; error?: string; + connectResponse?: Awaited>; }; export const firmwareUpdate = createThunk< @@ -66,7 +69,7 @@ export const firmwareUpdate = createThunk< >( `${FIRMWARE_MODULE_PREFIX}/firmwareUpdate`, async ( - { firmwareType, binary }, + { firmwareType, binary, ignoreBaseUrl = false }, { dispatch, getState, extra, fulfillWithValue, rejectWithValue }, ) => { dispatch(firmwareActions.setStatus('started')); @@ -104,7 +107,9 @@ export const firmwareUpdate = createThunk< dispatch(firmwareActions.cacheDevice(device)); } - const baseUrl = `${binFilesBaseUrl}${useDevkit ? '/devkit' : ''}`; + const baseUrl = ignoreBaseUrl + ? undefined + : `${binFilesBaseUrl}${useDevkit ? '/devkit' : ''}`; // update to same variant as is currently installed or to the regular one if device does not have any fw (new/wiped device), // unless the user wants to switch firmware type @@ -126,9 +131,9 @@ export const firmwareUpdate = createThunk< const firmwareUpdateResponse = await TrezorConnect.firmwareUpdate({ device, - baseUrl, btcOnly: toBitcoinOnlyFirmware, binary, + baseUrl, // Firmware language should only be set during the initial firmware installation. language: device.firmware === 'none' ? targetTranslationLanguage : undefined, }); @@ -146,8 +151,9 @@ export const firmwareUpdate = createThunk< return rejectWithValue({ device, - error: firmwareUpdateResponse.payload.error, ...targetProperties, + ...firmwareUpdateResponse.payload, + connectResponse: firmwareUpdateResponse, }); } else { const { check } = firmwareUpdateResponse.payload; @@ -169,6 +175,7 @@ export const firmwareUpdate = createThunk< return fulfillWithValue({ device, ...targetProperties, + connectResponse: firmwareUpdateResponse, }); } }, diff --git a/suite-common/wallet-core/src/firmware/getBinFilesBaseUrlThunk.ts b/suite-common/firmware/src/getBinFilesBaseUrlThunk.ts similarity index 100% rename from suite-common/wallet-core/src/firmware/getBinFilesBaseUrlThunk.ts rename to suite-common/firmware/src/getBinFilesBaseUrlThunk.ts diff --git a/packages/suite/src/hooks/suite/useFirmware.ts b/suite-common/firmware/src/hooks/useFirmwareInstallation.ts similarity index 78% rename from packages/suite/src/hooks/suite/useFirmware.ts rename to suite-common/firmware/src/hooks/useFirmwareInstallation.ts index 4fec5951f17..c2ba5f71366 100644 --- a/packages/suite/src/hooks/suite/useFirmware.ts +++ b/suite-common/firmware/src/hooks/useFirmwareInstallation.ts @@ -1,17 +1,19 @@ -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { useMemo, useCallback } from 'react'; import { FirmwareStatus } from '@suite-common/suite-types'; -import { firmwareUpdate, selectFirmware, firmwareActions } from '@suite-common/wallet-core'; +import { + firmwareUpdate as firmwareUpdateThunk, + selectFirmware, + firmwareActions, +} from '@suite-common/firmware'; import { DEVICE, DeviceModelInternal, FirmwareType, UI } from '@trezor/connect'; import { getFirmwareVersion, hasBitcoinOnlyFirmware, isBitcoinOnlyDevice, } from '@trezor/device-utils'; - -import { useSelector, useDevice, useTranslation } from 'src/hooks/suite'; -import { isWebUsb } from 'src/utils/suite/transport'; -import { MODAL } from 'src/actions/suite/constants'; +import { selectDevice } from '@suite-common/wallet-core'; /* There are three firmware update flows, depending on current firmware version: @@ -24,21 +26,25 @@ const VERSIONS_GUARANTEED_TO_WIPE_DEVICE_ON_UPDATE: ReturnType { - const { translationString } = useTranslation(); const dispatch = useDispatch(); const firmware = useSelector(selectFirmware); - const transport = useSelector(state => state.suite.transport); - const modal = useSelector(state => state.modal); - const { device } = useDevice(); + const device = useSelector(selectDevice); // Device in its state before installation is cached when installation begins. // Until then, access device as normal. @@ -60,15 +66,10 @@ export const useFirmware = ( )) || showManualReconnectPrompt; - const showFingerprintCheck = - modal.context === MODAL.CONTEXT_DEVICE && - modal.windowType === 'ButtonRequest_FirmwareCheck'; - const deviceModelInternal = originalDevice?.features?.internal_model; // Device may be wiped during firmware type switch because Universal and Bitcoin-only firmware have different vendor headers, // except T1B1 and T2T1. There may be some false negatives here during custom installation. // TODO: Determine this in Connect. - const deviceWillBeWiped = (!!shouldSwitchFirmwareType && deviceModelInternal !== undefined && @@ -102,10 +103,10 @@ export const useFirmware = ( firmware.uiEvent.payload.operation === 'downloading' ); - const getUpdateStatus = () => { + const updateStatus = useMemo(() => { if (firmware.status === 'done') { return { - operation: translationString('TR_FIRMWARE_STATUS_INSTALLATION_COMPLETED'), + operation: 'completed', progress: 100, }; } @@ -114,12 +115,12 @@ export const useFirmware = ( switch (firmware.uiEvent.payload.operation) { case 'flashing': return { - operation: translationString('TR_INSTALLING'), + operation: 'installing', progress: firmware.uiEvent.payload.progress, }; case 'validating': return { - operation: translationString('TR_VALIDATION'), + operation: 'validating', progress: 100, }; } @@ -130,13 +131,13 @@ export const useFirmware = ( firmware.uiEvent?.type === UI.FIRMWARE_RECONNECT && firmware.uiEvent.payload.method === 'wait' ) { - return { operation: translationString('TR_WAIT_FOR_REBOOT'), progress: 100 }; + return { operation: 'restarting', progress: 100 }; } return { operation: null, progress: 0 }; - }; + }, [firmware.uiEvent, firmware.status]); - const getTargetFirmwareType = () => { + const targetFirmwareType = useMemo(() => { const isCurrentlyBitcoinOnly = hasBitcoinOnlyFirmware(originalDevice); const isBitcoinOnlyAvailable = !!originalDevice?.firmwareRelease?.release.url_bitcoinonly; @@ -147,21 +148,28 @@ export const useFirmware = ( isBitcoinOnlyDevice(originalDevice) ? FirmwareType.BitcoinOnly : FirmwareType.Regular; - }; + }, [originalDevice, shouldSwitchFirmwareType]); + + const firmwareUpdate = useCallback( + (...params: Parameters) => + dispatch(firmwareUpdateThunk(...params)), + [dispatch], + ); + + const setStatus = useCallback( + (status: FirmwareStatus | 'error') => dispatch(firmwareActions.setStatus(status)), + [dispatch], + ); - const targetFirmwareType = getTargetFirmwareType(); + const resetReducer = useCallback(() => dispatch(firmwareActions.resetReducer()), [dispatch]); return { ...firmware, - ...getUpdateStatus(), + ...updateStatus, originalDevice, - firmwareUpdate: (...params: Parameters) => - dispatch(firmwareUpdate(...params)), - setStatus: (status: FirmwareStatus | 'error') => - dispatch(firmwareActions.setStatus(status)), - resetReducer: () => dispatch(firmwareActions.resetReducer()), - isWebUSB: isWebUsb(transport), - showFingerprintCheck, + firmwareUpdate, + setStatus, + resetReducer, targetFirmwareType, showManualReconnectPrompt, confirmOnDevice, diff --git a/suite-common/firmware/src/index.ts b/suite-common/firmware/src/index.ts new file mode 100644 index 00000000000..8486f5ad953 --- /dev/null +++ b/suite-common/firmware/src/index.ts @@ -0,0 +1,5 @@ +export * from './firmwareActions'; +export * from './firmwareReducer'; +export * from './firmwareThunks'; +export * from './getBinFilesBaseUrlThunk'; +export * from './hooks/useFirmwareInstallation'; diff --git a/suite-common/firmware/tsconfig.json b/suite-common/firmware/tsconfig.json new file mode 100644 index 00000000000..05d3b04ef9b --- /dev/null +++ b/suite-common/firmware/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "libDev" }, + "references": [ + { "path": "../redux-utils" }, + { "path": "../suite-types" }, + { "path": "../wallet-core" }, + { "path": "../../packages/connect" }, + { "path": "../../packages/device-utils" }, + { "path": "../../packages/env-utils" } + ] +} diff --git a/suite-common/wallet-core/src/device/deviceReducer.ts b/suite-common/wallet-core/src/device/deviceReducer.ts index 2a8961720ba..9b9d9df1523 100644 --- a/suite-common/wallet-core/src/device/deviceReducer.ts +++ b/suite-common/wallet-core/src/device/deviceReducer.ts @@ -2,7 +2,7 @@ import { isAnyOf } from '@reduxjs/toolkit'; import { A, pipe } from '@mobily/ts-belt'; import * as deviceUtils from '@suite-common/suite-utils'; -import { getDeviceInstances, getStatus } from '@suite-common/suite-utils'; +import { getDeviceInstances, getFwUpdateVersion, getStatus } from '@suite-common/suite-utils'; import { Device, DeviceState, Features, StaticSessionId, UI } from '@trezor/connect'; import { getFirmwareVersion, @@ -927,6 +927,11 @@ export const selectIsDeviceRemembered = createMemoizedSelector( device => !!device?.remember, ); +export const selectIsDeviceForceRemembered = createMemoizedSelector( + [selectDevice], + device => !!device?.forceRemember, +); + export const selectRememberedStandardWalletsCount = createMemoizedSelector( [selectPhysicalDevices], devices => @@ -1012,3 +1017,9 @@ export const selectIsBitcoinOnlyDevice = createMemoizedSelector([selectDevice], export const selectHasBitcoinOnlyFirmware = createMemoizedSelector([selectDevice], device => hasBitcoinOnlyFirmware(device), ); + +export const selectDeviceUpdateFirmwareVersion = (state: DeviceRootState) => { + const device = selectDevice(state); + + return device ? getFwUpdateVersion(device) : null; +}; diff --git a/suite-common/wallet-core/src/discovery/discoveryReducer.ts b/suite-common/wallet-core/src/discovery/discoveryReducer.ts index 788f9fc8d05..380fec73fcd 100644 --- a/suite-common/wallet-core/src/discovery/discoveryReducer.ts +++ b/suite-common/wallet-core/src/discovery/discoveryReducer.ts @@ -83,7 +83,7 @@ export const selectDiscovery = (state: DiscoveryRootState) => state.wallet.disco // Get discovery process for deviceState. export const selectDiscoveryByDeviceState = ( state: DiscoveryRootState, - deviceState: DeviceState | StaticSessionId | undefined, + deviceState: DeviceState | StaticSessionId | undefined | null, ) => deviceState ? state.wallet.discovery.find(d => @@ -101,7 +101,7 @@ export const selectDeviceDiscovery = (state: DiscoveryRootState & DeviceRootStat export const selectIsDiscoveryActiveByDeviceState = ( state: DiscoveryRootState & DeviceRootState, - deviceState: DeviceState | StaticSessionId | undefined, + deviceState: DeviceState | StaticSessionId | undefined | null, ) => { const discovery = selectDiscoveryByDeviceState(state, deviceState); diff --git a/suite-common/wallet-core/src/index.ts b/suite-common/wallet-core/src/index.ts index 4633e03d1c6..3af7c0897d0 100644 --- a/suite-common/wallet-core/src/index.ts +++ b/suite-common/wallet-core/src/index.ts @@ -20,10 +20,6 @@ export * from './discovery/discoveryActions'; export * from './discovery/discoveryReducer'; export * from './discovery/discoveryThunks'; export * from './fees/feesReducer'; -export * from './firmware/firmwareActions'; -export * from './firmware/firmwareThunks'; -export * from './firmware/firmwareReducer'; -export * from './firmware/getBinFilesBaseUrlThunk'; export * from './send/sendFormActions'; export * from './send/sendFormReducer'; export * from './send/sendFormThunks'; diff --git a/suite-native/app/app.config.ts b/suite-native/app/app.config.ts index e1004af9936..bb5958df7c0 100644 --- a/suite-native/app/app.config.ts +++ b/suite-native/app/app.config.ts @@ -169,7 +169,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { slug: appSlugs[buildType], owner: appOwners[buildType], version: suiteNativeVersion, - runtimeVersion: '16', + runtimeVersion: '17', ...(buildType === 'production' ? {} : { diff --git a/suite-native/atoms/src/AlertBox.tsx b/suite-native/atoms/src/AlertBox.tsx index 9f82a436def..524dfa906e7 100644 --- a/suite-native/atoms/src/AlertBox.tsx +++ b/suite-native/atoms/src/AlertBox.tsx @@ -83,6 +83,7 @@ export type AlertBoxProps = { title: ReactNode; borderRadius?: NativeRadius | number; contentColor?: Color; + rightButton?: ReactNode; }; const AlertSpinner = ({ color }: { color: Color }) => { @@ -98,6 +99,7 @@ export const AlertBox = ({ variant = 'info', borderRadius = 'r16', contentColor, + rightButton, }: AlertBoxProps) => { const { applyStyle } = useNativeStyles(); const { @@ -124,6 +126,7 @@ export const AlertBox = ({ {title} + {rightButton} ); }; diff --git a/suite-native/atoms/src/Box.tsx b/suite-native/atoms/src/Box.tsx index 9a093fa573f..7eb1ad4b223 100644 --- a/suite-native/atoms/src/Box.tsx +++ b/suite-native/atoms/src/Box.tsx @@ -1,4 +1,6 @@ import { View, ViewProps, ViewStyle } from 'react-native'; +import Animated from 'react-native-reanimated'; +import React from 'react'; import { D, pipe } from '@mobily/ts-belt'; @@ -52,7 +54,7 @@ const boxStyle = prepareNativeStyle((_utils, { ...styles }) => ({ ...styles, })); -export const Box = ({ style, ...props }: BoxProps) => { +export const Box = React.forwardRef(({ style, ...props }, ref) => { const { applyStyle, utils } = useNativeStyles(); const { isFlashOnRerenderEnabled } = useDebugView(); const ViewComponent = isFlashOnRerenderEnabled ? DebugView : View; @@ -67,8 +69,14 @@ export const Box = ({ style, ...props }: BoxProps) => { return ( ); -}; +}); + +Box.displayName = 'Box'; + +export const AnimatedBox = Animated.createAnimatedComponent(Box); +AnimatedBox.displayName = 'AnimatedBox'; diff --git a/suite-native/atoms/src/DebugView.tsx b/suite-native/atoms/src/DebugView.tsx index 074ce32cd77..2f59152923f 100644 --- a/suite-native/atoms/src/DebugView.tsx +++ b/suite-native/atoms/src/DebugView.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useRef, forwardRef } from 'react'; import { View, ViewProps } from 'react-native'; import Animated, { interpolateColor, @@ -40,7 +40,7 @@ export const useDebugView = () => { }; }; -export const DebugView = ({ style, children, ...props }: ViewProps) => { +export const DebugView = forwardRef(({ style, children, ...props }, ref) => { const { utils } = useNativeStyles(); const { isRerenderCountEnabled } = useDebugView(); const rerenderCount = useRef(0); @@ -77,7 +77,7 @@ export const DebugView = ({ style, children, ...props }: ViewProps) => { }); return ( - + {children} {isRerenderCountEnabled && ( { )} ); -}; +}); + +DebugView.displayName = 'DebugView'; diff --git a/suite-native/atoms/src/Text.tsx b/suite-native/atoms/src/Text.tsx index 0ab98c65146..a6098b5e2d9 100644 --- a/suite-native/atoms/src/Text.tsx +++ b/suite-native/atoms/src/Text.tsx @@ -5,6 +5,8 @@ import { PixelRatio, Platform, } from 'react-native'; +import Animated from 'react-native-reanimated'; +import React from 'react'; // @ts-expect-error This is not public RN API but it will make Text noticeable faster https://twitter.com/FernandoTheRojo/status/1707769877493121420 import { NativeText } from 'react-native/Libraries/Text/TextNativeComponent'; @@ -79,24 +81,35 @@ const textStyle = prepareNativeStyle((utils, { variant, color, t textAlign, })); -export const Text = ({ - variant = 'body', - color = 'textDefault', - textAlign = 'left', - style, - children, - ...otherProps -}: TextProps) => { - const { applyStyle } = useNativeStyles(); - const maxFontSizeMultiplier = variantToMaxFontSizeMultiplier[variant]; - - return ( - - {children} - - ); -}; +export const Text = React.forwardRef( + ( + { + variant = 'body', + color = 'textDefault', + textAlign = 'left', + style, + children, + ...otherProps + }, + ref, + ) => { + const { applyStyle } = useNativeStyles(); + const maxFontSizeMultiplier = variantToMaxFontSizeMultiplier[variant]; + + return ( + + {children} + + ); + }, +); + +Text.displayName = 'Text'; + +export const AnimatedText = Animated.createAnimatedComponent(Text); +AnimatedText.displayName = 'AnimatedText'; diff --git a/suite-native/device-manager/src/components/DeviceList.tsx b/suite-native/device-manager/src/components/DeviceList.tsx index 77fb4650dde..dbf206bcc95 100644 --- a/suite-native/device-manager/src/components/DeviceList.tsx +++ b/suite-native/device-manager/src/components/DeviceList.tsx @@ -173,7 +173,7 @@ export const DeviceList = ({ isVisible, onSelectDevice }: DeviceListProps) => { d => d.state && ( onSelectDevice(d)} /> diff --git a/suite-native/device/src/components/ConfirmOnTrezorImage.tsx b/suite-native/device/src/components/ConfirmOnTrezorImage.tsx index d0495985806..ba34947c5a7 100644 --- a/suite-native/device/src/components/ConfirmOnTrezorImage.tsx +++ b/suite-native/device/src/components/ConfirmOnTrezorImage.tsx @@ -12,11 +12,13 @@ type ConfirmOnTrezorImageProps = { const imageContainerStyle = prepareNativeStyle(utils => ({ position: 'absolute', - bottom: -utils.spacings.sp4, // Hides a part of the image under bottom screen edge. + bottom: utils.spacings.sp4 * -1, // Hides a part of the image under bottom screen edge. width: '100%', alignItems: 'center', })); +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + export const ConfirmOnTrezorImage = ({ bottomSheetText }: ConfirmOnTrezorImageProps) => { const [isBottomSheetOpened, setIsBottomSheetOpened] = useState(false); const { applyStyle } = useNativeStyles(); @@ -33,15 +35,14 @@ export const ConfirmOnTrezorImage = ({ bottomSheetText }: ConfirmOnTrezorImagePr return ( <> - - - - - + + { + const device = selectDevice(getState()); + if (!device) { + return rejectWithValue('Device not found'); + } + if (device.remember) { + return rejectWithValue('Device is already remembered'); + } + + dispatch( + deviceActions.rememberDevice({ + device, + remember: false, + forceRemember: forceRemember ? true : undefined, + }), + ); + + return; + }, +); diff --git a/suite-native/device/src/hooks/useHandleDeviceConnection.ts b/suite-native/device/src/hooks/useHandleDeviceConnection.ts index 33c08883fe8..d659c760ed9 100644 --- a/suite-native/device/src/hooks/useHandleDeviceConnection.ts +++ b/suite-native/device/src/hooks/useHandleDeviceConnection.ts @@ -25,6 +25,7 @@ import { selectDeviceRequestedPin } from '@suite-native/device-authorization'; import { selectIsOnboardingFinished } from '@suite-native/settings'; import { requestPrioritizedDeviceAccess } from '@suite-native/device-mutex'; import { useIsBiometricsOverlayVisible } from '@suite-native/biometrics'; +import { selectIsFirmwareInstallationRunning } from '@suite-native/firmware'; type NavigationProp = StackToStackCompositeNavigationProps< AuthorizeDeviceStackParamList | RootStackParamList, @@ -42,6 +43,7 @@ export const useHandleDeviceConnection = () => { const isDeviceConnected = useSelector(selectIsDeviceConnected); const { isBiometricsOverlayVisible } = useIsBiometricsOverlayVisible(); const isDeviceUsingPassphrase = useSelector(selectIsDeviceUsingPassphrase); + const isFirmwareInstallationRunning = useSelector(selectIsFirmwareInstallationRunning); const navigation = useNavigation(); const dispatch = useDispatch(); @@ -53,6 +55,8 @@ export const useHandleDeviceConnection = () => { // At the moment when unauthorized physical device is selected, // redirect to the Connecting screen where is handled the connection logic. useEffect(() => { + if (isFirmwareInstallationRunning) return; + if ( isDeviceConnected && isOnboardingFinished && @@ -83,11 +87,14 @@ export const useHandleDeviceConnection = () => { navigation, isDeviceUsingPassphrase, shouldBlockSendReviewRedirect, + isFirmwareInstallationRunning, ]); // In case that the physical device is disconnected, redirect to the home screen and // set connecting screen to be displayed again on the next device connection. useEffect(() => { + if (isFirmwareInstallationRunning) return; + if (isNoPhysicalDeviceConnected && isOnboardingFinished) { const previousRoute = navigation.getState()?.routes.at(-1)?.name; @@ -109,6 +116,7 @@ export const useHandleDeviceConnection = () => { isOnboardingFinished, navigation, shouldBlockSendReviewRedirect, + isFirmwareInstallationRunning, ]); // When trezor gets locked, it is necessary to display a PIN matrix for T1 so that it can be unlocked diff --git a/suite-native/device/src/index.ts b/suite-native/device/src/index.ts index fa6ba88c01e..2d3409d0b71 100644 --- a/suite-native/device/src/index.ts +++ b/suite-native/device/src/index.ts @@ -9,3 +9,4 @@ export * from './components/ConnectorImage'; export * from './components/DeviceImage'; export * from './utils'; export * from './selectors'; +export * from './deviceThunks'; diff --git a/suite-native/device/src/middlewares/deviceMiddleware.ts b/suite-native/device/src/middlewares/deviceMiddleware.ts index fa73c355a12..2d2b61917ee 100644 --- a/suite-native/device/src/middlewares/deviceMiddleware.ts +++ b/suite-native/device/src/middlewares/deviceMiddleware.ts @@ -13,10 +13,13 @@ import { selectAccountsByDeviceState, createDeviceInstanceThunk, createImportedDeviceThunk, + selectIsDeviceForceRemembered, } from '@suite-common/wallet-core'; import { FeatureFlag, selectIsFeatureFlagEnabled } from '@suite-native/feature-flags'; import { clearAndUnlockDeviceAccessQueue } from '@suite-native/device-mutex'; +import { isAnyDeviceEventAction, isDeviceEventAction } from '../utils'; + const isActionDeviceRelated = (action: AnyAction): boolean => { if ( isAnyOf( @@ -34,15 +37,16 @@ const isActionDeviceRelated = (action: AnyAction): boolean => { return true; } - return Object.values(DEVICE).includes(action.type); + return isAnyDeviceEventAction(action); }; export const prepareDeviceMiddleware = createMiddlewareWithExtraDeps( (action, { dispatch, next, getState }) => { - if (action.type === DEVICE.DISCONNECT) { + const isDeviceForceRemembered = selectIsDeviceForceRemembered(getState()); + + if (isDeviceEventAction(action, DEVICE.DISCONNECT) && !isDeviceForceRemembered) { dispatch(forgetDisconnectedDevices(action.payload)); } - /* The `next` function has to be executed here, because the further dispatched actions of this middleware expect that the state was already changed by the action stored in the `action` variable. */ next(action); @@ -79,7 +83,10 @@ export const prepareDeviceMiddleware = createMiddlewareWithExtraDeps( } break; case DEVICE.DISCONNECT: - dispatch(handleDeviceDisconnect(action.payload)); + if (!isDeviceForceRemembered) { + // In case of force remember we don't want to call this thunk because it will change selected device + dispatch(handleDeviceDisconnect(action.payload)); + } clearAndUnlockDeviceAccessQueue(); break; default: diff --git a/suite-native/device/src/utils.ts b/suite-native/device/src/utils.ts index 5c31e48cd0f..41bb6759f37 100644 --- a/suite-native/device/src/utils.ts +++ b/suite-native/device/src/utils.ts @@ -1,7 +1,8 @@ import { G } from '@mobily/ts-belt'; +import { AnyAction } from '@reduxjs/toolkit'; import * as semver from 'semver'; -import { DeviceModelInternal, VersionArray } from '@trezor/connect'; +import { Device, DEVICE, DeviceEvent, DeviceModelInternal, VersionArray } from '@trezor/connect'; export const minimalSupportedFirmwareVersion = { T1B1: [1, 12, 1] as VersionArray, @@ -27,3 +28,14 @@ export const isFirmwareVersionSupported = ( return semver.satisfies(versionString, `>=${minimalVersionString}`); }; + +export const isAnyDeviceEventAction = (action: AnyAction): action is DeviceEvent => { + return Object.values(DEVICE).includes(action.type); +}; + +export const isDeviceEventAction = ( + action: AnyAction, + actionType: T, +): action is { type: T; payload: Device } => { + return action.type === actionType; +}; diff --git a/suite-native/firmware/package.json b/suite-native/firmware/package.json new file mode 100644 index 00000000000..f34a5d4ee8d --- /dev/null +++ b/suite-native/firmware/package.json @@ -0,0 +1,28 @@ +{ + "name": "@suite-native/firmware", + "version": "1.0.0", + "private": true, + "license": "See LICENSE.md in repo root", + "sideEffects": false, + "main": "src/index", + "scripts": { + "depcheck": "yarn g:depcheck", + "lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'", + "type-check": "yarn g:tsc --build" + }, + "dependencies": { + "@react-navigation/native": "6.1.18", + "@reduxjs/toolkit": "1.9.5", + "@shopify/react-native-skia": "^1.5.10", + "@suite-common/firmware": "workspace:*", + "@suite-common/icons": "workspace:*", + "@suite-native/link": "workspace:*", + "@trezor/connect": "workspace:*", + "@trezor/styles": "workspace:*", + "react": "18.2.0", + "react-native": "0.76.1", + "react-native-reanimated": "3.16.1", + "react-native-safe-area-context": "4.14.0", + "react-redux": "8.0.7" + } +} diff --git a/suite-native/firmware/redux.d.ts b/suite-native/firmware/redux.d.ts new file mode 100644 index 00000000000..df9a0c3f969 --- /dev/null +++ b/suite-native/firmware/redux.d.ts @@ -0,0 +1,7 @@ +import { AsyncThunkAction } from '@reduxjs/toolkit'; + +declare module 'redux' { + export interface Dispatch { + >(thunk: TThunk): ReturnType; + } +} diff --git a/suite-native/firmware/src/components/UpdateProgressIndicator.tsx b/suite-native/firmware/src/components/UpdateProgressIndicator.tsx new file mode 100644 index 00000000000..e201355a283 --- /dev/null +++ b/suite-native/firmware/src/components/UpdateProgressIndicator.tsx @@ -0,0 +1,289 @@ +/* eslint-disable no-self-assign */ +import { useEffect, useMemo } from 'react'; +import { Platform } from 'react-native'; +import { + SharedValue, + useDerivedValue, + useSharedValue, + withDelay, + withSpring, + withTiming, +} from 'react-native-reanimated'; + +import { + Canvas, + Circle, + ColorMatrix, + Group, + ImageSVG, + MatrixColorFilterProps, + Paint, + Paragraph, + Path, + Shadow, + Skia, + TextAlign, + useSVG, +} from '@shopify/react-native-skia'; + +import { useNativeStyles } from '@trezor/styles'; + +const CANVAS_SIZE = 160; +const CIRCLE_DIAMETER = 128; +const CIRCLE_CENTER = CANVAS_SIZE / 2; + +const PROGRESS_STROKE_WIDTH = 5; + +const CHECKMARK_OFFSET_X = 10; // Adjust left/right position +const CHECKMARK_OFFSET_Y = 0; // Adjust up/down position +const CHECKMARK_SCALE = 1; // Adjust size +const LONG_LEG_RATIO = 0.25; // Reduce from 0.33 (1/3) to make long leg shorter + +const checkmarkPath = Skia.Path.MakeFromSVGString( + `M${CIRCLE_CENTER - CIRCLE_DIAMETER / 4 + CHECKMARK_OFFSET_X},${CIRCLE_CENTER + CHECKMARK_OFFSET_Y}` + + `l${(CIRCLE_DIAMETER / 8) * CHECKMARK_SCALE},${(CIRCLE_DIAMETER / 8) * CHECKMARK_SCALE}` + + `l${CIRCLE_DIAMETER * LONG_LEG_RATIO * CHECKMARK_SCALE},-${CIRCLE_DIAMETER * LONG_LEG_RATIO * CHECKMARK_SCALE}`, +)!; + +const progressCirclePath = Skia.Path.MakeFromSVGString( + `M ${CIRCLE_CENTER},${CIRCLE_CENTER - (CIRCLE_DIAMETER - PROGRESS_STROKE_WIDTH) / 2} A ${(CIRCLE_DIAMETER - PROGRESS_STROKE_WIDTH) / 2},${(CIRCLE_DIAMETER - PROGRESS_STROKE_WIDTH) / 2} 0 1,1 ${CIRCLE_CENTER},${CIRCLE_CENTER + (CIRCLE_DIAMETER - PROGRESS_STROKE_WIDTH) / 2} A ${(CIRCLE_DIAMETER - PROGRESS_STROKE_WIDTH) / 2},${(CIRCLE_DIAMETER - PROGRESS_STROKE_WIDTH) / 2} 0 1,1 ${CIRCLE_CENTER},${CIRCLE_CENTER - (CIRCLE_DIAMETER - PROGRESS_STROKE_WIDTH) / 2}`, +)!; + +const fontStyle = { + fontFamily: Platform.select({ ios: 'Helvetica', default: 'serif' }), + fontSize: 34, + letterSpacing: -1.4, +}; + +// For some reason you can't easily animate opacity of SVG image, so we need to do it manually. +const AnimatedOpacity = ({ + opacity, + children, +}: { + opacity: SharedValue; + children: React.ReactNode; +}) => { + const matrix = useDerivedValue( + // we can't use OpacityMatrix here because it's not worklet 😢 + () => + [ + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + opacity.value, + 0, + ] as MatrixColorFilterProps['matrix'], + ); + + return ( + + + + } + > + {children} + + ); +}; + +export type UpdateProgressIndicatorStatus = 'starting' | 'success' | 'error' | 'inProgress'; +export type UpdateProgressIndicatorProps = { + progress: number; + status: UpdateProgressIndicatorStatus; +}; + +// If you want to test animation states, use UpdateProgressIndicatorDemo, it will help you a lot. +export const UpdateProgressIndicator = ({ + progress, + status, +}: { + progress: number; + status: UpdateProgressIndicatorStatus; +}) => { + const checkmarkAnimationProgress = useSharedValue(0); + const { utils } = useNativeStyles(); + const progressEnd = useSharedValue(progress / 100); + const animatedBackgroundRadius = useSharedValue(0); + const backgroundColorFinished = useSharedValue(utils.colors.textPrimaryDefault); + const crossSvg = useSVG(require('@suite-common/icons/assets/x.svg')); + const trezorLogoSvg = useSVG(require('@suite-common/icons/assets/trezorLogo.svg')); + const trezorLogoOpacity = useSharedValue(1); + const errorSvgOpacity = useSharedValue(0); + const paragraphOpacity = useSharedValue(0); + + const isStarting = status === 'starting'; + const isSuccess = status === 'success'; + const isError = status === 'error'; + const isInProgress = status === 'inProgress'; + + useEffect(() => { + if (isStarting) { + animatedBackgroundRadius.value = withTiming(0, { duration: 600 }); + backgroundColorFinished.value = backgroundColorFinished.value; + + checkmarkAnimationProgress.value = 0; + progressEnd.value = withSpring(0); + + trezorLogoOpacity.value = withTiming(1, { duration: 600 }); + errorSvgOpacity.value = 0; + paragraphOpacity.value = 0; + } + if (isInProgress) { + animatedBackgroundRadius.value = withTiming(0, { duration: 600 }); + backgroundColorFinished.value = backgroundColorFinished.value; + + checkmarkAnimationProgress.value = 0; + progressEnd.value = withSpring(progress / 100); + + trezorLogoOpacity.value = 0; + errorSvgOpacity.value = 0; + paragraphOpacity.value = withTiming(1, { duration: 600 }); + } + if (isSuccess) { + animatedBackgroundRadius.value = withSpring(CIRCLE_DIAMETER / 2); + backgroundColorFinished.value = utils.colors.textPrimaryDefault; + + checkmarkAnimationProgress.value = withDelay(300, withSpring(1)); + progressEnd.value = 0; + + trezorLogoOpacity.value = 0; + errorSvgOpacity.value = 0; + paragraphOpacity.value = 0; + } + if (isError) { + animatedBackgroundRadius.value = withSpring(CIRCLE_DIAMETER / 2); + backgroundColorFinished.value = utils.colors.backgroundAlertRedBold; + + checkmarkAnimationProgress.value = 0; + progressEnd.value = 0; + + trezorLogoOpacity.value = 0; + errorSvgOpacity.value = withDelay(150, withTiming(1, { duration: 300 })); + paragraphOpacity.value = 0; + } + }, [ + progress, + progressEnd, + animatedBackgroundRadius, + checkmarkAnimationProgress, + isSuccess, + isError, + isStarting, + backgroundColorFinished, + utils.colors.backgroundAlertRedBold, + utils.colors.textPrimaryDefault, + isInProgress, + trezorLogoOpacity, + errorSvgOpacity, + paragraphOpacity, + ]); + + const paragraph = useMemo(() => { + if (!isInProgress) return null; + + return Skia.ParagraphBuilder.Make({ + textAlign: TextAlign.Center, + }) + .pushStyle({ ...fontStyle, color: Skia.Color(utils.colors.textPrimaryDefault) }) + .addText(`${progress}%`) + .build(); + }, [progress, utils.colors.textPrimaryDefault, isInProgress]); + + const isDone = isSuccess || isError; + + return ( + + + + {isInProgress && ( + + + + )} + + + + + + + + {!isDone && } + + + + {isSuccess && ( + + )} + {isError && ( + + + + )} + + ); +}; diff --git a/suite-native/firmware/src/components/UpdateProgressIndicatorDemo.tsx b/suite-native/firmware/src/components/UpdateProgressIndicatorDemo.tsx new file mode 100644 index 00000000000..bdaa507ff0c --- /dev/null +++ b/suite-native/firmware/src/components/UpdateProgressIndicatorDemo.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; + +import { VStack, Button, HStack } from '@suite-native/atoms'; + +import { UpdateProgressIndicator, UpdateProgressIndicatorStatus } from './UpdateProgressIndicator'; + +// DEBUG ONLY:This component is useful for testing animation states of UpdateProgressIndicator +export const UpdateProgressIndicatorDemo = () => { + const [status, setStatus] = useState<{ + status: UpdateProgressIndicatorStatus; + progress: number; + }>({ + status: 'starting', + progress: 0, + }); + + return ( + + + + + + + + + + + + + + ); +}; diff --git a/suite-native/firmware/src/hooks/useFirmware.tsx b/suite-native/firmware/src/hooks/useFirmware.tsx new file mode 100644 index 00000000000..36d46d9a04a --- /dev/null +++ b/suite-native/firmware/src/hooks/useFirmware.tsx @@ -0,0 +1,146 @@ +import { useCallback, useMemo, useRef, useState, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; + +import { + FirmwareUpdateResult, + useFirmwareInstallation, + UseFirmwareInstallationParams, +} from '@suite-common/firmware'; +import { TxKeyPath, useTranslate } from '@suite-native/intl'; + +import { nativeFirmwareActions } from '../nativeFirmwareSlice'; + +// If progress doesn't change for 1 minute +const MAYBE_STUCKED_TIMEOUT = 1 * 60 * 1000; // 1 minute + +export const useFirmware = (params: UseFirmwareInstallationParams) => { + const dispatch = useDispatch(); + const { + firmwareUpdate: firmwareUpdateCommon, + confirmOnDevice: confirmOnDeviceCommon, + operation, + status, + error, + progress, + ...firmwareInstallation + } = useFirmwareInstallation(params); + const { translate } = useTranslate(); + const [mayBeStucked, setMayBeStucked] = useState(false); + const mayBeStuckedTimeout = useRef | null>(null); + + const setIsFirmwareInstallationRunning = useCallback( + (isRunning: boolean) => { + dispatch(nativeFirmwareActions.setIsFirmwareInstallationRunning(isRunning)); + }, + [dispatch], + ); + + const resetMayBeStuckedTimeout = useCallback(() => { + if (mayBeStuckedTimeout.current) { + clearTimeout(mayBeStuckedTimeout.current); + } + setMayBeStucked(false); + }, []); + + const setMayBeStuckedTimeout = useCallback(() => { + resetMayBeStuckedTimeout(); + mayBeStuckedTimeout.current = setTimeout(() => { + setMayBeStucked(true); + }, MAYBE_STUCKED_TIMEOUT); + }, [resetMayBeStuckedTimeout]); + + useEffect(() => { + if (status === 'started' && progress < 100) { + setMayBeStuckedTimeout(); + } + + return () => { + resetMayBeStuckedTimeout(); + }; + }, [progress, status, setMayBeStuckedTimeout, resetMayBeStuckedTimeout]); + + const firmwareUpdate = useCallback(async () => { + const result = await firmwareUpdateCommon({ ignoreBaseUrl: true }) + .unwrap() + .catch(error => { + if ((error as FirmwareUpdateResult)?.connectResponse?.success !== undefined) { + // This is a firmware update error that is handled by us and we expect promise not to be rejected (for example user cancelled the action on device) + return error as FirmwareUpdateResult; + } + throw error; + }) + .then(({ connectResponse }) => { + return connectResponse; + }) + .finally(() => { + resetMayBeStuckedTimeout(); + }); + + return result; + }, [firmwareUpdateCommon, resetMayBeStuckedTimeout]); + + const confirmOnDevice = + confirmOnDeviceCommon || + // This is needed for firmware reinstall to show Confirm on device correctly + // @ts-expect-error types are not correct here, IDK why + firmwareInstallation.uiEvent?.payload?.code === 'ButtonRequest_Other'; + + const translatedText = useMemo(() => { + let text: { title: TxKeyPath; subtitle?: TxKeyPath } = { + title: 'moduleDeviceSettings.firmware.firmwareUpdateProgress.initializing.title', + }; + + const isInitialState = (status === 'started' && operation === null) || status === 'initial'; + + if (status === 'error') { + text = { + title: 'moduleDeviceSettings.firmware.firmwareUpdateProgress.error.title', + }; + } else if (isInitialState && !confirmOnDevice) { + text = { + title: 'moduleDeviceSettings.firmware.firmwareUpdateProgress.initializing.title', + }; + } else if (isInitialState) { + text = { + title: 'moduleDeviceSettings.firmware.firmwareUpdateProgress.confirming.title', + }; + } else if (operation === 'validating') { + text = { + title: 'moduleDeviceSettings.firmware.firmwareUpdateProgress.validating.title', + }; + } else if (operation === 'restarting') { + text = { + title: 'moduleDeviceSettings.firmware.firmwareUpdateProgress.restarting.title', + }; + } else if (operation === 'completed' || status === 'done') { + text = { + title: 'moduleDeviceSettings.firmware.firmwareUpdateProgress.completed.title', + subtitle: 'moduleDeviceSettings.firmware.firmwareUpdateProgress.completed.subtitle', + }; + } else if (operation === 'installing') { + text = { + title: 'moduleDeviceSettings.firmware.firmwareUpdateProgress.installing.title', + subtitle: + 'moduleDeviceSettings.firmware.firmwareUpdateProgress.installing.subtitle', + }; + } + + return { + title: translate(text.title), + subtitle: text.subtitle ? translate(text.subtitle) : error, + }; + }, [operation, status, error, confirmOnDevice, translate]); + + return { + ...firmwareInstallation, + setIsFirmwareInstallationRunning, + firmwareUpdate, + confirmOnDevice, + translatedText, + operation, + status, + error, + mayBeStucked, + progress, + }; +}; diff --git a/suite-native/firmware/src/index.ts b/suite-native/firmware/src/index.ts new file mode 100644 index 00000000000..1dfebc02402 --- /dev/null +++ b/suite-native/firmware/src/index.ts @@ -0,0 +1,3 @@ +export * from './nativeFirmwareSlice'; +export * from './components/UpdateProgressIndicatorDemo'; +export * from './screens/FirmwareUpdateInProgressScreen'; diff --git a/suite-native/firmware/src/nativeFirmwareSlice.ts b/suite-native/firmware/src/nativeFirmwareSlice.ts new file mode 100644 index 00000000000..83a3fe6d059 --- /dev/null +++ b/suite-native/firmware/src/nativeFirmwareSlice.ts @@ -0,0 +1,29 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface NativeFirmwareState { + isFirmwareInstallationRunning: boolean; +} + +const initialState: NativeFirmwareState = { + isFirmwareInstallationRunning: false, +}; + +type NativeFirmwareRootState = { + nativeFirmware: NativeFirmwareState; +}; + +export const nativeFirmwareSlice = createSlice({ + name: 'nativeFirmware', + initialState, + reducers: { + setIsFirmwareInstallationRunning: (state, action: PayloadAction) => { + state.isFirmwareInstallationRunning = action.payload; + }, + }, +}); + +export const nativeFirmwareActions = nativeFirmwareSlice.actions; +export const nativeFirmwareReducer = nativeFirmwareSlice.reducer; + +export const selectIsFirmwareInstallationRunning = (state: NativeFirmwareRootState) => + state.nativeFirmware.isFirmwareInstallationRunning; diff --git a/suite-native/firmware/src/screens/FirmwareUpdateInProgressScreen.tsx b/suite-native/firmware/src/screens/FirmwareUpdateInProgressScreen.tsx new file mode 100644 index 00000000000..cad15ee29b4 --- /dev/null +++ b/suite-native/firmware/src/screens/FirmwareUpdateInProgressScreen.tsx @@ -0,0 +1,209 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import Animated, { + FadeInDown, + FadeInUp, + FadeOutDown, + LinearTransition, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useDispatch } from 'react-redux'; + +import { useNavigation } from '@react-navigation/native'; + +import { authorizeDeviceThunk } from '@suite-common/wallet-core'; +import { Box, Button, Text, VStack } from '@suite-native/atoms'; +import { ConfirmOnTrezorImage, setDeviceForceRememberedThunk } from '@suite-native/device'; +import { requestPrioritizedDeviceAccess } from '@suite-native/device-mutex'; +import { Translation } from '@suite-native/intl'; +import { + DeviceSettingsStackParamList, + DeviceStackRoutes, + Screen, + StackNavigationProps, +} from '@suite-native/navigation'; +import TrezorConnect from '@trezor/connect'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { useOpenLink, SUITE_LITE_SUPPORT_URL } from '@suite-native/link'; + +import { + UpdateProgressIndicator, + UpdateProgressIndicatorStatus, +} from '../components/UpdateProgressIndicator'; +import { useFirmware } from '../hooks/useFirmware'; +type NavigationProp = StackNavigationProps< + DeviceSettingsStackParamList, + DeviceStackRoutes.FirmwareUpdateInProgress +>; + +const bottomButtonsContainerStyle = prepareNativeStyle<{ bottom: number }>((utils, { bottom }) => ({ + position: 'absolute', + left: utils.spacings.sp16, + right: utils.spacings.sp16, + bottom, +})); + +export const FirmwareUpdateInProgressScreen = () => { + const dispatch = useDispatch(); + const { applyStyle } = useNativeStyles(); + const navigation = useNavigation(); + const { bottom: bottomSafeAreaInset } = useSafeAreaInsets(); + const { + operation, + setIsFirmwareInstallationRunning, + confirmOnDevice, + firmwareUpdate, + progress, + status, + resetReducer, + translatedText, + mayBeStucked, + } = useFirmware({}); + const openLink = useOpenLink(); + + useEffect(() => { + // This will prevent device from being forgotten after firmware update, so discovery will not run again + dispatch(setDeviceForceRememberedThunk({ forceRemember: true })); + + return () => { + dispatch(setDeviceForceRememberedThunk({ forceRemember: false })); + resetReducer(); + }; + }, [dispatch, resetReducer]); + + const handleFirmwareUpdateFinished = useCallback(() => { + requestPrioritizedDeviceAccess({ + deviceCallback: () => dispatch(authorizeDeviceThunk()), + }); + navigation.goBack(); + }, [dispatch, navigation]); + + const startFirmwareUpdate = useCallback(async () => { + setIsFirmwareInstallationRunning(true); + + const result = await firmwareUpdate(); + + if (!result) { + // some error happened probably, handled in redux, we don't want to navigate anywhere + return; + } + if (!result.success) { + if ( + // Action cancelled on device + result.payload?.code === 'Failure_ActionCancelled' + ) { + navigation.navigate(DeviceStackRoutes.FirmwareUpdate); + } + + return; + } + + // wait few seconds to animation to finish and let user orientate little bit + setTimeout(() => { + // setting this to false will trigger standart device connection flow + setIsFirmwareInstallationRunning(false); + handleFirmwareUpdateFinished(); + }, 5000); + }, [ + setIsFirmwareInstallationRunning, + navigation, + handleFirmwareUpdateFinished, + firmwareUpdate, + ]); + + const handleRetry = useCallback(async () => { + await TrezorConnect.cancel(); + resetReducer(); + startFirmwareUpdate(); + }, [startFirmwareUpdate, resetReducer]); + + const handleMayBeStucked = useCallback(() => { + // todo + }, []); + + const handleContactSupport = useCallback(() => { + openLink(SUITE_LITE_SUPPORT_URL); + }, [openLink]); + + useEffect(() => { + // Small delay to let initial screen animation finish + const timeout = setTimeout(() => { + startFirmwareUpdate(); + }, 2000); + + return () => clearTimeout(timeout); + }, [startFirmwareUpdate]); + + const isError = status === 'error'; + + const indicatorStatus: UpdateProgressIndicatorStatus = useMemo(() => { + const isStarting = (status === 'started' && operation === null) || status === 'initial'; + const isSuccess = operation === 'completed'; + + if (isError) return 'error'; + if (isStarting) return 'starting'; + if (isSuccess) return 'success'; + if (!isStarting && !isSuccess && !isError) return 'inProgress'; + + // shouldn't happen, but just to be safe + return 'starting'; + }, [status, operation, isError]); + + const showConfirmOnDevice = confirmOnDevice && !isError; + const bottomButtonOffset = showConfirmOnDevice ? 180 : bottomSafeAreaInset + 12; + + return ( + + + + + + + {translatedText.title} + + + + + {translatedText.subtitle ?? ' '} + + + + + {isError && ( + + + + + )} + {mayBeStucked && ( + + + + )} + {showConfirmOnDevice && ( + + } + /> + )} + + ); +}; diff --git a/suite-native/firmware/tsconfig.json b/suite-native/firmware/tsconfig.json new file mode 100644 index 00000000000..bb182f4593b --- /dev/null +++ b/suite-native/firmware/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "libDev" }, + "references": [ + { "path": "../../suite-common/firmware" }, + { "path": "../../suite-common/icons" }, + { "path": "../link" }, + { "path": "../../packages/connect" }, + { "path": "../../packages/styles" } + ] +} diff --git a/suite-native/intl/src/en.ts b/suite-native/intl/src/en.ts index f3caf813fe0..28f2d115a2b 100644 --- a/suite-native/intl/src/en.ts +++ b/suite-native/intl/src/en.ts @@ -384,8 +384,37 @@ export const en = { type: 'Type', typeUniversal: 'Universal', typeBitcoinOnly: 'Bitcoin-only', - upToDate: 'You’re all up to date', - newVersionAvailable: 'New version available ({version})', + updateCard: { + upToDate: 'You’re all up to date', + newVersionAvailable: 'New version available ({version})', + updateButton: 'Update', + }, + firmwareUpdateScreen: { + updateButton: 'Update firmware', + title: 'Firmware update', + subtitle: 'New firmware is now available. Update your device now.', + }, + firmwareUpdateProgress: { + initializing: { title: 'Initializing firmware update.' }, + confirming: { title: 'Confirm firmware update on your Trezor.' }, + installing: { + title: 'Updating your firmware', + subtitle: 'Don’t close the app.', + }, + restarting: { title: 'Restarting Trezor.' }, + validating: { title: 'Validating firmware.' }, + completed: { + title: 'Firmware update completed.', + subtitle: 'Unlock your device to continue.', + }, + error: { + title: 'Update failed', + }, + confirmOnDeviceMessage: 'Go to your device and confirm the firmware update.', + retryButton: 'Retry', + contactSupportButton: 'Contact support', + stuckButton: 'What if it gets stuck?', + }, }, pinProtection: { title: 'PIN protection', diff --git a/suite-native/module-authorize-device/src/hooks/useOnDeviceReadyNavigation.ts b/suite-native/module-authorize-device/src/hooks/useOnDeviceReadyNavigation.ts index f2b7c86d3fd..b7d0e325428 100644 --- a/suite-native/module-authorize-device/src/hooks/useOnDeviceReadyNavigation.ts +++ b/suite-native/module-authorize-device/src/hooks/useOnDeviceReadyNavigation.ts @@ -19,6 +19,7 @@ import { StackToTabCompositeProps, } from '@suite-native/navigation'; import { useIsConnectPopupOpened } from '@suite-native/module-connect-popup'; +import { selectIsFirmwareInstallationRunning } from '@suite-native/firmware'; const LOADING_TIMEOUT = 2500; @@ -39,6 +40,7 @@ export const useOnDeviceReadyNavigation = () => { ); const isCoinEnablingInitFinished = useSelector(selectIsCoinEnablingInitFinished); const isConnectPopupOpened = useIsConnectPopupOpened(); + const isFirmwareInstallationRunning = useSelector(selectIsFirmwareInstallationRunning); // The connecting screen should be visible for at least 2.5 seconds before redirecting to HomeScreen. useEffect(() => { @@ -54,6 +56,8 @@ export const useOnDeviceReadyNavigation = () => { // also redirect to the Home screen otherwise user would be blocked forever. // This can happen if user enabled only COIN on device A and then connects device B which does not support COIN. useEffect(() => { + if (isFirmwareInstallationRunning) return; + if ( (isDeviceReadyToUseAndAuthorized && isTimeoutFinished) || (deviceEnabledDiscoveryNetworkSymbols.length === 0 && isCoinEnablingInitFinished) @@ -79,5 +83,6 @@ export const useOnDeviceReadyNavigation = () => { deviceEnabledDiscoveryNetworkSymbols, isCoinEnablingInitFinished, isConnectPopupOpened, + isFirmwareInstallationRunning, ]); }; diff --git a/suite-native/module-dev-utils/package.json b/suite-native/module-dev-utils/package.json index 67acb6a06a1..2c932cecaed 100644 --- a/suite-native/module-dev-utils/package.json +++ b/suite-native/module-dev-utils/package.json @@ -19,6 +19,7 @@ "@suite-native/config": "workspace:*", "@suite-native/discovery": "workspace:*", "@suite-native/feature-flags": "workspace:*", + "@suite-native/firmware": "workspace:*", "@suite-native/helpers": "workspace:*", "@suite-native/icons": "workspace:*", "@suite-native/link": "workspace:*", diff --git a/suite-native/module-dev-utils/src/screens/DemoScreen.tsx b/suite-native/module-dev-utils/src/screens/DemoScreen.tsx index e9d6e4416b6..4ca227d46ed 100644 --- a/suite-native/module-dev-utils/src/screens/DemoScreen.tsx +++ b/suite-native/module-dev-utils/src/screens/DemoScreen.tsx @@ -1,38 +1,39 @@ import { useRef, useState } from 'react'; import { TextInput, View } from 'react-native'; -import { Link } from '@suite-native/link'; import { - Text, + // Card, + // ListItemSkeleton, + AlertBox, + Badge, + BadgeVariant, Box, - Hint, - SearchInput, - Radio, - CheckBox, - Switch, - IconButton, - InputWrapper, - Input, - VStack, Button, ButtonColorScheme, + ButtonSize, + CheckBox, Divider, - Badge, - BadgeVariant, + Hint, HStack, - ButtonSize, - TextButton, + IconButton, + Input, + InputWrapper, NumPadButton, + Radio, + SearchInput, + Switch, + Text, + TextButton, TextButtonVariant, - // Card, - // ListItemSkeleton, - AlertBox, + VStack, } from '@suite-native/atoms'; -import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; -import { Screen, ScreenSubHeader } from '@suite-native/navigation'; -import { CryptoIcon, Icon } from '@suite-native/icons'; import { isDevelopOrDebugEnv } from '@suite-native/config'; +import { CryptoIcon, Icon } from '@suite-native/icons'; +import { Link } from '@suite-native/link'; +import { Screen, ScreenSubHeader } from '@suite-native/navigation'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; import { TypographyStyle } from '@trezor/theme'; +import { UpdateProgressIndicatorDemo } from '@suite-native/firmware'; const inputStackStyle = prepareNativeStyle(utils => ({ borderRadius: utils.borders.radii.r16, @@ -345,6 +346,7 @@ export const DemoScreen = () => { /> + {/* For some reason skeleton lags scrolling on iOS, we should investigate */} {/* Skeleton diff --git a/suite-native/module-dev-utils/tsconfig.json b/suite-native/module-dev-utils/tsconfig.json index f8af7aef1ef..e5122020490 100644 --- a/suite-native/module-dev-utils/tsconfig.json +++ b/suite-native/module-dev-utils/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../config" }, { "path": "../discovery" }, { "path": "../feature-flags" }, + { "path": "../firmware" }, { "path": "../helpers" }, { "path": "../icons" }, { "path": "../link" }, diff --git a/suite-native/module-device-settings/package.json b/suite-native/module-device-settings/package.json index 478fe917cc5..302049e7fc0 100644 --- a/suite-native/module-device-settings/package.json +++ b/suite-native/module-device-settings/package.json @@ -13,11 +13,13 @@ "@mobily/ts-belt": "^3.13.1", "@react-navigation/native": "6.1.18", "@react-navigation/native-stack": "6.11.0", + "@reduxjs/toolkit": "1.9.5", "@suite-common/suite-utils": "workspace:*", "@suite-common/wallet-core": "workspace:*", "@suite-native/alerts": "workspace:*", "@suite-native/analytics": "workspace:*", "@suite-native/atoms": "workspace:*", + "@suite-native/config": "workspace:*", "@suite-native/device": "workspace:*", "@suite-native/device-mutex": "workspace:*", "@suite-native/icons": "workspace:*", diff --git a/suite-native/module-device-settings/redux.d.ts b/suite-native/module-device-settings/redux.d.ts new file mode 100644 index 00000000000..df9a0c3f969 --- /dev/null +++ b/suite-native/module-device-settings/redux.d.ts @@ -0,0 +1,7 @@ +import { AsyncThunkAction } from '@reduxjs/toolkit'; + +declare module 'redux' { + export interface Dispatch { + >(thunk: TThunk): ReturnType; + } +} diff --git a/suite-native/module-device-settings/src/components/DeviceFirmwareCard.tsx b/suite-native/module-device-settings/src/components/DeviceFirmwareCard.tsx index dc30bcbe497..3be5efae4ce 100644 --- a/suite-native/module-device-settings/src/components/DeviceFirmwareCard.tsx +++ b/suite-native/module-device-settings/src/components/DeviceFirmwareCard.tsx @@ -2,18 +2,28 @@ import { ReactNode } from 'react'; import { useSelector } from 'react-redux'; import { G } from '@mobily/ts-belt'; +import { useNavigation } from '@react-navigation/native'; import { getFwUpdateVersion } from '@suite-common/suite-utils'; import { deviceModelToIconName } from '@suite-native/icons'; import { + DeviceRootState, + DiscoveryRootState, selectDevice, selectDeviceModel, selectDeviceReleaseInfo, + selectIsDiscoveryActiveByDeviceState, } from '@suite-common/wallet-core'; -import { HStack, Text, VStack } from '@suite-native/atoms'; +import { Button, HStack, Text, VStack } from '@suite-native/atoms'; import { Translation } from '@suite-native/intl'; import { getFirmwareVersion, hasBitcoinOnlyFirmware } from '@trezor/device-utils'; import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { + DeviceStackRoutes, + StackNavigationProps, + DeviceSettingsStackParamList, +} from '@suite-native/navigation'; +import { isDevelopOrDebugEnv } from '@suite-native/config'; import { DeviceSettingsCardLayout } from './DeviceSettingsCardLayout'; @@ -39,10 +49,22 @@ const FirmwareInfo = ({ label, value }: DeviceInfoProps) => { ); }; +type NavigationProp = StackNavigationProps< + DeviceSettingsStackParamList, + DeviceStackRoutes.FirmwareUpdate +>; + +// TODO: remove this once we finish debugging firmware update +const allowReinstall = isDevelopOrDebugEnv(); + export const DeviceFirmwareCard = () => { const device = useSelector(selectDevice); const deviceModel = useSelector(selectDeviceModel); const deviceReleaseInfo = useSelector(selectDeviceReleaseInfo); + const isDiscoveryRunning = useSelector((state: DiscoveryRootState & DeviceRootState) => + selectIsDiscoveryActiveByDeviceState(state, device?.state), + ); + const navigation = useNavigation(); if (!device || !deviceModel) { return null; @@ -57,20 +79,33 @@ export const DeviceFirmwareCard = () => { if (G.isNotNullable(deviceReleaseInfo)) { const isUpgradable = deviceReleaseInfo.isNewer ?? false; - if (isUpgradable) { + if (isUpgradable || allowReinstall) { return { title: ( ), variant: 'info', + rightButton: ( + + ), } as const; } return { - title: , + title: , variant: 'success', } as const; } diff --git a/suite-native/module-device-settings/src/navigation/DeviceSettingsStackNavigator.tsx b/suite-native/module-device-settings/src/navigation/DeviceSettingsStackNavigator.tsx index 4e2264d6907..b1c6c64e7cd 100644 --- a/suite-native/module-device-settings/src/navigation/DeviceSettingsStackNavigator.tsx +++ b/suite-native/module-device-settings/src/navigation/DeviceSettingsStackNavigator.tsx @@ -5,17 +5,19 @@ import { DeviceStackRoutes, stackNavigationOptionsConfig, } from '@suite-native/navigation'; +import { FirmwareUpdateInProgressScreen } from '@suite-native/firmware'; import { DeviceSettingsModalScreen } from '../screens/DeviceSettingsModalScreen'; import { DeviceAuthenticityStackNavigator } from './DeviceAuthenticityStackNavigator'; import { DevicePinProtectionStackNavigator } from './DevicePinProtectionStackNavigator'; - +import { FirmwareUpdateScreen } from '../screens/FirmwareUpdateScreen/FirmwareUpdateScreen'; +import { ContinueOnTrezorScreen } from '../screens/ContinueOnTrezorScreen'; const DeviceSettingsStack = createNativeStackNavigator(); export const DeviceSettingsStackNavigator = () => ( ( name={DeviceStackRoutes.DeviceAuthenticity} component={DeviceAuthenticityStackNavigator} /> + + + ); diff --git a/suite-native/module-device-settings/src/screens/ContinueOnTrezorScreen.tsx b/suite-native/module-device-settings/src/screens/ContinueOnTrezorScreen.tsx index 9329f826960..16148cb988a 100644 --- a/suite-native/module-device-settings/src/screens/ContinueOnTrezorScreen.tsx +++ b/suite-native/module-device-settings/src/screens/ContinueOnTrezorScreen.tsx @@ -6,6 +6,7 @@ import { ConnectorImage, DeviceImage } from '@suite-native/device'; import { Translation } from '@suite-native/intl'; import { getScreenHeight } from '@trezor/env-utils'; import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { DeviceModelInternal } from '@trezor/connect'; import { DeviceInteractionScreenWrapper } from '../components/DeviceInteractionScreenWrapper'; @@ -21,10 +22,6 @@ export const ContinueOnTrezorScreen = () => { const deviceModel = useSelector(selectDeviceModel); - if (!deviceModel) { - return null; - } - return ( @@ -32,7 +29,7 @@ export const ContinueOnTrezorScreen = () => { diff --git a/suite-native/module-device-settings/src/screens/DeviceSettingsModalScreen.tsx b/suite-native/module-device-settings/src/screens/DeviceSettingsModalScreen.tsx index bc1f416afac..a0265ad21c8 100644 --- a/suite-native/module-device-settings/src/screens/DeviceSettingsModalScreen.tsx +++ b/suite-native/module-device-settings/src/screens/DeviceSettingsModalScreen.tsx @@ -1,59 +1,33 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useSelector } from 'react-redux'; -import { useNavigation } from '@react-navigation/native'; - import { SUPPORTS_DEVICE_AUTHENTICITY_CHECK } from '@suite-common/suite-constants'; import { selectDevice, selectDeviceModel, selectDeviceReleaseInfo, - selectIsPortfolioTrackerDevice, } from '@suite-common/wallet-core'; import { Button, Text, VStack } from '@suite-native/atoms'; import { DeviceImage } from '@suite-native/device'; import { Translation, useTranslate } from '@suite-native/intl'; -import { - AppTabsRoutes, - HomeStackRoutes, - RootStackParamList, - RootStackRoutes, - Screen, - ScreenSubHeader, - StackNavigationProps, -} from '@suite-native/navigation'; +import { Screen, ScreenSubHeader } from '@suite-native/navigation'; import { DeviceAuthenticityCard } from '../components/DeviceAuthenticityCard'; import { DeviceFirmwareCard } from '../components/DeviceFirmwareCard'; import { DevicePinProtectionCard } from '../components/DevicePinProtectionCard'; import { HowToUpdateBottomSheet } from '../components/HowToUpdateBottomSheet'; -type NavigationProp = StackNavigationProps; - export const DeviceSettingsModalScreen = () => { - const navigation = useNavigation(); const { translate } = useTranslate(); const device = useSelector(selectDevice); const deviceModel = useSelector(selectDeviceModel); - const isPortfolioTrackerDevice = useSelector(selectIsPortfolioTrackerDevice); const deviceReleaseInfo = useSelector(selectDeviceReleaseInfo); const [isUpdateSheetOpen, setIsUpdateSheetOpen] = useState(false); const isUpgradable = deviceReleaseInfo?.isNewer ?? false; - useEffect(() => { - if (isPortfolioTrackerDevice) { - navigation.navigate(RootStackRoutes.AppTabs, { - screen: AppTabsRoutes.HomeStack, - params: { - screen: HomeStackRoutes.Home, - }, - }); - } - }, [isPortfolioTrackerDevice, navigation]); - if (!device || !deviceModel) { return null; } diff --git a/suite-native/module-device-settings/src/screens/FirmwareUpdateScreen/FirmwareUpdateScreen.tsx b/suite-native/module-device-settings/src/screens/FirmwareUpdateScreen/FirmwareUpdateScreen.tsx new file mode 100644 index 00000000000..25c7460faae --- /dev/null +++ b/suite-native/module-device-settings/src/screens/FirmwareUpdateScreen/FirmwareUpdateScreen.tsx @@ -0,0 +1,73 @@ +import { useSelector } from 'react-redux'; + +import { useNavigation } from '@react-navigation/native'; + +import { Box, Button, Text } from '@suite-native/atoms'; +import { Translation } from '@suite-native/intl'; +import { + DeviceSettingsStackParamList, + DeviceStackRoutes, + Screen, + ScreenSubHeader, + StackNavigationProps, +} from '@suite-native/navigation'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { + DeviceRootState, + selectIsDiscoveryActiveByDeviceState, + DiscoveryRootState, + selectDeviceState, +} from '@suite-common/wallet-core'; + +import { FirmwareUpdateVersionCard } from './FirmwareVersionCard'; + +const firmwareUpdateButtonStyle = prepareNativeStyle(utils => ({ + marginHorizontal: utils.spacings.sp16, +})); + +type NavigationProp = StackNavigationProps< + DeviceSettingsStackParamList, + DeviceStackRoutes.FirmwareUpdate +>; + +export const FirmwareUpdateScreen = () => { + const { applyStyle } = useNativeStyles(); + + const deviceState = useSelector(selectDeviceState); + const isDiscoveryRunning = useSelector((state: DiscoveryRootState & DeviceRootState) => + selectIsDiscoveryActiveByDeviceState(state, deviceState), + ); + + const navigation = useNavigation(); + const handleUpdateFirmware = () => { + navigation.navigate(DeviceStackRoutes.FirmwareUpdateInProgress); + }; + + return ( + } + footer={ + + } + > + + + + + + + + + + + + + ); +}; diff --git a/suite-native/module-device-settings/src/screens/FirmwareUpdateScreen/FirmwareVersionCard.tsx b/suite-native/module-device-settings/src/screens/FirmwareUpdateScreen/FirmwareVersionCard.tsx new file mode 100644 index 00000000000..596aba02529 --- /dev/null +++ b/suite-native/module-device-settings/src/screens/FirmwareUpdateScreen/FirmwareVersionCard.tsx @@ -0,0 +1,116 @@ +import { PixelRatio } from 'react-native'; +import { useSelector } from 'react-redux'; + +import { + selectDeviceFirmwareVersion, + selectDeviceUpdateFirmwareVersion, + selectIsBitcoinOnlyDevice, +} from '@suite-common/wallet-core'; +import { Box, BoxProps, Text } from '@suite-native/atoms'; +import { Icon } from '@suite-native/icons'; +import { useTranslate } from '@suite-native/intl'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { Color } from '@trezor/theme'; + +type FirmwareVersionCardProps = { + title: string; + titleColor: Color; + version: string; + fwType: string; + backgroundColor: Color; +} & BoxProps; + +const cardContainerStyle = prepareNativeStyle<{ backgroundColor: Color }>( + (utils, { backgroundColor }) => ({ + padding: utils.spacings.sp16, + backgroundColor: utils.colors[backgroundColor], + borderRadius: utils.borders.radii.r12, + width: '50%', + justifyContent: 'center', + }), +); + +export const FirmwareVersionCard = ({ + title, + version, + fwType, + titleColor, + backgroundColor, + children, + ...boxProps +}: FirmwareVersionCardProps) => { + const { applyStyle } = useNativeStyles(); + + return ( + + + {title} + + + {version} + {' • '} + {fwType} + + {children} + + ); +}; + +const firmwareArrowStyle = prepareNativeStyle(utils => { + const firmwareArrowSize = (32 + 4) * PixelRatio.getFontScale(); + + return { + height: firmwareArrowSize, + width: firmwareArrowSize, + borderRadius: utils.borders.radii.round, + backgroundColor: utils.colors.backgroundSurfaceElevation1, + borderColor: utils.colors.backgroundSurfaceElevation0, + borderWidth: 4, + justifyContent: 'center', + alignItems: 'center', + position: 'absolute', + zIndex: 3, + left: -(firmwareArrowSize / 2), + }; +}); + +export const FirmwareUpdateVersionCard = (props: BoxProps) => { + const { applyStyle } = useNativeStyles(); + const firmwareVersion = useSelector(selectDeviceFirmwareVersion); + const updateFirmwareVersion = useSelector(selectDeviceUpdateFirmwareVersion); + const isBtcOnly = useSelector(selectIsBitcoinOnlyDevice); + const { translate } = useTranslate(); + + const firmwareTypeTranslationId = isBtcOnly + ? 'moduleDeviceSettings.firmware.typeBitcoinOnly' + : 'moduleDeviceSettings.firmware.typeUniversal'; + + return ( + + + + + + + + + + ); +}; diff --git a/suite-native/module-device-settings/tsconfig.json b/suite-native/module-device-settings/tsconfig.json index 86b27248543..e392bdb0fb7 100644 --- a/suite-native/module-device-settings/tsconfig.json +++ b/suite-native/module-device-settings/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../alerts" }, { "path": "../analytics" }, { "path": "../atoms" }, + { "path": "../config" }, { "path": "../device" }, { "path": "../device-mutex" }, { "path": "../icons" }, diff --git a/suite-native/module-home/src/screens/HomeScreen/HomeScreen.tsx b/suite-native/module-home/src/screens/HomeScreen/HomeScreen.tsx index b626b66d388..3930a6da3f1 100644 --- a/suite-native/module-home/src/screens/HomeScreen/HomeScreen.tsx +++ b/suite-native/module-home/src/screens/HomeScreen/HomeScreen.tsx @@ -2,21 +2,22 @@ import { useRef } from 'react'; import { useSelector } from 'react-redux'; import { - selectIsDeviceAuthorized, selectDeviceAuthFailed, + selectIsDeviceAuthorized, selectIsDeviceUnlocked, selectIsDiscoveredDeviceAccountless, } from '@suite-common/wallet-core'; +import { Box } from '@suite-native/atoms'; import { DeviceManagerScreenHeader } from '@suite-native/device-manager'; import { Screen } from '@suite-native/navigation'; -import { Box } from '@suite-native/atoms'; import { BiometricsBottomSheet } from './components/BiometricsBottomSheet'; import { EmptyHomeRenderer } from './components/EmptyHomeRenderer'; -import { PortfolioContent } from './components/PortfolioContent'; -import { useHomeRefreshControl } from './useHomeRefreshControl'; import { EnableViewOnlyBottomSheet } from './components/EnableViewOnlyBottomSheet'; +import { PortfolioContent } from './components/PortfolioContent'; import { PortfolioGraphRef } from './components/PortfolioGraph'; +import { useHomeRefreshControl } from './useHomeRefreshControl'; + export const HomeScreen = () => { const isDiscoveredDeviceAccountless = useSelector(selectIsDiscoveredDeviceAccountless); const isDeviceAuthorized = useSelector(selectIsDeviceAuthorized); diff --git a/suite-native/navigation/src/navigators.ts b/suite-native/navigation/src/navigators.ts index 5d54bf4fa86..9836672c2e6 100644 --- a/suite-native/navigation/src/navigators.ts +++ b/suite-native/navigation/src/navigators.ts @@ -1,32 +1,32 @@ import { NavigatorScreenParams } from '@react-navigation/native'; -import { RequireAllOrNone } from 'type-fest'; import { ParsedURL } from 'expo-linking'; +import { RequireAllOrNone } from 'type-fest'; +import { AccountType, NetworkSymbol } from '@suite-common/wallet-config'; import { AccountKey, GeneralPrecomposedTransactionFinal, TokenAddress, XpubAddress, } from '@suite-common/wallet-types'; -import { AccountType, NetworkSymbol } from '@suite-common/wallet-config'; import { AccountInfo } from '@trezor/connect'; import { - AppTabsRoutes, AccountsImportStackRoutes, - HomeStackRoutes, - RootStackRoutes, - SettingsStackRoutes, - ReceiveStackRoutes, AccountsStackRoutes, + AddCoinAccountStackRoutes, + AppTabsRoutes, + AuthorizeDeviceStackRoutes, + DeviceAuthenticityStackRoutes, + DevicePinProtectionStackRoutes, + DeviceStackRoutes, DevUtilsStackRoutes, + HomeStackRoutes, OnboardingStackRoutes, - AuthorizeDeviceStackRoutes, - AddCoinAccountStackRoutes, + ReceiveStackRoutes, + RootStackRoutes, SendStackRoutes, - DeviceStackRoutes, - DevicePinProtectionStackRoutes, - DeviceAuthenticityStackRoutes, + SettingsStackRoutes, } from './routes'; import { NavigateParameters } from './types'; @@ -154,6 +154,9 @@ export type DeviceSettingsStackParamList = { [DeviceStackRoutes.DeviceSettings]: undefined; [DeviceStackRoutes.DevicePinProtection]: undefined; [DeviceStackRoutes.DeviceAuthenticity]: undefined; + [DeviceStackRoutes.FirmwareUpdate]: undefined; + [DeviceStackRoutes.FirmwareUpdateInProgress]: undefined; + [DeviceStackRoutes.ContinueOnTrezor]: undefined; }; export type DevicePinProtectionStackParamList = { diff --git a/suite-native/navigation/src/routes.ts b/suite-native/navigation/src/routes.ts index b16b8295dba..12a483d9ad9 100644 --- a/suite-native/navigation/src/routes.ts +++ b/suite-native/navigation/src/routes.ts @@ -42,6 +42,9 @@ export enum DeviceStackRoutes { DeviceSettings = 'DeviceSettings', DevicePinProtection = 'DevicePinProtection', DeviceAuthenticity = 'DeviceAuthenticity', + FirmwareUpdate = 'FirmwareUpdate', + FirmwareUpdateInProgress = 'FirmwareUpdateInProgress', + ContinueOnTrezor = 'ContinueOnTrezor', } export enum DevicePinProtectionStackRoutes { diff --git a/suite-native/state/package.json b/suite-native/state/package.json index 6d613577bdc..e19ca9ad4d0 100644 --- a/suite-native/state/package.json +++ b/suite-native/state/package.json @@ -13,6 +13,7 @@ "@reduxjs/toolkit": "1.9.5", "@sentry/react-native": "6.1.0", "@suite-common/analytics": "workspace:*", + "@suite-common/firmware": "workspace:*", "@suite-common/logger": "workspace:*", "@suite-common/message-system": "workspace:*", "@suite-common/redux-utils": "workspace:*", @@ -25,6 +26,7 @@ "@suite-native/device-authorization": "workspace:*", "@suite-native/discovery": "workspace:*", "@suite-native/feature-flags": "workspace:*", + "@suite-native/firmware": "workspace:*", "@suite-native/graph": "workspace:*", "@suite-native/message-system": "workspace:*", "@suite-native/module-send": "workspace:*", @@ -37,6 +39,10 @@ "react-native": "0.76.1", "react-redux": "8.0.7", "redux-devtools-expo-dev-plugin": "^0.1.0", + "redux-logger": "^3.0.6", "redux-persist": "6.0.0" + }, + "devDependencies": { + "@types/redux-logger": "^3.0.11" } } diff --git a/suite-native/state/src/extraDependencies.ts b/suite-native/state/src/extraDependencies.ts index 9d6e31eff60..718d2a920ef 100644 --- a/suite-native/state/src/extraDependencies.ts +++ b/suite-native/state/src/extraDependencies.ts @@ -22,10 +22,10 @@ const deviceType = Device.isDevice ? 'device' : 'emulator'; const transportsPerDeviceType = { device: Platform.select({ - ios: ['BridgeTransport', 'UdpTransport'], + ios: ['BridgeTransport'], android: [NativeUsbTransport], }), - emulator: ['BridgeTransport', 'UdpTransport'], + emulator: ['BridgeTransport'], } as const; const transports = transportsPerDeviceType[deviceType]; diff --git a/suite-native/state/src/reducers.ts b/suite-native/state/src/reducers.ts index 36dd58d1788..f9d20e91db4 100644 --- a/suite-native/state/src/reducers.ts +++ b/suite-native/state/src/reducers.ts @@ -11,6 +11,7 @@ import { prepareStakeReducer, prepareTransactionsReducer, } from '@suite-common/wallet-core'; +import { prepareFirmwareReducer } from '@suite-common/firmware'; import { appSettingsReducer, appSettingsPersistWhitelist } from '@suite-native/settings'; import { sendFormSlice } from '@suite-native/module-send'; import { logsSlice } from '@suite-common/logger'; @@ -33,6 +34,7 @@ import { graphReducer, graphPersistTransform } from '@suite-native/graph'; import { discoveryConfigPersistWhitelist, discoveryConfigReducer } from '@suite-native/discovery'; import { featureFlagsPersistedKeys, featureFlagsReducer } from '@suite-native/feature-flags'; import { prepareTokenDefinitionsReducer } from '@suite-common/token-definitions'; +import { nativeFirmwareReducer } from '@suite-native/firmware'; import { extraDependencies } from './extraDependencies'; import { appReducer } from './appSlice'; @@ -48,6 +50,7 @@ const discoveryReducer = prepareDiscoveryReducer(extraDependencies); const tokenDefinitionsReducer = prepareTokenDefinitionsReducer(extraDependencies); const sendFormReducer = sendFormSlice.prepareReducer(extraDependencies); const stakeReducer = prepareStakeReducer(extraDependencies); +const firmwareReducer = prepareFirmwareReducer(extraDependencies); export const prepareRootReducers = async () => { const appSettingsPersistedReducer = await preparePersistReducer({ @@ -163,6 +166,8 @@ export const prepareRootReducers = async () => { graph: graphReducer, device: devicePersistedReducer, deviceAuthorization: deviceAuthorizationReducer, + firmware: firmwareReducer, + nativeFirmware: nativeFirmwareReducer, logs: logsSlice.reducer, notifications: notificationsReducer, discoveryConfig: discoveryConfigPersistedReducer, diff --git a/suite-native/state/src/store.ts b/suite-native/state/src/store.ts index 049e348ec58..7a28112a9f2 100644 --- a/suite-native/state/src/store.ts +++ b/suite-native/state/src/store.ts @@ -1,5 +1,6 @@ import { configureStore, Middleware, StoreEnhancer } from '@reduxjs/toolkit'; import devToolsEnhancer from 'redux-devtools-expo-dev-plugin'; +import { logger } from 'redux-logger'; import { sendFormMiddleware } from '@suite-native/module-send/src/sendFormMiddleware'; import { prepareFiatRatesMiddleware } from '@suite-common/wallet-core'; @@ -11,6 +12,8 @@ import { blockchainMiddleware } from '@suite-native/blockchain'; import { extraDependencies } from './extraDependencies'; import { prepareRootReducers } from './reducers'; +const ENABLE_REDUX_LOGGER = false; + const middlewares: Middleware[] = [ messageSystemMiddleware, blockchainMiddleware, @@ -25,6 +28,9 @@ const enhancers: Array> = []; if (__DEV__) { enhancers.push(devToolsEnhancer({ maxAge: 150 })!); + if (ENABLE_REDUX_LOGGER) { + middlewares.push(logger); + } } export const initStore = async () => diff --git a/suite-native/state/tsconfig.json b/suite-native/state/tsconfig.json index 8da15fa87d8..aecd9e24cb2 100644 --- a/suite-native/state/tsconfig.json +++ b/suite-native/state/tsconfig.json @@ -5,6 +5,7 @@ { "path": "../../suite-common/analytics" }, + { "path": "../../suite-common/firmware" }, { "path": "../../suite-common/logger" }, { "path": "../../suite-common/message-system" @@ -29,6 +30,7 @@ { "path": "../device-authorization" }, { "path": "../discovery" }, { "path": "../feature-flags" }, + { "path": "../firmware" }, { "path": "../graph" }, { "path": "../message-system" }, { "path": "../module-send" }, diff --git a/yarn.lock b/yarn.lock index 2730c524c4a..62fce5f3582 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9192,6 +9192,22 @@ __metadata: languageName: unknown linkType: soft +"@suite-common/firmware@workspace:*, @suite-common/firmware@workspace:suite-common/firmware": + version: 0.0.0-use.local + resolution: "@suite-common/firmware@workspace:suite-common/firmware" + dependencies: + "@reduxjs/toolkit": "npm:1.9.5" + "@suite-common/redux-utils": "workspace:*" + "@suite-common/suite-types": "workspace:*" + "@suite-common/wallet-core": "workspace:*" + "@trezor/connect": "workspace:*" + "@trezor/device-utils": "workspace:*" + "@trezor/env-utils": "workspace:*" + react: "npm:18.2.0" + react-redux: "npm:8.0.7" + languageName: unknown + linkType: soft + "@suite-common/formatters@workspace:*, @suite-common/formatters@workspace:suite-common/formatters": version: 0.0.0-use.local resolution: "@suite-common/formatters@workspace:suite-common/formatters" @@ -10013,6 +10029,26 @@ __metadata: languageName: unknown linkType: soft +"@suite-native/firmware@workspace:*, @suite-native/firmware@workspace:suite-native/firmware": + version: 0.0.0-use.local + resolution: "@suite-native/firmware@workspace:suite-native/firmware" + dependencies: + "@react-navigation/native": "npm:6.1.18" + "@reduxjs/toolkit": "npm:1.9.5" + "@shopify/react-native-skia": "npm:^1.5.10" + "@suite-common/firmware": "workspace:*" + "@suite-common/icons": "workspace:*" + "@suite-native/link": "workspace:*" + "@trezor/connect": "workspace:*" + "@trezor/styles": "workspace:*" + react: "npm:18.2.0" + react-native: "npm:0.76.1" + react-native-reanimated: "npm:3.16.1" + react-native-safe-area-context: "npm:4.14.0" + react-redux: "npm:8.0.7" + languageName: unknown + linkType: soft + "@suite-native/formatters@workspace:*, @suite-native/formatters@workspace:suite-native/formatters": version: 0.0.0-use.local resolution: "@suite-native/formatters@workspace:suite-native/formatters" @@ -10353,6 +10389,7 @@ __metadata: "@suite-native/config": "workspace:*" "@suite-native/discovery": "workspace:*" "@suite-native/feature-flags": "workspace:*" + "@suite-native/firmware": "workspace:*" "@suite-native/helpers": "workspace:*" "@suite-native/icons": "workspace:*" "@suite-native/link": "workspace:*" @@ -10376,11 +10413,13 @@ __metadata: "@mobily/ts-belt": "npm:^3.13.1" "@react-navigation/native": "npm:6.1.18" "@react-navigation/native-stack": "npm:6.11.0" + "@reduxjs/toolkit": "npm:1.9.5" "@suite-common/suite-utils": "workspace:*" "@suite-common/wallet-core": "workspace:*" "@suite-native/alerts": "workspace:*" "@suite-native/analytics": "workspace:*" "@suite-native/atoms": "workspace:*" + "@suite-native/config": "workspace:*" "@suite-native/device": "workspace:*" "@suite-native/device-mutex": "workspace:*" "@suite-native/icons": "workspace:*" @@ -10749,6 +10788,7 @@ __metadata: "@reduxjs/toolkit": "npm:1.9.5" "@sentry/react-native": "npm:6.1.0" "@suite-common/analytics": "workspace:*" + "@suite-common/firmware": "workspace:*" "@suite-common/logger": "workspace:*" "@suite-common/message-system": "workspace:*" "@suite-common/redux-utils": "workspace:*" @@ -10761,6 +10801,7 @@ __metadata: "@suite-native/device-authorization": "workspace:*" "@suite-native/discovery": "workspace:*" "@suite-native/feature-flags": "workspace:*" + "@suite-native/firmware": "workspace:*" "@suite-native/graph": "workspace:*" "@suite-native/message-system": "workspace:*" "@suite-native/module-send": "workspace:*" @@ -10768,11 +10809,13 @@ __metadata: "@suite-native/storage": "workspace:*" "@trezor/transport-native": "workspace:*" "@trezor/utils": "workspace:*" + "@types/redux-logger": "npm:^3.0.11" expo-device: "npm:6.0.2" react: "npm:18.2.0" react-native: "npm:0.76.1" react-redux: "npm:8.0.7" redux-devtools-expo-dev-plugin: "npm:^0.1.0" + redux-logger: "npm:^3.0.6" redux-persist: "npm:6.0.0" languageName: unknown linkType: soft @@ -12227,6 +12270,7 @@ __metadata: "@suite-common/connect-init": "workspace:*" "@suite-common/device-authenticity": "workspace:*" "@suite-common/fiat-services": "workspace:*" + "@suite-common/firmware": "workspace:*" "@suite-common/formatters": "workspace:*" "@suite-common/icons": "workspace:*" "@suite-common/intl-types": "workspace:*" @@ -35260,7 +35304,7 @@ __metadata: languageName: node linkType: hard -"react-native-safe-area-context@npm:^4.14.0": +"react-native-safe-area-context@npm:4.14.0, react-native-safe-area-context@npm:^4.14.0": version: 4.14.0 resolution: "react-native-safe-area-context@npm:4.14.0" peerDependencies: