diff --git a/suite-native/app/src/navigation/RootStackNavigator.tsx b/suite-native/app/src/navigation/RootStackNavigator.tsx index 7c45eb4e61e..21e90b75323 100644 --- a/suite-native/app/src/navigation/RootStackNavigator.tsx +++ b/suite-native/app/src/navigation/RootStackNavigator.tsx @@ -41,7 +41,7 @@ export const RootStackNavigator = () => { return RootStackRoutes.AppTabs; } - return RootStackRoutes.Onboarding; + return RootStackRoutes.OnboardingStack; }; return ( @@ -49,16 +49,7 @@ export const RootStackNavigator = () => { initialRouteName={getInitialRouteName()} screenOptions={stackNavigationOptionsConfig} > - - { name={RootStackRoutes.DevUtilsStack} component={DevUtilsStackNavigator} /> - - - - - + {/* Navigation flows that start by push from bottom animation on the first screen of its stack. */} + + + + + + + + + ); }; diff --git a/suite-native/device/src/hooks/useDetectDeviceError.tsx b/suite-native/device/src/hooks/useDetectDeviceError.tsx index 6e006071abd..e25e0154f12 100644 --- a/suite-native/device/src/hooks/useDetectDeviceError.tsx +++ b/suite-native/device/src/hooks/useDetectDeviceError.tsx @@ -34,7 +34,11 @@ import { BootloaderModalAppendix } from '../components/BootloaderModalAppendix'; import { IncompatibleFirmwareModalAppendix } from '../components/IncompatibleFirmwareModalAppendix'; import { UnacquiredDeviceModalAppendix } from '../components/UnacquiredDeviceModalAppendix'; import { UninitializedDeviceModalAppendix } from '../components/UninitializedDeviceModalAppendix'; -import { selectDeviceError, selectIsDeviceFirmwareSupported } from '../selectors'; +import { + selectDeviceError, + selectIsDeviceFirmwareSupported, + selectIsDeviceSetupSupported, +} from '../selectors'; export const SUITE_WEB_URL = 'https://suite.trezor.io/web/'; @@ -61,6 +65,7 @@ export const useDetectDeviceError = () => { const isFirmwareInstallationRunning = useSelector(selectIsFirmwareInstallationRunning); const hasDeviceFirmwareInstalled = useSelector(selectHasDeviceFirmwareInstalled); const isOnboardingFinished = useSelector(selectIsOnboardingFinished); + const isDeviceSetupSupported = useSelector(selectIsDeviceSetupSupported); const isDeviceFirmwareSupported = useSelector(selectIsDeviceFirmwareSupported); const deviceError = useSelector(selectDeviceError); @@ -103,7 +108,8 @@ export const useDetectDeviceError = () => { !isDeviceFirmwareSupported && isOnboardingFinished && !isPortfolioTrackerDevice && - !wasDeviceEjectedByUser + !wasDeviceEjectedByUser && + !isDeviceSetupSupported ) { showAlert({ title: , @@ -130,10 +136,11 @@ export const useDetectDeviceError = () => { dispatch, showAlert, handleDisconnect, + isDeviceSetupSupported, ]); useEffect(() => { - if (!isOnboardingFinished) return; + if (!isOnboardingFinished || isDeviceSetupSupported) return; if ( isConnectedDeviceUninitialized && @@ -194,6 +201,7 @@ export const useDetectDeviceError = () => { openLink, deviceError, handleDisconnect, + isDeviceSetupSupported, ]); useEffect(() => { diff --git a/suite-native/device/src/hooks/useHandleDeviceConnection.ts b/suite-native/device/src/hooks/useHandleDeviceConnection.ts index 35bedf2155e..c570b0fd28e 100644 --- a/suite-native/device/src/hooks/useHandleDeviceConnection.ts +++ b/suite-native/device/src/hooks/useHandleDeviceConnection.ts @@ -7,6 +7,7 @@ import { authorizeDeviceThunk, selectIsDeviceConnected, selectIsDeviceConnectedAndAuthorized, + selectIsDeviceInitialized, selectIsDeviceRemembered, selectIsDeviceUsingPassphrase, selectIsNoPhysicalDeviceConnected, @@ -21,15 +22,18 @@ import { AuthorizeDeviceStackParamList, AuthorizeDeviceStackRoutes, HomeStackRoutes, + OnboardingStackRoutes, RootStackParamList, RootStackRoutes, StackToStackCompositeNavigationProps, } from '@suite-native/navigation'; import { selectIsOnboardingFinished } from '@suite-native/settings'; +import { selectIsDeviceSetupSupported } from '../selectors'; + type NavigationProp = StackToStackCompositeNavigationProps< AuthorizeDeviceStackParamList | RootStackParamList, - AuthorizeDeviceStackRoutes.PinMatrix | RootStackRoutes.Onboarding, + AuthorizeDeviceStackRoutes.PinMatrix | RootStackRoutes.OnboardingStack, RootStackParamList >; @@ -41,9 +45,12 @@ export const useHandleDeviceConnection = () => { const isDeviceConnectedAndAuthorized = useSelector(selectIsDeviceConnectedAndAuthorized); const hasDeviceRequestedPin = useSelector(selectDeviceRequestedPin); const isDeviceConnected = useSelector(selectIsDeviceConnected); - const { isBiometricsOverlayVisible } = useIsBiometricsOverlayVisible(); + const isDeviceInitialized = useSelector(selectIsDeviceInitialized); const isDeviceUsingPassphrase = useSelector(selectIsDeviceUsingPassphrase); const isFirmwareInstallationRunning = useSelector(selectIsFirmwareInstallationRunning); + const isDeviceSetupSupported = useSelector(selectIsDeviceSetupSupported); + + const { isBiometricsOverlayVisible } = useIsBiometricsOverlayVisible(); const navigation = useNavigation(); const dispatch = useDispatch(); @@ -52,12 +59,46 @@ export const useHandleDeviceConnection = () => { const isSendStackFocused = lastRoute === RootStackRoutes.SendStack; const shouldBlockSendReviewRedirect = isDeviceRemembered && isSendStackFocused; + // When is an uninitialized device model that supports device setup, navigate to device onboarding. + useEffect(() => { + if ( + isDeviceSetupSupported && + !isDeviceInitialized && + isDeviceConnected && + isOnboardingFinished && + !isPortfolioTrackerDevice && + !isBiometricsOverlayVisible + ) { + requestPrioritizedDeviceAccess({ + deviceCallback: () => dispatch(authorizeDeviceThunk()), + }); + + if (!isDeviceInitialized) { + navigation.navigate(RootStackRoutes.OnboardingStack, { + screen: OnboardingStackRoutes.UninitializedDeviceLanding, + }); + + return; + } + } + }, [ + dispatch, + isDeviceConnected, + isOnboardingFinished, + isBiometricsOverlayVisible, + navigation, + isDeviceInitialized, + isPortfolioTrackerDevice, + isDeviceSetupSupported, + ]); + // 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 ( + isDeviceInitialized && isDeviceConnected && isOnboardingFinished && !isPortfolioTrackerDevice && @@ -88,6 +129,7 @@ export const useHandleDeviceConnection = () => { isDeviceUsingPassphrase, shouldBlockSendReviewRedirect, isFirmwareInstallationRunning, + isDeviceInitialized, ]); // In case that the physical device is disconnected, redirect to the home screen and @@ -96,12 +138,7 @@ export const useHandleDeviceConnection = () => { if (isFirmwareInstallationRunning) return; if (isNoPhysicalDeviceConnected && isOnboardingFinished) { - const previousRoute = navigation.getState()?.routes.at(-1)?.name; - - // This accidentally gets triggered by finishing onboarding with no device connected, - // so this prevents from redirect being duplicated. - const isPreviousRouteOnboarding = previousRoute === RootStackRoutes.Onboarding; - if (isPreviousRouteOnboarding || shouldBlockSendReviewRedirect) { + if (shouldBlockSendReviewRedirect) { return; } navigation.navigate(RootStackRoutes.AppTabs, { diff --git a/suite-native/device/src/index.ts b/suite-native/device/src/index.ts index b21ffa3c21b..7d286134363 100644 --- a/suite-native/device/src/index.ts +++ b/suite-native/device/src/index.ts @@ -12,3 +12,4 @@ export * from './utils'; export * from './selectors'; export * from './deviceThunks'; export * from './config/firmware'; +export * from './types'; diff --git a/suite-native/device/src/selectors.ts b/suite-native/device/src/selectors.ts index 3bcefe803ab..15f35153d51 100644 --- a/suite-native/device/src/selectors.ts +++ b/suite-native/device/src/selectors.ts @@ -36,7 +36,7 @@ import { doesCoinSupportStaking } from '@suite-native/staking'; import { BigNumber } from '@trezor/utils'; import { revisionCheckErrorScenarios } from './config/firmware'; -import { isFirmwareVersionSupported } from './utils'; +import { isDeviceSetupSupported, isFirmwareVersionSupported } from './utils'; type NativeDeviceRootState = DeviceRootState & AccountsRootState & @@ -185,3 +185,8 @@ export const selectHasFirmwareAuthenticityCheckHardFailed = (state: FwAuthentici return isRevisionHardError; }; + +export const selectIsDeviceSetupSupported = createMemoizedSelector( + [selectDeviceModel], + model => !!model && isDeviceSetupSupported(model), +); diff --git a/suite-native/device/src/types.ts b/suite-native/device/src/types.ts new file mode 100644 index 00000000000..9232c8735e5 --- /dev/null +++ b/suite-native/device/src/types.ts @@ -0,0 +1,9 @@ +import { DeviceModelInternal } from '@trezor/connect'; + +export type SetupSupportingDeviceModel = Exclude< + DeviceModelInternal, + | DeviceModelInternal.T1B1 + | DeviceModelInternal.T2T1 + | DeviceModelInternal.T3W1 + | DeviceModelInternal.UNKNOWN +>; diff --git a/suite-native/device/src/utils.ts b/suite-native/device/src/utils.ts index 0758b93adb7..0bd5c6aa78c 100644 --- a/suite-native/device/src/utils.ts +++ b/suite-native/device/src/utils.ts @@ -2,6 +2,7 @@ import { G } from '@mobily/ts-belt'; import { AnyAction } from '@reduxjs/toolkit'; import * as semver from 'semver'; +import { UnreachableCaseError } from '@suite-common/suite-utils'; import { DEVICE, Device, DeviceEvent, DeviceModelInternal, VersionArray } from '@trezor/connect'; export const minimalSupportedFirmwareVersion = { @@ -37,3 +38,20 @@ export const isDeviceEventAction = ( action: AnyAction, actionType: T, ): action is { type: T; payload: Device } => action.type === actionType; + +export const isDeviceSetupSupported = (model: DeviceModelInternal) => { + // Exhaustive check for case that new model is introduced later it won't be forgotten. + switch (model) { + case DeviceModelInternal.T2B1: + case DeviceModelInternal.T3B1: + case DeviceModelInternal.T3T1: + return true; + case DeviceModelInternal.T1B1: + case DeviceModelInternal.T2T1: + case DeviceModelInternal.T3W1: + case DeviceModelInternal.UNKNOWN: + return false; + default: + throw new UnreachableCaseError(model); + } +}; diff --git a/suite-native/intl/src/en.ts b/suite-native/intl/src/en.ts index ad3b806f22b..8e110e5b1fd 100644 --- a/suite-native/intl/src/en.ts +++ b/suite-native/intl/src/en.ts @@ -825,6 +825,21 @@ export const en = { notNow: 'Not now', }, }, + + uninitializedDeviceLandingScreen: { + noFirmware: { + title: 'Now it’s just you\nand your crypto', + button: "Let's get started", + }, + firmware: { + title: 'Have you used this Trezor before?', + subtitle: + 'Firmware is already installed on this Trezor. Continue only if you have used this Trezor before.', + confirmButton: 'Yes, set up my Trezor', + noButton: 'No, I have not', + }, + lookDifferentLabel: 'My device looks different', + }, }, moduleAccountManagement: { accountSettingsScreen: { diff --git a/suite-native/module-onboarding/package.json b/suite-native/module-onboarding/package.json index e3ec921e91c..d1e10db9433 100644 --- a/suite-native/module-onboarding/package.json +++ b/suite-native/module-onboarding/package.json @@ -13,15 +13,18 @@ "@react-navigation/core": "^6.4.10", "@react-navigation/native": "6.1.18", "@react-navigation/native-stack": "6.11.0", + "@reduxjs/toolkit": "1.9.5", "@suite-native/atoms": "workspace:*", "@suite-native/icons": "workspace:*", "@suite-native/intl": "workspace:*", "@suite-native/navigation": "workspace:*", + "@trezor/connect": "workspace:*", "@trezor/env-utils": "workspace:*", "@trezor/styles": "workspace:*", "expo-linear-gradient": "^14.0.1", "react": "18.2.0", "react-native": "0.76.1", + "react-native-svg": "^15.9.0", "react-redux": "8.0.7" } } diff --git a/suite-native/module-onboarding/redux.d.ts b/suite-native/module-onboarding/redux.d.ts new file mode 100644 index 00000000000..df9a0c3f969 --- /dev/null +++ b/suite-native/module-onboarding/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-onboarding/src/assets/t3b1.png b/suite-native/module-onboarding/src/assets/t3b1.png new file mode 100644 index 00000000000..31943568425 Binary files /dev/null and b/suite-native/module-onboarding/src/assets/t3b1.png differ diff --git a/suite-native/module-onboarding/src/assets/t3t1.png b/suite-native/module-onboarding/src/assets/t3t1.png new file mode 100644 index 00000000000..9e0a0488c92 Binary files /dev/null and b/suite-native/module-onboarding/src/assets/t3t1.png differ diff --git a/suite-native/module-onboarding/src/components/HeaderUnderlineSvg.tsx b/suite-native/module-onboarding/src/components/HeaderUnderlineSvg.tsx new file mode 100644 index 00000000000..e56ca125660 --- /dev/null +++ b/suite-native/module-onboarding/src/components/HeaderUnderlineSvg.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import Svg, { Path, SvgProps } from 'react-native-svg'; + +import { useIllustrationColors } from '@suite-native/atoms'; + +export const HeaderUnderlineSvg = (props: SvgProps) => { + const { lineColor } = useIllustrationColors(); + + return ( + + + + ); +}; diff --git a/suite-native/module-onboarding/src/navigation/OnboardingStackNavigator.tsx b/suite-native/module-onboarding/src/navigation/OnboardingStackNavigator.tsx index a0ea19adcdc..08cf84e32a4 100644 --- a/suite-native/module-onboarding/src/navigation/OnboardingStackNavigator.tsx +++ b/suite-native/module-onboarding/src/navigation/OnboardingStackNavigator.tsx @@ -8,6 +8,7 @@ import { import { AnalyticsConsentScreen } from '../screens/AnalyticsConsentScreen'; import { BiometricsScreen } from '../screens/BiometricsScreen'; +import { UninitializedDeviceLandingScreen } from '../screens/UninitializedDeviceLandingScreen'; import { WelcomeScreen } from '../screens/WelcomeScreen'; export const OnboardingStack = createNativeStackNavigator(); @@ -26,5 +27,9 @@ export const OnboardingStackNavigator = () => ( name={OnboardingStackRoutes.Biometrics} component={BiometricsScreen} /> + ); diff --git a/suite-native/module-onboarding/src/screens/UninitializedDeviceLandingScreen.tsx b/suite-native/module-onboarding/src/screens/UninitializedDeviceLandingScreen.tsx new file mode 100644 index 00000000000..543ce5deb25 --- /dev/null +++ b/suite-native/module-onboarding/src/screens/UninitializedDeviceLandingScreen.tsx @@ -0,0 +1,118 @@ +import { useSelector } from 'react-redux'; + +import { selectDeviceModel, selectHasDeviceFirmwareInstalled } from '@suite-common/wallet-core'; +import { Box, Button, Image, Text, TextButton, TitleHeader, VStack } from '@suite-native/atoms'; +import { SetupSupportingDeviceModel } from '@suite-native/device'; +import { Translation } from '@suite-native/intl'; +import { Screen, ScreenHeader } from '@suite-native/navigation'; +import { useToast } from '@suite-native/toasts'; +import { DeviceModelInternal } from '@trezor/connect'; +import { getScreenHeight } from '@trezor/env-utils'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; + +import { HeaderUnderlineSvg } from '../components/HeaderUnderlineSvg'; + +const trezorImageStyle = prepareNativeStyle<{ hasDeviceFirmwareInstalled: boolean }>( + (_, { hasDeviceFirmwareInstalled }) => ({ + width: '100%', + height: hasDeviceFirmwareInstalled ? 280 : 360, + maxHeight: (getScreenHeight() / 3) * 2, + alignItems: 'center', + }), +); + +const trezorModelImageMap = { + [DeviceModelInternal.T2B1]: require('../assets/t3b1.png'), + [DeviceModelInternal.T3B1]: require('../assets/t3b1.png'), + [DeviceModelInternal.T3T1]: require('../assets/t3t1.png'), +} as const satisfies Record; + +const UninitializedDeviceLandingScreenContent = () => { + const { applyStyle } = useNativeStyles(); + const deviceModel = useSelector(selectDeviceModel) as SetupSupportingDeviceModel; + const hasDeviceFirmwareInstalled = useSelector(selectHasDeviceFirmwareInstalled); + + if (!deviceModel) { + return null; + } + + return ( + + {hasDeviceFirmwareInstalled ? ( + + } + titleVariant="titleMedium" + subtitle={ + + } + /> + ) : ( + + + + + + + )} + + + ); +}; + +export const UninitializedDeviceLandingScreen = () => { + const { showToast } = useToast(); + const hasDeviceFirmwareInstalled = useSelector(selectHasDeviceFirmwareInstalled); + + const handleConfirmButtonPress = () => { + // TODO: navigate to next onboarding screen based on firmware status + showToast({ variant: 'warning', message: 'TODO: implement next screen' }); + }; + + const handleDeclineButtonPress = () => { + // TODO: navigate to screen where user can see more info regarding tampered devices. + showToast({ variant: 'warning', message: 'TODO: implement next screen' }); + }; + + return ( + // TODO: add handling of close button event + }> + + + + + + + + + + {hasDeviceFirmwareInstalled && ( + + )} + + + + ); +}; diff --git a/suite-native/module-onboarding/tsconfig.json b/suite-native/module-onboarding/tsconfig.json index 261062d51ba..d67c01e421f 100644 --- a/suite-native/module-onboarding/tsconfig.json +++ b/suite-native/module-onboarding/tsconfig.json @@ -6,6 +6,7 @@ { "path": "../icons" }, { "path": "../intl" }, { "path": "../navigation" }, + { "path": "../../packages/connect" }, { "path": "../../packages/env-utils" }, { "path": "../../packages/styles" } ] diff --git a/suite-native/navigation/src/navigators.ts b/suite-native/navigation/src/navigators.ts index 02da8ea4170..f4e7b4eb52b 100644 --- a/suite-native/navigation/src/navigators.ts +++ b/suite-native/navigation/src/navigators.ts @@ -112,6 +112,7 @@ export type OnboardingStackParamList = { [OnboardingStackRoutes.Welcome]: undefined; [OnboardingStackRoutes.AnalyticsConsent]: undefined; [OnboardingStackRoutes.Biometrics]: undefined; + [OnboardingStackRoutes.UninitializedDeviceLanding]: undefined; }; export type AccountsImportStackParamList = { @@ -193,7 +194,7 @@ export type AuthorizeDeviceStackParamList = { export type RootStackParamList = { [RootStackRoutes.AppTabs]: NavigatorScreenParams; - [RootStackRoutes.Onboarding]: NavigatorScreenParams; + [RootStackRoutes.OnboardingStack]: NavigatorScreenParams; [RootStackRoutes.AuthorizeDeviceStack]: NavigatorScreenParams; [RootStackRoutes.AccountsImport]: NavigatorScreenParams; [RootStackRoutes.AccountSettings]: { accountKey: AccountKey }; diff --git a/suite-native/navigation/src/routes.ts b/suite-native/navigation/src/routes.ts index e8f24416e53..298fa5b139c 100644 --- a/suite-native/navigation/src/routes.ts +++ b/suite-native/navigation/src/routes.ts @@ -1,7 +1,7 @@ export enum RootStackRoutes { AppTabs = 'AppTabs', LegacyOnboarding = 'LegacyOnboarding', - Onboarding = 'Onboarding', + OnboardingStack = 'OnboardingStack', AccountsImport = 'AccountsImport', AuthorizeDeviceStack = 'AuthorizeDeviceStack', AccountDetail = 'AccountDetail', @@ -29,6 +29,7 @@ export enum OnboardingStackRoutes { Welcome = 'Welcome', AnalyticsConsent = 'AnalyticsConsent', Biometrics = 'Biometrics', + UninitializedDeviceLanding = 'UninitializedDeviceLanding', } export enum AccountsImportStackRoutes { diff --git a/yarn.lock b/yarn.lock index 138f0265f67..88be7ffaf7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10807,15 +10807,18 @@ __metadata: "@react-navigation/core": "npm:^6.4.10" "@react-navigation/native": "npm:6.1.18" "@react-navigation/native-stack": "npm:6.11.0" + "@reduxjs/toolkit": "npm:1.9.5" "@suite-native/atoms": "workspace:*" "@suite-native/icons": "workspace:*" "@suite-native/intl": "workspace:*" "@suite-native/navigation": "workspace:*" + "@trezor/connect": "workspace:*" "@trezor/env-utils": "workspace:*" "@trezor/styles": "workspace:*" expo-linear-gradient: "npm:^14.0.1" react: "npm:18.2.0" react-native: "npm:0.76.1" + react-native-svg: "npm:^15.9.0" react-redux: "npm:8.0.7" languageName: unknown linkType: soft