diff --git a/apps/cli/src/commands-index.ts b/apps/cli/src/commands-index.ts index d91c06206c99..53989eb83ecc 100644 --- a/apps/cli/src/commands-index.ts +++ b/apps/cli/src/commands-index.ts @@ -42,6 +42,7 @@ import signMessage from "./commands/signMessage"; import speculosList from "./commands/speculosList"; import swap from "./commands/swap"; import sync from "./commands/sync"; +import synchronousOnboarding from "./commands/synchronousOnboarding"; import testDetectOpCollision from "./commands/testDetectOpCollision"; import testGetTrustedInputFromTxHash from "./commands/testGetTrustedInputFromTxHash"; import user from "./commands/user"; @@ -93,6 +94,7 @@ export default { speculosList, swap, sync, + synchronousOnboarding, testDetectOpCollision, testGetTrustedInputFromTxHash, user, diff --git a/apps/cli/src/commands/synchronousOnboarding.ts b/apps/cli/src/commands/synchronousOnboarding.ts new file mode 100644 index 000000000000..c4d9cec043de --- /dev/null +++ b/apps/cli/src/commands/synchronousOnboarding.ts @@ -0,0 +1,30 @@ +import { + getOnboardingStatePolling, + OnboardingStatePollingResult, +} from "@ledgerhq/live-common/lib/hw/getOnboardingStatePolling"; +import { Observable } from "rxjs"; +import { deviceOpt } from "../scan"; + +export default { + description: "track the onboarding status of your device", + args: [ + { + name: "pollingPeriodMs", + alias: "p", + desc: "polling period in milliseconds", + type: Number, + }, + deviceOpt, + ], + job: ({ + device, + pollingPeriodMs, + }: Partial<{ + device: string; + pollingPeriodMs: number; + }>): Observable => + getOnboardingStatePolling({ + deviceId: device ?? "", + pollingPeriodMs: pollingPeriodMs ?? 1000, + }), +}; diff --git a/apps/ledger-live-mobile/fastlane/Fastfile b/apps/ledger-live-mobile/fastlane/Fastfile index 285439b980a0..2373700b6e53 100644 --- a/apps/ledger-live-mobile/fastlane/Fastfile +++ b/apps/ledger-live-mobile/fastlane/Fastfile @@ -109,7 +109,7 @@ platform :ios do build_number = latest_testflight_build_number( version: trim_version_number(package["version"]), - app_identifier: MY_APP_BUNDLE_ID + app_identifier: "com.ledger.live" ) increment_build_number({ diff --git a/apps/ledger-live-mobile/src/components/RootNavigator/BaseOnboardingNavigator.js b/apps/ledger-live-mobile/src/components/RootNavigator/BaseOnboardingNavigator.js index addca9b75c0c..ae74b644b237 100644 --- a/apps/ledger-live-mobile/src/components/RootNavigator/BaseOnboardingNavigator.js +++ b/apps/ledger-live-mobile/src/components/RootNavigator/BaseOnboardingNavigator.js @@ -11,6 +11,7 @@ import { ScreenName, NavigatorName } from "../../const"; import PairDevices from "../../screens/PairDevices"; import EditDeviceName from "../../screens/EditDeviceName"; import OnboardingNavigator from "./OnboardingNavigator"; +import { SyncOnboardingNavigator } from "./SyncOnboardingNavigator"; import ImportAccountsNavigator from "./ImportAccountsNavigator"; import PasswordAddFlowNavigator from "./PasswordAddFlowNavigator"; import PasswordModifyFlowNavigator from "./PasswordModifyFlowNavigator"; @@ -63,6 +64,10 @@ export default function BaseOnboardingNavigator() { name={NavigatorName.Onboarding} component={OnboardingNavigator} /> + (); + +export const SyncOnboardingNavigator = () => ( + + + +); diff --git a/apps/ledger-live-mobile/src/const/navigation.js b/apps/ledger-live-mobile/src/const/navigation.js index ff51b64d1b30..79324b3f9d3c 100644 --- a/apps/ledger-live-mobile/src/const/navigation.js +++ b/apps/ledger-live-mobile/src/const/navigation.js @@ -319,6 +319,8 @@ export const ScreenName = { "OnboardingModalSyncDesktopInformation", OnboardingModalRecoveryPhraseWarning: "OnboardingModalRecoveryPhraseWarning", + SyncOnboardingWelcome: "SyncOnboardingWelcome", + PlatformCatalog: "PlatformCatalog", PlatformApp: "PlatformApp", @@ -386,6 +388,7 @@ export const NavigatorName = { MigrateAccountsFlow: "MigrateAccountsFlow", NftNavigator: "NftNavigator", Onboarding: "Onboarding", + SyncOnboarding: "SyncOnboarding", PasswordAddFlow: "PasswordAddFlow", PasswordModifyFlow: "PasswordModifyFlow", Platform: "Platform", diff --git a/apps/ledger-live-mobile/src/index.js b/apps/ledger-live-mobile/src/index.js index 38eb83362761..f41e4c1a7ba7 100644 --- a/apps/ledger-live-mobile/src/index.js +++ b/apps/ledger-live-mobile/src/index.js @@ -201,7 +201,7 @@ function getProxyURL(url: ?string) { return url; } -// DeepLinking +// Deep linking general options const linkingOptions = { async getInitialURL() { const url = await Linking.getInitialURL(); @@ -221,137 +221,162 @@ const linkingOptions = { }; }, prefixes: ["ledgerlive://"], - config: { +}; + +// Deep linking screens config available only for users that have already been onboarded +const alreadyOnboardedLinkingConfigScreens = { + [NavigatorName.Base]: { + initialRouteName: NavigatorName.Main, screens: { - [NavigatorName.Base]: { - initialRouteName: NavigatorName.Main, + /** + * @params ?uri: string + * ie: "ledgerlive://wc?uri=wc:00e46b69-d0cc-4b3e-b6a2-cee442f97188@1?bridge=https%3A%2F%2Fbridge.walletconnect.org&key=91303dedf64285cbbaf9120f6e9d160a5c8aa3deb67017a3874cd272323f48ae + */ + [ScreenName.WalletConnectDeeplinkingSelectAccount]: "wc", + [NavigatorName.Main]: { + initialRouteName: ScreenName.Portfolio, screens: { /** - * @params ?uri: string - * ie: "ledgerlive://wc?uri=wc:00e46b69-d0cc-4b3e-b6a2-cee442f97188@1?bridge=https%3A%2F%2Fbridge.walletconnect.org&key=91303dedf64285cbbaf9120f6e9d160a5c8aa3deb67017a3874cd272323f48ae + * ie: "ledgerlive://portfolio" -> will redirect to the portfolio */ - [ScreenName.WalletConnectDeeplinkingSelectAccount]: "wc", - [NavigatorName.Main]: { - initialRouteName: ScreenName.Portfolio, + [NavigatorName.Portfolio]: { screens: { - /** - * ie: "ledgerlive://portfolio" -> will redirect to the portfolio - */ - [NavigatorName.Portfolio]: { - screens: { - [ScreenName.Portfolio]: "portfolio", - [NavigatorName.PortfolioAccounts]: { - screens: { - /** - * @params ?currency: string - * ie: "ledgerlive://account?currency=bitcoin" will open the first bitcoin account - */ - [ScreenName.Accounts]: "account", - }, - }, - }, - }, - [NavigatorName.Market]: { - screens: { - /** - * @params ?platform: string - * ie: "ledgerlive://discover" will open the catalog - * ie: "ledgerlive://discover/paraswap?theme=light" will open the catalog and the paraswap dapp with a light theme as parameter - */ - [ScreenName.MarketList]: "market", - }, - }, - [NavigatorName.Discover]: { + [ScreenName.Portfolio]: "portfolio", + [NavigatorName.PortfolioAccounts]: { screens: { /** - * @params ?platform: string - * ie: "ledgerlive://discover" will open the catalog - * ie: "ledgerlive://discover/paraswap?theme=light" will open the catalog and the paraswap dapp with a light theme as parameter + * @params ?currency: string + * ie: "ledgerlive://account?currency=bitcoin" will open the first bitcoin account */ - [ScreenName.PlatformCatalog]: "discover/:platform?", + [ScreenName.Accounts]: "account", }, }, - [NavigatorName.Manager]: { - screens: { - /** - * ie: "ledgerlive://manager" will open the manager - * - * @params ?installApp: string - * ie: "ledgerlive://manager?installApp=bitcoin" will open the manager with "bitcoin" prefilled in the search input - * - * * @params ?searchQuery: string - * ie: "ledgerlive://manager?searchQuery=bitcoin" will open the manager with "bitcoin" prefilled in the search input - */ - [ScreenName.Manager]: "manager", - }, - }, - }, - }, - [NavigatorName.ReceiveFunds]: { - screens: { - /** - * @params ?currency: string - * ie: "ledgerlive://receive?currency=bitcoin" will open the prefilled search account in the receive flow - */ - [ScreenName.ReceiveSelectAccount]: "receive", }, }, - [NavigatorName.Swap]: { + [NavigatorName.Market]: { screens: { /** - * @params ?currency: string - * ie: "ledgerlive://receive?currency=bitcoin" will open the prefilled search account in the receive flow + * @params ?platform: string + * ie: "ledgerlive://discover" will open the catalog + * ie: "ledgerlive://discover/paraswap?theme=light" will open the catalog and the paraswap dapp with a light theme as parameter */ - [ScreenName.Swap]: "swap", + [ScreenName.MarketList]: "market", }, }, - [NavigatorName.SendFunds]: { + [NavigatorName.Discover]: { screens: { /** - * @params ?currency: string - * ie: "ledgerlive://send?currency=bitcoin" will open the prefilled search account in the send flow + * @params ?platform: string + * ie: "ledgerlive://discover" will open the catalog + * ie: "ledgerlive://discover/paraswap?theme=light" will open the catalog and the paraswap dapp with a light theme as parameter */ - [ScreenName.SendCoin]: "send", + [ScreenName.PlatformCatalog]: "discover/:platform?", }, }, - [NavigatorName.ExchangeBuyFlow]: { + [NavigatorName.Manager]: { screens: { /** - * @params currency: string - * ie: "ledgerlive://buy/bitcoin" -> will redirect to the prefilled search currency in the buy crypto flow + * ie: "ledgerlive://manager" will open the manager + * + * @params ?installApp: string + * ie: "ledgerlive://manager?installApp=bitcoin" will open the manager with "bitcoin" prefilled in the search input + * + * * @params ?searchQuery: string + * ie: "ledgerlive://manager?searchQuery=bitcoin" will open the manager with "bitcoin" prefilled in the search input */ - [ScreenName.ExchangeSelectCurrency]: "buy/:currency", + [ScreenName.Manager]: "manager", }, }, + }, + }, + [NavigatorName.ReceiveFunds]: { + screens: { /** - * ie: "ledgerlive://buy" -> will redirect to the main exchange page + * @params ?currency: string + * ie: "ledgerlive://receive?currency=bitcoin" will open the prefilled search account in the receive flow */ - [NavigatorName.Exchange]: { - initialRouteName: "buy", - screens: { - [ScreenName.ExchangeBuy]: "buy", - [ScreenName.Coinify]: "buy/coinify", - }, - }, + [ScreenName.ReceiveSelectAccount]: "receive", + }, + }, + [NavigatorName.Swap]: { + screens: { /** - * ie: "ledgerlive://swap" -> will redirect to the main swap page + * @params ?currency: string + * ie: "ledgerlive://receive?currency=bitcoin" will open the prefilled search account in the receive flow */ - [NavigatorName.Swap]: "swap", - [NavigatorName.Settings]: { - initialRouteName: [ScreenName.SettingsScreen], - screens: { - /** - * ie: "ledgerlive://settings/experimental" -> will redirect to the experimental settings panel - */ - [ScreenName.SettingsScreen]: "settings", - [ScreenName.GeneralSettings]: "settings/general", - [ScreenName.AccountsSettings]: "settings/accounts", - [ScreenName.AboutSettings]: "settings/about", - [ScreenName.HelpSettings]: "settings/help", - [ScreenName.ExperimentalSettings]: "settings/experimental", - [ScreenName.DeveloperSettings]: "settings/developer", - }, - }, + [ScreenName.Swap]: "swap", + }, + }, + [NavigatorName.SendFunds]: { + screens: { + /** + * @params ?currency: string + * ie: "ledgerlive://send?currency=bitcoin" will open the prefilled search account in the send flow + */ + [ScreenName.SendCoin]: "send", + }, + }, + [NavigatorName.ExchangeBuyFlow]: { + screens: { + /** + * @params currency: string + * ie: "ledgerlive://buy/bitcoin" -> will redirect to the prefilled search currency in the buy crypto flow + */ + [ScreenName.ExchangeSelectCurrency]: "buy/:currency", + }, + }, + /** + * ie: "ledgerlive://buy" -> will redirect to the main exchange page + */ + [NavigatorName.Exchange]: { + initialRouteName: "buy", + screens: { + [ScreenName.ExchangeBuy]: "buy", + [ScreenName.Coinify]: "buy/coinify", + }, + }, + /** + * ie: "ledgerlive://swap" -> will redirect to the main swap page + */ + [NavigatorName.Swap]: "swap", + [NavigatorName.Settings]: { + initialRouteName: [ScreenName.SettingsScreen], + screens: { + /** + * ie: "ledgerlive://settings/experimental" -> will redirect to the experimental settings panel + */ + [ScreenName.SettingsScreen]: "settings", + [ScreenName.GeneralSettings]: "settings/general", + [ScreenName.AccountsSettings]: "settings/accounts", + [ScreenName.AboutSettings]: "settings/about", + [ScreenName.HelpSettings]: "settings/help", + [ScreenName.ExperimentalSettings]: "settings/experimental", + [ScreenName.DeveloperSettings]: "settings/developer", + }, + }, + }, + }, +}; + +// Deep linking screens config for the onboarding, always available +// WIP: not final deep links path/name +const onboardingLinkingConfigScreens = { + [NavigatorName.BaseOnboarding]: { + screens: { + [NavigatorName.SyncOnboarding]: { + screens: { + /** + * ie: "ledgerlive://onboarding/sync -> will redirect to the synchronous onboarding + */ + [ScreenName.SyncOnboardingWelcome]: "onboarding/sync", + }, + }, + [NavigatorName.Onboarding]: { + screens: { + /** + * ie: "ledgerlive://onboarding/sync -> will redirect to select device during onboarding + */ + [ScreenName.OnboardingDeviceSelection]: "onboarding/select", }, }, }, @@ -366,10 +391,15 @@ const DeepLinkingNavigator = ({ children }: { children: React$Node }) => { const linking = useMemo( () => ({ ...linkingOptions, - enabled: - hasCompletedOnboarding && - wcContext.initDone && - !wcContext.session.session, + config: { + screens: { + ...onboardingLinkingConfigScreens, + ...(hasCompletedOnboarding + ? alreadyOnboardedLinkingConfigScreens + : {}), + }, + }, + enabled: wcContext.initDone && !wcContext.session.session, }), [hasCompletedOnboarding, wcContext.initDone, wcContext.session.session], ); diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index 5f220f4c6a3e..bad431b82b2f 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -887,6 +887,7 @@ "nanoS": "Nano S", "nanoSP": "Nano S Plus", "nanoX": "Nano X", + "nanoFTS": "Nano FTS", "blue": "Blue", "chooseDevice": "Choose your device" }, diff --git a/apps/ledger-live-mobile/src/screens/Onboarding/steps/deviceSelection.tsx b/apps/ledger-live-mobile/src/screens/Onboarding/steps/deviceSelection.tsx index 949582de1b68..aaf610bc4d12 100644 --- a/apps/ledger-live-mobile/src/screens/Onboarding/steps/deviceSelection.tsx +++ b/apps/ledger-live-mobile/src/screens/Onboarding/steps/deviceSelection.tsx @@ -3,15 +3,20 @@ import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; import { useNavigation } from "@react-navigation/native"; import { TrackScreen } from "../../../analytics"; -import nanoS from "../assets/nanoS"; -import nanoSP from "../assets/nanoSP"; -import nanoX from "../assets/nanoX"; -import { ScreenName } from "../../../const"; +import nanoSSvg from "../assets/nanoS"; +import nanoSPSvg from "../assets/nanoSP"; +import nanoXSvg from "../assets/nanoX"; +import { ScreenName, NavigatorName } from "../../../const"; import OnboardingView from "../OnboardingView"; import StyledStatusBar from "../../../components/StyledStatusBar"; import ChoiceCard from "../../../components/ChoiceCard"; -const devices = [nanoX, nanoSP, nanoS]; +const nanoX = { SvgDevice: nanoXSvg, id: "nanoX" }; +const nanoS = { SvgDevice: nanoSSvg, id: "nanoS" }; +const nanoSP = { SvgDevice: nanoSPSvg, id: "nanoSP" }; +const nanoFTS = { SvgDevice: nanoXSvg, id: "nanoFTS" }; + +const devices = [nanoX, nanoSP, nanoS, nanoFTS]; function OnboardingStepDeviceSelection() { const navigation = useNavigation(); @@ -19,11 +24,20 @@ function OnboardingStepDeviceSelection() { const { colors } = useTheme(); const next = (deviceModelId: string) => { - // TODO: FIX @react-navigation/native using Typescript - // @ts-ignore next-line - navigation.navigate(ScreenName.OnboardingUseCase, { - deviceModelId, - }); + if (deviceModelId === "nanoFTS") { + // TODO: fix navigation typescript by adding a type def RootStackParamList to the createStackNavigator + // See: https://reactnavigation.org/docs/typescript/#type-checking-the-navigator + // @ts-ignore next-line + navigation.navigate(NavigatorName.SyncOnboarding, { + screen: ScreenName.SyncOnboardingWelcome, + }); + } else { + // TODO: FIX @react-navigation/native using Typescript + // @ts-ignore next-line + navigation.navigate(ScreenName.OnboardingUseCase, { + deviceModelId, + }); + } }; return ( @@ -32,18 +46,18 @@ function OnboardingStepDeviceSelection() { title={t("onboarding.stepSelectDevice.title")} > - {devices.map(Device => ( + {devices.map(device => ( next(Device.id)} - subTitle={t(`onboarding.stepSelectDevice.${Device.id}`)} + eventProperties={{ id: device.id }} + testID={`Onboarding Device - Selection|${device.id}`} + onPress={() => next(device.id)} + subTitle={t(`onboarding.stepSelectDevice.${device.id}`)} subTitleProps={{ variant: "h2", color: "neutral.c100" }} title="Ledger" titleProps={{ variant: "small", color: "neutral.c70" }} - Image={} + Image={} /> ))} diff --git a/apps/ledger-live-mobile/src/screens/PairDevices/index.js b/apps/ledger-live-mobile/src/screens/PairDevices/index.js index 86036828a884..28b061b6e4a2 100644 --- a/apps/ledger-live-mobile/src/screens/PairDevices/index.js +++ b/apps/ledger-live-mobile/src/screens/PairDevices/index.js @@ -4,8 +4,11 @@ import { StyleSheet } from "react-native"; import SafeAreaView from "react-native-safe-area-view"; import { useDispatch, useSelector } from "react-redux"; import { timeout, tap } from "rxjs/operators"; +import { from } from "rxjs"; import getDeviceInfo from "@ledgerhq/live-common/lib/hw/getDeviceInfo"; import getDeviceName from "@ledgerhq/live-common/lib/hw/getDeviceName"; +import getVersion from "@ledgerhq/live-common/lib/hw/getVersion"; +import { withDevice } from "@ledgerhq/live-common/lib/hw/deviceAccess"; import { listApps } from "@ledgerhq/live-common/lib/apps/hw"; import type { DeviceModelId } from "@ledgerhq/devices"; import { delay } from "@ledgerhq/live-common/lib/promise"; @@ -28,6 +31,7 @@ import Paired from "./Paired"; import Scanning from "./Scanning"; import ScanningTimeout from "./ScanningTimeout"; import RenderError from "./RenderError"; +import { ScreenName, NavigatorName } from "../../const"; type Props = { navigation: any, @@ -44,6 +48,8 @@ type PairDevicesProps = { type RouteParams = { onDone?: (device: Device) => void, + onDoneNavigateTo: string, + onlySelectDeviceWithoutFullAppPairing?: Boolean, }; type BleDevice = { @@ -110,7 +116,26 @@ function PairDevicesInner({ navigation, route }: Props) { modelId: "nanoX", wired: false, }; + + // Pairing state for a known or unknown bluetooth device dispatch({ type: "pairing", payload: device }); + + if (route.params?.onlySelectDeviceWithoutFullAppPairing) { + // Sends a first request to trigger the native bluetooth pairing step + // (if the device is unknown to the phone) and make sure that the device + // is correctly paired to the phone + try { + await withDevice(device.deviceId)(t => + from(getVersion(t)), + ).toPromise(); + } catch (_) { + // Silently swwallowing error, we only want to trigger the native ble pairing step + } + + onDone(device); + return; + } + try { const transport = await TransportBLE.open(bleDevice); if (unmounted.current) return; @@ -204,8 +229,17 @@ function PairDevicesInner({ navigation, route }: Props) { const onDone = useCallback( (device: Device) => { - navigation.goBack(); - route.params?.onDone?.(device); + // To avoid passing a onDone function param that is not serializable + if (route.params?.onDoneNavigateTo === ScreenName.SyncOnboardingWelcome) { + console.log("PairDevices: ๐Ÿฆฎ navigate directly to SyncOnboarding"); + navigation.navigate(NavigatorName.SyncOnboarding, { + screen: ScreenName.SyncOnboardingWelcome, + params: { pairedDevice: device }, + }); + } else { + navigation.goBack(); + route.params?.onDone?.(device); + } }, [navigation, route], ); diff --git a/apps/ledger-live-mobile/src/screens/SyncOnboarding/index.tsx b/apps/ledger-live-mobile/src/screens/SyncOnboarding/index.tsx new file mode 100644 index 000000000000..21994815dfc9 --- /dev/null +++ b/apps/ledger-live-mobile/src/screens/SyncOnboarding/index.tsx @@ -0,0 +1,159 @@ +import React, { useEffect, useState, useCallback } from "react"; +import type { ReactElement } from "react"; +import type { StackScreenProps } from "@react-navigation/stack"; +import { Flex, Text } from "@ledgerhq/native-ui"; +import type { OnboardingState } from "@ledgerhq/live-common/lib/hw/extractOnboardingState"; +import { OnboardingStep } from "@ledgerhq/live-common/lib/hw/extractOnboardingState"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import { useOnboardingStatePolling } from "@ledgerhq/live-common/lib/onboarding/hooks/useOnboardingStatePolling"; +import styled, { useTheme } from "styled-components/native"; +import { ScreenName } from "../../const"; +import type { SyncOnboardingStackParamList } from "../../components/RootNavigator/SyncOnboardingNavigator"; +import Alert from "../../components/Alert"; + +const pollingPeriodMs = 1000; + +type Props = StackScreenProps< + SyncOnboardingStackParamList, + "SyncOnboardingWelcome" +>; + +const AlertView = styled.View` + padding: 20px 10px 0 10px; +`; + +export const SyncOnboarding = ({ navigation, route }: Props): ReactElement => { + const { colors } = useTheme(); + const [device, setDevice] = useState(null); + const [stepIndex, setStepIndex] = useState(0); + + const { onboardingState, allowedError, fatalError } = useOnboardingStatePolling({ device, pollingPeriodMs }); + + const { pairedDevice } = route.params; + + // When reaching the synchronized onboarding, the device could already + // be BLE paired to the phone and at the same time not known by LLM, + // it does not matter. + // So as long as the device is not onboarded, the LLM will ask for a pairing. + useEffect(() => { + console.log("SyncOnboarding: navigate to pairDevices"); + + // @ts-expect-error navigation issue + navigation.navigate(ScreenName.PairDevices, { + onlySelectDeviceWithoutFullAppPairing: true, + onDoneNavigateTo: ScreenName.SyncOnboardingWelcome, + }); + }, [navigation]); + + const handleOnPaired = useCallback((pairedDevice: Device) => { + console.log( + `SyncOnboarding: handleOnPaired ${JSON.stringify(pairedDevice)}`, + ); + + setDevice(pairedDevice); + }, []); + + // Triggered when a new paired device is passed when navigating to this screen + // It avoids having a callback function passed to the PairDevices screen + useEffect(() => { + if (pairedDevice) { + handleOnPaired(pairedDevice); + } + }, [pairedDevice, handleOnPaired]); + + // WIP: only for demo + const onboardingSteps = [ + "Pairing device", + "Welcome Page", + "Setup choice", + "Setting up pin", + `Writing seed words ${ + onboardingState && onboardingState.currentOnboardingStep === OnboardingStep.NewDevice + ? onboardingState.currentSeedWordIndex + 1 + : "" + }`, + `${ + onboardingState?.currentOnboardingStep === OnboardingStep.RestoreSeed ? "Restoring seed ๐Ÿ‡ " : "Confirming seed words " + } ${ + onboardingState && (onboardingState.currentOnboardingStep === OnboardingStep.NewDeviceConfirming || onboardingState.currentOnboardingStep === OnboardingStep.RestoreSeed) + ? onboardingState.currentSeedWordIndex + 1 + : "" + }`, + "Safety Warning", + "Ready" + ]; + + // Updates UI step index from the onboarding state + useEffect(() => { + if (!device) { + // No device is paired yet + setStepIndex(0); + return; + } + + // No change if the onboardingState is null + if (!onboardingState) { + return; + } + + switch(onboardingState?.currentOnboardingStep) { + case OnboardingStep.WelcomeScreen: + setStepIndex(1); + break; + case OnboardingStep.SetupChoice: + setStepIndex(2); + break; + case OnboardingStep.Pin: + setStepIndex(3); + break; + case OnboardingStep.NewDevice: + setStepIndex(4); + break; + case OnboardingStep.NewDeviceConfirming: + case OnboardingStep.RestoreSeed: + setStepIndex(5); + break; + case OnboardingStep.SafetyWarning: + setStepIndex(6); + break; + case OnboardingStep.Ready: + setStepIndex(7); + break; + default: + setStepIndex(0); + } + }, [onboardingState, device]); + + return ( + <> + {allowedError ? ( + + + {allowedError.message} + + + ) : null} + {fatalError ? ( + + + {fatalError.message} + + + ) : null} + + {onboardingSteps.map((label, i) => ( + + {label} {stepIndex === i && stepIndex < 7 ? "โœ๏ธ" : stepIndex > i || stepIndex === 7 ? "โœ…" : "๐Ÿฆง"} + + ))} + + + ); +}; diff --git a/libs/ledger-live-common/src/hw/extractOnboardingState.test.ts b/libs/ledger-live-common/src/hw/extractOnboardingState.test.ts new file mode 100644 index 000000000000..f5b98e02d098 --- /dev/null +++ b/libs/ledger-live-common/src/hw/extractOnboardingState.test.ts @@ -0,0 +1,251 @@ +import { + extractOnboardingState, + OnboardingStep, +} from "./extractOnboardingState"; + +describe("@hw/extractOnboardingState", () => { + describe("extractOnboardingState", () => { + describe("When the flag bytes are incorrect", () => { + it("should throw an error", () => { + const incompleteFlagsBytes = Buffer.from([0, 0]); + // DeviceExtractOnboardingStateError is not of type Error, + // so cannot check in toThrow(DeviceExtractOnboardingStateError) + expect(() => extractOnboardingState(incompleteFlagsBytes)).toThrow(); + }); + }); + + describe("When the device is onboarded", () => { + it("should return a device state that is onboarded", () => { + const flagsBytes = Buffer.from([1 << 2, 0, 0, 0]); + + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.isOnboarded).toBe(true); + }); + }); + + describe("When the device is in recovery mode", () => { + it("should return a device state that is in recovery mode", () => { + const flagsBytes = Buffer.from([1, 0, 0, 0]); + + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.isInRecoveryMode).toBe(true); + }); + }); + + describe("When the device is not onboarded and in normal mode", () => { + let flagsBytes: Buffer; + + beforeEach(() => { + flagsBytes = Buffer.from([0, 0, 0, 0]); + }); + + describe("and the user is on the welcome screen", () => { + beforeEach(() => { + flagsBytes[3] = 0; + }); + + it("should return an onboarding step that is set at the welcome screen", () => { + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.currentOnboardingStep).toBe( + OnboardingStep.WelcomeScreen + ); + }); + }); + + describe("and the user is choosing what kind of setup they want", () => { + beforeEach(() => { + flagsBytes[3] = 1; + }); + + it("should return an onboarding step that is set at the setup choice", () => { + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.currentOnboardingStep).toBe( + OnboardingStep.SetupChoice + ); + }); + }); + + describe("and the user is setting their pin", () => { + beforeEach(() => { + flagsBytes[3] = 2; + }); + + it("should return an onboarding step that is set at setting the pin", () => { + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.currentOnboardingStep).toBe( + OnboardingStep.Pin + ); + }); + }); + + describe("and the user is generating a new seed", () => { + describe("and the seed phrase type is set to 24 words", () => { + beforeEach(() => { + // 24-words seed + flagsBytes[2] |= 0 << 5; + }); + + it("should return a device state with the correct seed phrase type", () => { + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.seedPhraseType).toBe("24-words"); + }); + + describe("and the user is writing the seed word i", () => { + beforeEach(() => { + flagsBytes[3] = 3; + }); + + it("should return an onboarding step that is set at writting the seed phrase", () => { + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.currentOnboardingStep).toBe( + OnboardingStep.NewDevice + ); + }); + + it("should return a device state with the index of the current seed word being written", () => { + const byte3 = flagsBytes[2]; + for (let wordIndex = 0; wordIndex < 24; wordIndex++) { + flagsBytes[2] = byte3 | wordIndex; + + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.currentSeedWordIndex).toBe(wordIndex); + } + }); + }); + + describe("and the user is confirming the seed word i", () => { + beforeEach(() => { + flagsBytes[3] = 4; + }); + + it("should return an onboarding step that is set at confirming the seed phrase", () => { + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.currentOnboardingStep).toBe( + OnboardingStep.NewDeviceConfirming + ); + }); + + it("should return a device state with the index of the current seed word being confirmed", () => { + const byte3 = flagsBytes[2]; + for (let wordIndex = 0; wordIndex < 24; wordIndex++) { + flagsBytes[2] = byte3 | wordIndex; + + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.currentSeedWordIndex).toBe(wordIndex); + } + }); + }); + }); + }); + + describe("and the user is recovering a seed", () => { + describe("and the seed phrase type is set to X words", () => { + it("should return a device state with the correct seed phrase type", () => { + const byte3 = flagsBytes[2]; + + // 24-words + flagsBytes[2] = byte3 | (0 << 5); + let onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.seedPhraseType).toBe("24-words"); + + // 18-words + flagsBytes[2] = byte3 | (1 << 5); + onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.seedPhraseType).toBe("18-words"); + + // 12-words + flagsBytes[2] = byte3 | (2 << 5); + onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.seedPhraseType).toBe("12-words"); + }); + + describe("and the user is confirming (seed recovery) the seed word i", () => { + beforeEach(() => { + // 24-words seed + flagsBytes[2] |= 0 << 5; + + flagsBytes[3] = 5; + }); + + it("should return an onboarding step that is set at confirming the restored seed phrase", () => { + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.currentOnboardingStep).toBe( + OnboardingStep.RestoreSeed + ); + }); + + it("should return a device state with the index of the current seed word being confirmed", () => { + const byte3 = flagsBytes[2]; + for (let wordIndex = 0; wordIndex < 24; wordIndex++) { + flagsBytes[2] = byte3 | wordIndex; + + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.currentSeedWordIndex).toBe(wordIndex); + } + }); + }); + }); + }); + + describe("and the user is on the safety warning screen", () => { + beforeEach(() => { + flagsBytes[3] = 6; + }); + + it("should return an onboarding step that is set at the safety warning screen", () => { + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.currentOnboardingStep).toBe( + OnboardingStep.SafetyWarning + ); + }); + }); + + describe("and the user finished the onboarding process", () => { + beforeEach(() => { + flagsBytes[3] = 7; + }); + + it("should return an onboarding step that is set at ready", () => { + const onboardingState = extractOnboardingState(flagsBytes); + + expect(onboardingState).not.toBeNull(); + expect(onboardingState?.currentOnboardingStep).toBe( + OnboardingStep.Ready + ); + }); + }); + }); + }); +}); diff --git a/libs/ledger-live-common/src/hw/extractOnboardingState.ts b/libs/ledger-live-common/src/hw/extractOnboardingState.ts new file mode 100644 index 000000000000..44d90385cda5 --- /dev/null +++ b/libs/ledger-live-common/src/hw/extractOnboardingState.ts @@ -0,0 +1,92 @@ +import { DeviceExtractOnboardingStateError } from "@ledgerhq/errors"; +import { SeedPhraseType } from "../types/manager"; + +const onboardingFlagsBytesLength = 4; + +const onboardedMask = 0x04; +const inRecoveryModeMask = 0x01; +const seedPhraseTypeMask = 0x60; +const seedPhraseTypeFlagOffset = 5; +const currentSeedWordIndexMask = 0x1f; + +const fromBitsToSeedPhraseType = new Map([ + [0, SeedPhraseType.TwentyFour], + [1, SeedPhraseType.Eighteen], + [2, SeedPhraseType.Twelve], +]); + +export enum OnboardingStep { + WelcomeScreen = "WELCOME_SCREEN", + SetupChoice = "SETUP_CHOICE", + Pin = "PIN", + NewDevice = "NEW_DEVICE", // path "new device" & currentSeedWordIndex available + NewDeviceConfirming = "NEW_DEVICE_CONFIRMING", // path "new device" & currentSeedWordIndex available + RestoreSeed = "RESTORE_SEED", // path "restore seed" & currentSeedWordIndex available + SafetyWarning = "SAFETY WARNING", + Ready = "READY", +} + +const fromBitsToOnboardingStep = new Map([ + [0, OnboardingStep.WelcomeScreen], + [1, OnboardingStep.SetupChoice], + [2, OnboardingStep.Pin], + [3, OnboardingStep.NewDevice], + [4, OnboardingStep.NewDeviceConfirming], + [5, OnboardingStep.RestoreSeed], + [6, OnboardingStep.SafetyWarning], + [7, OnboardingStep.Ready], +]); + +export type OnboardingState = { + // Device not yet onboarded otherwise + isOnboarded: boolean; + // In normal mode otherwise + isInRecoveryMode: boolean; + + seedPhraseType: SeedPhraseType; + + currentOnboardingStep: OnboardingStep; + currentSeedWordIndex: number; +}; + +export const extractOnboardingState = (flagsBytes: Buffer): OnboardingState => { + if (!flagsBytes || flagsBytes.length < onboardingFlagsBytesLength) { + throw new DeviceExtractOnboardingStateError( + "Incorrect onboarding flags bytes" + ); + } + + const isOnboarded = Boolean(flagsBytes[0] & onboardedMask); + const isInRecoveryMode = Boolean(flagsBytes[0] & inRecoveryModeMask); + + const seedPhraseTypeBits = + (flagsBytes[2] & seedPhraseTypeMask) >> seedPhraseTypeFlagOffset; + const seedPhraseType = fromBitsToSeedPhraseType.get(seedPhraseTypeBits); + + if (!seedPhraseType) { + throw new DeviceExtractOnboardingStateError( + "Incorrect onboarding bits for the seed phrase type" + ); + } + + const currentOnboardingStepBits = flagsBytes[3]; + const currentOnboardingStep = fromBitsToOnboardingStep.get( + currentOnboardingStepBits + ); + + if (!currentOnboardingStep) { + throw new DeviceExtractOnboardingStateError( + "Incorrect onboarding bits for the current onboarding step" + ); + } + + const currentSeedWordIndex = flagsBytes[2] & currentSeedWordIndexMask; + + return { + isOnboarded, + isInRecoveryMode, + seedPhraseType, + currentOnboardingStep, + currentSeedWordIndex, + }; +}; diff --git a/libs/ledger-live-common/src/hw/getOnboardingStatePolling.test.ts b/libs/ledger-live-common/src/hw/getOnboardingStatePolling.test.ts new file mode 100644 index 000000000000..5c987a76d505 --- /dev/null +++ b/libs/ledger-live-common/src/hw/getOnboardingStatePolling.test.ts @@ -0,0 +1,223 @@ +import { getOnboardingStatePolling } from "./getOnboardingStatePolling"; +import { from, Subscription, TimeoutError } from "rxjs"; +import * as rxjsOperators from "rxjs/operators"; +import { DeviceModelId } from "@ledgerhq/devices"; +import Transport from "@ledgerhq/hw-transport"; +import { + DeviceExtractOnboardingStateError, + DisconnectedDevice, +} from "@ledgerhq/errors"; +import { withDevice } from "./deviceAccess"; +import getVersion from "./getVersion"; +import { + extractOnboardingState, + OnboardingState, + OnboardingStep, +} from "./extractOnboardingState"; +import { SeedPhraseType } from "../types/manager"; + +jest.mock("./deviceAccess"); +jest.mock("./getVersion"); +jest.mock("./extractOnboardingState"); +jest.mock("@ledgerhq/hw-transport"); +jest.useFakeTimers(); + +const aDevice = { + deviceId: "DEVICE_ID_A", + deviceName: "DEVICE_NAME_A", + modelId: DeviceModelId.nanoFTS, + wired: false, +}; + +// As extractOnboardingState is mocked, the firmwareInfo +// returned by getVersion does not matter +const aFirmwareInfo = { + isBootloader: false, + rawVersion: "", + targetId: 0, + mcuVersion: "", + flags: Buffer.from([]), +}; + +const pollingPeriodMs = 1000; + +const mockedGetVersion = jest.mocked(getVersion); +// const mockedWithDevice = withDevice as jest.Mock; +const mockedWithDevice = jest.mocked(withDevice); +mockedWithDevice.mockReturnValue((job) => from(job(new Transport()))); + +const mockedExtractOnboardingState = jest.mocked(extractOnboardingState); + +describe("getOnboardingStatePolling", () => { + let anOnboardingState: OnboardingState; + let onboardingStatePollingSubscription: Subscription | null; + + beforeEach(() => { + anOnboardingState = { + isOnboarded: false, + isInRecoveryMode: false, + seedPhraseType: SeedPhraseType.TwentyFour, + currentSeedWordIndex: 0, + currentOnboardingStep: OnboardingStep.NewDevice, + }; + }); + + afterEach(() => { + mockedGetVersion.mockClear(); + mockedExtractOnboardingState.mockClear(); + jest.clearAllTimers(); + onboardingStatePollingSubscription?.unsubscribe(); + }); + + describe("When a communication error occurs while fetching the device state", () => { + describe("and when the error is allowed and thrown before the defined timeout", () => { + it("should update the onboarding state to null and keep track of the allowed error", (done) => { + mockedGetVersion.mockRejectedValue( + new DisconnectedDevice("An allowed error") + ); + mockedExtractOnboardingState.mockReturnValue(anOnboardingState); + + const device = aDevice; + + getOnboardingStatePolling({ + deviceId: device.deviceId, + pollingPeriodMs, + }).subscribe({ + next: (value) => { + expect(value.onboardingState).toBeNull(); + expect(value.allowedError).toBeInstanceOf(DisconnectedDevice); + done(); + }, + }); + + jest.runOnlyPendingTimers(); + }); + }); + + describe("and when a timeout occurred before the error (or the fetch took too long)", () => { + it("should update the allowed error value to notify the consumer", (done) => { + mockedGetVersion.mockResolvedValue(aFirmwareInfo); + mockedExtractOnboardingState.mockReturnValue(anOnboardingState); + + const device = aDevice; + + getOnboardingStatePolling({ + deviceId: device.deviceId, + pollingPeriodMs, + }).subscribe({ + next: (value) => { + expect(value.onboardingState).toBeNull(); + expect(value.allowedError).toBeInstanceOf(TimeoutError); + done(); + }, + }); + + // Waits more than the timeout + jest.advanceTimersByTime(pollingPeriodMs + 1); + }); + }); + + describe("and when the error is fatal and thrown before the defined timeout", () => { + it("should notify the consumer that a unallowed error occurred", (done) => { + mockedGetVersion.mockRejectedValue(new Error("Unknown error")); + + const device = aDevice; + + getOnboardingStatePolling({ + deviceId: device.deviceId, + pollingPeriodMs, + }).subscribe({ + error: (error) => { + expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe("Unknown error"); + done(); + }, + }); + + jest.runOnlyPendingTimers(); + }); + }); + }); + + describe("When the fetched device state is incorrect", () => { + it("should return a null onboarding state, and keep track of the extract error", (done) => { + mockedGetVersion.mockResolvedValue(aFirmwareInfo); + mockedExtractOnboardingState.mockImplementation(() => { + throw new DeviceExtractOnboardingStateError( + "Some incorrect device info" + ); + }); + + const device = aDevice; + + onboardingStatePollingSubscription = getOnboardingStatePolling({ + deviceId: device.deviceId, + pollingPeriodMs, + }).subscribe({ + next: (value) => { + expect(value.onboardingState).toBeNull(); + expect(value.allowedError).toBeInstanceOf( + DeviceExtractOnboardingStateError + ); + done(); + }, + }); + + jest.runOnlyPendingTimers(); + }); + }); + + describe("When polling returns a correct device state", () => { + it("should return a correct onboarding state", (done) => { + mockedGetVersion.mockResolvedValue(aFirmwareInfo); + mockedExtractOnboardingState.mockReturnValue(anOnboardingState); + + const device = aDevice; + + onboardingStatePollingSubscription = getOnboardingStatePolling({ + deviceId: device.deviceId, + pollingPeriodMs, + }).subscribe({ + next: (value) => { + expect(value.onboardingState).toEqual(anOnboardingState); + expect(value.allowedError).toBeNull(); + done(); + }, + error: (error) => { + done(error); + }, + }); + + jest.runOnlyPendingTimers(); + }); + + it("should poll a new onboarding state after the defined period of time", (done) => { + mockedGetVersion.mockResolvedValue(aFirmwareInfo); + mockedExtractOnboardingState.mockReturnValue(anOnboardingState); + + const device = aDevice; + + // Did not manage to test that the polling is repeated by using jest's fake timer + // and advanceTimersByTime method or equivalent. + // Hacky test: spy on the repeat operator to see if it has been called. + const spiedRepeat = jest.spyOn(rxjsOperators, "repeat"); + + onboardingStatePollingSubscription = getOnboardingStatePolling({ + deviceId: device.deviceId, + pollingPeriodMs, + }).subscribe({ + next: (value) => { + expect(value.onboardingState).toEqual(anOnboardingState); + expect(value.allowedError).toBeNull(); + expect(spiedRepeat).toHaveBeenCalledTimes(1); + done(); + }, + error: (error) => { + done(error); + }, + }); + + jest.runOnlyPendingTimers(); + }); + }); +}); diff --git a/libs/ledger-live-common/src/hw/getOnboardingStatePolling.ts b/libs/ledger-live-common/src/hw/getOnboardingStatePolling.ts new file mode 100644 index 000000000000..0c7736abccf8 --- /dev/null +++ b/libs/ledger-live-common/src/hw/getOnboardingStatePolling.ts @@ -0,0 +1,186 @@ +import { + from, + merge, + partition, + of, + throwError, + Observable, + TimeoutError, +} from "rxjs"; +import { map, catchError, repeat, first, timeout } from "rxjs/operators"; +import getVersion from "./getVersion"; +import { withDevice } from "./deviceAccess"; +import { + TransportStatusError, + DeviceOnboardingStatePollingError, + DeviceExtractOnboardingStateError, + DisconnectedDevice, + CantOpenDevice, +} from "@ledgerhq/errors"; +import { FirmwareInfo } from "../types/manager"; +import { + extractOnboardingState, + OnboardingState, +} from "./extractOnboardingState"; + +export type OnboardingStatePollingResult = { + onboardingState: OnboardingState | null; + allowedError: Error | null; +}; + +export const getOnboardingStatePolling = ({ + deviceId, + pollingPeriodMs, +}: { + deviceId: string; + pollingPeriodMs: number; +}): Observable => { + console.log("๐ŸŽ GOING TO START"); + + // Could just be a boolean: firstRun ? + let i = 0; + + const delayedOnboardingStateOnce$: Observable = + new Observable((subscriber) => { + console.log(`SyncOnboarding: โ–ถ๏ธ Polling from Observable ${i}`); + const delayMs = i > 0 ? pollingPeriodMs : 0; + console.log(`SyncOnboarding: polling delayed by ${delayMs} ms`); + i++; + + const getOnboardingStateOnce = () => { + const firmwareInfoOrAllowedError$ = withDevice(deviceId)((t) => + from(getVersion(t)) + ).pipe( + // TODO: choose timeout ms value. For now = polling period + timeout(pollingPeriodMs), // Throws a TimeoutError + first(), + catchError((error: any) => { + if (isAllowedOnboardingStatePollingError(error)) { + // Pushes the error to the next step to be processed (no retry from the beginning) + return of(error); + } + + console.log( + `SyncOnboarding: ๐Ÿ’ฅ Fatal Error ${error} -> ${JSON.stringify( + error + )}` + ); + return throwError(error); + }) + ); + + // If an error is catched previously, and this error is "allowed", + // the value from the observable is not a FirmwareInfo but an Error + const [firmwareInfo$, allowedError$] = partition( + firmwareInfoOrAllowedError$, + // TS cannot infer correctly the value given to RxJS partition + (value: any) => Boolean(value?.flags) + ); + + const onboardingStateFromFirmwareInfo$ = firmwareInfo$.pipe( + map((firmwareInfo: FirmwareInfo) => { + console.log( + `SyncOnboarding: โ™ง MAP got firmwareInfo: ${JSON.stringify( + firmwareInfo + )}` + ); + + let onboardingState: OnboardingState | null = null; + + try { + onboardingState = extractOnboardingState(firmwareInfo.flags); + } catch (error) { + console.log( + `SyncOnboarding: extract onboarding error ${JSON.stringify( + error + )}` + ); + if (error instanceof DeviceExtractOnboardingStateError) { + return { + onboardingState: null, + allowedError: + error as typeof DeviceExtractOnboardingStateError, + }; + } else { + return { + onboardingState: null, + allowedError: new DeviceOnboardingStatePollingError( + "SyncOnboarding: Unknown error while extracting the onboarding state" + ), + }; + } + } + return { onboardingState, allowedError: null }; + }) + ); + + // Handles the case of an (allowed) Error value + const onboardingStateFromAllowedError$ = allowedError$.pipe( + map((allowedError: Error) => { + console.log( + `SyncOnboarding: โ™ง MAP got accepted error: ${JSON.stringify( + allowedError + )}` + ); + return { + onboardingState: null, + allowedError: allowedError, + }; + }) + ); + + return merge( + onboardingStateFromFirmwareInfo$, + onboardingStateFromAllowedError$ + ); + }; + + // Delays the fetch of the onboarding state + setTimeout(() => { + getOnboardingStateOnce().subscribe({ + next: (value: OnboardingStatePollingResult) => { + subscriber.next(value); + }, + error: (error: any) => { + subscriber.error(error); + }, + complete: () => subscriber.complete(), + }); + }, delayMs); + }); + + return delayedOnboardingStateOnce$.pipe(repeat()); +}; + +export const isAllowedOnboardingStatePollingError = (error: Error): boolean => { + // Timeout error thrown by rxjs's timeout + if (error && error instanceof TimeoutError) { + console.log(`SyncOnboarding: timeout error โŒ›๏ธ ${JSON.stringify(error)}`); + return true; + } + + if (error && error instanceof DisconnectedDevice) { + console.log( + `SyncOnboarding: disconnection error ๐Ÿ”Œ ${JSON.stringify(error)}` + ); + return true; + } + + if (error && error instanceof CantOpenDevice) { + console.log( + `SyncOnboarding: cannot open device error ๐Ÿ”Œ ${JSON.stringify(error)}` + ); + return true; + } + + if ( + error && + error instanceof TransportStatusError + // error.statusCode === 0x6d06 + ) { + console.log(`SyncOnboarding: 0x6d06 error ๐Ÿ”จ ${JSON.stringify(error)}`); + return true; + } + + return false; +}; diff --git a/libs/ledger-live-common/src/onboarding/hooks/useOnboardingStatePolling.test.ts b/libs/ledger-live-common/src/onboarding/hooks/useOnboardingStatePolling.test.ts new file mode 100644 index 000000000000..ebb3063c437a --- /dev/null +++ b/libs/ledger-live-common/src/onboarding/hooks/useOnboardingStatePolling.test.ts @@ -0,0 +1,304 @@ +import { timer, of } from "rxjs"; +import { map, delayWhen } from "rxjs/operators"; +import { renderHook, act } from "@testing-library/react-hooks"; +import { DeviceModelId } from "@ledgerhq/devices"; +import { DisconnectedDevice } from "@ledgerhq/errors"; +import { useOnboardingStatePolling } from "./useOnboardingStatePolling"; +import { + OnboardingState, + OnboardingStep, +} from "../../hw/extractOnboardingState"; +import { SeedPhraseType } from "../../types/manager"; +import { getOnboardingStatePolling } from "../../hw/getOnboardingStatePolling"; + +jest.mock("../../hw/getOnboardingStatePolling"); +jest.useFakeTimers(); + +const aDevice = { + deviceId: "DEVICE_ID_A", + deviceName: "DEVICE_NAME_A", + modelId: DeviceModelId.nanoFTS, + wired: false, +}; + +const pollingPeriodMs = 1000; + +const mockedGetOnboardingStatePolling = jest.mocked(getOnboardingStatePolling); + +describe("useOnboardingStatePolling", () => { + let anOnboardingState: OnboardingState; + let aSecondOnboardingState: OnboardingState; + + beforeEach(() => { + anOnboardingState = { + isOnboarded: false, + isInRecoveryMode: false, + seedPhraseType: SeedPhraseType.TwentyFour, + currentSeedWordIndex: 0, + currentOnboardingStep: OnboardingStep.NewDevice, + }; + + aSecondOnboardingState = { + ...anOnboardingState, + currentOnboardingStep: OnboardingStep.NewDeviceConfirming, + }; + }); + + afterEach(() => { + mockedGetOnboardingStatePolling.mockClear(); + }); + + describe("When polling returns a correct device state", () => { + beforeEach(() => { + mockedGetOnboardingStatePolling.mockReturnValue( + of( + { + onboardingState: { ...anOnboardingState }, + allowedError: null, + }, + { + onboardingState: { ...aSecondOnboardingState }, + allowedError: null, + } + ).pipe( + delayWhen((_, index) => { + // "delay" or "delayWhen" piped to a streaming source, for ex the "of" operator, will not block the next + // Observable to be streamed. They return an Observable that delays the emission of the source Observable, + // but do not create a delay in-between each emission. That's why the delay is increased by multiplying by "index". + // "concatMap" could have been used to wait for the previous Observable to complete, but + // the "index" arg given to "delayWhen" would always be 0 + return timer(index * pollingPeriodMs); + }) + ) + ); + }); + + it("should update the onboarding state returned to the consumer", async () => { + const device = aDevice; + + const { result } = renderHook(() => + useOnboardingStatePolling({ device, pollingPeriodMs }) + ); + + await act(async () => { + jest.advanceTimersByTime(1); + }); + + expect(result.current.fatalError).toBeNull(); + expect(result.current.allowedError).toBeNull(); + expect(result.current.onboardingState).toEqual(anOnboardingState); + }); + + it("should fetch again the state at a defined frequency and update (if new) the onboarding state returned to the consumer", async () => { + const device = aDevice; + + const { result } = renderHook(() => + useOnboardingStatePolling({ device, pollingPeriodMs }) + ); + + await act(async () => { + jest.advanceTimersByTime(1); + }); + + expect(result.current.fatalError).toBeNull(); + expect(result.current.allowedError).toBeNull(); + expect(result.current.onboardingState).toEqual(anOnboardingState); + + // Next polling + await act(async () => { + jest.advanceTimersByTime(pollingPeriodMs); + }); + + expect(result.current.fatalError).toBeNull(); + expect(result.current.allowedError).toBeNull(); + expect(result.current.onboardingState).toEqual(aSecondOnboardingState); + }); + + describe("and when the hook consumer stops the polling", () => { + it("should stop the polling and stop fetching the device onboarding state", async () => { + const device = aDevice; + let stopPolling = false; + + const { result, rerender } = renderHook(() => + useOnboardingStatePolling({ device, pollingPeriodMs, stopPolling }) + ); + + await act(async () => { + jest.advanceTimersByTime(1); + }); + + // Everything is normal on the first run + expect(mockedGetOnboardingStatePolling).toHaveBeenCalledTimes(1); + expect(result.current.fatalError).toBeNull(); + expect(result.current.allowedError).toBeNull(); + expect(result.current.onboardingState).toEqual(anOnboardingState); + + // The consumer stops the polling + stopPolling = true; + rerender({ device, pollingPeriodMs, stopPolling }); + + await act(async () => { + // Waits as long as we want + jest.advanceTimersByTime(10 * pollingPeriodMs); + }); + + // While the hook was rerendered, it did not call a new time getOnboardingStatePolling + expect(mockedGetOnboardingStatePolling).toHaveBeenCalledTimes(1); + // And the state should stay the same (and not be aSecondOnboardingState) + expect(result.current.fatalError).toBeNull(); + expect(result.current.allowedError).toBeNull(); + expect(result.current.onboardingState).toEqual(anOnboardingState); + }); + }); + }); + + describe("When an allowed error occurs while polling the device state", () => { + beforeEach(() => { + mockedGetOnboardingStatePolling.mockReturnValue( + of( + { + onboardingState: { ...anOnboardingState }, + allowedError: null, + }, + { + onboardingState: null, + allowedError: new DisconnectedDevice("An allowed error"), + }, + { + onboardingState: { ...aSecondOnboardingState }, + allowedError: null, + } + ).pipe( + delayWhen((_, index) => { + return timer(index * pollingPeriodMs); + }) + ) + ); + }); + + it("should update the allowed error returned to the consumer, update the fatal error to null and keep the previous onboarding state", async () => { + const device = aDevice; + + const { result } = renderHook(() => + useOnboardingStatePolling({ device, pollingPeriodMs }) + ); + + await act(async () => { + jest.advanceTimersByTime(1); + }); + + // Everything is ok on the first run + expect(result.current.fatalError).toBeNull(); + expect(result.current.allowedError).toBeNull(); + expect(result.current.onboardingState).toEqual(anOnboardingState); + + await act(async () => { + jest.advanceTimersByTime(pollingPeriodMs); + }); + + expect(result.current.allowedError).toBeInstanceOf(DisconnectedDevice); + expect(result.current.fatalError).toBeNull(); + expect(result.current.onboardingState).toEqual(anOnboardingState); + }); + + it("should be able to recover once the allowed error is fixed and the onboarding state is updated", async () => { + const device = aDevice; + + const { result } = renderHook(() => + useOnboardingStatePolling({ device, pollingPeriodMs }) + ); + + await act(async () => { + jest.advanceTimersByTime(pollingPeriodMs + 1); + }); + + // Allowed error occured + expect(result.current.allowedError).toBeInstanceOf(DisconnectedDevice); + expect(result.current.fatalError).toBeNull(); + expect(result.current.onboardingState).toEqual(anOnboardingState); + + await act(async () => { + jest.advanceTimersByTime(pollingPeriodMs); + }); + + // Everything is ok on the next run + expect(result.current.fatalError).toBeNull(); + expect(result.current.allowedError).toBeNull(); + expect(result.current.onboardingState).toEqual(aSecondOnboardingState); + }); + }); + + describe("When a (fatal) error is thrown while polling the device state", () => { + const anOnboardingStateThatShouldNeverBeReached = { + ...aSecondOnboardingState, + }; + + beforeEach(() => { + mockedGetOnboardingStatePolling.mockReturnValue( + of( + { + onboardingState: { ...anOnboardingState }, + allowedError: null, + }, + { + onboardingState: { ...anOnboardingState }, + allowedError: null, + }, + { + // It should never be reached + onboardingState: { ...anOnboardingStateThatShouldNeverBeReached }, + allowedError: null, + } + ).pipe( + delayWhen((_, index) => { + return timer(index * pollingPeriodMs); + }), + map((value, index) => { + // Throws an error the second time + if (index === 1) { + throw new Error("An unallowed error"); + } + return value; + }) + ) + ); + }); + + it("should update the fatal error returned to the consumer, update the allowed error to null, keep the previous onboarding state and stop the polling", async () => { + const device = aDevice; + + const { result } = renderHook(() => + useOnboardingStatePolling({ device, pollingPeriodMs }) + ); + + await act(async () => { + jest.advanceTimersByTime(1); + }); + + // Everything is ok on the first run + expect(result.current.fatalError).toBeNull(); + expect(result.current.allowedError).toBeNull(); + expect(result.current.onboardingState).toEqual(anOnboardingState); + + await act(async () => { + jest.advanceTimersByTime(pollingPeriodMs); + }); + + // Fatal error on the second run + expect(result.current.allowedError).toBeNull(); + expect(result.current.fatalError).toBeInstanceOf(Error); + expect(result.current.onboardingState).toEqual(anOnboardingState); + + await act(async () => { + jest.advanceTimersByTime(pollingPeriodMs); + }); + + // The polling should have been stopped, and we never update the onboardingState + expect(result.current.allowedError).toBeNull(); + expect(result.current.fatalError).toBeInstanceOf(Error); + expect(result.current.onboardingState).not.toEqual( + anOnboardingStateThatShouldNeverBeReached + ); + }); + }); +}); diff --git a/libs/ledger-live-common/src/onboarding/hooks/useOnboardingStatePolling.ts b/libs/ledger-live-common/src/onboarding/hooks/useOnboardingStatePolling.ts new file mode 100644 index 000000000000..ab6b8ea3b917 --- /dev/null +++ b/libs/ledger-live-common/src/onboarding/hooks/useOnboardingStatePolling.ts @@ -0,0 +1,91 @@ +import { useState, useEffect } from "react"; +import { Subscription } from "rxjs"; +import type { Device } from "../../hw/actions/types"; +import { DeviceOnboardingStatePollingError } from "@ledgerhq/errors"; + +import type { OnboardingStatePollingResult } from "../../hw/getOnboardingStatePolling"; +import { getOnboardingStatePolling } from "../../hw/getOnboardingStatePolling"; +import { OnboardingState } from "../../hw/extractOnboardingState"; + +export type UseOnboardingStatePollingResult = OnboardingStatePollingResult & { + fatalError: Error | null; +}; + +// Polls the current device onboarding state, and notify the hook consumer of +// any allowed errors and fatal errors +export const useOnboardingStatePolling = ({ + device, + pollingPeriodMs, + stopPolling = false, +}: { + device: Device | null; + pollingPeriodMs: number; + stopPolling?: boolean; +}): UseOnboardingStatePollingResult => { + const [onboardingState, setOnboardingState] = + useState(null); + const [allowedError, setAllowedError] = useState(null); + const [fatalError, setFatalError] = useState(null); + + useEffect(() => { + let onboardingStatePollingSubscription: Subscription; + + // If stopPolling is updated and set to true, the useEffect hook will call its + // cleanup function (return) and the polling won't restart with the below condition + if (device && !stopPolling) { + console.log( + `SyncOnboarding: ๐Ÿง‘โ€๐Ÿ’ป new device: ${JSON.stringify(device)}` + ); + + onboardingStatePollingSubscription = getOnboardingStatePolling({ + deviceId: device.deviceId, + pollingPeriodMs, + }).subscribe({ + next: (onboardingStatePollingResult: OnboardingStatePollingResult) => { + console.log( + `SyncOnboarding: device version info ${JSON.stringify( + onboardingStatePollingResult + )}` + ); + + if (onboardingStatePollingResult) { + setFatalError(null); + setAllowedError(onboardingStatePollingResult.allowedError); + + // Does not update the onboarding state if an allowed error occurred + if (!onboardingStatePollingResult.allowedError) { + setOnboardingState(onboardingStatePollingResult.onboardingState); + } + } + }, + error: (error) => { + console.log( + `SyncOnboarding: error ending polling ${error} -> ${JSON.stringify({ + error, + })}` + ); + setAllowedError(null); + setFatalError( + new DeviceOnboardingStatePollingError( + `Error from: ${error?.name ?? error} ${error?.message}` + ) + ); + }, + }); + } + + return () => { + console.log("SyncOnboarding: cleaning up polling ๐Ÿงน"); + onboardingStatePollingSubscription?.unsubscribe(); + }; + }, [ + device, + pollingPeriodMs, + setOnboardingState, + setAllowedError, + setFatalError, + stopPolling, + ]); + + return { onboardingState, allowedError, fatalError }; +}; diff --git a/libs/ledger-live-common/src/types/manager.ts b/libs/ledger-live-common/src/types/manager.ts index 68f8f1f332c8..36edb237a255 100644 --- a/libs/ledger-live-common/src/types/manager.ts +++ b/libs/ledger-live-common/src/types/manager.ts @@ -68,6 +68,11 @@ export type McuVersion = { date_creation: string; date_last_modified: string; }; +export enum SeedPhraseType { + Twelve = "12-words", + Eighteen = "18-words", + TwentyFour = "24-words", +} export type FirmwareInfo = { isBootloader: boolean; rawVersion: string; // if SE seVersion, if BL blVersion diff --git a/libs/ledgerjs/packages/errors/src/index.ts b/libs/ledgerjs/packages/errors/src/index.ts index 455571a1ee4a..276b0f0bc09e 100644 --- a/libs/ledgerjs/packages/errors/src/index.ts +++ b/libs/ledgerjs/packages/errors/src/index.ts @@ -54,6 +54,12 @@ export const DisconnectedDevice = createCustomErrorClass("DisconnectedDevice"); export const DisconnectedDeviceDuringOperation = createCustomErrorClass( "DisconnectedDeviceDuringOperation" ); +export const DeviceOnboardingStatePollingError = createCustomErrorClass( + "DeviceOnboardingStatePollingError" +); +export const DeviceExtractOnboardingStateError = createCustomErrorClass( + "DeviceExtractOnboardingStateError" +); export const EnpointConfigError = createCustomErrorClass("EnpointConfig"); export const EthAppPleaseEnableContractData = createCustomErrorClass( "EthAppPleaseEnableContractData"