diff --git a/src/App.tsx b/src/App.tsx index 4977067dc60..1a749b8ce6d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import '@/languages'; import * as Sentry from '@sentry/react-native'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState, memo } from 'react'; import { AppRegistry, Dimensions, LogBox, StyleSheet, View } from 'react-native'; import { Toaster } from 'sonner-native'; import { MobileWalletProtocolProvider } from '@coinbase/mobile-wallet-protocol-host'; @@ -9,9 +9,8 @@ import { useApplicationSetup } from '@/hooks/useApplicationSetup'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { enableScreens } from 'react-native-screens'; -import { connect, Provider as ReduxProvider } from 'react-redux'; +import { connect, Provider as ReduxProvider, shallowEqual } from 'react-redux'; import { RecoilRoot } from 'recoil'; -import PortalConsumer from '@/components/PortalConsumer'; import ErrorBoundary from '@/components/error-boundary/ErrorBoundary'; import { OfflineToast } from '@/components/toasts'; import { designSystemPlaygroundEnabled, reactNativeDisableYellowBox, showNetworkRequests, showNetworkResponses } from '@/config/debug'; @@ -24,7 +23,6 @@ import store, { AppDispatch, type AppState } from '@/redux/store'; import { MainThemeProvider } from '@/theme/ThemeContext'; import { SharedValuesProvider } from '@/helpers/SharedValuesContext'; import { InitialRouteContext } from '@/navigation/initialRoute'; -import { Portal } from '@/react-native-cool-modals/Portal'; import { NotificationsHandler } from '@/notifications/NotificationsHandler'; import { analyticsV2 } from '@/analytics'; import { getOrCreateDeviceId } from '@/analytics/utils'; @@ -39,6 +37,7 @@ import { RootStackParamList } from '@/navigation/types'; import { IS_ANDROID, IS_DEV } from '@/env'; import { prefetchDefaultFavorites } from '@/resources/favorites'; import Routes from '@/navigation/Routes'; +import { BackupsSync } from '@/state/sync/BackupsSync'; import { BackendNetworks } from '@/components/BackendNetworks'; import { AbsolutePortalRoot } from './components/AbsolutePortal'; @@ -68,13 +67,11 @@ function App({ walletReady }: AppProps) { }, []); return ( - + <> {initialRoute && ( - - )} @@ -82,14 +79,27 @@ function App({ walletReady }: AppProps) { + - + + ); } -const AppWithRedux = connect(state => ({ - walletReady: state.appState.walletReady, -}))(App); +const AppWithRedux = connect( + state => ({ + walletReady: state.appState.walletReady, + }), + null, + null, + { + areStatesEqual: (next, prev) => { + // Only update if walletReady actually changed + return next.appState.walletReady === prev.appState.walletReady; + }, + areOwnPropsEqual: shallowEqual, + } +)(memo(App)); function Root() { const [initializing, setInitializing] = useState(true); diff --git a/src/components/AbsolutePortal.tsx b/src/components/AbsolutePortal.tsx index c98b036cd70..a992e84c11c 100644 --- a/src/components/AbsolutePortal.tsx +++ b/src/components/AbsolutePortal.tsx @@ -1,5 +1,5 @@ import React, { PropsWithChildren, ReactNode, useEffect, useState } from 'react'; -import { View } from 'react-native'; +import { StyleProp, ViewStyle, View } from 'react-native'; const absolutePortal = { nodes: [] as ReactNode[], @@ -24,7 +24,7 @@ const absolutePortal = { }, }; -export const AbsolutePortalRoot = () => { +export const AbsolutePortalRoot = ({ style }: { style?: StyleProp }) => { const [nodes, setNodes] = useState(absolutePortal.nodes); useEffect(() => { @@ -32,7 +32,7 @@ export const AbsolutePortalRoot = () => { return () => unsubscribe(); }, []); - return {nodes}; + return {nodes}; }; export const AbsolutePortal = ({ children }: PropsWithChildren) => { diff --git a/src/components/ExchangeTokenRow.tsx b/src/components/ExchangeTokenRow.tsx index 0e509debbe4..9fabd7219f5 100644 --- a/src/components/ExchangeTokenRow.tsx +++ b/src/components/ExchangeTokenRow.tsx @@ -2,7 +2,7 @@ import React from 'react'; import isEqual from 'react-fast-compare'; import { Box, Column, Columns, Inline, Stack, Text } from '@/design-system'; import { isNativeAsset } from '@/handlers/assets'; -import { useAsset, useDimensions } from '@/hooks'; +import { useAsset } from '@/hooks'; import { ButtonPressAnimation } from '@/components/animations'; import { FloatingEmojis } from '@/components/floating-emojis'; import { IS_IOS } from '@/env'; @@ -34,7 +34,6 @@ export default React.memo(function ExchangeTokenRow({ disabled, }, }: ExchangeTokenRowProps) { - const { width: deviceWidth } = useDimensions(); const item = useAsset({ address, chainId, @@ -101,10 +100,8 @@ export default React.memo(function ExchangeTokenRow({ {isInfoButtonVisible && } {showFavoriteButton && (IS_IOS ? ( - // @ts-ignore { - if (isWalletLoading) { - setComponent(, true); - } - return hide; - }, [hide, isWalletLoading, setComponent]); - - return null; -} diff --git a/src/components/WalletLoadingListener.tsx b/src/components/WalletLoadingListener.tsx new file mode 100644 index 00000000000..6a9e605ab4f --- /dev/null +++ b/src/components/WalletLoadingListener.tsx @@ -0,0 +1,17 @@ +import React, { useEffect } from 'react'; +import { LoadingOverlay } from './modal'; +import { sheetVerticalOffset } from '@/navigation/effects'; +import { walletLoadingStore } from '@/state/walletLoading/walletLoading'; + +export default function WalletLoadingListener() { + const loadingState = walletLoadingStore(state => state.loadingState); + + useEffect(() => { + if (loadingState) { + walletLoadingStore.getState().setComponent(); + } + return walletLoadingStore.getState().hide; + }, [loadingState]); + + return null; +} diff --git a/src/components/asset-list/RecyclerAssetList2/FastComponents/FastCurrencySelectionRow.tsx b/src/components/asset-list/RecyclerAssetList2/FastComponents/FastCurrencySelectionRow.tsx index d122b9a622e..49bf81fd43e 100644 --- a/src/components/asset-list/RecyclerAssetList2/FastComponents/FastCurrencySelectionRow.tsx +++ b/src/components/asset-list/RecyclerAssetList2/FastComponents/FastCurrencySelectionRow.tsx @@ -174,10 +174,8 @@ export default React.memo(function FastCurrencySelectionRow({ {showFavoriteButton && chainId === ChainId.mainnet && (ios ? ( - // @ts-ignore - + @@ -216,13 +216,17 @@ function SendButton() { ); } -export function MoreButton() { - // //////////////////////////////////////////////////// - // Handlers - +export function CopyButton() { const [isToastActive, setToastActive] = useRecoilState(addressCopiedToastAtom); const { accountAddress } = useAccountProfile(); + const { isDamaged } = useWallets(); + const handlePressCopy = React.useCallback(() => { + if (isDamaged) { + showWalletErrorAlert(); + return; + } + if (!isToastActive) { setToastActive(true); setTimeout(() => { @@ -230,7 +234,7 @@ export function MoreButton() { }, 2000); } Clipboard.setString(accountAddress); - }, [accountAddress, isToastActive, setToastActive]); + }, [accountAddress, isDamaged, isToastActive, setToastActive]); return ( <> diff --git a/src/components/asset-list/RecyclerAssetList2/profile-header/ProfileNameRow.tsx b/src/components/asset-list/RecyclerAssetList2/profile-header/ProfileNameRow.tsx index 616935fe6ff..9859a4ae105 100644 --- a/src/components/asset-list/RecyclerAssetList2/profile-header/ProfileNameRow.tsx +++ b/src/components/asset-list/RecyclerAssetList2/profile-header/ProfileNameRow.tsx @@ -109,7 +109,6 @@ export function ProfileNameRow({ scaleTo={0} size={50} wiggleFactor={0} - // @ts-expect-error – JS component setOnNewEmoji={newOnNewEmoji => (onNewEmoji.current = newOnNewEmoji)} /> diff --git a/src/components/backup/AddWalletToCloudBackupStep.tsx b/src/components/backup/AddWalletToCloudBackupStep.tsx deleted file mode 100644 index 62f92a99e2f..00000000000 --- a/src/components/backup/AddWalletToCloudBackupStep.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React, { useCallback } from 'react'; -import { Bleed, Box, Inline, Inset, Separator, Stack, Text } from '@/design-system'; -import * as lang from '@/languages'; -import { ImgixImage } from '../images'; -import WalletsAndBackupIcon from '@/assets/WalletsAndBackup.png'; -import { Source } from 'react-native-fast-image'; -import { cloudPlatform } from '@/utils/platform'; -import { ButtonPressAnimation } from '../animations'; -import Routes from '@/navigation/routesNames'; -import { useNavigation } from '@/navigation'; -import { useWallets } from '@/hooks'; -import { WalletCountPerType, useVisibleWallets } from '@/screens/SettingsSheet/useVisibleWallets'; -import { format } from 'date-fns'; -import { useCreateBackup } from './useCreateBackup'; -import { login } from '@/handlers/cloudBackup'; - -const imageSize = 72; - -export default function AddWalletToCloudBackupStep() { - const { goBack } = useNavigation(); - const { wallets, selectedWallet } = useWallets(); - - const walletTypeCount: WalletCountPerType = { - phrase: 0, - privateKey: 0, - }; - - const { lastBackupDate } = useVisibleWallets({ wallets, walletTypeCount }); - - const { onSubmit } = useCreateBackup({ - walletId: selectedWallet.id, - navigateToRoute: { - route: Routes.SETTINGS_SHEET, - params: { - screen: Routes.SETTINGS_SECTION_BACKUP, - }, - }, - }); - - const potentiallyLoginAndSubmit = useCallback(async () => { - await login(); - return onSubmit({}); - }, [onSubmit]); - - const onMaybeLater = useCallback(() => goBack(), [goBack]); - - return ( - - - - - - {lang.t(lang.l.back_up.cloud.add_wallet_to_cloud_backups)} - - - - - - - - - potentiallyLoginAndSubmit().then(success => success && goBack())}> - - - - - 􀎽{' '} - {lang.t(lang.l.back_up.cloud.back_to_cloud_platform_now, { - cloudPlatform, - })} - - - - - - - - - - - - - - - - {lang.t(lang.l.back_up.cloud.mayber_later)} - - - - - - - - - - - {lastBackupDate && ( - - - - - {lang.t(lang.l.back_up.cloud.latest_backup, { - date: format(lastBackupDate, "M/d/yy 'at' h:mm a"), - })} - - - - - )} - - ); -} diff --git a/src/components/backup/BackupManuallyStep.tsx b/src/components/backup/BackupManuallyStep.tsx deleted file mode 100644 index da18d73806a..00000000000 --- a/src/components/backup/BackupManuallyStep.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useCallback } from 'react'; -import { Bleed, Box, Inline, Inset, Separator, Stack, Text } from '@/design-system'; -import * as lang from '@/languages'; -import { ImgixImage } from '../images'; -import ManuallyBackedUpIcon from '@/assets/ManuallyBackedUp.png'; -import { Source } from 'react-native-fast-image'; -import { ButtonPressAnimation } from '../animations'; -import { useNavigation } from '@/navigation'; -import Routes from '@/navigation/routesNames'; -import { useWallets } from '@/hooks'; -import walletTypes from '@/helpers/walletTypes'; -import { SETTINGS_BACKUP_ROUTES } from '@/screens/SettingsSheet/components/Backups/routes'; -import walletBackupTypes from '@/helpers/walletBackupTypes'; - -const imageSize = 72; - -export default function BackupManuallyStep() { - const { navigate, goBack } = useNavigation(); - const { selectedWallet } = useWallets(); - - const onManualBackup = async () => { - const title = - selectedWallet?.imported && selectedWallet.type === walletTypes.privateKey - ? (selectedWallet.addresses || [])[0].label - : selectedWallet.name; - - goBack(); - navigate(Routes.SETTINGS_SHEET, { - screen: SETTINGS_BACKUP_ROUTES.SECRET_WARNING, - params: { - isBackingUp: true, - title, - backupType: walletBackupTypes.manual, - walletId: selectedWallet.id, - }, - }); - }; - - const onMaybeLater = useCallback(() => goBack(), [goBack]); - - return ( - - - - - - {lang.t(lang.l.back_up.manual.backup_manually_now)} - - - - - - - - - - - - - - {lang.t(lang.l.back_up.manual.back_up_now)} - - - - - - - - - - - - - - - - {lang.t(lang.l.back_up.manual.already_backed_up)} - - - - - - - - - - - ); -} diff --git a/src/components/backup/BackupSheet.tsx b/src/components/backup/BackupSheet.tsx index 5b6a0a4300a..12e15c60190 100644 --- a/src/components/backup/BackupSheet.tsx +++ b/src/components/backup/BackupSheet.tsx @@ -2,13 +2,10 @@ import { RouteProp, useRoute } from '@react-navigation/native'; import React, { useCallback } from 'react'; import { BackupCloudStep, RestoreCloudStep } from '.'; import WalletBackupStepTypes from '@/helpers/walletBackupStepTypes'; -import BackupChooseProviderStep from '@/components/backup/BackupChooseProviderStep'; +import BackupWalletPrompt from '@/components/backup/BackupWalletPrompt'; import { BackgroundProvider } from '@/design-system'; import { SimpleSheet } from '@/components/sheet/SimpleSheet'; -import AddWalletToCloudBackupStep from '@/components/backup/AddWalletToCloudBackupStep'; -import BackupManuallyStep from './BackupManuallyStep'; import { getHeightForStep } from '@/navigation/config'; -import { CloudBackupProvider } from './CloudBackupProvider'; type BackupSheetParams = { BackupSheet: { @@ -21,38 +18,32 @@ type BackupSheetParams = { }; export default function BackupSheet() { - const { params: { step = WalletBackupStepTypes.no_provider } = {} } = useRoute>(); + const { params: { step = WalletBackupStepTypes.backup_prompt } = {} } = useRoute>(); const renderStep = useCallback(() => { switch (step) { - case WalletBackupStepTypes.backup_now_to_cloud: - return ; - case WalletBackupStepTypes.backup_now_manually: - return ; case WalletBackupStepTypes.backup_cloud: return ; case WalletBackupStepTypes.restore_from_backup: return ; - case WalletBackupStepTypes.no_provider: + case WalletBackupStepTypes.backup_prompt: default: - return ; + return ; } }, [step]); return ( - - - {({ backgroundColor }) => ( - - {renderStep()} - - )} - - + + {({ backgroundColor }) => ( + + {renderStep()} + + )} + ); } diff --git a/src/components/backup/BackupChooseProviderStep.tsx b/src/components/backup/BackupWalletPrompt.tsx similarity index 66% rename from src/components/backup/BackupChooseProviderStep.tsx rename to src/components/backup/BackupWalletPrompt.tsx index 38325639704..77071534f48 100644 --- a/src/components/backup/BackupChooseProviderStep.tsx +++ b/src/components/backup/BackupWalletPrompt.tsx @@ -1,7 +1,6 @@ -import React from 'react'; -import { useCreateBackup } from '@/components/backup/useCreateBackup'; +import React, { useCallback, useMemo } from 'react'; import { Bleed, Box, Inline, Inset, Separator, Stack, Text } from '@/design-system'; -import * as lang from '@/languages'; +import * as i18n from '@/languages'; import { ImgixImage } from '../images'; import WalletsAndBackupIcon from '@/assets/WalletsAndBackup.png'; import ManuallyBackedUpIcon from '@/assets/ManuallyBackedUp.png'; @@ -14,13 +13,13 @@ import { useNavigation } from '@/navigation'; import Routes from '@/navigation/routesNames'; import { SETTINGS_BACKUP_ROUTES } from '@/screens/SettingsSheet/components/Backups/routes'; import { useWallets } from '@/hooks'; -import walletTypes from '@/helpers/walletTypes'; +import WalletTypes from '@/helpers/walletTypes'; import walletBackupTypes from '@/helpers/walletBackupTypes'; -import { IS_ANDROID } from '@/env'; -import { GoogleDriveUserData, getGoogleAccountUserData, isCloudBackupAvailable, login } from '@/handlers/cloudBackup'; -import { WrappedAlert as Alert } from '@/helpers/alert'; -import { RainbowError, logger } from '@/logger'; -import { Linking } from 'react-native'; +import { useCreateBackup } from '@/components/backup/useCreateBackup'; +import { backupsStore, CloudBackupState } from '@/state/backups/backups'; +import { executeFnIfCloudBackupAvailable } from '@/model/backup'; +import { TextColor } from '@/design-system/color/palettes'; +import { CustomColor } from '@/design-system/color/useForegroundColor'; const imageSize = 72; @@ -28,67 +27,31 @@ export default function BackupSheetSectionNoProvider() { const { colors } = useTheme(); const { navigate, goBack } = useNavigation(); const { selectedWallet } = useWallets(); + const createBackup = useCreateBackup(); + const { status } = backupsStore(state => ({ + status: state.status, + })); - const { onSubmit, loading } = useCreateBackup({ - walletId: selectedWallet.id, - navigateToRoute: { - route: Routes.SETTINGS_SHEET, - params: { - screen: Routes.SETTINGS_SECTION_BACKUP, - }, - }, - }); - - const onCloudBackup = async () => { - if (loading !== 'none') { - return; - } - // NOTE: On Android we need to make sure the user is signed into a Google account before trying to backup - // otherwise we'll fake backup and it's confusing... - if (IS_ANDROID) { - try { - await login(); - getGoogleAccountUserData().then((accountDetails: GoogleDriveUserData | undefined) => { - if (!accountDetails) { - Alert.alert(lang.t(lang.l.back_up.errors.no_account_found)); - return; - } - }); - } catch (e) { - logger.error(new RainbowError('[BackupSheetSectionNoProvider]: No account found'), { - error: e, - }); - Alert.alert(lang.t(lang.l.back_up.errors.no_account_found)); - } - } else { - const isAvailable = await isCloudBackupAvailable(); - if (!isAvailable) { - Alert.alert( - lang.t(lang.l.modal.back_up.alerts.cloud_not_enabled.label), - lang.t(lang.l.modal.back_up.alerts.cloud_not_enabled.description), - [ - { - onPress: () => { - Linking.openURL('https://support.apple.com/en-us/HT204025'); - }, - text: lang.t(lang.l.modal.back_up.alerts.cloud_not_enabled.show_me), - }, - { - style: 'cancel', - text: lang.t(lang.l.modal.back_up.alerts.cloud_not_enabled.no_thanks), - }, - ] - ); - return; - } - } + const onCloudBackup = useCallback(() => { + // pop the bottom sheet, and navigate to the backup section inside settings sheet + goBack(); + navigate(Routes.SETTINGS_SHEET, { + screen: Routes.SETTINGS_SECTION_BACKUP, + initial: false, + }); - onSubmit({}); - }; + executeFnIfCloudBackupAvailable({ + fn: () => + createBackup({ + walletId: selectedWallet.id, + }), + logout: true, + }); + }, [createBackup, goBack, navigate, selectedWallet.id]); - const onManualBackup = async () => { + const onManualBackup = useCallback(async () => { const title = - selectedWallet?.imported && selectedWallet.type === walletTypes.privateKey + selectedWallet?.imported && selectedWallet.type === WalletTypes.privateKey ? (selectedWallet.addresses || [])[0].label : selectedWallet.name; @@ -102,13 +65,38 @@ export default function BackupSheetSectionNoProvider() { walletId: selectedWallet.id, }, }); - }; + }, [goBack, navigate, selectedWallet.addresses, selectedWallet.id, selectedWallet?.imported, selectedWallet.name, selectedWallet.type]); + + const isCloudBackupDisabled = useMemo(() => { + return status !== CloudBackupState.Ready && status !== CloudBackupState.NotAvailable; + }, [status]); + + const { color, text } = useMemo<{ text: string; color: TextColor | CustomColor }>(() => { + if (status === CloudBackupState.FailedToInitialize || status === CloudBackupState.NotAvailable) { + return { + text: i18n.t(i18n.l.back_up.cloud.statuses.not_enabled), + color: 'primary (Deprecated)', + }; + } + + if (status === CloudBackupState.Ready) { + return { + text: i18n.t(i18n.l.back_up.cloud.cloud_backup), + color: 'primary (Deprecated)', + }; + } + + return { + text: i18n.t(i18n.l.back_up.cloud.statuses.syncing), + color: 'yellow', + }; + }, [status]); return ( - {lang.t(lang.l.back_up.cloud.how_would_you_like_to_backup)} + {i18n.t(i18n.l.back_up.cloud.how_would_you_like_to_backup)} @@ -116,8 +104,7 @@ export default function BackupSheetSectionNoProvider() { - {/* replace this with BackUpMenuButton */} - + @@ -133,18 +120,18 @@ export default function BackupSheetSectionNoProvider() { marginRight={{ custom: -12 }} marginTop={{ custom: 0 }} marginBottom={{ custom: -8 }} - source={WalletsAndBackupIcon as Source} + source={WalletsAndBackupIcon} width={{ custom: imageSize }} size={imageSize} /> - - {lang.t(lang.l.back_up.cloud.cloud_backup)} + + {text} - {lang.t(lang.l.back_up.cloud.recommended_for_beginners)} + {i18n.t(i18n.l.back_up.cloud.recommended_for_beginners)} {' '} - {lang.t(lang.l.back_up.cloud.choose_backup_cloud_description, { + {i18n.t(i18n.l.back_up.cloud.choose_backup_cloud_description, { cloudPlatform, })} @@ -192,10 +179,10 @@ export default function BackupSheetSectionNoProvider() { size={imageSize} /> - {lang.t(lang.l.back_up.cloud.manual_backup)} + {i18n.t(i18n.l.back_up.cloud.manual_backup)} - {lang.t(lang.l.back_up.cloud.choose_backup_manual_description)} + {i18n.t(i18n.l.back_up.cloud.choose_backup_manual_description)} diff --git a/src/components/backup/ChooseBackupStep.tsx b/src/components/backup/ChooseBackupStep.tsx index 2f7b68cedf6..d08d4cdb0e2 100644 --- a/src/components/backup/ChooseBackupStep.tsx +++ b/src/components/backup/ChooseBackupStep.tsx @@ -6,26 +6,24 @@ import { useDimensions } from '@/hooks'; import { useNavigation } from '@/navigation'; import styled from '@/styled-thing'; import { margin, padding } from '@/styles'; -import { Box, Stack, Text } from '@/design-system'; -import { RouteProp, useRoute } from '@react-navigation/native'; +import { Box, Stack } from '@/design-system'; import { sharedCoolModalTopOffset } from '@/navigation/config'; -import { ImgixImage } from '../images'; +import { ImgixImage } from '@/components/images'; import MenuContainer from '@/screens/SettingsSheet/components/MenuContainer'; import Menu from '@/screens/SettingsSheet/components/Menu'; import { format } from 'date-fns'; import MenuItem from '@/screens/SettingsSheet/components/MenuItem'; import Routes from '@/navigation/routesNames'; -import { Backup, parseTimestampFromFilename } from '@/model/backup'; -import { RestoreSheetParams } from '@/screens/RestoreSheet'; +import { BackupFile, parseTimestampFromFilename } from '@/model/backup'; import { Source } from 'react-native-fast-image'; import { IS_ANDROID } from '@/env'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import useCloudBackups, { CloudBackupStep } from '@/hooks/useCloudBackups'; -import { Centered } from '../layout'; -import { cloudPlatform } from '@/utils/platform'; -import Spinner from '../Spinner'; -import ActivityIndicator from '../ActivityIndicator'; +import { Page } from '@/components/layout'; +import Spinner from '@/components/Spinner'; +import ActivityIndicator from '@/components/ActivityIndicator'; import { useTheme } from '@/theme'; +import { backupsStore, CloudBackupState, LoadingStates } from '@/state/backups/backups'; +import { titleForBackupState } from '@/screens/SettingsSheet/utils'; const Title = styled(RNText).attrs({ align: 'left', @@ -53,60 +51,27 @@ const Masthead = styled(Box).attrs({ }); export function ChooseBackupStep() { - const { - params: { fromSettings }, - } = useRoute>(); const { colors } = useTheme(); - const { isFetching, backups, userData, step, fetchBackups } = useCloudBackups(); + const { status, backups, mostRecentBackup } = backupsStore(state => ({ + status: state.status, + backups: state.backups, + mostRecentBackup: state.mostRecentBackup, + })); + + const isLoading = LoadingStates.includes(status); const { top } = useSafeAreaInsets(); const { height: deviceHeight } = useDimensions(); const { navigate } = useNavigation(); - const cloudBackups = backups.files - .filter(backup => { - if (IS_ANDROID) { - return !backup.name.match(/UserData/i); - } - - return backup.isFile && backup.size > 0 && !backup.name.match(/UserData/i); - }) - .sort((a, b) => { - return parseTimestampFromFilename(b.name) - parseTimestampFromFilename(a.name); - }); - - const mostRecentBackup = cloudBackups.reduce( - (prev, current) => { - if (!current) { - return prev; - } - - if (!prev) { - return current; - } - - const prevTimestamp = new Date(prev.lastModified).getTime(); - const currentTimestamp = new Date(current.lastModified).getTime(); - if (currentTimestamp > prevTimestamp) { - return current; - } - - return prev; - }, - undefined as Backup | undefined - ); - const onSelectCloudBackup = useCallback( - (selectedBackup: Backup) => { + (selectedBackup: BackupFile) => { navigate(Routes.RESTORE_CLOUD_SHEET, { - backups, - userData, selectedBackup, - fromSettings, }); }, - [navigate, userData, backups, fromSettings] + [navigate] ); const height = IS_ANDROID ? deviceHeight - top : deviceHeight - sharedCoolModalTopOffset - 48; @@ -132,7 +97,7 @@ export function ChooseBackupStep() { - {!isFetching && step === CloudBackupStep.FAILED && ( + {status === CloudBackupState.FailedToInitialize && ( backupsStore.getState().syncAndFetchBackups()} titleComponent={} /> )} - {!isFetching && !cloudBackups.length && step !== CloudBackupStep.FAILED && ( + {status === CloudBackupState.Ready && backups.files.length === 0 && ( + + + } + /> + + + - } /> + backupsStore.getState().syncAndFetchBackups()} + titleComponent={} + /> )} - {!isFetching && cloudBackups.length > 0 && ( + {status === CloudBackupState.Ready && backups.files.length > 0 && ( {mostRecentBackup && ( - - } - onPress={() => onSelectCloudBackup(mostRecentBackup)} - size={52} - width="full" - titleComponent={} - /> - + + + } + onPress={() => onSelectCloudBackup(mostRecentBackup)} + size={52} + width="full" + titleComponent={} + /> + + )} - - {cloudBackups.map( - backup => - backup.name !== mostRecentBackup?.name && ( + + + + {backups.files + .filter(backup => backup.name !== mostRecentBackup?.name) + .sort((a, b) => { + const timestampA = new Date(parseTimestampFromFilename(a.name)).getTime(); + const timestampB = new Date(parseTimestampFromFilename(b.name)).getTime(); + return timestampB - timestampA; + }) + .map(backup => ( + onSelectCloudBackup(backup)} + size={52} + width="full" + titleComponent={ + + } + /> + ))} + {backups.files.length === 1 && ( onSelectCloudBackup(backup)} + disabled size={52} - width="full" - titleComponent={ - - } + titleComponent={} /> - ) - )} + )} + + - {cloudBackups.length === 1 && ( + } + width="full" + onPress={() => backupsStore.getState().syncAndFetchBackups()} + titleComponent={} /> - )} - + + )} - {isFetching && ( - + {isLoading && ( + {android ? : } - - {lang.t(lang.l.back_up.cloud.fetching_backups, { - cloudPlatformName: cloudPlatform, - })} - - + {titleForBackupState[status]} + )} diff --git a/src/components/backup/CloudBackupProvider.tsx b/src/components/backup/CloudBackupProvider.tsx deleted file mode 100644 index 377e9d13a83..00000000000 --- a/src/components/backup/CloudBackupProvider.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react'; -import type { BackupUserData, CloudBackups } from '@/model/backup'; -import { - fetchAllBackups, - fetchUserDataFromCloud, - getGoogleAccountUserData, - isCloudBackupAvailable, - syncCloud, -} from '@/handlers/cloudBackup'; -import { RainbowError, logger } from '@/logger'; -import { IS_ANDROID } from '@/env'; - -type CloudBackupContext = { - isFetching: boolean; - backups: CloudBackups; - fetchBackups: () => Promise; - userData: BackupUserData | undefined; -}; - -const CloudBackupContext = createContext({} as CloudBackupContext); - -export function CloudBackupProvider({ children }: PropsWithChildren) { - const [isFetching, setIsFetching] = useState(false); - const [backups, setBackups] = useState({ - files: [], - }); - - const [userData, setUserData] = useState(); - - const fetchBackups = async () => { - try { - setIsFetching(true); - const isAvailable = await isCloudBackupAvailable(); - if (!isAvailable) { - logger.debug('[CloudBackupProvider]: Cloud backup is not available'); - setIsFetching(false); - return; - } - - if (IS_ANDROID) { - const gdata = await getGoogleAccountUserData(); - if (!gdata) { - return; - } - } - - logger.debug('[CloudBackupProvider]: Syncing with cloud'); - await syncCloud(); - - logger.debug('[CloudBackupProvider]: Fetching user data'); - const userData = await fetchUserDataFromCloud(); - setUserData(userData); - - logger.debug('[CloudBackupProvider]: Fetching all backups'); - const backups = await fetchAllBackups(); - - logger.debug(`[CloudBackupProvider]: Retrieved ${backups.files.length} backup files`); - setBackups(backups); - } catch (e) { - logger.error(new RainbowError('[CloudBackupProvider]: Failed to fetch all backups'), { - error: e, - }); - } - setIsFetching(false); - }; - - useEffect(() => { - fetchBackups(); - }, []); - - const value = { - isFetching, - backups, - fetchBackups, - userData, - }; - - return {children}; -} - -export function useCloudBackups() { - const context = useContext(CloudBackupContext); - if (context === null) { - throw new Error('useCloudBackups must be used within a CloudBackupProvider'); - } - return context; -} diff --git a/src/components/backup/RestoreCloudStep.tsx b/src/components/backup/RestoreCloudStep.tsx index e8bd83aa7a3..ce0774f2ec3 100644 --- a/src/components/backup/RestoreCloudStep.tsx +++ b/src/components/backup/RestoreCloudStep.tsx @@ -6,8 +6,7 @@ import WalletAndBackup from '@/assets/WalletsAndBackup.png'; import { KeyboardArea } from 'react-native-keyboard-area'; import { - Backup, - fetchBackupPassword, + BackupFile, getLocalBackupPassword, restoreCloudBackup, RestoreCloudBackupResultStates, @@ -17,10 +16,10 @@ import { cloudPlatform } from '@/utils/platform'; import { PasswordField } from '../fields'; import { Text } from '../text'; import { WrappedAlert as Alert } from '@/helpers/alert'; -import { cloudBackupPasswordMinLength, isCloudBackupPasswordValid, normalizeAndroidBackupFilename } from '@/handlers/cloudBackup'; +import { isCloudBackupPasswordValid, normalizeAndroidBackupFilename } from '@/handlers/cloudBackup'; import walletBackupTypes from '@/helpers/walletBackupTypes'; import { useDimensions, useInitializeWallet } from '@/hooks'; -import { useNavigation } from '@/navigation'; +import { Navigation, useNavigation } from '@/navigation'; import { addressSetSelected, setAllWalletsWithIdsAsBackedUp, walletsLoadState, walletsSetSelected } from '@/redux/wallets'; import Routes from '@/navigation/routesNames'; import styled from '@/styled-thing'; @@ -35,8 +34,16 @@ import RainbowButtonTypes from '../buttons/rainbow-button/RainbowButtonTypes'; import { RouteProp, useRoute } from '@react-navigation/native'; import { RestoreSheetParams } from '@/screens/RestoreSheet'; import { Source } from 'react-native-fast-image'; -import { useTheme } from '@/theme'; -import useCloudBackups from '@/hooks/useCloudBackups'; +import { ThemeContextProps, useTheme } from '@/theme'; +import { WalletLoadingStates } from '@/helpers/walletLoadingStates'; +import { isEmpty } from 'lodash'; +import { backupsStore } from '@/state/backups/backups'; +import { walletLoadingStore } from '@/state/walletLoading/walletLoading'; + +type ComponentProps = { + theme: ThemeContextProps; + color: ThemeContextProps['colors'][keyof ThemeContextProps['colors']]; +}; const Title = styled(Text).attrs({ size: 'big', @@ -45,7 +52,7 @@ const Title = styled(Text).attrs({ ...padding.object(12, 0, 0), }); -const DescriptionText = styled(Text).attrs(({ theme: { colors }, color }: any) => ({ +const DescriptionText = styled(Text).attrs(({ theme: { colors }, color }: ComponentProps) => ({ align: 'left', color: color || colors.alpha(colors.blueGreyDark, 0.5), lineHeight: 'looser', @@ -53,7 +60,7 @@ const DescriptionText = styled(Text).attrs(({ theme: { colors }, color }: any) = weight: 'medium', }))({}); -const ButtonText = styled(Text).attrs(({ theme: { colors }, color }: any) => ({ +const ButtonText = styled(Text).attrs(({ theme: { colors }, color }: ComponentProps) => ({ align: 'center', letterSpacing: 'rounded', color: color || colors.alpha(colors.blueGreyDark, 0.5), @@ -71,38 +78,46 @@ const Masthead = styled(Box).attrs({ }); const KeyboardSizeView = styled(KeyboardArea)({ - backgroundColor: ({ theme: { colors } }: any) => colors.transparent, + backgroundColor: ({ theme: { colors } }: ComponentProps) => colors.transparent, }); type RestoreCloudStepParams = { RestoreSheet: { - selectedBackup: Backup; + selectedBackup: BackupFile; }; }; export default function RestoreCloudStep() { const { params } = useRoute>(); + const { password } = backupsStore(state => ({ + password: state.password, + })); - const { userData } = useCloudBackups(); + const loadingState = walletLoadingStore(state => state.loadingState); const { selectedBackup } = params; const { isDarkMode } = useTheme(); - const [loading, setLoading] = useState(false); + const { canGoBack, goBack } = useNavigation(); + + const onRestoreSuccess = useCallback(() => { + while (canGoBack()) { + goBack(); + } + }, [canGoBack, goBack]); const dispatch = useDispatch(); const { width: deviceWidth, height: deviceHeight } = useDimensions(); - const { replace, navigate, getState: dangerouslyGetState, goBack } = useNavigation(); const [validPassword, setValidPassword] = useState(false); const [incorrectPassword, setIncorrectPassword] = useState(false); - const [password, setPassword] = useState(''); const passwordRef = useRef(null); const initializeWallet = useInitializeWallet(); useEffect(() => { const fetchPasswordIfPossible = async () => { - const pwd = await fetchBackupPassword(); + const pwd = await getLocalBackupPassword(); if (pwd) { - setPassword(pwd); + backupsStore.getState().setStoredPassword(pwd); + backupsStore.getState().setPassword(pwd); } }; fetchPasswordIfPossible(); @@ -118,35 +133,42 @@ export default function RestoreCloudStep() { }, [incorrectPassword, password]); const onPasswordChange = useCallback(({ nativeEvent: { text: inputText } }: { nativeEvent: { text: string } }) => { - setPassword(inputText); + backupsStore.getState().setPassword(inputText); setIncorrectPassword(false); }, []); const onSubmit = useCallback(async () => { - setLoading(true); + // NOTE: Localizing password to prevent an empty string from being saved if we re-render + const pwd = password.trim(); + let filename = selectedBackup.name; + + const prevWalletsState = await dispatch(walletsLoadState()); + try { if (!selectedBackup.name) { throw new Error('No backup file selected'); } - const prevWalletsState = await dispatch(walletsLoadState()); - + walletLoadingStore.setState({ + loadingState: WalletLoadingStates.RESTORING_WALLET, + }); const status = await restoreCloudBackup({ - password, - userData, - nameOfSelectedBackupFile: selectedBackup.name, + password: pwd, + backupFilename: filename, }); - if (status === RestoreCloudBackupResultStates.success) { // Store it in the keychain in case it was missing - const hasSavedPassword = await getLocalBackupPassword(); - if (!hasSavedPassword) { - await saveLocalBackupPassword(password); + if (backupsStore.getState().storedPassword !== pwd) { + await saveLocalBackupPassword(pwd); + } + + // Reset the storedPassword state for next restoration process + if (backupsStore.getState().storedPassword) { + backupsStore.getState().setStoredPassword(''); } InteractionManager.runAfterInteractions(async () => { const newWalletsState = await dispatch(walletsLoadState()); - let filename = selectedBackup.name; if (IS_ANDROID && filename) { filename = normalizeAndroidBackupFilename(filename); } @@ -188,14 +210,21 @@ export default function RestoreCloudStep() { const p2 = dispatch(addressSetSelected(firstAddress)); await Promise.all([p1, p2]); await initializeWallet(null, null, null, false, false, null, true, null); - - const operation = dangerouslyGetState()?.index === 1 ? navigate : replace; - operation(Routes.SWIPE_LAYOUT, { - screen: Routes.WALLET_SCREEN, - }); - - setLoading(false); }); + + onRestoreSuccess(); + backupsStore.getState().setPassword(''); + if (isEmpty(prevWalletsState)) { + Navigation.handleAction( + Routes.SWIPE_LAYOUT, + { + screen: Routes.WALLET_SCREEN, + }, + true + ); + } else { + Navigation.handleAction(Routes.WALLET_SCREEN, {}); + } } else { switch (status) { case RestoreCloudBackupResultStates.incorrectPassword: @@ -211,18 +240,17 @@ export default function RestoreCloudStep() { } } catch (e) { Alert.alert(lang.t('back_up.restore_cloud.error_while_restoring')); + } finally { + walletLoadingStore.setState({ + loadingState: null, + }); } - - setLoading(false); - }, [selectedBackup.name, password, userData, dispatch, initializeWallet, dangerouslyGetState, navigate, replace]); + }, [password, selectedBackup.name, dispatch, onRestoreSuccess, initializeWallet]); const onPasswordSubmit = useCallback(() => { validPassword && onSubmit(); }, [onSubmit, validPassword]); - const isPasswordValid = - (password !== '' && password.length < cloudBackupPasswordMinLength && !passwordRef?.current?.isFocused()) || incorrectPassword; - return ( @@ -248,8 +276,8 @@ export default function RestoreCloudStep() { ; }; }; -export type useCreateBackupStateType = 'none' | 'loading' | 'success' | 'error'; +type ConfirmBackupProps = { + password: string; +} & UseCreateBackupProps; -export enum BackupTypes { - Single = 'single', - All = 'all', -} - -export const useCreateBackup = ({ walletId, navigateToRoute }: UseCreateBackupProps) => { +export const useCreateBackup = () => { const dispatch = useDispatch(); const { navigate } = useNavigation(); - const { fetchBackups } = useCloudBackups(); const walletCloudBackup = useWalletCloudBackup(); const { wallets } = useWallets(); - const latestBackup = useMemo(() => findLatestBackUp(wallets), [wallets]); - const [loading, setLoading] = useState('none'); - - const [password, setPassword] = useState(''); const setLoadingStateWithTimeout = useCallback( - (state: useCreateBackupStateType, resetInMS = 2500) => { - setLoading(state); + ({ state, outOfSync = false, failInMs = 10_000 }: { state: CloudBackupState; outOfSync?: boolean; failInMs?: number }) => { + backupsStore.getState().setStatus(state); + if (outOfSync) { + setTimeout(() => { + backupsStore.getState().setStatus(CloudBackupState.Syncing); + }, 1_000); + } setTimeout(() => { - setLoading('none'); - }, resetInMS); + const currentState = backupsStore.getState().status; + if (currentState === state) { + backupsStore.getState().setStatus(CloudBackupState.Ready); + } + }, failInMs); }, - [setLoading] + [] + ); + + const onSuccess = useCallback( + async (password: string) => { + if (backupsStore.getState().storedPassword !== password) { + await saveLocalBackupPassword(password); + } + // Reset the storedPassword state for next backup + backupsStore.getState().setStoredPassword(''); + analytics.track('Backup Complete', { + category: 'backup', + label: cloudPlatform, + }); + setLoadingStateWithTimeout({ + state: CloudBackupState.Success, + outOfSync: true, + }); + backupsStore.getState().syncAndFetchBackups(); + }, + [setLoadingStateWithTimeout] ); - const onSuccess = useCallback(async () => { - const hasSavedPassword = await getLocalBackupPassword(); - if (!hasSavedPassword) { - await saveLocalBackupPassword(password); - } - analytics.track('Backup Complete', { - category: 'backup', - label: cloudPlatform, - }); - setLoadingStateWithTimeout('success'); - fetchBackups(); - }, [setLoadingStateWithTimeout, fetchBackups, password]); const onError = useCallback( - (msg: string) => { + (msg: string, isDamaged?: boolean) => { InteractionManager.runAfterInteractions(async () => { - DelayedAlert({ title: msg }, 500); - setLoadingStateWithTimeout('error', 5000); + if (isDamaged) { + showWalletErrorAlert(); + } else { + DelayedAlert({ title: msg }, 500); + } + setLoadingStateWithTimeout({ state: CloudBackupState.Error }); }); }, [setLoadingStateWithTimeout] ); const onConfirmBackup = useCallback( - async ({ password, type }: { password: string; type: BackupTypes }) => { + async ({ password, walletId, navigateToRoute }: ConfirmBackupProps) => { analytics.track('Tapped "Confirm Backup"'); - setLoading('loading'); + backupsStore.getState().setStatus(CloudBackupState.InProgress); - if (type === BackupTypes.All) { + if (typeof walletId === 'undefined') { if (!wallets) { - onError('Error loading wallets. Please try again.'); - setLoading('error'); + onError(i18n.t(i18n.l.back_up.errors.no_keys_found)); + backupsStore.getState().setStatus(CloudBackupState.Error); + return; + } + + const validWallets = Object.fromEntries(Object.entries(wallets).filter(([_, wallet]) => !wallet.damaged)); + if (Object.keys(validWallets).length === 0) { + onError(i18n.t(i18n.l.back_up.errors.no_keys_found), true); + backupsStore.getState().setStatus(CloudBackupState.Error); return; } + backupAllWalletsToCloud({ - wallets: wallets as AllRainbowWallets, + wallets: validWallets, password, - latestBackup, onError, onSuccess, dispatch, @@ -94,12 +114,6 @@ export const useCreateBackup = ({ walletId, navigateToRoute }: UseCreateBackupPr return; } - if (!walletId) { - onError('Wallet not found. Please try again.'); - setLoading('error'); - return; - } - await walletCloudBackup({ onError, onSuccess, @@ -111,13 +125,13 @@ export const useCreateBackup = ({ walletId, navigateToRoute }: UseCreateBackupPr navigate(navigateToRoute.route, navigateToRoute.params || {}); } }, - [walletId, walletCloudBackup, onError, onSuccess, navigateToRoute, wallets, latestBackup, dispatch, navigate] + [walletCloudBackup, onError, wallets, onSuccess, dispatch, navigate] ); - const getPassword = useCallback(async (): Promise => { + const getPassword = useCallback(async (props: UseCreateBackupProps): Promise => { const password = await getLocalBackupPassword(); if (password) { - setPassword(password); + backupsStore.getState().setStoredPassword(password); return password; } @@ -126,32 +140,36 @@ export const useCreateBackup = ({ walletId, navigateToRoute }: UseCreateBackupPr nativeScreen: true, step: walletBackupStepTypes.backup_cloud, onSuccess: async (password: string) => { - setPassword(password); - resolve(password); + return resolve(password); }, onCancel: async () => { - resolve(null); + return resolve(null); }, - walletId, + ...props, }); }); - }, [walletId]); + }, []); - const onSubmit = useCallback( - async ({ type = BackupTypes.Single }: { type?: BackupTypes }) => { - const password = await getPassword(); - if (password) { - onConfirmBackup({ - password, - type, + const createBackup = useCallback( + async (props: UseCreateBackupProps) => { + if (backupsStore.getState().status !== CloudBackupState.Ready) { + return false; + } + const password = await getPassword(props); + if (!password) { + setLoadingStateWithTimeout({ + state: CloudBackupState.Ready, }); - return true; + return false; } - setLoadingStateWithTimeout('error'); - return false; + onConfirmBackup({ + password, + ...props, + }); + return true; }, [getPassword, onConfirmBackup, setLoadingStateWithTimeout] ); - return { onSuccess, onError, onSubmit, loading }; + return createBackup; }; diff --git a/src/components/fields/PasswordField.tsx b/src/components/fields/PasswordField.tsx index 0925b29862c..6d28e81e802 100644 --- a/src/components/fields/PasswordField.tsx +++ b/src/components/fields/PasswordField.tsx @@ -1,14 +1,37 @@ import React, { forwardRef, useCallback, Ref } from 'react'; -import { useTheme } from '../../theme/ThemeContext'; +import { ThemeContextProps, useTheme } from '../../theme/ThemeContext'; import { Input } from '../inputs'; import { cloudBackupPasswordMinLength } from '@/handlers/cloudBackup'; import { useDimensions } from '@/hooks'; import styled from '@/styled-thing'; -import { padding } from '@/styles'; +import { padding, position } from '@/styles'; import ShadowStack from '@/react-native-shadow-stack'; import { Box } from '@/design-system'; import { TextInput, TextInputProps, View } from 'react-native'; import { IS_IOS, IS_ANDROID } from '@/env'; +import { Icon } from '../icons'; + +const FieldAccessoryBadgeSize = 22; +const FieldAccessoryBadgeWrapper = styled(ShadowStack).attrs( + ({ theme: { colors, isDarkMode }, color }: { theme: ThemeContextProps; color: string }) => ({ + ...position.sizeAsObject(FieldAccessoryBadgeSize), + borderRadius: FieldAccessoryBadgeSize, + shadows: [[0, 4, 12, isDarkMode ? colors.shadow : color, isDarkMode ? 0.1 : 0.4]], + }) +)({ + marginBottom: 12, + position: 'absolute', + right: 12, + top: 12, +}); + +function FieldAccessoryBadge({ color, name }: { color: string; name: string }) { + return ( + + + + ); +} const Container = styled(Box)({ width: '100%', @@ -53,9 +76,9 @@ interface PasswordFieldProps extends TextInputProps { } const PasswordField = forwardRef( - ({ password, returnKeyType = 'done', style, textContentType, ...props }, ref: Ref) => { + ({ password, isInvalid, returnKeyType = 'done', style, textContentType, ...props }, ref: Ref) => { const { width: deviceWidth } = useDimensions(); - const { isDarkMode } = useTheme(); + const { isDarkMode, colors } = useTheme(); const handleFocus = useCallback(() => { if (ref && 'current' in ref && ref.current) { @@ -67,6 +90,7 @@ const PasswordField = forwardRef( + {isInvalid && } ); diff --git a/src/components/floating-emojis/FloatingEmojis.js b/src/components/floating-emojis/FloatingEmojis.js deleted file mode 100644 index f4dc8342f50..00000000000 --- a/src/components/floating-emojis/FloatingEmojis.js +++ /dev/null @@ -1,156 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Animated, View } from 'react-native'; -import FloatingEmoji from './FloatingEmoji'; -import GravityEmoji from './GravityEmoji'; -import { useTimeout } from '@/hooks'; -import { position } from '@/styles'; - -const EMPTY_ARRAY = []; -const getEmoji = emojis => Math.floor(Math.random() * emojis.length); -const getRandomNumber = (min, max) => Math.random() * (max - min) + min; - -const FloatingEmojis = ({ - centerVertically, - children, - disableHorizontalMovement, - disableRainbow, - disableVerticalMovement, - distance, - duration, - emojis, - fadeOut, - gravityEnabled, - marginTop, - opacity, - opacityThreshold, - range, - scaleTo, - setOnNewEmoji, - size, - wiggleFactor, - ...props -}) => { - const emojisArray = useMemo(() => (Array.isArray(emojis) ? emojis : [emojis]), [emojis]); - const [floatingEmojis, setEmojis] = useState(EMPTY_ARRAY); - const [startTimeout, stopTimeout] = useTimeout(); - const clearEmojis = useCallback(() => setEmojis(EMPTY_ARRAY), []); - - // 🚧️ TODO: 🚧️ - // Clear emojis if page navigatorPosition falls below 0.93 (which we should call like `pageTransitionThreshold` or something) - // otherwise, the FloatingEmojis look weird during stack transitions - - const onNewEmoji = useCallback( - (x, y) => { - // Set timeout to automatically clearEmojis after the latest one has finished animating - stopTimeout(); - startTimeout(clearEmojis, duration * 1.1); - - setEmojis(existingEmojis => { - const newEmoji = { - // if a user has smashed the button 7 times, they deserve a 🌈 rainbow - emojiToRender: - (existingEmojis.length + 1) % 7 === 0 && !disableRainbow - ? 'rainbow' - : emojisArray.length === 1 - ? emojisArray[0] - : emojisArray[getEmoji(emojisArray)], - x: x ? x - getRandomNumber(-20, 20) : getRandomNumber(...range), - y: y || 0, - }; - return [...existingEmojis, newEmoji]; - }); - }, - [clearEmojis, disableRainbow, duration, emojisArray, range, startTimeout, stopTimeout] - ); - - useEffect(() => { - setOnNewEmoji?.(onNewEmoji); - return () => setOnNewEmoji?.(undefined); - }, [setOnNewEmoji, onNewEmoji]); - - return ( - - {typeof children === 'function' ? children({ onNewEmoji }) : children} - - {gravityEnabled - ? floatingEmojis.map(({ emojiToRender, x, y }, index) => ( - - )) - : floatingEmojis.map(({ emojiToRender, x, y }, index) => ( - - ))} - - - ); -}; - -FloatingEmojis.propTypes = { - centerVertically: PropTypes.bool, - children: PropTypes.node, - disableHorizontalMovement: PropTypes.bool, - disableRainbow: PropTypes.bool, - disableVerticalMovement: PropTypes.bool, - distance: PropTypes.number, - duration: PropTypes.number, - emojis: PropTypes.arrayOf(PropTypes.string).isRequired, - fadeOut: PropTypes.bool, - gravityEnabled: PropTypes.bool, - marginTop: PropTypes.number, - opacity: PropTypes.oneOfType([PropTypes.number, PropTypes.object]), - opacityThreshold: PropTypes.number, - range: PropTypes.arrayOf(PropTypes.number), - scaleTo: PropTypes.number, - setOnNewEmoji: PropTypes.func, - size: PropTypes.string.isRequired, - wiggleFactor: PropTypes.number, -}; - -FloatingEmojis.defaultProps = { - distance: 130, - duration: 2000, - // Defaults the emoji to 👍️ (thumbs up). - // To view complete list of emojis compatible with this component, - // head to https://github.com/muan/unicode-emoji-json/blob/master/data-by-emoji.json - emojis: ['thumbs_up'], - fadeOut: true, - opacity: 1, - range: [0, 80], - scaleTo: 1, - size: 30, - wiggleFactor: 0.5, -}; - -export default FloatingEmojis; diff --git a/src/components/floating-emojis/FloatingEmojis.tsx b/src/components/floating-emojis/FloatingEmojis.tsx new file mode 100644 index 00000000000..7eccc8b69de --- /dev/null +++ b/src/components/floating-emojis/FloatingEmojis.tsx @@ -0,0 +1,151 @@ +import React, { useCallback, useEffect, useMemo, useState, ReactNode } from 'react'; +import { Animated, View, ViewProps } from 'react-native'; +import FloatingEmoji from './FloatingEmoji'; +import GravityEmoji from './GravityEmoji'; +import { useTimeout } from '@/hooks'; +import { position } from '@/styles'; +import { DebugLayout } from '@/design-system'; +import { DEVICE_HEIGHT, DEVICE_WIDTH } from '@/utils/deviceUtils'; +import { AbsolutePortal } from '../AbsolutePortal'; + +interface Emoji { + emojiToRender: string; + x: number; + y: number; +} + +interface FloatingEmojisProps extends Omit { + centerVertically?: boolean; + children?: ReactNode | ((props: { onNewEmoji: (x?: number, y?: number) => void }) => ReactNode); + disableHorizontalMovement?: boolean; + disableRainbow?: boolean; + disableVerticalMovement?: boolean; + distance?: number; + duration?: number; + emojis: string[]; + fadeOut?: boolean; + gravityEnabled?: boolean; + marginTop?: number; + opacity?: number | Animated.AnimatedInterpolation; + opacityThreshold?: number; + range?: [number, number]; + scaleTo?: number; + setOnNewEmoji?: (fn: ((x?: number, y?: number) => void) | undefined) => void; + size: number; + wiggleFactor?: number; +} + +const EMPTY_ARRAY: Emoji[] = []; +const getEmoji = (emojis: string[]) => Math.floor(Math.random() * emojis.length); +const getRandomNumber = (min: number, max: number) => Math.random() * (max - min) + min; + +const FloatingEmojis: React.FC = ({ + centerVertically, + children, + disableHorizontalMovement, + disableRainbow, + disableVerticalMovement, + distance = 130, + duration = 2000, + emojis, + fadeOut = true, + gravityEnabled, + marginTop, + opacity = 1, + opacityThreshold, + range: [rangeMin, rangeMax] = [0, 80], + scaleTo = 1, + setOnNewEmoji, + size = 30, + wiggleFactor = 0.5, + style, + ...props +}) => { + const emojisArray = useMemo(() => (Array.isArray(emojis) ? emojis : [emojis]), [emojis]); + const [floatingEmojis, setEmojis] = useState(EMPTY_ARRAY); + const [startTimeout, stopTimeout] = useTimeout(); + const clearEmojis = useCallback(() => setEmojis(EMPTY_ARRAY), []); + + // 🚧️ TODO: 🚧️ + // Clear emojis if page navigatorPosition falls below 0.93 (which we should call like `pageTransitionThreshold` or something) + // otherwise, the FloatingEmojis look weird during stack transitions + + const onNewEmoji = useCallback( + (x?: number, y?: number) => { + // Set timeout to automatically clearEmojis after the latest one has finished animating + stopTimeout(); + startTimeout(clearEmojis, duration * 1.1); + + setEmojis(existingEmojis => { + const newEmoji = { + emojiToRender: + (existingEmojis.length + 1) % 7 === 0 && !disableRainbow + ? 'rainbow' + : emojisArray.length === 1 + ? emojisArray[0] + : emojisArray[getEmoji(emojisArray)], + x: x !== undefined ? x - getRandomNumber(-20, 20) : getRandomNumber(rangeMin, rangeMax), + y: y || 0, + }; + return [...existingEmojis, newEmoji]; + }); + }, + [clearEmojis, disableRainbow, duration, emojisArray, rangeMin, rangeMax, startTimeout, stopTimeout] + ); + + useEffect(() => { + setOnNewEmoji?.(onNewEmoji); + return () => setOnNewEmoji?.(undefined); + }, [setOnNewEmoji, onNewEmoji]); + + return ( + + {typeof children === 'function' ? children({ onNewEmoji }) : children} + + + {gravityEnabled + ? floatingEmojis.map(({ emojiToRender, x, y }, index) => ( + + )) + : floatingEmojis.map(({ emojiToRender, x, y }, index) => ( + + ))} + + + + ); +}; + +export default FloatingEmojis; diff --git a/src/components/floating-emojis/GravityEmoji.tsx b/src/components/floating-emojis/GravityEmoji.tsx index 2bf06a3901f..0b1de95b47c 100644 --- a/src/components/floating-emojis/GravityEmoji.tsx +++ b/src/components/floating-emojis/GravityEmoji.tsx @@ -4,7 +4,9 @@ import { Emoji } from '../text'; interface GravityEmojiProps { distance: number; + duration: number; emoji: string; + index: number; left: number; size: number; top: number; diff --git a/src/components/remote-promo-sheet/runChecks.ts b/src/components/remote-promo-sheet/runChecks.ts index f83170eecce..9167de41182 100644 --- a/src/components/remote-promo-sheet/runChecks.ts +++ b/src/components/remote-promo-sheet/runChecks.ts @@ -1,7 +1,6 @@ import { IS_TEST } from '@/env'; -import { runFeatureUnlockChecks } from '@/handlers/walletReadyEvents'; +import { runFeaturesLocalCampaignAndBackupChecks } from '@/handlers/walletReadyEvents'; import { logger } from '@/logger'; -import { runLocalCampaignChecks } from '@/components/remote-promo-sheet/localCampaignChecks'; import { checkForRemotePromoSheet } from '@/components/remote-promo-sheet/checkForRemotePromoSheet'; import { useCallback, useEffect } from 'react'; import { InteractionManager } from 'react-native'; @@ -19,11 +18,7 @@ export const useRunChecks = ({ runChecksOnMount = true, walletReady }: { runChec return; } - const showedFeatureUnlock = await runFeatureUnlockChecks(); - if (showedFeatureUnlock) return; - - const showedLocalPromo = await runLocalCampaignChecks(); - if (showedLocalPromo) return; + if (await runFeaturesLocalCampaignAndBackupChecks()) return; if (!remotePromoSheets) { logger.debug('[useRunChecks]: remote promo sheets is disabled'); diff --git a/src/components/secret-display/SecretDisplaySection.tsx b/src/components/secret-display/SecretDisplaySection.tsx index 0ef93ba05e6..3cd37f05611 100644 --- a/src/components/secret-display/SecretDisplaySection.tsx +++ b/src/components/secret-display/SecretDisplaySection.tsx @@ -1,5 +1,4 @@ import { RouteProp, useRoute } from '@react-navigation/native'; -import { captureException } from '@sentry/react-native'; import React, { ReactNode, useCallback, useEffect, useState } from 'react'; import { createdWithBiometricError, identifyWalletType, loadPrivateKey, loadSeedPhraseAndMigrateIfNeeded } from '@/model/wallet'; import ActivityIndicator from '../ActivityIndicator'; @@ -25,6 +24,7 @@ import { useNavigation } from '@/navigation'; import { ImgixImage } from '../images'; import RoutesWithPlatformDifferences from '@/navigation/routesNames'; import { Source } from 'react-native-fast-image'; +import { backupsStore } from '@/state/backups/backups'; const MIN_HEIGHT = 740; @@ -63,6 +63,9 @@ export function SecretDisplaySection({ onSecretLoaded, onWalletTypeIdentified }: const { colors } = useTheme(); const { params } = useRoute>(); const { selectedWallet, wallets } = useWallets(); + const { backupProvider } = backupsStore(state => ({ + backupProvider: state.backupProvider, + })); const { onManuallyBackupWalletId } = useWalletManualBackup(); const { navigate } = useNavigation(); @@ -124,9 +127,12 @@ export function SecretDisplaySection({ onSecretLoaded, onWalletTypeIdentified }: const handleConfirmSaved = useCallback(() => { if (backupType === WalletBackupTypes.manual) { onManuallyBackupWalletId(walletId); + if (!backupProvider) { + backupsStore.getState().setBackupProvider(WalletBackupTypes.manual); + } navigate(RoutesWithPlatformDifferences.SETTINGS_SECTION_BACKUP); } - }, [backupType, walletId, onManuallyBackupWalletId, navigate]); + }, [backupType, onManuallyBackupWalletId, walletId, backupProvider, navigate]); const getIconForBackupType = useCallback(() => { if (isBackingUp) { diff --git a/src/design-system/components/Inline/Inline.tsx b/src/design-system/components/Inline/Inline.tsx index 5754bae6a93..3f93791cbc8 100644 --- a/src/design-system/components/Inline/Inline.tsx +++ b/src/design-system/components/Inline/Inline.tsx @@ -51,7 +51,7 @@ export function Inline({ > {wrap || !separator ? children - : Children.map(children, (child, index) => { + : Children.toArray(children).map((child, index) => { if (!child) return null; return ( <> diff --git a/src/handlers/cloudBackup.ts b/src/handlers/cloudBackup.ts index 1eb3f5be795..14347c42a75 100644 --- a/src/handlers/cloudBackup.ts +++ b/src/handlers/cloudBackup.ts @@ -1,14 +1,15 @@ import { sortBy } from 'lodash'; // @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'reac... Remove this comment to see the full error message import RNCloudFs from 'react-native-cloud-fs'; -import { RAINBOW_MASTER_KEY } from 'react-native-dotenv'; import RNFS from 'react-native-fs'; import AesEncryptor from '../handlers/aesEncryption'; import { logger, RainbowError } from '@/logger'; import { IS_ANDROID, IS_IOS } from '@/env'; -import { CloudBackups } from '@/model/backup'; +import { BackupFile, CloudBackups } from '@/model/backup'; + const REMOTE_BACKUP_WALLET_DIR = 'rainbow.me/wallet-backups'; -const USERDATA_FILE = 'UserData.json'; +export const USERDATA_FILE = 'UserData.json'; + const encryptor = new AesEncryptor(); export const CLOUD_BACKUP_ERRORS = { @@ -65,13 +66,18 @@ export async function fetchAllBackups(): Promise { if (android) { await RNCloudFs.loginIfNeeded(); } - return RNCloudFs.listFiles({ + + const files = await RNCloudFs.listFiles({ scope: 'hidden', targetPath: REMOTE_BACKUP_WALLET_DIR, }); + + return { + files: files?.files?.filter((file: BackupFile) => normalizeAndroidBackupFilename(file.name) !== USERDATA_FILE) || [], + }; } -export async function encryptAndSaveDataToCloud(data: any, password: any, filename: any) { +export async function encryptAndSaveDataToCloud(data: Record, password: string, filename: string) { // Encrypt the data try { const encryptedData = await encryptor.encrypt(password, JSON.stringify(data)); @@ -100,6 +106,7 @@ export async function encryptAndSaveDataToCloud(data: any, password: any, filena scope, sourcePath: sourceUri, targetPath: destinationPath, + update: true, }); // Now we need to verify the file has been stored in the cloud const exists = await RNCloudFs.fileExists( @@ -201,19 +208,6 @@ export async function getDataFromCloud(backupPassword: any, filename: string | n throw error; } -export async function backupUserDataIntoCloud(data: any) { - const filename = USERDATA_FILE; - const password = RAINBOW_MASTER_KEY; - return encryptAndSaveDataToCloud(data, password, filename); -} - -export async function fetchUserDataFromCloud() { - const filename = USERDATA_FILE; - const password = RAINBOW_MASTER_KEY; - - return getDataFromCloud(password, filename); -} - export const cloudBackupPasswordMinLength = 8; export function isCloudBackupPasswordValid(password: any) { diff --git a/src/handlers/walletReadyEvents.ts b/src/handlers/walletReadyEvents.ts index 1cfa62be144..b62749da519 100644 --- a/src/handlers/walletReadyEvents.ts +++ b/src/handlers/walletReadyEvents.ts @@ -1,4 +1,3 @@ -import { IS_TESTING } from 'react-native-dotenv'; import { triggerOnSwipeLayout } from '../navigation/onNavigationStateChange'; import { getKeychainIntegrityState } from './localstorage/globalSettings'; import { runLocalCampaignChecks } from '@/components/remote-promo-sheet/localCampaignChecks'; @@ -6,18 +5,15 @@ import { EthereumAddress } from '@/entities'; import WalletBackupStepTypes from '@/helpers/walletBackupStepTypes'; import WalletTypes from '@/helpers/walletTypes'; import { featureUnlockChecks } from '@/featuresToUnlock'; -import { AllRainbowWallets, RainbowAccount, RainbowWallet } from '@/model/wallet'; +import { AllRainbowWallets, RainbowAccount } from '@/model/wallet'; import { Navigation } from '@/navigation'; import store from '@/redux/store'; import { checkKeychainIntegrity } from '@/redux/wallets'; import Routes from '@/navigation/routesNames'; import { logger } from '@/logger'; -import { checkWalletsForBackupStatus } from '@/screens/SettingsSheet/utils'; -import walletBackupTypes from '@/helpers/walletBackupTypes'; -import { InteractionManager } from 'react-native'; - -const BACKUP_SHEET_DELAY_MS = 3000; +import { IS_TEST } from '@/env'; +import { backupsStore, LoadingStates } from '@/state/backups/backups'; export const runKeychainIntegrityChecks = async () => { const keychainIntegrityState = await getKeychainIntegrityState(); @@ -26,60 +22,38 @@ export const runKeychainIntegrityChecks = async () => { } }; -export const runWalletBackupStatusChecks = () => { - const { - selected, - wallets, - }: { - wallets: AllRainbowWallets | null; - selected: RainbowWallet | undefined; - } = store.getState().wallets; - - // count how many visible, non-imported and non-readonly wallets are not backed up - if (!wallets) return; - - const { backupProvider } = checkWalletsForBackupStatus(wallets); - - const rainbowWalletsNotBackedUp = Object.values(wallets).filter(wallet => { - const hasVisibleAccount = wallet.addresses?.find((account: RainbowAccount) => account.visible); - return ( - !wallet.imported && - !!hasVisibleAccount && - wallet.type !== WalletTypes.readOnly && - wallet.type !== WalletTypes.bluetooth && - !wallet.backedUp - ); +const delay = (ms: number) => + new Promise(resolve => { + setTimeout(resolve, ms); }); - if (!rainbowWalletsNotBackedUp.length) return; - - logger.debug('[walletReadyEvents]: there is a rainbow wallet not backed up'); +const promptForBackupOnceReadyOrNotAvailable = async (): Promise => { + const { status } = backupsStore.getState(); + if (LoadingStates.includes(status)) { + await delay(1000); + return promptForBackupOnceReadyOrNotAvailable(); + } - const hasSelectedWallet = rainbowWalletsNotBackedUp.find(notBackedUpWallet => notBackedUpWallet.id === selected!.id); - logger.debug('[walletReadyEvents]: rainbow wallet not backed up that is selected?', { - hasSelectedWallet, - }); + logger.debug(`[walletReadyEvents]: BackupSheet: showing backup now sheet for selected wallet`); + triggerOnSwipeLayout(() => + Navigation.handleAction(Routes.BACKUP_SHEET, { + step: WalletBackupStepTypes.backup_prompt, + }) + ); + return true; +}; - // if one of them is selected, show the default BackupSheet - if (selected && hasSelectedWallet && IS_TESTING !== 'true') { - let stepType: string = WalletBackupStepTypes.no_provider; - if (backupProvider === walletBackupTypes.cloud) { - stepType = WalletBackupStepTypes.backup_now_to_cloud; - } else if (backupProvider === walletBackupTypes.manual) { - stepType = WalletBackupStepTypes.backup_now_manually; - } +export const runWalletBackupStatusChecks = async (): Promise => { + const { selected } = store.getState().wallets; + if (!selected || IS_TEST) return false; - setTimeout(() => { - logger.debug(`[walletReadyEvents]: showing ${stepType} backup sheet for selected wallet`); - triggerOnSwipeLayout(() => - Navigation.handleAction(Routes.BACKUP_SHEET, { - step: stepType, - }) - ); - }, BACKUP_SHEET_DELAY_MS); - return; + const selectedWalletNeedsBackedUp = + !selected.backedUp && !selected.damaged && selected.type !== WalletTypes.readOnly && selected.type !== WalletTypes.bluetooth; + if (selectedWalletNeedsBackedUp) { + logger.debug('[walletReadyEvents]: Selected wallet is not backed up, prompting backup sheet'); + return promptForBackupOnceReadyOrNotAvailable(); } - return; + return false; }; export const runFeatureUnlockChecks = async (): Promise => { @@ -107,19 +81,24 @@ export const runFeatureUnlockChecks = async (): Promise => { // short circuits once the first feature is unlocked for (const featureUnlockCheck of featureUnlockChecks) { - InteractionManager.runAfterInteractions(async () => { - const unlockNow = await featureUnlockCheck(walletsToCheck); - if (unlockNow) { - return true; - } - }); + const unlockNow = await featureUnlockCheck(walletsToCheck); + if (unlockNow) { + return true; + } } return false; }; -export const runFeatureAndLocalCampaignChecks = async () => { - const showingFeatureUnlock: boolean = await runFeatureUnlockChecks(); - if (!showingFeatureUnlock) { - await runLocalCampaignChecks(); +export const runFeaturesLocalCampaignAndBackupChecks = async () => { + if (await runFeatureUnlockChecks()) { + return true; } + if (await runLocalCampaignChecks()) { + return true; + } + if (await runWalletBackupStatusChecks()) { + return true; + } + + return false; }; diff --git a/src/helpers/walletBackupStepTypes.ts b/src/helpers/walletBackupStepTypes.ts index d3afc9598a2..2fbf0cb8f9e 100644 --- a/src/helpers/walletBackupStepTypes.ts +++ b/src/helpers/walletBackupStepTypes.ts @@ -1,5 +1,5 @@ export default { - no_provider: 'no_provider', + backup_prompt: 'backup_prompt', backup_manual: 'backup_manual', backup_cloud: 'backup_cloud', restore_from_backup: 'restore_from_backup', diff --git a/src/helpers/walletLoadingStates.ts b/src/helpers/walletLoadingStates.ts new file mode 100644 index 00000000000..a9cdd674d2e --- /dev/null +++ b/src/helpers/walletLoadingStates.ts @@ -0,0 +1,10 @@ +import * as i18n from '@/languages'; + +export const WalletLoadingStates = { + BACKING_UP_WALLET: i18n.t('loading.backing_up'), + CREATING_WALLET: i18n.t('loading.creating_wallet'), + IMPORTING_WALLET: i18n.t('loading.importing_wallet'), + RESTORING_WALLET: i18n.t('loading.restoring'), +} as const; + +export type WalletLoadingStates = (typeof WalletLoadingStates)[keyof typeof WalletLoadingStates]; diff --git a/src/hooks/useActiveRoute.ts b/src/hooks/useActiveRoute.ts new file mode 100644 index 00000000000..523eb741004 --- /dev/null +++ b/src/hooks/useActiveRoute.ts @@ -0,0 +1,16 @@ +import { Navigation, useNavigation } from '@/navigation'; +import { useEffect, useState } from 'react'; + +export const useActiveRoute = () => { + const { addListener } = useNavigation(); + const [activeRoute, setActiveRoute] = useState(Navigation.getActiveRoute()); + + useEffect(() => { + const unsubscribe = addListener('state', () => { + setActiveRoute(Navigation.getActiveRoute()); + }); + return unsubscribe; + }, [addListener]); + + return activeRoute?.name; +}; diff --git a/src/hooks/useCloudBackups.ts b/src/hooks/useCloudBackups.ts deleted file mode 100644 index 506e669c682..00000000000 --- a/src/hooks/useCloudBackups.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect, useState } from 'react'; -import type { BackupUserData, CloudBackups } from '../model/backup'; -import { fetchAllBackups, fetchUserDataFromCloud, isCloudBackupAvailable, syncCloud } from '@/handlers/cloudBackup'; -import { RainbowError, logger } from '@/logger'; - -export const enum CloudBackupStep { - IDLE, - SYNCING, - FETCHING_USER_DATA, - FETCHING_ALL_BACKUPS, - FAILED, -} - -export default function useCloudBackups() { - const [isFetching, setIsFetching] = useState(false); - const [backups, setBackups] = useState({ - files: [], - }); - - const [step, setStep] = useState(CloudBackupStep.SYNCING); - - const [userData, setUserData] = useState(); - - const fetchBackups = async () => { - try { - setIsFetching(true); - const isAvailable = isCloudBackupAvailable(); - if (!isAvailable) { - logger.debug('[useCloudBackups]: Cloud backup is not available'); - setIsFetching(false); - setStep(CloudBackupStep.IDLE); - return; - } - - setStep(CloudBackupStep.SYNCING); - logger.debug('[useCloudBackups]: Syncing with cloud'); - await syncCloud(); - - setStep(CloudBackupStep.FETCHING_USER_DATA); - logger.debug('[useCloudBackups]: Fetching user data'); - const userData = await fetchUserDataFromCloud(); - setUserData(userData); - - setStep(CloudBackupStep.FETCHING_ALL_BACKUPS); - logger.debug('[useCloudBackups]: Fetching all backups'); - const backups = await fetchAllBackups(); - - logger.debug(`[useCloudBackups]: Retrieved ${backups.files.length} backup files`); - setBackups(backups); - setStep(CloudBackupStep.IDLE); - } catch (e) { - setStep(CloudBackupStep.FAILED); - logger.error(new RainbowError('[useCloudBackups]: Failed to fetch all backups'), { - error: e, - }); - } - setIsFetching(false); - }; - - useEffect(() => { - fetchBackups(); - }, []); - - return { - isFetching, - backups, - fetchBackups, - userData, - step, - }; -} diff --git a/src/hooks/useImportingWallet.ts b/src/hooks/useImportingWallet.ts index d0f92e12a3a..f1cde6313da 100644 --- a/src/hooks/useImportingWallet.ts +++ b/src/hooks/useImportingWallet.ts @@ -3,7 +3,6 @@ import lang from 'i18n-js'; import { keys } from 'lodash'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { InteractionManager, Keyboard, TextInput } from 'react-native'; -import { IS_TESTING } from 'react-native-dotenv'; import { useDispatch } from 'react-redux'; import useAccountSettings from './useAccountSettings'; import { fetchENSAvatar } from './useENSAvatar'; @@ -29,9 +28,9 @@ import { deriveAccountFromWalletInput } from '@/utils/wallet'; import { logger, RainbowError } from '@/logger'; import { handleReviewPromptAction } from '@/utils/reviewAlert'; import { ReviewPromptAction } from '@/storage/schema'; -import { checkWalletsForBackupStatus } from '@/screens/SettingsSheet/utils'; -import walletBackupTypes from '@/helpers/walletBackupTypes'; import { ChainId } from '@/state/backendNetworks/types'; +import { backupsStore } from '@/state/backups/backups'; +import { IS_TEST } from '@/env'; export default function useImportingWallet({ showImportModal = true } = {}) { const { accountAddress } = useAccountSettings(); @@ -52,6 +51,10 @@ export default function useImportingWallet({ showImportModal = true } = {}) { const { updateWalletENSAvatars } = useWalletENSAvatar(); const profilesEnabled = useExperimentalFlag(PROFILES); + const { backupProvider } = backupsStore(state => ({ + backupProvider: state.backupProvider, + })); + const inputRef = useRef(null); const { handleFocus } = useMagicAutofocus(inputRef); @@ -291,7 +294,7 @@ export default function useImportingWallet({ showImportModal = true } = {}) { image, true ); - await dispatch(walletsLoadState(profilesEnabled)); + await dispatch(walletsLoadState()); handleSetImporting(false); } else { const previousWalletCount = keys(wallets).length; @@ -346,19 +349,11 @@ export default function useImportingWallet({ showImportModal = true } = {}) { isValidBluetoothDeviceId(input) ) ) { - const { backupProvider } = checkWalletsForBackupStatus(wallets); - - let stepType: string = WalletBackupStepTypes.no_provider; - if (backupProvider === walletBackupTypes.cloud) { - stepType = WalletBackupStepTypes.backup_now_to_cloud; - } else if (backupProvider === walletBackupTypes.manual) { - stepType = WalletBackupStepTypes.backup_now_manually; - } - - IS_TESTING !== 'true' && + if (!IS_TEST) { Navigation.handleAction(Routes.BACKUP_SHEET, { - step: stepType, + step: WalletBackupStepTypes.backup_prompt, }); + } } }, 1000); @@ -414,6 +409,7 @@ export default function useImportingWallet({ showImportModal = true } = {}) { showImportModal, profilesEnabled, dangerouslyGetParent, + backupProvider, ]); return { diff --git a/src/hooks/useInitializeWallet.ts b/src/hooks/useInitializeWallet.ts index 5f934050e9d..80aa4e903ea 100644 --- a/src/hooks/useInitializeWallet.ts +++ b/src/hooks/useInitializeWallet.ts @@ -68,7 +68,7 @@ export default function useInitializeWallet() { if (shouldRunMigrations && !seedPhrase) { logger.debug('[useInitializeWallet]: shouldRunMigrations && !seedPhrase? => true'); - await dispatch(walletsLoadState(profilesEnabled)); + await dispatch(walletsLoadState()); logger.debug('[useInitializeWallet]: walletsLoadState call #1'); await runMigrations(); logger.debug('[useInitializeWallet]: done with migrations'); @@ -110,7 +110,7 @@ export default function useInitializeWallet() { if (seedPhrase || isNew) { logger.debug('[useInitializeWallet]: walletsLoadState call #2'); - await dispatch(walletsLoadState(profilesEnabled)); + await dispatch(walletsLoadState()); } if (isNil(walletAddress)) { diff --git a/src/hooks/useManageCloudBackups.ts b/src/hooks/useManageCloudBackups.ts index 141f26b7f4e..323fd1d62db 100644 --- a/src/hooks/useManageCloudBackups.ts +++ b/src/hooks/useManageCloudBackups.ts @@ -3,12 +3,21 @@ import lang from 'i18n-js'; import { useDispatch } from 'react-redux'; import { cloudPlatform } from '../utils/platform'; import { WrappedAlert as Alert } from '@/helpers/alert'; -import { GoogleDriveUserData, getGoogleAccountUserData, deleteAllBackups, logoutFromGoogleDrive } from '@/handlers/cloudBackup'; -import { clearAllWalletsBackupStatus, updateWalletBackupStatusesBasedOnCloudUserData } from '@/redux/wallets'; +import { + GoogleDriveUserData, + getGoogleAccountUserData, + deleteAllBackups, + logoutFromGoogleDrive as logout, + login, +} from '@/handlers/cloudBackup'; +import { clearAllWalletsBackupStatus } from '@/redux/wallets'; import { showActionSheetWithOptions } from '@/utils'; import { IS_ANDROID } from '@/env'; import { RainbowError, logger } from '@/logger'; import * as i18n from '@/languages'; +import { backupsStore, CloudBackupState } from '@/state/backups/backups'; +import * as keychain from '@/keychain'; +import { authenticateWithPIN } from '@/handlers/authentication'; export default function useManageCloudBackups() { const dispatch = useDispatch(); @@ -48,10 +57,21 @@ export default function useManageCloudBackups() { await dispatch(clearAllWalletsBackupStatus()); }; + const logoutFromGoogleDrive = async () => { + await logout(); + backupsStore.setState({ + backupProvider: undefined, + backups: { files: [] }, + mostRecentBackup: undefined, + status: CloudBackupState.NotAvailable, + }); + }; + const loginToGoogleDrive = async () => { - await dispatch(updateWalletBackupStatusesBasedOnCloudUserData()); try { + await login(); const accountDetails = await getGoogleAccountUserData(); + backupsStore.getState().syncAndFetchBackups(); setAccountDetails(accountDetails ?? undefined); } catch (error) { logger.error(new RainbowError(`[useManageCloudBackups]: Logging into Google Drive failed.`), { @@ -78,14 +98,36 @@ export default function useManageCloudBackups() { }, async (buttonIndex: any) => { if (buttonIndex === 0) { - if (IS_ANDROID) { - logoutFromGoogleDrive(); - setAccountDetails(undefined); - } - removeBackupStateFromAllWallets(); + try { + let userPIN: string | undefined; + const hasBiometricsEnabled = await keychain.getSupportedBiometryType(); + if (IS_ANDROID && !hasBiometricsEnabled) { + try { + userPIN = (await authenticateWithPIN()) ?? undefined; + } catch (e) { + Alert.alert(i18n.t(i18n.l.back_up.wrong_pin)); + return; + } + } - await deleteAllBackups(); - Alert.alert(lang.t('back_up.backup_deleted_successfully')); + // Prompt for authentication before allowing them to delete backups + await keychain.getAllKeys(); + + if (IS_ANDROID) { + logoutFromGoogleDrive(); + setAccountDetails(undefined); + } + removeBackupStateFromAllWallets(); + + await deleteAllBackups(); + Alert.alert(lang.t('back_up.backup_deleted_successfully')); + } catch (e) { + logger.error(new RainbowError(`[useManageCloudBackups]: Error deleting all backups`), { + error: (e as Error).message, + }); + + Alert.alert(lang.t('back_up.errors.keychain_access')); + } } } ); @@ -94,7 +136,7 @@ export default function useManageCloudBackups() { if (_buttonIndex === 1 && IS_ANDROID) { logoutFromGoogleDrive(); setAccountDetails(undefined); - removeBackupStateFromAllWallets().then(() => loginToGoogleDrive()); + loginToGoogleDrive(); } } ); diff --git a/src/hooks/useUpdateEmoji.ts b/src/hooks/useUpdateEmoji.ts index d38f229ae20..7a6781788b0 100644 --- a/src/hooks/useUpdateEmoji.ts +++ b/src/hooks/useUpdateEmoji.ts @@ -17,11 +17,11 @@ export default function useUpdateEmoji() { const saveInfo = useCallback( async (name: string, color: number) => { const walletId = selectedWallet.id; - const newWallets: typeof wallets = { + const newWallets = { ...wallets, [walletId]: { ...wallets![walletId], - addresses: wallets![walletId].addresses.map((singleAddress: { address: string }) => + addresses: wallets![walletId].addresses.map(singleAddress => singleAddress.address.toLowerCase() === accountAddress.toLowerCase() ? { ...singleAddress, diff --git a/src/hooks/useWalletCloudBackup.ts b/src/hooks/useWalletCloudBackup.ts index 57b9caac681..cb5d6350a5e 100644 --- a/src/hooks/useWalletCloudBackup.ts +++ b/src/hooks/useWalletCloudBackup.ts @@ -1,16 +1,14 @@ -import { captureException } from '@sentry/react-native'; -import lang from 'i18n-js'; import { values } from 'lodash'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { Linking } from 'react-native'; import { useDispatch } from 'react-redux'; -import { addWalletToCloudBackup, backupWalletToCloud, findLatestBackUp } from '../model/backup'; +import { backupWalletToCloud } from '../model/backup'; import { setWalletBackedUp } from '../redux/wallets'; import { cloudPlatform } from '../utils/platform'; import useWallets from './useWallets'; import { WrappedAlert as Alert } from '@/helpers/alert'; import { analytics } from '@/analytics'; -import { CLOUD_BACKUP_ERRORS, isCloudBackupAvailable } from '@/handlers/cloudBackup'; +import { CLOUD_BACKUP_ERRORS, getGoogleAccountUserData, isCloudBackupAvailable, login } from '@/handlers/cloudBackup'; import WalletBackupTypes from '@/helpers/walletBackupTypes'; import { logger, RainbowError } from '@/logger'; import { getSupportedBiometryType } from '@/keychain'; @@ -41,7 +39,6 @@ export function getUserError(e: Error) { export default function useWalletCloudBackup() { const dispatch = useDispatch(); const { wallets } = useWallets(); - const latestBackup = useMemo(() => findLatestBackUp(wallets), [wallets]); const walletCloudBackup = useCallback( async ({ @@ -52,36 +49,63 @@ export default function useWalletCloudBackup() { }: { handleNoLatestBackup?: () => void; handlePasswordNotFound?: () => void; - onError?: (error: string) => void; - onSuccess?: () => void; + onError?: (error: string, isDamaged?: boolean) => void; + onSuccess?: (password: string) => void; password: string; walletId: string; }): Promise => { - const isAvailable = await isCloudBackupAvailable(); - if (!isAvailable) { - analytics.track('iCloud not enabled', { - category: 'backup', - }); - Alert.alert(lang.t('modal.back_up.alerts.cloud_not_enabled.label'), lang.t('modal.back_up.alerts.cloud_not_enabled.description'), [ - { - onPress: () => { - Linking.openURL('https://support.apple.com/en-us/HT204025'); - analytics.track('View how to Enable iCloud', { - category: 'backup', - }); - }, - text: lang.t('modal.back_up.alerts.cloud_not_enabled.show_me'), - }, - { - onPress: () => { - analytics.track('Ignore how to enable iCloud', { - category: 'backup', - }); - }, - style: 'cancel', - text: lang.t('modal.back_up.alerts.cloud_not_enabled.no_thanks'), - }, - ]); + if (IS_ANDROID) { + try { + await login(); + const userData = await getGoogleAccountUserData(); + if (!userData) { + Alert.alert(i18n.t(i18n.l.back_up.errors.no_account_found)); + return false; + } + } catch (e) { + logger.error(new RainbowError('[BackupSheetSectionNoProvider]: No account found'), { + error: e, + }); + Alert.alert(i18n.t(i18n.l.back_up.errors.no_account_found)); + return false; + } + } else { + const isAvailable = await isCloudBackupAvailable(); + if (!isAvailable) { + analytics.track('iCloud not enabled', { + category: 'backup', + }); + Alert.alert( + i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.label), + i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.description), + [ + { + onPress: () => { + Linking.openURL('https://support.apple.com/en-us/HT204025'); + analytics.track('View how to Enable iCloud', { + category: 'backup', + }); + }, + text: i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.show_me), + }, + { + onPress: () => { + analytics.track('Ignore how to enable iCloud', { + category: 'backup', + }); + }, + style: 'cancel', + text: i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.no_thanks), + }, + ] + ); + return false; + } + } + + const wallet = wallets?.[walletId]; + if (wallet?.damaged) { + onError?.(i18n.t(i18n.l.back_up.errors.damaged_wallet), true); return false; } @@ -101,23 +125,14 @@ export default function useWalletCloudBackup() { logger.debug('[useWalletCloudBackup]: password fetched correctly'); let updatedBackupFile = null; + try { - if (!latestBackup) { - logger.debug(`[useWalletCloudBackup]: backing up to ${cloudPlatform}: ${wallets![walletId]}`); - updatedBackupFile = await backupWalletToCloud({ - password, - wallet: wallets![walletId], - userPIN, - }); - } else { - logger.debug(`[useWalletCloudBackup]: adding wallet to ${cloudPlatform} backup: ${wallets![walletId]}`); - updatedBackupFile = await addWalletToCloudBackup({ - password, - wallet: wallets![walletId], - filename: latestBackup, - userPIN, - }); - } + logger.debug(`[useWalletCloudBackup]: backing up to ${cloudPlatform}: ${(wallets || {})[walletId]}`); + updatedBackupFile = await backupWalletToCloud({ + password, + wallet: (wallets || {})[walletId], + userPIN, + }); } catch (e: any) { const userError = getUserError(e); !!onError && onError(userError); @@ -134,7 +149,7 @@ export default function useWalletCloudBackup() { logger.debug('[useWalletCloudBackup]: backup completed!'); await dispatch(setWalletBackedUp(walletId, WalletBackupTypes.cloud, updatedBackupFile)); logger.debug('[useWalletCloudBackup]: backup saved everywhere!'); - !!onSuccess && onSuccess(); + !!onSuccess && onSuccess(password); return true; } catch (e) { logger.error(new RainbowError(`[useWalletCloudBackup]: error while trying to save wallet backup state: ${e}`)); @@ -148,7 +163,7 @@ export default function useWalletCloudBackup() { return false; }, - [dispatch, latestBackup, wallets] + [dispatch, wallets] ); return walletCloudBackup; diff --git a/src/hooks/useWallets.ts b/src/hooks/useWallets.ts index 38363886917..20de06f22a1 100644 --- a/src/hooks/useWallets.ts +++ b/src/hooks/useWallets.ts @@ -5,14 +5,12 @@ import { RainbowWallet } from '@/model/wallet'; import { AppState } from '@/redux/store'; const walletSelector = createSelector( - ({ wallets: { isWalletLoading, selected = {} as RainbowWallet, walletNames, wallets } }: AppState) => ({ - isWalletLoading, - selectedWallet: selected as any, + ({ wallets: { selected = {} as RainbowWallet, walletNames, wallets } }: AppState) => ({ + selectedWallet: selected, walletNames, wallets, }), - ({ isWalletLoading, selectedWallet, walletNames, wallets }) => ({ - isWalletLoading, + ({ selectedWallet, walletNames, wallets }) => ({ selectedWallet, walletNames, wallets, @@ -20,13 +18,12 @@ const walletSelector = createSelector( ); export default function useWallets() { - const { isWalletLoading, selectedWallet, walletNames, wallets } = useSelector(walletSelector); + const { selectedWallet, walletNames, wallets } = useSelector(walletSelector); return { isDamaged: selectedWallet?.damaged, isReadOnlyWallet: selectedWallet.type === WalletTypes.readOnly, isHardwareWallet: !!selectedWallet.deviceId, - isWalletLoading, selectedWallet, walletNames, wallets, diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 4bfe7a65a0d..13044d44f18 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -96,7 +96,8 @@ "generic": "Error while trying to backup. Error code: %{errorCodes}", "no_keys_found": "No keys found. Please try again.", "backup_not_found": "Backup not found. Please try again.", - "no_account_found": "Unable to retrieve backup files. Make sure you're logged in." + "no_account_found": "Unable to retrieve backup files. Make sure you're logged in.", + "damaged_wallet": "Unable to backup wallet. Missing keychain data." }, "wrong_pin": "The PIN code you entered was incorrect and we can't make a backup. Please try again with the correct code.", "already_backed_up": { @@ -115,6 +116,8 @@ "no_backups": "No backups found", "failed_to_fetch_backups": "Failed to fetch backups", "retry": "Retry", + "refresh": "Refresh", + "syncing_cloud_store": "Syncing to %{cloudPlatformName}", "fetching_backups": "Retrieving backups from %{cloudPlatformName}", "back_up_to_platform": "Back up to %{cloudPlatformName}", "restore_from_platform": "Restore from %{cloudPlatformName}", @@ -137,7 +140,7 @@ "choose_backup_cloud_description": "Securely back up your wallet to %{cloudPlatform} so you can restore it if you lose your device or get a new one.", "choose_backup_manual_description": "Back up your wallet manually by saving your secret phrase in a secure location.", "enable_cloud_backups_description": "If you prefer to back up your wallets manually, you can do so below.", - "latest_backup": "Last Backup: %{date}", + "latest_backup": "Latest Backup: %{date}", "back_up_all_wallets_to_cloud": "Back Up All Wallets to %{cloudPlatformName}", "most_recent_backup": "Most Recent Backup", "out_of_date": "Out of Date", @@ -145,6 +148,12 @@ "older_backups": "Older Backups", "no_older_backups": "No Older Backups", "older_backups_title": "%{date} at %{time}", + "statuses": { + "not_enabled": "Not Enabled", + "syncing": "Syncing", + "out_of_date": "Out of Date", + "up_to_date": "Up to Date" + }, "password": { "a_password_youll_remember_part_one": "This password is", "not": "not", @@ -1220,6 +1229,12 @@ "check_out_this_wallet": "Check out this wallet's collectibles on 🌈 Rainbow at %{showcaseUrl}" } }, + "loading": { + "backing_up": "Backing up...", + "creating_wallet": "Creating wallet...", + "importing_wallet": "Importing...", + "restoring": "Restoring..." + }, "message": { "click_to_copy_to_clipboard": "Click to copy to clipboard", "coming_soon": "Coming soon...", diff --git a/src/model/backup.ts b/src/model/backup.ts index 2eb50a7c297..c838796664d 100644 --- a/src/model/backup.ts +++ b/src/model/backup.ts @@ -1,15 +1,23 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { NativeModules } from 'react-native'; +import { NativeModules, Linking } from 'react-native'; import { captureException } from '@sentry/react-native'; import { endsWith } from 'lodash'; -import { CLOUD_BACKUP_ERRORS, encryptAndSaveDataToCloud, getDataFromCloud } from '@/handlers/cloudBackup'; +import { + CLOUD_BACKUP_ERRORS, + encryptAndSaveDataToCloud, + getDataFromCloud, + isCloudBackupAvailable, + getGoogleAccountUserData, + login, + logoutFromGoogleDrive, + normalizeAndroidBackupFilename, +} from '@/handlers/cloudBackup'; +import { Alert as NativeAlert } from '@/components/alerts'; import WalletBackupTypes from '../helpers/walletBackupTypes'; -import WalletTypes from '../helpers/walletTypes'; -import { Alert } from '@/components/alerts'; import { allWalletsKey, pinKey, privateKeyKey, seedPhraseKey, selectedWalletKey, identifierForVendorKey } from '@/utils/keychainConstants'; import * as keychain from '@/model/keychain'; import * as kc from '@/keychain'; -import { AllRainbowWallets, allWalletsVersion, createWallet, RainbowWallet } from './wallet'; +import { AllRainbowWallets, createWallet, RainbowWallet } from './wallet'; import { analytics } from '@/analytics'; import { logger, RainbowError } from '@/logger'; import { IS_ANDROID, IS_DEV } from '@/env'; @@ -24,16 +32,19 @@ import Routes from '@/navigation/routesNames'; import { clearAllStorages } from './mmkv'; import walletBackupStepTypes from '@/helpers/walletBackupStepTypes'; import { getRemoteConfig } from './remoteConfig'; +import { WrappedAlert as Alert } from '@/helpers/alert'; +import { AppDispatch } from '@/redux/store'; +import { backupsStore, CloudBackupState } from '@/state/backups/backups'; const { DeviceUUID } = NativeModules; const encryptor = new AesEncryptor(); const PIN_REGEX = /^\d{4}$/; export interface CloudBackups { - files: Backup[]; + files: BackupFile[]; } -export interface Backup { +export interface BackupFile { isDirectory: boolean; isFile: boolean; lastModified: string; @@ -44,8 +55,9 @@ export interface Backup { } export const parseTimestampFromFilename = (filename: string) => { + const name = normalizeAndroidBackupFilename(filename); return Number( - filename + name .replace('.backup_', '') .replace('backup_', '') .replace('.json', '') @@ -54,6 +66,27 @@ export const parseTimestampFromFilename = (filename: string) => { ); }; +/** + * Parse the timestamp from a backup file name + * @param filename - The name of the backup file backup_${now}.json + * @returns The timestamp as a number + */ +export const parseTimestampFromBackupFile = (filename: string | null): number | undefined => { + if (!filename) { + return; + } + const match = filename.match(/backup_(\d+)\.json/); + if (!match) { + return; + } + + if (Number.isNaN(Number(match[1]))) { + return; + } + + return Number(match[1]); +}; + type BackupPassword = string; interface BackedUpData { @@ -63,9 +96,72 @@ interface BackedUpData { export interface BackupUserData { wallets: AllRainbowWallets; } +type MaybePromise = T | Promise; + +export const executeFnIfCloudBackupAvailable = async ({ fn, logout = false }: { fn: () => MaybePromise; logout?: boolean }) => { + backupsStore.getState().setStatus(CloudBackupState.InProgress); + + if (IS_ANDROID) { + try { + if (logout) { + await logoutFromGoogleDrive(); + } + + const currentUser = await getGoogleAccountUserData(); + if (!currentUser) { + await login(); + await backupsStore.getState().syncAndFetchBackups(); + } + + const userData = await getGoogleAccountUserData(); + if (!userData) { + Alert.alert(i18n.t(i18n.l.back_up.errors.no_account_found)); + backupsStore.getState().setStatus(CloudBackupState.NotAvailable); + return; + } + // execute the function + + // NOTE: Set this back to ready in order to process the backup + backupsStore.getState().setStatus(CloudBackupState.Ready); + return await fn(); + } catch (e) { + logger.error(new RainbowError('[BackupSheetSectionNoProvider]: No account found'), { + error: e, + }); + Alert.alert(i18n.t(i18n.l.back_up.errors.no_account_found)); + backupsStore.getState().setStatus(CloudBackupState.NotAvailable); + } + } else { + const isAvailable = await isCloudBackupAvailable(); + if (!isAvailable) { + Alert.alert( + i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.label), + i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.description), + [ + { + onPress: () => { + Linking.openURL('https://support.apple.com/en-us/HT204025'); + }, + text: i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.show_me), + }, + { + style: 'cancel', + text: i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.no_thanks), + }, + ] + ); + backupsStore.getState().setStatus(CloudBackupState.NotAvailable); + return; + } + + // NOTE: Set this back to ready in order to process the backup + backupsStore.getState().setStatus(CloudBackupState.Ready); + return await fn(); + } +}; async function extractSecretsForWallet(wallet: RainbowWallet) { - const allKeys = await keychain.loadAllKeys(); + const allKeys = await kc.getAllKeys(); if (!allKeys) throw new Error(CLOUD_BACKUP_ERRORS.KEYCHAIN_ACCESS_ERROR); const secrets = {} as { [key: string]: string }; @@ -100,17 +196,15 @@ async function extractSecretsForWallet(wallet: RainbowWallet) { export async function backupAllWalletsToCloud({ wallets, password, - latestBackup, onError, onSuccess, dispatch, }: { wallets: AllRainbowWallets; password: BackupPassword; - latestBackup: string | null; onError?: (message: string) => void; - onSuccess?: () => void; - dispatch: any; + onSuccess?: (password: BackupPassword) => void; + dispatch: AppDispatch; }) { let userPIN: string | undefined; const hasBiometricsEnabled = await kc.getSupportedBiometryType(); @@ -126,11 +220,9 @@ export async function backupAllWalletsToCloud({ try { /** * Loop over all keys and decrypt if necessary for android - * if no latest backup, create first backup with all secrets - * if latest backup, update updatedAt and add new secrets to the backup */ - const allKeys = await keychain.loadAllKeys(); + const allKeys = await kc.getAllKeys(); if (!allKeys) { onError?.(i18n.t(i18n.l.back_up.errors.no_keys_found)); return; @@ -157,49 +249,21 @@ export async function backupAllWalletsToCloud({ label: cloudPlatform, }); - let updatedBackupFile: any = null; - if (!latestBackup) { - const data = { - createdAt: now, - secrets: {}, - }; - const promises = Object.entries(allSecrets).map(async ([username, password]) => { - const processedNewSecrets = await decryptAllPinEncryptedSecretsIfNeeded({ [username]: password }, userPIN); - - data.secrets = { - ...data.secrets, - ...processedNewSecrets, - }; - }); - - await Promise.all(promises); - updatedBackupFile = await encryptAndSaveDataToCloud(data, password, `backup_${now}.json`); - } else { - // if we have a latest backup file, we need to update the updatedAt and add new secrets to the backup file.. - const backup = await getDataFromCloud(password, latestBackup); - if (!backup) { - onError?.(i18n.t(i18n.l.back_up.errors.backup_not_found)); - return; - } + const data = { + createdAt: now, + secrets: {}, + }; + const promises = Object.entries(allSecrets).map(async ([username, password]) => { + const processedNewSecrets = await decryptAllPinEncryptedSecretsIfNeeded({ [username]: password }, userPIN); - const data = { - createdAt: backup.createdAt, - secrets: backup.secrets, + data.secrets = { + ...data.secrets, + ...processedNewSecrets, }; + }); - const promises = Object.entries(allSecrets).map(async ([username, password]) => { - const processedNewSecrets = await decryptAllPinEncryptedSecretsIfNeeded({ [username]: password }, userPIN); - - data.secrets = { - ...data.secrets, - ...processedNewSecrets, - }; - }); - - await Promise.all(promises); - updatedBackupFile = await encryptAndSaveDataToCloud(data, password, latestBackup); - } - + await Promise.all(promises); + const updatedBackupFile = await encryptAndSaveDataToCloud(data, password, `backup_${now}.json`); const walletIdsToUpdate = Object.keys(wallets); await dispatch(setAllWalletsWithIdsAsBackedUp(walletIdsToUpdate, WalletBackupTypes.cloud, updatedBackupFile)); @@ -209,16 +273,18 @@ export async function backupAllWalletsToCloud({ label: cloudPlatform, }); - onSuccess?.(); - } catch (error: any) { - const userError = getUserError(error); - onError?.(userError); - captureException(error); - analytics.track(`Error backing up all wallets to ${cloudPlatform}`, { - category: 'backup', - error: userError, - label: cloudPlatform, - }); + onSuccess?.(password); + } catch (error) { + if (error instanceof Error) { + const userError = getUserError(error); + onError?.(userError); + captureException(error); + analytics.track(`Error backing up all wallets to ${cloudPlatform}`, { + category: 'backup', + error: userError, + label: cloudPlatform, + }); + } } } @@ -251,9 +317,15 @@ export async function addWalletToCloudBackup({ wallet: RainbowWallet; filename: string; userPIN?: string; -}): Promise { - // @ts-ignore +}): Promise { const backup = await getDataFromCloud(password, filename); + if (!backup) { + logger.error(new RainbowError('[backup]: Unable to get backup data for filename'), { + filename, + }); + return null; + } + const now = Date.now(); const newSecretsToBeAddedToBackup = await extractSecretsForWallet(wallet); const processedNewSecrets = await decryptAllPinEncryptedSecretsIfNeeded(newSecretsToBeAddedToBackup, userPIN); @@ -321,25 +393,6 @@ export async function decryptAllPinEncryptedSecretsIfNeeded(secrets: Record { - // Check if there's a wallet backed up - if (wallet.backedUp && wallet.backupDate && wallet.backupFile && wallet.backupType === WalletBackupTypes.cloud) { - // If there is one, let's grab the latest backup - if (!latestBackup || Number(wallet.backupDate) > latestBackup) { - filename = wallet.backupFile; - latestBackup = Number(wallet.backupDate); - } - } - }); - } - return filename; -} - export const RestoreCloudBackupResultStates = { success: 'success', failedWhenRestoring: 'failedWhenRestoring', @@ -368,16 +421,14 @@ const sanitizeFilename = (filename: string) => { */ export async function restoreCloudBackup({ password, - userData, - nameOfSelectedBackupFile, + backupFilename, }: { password: BackupPassword; - userData: BackupUserData | undefined; - nameOfSelectedBackupFile: string; + backupFilename: string; }): Promise { try { // 1 - sanitize filename to remove extra things we don't care about - const filename = sanitizeFilename(nameOfSelectedBackupFile); + const filename = sanitizeFilename(backupFilename); if (!filename) { return RestoreCloudBackupResultStates.failedWhenRestoring; } @@ -402,26 +453,6 @@ export async function restoreCloudBackup({ } } - if (userData) { - // Restore only wallets that were backed up in cloud - // or wallets that are read-only - const walletsToRestore: AllRainbowWallets = {}; - Object.values(userData?.wallets ?? {}).forEach(wallet => { - if ( - (wallet.backedUp && wallet.backupDate && wallet.backupFile && wallet.backupType === WalletBackupTypes.cloud) || - wallet.type === WalletTypes.readOnly - ) { - walletsToRestore[wallet.id] = wallet; - } - }); - - // All wallets - dataToRestore[allWalletsKey] = { - version: allWalletsVersion, - wallets: walletsToRestore, - }; - } - const restoredSuccessfully = await restoreSpecificBackupIntoKeychain(dataToRestore, userPIN); return restoredSuccessfully ? RestoreCloudBackupResultStates.success : RestoreCloudBackupResultStates.failedWhenRestoring; } catch (error) { @@ -525,74 +556,6 @@ async function restoreSpecificBackupIntoKeychain(backedUpData: BackedUpData, use } } -async function restoreCurrentBackupIntoKeychain(backedUpData: BackedUpData, newPIN?: string): Promise { - try { - // Access control config per each type of key - const privateAccessControlOptions = await keychain.getPrivateAccessControlOptions(); - const encryptedBackupPinData = backedUpData[pinKey]; - const backupPIN = await decryptPIN(encryptedBackupPinData); - - await Promise.all( - Object.keys(backedUpData).map(async key => { - let value = backedUpData[key]; - const theKeyIsASeedPhrase = endsWith(key, seedPhraseKey); - const theKeyIsAPrivateKey = endsWith(key, privateKeyKey); - const accessControl: typeof kc.publicAccessControlOptions = - theKeyIsASeedPhrase || theKeyIsAPrivateKey ? privateAccessControlOptions : kc.publicAccessControlOptions; - - /* - * Backups that were saved encrypted with PIN to the cloud need to be - * decrypted with the backup PIN first, and then if we still need - * to store them as encrypted, - * we need to re-encrypt them with a new PIN - */ - if (theKeyIsASeedPhrase) { - const parsedValue = JSON.parse(value); - parsedValue.seedphrase = await decryptSecretFromBackupPin({ - secret: parsedValue.seedphrase, - backupPIN, - }); - value = JSON.stringify(parsedValue); - } else if (theKeyIsAPrivateKey) { - const parsedValue = JSON.parse(value); - parsedValue.privateKey = await decryptSecretFromBackupPin({ - secret: parsedValue.privateKey, - backupPIN, - }); - value = JSON.stringify(parsedValue); - } - - /* - * Since we're decrypting the data that was saved as PIN code encrypted, - * we will allow the user to create a new PIN code. - * We store the old PIN code in the backup, but we don't want to restore it, - * since it will override the new PIN code that we just saved to keychain. - */ - if (key === pinKey) { - return; - } - - if (typeof value === 'string') { - return kc.set(key, value, { - ...accessControl, - androidEncryptionPin: newPIN, - }); - } else { - return kc.setObject(key, value, { - ...accessControl, - androidEncryptionPin: newPIN, - }); - } - }) - ); - - return true; - } catch (e) { - logger.error(new RainbowError(`[backup]: Error restoring current backup into keychain: ${e}`)); - return false; - } -} - async function decryptSecretFromBackupPin({ secret, backupPIN }: { secret?: string; backupPIN?: string }) { let processedSecret = secret; @@ -638,13 +601,9 @@ export async function saveBackupPassword(password: BackupPassword): Promise { - const rainbowBackupPassword = await keychain.loadString('RainbowBackupPassword'); - if (typeof rainbowBackupPassword === 'number') { - return null; - } - - if (rainbowBackupPassword) { - return rainbowBackupPassword; + const { value } = await kc.get('RainbowBackupPassword'); + if (value) { + return value; } return await fetchBackupPassword(); @@ -653,7 +612,7 @@ export async function getLocalBackupPassword(): Promise { export async function saveLocalBackupPassword(password: string) { const privateAccessControlOptions = await keychain.getPrivateAccessControlOptions(); - await keychain.saveString('RainbowBackupPassword', password, privateAccessControlOptions); + await kc.set('RainbowBackupPassword', password, privateAccessControlOptions); saveBackupPassword(password); } @@ -666,7 +625,7 @@ export async function fetchBackupPassword(): Promise { try { const { value: results } = await kc.getSharedWebCredentials(); if (results) { - return results.password as BackupPassword; + return results.password; } return null; } catch (e) { @@ -695,7 +654,7 @@ export async function getDeviceUUID(): Promise { } const FailureAlert = () => - Alert({ + NativeAlert({ buttons: [ { style: 'cancel', diff --git a/src/navigation/HardwareWalletTxNavigator.tsx b/src/navigation/HardwareWalletTxNavigator.tsx index 28e290065dc..4b209dabe30 100644 --- a/src/navigation/HardwareWalletTxNavigator.tsx +++ b/src/navigation/HardwareWalletTxNavigator.tsx @@ -63,7 +63,7 @@ export const HardwareWalletTxNavigator = () => { const { navigate } = useNavigation(); - const deviceId = selectedWallet?.deviceId; + const deviceId = selectedWallet.deviceId ?? ''; const [isReady, setIsReady] = useRecoilState(LedgerIsReadyAtom); const [readyForPolling, setReadyForPolling] = useRecoilState(readyForPollingAtom); const [triggerPollerCleanup, setTriggerPollerCleanup] = useRecoilState(triggerPollerCleanupAtom); diff --git a/src/navigation/Routes.android.tsx b/src/navigation/Routes.android.tsx index 05b09059948..1e03c2d96a3 100644 --- a/src/navigation/Routes.android.tsx +++ b/src/navigation/Routes.android.tsx @@ -90,6 +90,8 @@ import { ControlPanel } from '@/components/DappBrowser/control-panel/ControlPane import { ClaimRewardsPanel } from '@/screens/points/claim-flow/ClaimRewardsPanel'; import { ClaimClaimablePanel } from '@/screens/claimables/ClaimPanel'; import { RootStackParamList } from './types'; +import WalletLoadingListener from '@/components/WalletLoadingListener'; +import { Portal as CMPortal } from '@/react-native-cool-modals/Portal'; import { NetworkSelector } from '@/components/NetworkSwitcher'; const Stack = createStackNavigator(); @@ -213,7 +215,7 @@ function BSNavigator() { step === walletBackupStepTypes.restore_from_backup ) { heightForStep = backupSheetSizes.long; - } else if (step === walletBackupStepTypes.no_provider) { + } else if (step === walletBackupStepTypes.backup_prompt) { heightForStep = backupSheetSizes.medium; } @@ -274,6 +276,10 @@ const AppContainerWithAnalytics = React.forwardRef + + {/* NOTE: Internally, these use some navigational checks */} + + )); diff --git a/src/navigation/Routes.ios.tsx b/src/navigation/Routes.ios.tsx index c07ef6294b5..201eb3aa374 100644 --- a/src/navigation/Routes.ios.tsx +++ b/src/navigation/Routes.ios.tsx @@ -103,6 +103,8 @@ import { ControlPanel } from '@/components/DappBrowser/control-panel/ControlPane import { ClaimRewardsPanel } from '@/screens/points/claim-flow/ClaimRewardsPanel'; import { ClaimClaimablePanel } from '@/screens/claimables/ClaimPanel'; import { RootStackParamList } from './types'; +import WalletLoadingListener from '@/components/WalletLoadingListener'; +import { Portal as CMPortal } from '@/react-native-cool-modals/Portal'; import { NetworkSelector } from '@/components/NetworkSwitcher'; const Stack = createStackNavigator(); @@ -289,6 +291,10 @@ const AppContainerWithAnalytics = React.forwardRef + + {/* NOTE: Internally, these use some navigational checks */} + + )); diff --git a/src/navigation/config.tsx b/src/navigation/config.tsx index 38b7a0032fe..9097c84e1d6 100644 --- a/src/navigation/config.tsx +++ b/src/navigation/config.tsx @@ -103,12 +103,10 @@ export const getHeightForStep = (step: string) => { case WalletBackupStepTypes.backup_manual: case WalletBackupStepTypes.restore_from_backup: return backupSheetSizes.long; - case WalletBackupStepTypes.no_provider: + case WalletBackupStepTypes.backup_prompt: return backupSheetSizes.medium; case WalletBackupStepTypes.check_identifier: return backupSheetSizes.check_identifier; - case WalletBackupStepTypes.backup_now_manually: - return backupSheetSizes.shorter; default: return backupSheetSizes.short; } diff --git a/src/react-native-cool-modals/Portal.js b/src/react-native-cool-modals/Portal.js deleted file mode 100644 index 5d03cdadeb8..00000000000 --- a/src/react-native-cool-modals/Portal.js +++ /dev/null @@ -1,50 +0,0 @@ -import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; -import { Platform, requireNativeComponent, StyleSheet, View } from 'react-native'; - -const NativePortalContext = createContext(); - -export function usePortal() { - return useContext(NativePortalContext); -} - -const NativePortal = Platform.OS === 'ios' ? requireNativeComponent('WindowPortal') : View; - -const Wrapper = Platform.OS === 'ios' ? ({ children }) => children : View; - -export function Portal({ children }) { - const [Component, setComponentState] = useState(null); - const [blockTouches, setBlockTouches] = useState(false); - - const hide = useCallback(() => { - setComponentState(); - setBlockTouches(false); - }, []); - - const setComponent = useCallback((value, blockTouches) => { - setComponentState(value); - setBlockTouches(blockTouches); - }, []); - - const contextValue = useMemo( - () => ({ - hide, - setComponent, - }), - [hide, setComponent] - ); - - return ( - - - {children} - - {Component} - - - - ); -} diff --git a/src/react-native-cool-modals/Portal.tsx b/src/react-native-cool-modals/Portal.tsx new file mode 100644 index 00000000000..dd2830ee0b4 --- /dev/null +++ b/src/react-native-cool-modals/Portal.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { IS_IOS } from '@/env'; +import { walletLoadingStore } from '@/state/walletLoading/walletLoading'; +import { requireNativeComponent, StyleSheet, View } from 'react-native'; +import Routes from '@/navigation/routesNames'; +import { useActiveRoute } from '@/hooks/useActiveRoute'; + +const NativePortal = IS_IOS ? requireNativeComponent('WindowPortal') : View; +const Wrapper = IS_IOS ? ({ children }: { children: React.ReactNode }) => children : View; + +export function Portal() { + const activeRoute = useActiveRoute(); + + const { blockTouches, Component } = walletLoadingStore(state => ({ + blockTouches: state.blockTouches, + Component: state.Component, + })); + + if (!Component || (activeRoute === Routes.PIN_AUTHENTICATION_SCREEN && !IS_IOS)) { + return null; + } + + console.log('blockTouches', blockTouches); + + return ( + + + {Component} + + + ); +} diff --git a/src/redux/wallets.ts b/src/redux/wallets.ts index deb49a5ea9b..d17f8b4c0d8 100644 --- a/src/redux/wallets.ts +++ b/src/redux/wallets.ts @@ -3,10 +3,8 @@ import { toChecksumAddress } from 'ethereumjs-util'; import { isEmpty, keys } from 'lodash'; import { Dispatch } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; -import { backupUserDataIntoCloud, fetchUserDataFromCloud } from '../handlers/cloudBackup'; import { saveKeychainIntegrityState } from '../handlers/localstorage/globalSettings'; import { getWalletNames, saveWalletNames } from '../handlers/localstorage/walletNames'; -import WalletBackupTypes from '../helpers/walletBackupTypes'; import WalletTypes from '../helpers/walletTypes'; import { fetchENSAvatar } from '../hooks/useENSAvatar'; import { hasKey } from '../model/keychain'; @@ -30,6 +28,7 @@ import { AppGetState, AppState } from './store'; import { fetchReverseRecord } from '@/handlers/ens'; import { lightModeThemeColors } from '@/styles'; import { RainbowError, logger } from '@/logger'; +import { parseTimestampFromBackupFile } from '@/model/backup'; // -- Types ---------------------------------------- // @@ -37,11 +36,6 @@ import { RainbowError, logger } from '@/logger'; * The current state of the `wallets` reducer. */ interface WalletsState { - /** - * The current loading state of the wallet. - */ - isWalletLoading: any; - /** * The currently selected wallet. */ @@ -62,21 +56,12 @@ interface WalletsState { * An action for the `wallets` reducer. */ type WalletsAction = - | WalletsSetIsLoadingAction | WalletsSetSelectedAction | WalletsUpdateAction | WalletsUpdateNamesAction | WalletsLoadAction | WalletsAddedAccountAction; -/** - * An action that sets the wallet loading state. - */ -interface WalletsSetIsLoadingAction { - type: typeof WALLETS_SET_IS_LOADING; - payload: WalletsState['isWalletLoading']; -} - /** * An action that sets the selected wallet. */ @@ -130,90 +115,88 @@ const WALLETS_SET_SELECTED = 'wallets/SET_SELECTED'; /** * Loads wallet information from storage and updates state accordingly. */ -export const walletsLoadState = - (profilesEnabled = false) => - async (dispatch: ThunkDispatch, getState: AppGetState) => { - try { - const { accountAddress } = getState().settings; - let addressFromKeychain: string | null = accountAddress; - const allWalletsResult = await getAllWallets(); - const wallets = allWalletsResult?.wallets || {}; - if (isEmpty(wallets)) return; - const selected = await getSelectedWallet(); - // Prevent irrecoverable state (no selected wallet) - let selectedWallet = selected?.wallet; - // Check if the selected wallet is among all the wallets - if (selectedWallet && !wallets[selectedWallet.id]) { - // If not then we should clear it and default to the first one - const firstWalletKey = Object.keys(wallets)[0]; - selectedWallet = wallets[firstWalletKey]; - await setSelectedWallet(selectedWallet); - } +export const walletsLoadState = () => async (dispatch: ThunkDispatch, getState: AppGetState) => { + try { + const { accountAddress } = getState().settings; + let addressFromKeychain: string | null = accountAddress; + const allWalletsResult = await getAllWallets(); + const wallets = allWalletsResult?.wallets || {}; + if (isEmpty(wallets)) return; + const selected = await getSelectedWallet(); + // Prevent irrecoverable state (no selected wallet) + let selectedWallet = selected?.wallet; + // Check if the selected wallet is among all the wallets + if (selectedWallet && !wallets[selectedWallet.id]) { + // If not then we should clear it and default to the first one + const firstWalletKey = Object.keys(wallets)[0]; + selectedWallet = wallets[firstWalletKey]; + await setSelectedWallet(selectedWallet); + } - if (!selectedWallet) { - const address = await loadAddress(); - if (!address) { - selectedWallet = wallets[Object.keys(wallets)[0]]; - } else { - keys(wallets).some(key => { - const someWallet = wallets[key]; - const found = (someWallet.addresses || []).some(account => { - return toChecksumAddress(account.address) === toChecksumAddress(address!); - }); - if (found) { - selectedWallet = someWallet; - logger.debug('[redux/wallets]: Found selected wallet based on loadAddress result'); - } - return found; + if (!selectedWallet) { + const address = await loadAddress(); + if (!address) { + selectedWallet = wallets[Object.keys(wallets)[0]]; + } else { + keys(wallets).some(key => { + const someWallet = wallets[key]; + const found = (someWallet.addresses || []).some(account => { + return toChecksumAddress(account.address) === toChecksumAddress(address!); }); - } + if (found) { + selectedWallet = someWallet; + logger.debug('[redux/wallets]: Found selected wallet based on loadAddress result'); + } + return found; + }); } + } - // Recover from broken state (account address not in selected wallet) - if (!addressFromKeychain) { - addressFromKeychain = await loadAddress(); - logger.debug("[redux/wallets]: addressFromKeychain wasn't set on settings so it is being loaded from loadAddress"); - } + // Recover from broken state (account address not in selected wallet) + if (!addressFromKeychain) { + addressFromKeychain = await loadAddress(); + logger.debug("[redux/wallets]: addressFromKeychain wasn't set on settings so it is being loaded from loadAddress"); + } - const selectedAddress = selectedWallet?.addresses.find(a => { - return a.visible && a.address === addressFromKeychain; - }); + const selectedAddress = selectedWallet?.addresses.find(a => { + return a.visible && a.address === addressFromKeychain; + }); - // Let's select the first visible account if we don't have a selected address - if (!selectedAddress) { - const allWallets = Object.values(allWalletsResult?.wallets || {}); - let account = null; - for (const wallet of allWallets) { - for (const rainbowAccount of wallet.addresses || []) { - if (rainbowAccount.visible) { - account = rainbowAccount; - break; - } + // Let's select the first visible account if we don't have a selected address + if (!selectedAddress) { + const allWallets = Object.values(allWalletsResult?.wallets || {}); + let account = null; + for (const wallet of allWallets) { + for (const rainbowAccount of wallet.addresses || []) { + if (rainbowAccount.visible) { + account = rainbowAccount; + break; } } - if (!account) return; - await dispatch(settingsUpdateAccountAddress(account.address)); - await saveAddress(account.address); - logger.debug('[redux/wallets]: Selected the first visible address because there was not selected one'); } + if (!account) return; + await dispatch(settingsUpdateAccountAddress(account.address)); + await saveAddress(account.address); + logger.debug('[redux/wallets]: Selected the first visible address because there was not selected one'); + } - const walletNames = await getWalletNames(); - dispatch({ - payload: { - selected: selectedWallet, - walletNames, - wallets, - }, - type: WALLETS_LOAD, - }); + const walletNames = await getWalletNames(); + dispatch({ + payload: { + selected: selectedWallet, + walletNames, + wallets, + }, + type: WALLETS_LOAD, + }); - return wallets; - } catch (error) { - logger.error(new RainbowError('[redux/wallets]: Exception during walletsLoadState'), { - message: (error as Error)?.message, - }); - } - }; + return wallets; + } catch (error) { + logger.error(new RainbowError('[redux/wallets]: Exception during walletsLoadState'), { + message: (error as Error)?.message, + }); + } +}; /** * Saves new wallets to storage and updates state accordingly. @@ -252,21 +235,21 @@ export const walletsSetSelected = (wallet: RainbowWallet) => async (dispatch: Di * @param updateUserMetadata Whether to update user metadata. */ export const setAllWalletsWithIdsAsBackedUp = - ( - walletIds: RainbowWallet['id'][], - method: RainbowWallet['backupType'], - backupFile: RainbowWallet['backupFile'] = null, - updateUserMetadata = true - ) => + (walletIds: RainbowWallet['id'][], method: RainbowWallet['backupType'], backupFile: RainbowWallet['backupFile'] = null) => async (dispatch: ThunkDispatch, getState: AppGetState) => { const { wallets, selected } = getState().wallets; const newWallets = { ...wallets }; + let backupDate = Date.now(); + if (backupFile) { + backupDate = parseTimestampFromBackupFile(backupFile) ?? Date.now(); + } + walletIds.forEach(walletId => { newWallets[walletId] = { ...newWallets[walletId], backedUp: true, - backupDate: Date.now(), + backupDate, backupFile, backupType: method, }; @@ -276,17 +259,6 @@ export const setAllWalletsWithIdsAsBackedUp = if (selected?.id && walletIds.includes(selected?.id)) { await dispatch(walletsSetSelected(newWallets[selected.id])); } - - if (method === WalletBackupTypes.cloud && updateUserMetadata) { - try { - await backupUserDataIntoCloud({ wallets: newWallets }); - } catch (e) { - logger.error(new RainbowError('[redux/wallets]: Saving multiple wallets UserData to cloud failed.'), { - message: (e as Error)?.message, - }); - throw e; - } - } }; /** @@ -296,122 +268,28 @@ export const setAllWalletsWithIdsAsBackedUp = * @param walletId The ID of the wallet to modify. * @param method The backup type used. * @param backupFile The backup file, if present. - * @param updateUserMetadata Whether to update user metadata. */ export const setWalletBackedUp = - ( - walletId: RainbowWallet['id'], - method: RainbowWallet['backupType'], - backupFile: RainbowWallet['backupFile'] = null, - updateUserMetadata = true - ) => + (walletId: RainbowWallet['id'], method: RainbowWallet['backupType'], backupFile: RainbowWallet['backupFile'] = null) => async (dispatch: ThunkDispatch, getState: AppGetState) => { const { wallets, selected } = getState().wallets; const newWallets = { ...wallets }; + let backupDate = Date.now(); + if (backupFile) { + backupDate = parseTimestampFromBackupFile(backupFile) ?? Date.now(); + } newWallets[walletId] = { ...newWallets[walletId], backedUp: true, - backupDate: Date.now(), + backupDate, backupFile, backupType: method, }; await dispatch(walletsUpdate(newWallets)); - if (selected!.id === walletId) { + if (selected?.id === walletId) { await dispatch(walletsSetSelected(newWallets[walletId])); } - - if (method === WalletBackupTypes.cloud && updateUserMetadata) { - try { - await backupUserDataIntoCloud({ wallets: newWallets }); - } catch (e) { - logger.error(new RainbowError('[redux/wallets]: Saving wallet UserData to cloud failed.'), { - message: (e as Error)?.message, - }); - throw e; - } - } - }; - -/** - * Grabs user data stored in the cloud and based on this data marks wallets - * as backed up or not - */ -export const updateWalletBackupStatusesBasedOnCloudUserData = - () => async (dispatch: ThunkDispatch, getState: AppGetState) => { - const { wallets, selected } = getState().wallets; - const newWallets = { ...wallets }; - - let currentUserData: { wallets: { [p: string]: RainbowWallet } } | undefined; - try { - currentUserData = await fetchUserDataFromCloud(); - } catch (error) { - logger.error(new RainbowError('[redux/wallets]: There was an error when trying to update wallet backup statuses'), { - error: (error as Error).message, - }); - return; - } - if (currentUserData === undefined) { - return; - } - - // build hashmap of address to wallet based on backup metadata - const addressToWalletLookup = new Map(); - Object.values(currentUserData.wallets).forEach(wallet => { - wallet.addresses?.forEach(account => { - addressToWalletLookup.set(account.address, wallet); - }); - }); - - /* - marking wallet as already backed up if all addresses are backed up properly - and linked to the same wallet - - we assume it's not backed up if: - * we don't have an address in the backup metadata - * we have an address in the backup metadata, but it's linked to multiple - wallet ids (should never happen, but that's a sanity check) - */ - Object.values(newWallets).forEach(wallet => { - const localWalletId = wallet.id; - - let relatedCloudWalletId: string | null = null; - for (const account of wallet.addresses || []) { - const walletDataForCurrentAddress = addressToWalletLookup.get(account.address); - if (!walletDataForCurrentAddress) { - return; - } - if (relatedCloudWalletId === null) { - relatedCloudWalletId = walletDataForCurrentAddress.id; - } else if (relatedCloudWalletId !== walletDataForCurrentAddress.id) { - logger.warn( - '[redux/wallets]: Wallet address is linked to multiple or different accounts in the cloud backup metadata. It could mean that there is an issue with the cloud backup metadata.' - ); - return; - } - } - - if (relatedCloudWalletId === null) { - return; - } - - // update only if we checked the wallet is actually backed up - const cloudBackupData = currentUserData?.wallets[relatedCloudWalletId]; - if (cloudBackupData) { - newWallets[localWalletId] = { - ...newWallets[localWalletId], - backedUp: cloudBackupData.backedUp, - backupDate: cloudBackupData.backupDate, - backupFile: cloudBackupData.backupFile, - backupType: cloudBackupData.backupType, - }; - } - }); - - await dispatch(walletsUpdate(newWallets)); - if (selected?.id) { - await dispatch(walletsSetSelected(newWallets[selected.id])); - } }; /** @@ -706,7 +584,6 @@ export const checkKeychainIntegrity = () => async (dispatch: ThunkDispatch { switch (action.type) { - case WALLETS_SET_IS_LOADING: - return { ...state, isWalletLoading: action.payload }; case WALLETS_SET_SELECTED: return { ...state, selected: action.payload }; case WALLETS_UPDATE: diff --git a/src/resources/transactions/consolidatedTransactions.ts b/src/resources/transactions/consolidatedTransactions.ts index a749e2f72d0..26db739a248 100644 --- a/src/resources/transactions/consolidatedTransactions.ts +++ b/src/resources/transactions/consolidatedTransactions.ts @@ -135,6 +135,7 @@ export function useConsolidatedTransactions( keepPreviousData: true, getNextPageParam: lastPage => lastPage?.nextPage, refetchInterval: CONSOLIDATED_TRANSACTIONS_INTERVAL, + enabled: !!address, retry: 3, } ); diff --git a/src/screens/AddWalletSheet.tsx b/src/screens/AddWalletSheet.tsx index 4b010ff3f16..92712d4241d 100644 --- a/src/screens/AddWalletSheet.tsx +++ b/src/screens/AddWalletSheet.tsx @@ -5,11 +5,10 @@ import { useNavigation } from '@/navigation'; import Routes from '@/navigation/routesNames'; import React, { useRef } from 'react'; import * as i18n from '@/languages'; -import { HARDWARE_WALLETS, PROFILES, useExperimentalFlag } from '@/config'; +import { HARDWARE_WALLETS, useExperimentalFlag } from '@/config'; import { analytics, analyticsV2 } from '@/analytics'; -import { InteractionManager, Linking } from 'react-native'; +import { InteractionManager } from 'react-native'; import { createAccountForWallet, walletsLoadState } from '@/redux/wallets'; -import WalletBackupTypes from '@/helpers/walletBackupTypes'; import { createWallet } from '@/model/wallet'; import WalletTypes from '@/helpers/walletTypes'; import { logger, RainbowError } from '@/logger'; @@ -19,20 +18,13 @@ import PairHairwareWallet from '@/assets/PairHardwareWallet.png'; import ImportSecretPhraseOrPrivateKey from '@/assets/ImportSecretPhraseOrPrivateKey.png'; import WatchWalletIcon from '@/assets/watchWallet.png'; import { useDispatch } from 'react-redux'; -import { - backupUserDataIntoCloud, - getGoogleAccountUserData, - GoogleDriveUserData, - isCloudBackupAvailable, - login, - logoutFromGoogleDrive, -} from '@/handlers/cloudBackup'; import showWalletErrorAlert from '@/helpers/support'; import { cloudPlatform } from '@/utils/platform'; -import { IS_ANDROID } from '@/env'; import { RouteProp, useRoute } from '@react-navigation/native'; -import { WrappedAlert as Alert } from '@/helpers/alert'; import { useInitializeWallet, useWallets } from '@/hooks'; +import { WalletLoadingStates } from '@/helpers/walletLoadingStates'; +import { executeFnIfCloudBackupAvailable } from '@/model/backup'; +import { walletLoadingStore } from '@/state/walletLoading/walletLoading'; const TRANSLATIONS = i18n.l.wallet.new.add_wallet_sheet; @@ -52,7 +44,6 @@ export const AddWalletSheet = () => { const { goBack, navigate } = useNavigation(); const hardwareWalletsEnabled = useExperimentalFlag(HARDWARE_WALLETS); - const profilesEnabled = useExperimentalFlag(PROFILES); const dispatch = useDispatch(); const initializeWallet = useInitializeWallet(); const creatingWallet = useRef(); @@ -83,6 +74,10 @@ export const AddWalletSheet = () => { }, onCloseModal: async (args: any) => { if (args) { + walletLoadingStore.setState({ + loadingState: WalletLoadingStates.CREATING_WALLET, + }); + const name = args?.name ?? ''; const color = args?.color ?? null; // Check if the selected wallet is the primary @@ -113,31 +108,18 @@ export const AddWalletSheet = () => { try { // If we found it and it's not damaged use it to create the new account if (primaryWalletKey && !wallets?.[primaryWalletKey].damaged) { - const newWallets = await dispatch(createAccountForWallet(primaryWalletKey, color, name)); + await dispatch(createAccountForWallet(primaryWalletKey, color, name)); // @ts-ignore await initializeWallet(); - // If this wallet was previously backed up to the cloud - // We need to update userData backup so it can be restored too - if (wallets?.[primaryWalletKey].backedUp && wallets[primaryWalletKey].backupType === WalletBackupTypes.cloud) { - try { - await backupUserDataIntoCloud({ wallets: newWallets }); - } catch (e) { - logger.error(new RainbowError('[AddWalletSheet]: Updating wallet userdata failed after new account creation'), { - error: e, - }); - throw e; - } - } - - // If doesn't exist, we need to create a new wallet } else { + // If doesn't exist, we need to create a new wallet await createWallet({ color, name, clearCallbackOnStartCreation: true, }); - await dispatch(walletsLoadState(profilesEnabled)); - // @ts-ignore + await dispatch(walletsLoadState()); + // @ts-expect-error - needs refactor to object params await initializeWallet(); } } catch (e) { @@ -149,6 +131,10 @@ export const AddWalletSheet = () => { showWalletErrorAlert(); }, 1000); } + } finally { + walletLoadingStore.setState({ + loadingState: null, + }); } } creatingWallet.current = false; @@ -197,47 +183,11 @@ export const AddWalletSheet = () => { isFirstWallet, type: 'seed', }); - if (IS_ANDROID) { - try { - await logoutFromGoogleDrive(); - await login(); - - getGoogleAccountUserData().then((accountDetails: GoogleDriveUserData | undefined) => { - if (accountDetails) { - return navigate(Routes.RESTORE_SHEET); - } - Alert.alert(i18n.t(i18n.l.back_up.errors.no_account_found)); - }); - } catch (e) { - Alert.alert(i18n.t(i18n.l.back_up.errors.no_account_found)); - logger.error(new RainbowError('[AddWalletSheet]: Error while trying to restore from cloud'), { - error: e, - }); - } - } else { - const isAvailable = await isCloudBackupAvailable(); - if (!isAvailable) { - Alert.alert( - i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.label), - i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.description), - [ - { - onPress: () => { - Linking.openURL('https://support.apple.com/en-us/HT204025'); - }, - text: i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.show_me), - }, - { - style: 'cancel', - text: i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.no_thanks), - }, - ] - ); - return; - } - navigate(Routes.RESTORE_SHEET); - } + executeFnIfCloudBackupAvailable({ + fn: () => navigate(Routes.RESTORE_SHEET), + logout: true, + }); }; const restoreFromCloudDescription = i18n.t(TRANSLATIONS.options.cloud.description_restore_sheet, { diff --git a/src/screens/RestoreSheet.tsx b/src/screens/RestoreSheet.tsx index 4a3e324bb65..f8186c86341 100644 --- a/src/screens/RestoreSheet.tsx +++ b/src/screens/RestoreSheet.tsx @@ -1,5 +1,5 @@ import { RouteProp, useRoute } from '@react-navigation/native'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import RestoreCloudStep from '../components/backup/RestoreCloudStep'; import ChooseBackupStep from '@/components/backup/ChooseBackupStep'; import Routes from '@/navigation/routesNames'; diff --git a/src/screens/SendSheet.tsx b/src/screens/SendSheet.tsx index f7f71b7173f..c634befe7b0 100644 --- a/src/screens/SendSheet.tsx +++ b/src/screens/SendSheet.tsx @@ -50,7 +50,7 @@ import Routes from '@/navigation/routesNames'; import styled from '@/styled-thing'; import { borders } from '@/styles'; import { convertAmountAndPriceToNativeDisplay, convertAmountFromNativeValue, formatInputDecimals, lessThan } from '@/helpers/utilities'; -import { deviceUtils, ethereumUtils, getUniqueTokenType, safeAreaInsetValues } from '@/utils'; +import { deviceUtils, ethereumUtils, getUniqueTokenType, isLowerCaseMatch, safeAreaInsetValues } from '@/utils'; import { logger, RainbowError } from '@/logger'; import { IS_ANDROID, IS_IOS } from '@/env'; import { NoResults } from '@/components/list'; @@ -62,13 +62,14 @@ import { getNextNonce } from '@/state/nonces'; import { usePersistentDominantColorFromImage } from '@/hooks/usePersistentDominantColorFromImage'; import { performanceTracking, Screens, TimeToSignOperation } from '@/state/performance/performance'; import { REGISTRATION_STEPS } from '@/helpers/ens'; -import { useUserAssetsStore } from '@/state/assets/userAssets'; import { ChainId } from '@/state/backendNetworks/types'; import { useBackendNetworksStore } from '@/state/backendNetworks/backendNetworks'; import { RootStackParamList } from '@/navigation/types'; import { ThemeContextProps, useTheme } from '@/theme'; import { StaticJsonRpcProvider } from '@ethersproject/providers'; import { Contact } from '@/redux/contacts'; +import { useUserAssetsStore } from '@/state/assets/userAssets'; +import store from '@/redux/store'; const sheetHeight = deviceUtils.dimensions.height - (IS_ANDROID ? 30 : 10); const statusBarHeight = IS_IOS ? safeAreaInsetValues.top : StatusBar.currentHeight; @@ -95,6 +96,17 @@ const SheetContainer = styled(Column).attrs({ }); const validateRecipient = (toAddress?: string, tokenAddress?: string) => { + const { wallets } = store.getState().wallets; + // check for if the recipient is in a damaged wallet state and prevent + if (wallets) { + const internalWallet = Object.values(wallets).find(wallet => + wallet.addresses.some(address => isLowerCaseMatch(address.address, toAddress)) + ); + if (internalWallet?.damaged) { + return false; + } + } + if (!toAddress || toAddress?.toLowerCase() === tokenAddress?.toLowerCase()) { return false; } diff --git a/src/screens/SettingsSheet/SettingsSheet.tsx b/src/screens/SettingsSheet/SettingsSheet.tsx index 7a68ad83d86..094cdc17456 100644 --- a/src/screens/SettingsSheet/SettingsSheet.tsx +++ b/src/screens/SettingsSheet/SettingsSheet.tsx @@ -21,7 +21,6 @@ import { useDimensions } from '@/hooks'; import { SETTINGS_BACKUP_ROUTES } from './components/Backups/routes'; import { IS_ANDROID } from '@/env'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { CloudBackupProvider } from '@/components/backup/CloudBackupProvider'; const Stack = createStackNavigator(); @@ -52,102 +51,100 @@ export function SettingsSheet() { const memoSettingsOptions = useMemo(() => settingsOptions(colors), [colors]); return ( - - - {({ backgroundColor }) => ( - + {({ backgroundColor }) => ( + + - - - {() => ( - - )} - - {Object.values(SettingsPages).map( - ({ component, getTitle, key }) => - component && ( - - ) + {() => ( + )} - ({ - cardStyleInterpolator: settingsCardStyleInterpolator, - title: route.params?.title, - })} - /> - ({ - cardStyleInterpolator: settingsCardStyleInterpolator, - title: route.params?.title, - })} - /> - ({ - cardStyleInterpolator: settingsCardStyleInterpolator, - title: route.params?.title, - })} - /> - ({ - cardStyleInterpolator: settingsCardStyleInterpolator, - title: route.params?.title, - })} - /> - ({ - cardStyleInterpolator: settingsCardStyleInterpolator, - title: route.params?.title, - })} - /> - - - )} - - + + {Object.values(SettingsPages).map( + ({ component, getTitle, key }) => + component && ( + + ) + )} + ({ + cardStyleInterpolator: settingsCardStyleInterpolator, + title: route.params?.title, + })} + /> + ({ + cardStyleInterpolator: settingsCardStyleInterpolator, + title: route.params?.title, + })} + /> + ({ + cardStyleInterpolator: settingsCardStyleInterpolator, + title: route.params?.title, + })} + /> + ({ + cardStyleInterpolator: settingsCardStyleInterpolator, + title: route.params?.title, + })} + /> + ({ + cardStyleInterpolator: settingsCardStyleInterpolator, + title: route.params?.title, + })} + /> + + + )} + ); } diff --git a/src/screens/SettingsSheet/components/Backups/BackUpMenuButton.tsx b/src/screens/SettingsSheet/components/Backups/BackUpMenuButton.tsx index 1b2f4334e8e..ba33ae5da99 100644 --- a/src/screens/SettingsSheet/components/Backups/BackUpMenuButton.tsx +++ b/src/screens/SettingsSheet/components/Backups/BackUpMenuButton.tsx @@ -1,4 +1,3 @@ -import { useCreateBackupStateType } from '@/components/backup/useCreateBackup'; import { useTheme } from '@/theme'; import React, { useState, useMemo, useEffect } from 'react'; import * as i18n from '@/languages'; @@ -6,102 +5,103 @@ import MenuItem from '../MenuItem'; import Spinner from '@/components/Spinner'; import { FloatingEmojis } from '@/components/floating-emojis'; import { useDimensions } from '@/hooks'; +import { CloudBackupState } from '@/state/backups/backups'; export const BackUpMenuItem = ({ icon = '􀊯', - loading, + backupState, onPress, title, + disabled, }: { icon?: string; - loading: useCreateBackupStateType; + backupState: CloudBackupState; title: string; onPress: () => void; + disabled?: boolean; }) => { const { colors } = useTheme(); const { width: deviceWidth } = useDimensions(); const [emojiTrigger, setEmojiTrigger] = useState void)>(null); useEffect(() => { - if (loading === 'success') { + if (backupState === CloudBackupState.Success) { for (let i = 0; i < 20; i++) { setTimeout(() => { emojiTrigger?.(); }, 100 * i); } } - }, [emojiTrigger, loading]); + }, [emojiTrigger, backupState]); const accentColor = useMemo(() => { - switch (loading) { - case 'success': + switch (backupState) { + case CloudBackupState.Success: return colors.green; - case 'error': + case CloudBackupState.Error: return colors.red; default: return undefined; } - }, [colors, loading]); + }, [colors, backupState]); const titleText = useMemo(() => { - switch (loading) { - case 'loading': + switch (backupState) { + case CloudBackupState.InProgress: return i18n.t(i18n.l.back_up.cloud.backing_up); - case 'success': + case CloudBackupState.Success: return i18n.t(i18n.l.back_up.cloud.backup_success); - case 'error': + case CloudBackupState.Error: return i18n.t(i18n.l.back_up.cloud.backup_failed); default: return title; } - }, [loading, title]); + }, [backupState, title]); const localIcon = useMemo(() => { - switch (loading) { - case 'success': + switch (backupState) { + case CloudBackupState.Success: return '􀁢'; - case 'error': + case CloudBackupState.Error: return '􀀲'; default: return icon; } - }, [icon, loading]); + }, [icon, backupState]); return ( - <> - {/* @ts-ignore js */} - - {({ onNewEmoji }: { onNewEmoji: () => void }) => ( - - ) : ( - - ) - } - onPress={() => { - setEmojiTrigger(() => onNewEmoji); - onPress(); - }} - size={52} - titleComponent={} - /> - )} - - + + {({ onNewEmoji }) => ( + + ) : ( + + ) + } + onPress={() => { + setEmojiTrigger(() => onNewEmoji); + onPress(); + }} + size={52} + titleComponent={} + /> + )} + ); }; diff --git a/src/screens/SettingsSheet/components/Backups/ViewCloudBackups.tsx b/src/screens/SettingsSheet/components/Backups/ViewCloudBackups.tsx index 1842d3fae2a..90cbdddeff3 100644 --- a/src/screens/SettingsSheet/components/Backups/ViewCloudBackups.tsx +++ b/src/screens/SettingsSheet/components/Backups/ViewCloudBackups.tsx @@ -5,19 +5,18 @@ import { Text as RNText } from '@/components/text'; import Menu from '../Menu'; import MenuContainer from '../MenuContainer'; import MenuItem from '../MenuItem'; -import { Backup, parseTimestampFromFilename } from '@/model/backup'; +import { BackupFile, parseTimestampFromFilename } from '@/model/backup'; import { format } from 'date-fns'; -import { Stack } from '@/design-system'; import { useNavigation } from '@/navigation'; import Routes from '@/navigation/routesNames'; -import { IS_ANDROID } from '@/env'; import walletBackupStepTypes from '@/helpers/walletBackupStepTypes'; -import { useCloudBackups } from '@/components/backup/CloudBackupProvider'; -import { Centered } from '@/components/layout'; +import { Page } from '@/components/layout'; import Spinner from '@/components/Spinner'; import ActivityIndicator from '@/components/ActivityIndicator'; -import { cloudPlatform } from '@/utils/platform'; import { useTheme } from '@/theme'; +import { CloudBackupState, LoadingStates, backupsStore } from '@/state/backups/backups'; +import { titleForBackupState } from '../../utils'; +import { Box } from '@/design-system'; const LoadingText = styled(RNText).attrs(({ theme: { colors } }: any) => ({ color: colors.blueGreyDark, @@ -32,43 +31,14 @@ const ViewCloudBackups = () => { const { navigate } = useNavigation(); const { colors } = useTheme(); - const { isFetching, backups } = useCloudBackups(); - - const cloudBackups = backups.files - .filter(backup => { - if (IS_ANDROID) { - return !backup.name.match(/UserData/i); - } - - return backup.isFile && backup.size > 0 && !backup.name.match(/UserData/i); - }) - .sort((a, b) => { - return parseTimestampFromFilename(b.name) - parseTimestampFromFilename(a.name); - }); - - const mostRecentBackup = cloudBackups.reduce( - (prev, current) => { - if (!current) { - return prev; - } - - if (!prev) { - return current; - } - - const prevTimestamp = new Date(prev.lastModified).getTime(); - const currentTimestamp = new Date(current.lastModified).getTime(); - if (currentTimestamp > prevTimestamp) { - return current; - } - - return prev; - }, - undefined as Backup | undefined - ); + const { status, backups, mostRecentBackup } = backupsStore(state => ({ + status: state.status, + backups: state.backups, + mostRecentBackup: state.mostRecentBackup, + })); const onSelectCloudBackup = useCallback( - async (selectedBackup: Backup) => { + async (selectedBackup: BackupFile) => { navigate(Routes.BACKUP_SHEET, { step: walletBackupStepTypes.restore_from_backup, selectedBackup, @@ -77,80 +47,110 @@ const ViewCloudBackups = () => { [navigate] ); - return ( - - - {!isFetching && !cloudBackups.length && ( - - } /> - - )} + const renderNoBackupsState = () => ( + <> + + } /> + + + ); + + const renderMostRecentBackup = () => { + if (!mostRecentBackup) { + return null; + } + + return ( + + + } + onPress={() => onSelectCloudBackup(mostRecentBackup)} + size={52} + width="full" + titleComponent={} + /> + + + ); + }; + + const renderOlderBackups = () => ( + <> + + + {backups.files + .filter(backup => backup.name !== mostRecentBackup?.name) + .sort((a, b) => { + const timestampA = new Date(parseTimestampFromFilename(a.name)).getTime(); + const timestampB = new Date(parseTimestampFromFilename(b.name)).getTime(); + return timestampB - timestampA; + }) + .map(backup => ( + onSelectCloudBackup(backup)} + size={52} + width="full" + titleComponent={ + + } + /> + ))} + {backups.files.length === 1 && ( + } + /> + )} + + + + + backupsStore.getState().syncAndFetchBackups()} + titleComponent={} + /> + + + ); - {!isFetching && cloudBackups.length && ( - <> - {mostRecentBackup && ( - - } - onPress={() => onSelectCloudBackup(mostRecentBackup)} - size={52} - width="full" - titleComponent={} - /> - - )} + const renderBackupsList = () => ( + <> + {renderMostRecentBackup()} + {renderOlderBackups()} + + ); - - {cloudBackups.map( - backup => - backup.name !== mostRecentBackup?.name && ( - onSelectCloudBackup(backup)} - size={52} - width="full" - titleComponent={ - - } - /> - ) - )} + const isLoading = LoadingStates.includes(status); - {cloudBackups.length === 1 && ( - } - /> - )} - - - )} + if (isLoading) { + return ( + + {android ? : } + {titleForBackupState[status]} + + ); + } - {isFetching && ( - - {android ? : } - { - - {i18n.t(i18n.l.back_up.cloud.fetching_backups, { - cloudPlatformName: cloudPlatform, - })} - - } - - )} - + return ( + + {status === CloudBackupState.Ready && !backups.files.length && renderNoBackupsState()} + {status === CloudBackupState.Ready && backups.files.length > 0 && renderBackupsList()} ); }; diff --git a/src/screens/SettingsSheet/components/Backups/ViewWalletBackup.tsx b/src/screens/SettingsSheet/components/Backups/ViewWalletBackup.tsx index d085c3f62fd..9fddd15964d 100644 --- a/src/screens/SettingsSheet/components/Backups/ViewWalletBackup.tsx +++ b/src/screens/SettingsSheet/components/Backups/ViewWalletBackup.tsx @@ -29,31 +29,23 @@ import Routes from '@/navigation/routesNames'; import walletBackupTypes from '@/helpers/walletBackupTypes'; import { SETTINGS_BACKUP_ROUTES } from './routes'; import { analyticsV2 } from '@/analytics'; -import { InteractionManager, Linking } from 'react-native'; +import { InteractionManager } from 'react-native'; import { useDispatch } from 'react-redux'; -import { createAccountForWallet, walletsLoadState } from '@/redux/wallets'; -import { - GoogleDriveUserData, - backupUserDataIntoCloud, - getGoogleAccountUserData, - isCloudBackupAvailable, - login, -} from '@/handlers/cloudBackup'; +import { createAccountForWallet } from '@/redux/wallets'; import { logger, RainbowError } from '@/logger'; -import { RainbowAccount, createWallet } from '@/model/wallet'; -import { PROFILES, useExperimentalFlag } from '@/config'; +import { RainbowAccount } from '@/model/wallet'; import showWalletErrorAlert from '@/helpers/support'; -import { IS_ANDROID, IS_IOS } from '@/env'; +import { IS_IOS } from '@/env'; import ImageAvatar from '@/components/contacts/ImageAvatar'; -import { useCreateBackup } from '@/components/backup/useCreateBackup'; import { BackUpMenuItem } from './BackUpMenuButton'; -import { checkWalletsForBackupStatus } from '../../utils'; -import { useCloudBackups } from '@/components/backup/CloudBackupProvider'; -import { WalletCountPerType, useVisibleWallets } from '../../useVisibleWallets'; import { format } from 'date-fns'; import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; -import { Backup, parseTimestampFromFilename } from '@/model/backup'; -import { WrappedAlert as Alert } from '@/helpers/alert'; +import { useCreateBackup } from '@/components/backup/useCreateBackup'; +import { backupsStore } from '@/state/backups/backups'; +import { WalletLoadingStates } from '@/helpers/walletLoadingStates'; +import { executeFnIfCloudBackupAvailable } from '@/model/backup'; +import { isWalletBackedUpForCurrentAccount } from '../../utils'; +import { walletLoadingStore } from '@/state/walletLoading/walletLoading'; type ViewWalletBackupParams = { ViewWalletBackup: { walletId: string; title: string; imported?: boolean }; @@ -126,107 +118,38 @@ const ContextMenuWrapper = ({ children, account, menuConfig, onPressMenuItem }: const ViewWalletBackup = () => { const { params } = useRoute>(); - const { backups } = useCloudBackups(); + const createBackup = useCreateBackup(); + const { status, backupProvider, mostRecentBackup } = backupsStore(state => ({ + status: state.status, + backupProvider: state.backupProvider, + mostRecentBackup: state.mostRecentBackup, + })); const { walletId, title: incomingTitle } = params; const creatingWallet = useRef(); const { isDamaged, wallets } = useWallets(); const wallet = wallets?.[walletId]; const dispatch = useDispatch(); const initializeWallet = useInitializeWallet(); - const profilesEnabled = useExperimentalFlag(PROFILES); - - const walletTypeCount: WalletCountPerType = { - phrase: 0, - privateKey: 0, - }; - - const { lastBackupDate } = useVisibleWallets({ wallets, walletTypeCount }); - - const cloudBackups = backups.files - .filter(backup => { - if (IS_ANDROID) { - return !backup.name.match(/UserData/i); - } - - return backup.isFile && backup.size > 0 && !backup.name.match(/UserData/i); - }) - .sort((a, b) => { - return parseTimestampFromFilename(b.name) - parseTimestampFromFilename(a.name); - }); - - const mostRecentBackup = cloudBackups.reduce( - (prev, current) => { - if (!current) { - return prev; - } - - if (!prev) { - return current; - } - - const prevTimestamp = new Date(prev.lastModified).getTime(); - const currentTimestamp = new Date(current.lastModified).getTime(); - if (currentTimestamp > prevTimestamp) { - return current; - } - - return prev; - }, - undefined as Backup | undefined - ); - - const { backupProvider } = useMemo(() => checkWalletsForBackupStatus(wallets), [wallets]); const isSecretPhrase = WalletTypes.mnemonic === wallet?.type; - const title = wallet?.type === WalletTypes.privateKey ? wallet?.addresses[0].label : incomingTitle; + const isBackedUp = isWalletBackedUpForCurrentAccount({ + backupType: wallet?.backupType, + backedUp: wallet?.backedUp, + backupFile: wallet?.backupFile, + }); const { navigate } = useNavigation(); const [isToastActive, setToastActive] = useRecoilState(addressCopiedToastAtom); - const { onSubmit, loading } = useCreateBackup({ - walletId, - }); const backupWalletsToCloud = useCallback(async () => { - if (IS_ANDROID) { - try { - await login(); - - getGoogleAccountUserData().then((accountDetails: GoogleDriveUserData | undefined) => { - if (accountDetails) { - return onSubmit({}); - } - Alert.alert(i18n.t(i18n.l.back_up.errors.no_account_found)); - }); - } catch (e) { - Alert.alert(i18n.t(i18n.l.back_up.errors.no_account_found)); - logger.error(new RainbowError(`[ViewWalletBackup]: Logging into Google Drive failed`), { error: e }); - } - } else { - const isAvailable = await isCloudBackupAvailable(); - if (!isAvailable) { - Alert.alert( - i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.label), - i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.description), - [ - { - onPress: () => { - Linking.openURL('https://support.apple.com/en-us/HT204025'); - }, - text: i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.show_me), - }, - { - style: 'cancel', - text: i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.no_thanks), - }, - ] - ); - return; - } - } - - onSubmit({}); - }, [onSubmit]); + executeFnIfCloudBackupAvailable({ + fn: () => + createBackup({ + walletId, + }), + }); + }, [createBackup, walletId]); const onNavigateToSecretWarning = useCallback(() => { navigate(SETTINGS_BACKUP_ROUTES.SECRET_WARNING, { @@ -265,36 +188,17 @@ const ViewWalletBackup = () => { }, onCloseModal: async (args: any) => { if (args) { + walletLoadingStore.setState({ + loadingState: WalletLoadingStates.CREATING_WALLET, + }); + const name = args?.name ?? ''; const color = args?.color ?? null; // Check if the selected wallet is the primary try { // If we found it and it's not damaged use it to create the new account if (wallet && !wallet.damaged) { - const newWallets = await dispatch(createAccountForWallet(wallet.id, color, name)); - // @ts-expect-error - no params - await initializeWallet(); - // If this wallet was previously backed up to the cloud - // We need to update userData backup so it can be restored too - if (wallet.backedUp && wallet.backupType === walletBackupTypes.cloud) { - try { - await backupUserDataIntoCloud({ wallets: newWallets }); - } catch (e) { - logger.error(new RainbowError(`[ViewWalletBackup]: Updating wallet userdata failed after new account creation`), { - error: e, - }); - throw e; - } - } - - // If doesn't exist, we need to create a new wallet - } else { - await createWallet({ - color, - name, - clearCallbackOnStartCreation: true, - }); - await dispatch(walletsLoadState(profilesEnabled)); + await dispatch(createAccountForWallet(wallet.id, color, name)); // @ts-expect-error - no params await initializeWallet(); } @@ -307,6 +211,10 @@ const ViewWalletBackup = () => { showWalletErrorAlert(); }, 1000); } + } finally { + walletLoadingStore.setState({ + loadingState: null, + }); } } creatingWallet.current = false; @@ -324,7 +232,7 @@ const ViewWalletBackup = () => { error: e, }); } - }, [creatingWallet, dispatch, isDamaged, navigate, initializeWallet, profilesEnabled, wallet]); + }, [creatingWallet, dispatch, isDamaged, navigate, initializeWallet, wallet]); const handleCopyAddress = React.useCallback( (address: string) => { @@ -386,7 +294,7 @@ const ViewWalletBackup = () => { return ( - {!wallet?.backedUp && ( + {!isBackedUp && ( <> { /> - {backupProvider === walletBackupTypes.cloud && ( + { title={i18n.t(i18n.l.back_up.cloud.back_up_all_wallets_to_cloud, { cloudPlatformName: cloudPlatform, })} - loading={loading} + backupState={status} onPress={backupWalletsToCloud} /> - - )} - - {backupProvider !== walletBackupTypes.cloud && ( - } @@ -456,20 +355,12 @@ const ViewWalletBackup = () => { titleComponent={} testID={'back-up-manually'} /> - - )} + )} - {wallet?.backedUp && ( + {isBackedUp && ( <> { paddingBottom={{ custom: 24 }} iconComponent={ } titleComponent={ { { )} - - } - onPress={onNavigateToSecretWarning} - size={52} - titleComponent={ - + + - } - /> - + + + )} + + + + } + onPress={onNavigateToSecretWarning} + size={52} + titleComponent={ + + } + /> + + {wallet?.addresses .filter(a => a.visible) - .map((account: RainbowAccount) => ( - - } - labelComponent={ - account.label.endsWith('.eth') || account.label !== '' ? ( - - ) : null - } - titleComponent={ - - } - rightComponent={} - /> - - ))} + .map((account: RainbowAccount) => { + const isNamedOrEns = account.label.endsWith('.eth') || removeFirstEmojiFromString(account.label) !== ''; + const label = isNamedOrEns ? abbreviations.address(account.address, 3, 5) : undefined; + const title = isNamedOrEns + ? abbreviations.abbreviateEnsForDisplay(removeFirstEmojiFromString(account.label), 20) ?? '' + : abbreviations.address(account.address, 3, 5) ?? ''; + + return ( + + } + labelComponent={label ? : null} + titleComponent={} + rightComponent={} + /> + + ); + })} {wallet?.type !== WalletTypes.privateKey && ( - - } - onPress={onCreateNewWallet} - size={52} - titleComponent={} - /> - + + + } + onPress={onCreateNewWallet} + size={52} + titleComponent={} + /> + + )} diff --git a/src/screens/SettingsSheet/components/Backups/WalletsAndBackup.tsx b/src/screens/SettingsSheet/components/Backups/WalletsAndBackup.tsx index 9823fd2555f..74ec4a4e969 100644 --- a/src/screens/SettingsSheet/components/Backups/WalletsAndBackup.tsx +++ b/src/screens/SettingsSheet/components/Backups/WalletsAndBackup.tsx @@ -1,5 +1,4 @@ -/* eslint-disable no-nested-ternary */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { cloudPlatform } from '@/utils/platform'; import Menu from '../Menu'; import MenuContainer from '../MenuContainer'; @@ -12,11 +11,11 @@ import WalletTypes, { EthereumWalletType } from '@/helpers/walletTypes'; import ImageAvatar from '@/components/contacts/ImageAvatar'; import { useENSAvatar, useInitializeWallet, useManageCloudBackups, useWallets } from '@/hooks'; import { useNavigation } from '@/navigation'; -import { abbreviations } from '@/utils'; +import { abbreviations, deviceUtils } from '@/utils'; import { addressHashedEmoji } from '@/utils/profileUtils'; import * as i18n from '@/languages'; -import MenuHeader from '../MenuHeader'; -import { checkWalletsForBackupStatus } from '../../utils'; +import MenuHeader, { StatusType } from '../MenuHeader'; +import { checkLocalWalletsForBackupStatus, isWalletBackedUpForCurrentAccount } from '../../utils'; import { Inline, Text, Box, Stack } from '@/design-system'; import { ContactAvatar } from '@/components/contacts'; import { useTheme } from '@/theme'; @@ -25,26 +24,40 @@ import { backupsCard } from '@/components/cards/utils/constants'; import { WalletCountPerType, useVisibleWallets } from '../../useVisibleWallets'; import { SETTINGS_BACKUP_ROUTES } from './routes'; import { RainbowAccount, createWallet } from '@/model/wallet'; -import { PROFILES, useExperimentalFlag } from '@/config'; import { useDispatch } from 'react-redux'; import { walletsLoadState } from '@/redux/wallets'; import { RainbowError, logger } from '@/logger'; import { IS_ANDROID, IS_IOS } from '@/env'; -import { BackupTypes, useCreateBackup } from '@/components/backup/useCreateBackup'; +import { useCreateBackup } from '@/components/backup/useCreateBackup'; import { BackUpMenuItem } from './BackUpMenuButton'; import { format } from 'date-fns'; import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; -import { Backup, parseTimestampFromFilename } from '@/model/backup'; -import { useCloudBackups } from '@/components/backup/CloudBackupProvider'; -import { GoogleDriveUserData, getGoogleAccountUserData, isCloudBackupAvailable, login } from '@/handlers/cloudBackup'; -import { WrappedAlert as Alert } from '@/helpers/alert'; -import { Linking } from 'react-native'; -import { noop } from 'lodash'; +import { backupsStore, CloudBackupState } from '@/state/backups/backups'; +import { WalletLoadingStates } from '@/helpers/walletLoadingStates'; +import { executeFnIfCloudBackupAvailable } from '@/model/backup'; +import { walletLoadingStore } from '@/state/walletLoading/walletLoading'; +import { AbsolutePortalRoot } from '@/components/AbsolutePortal'; +import { FlatList, ScrollView } from 'react-native'; type WalletPillProps = { account: RainbowAccount; }; +// constants for the account section +const menuContainerPadding = 19.5; // 19px is the padding on the left and right of the container but we need 1px more to account for the shadows on each container +const accountsContainerWidth = deviceUtils.dimensions.width - menuContainerPadding * 4; +const spaceBetweenAccounts = 4; +const accountsItemWidth = accountsContainerWidth / 3; +const basePadding = 16; +const rowHeight = 36; + +const getAccountSectionHeight = (numAccounts: number) => { + const rows = Math.ceil(Math.max(1, numAccounts) / 3); + const paddingBetween = (rows - 1) * 4; + + return basePadding + rows * rowHeight - paddingBetween; +}; + const WalletPill = ({ account }: WalletPillProps) => { const label = useMemo(() => removeFirstEmojiFromString(account.label), [account.label]); @@ -58,7 +71,7 @@ const WalletPill = ({ account }: WalletPillProps) => { key={account.address} flexDirection="row" alignItems="center" - backgroundColor={colors.alpha(colors.grey, 0.4)} + backgroundColor={colors.alpha(colors.grey, 0.24)} borderRadius={23} shadowColor={isDarkMode ? colors.shadow : colors.alpha(colors.blueGreyDark, 0.1)} elevation={12} @@ -67,6 +80,7 @@ const WalletPill = ({ account }: WalletPillProps) => { paddingLeft={{ custom: 4 }} paddingRight={{ custom: 8 }} padding={{ custom: 4 }} + width={{ custom: accountsItemWidth }} > {ENSAvatar?.imageUrl ? ( @@ -82,27 +96,22 @@ const WalletPill = ({ account }: WalletPillProps) => { ); }; -const getAccountSectionHeight = (numAccounts: number) => { - const basePadding = 16; - const rowHeight = 36; - const rows = Math.ceil(Math.max(1, numAccounts) / 3); - const paddingBetween = (rows - 1) * 4; - - return basePadding + rows * rowHeight - paddingBetween; -}; - export const WalletsAndBackup = () => { const { navigate } = useNavigation(); const { wallets } = useWallets(); - const profilesEnabled = useExperimentalFlag(PROFILES); - const { backups } = useCloudBackups(); const dispatch = useDispatch(); - const initializeWallet = useInitializeWallet(); + const scrollviewRef = useRef(null); - const { onSubmit, loading } = useCreateBackup({ - walletId: undefined, // NOTE: This is not used when backing up All wallets - }); + const createBackup = useCreateBackup(); + const { status, backupProvider, backups, mostRecentBackup } = backupsStore(state => ({ + status: state.status, + backupProvider: state.backupProvider, + backups: state.backups, + mostRecentBackup: state.mostRecentBackup, + })); + + const initializeWallet = useInitializeWallet(); const { manageCloudBackups } = useManageCloudBackups(); @@ -111,52 +120,15 @@ export const WalletsAndBackup = () => { privateKey: 0, }; - const { allBackedUp, backupProvider } = useMemo(() => checkWalletsForBackupStatus(wallets), [wallets]); + const { allBackedUp } = useMemo(() => checkLocalWalletsForBackupStatus(wallets, backups), [wallets, backups]); - const { visibleWallets, lastBackupDate } = useVisibleWallets({ wallets, walletTypeCount }); - - const cloudBackups = backups.files - .filter(backup => { - if (IS_ANDROID) { - return !backup.name.match(/UserData/i); - } - - return backup.isFile && backup.size > 0 && !backup.name.match(/UserData/i); - }) - .sort((a, b) => { - return parseTimestampFromFilename(b.name) - parseTimestampFromFilename(a.name); - }); - - const mostRecentBackup = cloudBackups.reduce( - (prev, current) => { - if (!current) { - return prev; - } - - if (!prev) { - return current; - } - - const prevTimestamp = new Date(prev.lastModified).getTime(); - const currentTimestamp = new Date(current.lastModified).getTime(); - if (currentTimestamp > prevTimestamp) { - return current; - } - - return prev; - }, - undefined as Backup | undefined - ); + const visibleWallets = useVisibleWallets({ wallets, walletTypeCount }); const sortedWallets = useMemo(() => { - const notBackedUpSecretPhraseWallets = visibleWallets.filter( - wallet => !wallet.isBackedUp && wallet.type === EthereumWalletType.mnemonic - ); - const notBackedUpPrivateKeyWallets = visibleWallets.filter( - wallet => !wallet.isBackedUp && wallet.type === EthereumWalletType.privateKey - ); - const backedUpSecretPhraseWallets = visibleWallets.filter(wallet => wallet.isBackedUp && wallet.type === EthereumWalletType.mnemonic); - const backedUpPrivateKeyWallets = visibleWallets.filter(wallet => wallet.isBackedUp && wallet.type === EthereumWalletType.privateKey); + const notBackedUpSecretPhraseWallets = visibleWallets.filter(wallet => !wallet.backedUp && wallet.type === EthereumWalletType.mnemonic); + const notBackedUpPrivateKeyWallets = visibleWallets.filter(wallet => !wallet.backedUp && wallet.type === EthereumWalletType.privateKey); + const backedUpSecretPhraseWallets = visibleWallets.filter(wallet => wallet.backedUp && wallet.type === EthereumWalletType.mnemonic); + const backedUpPrivateKeyWallets = visibleWallets.filter(wallet => wallet.backedUp && wallet.type === EthereumWalletType.privateKey); return [ ...notBackedUpSecretPhraseWallets, @@ -166,48 +138,28 @@ export const WalletsAndBackup = () => { ]; }, [visibleWallets]); - const backupAllNonBackedUpWalletsTocloud = useCallback(async () => { - if (IS_ANDROID) { - try { - await login(); - - getGoogleAccountUserData().then((accountDetails: GoogleDriveUserData | undefined) => { - if (accountDetails) { - return onSubmit({ type: BackupTypes.All }); + const backupAllNonBackedUpWalletsTocloud = useCallback(() => { + executeFnIfCloudBackupAvailable({ + fn: () => createBackup({}), + }); + }, [createBackup]); + + const enableCloudBackups = useCallback(() => { + executeFnIfCloudBackupAvailable({ + fn: async () => { + // NOTE: For Android we could be coming from a not-logged-in state, so we + // need to check if we have any wallets to back up first. + if (IS_ANDROID) { + const currentBackups = backupsStore.getState().backups; + if (checkLocalWalletsForBackupStatus(wallets, currentBackups).allBackedUp) { + return; } - Alert.alert(i18n.t(i18n.l.back_up.errors.no_account_found)); - }); - } catch (e) { - Alert.alert(i18n.t(i18n.l.back_up.errors.no_account_found)); - logger.error(new RainbowError(`[WalletsAndBackup]: Logging into Google Drive failed`), { - error: e, - }); - } - } else { - const isAvailable = await isCloudBackupAvailable(); - if (!isAvailable) { - Alert.alert( - i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.label), - i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.description), - [ - { - onPress: () => { - Linking.openURL('https://support.apple.com/en-us/HT204025'); - }, - text: i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.show_me), - }, - { - style: 'cancel', - text: i18n.t(i18n.l.modal.back_up.alerts.cloud_not_enabled.no_thanks), - }, - ] - ); - return; - } - } - - onSubmit({ type: BackupTypes.All }); - }, [onSubmit]); + } + return createBackup({}); + }, + logout: true, + }); + }, [createBackup, wallets]); const onViewCloudBackups = useCallback(async () => { navigate(SETTINGS_BACKUP_ROUTES.VIEW_CLOUD_BACKUPS, { @@ -223,13 +175,17 @@ export const WalletsAndBackup = () => { onCloseModal: async ({ name }: { name: string }) => { const nameValue = name.trim() !== '' ? name.trim() : ''; try { + walletLoadingStore.setState({ + loadingState: WalletLoadingStates.CREATING_WALLET, + }); + await createWallet({ color: null, name: nameValue, clearCallbackOnStartCreation: true, }); - await dispatch(walletsLoadState(profilesEnabled)); + await dispatch(walletsLoadState()); // @ts-expect-error - no params await initializeWallet(); @@ -237,10 +193,15 @@ export const WalletsAndBackup = () => { logger.error(new RainbowError(`[WalletsAndBackup]: Failed to create new secret phrase`), { error: err, }); + } finally { + walletLoadingStore.setState({ + loadingState: null, + }); + scrollviewRef.current?.scrollTo({ y: 0, animated: true }); } }, }); - }, [dispatch, initializeWallet, navigate, profilesEnabled, walletTypeCount.phrase]); + }, [dispatch, initializeWallet, navigate, walletTypeCount.phrase]); const onPressLearnMoreAboutCloudBackups = useCallback(() => { navigate(Routes.LEARN_WEB_VIEW_SCREEN, { @@ -263,6 +224,66 @@ export const WalletsAndBackup = () => { [navigate, wallets] ); + const { status: iconStatusType, text } = useMemo<{ status: StatusType; text: string }>(() => { + if (!backupProvider) { + if (status === CloudBackupState.FailedToInitialize || status === CloudBackupState.NotAvailable) { + return { + status: 'not-enabled', + text: i18n.t(i18n.l.back_up.cloud.statuses.not_enabled), + }; + } + + if (status !== CloudBackupState.Ready) { + return { + status: 'out-of-sync', + text: i18n.t(i18n.l.back_up.cloud.statuses.syncing), + }; + } + + if (!allBackedUp) { + return { + status: 'out-of-date', + text: i18n.t(i18n.l.back_up.cloud.statuses.out_of_date), + }; + } + + return { + status: 'up-to-date', + text: i18n.t(i18n.l.back_up.cloud.statuses.up_to_date), + }; + } + + if (status === CloudBackupState.FailedToInitialize || status === CloudBackupState.NotAvailable) { + return { + status: 'not-enabled', + text: i18n.t(i18n.l.back_up.cloud.statuses.not_enabled), + }; + } + + if (status !== CloudBackupState.Ready) { + return { + status: 'out-of-sync', + text: i18n.t(i18n.l.back_up.cloud.statuses.syncing), + }; + } + + if (!allBackedUp) { + return { + status: 'out-of-date', + text: i18n.t(i18n.l.back_up.cloud.statuses.out_of_date), + }; + } + + return { + status: 'up-to-date', + text: i18n.t(i18n.l.back_up.cloud.statuses.up_to_date), + }; + }, [backupProvider, status, allBackedUp]); + + const isCloudBackupDisabled = useMemo(() => { + return status !== CloudBackupState.Ready && status !== CloudBackupState.NotAvailable; + }, [status]); + const renderView = useCallback(() => { switch (backupProvider) { default: @@ -275,7 +296,7 @@ export const WalletsAndBackup = () => { paddingTop={{ custom: 8 }} iconComponent={} titleComponent={} - statusComponent={} + statusComponent={} labelComponent={ { /> - - - + + + + + - {sortedWallets.map(({ name, isBackedUp, accounts, key, numAccounts, backedUp, imported }) => { + {sortedWallets.map(({ id, name, backedUp, backupFile, backupType, imported, addresses }) => { + const isBackedUp = isWalletBackedUpForCurrentAccount({ backedUp, backupFile, backupType }); + return ( - + { } > - {!backedUp && ( + {!isBackedUp && ( @@ -330,37 +356,43 @@ export const WalletsAndBackup = () => { {imported && } 1 + addresses.length > 1 ? i18n.t(i18n.l.wallet.back_ups.wallet_count_gt_one, { - numAccounts, + numAccounts: addresses.length, }) : i18n.t(i18n.l.wallet.back_ups.wallet_count, { - numAccounts, + numAccounts: addresses.length, }) } /> } leftComponent={} - onPress={() => onNavigateToWalletView(key, name)} + onPress={() => onNavigateToWalletView(id, name)} size={60} titleComponent={} /> - {accounts.map(account => ( - - ))} - + } + keyExtractor={item => item.address} + numColumns={3} + scrollEnabled={false} + /> } /> ); })} + { titleComponent={} /> - - - } - onPress={onViewCloudBackups} - size={52} - titleComponent={ - - } - /> - } - onPress={manageCloudBackups} - size={52} - titleComponent={ - - } - /> - ); @@ -416,12 +417,7 @@ export const WalletsAndBackup = () => { paddingTop={{ custom: 8 }} iconComponent={} titleComponent={} - statusComponent={ - - } + statusComponent={} labelComponent={ allBackedUp ? ( { /> - + - - + } + > + + + - {sortedWallets.map(({ name, isBackedUp, accounts, key, numAccounts, backedUp, imported }) => { + {sortedWallets.map(({ id, name, backedUp, backupFile, backupType, imported, addresses }) => { + const isBackedUp = isWalletBackedUpForCurrentAccount({ backedUp, backupFile, backupType }); + return ( - + { } > - {!backedUp && } + {!isBackedUp && ( + + )} {imported && } 1 + addresses.length > 1 ? i18n.t(i18n.l.wallet.back_ups.wallet_count_gt_one, { - numAccounts, + numAccounts: addresses.length, }) : i18n.t(i18n.l.wallet.back_ups.wallet_count, { - numAccounts, + numAccounts: addresses.length, }) } /> } leftComponent={} - onPress={() => onNavigateToWalletView(key, name)} + onPress={() => onNavigateToWalletView(id, name)} size={60} titleComponent={} /> - {accounts.map(account => ( - - ))} - + } + keyExtractor={item => item.address} + numColumns={3} + scrollEnabled={false} + /> } /> @@ -581,12 +588,13 @@ export const WalletsAndBackup = () => { case WalletBackupTypes.manual: { return ( - {sortedWallets.map(({ name, isBackedUp, accounts, key, numAccounts, backedUp, imported }) => { + {sortedWallets.map(({ id, name, backedUp, backupType, backupFile, imported, addresses }) => { + const isBackedUp = isWalletBackedUpForCurrentAccount({ backedUp, backupType, backupFile }); return ( - + { } > - {!backedUp && } + {!isBackedUp && ( + + )} {imported && } 1 + addresses.length > 1 ? i18n.t(i18n.l.wallet.back_ups.wallet_count_gt_one, { - numAccounts, + numAccounts: addresses.length, }) : i18n.t(i18n.l.wallet.back_ups.wallet_count, { - numAccounts, + numAccounts: addresses.length, }) } /> } leftComponent={} - onPress={() => onNavigateToWalletView(key, name)} + onPress={() => onNavigateToWalletView(id, name)} size={60} titleComponent={} /> - {accounts.map(account => ( - - ))} - + } + keyExtractor={item => item.address} + numColumns={3} + scrollEnabled={false} + /> } /> @@ -645,26 +664,29 @@ export const WalletsAndBackup = () => { /> - - {i18n.t(i18n.l.wallet.back_ups.cloud_backup_description, { - cloudPlatform, - })} + + + {i18n.t(i18n.l.wallet.back_ups.cloud_backup_description, { + cloudPlatform, + })} - - {' '} - {i18n.t(i18n.l.wallet.back_ups.cloud_backup_link)} + + {' '} + {i18n.t(i18n.l.wallet.back_ups.cloud_backup_link)} + - - } - > - - + } + > + + + ); @@ -672,21 +694,29 @@ export const WalletsAndBackup = () => { } }, [ backupProvider, - loading, - backupAllNonBackedUpWalletsTocloud, + iconStatusType, + text, + status, + isCloudBackupDisabled, + enableCloudBackups, sortedWallets, onCreateNewSecretPhrase, - onViewCloudBackups, - manageCloudBackups, navigate, onNavigateToWalletView, allBackedUp, mostRecentBackup, - lastBackupDate, + backupAllNonBackedUpWalletsTocloud, + onViewCloudBackups, + manageCloudBackups, onPressLearnMoreAboutCloudBackups, ]); - return {renderView()}; + return ( + + + {renderView()} + + ); }; export default WalletsAndBackup; diff --git a/src/screens/SettingsSheet/components/GoogleAccountSection.tsx b/src/screens/SettingsSheet/components/GoogleAccountSection.tsx index b415e1d4d30..10e28e6ebc6 100644 --- a/src/screens/SettingsSheet/components/GoogleAccountSection.tsx +++ b/src/screens/SettingsSheet/components/GoogleAccountSection.tsx @@ -3,14 +3,12 @@ import { getGoogleAccountUserData, GoogleDriveUserData, logoutFromGoogleDrive } import ImageAvatar from '@/components/contacts/ImageAvatar'; import { showActionSheetWithOptions } from '@/utils'; import * as i18n from '@/languages'; -import { clearAllWalletsBackupStatus, updateWalletBackupStatusesBasedOnCloudUserData } from '@/redux/wallets'; -import { useDispatch } from 'react-redux'; import Menu from './Menu'; import MenuItem from './MenuItem'; import { logger, RainbowError } from '@/logger'; +import { backupsStore } from '@/state/backups/backups'; export const GoogleAccountSection: React.FC = () => { - const dispatch = useDispatch(); const [accountDetails, setAccountDetails] = useState(undefined); const [loading, setLoading] = useState(true); @@ -29,12 +27,6 @@ export const GoogleAccountSection: React.FC = () => { }); }, []); - const removeBackupStateFromAllWallets = async () => { - setLoading(true); - await dispatch(clearAllWalletsBackupStatus()); - setLoading(false); - }; - const onGoogleAccountPress = () => { showActionSheetWithOptions( { @@ -49,11 +41,10 @@ export const GoogleAccountSection: React.FC = () => { if (buttonIndex === 0) { logoutFromGoogleDrive(); setAccountDetails(undefined); - removeBackupStateFromAllWallets().then(() => loginToGoogleDrive()); + loginToGoogleDrive(); } else if (buttonIndex === 1) { logoutFromGoogleDrive(); setAccountDetails(undefined); - removeBackupStateFromAllWallets(); } } ); @@ -61,10 +52,10 @@ export const GoogleAccountSection: React.FC = () => { const loginToGoogleDrive = async () => { setLoading(true); - await dispatch(updateWalletBackupStatusesBasedOnCloudUserData()); try { const accountDetails = await getGoogleAccountUserData(); setAccountDetails(accountDetails ?? undefined); + backupsStore.getState().syncAndFetchBackups(); } catch (error) { logger.error(new RainbowError(`[GoogleAccountSection]: Logging into Google Drive failed`), { error: (error as Error).message, diff --git a/src/screens/SettingsSheet/components/MenuContainer.tsx b/src/screens/SettingsSheet/components/MenuContainer.tsx index 500960c55a5..cabb0157fb7 100644 --- a/src/screens/SettingsSheet/components/MenuContainer.tsx +++ b/src/screens/SettingsSheet/components/MenuContainer.tsx @@ -3,13 +3,14 @@ import { ScrollView } from 'react-native'; import { Box, Inset, Space, Stack } from '@/design-system'; interface MenuContainerProps { + scrollviewRef?: React.RefObject; children: React.ReactNode; Footer?: React.ReactNode; testID?: string; space?: Space; } -const MenuContainer = ({ children, testID, Footer, space = '36px' }: MenuContainerProps) => { +const MenuContainer = ({ scrollviewRef, children, testID, Footer, space = '36px' }: MenuContainerProps) => { return ( // ios scroll fix ( ); -type StatusType = 'not-enabled' | 'out-of-date' | 'up-to-date'; +export type StatusType = 'not-enabled' | 'out-of-date' | 'up-to-date' | 'out-of-sync'; interface StatusIconProps { status: StatusType; @@ -87,6 +87,10 @@ const StatusIcon = ({ status, text }: StatusIconProps) => { backgroundColor: isDarkMode ? colors.alpha(colors.blueGreyDark, 0.1) : colors.alpha(colors.blueGreyDark, 0.1), color: isDarkMode ? colors.alpha(colors.blueGreyDark, 0.6) : colors.alpha(colors.blueGreyDark, 0.8), }, + 'out-of-sync': { + backgroundColor: colors.alpha(colors.yellow, 0.2), + color: colors.yellow, + }, 'out-of-date': { backgroundColor: colors.alpha(colors.brightRed, 0.2), color: colors.brightRed, diff --git a/src/screens/SettingsSheet/components/SettingsSection.tsx b/src/screens/SettingsSheet/components/SettingsSection.tsx index 9fae44a89eb..095b88cbb85 100644 --- a/src/screens/SettingsSheet/components/SettingsSection.tsx +++ b/src/screens/SettingsSheet/components/SettingsSection.tsx @@ -28,9 +28,11 @@ import { showActionSheetWithOptions } from '@/utils'; import { handleReviewPromptAction } from '@/utils/reviewAlert'; import { ReviewPromptAction } from '@/storage/schema'; import { SettingsExternalURLs } from '../constants'; -import { capitalizeFirstLetter, checkWalletsForBackupStatus } from '../utils'; +import { checkLocalWalletsForBackupStatus } from '../utils'; import walletBackupTypes from '@/helpers/walletBackupTypes'; import { Box } from '@/design-system'; +import { capitalize } from 'lodash'; +import { backupsStore } from '@/state/backups/backups'; interface SettingsSectionProps { onCloseModal: () => void; @@ -59,10 +61,14 @@ const SettingsSection = ({ const isLanguageSelectionEnabled = useExperimentalFlag(LANGUAGE_SETTINGS); const isNotificationsEnabled = useExperimentalFlag(NOTIFICATIONS); + const { backupProvider, backups } = backupsStore(state => ({ + backupProvider: state.backupProvider, + backups: state.backups, + })); + const { isDarkMode, setTheme, colorScheme } = useTheme(); const onSendFeedback = useSendFeedback(); - const { backupProvider } = useMemo(() => checkWalletsForBackupStatus(wallets), [wallets]); const onPressReview = useCallback(async () => { if (ios) { @@ -85,7 +91,7 @@ const SettingsSection = ({ const onPressLearn = useCallback(() => Linking.openURL(SettingsExternalURLs.rainbowLearn), []); - const { allBackedUp, canBeBackedUp } = useMemo(() => checkWalletsForBackupStatus(wallets), [wallets]); + const { allBackedUp } = useMemo(() => checkLocalWalletsForBackupStatus(wallets, backups), [wallets, backups]); const themeMenuConfig = useMemo(() => { return { @@ -170,21 +176,19 @@ const SettingsSection = ({ return ( }> - {canBeBackedUp && ( - } - onPress={onPressBackup} - rightComponent={ - - - - } - size={60} - testID={'backup-section'} - titleComponent={} - /> - )} + } + onPress={onPressBackup} + rightComponent={ + + + + } + size={60} + testID={'backup-section'} + titleComponent={} + /> {isNotificationsEnabled && ( } - rightComponent={{colorScheme ? capitalizeFirstLetter(colorScheme) : ''}} + rightComponent={{colorScheme ? capitalize(colorScheme) : ''}} size={60} testID={`theme-section-${isDarkMode ? 'dark' : 'light'}`} titleComponent={} diff --git a/src/screens/SettingsSheet/useVisibleWallets.ts b/src/screens/SettingsSheet/useVisibleWallets.ts index 64e73aa0929..c677dd738db 100644 --- a/src/screens/SettingsSheet/useVisibleWallets.ts +++ b/src/screens/SettingsSheet/useVisibleWallets.ts @@ -1,9 +1,7 @@ -import { useState } from 'react'; import * as i18n from '@/languages'; import WalletTypes, { EthereumWalletType } from '@/helpers/walletTypes'; -import { DEFAULT_WALLET_NAME, RainbowAccount, RainbowWallet } from '@/model/wallet'; -import walletBackupTypes from '@/helpers/walletBackupTypes'; +import { RainbowWallet } from '@/model/wallet'; type WalletByKey = { [key: string]: RainbowWallet; @@ -19,20 +17,6 @@ export type WalletCountPerType = { privateKey: number; }; -export type AmendedRainbowWallet = RainbowWallet & { - name: string; - isBackedUp: boolean | undefined; - accounts: RainbowAccount[]; - key: string; - label: string; - numAccounts: number; -}; - -type UseVisibleWalletReturnType = { - visibleWallets: AmendedRainbowWallet[]; - lastBackupDate: number | undefined; -}; - export const getTitleForWalletType = (type: EthereumWalletType, walletTypeCount: WalletCountPerType) => { switch (type) { case EthereumWalletType.mnemonic: @@ -48,51 +32,26 @@ export const getTitleForWalletType = (type: EthereumWalletType, walletTypeCount: } }; -const isWalletGroupNamed = (wallet: RainbowWallet) => wallet.name && wallet.name.trim() !== '' && wallet.name !== DEFAULT_WALLET_NAME; - -export const useVisibleWallets = ({ wallets, walletTypeCount }: UseVisibleWalletProps): UseVisibleWalletReturnType => { - const [lastBackupDate, setLastBackupDate] = useState(undefined); - +export const useVisibleWallets = ({ wallets, walletTypeCount }: UseVisibleWalletProps): RainbowWallet[] => { if (!wallets) { - return { - visibleWallets: [], - lastBackupDate, - }; + return []; } - return { - visibleWallets: Object.keys(wallets) - .filter(key => wallets[key].type !== WalletTypes.readOnly && wallets[key].type !== WalletTypes.bluetooth) - .map(key => { - const wallet = wallets[key]; - const visibleAccounts = (wallet.addresses || []).filter(a => a.visible); - const totalAccounts = visibleAccounts.length; - - if ( - wallet.backedUp && - wallet.backupDate && - wallet.backupType === walletBackupTypes.cloud && - (!lastBackupDate || Number(wallet.backupDate) > lastBackupDate) - ) { - setLastBackupDate(Number(wallet.backupDate)); - } - - if (wallet.type === WalletTypes.mnemonic) { - walletTypeCount.phrase += 1; - } else if (wallet.type === WalletTypes.privateKey) { - walletTypeCount.privateKey += 1; - } - - return { - ...wallet, - name: isWalletGroupNamed(wallet) ? wallet.name : getTitleForWalletType(wallet.type, walletTypeCount), - isBackedUp: wallet.backedUp, - accounts: visibleAccounts, - key, - label: wallet.name, - numAccounts: totalAccounts, - }; - }), - lastBackupDate, - }; + return Object.keys(wallets) + .filter(key => wallets[key].type !== WalletTypes.readOnly && wallets[key].type !== WalletTypes.bluetooth) + .map(key => { + const wallet = wallets[key]; + + if (wallet.type === WalletTypes.mnemonic) { + walletTypeCount.phrase += 1; + } else if (wallet.type === WalletTypes.privateKey) { + walletTypeCount.privateKey += 1; + } + + return { + ...wallet, + name: getTitleForWalletType(wallet.type, walletTypeCount), + addresses: Object.values(wallet.addresses).filter(address => address.visible), + }; + }); }; diff --git a/src/screens/SettingsSheet/utils.ts b/src/screens/SettingsSheet/utils.ts index 08fa3e03e22..0fb1d26faff 100644 --- a/src/screens/SettingsSheet/utils.ts +++ b/src/screens/SettingsSheet/utils.ts @@ -1,118 +1,121 @@ import WalletBackupTypes from '@/helpers/walletBackupTypes'; import WalletTypes from '@/helpers/walletTypes'; +import { useWallets } from '@/hooks'; +import { isEmpty } from 'lodash'; +import { BackupFile, CloudBackups, parseTimestampFromFilename } from '@/model/backup'; +import * as i18n from '@/languages'; +import { cloudPlatform } from '@/utils/platform'; +import { backupsStore, CloudBackupState } from '@/state/backups/backups'; import { RainbowWallet } from '@/model/wallet'; -import { Navigation } from '@/navigation'; -import { BackupUserData, getLocalBackupPassword } from '@/model/backup'; -import Routes from '@/navigation/routesNames'; -import WalletBackupStepTypes from '@/helpers/walletBackupStepTypes'; - -type WalletsByKey = { - [key: string]: RainbowWallet; -}; +import { IS_ANDROID, IS_IOS } from '@/env'; +import { normalizeAndroidBackupFilename } from '@/handlers/cloudBackup'; type WalletBackupStatus = { allBackedUp: boolean; areBackedUp: boolean; canBeBackedUp: boolean; - backupProvider: string | undefined; }; -export const capitalizeFirstLetter = (str: string) => { - return str.charAt(0).toUpperCase() + str.slice(1); +export const hasManuallyBackedUpWallet = (wallets: ReturnType['wallets']) => { + if (!wallets) return false; + return Object.values(wallets).some(wallet => wallet.backupType === WalletBackupTypes.manual); }; -export const checkUserDataForBackupProvider = (userData?: BackupUserData): { backupProvider: string | undefined } => { - let backupProvider: string | undefined = undefined; - - if (!userData?.wallets) return { backupProvider }; - - Object.values(userData.wallets).forEach(wallet => { - if (wallet.backedUp && wallet.type !== WalletTypes.readOnly) { - if (wallet.backupType === WalletBackupTypes.cloud) { - backupProvider = WalletBackupTypes.cloud; - } else if (backupProvider !== WalletBackupTypes.cloud && wallet.backupType === WalletBackupTypes.manual) { - backupProvider = WalletBackupTypes.manual; - } - } - }); - - return { backupProvider }; -}; - -export const checkWalletsForBackupStatus = (wallets: WalletsByKey | null): WalletBackupStatus => { - if (!wallets) +export const checkLocalWalletsForBackupStatus = ( + wallets: ReturnType['wallets'], + backups: CloudBackups +): WalletBackupStatus => { + if (!wallets || isEmpty(wallets)) { return { allBackedUp: false, areBackedUp: false, canBeBackedUp: false, - backupProvider: undefined, }; + } + + // FOR ANDROID, we need to check if the current google account also has the backup file + if (IS_ANDROID) { + return Object.values(wallets).reduce( + (acc, wallet) => { + const isBackupEligible = wallet.type !== WalletTypes.readOnly && wallet.type !== WalletTypes.bluetooth; + const hasBackupFile = backups.files.some( + file => normalizeAndroidBackupFilename(file.name) === normalizeAndroidBackupFilename(wallet.backupFile ?? '') + ); + + return { + allBackedUp: acc.allBackedUp && hasBackupFile && (wallet.backedUp || !isBackupEligible), + areBackedUp: acc.areBackedUp && hasBackupFile && (wallet.backedUp || !isBackupEligible), + canBeBackedUp: acc.canBeBackedUp && isBackupEligible, + }; + }, + { allBackedUp: true, areBackedUp: true, canBeBackedUp: false } + ); + } + + return Object.values(wallets).reduce( + (acc, wallet) => { + const isBackupEligible = wallet.type !== WalletTypes.readOnly && wallet.type !== WalletTypes.bluetooth; + + return { + allBackedUp: acc.allBackedUp && (wallet.backedUp || !isBackupEligible), + areBackedUp: acc.areBackedUp && (wallet.backedUp || !isBackupEligible || wallet.imported), + canBeBackedUp: acc.canBeBackedUp && isBackupEligible, + }; + }, + { allBackedUp: true, areBackedUp: true, canBeBackedUp: false } + ); +}; - let backupProvider: string | undefined = undefined; - let areBackedUp = true; - let canBeBackedUp = false; - let allBackedUp = true; - - Object.keys(wallets).forEach(key => { - if (wallets[key].backedUp && wallets[key].type !== WalletTypes.readOnly && wallets[key].type !== WalletTypes.bluetooth) { - if (wallets[key].backupType === WalletBackupTypes.cloud) { - backupProvider = WalletBackupTypes.cloud; - } else if (backupProvider !== WalletBackupTypes.cloud && wallets[key].backupType === WalletBackupTypes.manual) { - backupProvider = WalletBackupTypes.manual; - } - } +export const getMostRecentCloudBackup = (backups: BackupFile[]) => { + const cloudBackups = backups.sort((a, b) => { + return parseTimestampFromFilename(b.name) - parseTimestampFromFilename(a.name); + }); - if (!wallets[key].backedUp && wallets[key].type !== WalletTypes.readOnly && wallets[key].type !== WalletTypes.bluetooth) { - allBackedUp = false; + return cloudBackups.reduce((prev, current) => { + if (!current) { + return prev; } - if ( - !wallets[key].backedUp && - wallets[key].type !== WalletTypes.readOnly && - wallets[key].type !== WalletTypes.bluetooth && - !wallets[key].imported - ) { - areBackedUp = false; + if (!prev) { + return current; } - if (wallets[key].type !== WalletTypes.bluetooth && wallets[key].type !== WalletTypes.readOnly) { - canBeBackedUp = true; + const prevTimestamp = new Date(prev.lastModified).getTime(); + const currentTimestamp = new Date(current.lastModified).getTime(); + if (currentTimestamp > prevTimestamp) { + return current; } - }); - return { - allBackedUp, - areBackedUp, - canBeBackedUp, - backupProvider, - }; + + return prev; + }, cloudBackups[0]); }; -export const getWalletsThatNeedBackedUp = (wallets: { [key: string]: RainbowWallet } | null): RainbowWallet[] => { - if (!wallets) return []; - const walletsToBackup: RainbowWallet[] = []; - Object.keys(wallets).forEach(key => { - if ( - !wallets[key].backedUp && - wallets[key].type !== WalletTypes.readOnly && - wallets[key].type !== WalletTypes.bluetooth && - !wallets[key].imported - ) { - walletsToBackup.push(wallets[key]); - } - }); - return walletsToBackup; +export const titleForBackupState: Partial> = { + [CloudBackupState.Initializing]: i18n.t(i18n.l.back_up.cloud.syncing_cloud_store, { + cloudPlatformName: cloudPlatform, + }), + [CloudBackupState.Syncing]: i18n.t(i18n.l.back_up.cloud.syncing_cloud_store, { + cloudPlatformName: cloudPlatform, + }), + [CloudBackupState.Fetching]: i18n.t(i18n.l.back_up.cloud.fetching_backups, { + cloudPlatformName: cloudPlatform, + }), }; -export const fetchBackupPasswordAndNavigate = async () => { - const password = await getLocalBackupPassword(); +export const isWalletBackedUpForCurrentAccount = ({ backupType, backedUp, backupFile }: Partial) => { + if (!backupType || !backupFile) { + return false; + } - return new Promise(resolve => { - return Navigation.handleAction(Routes.BACKUP_SHEET, { - step: WalletBackupStepTypes.backup_cloud, - password, - onSuccess: async (password: string) => { - resolve(password); - }, - }); - }); + if (IS_IOS || backupType === WalletBackupTypes.manual) { + return backedUp; + } + + // NOTE: For Android, we also need to check if the current google account has the matching backup file + if (!backupFile) { + return false; + } + + const backupFiles = backupsStore.getState().backups; + return backupFiles.files.some(file => normalizeAndroidBackupFilename(file.name) === normalizeAndroidBackupFilename(backupFile)); }; diff --git a/src/screens/WalletScreen/index.tsx b/src/screens/WalletScreen/index.tsx index acfcbcfb176..bf5fa201570 100644 --- a/src/screens/WalletScreen/index.tsx +++ b/src/screens/WalletScreen/index.tsx @@ -25,11 +25,11 @@ import { RemoteCardsSync } from '@/state/sync/RemoteCardsSync'; import { RemotePromoSheetSync } from '@/state/sync/RemotePromoSheetSync'; import { UserAssetsSync } from '@/state/sync/UserAssetsSync'; import { MobileWalletProtocolListener } from '@/components/MobileWalletProtocolListener'; -import { runWalletBackupStatusChecks } from '@/handlers/walletReadyEvents'; import { RouteProp, useRoute } from '@react-navigation/native'; import { RootStackParamList } from '@/navigation/types'; import { useNavigation } from '@/navigation'; import Routes from '@/navigation/Routes'; +import { BackendNetworks } from '@/components/BackendNetworks'; import walletTypes from '@/helpers/walletTypes'; enum WalletLoadingStates { @@ -45,7 +45,6 @@ function WalletScreen() { const walletState = useRef(WalletLoadingStates.IDLE); const initializeWallet = useInitializeWallet(); const { network: currentNetwork, accountAddress, appIcon } = useAccountSettings(); - const loadAccountLateData = useLoadAccountLateData(); const loadGlobalLateData = useLoadGlobalLateData(); const insets = useSafeAreaInsets(); @@ -149,7 +148,6 @@ function WalletScreen() { if (walletReady) { loadAccountLateData(); loadGlobalLateData(); - runWalletBackupStatusChecks(); } }, [loadAccountLateData, loadGlobalLateData, walletReady]); @@ -185,6 +183,7 @@ function WalletScreen() { + {/* NOTE: This component listens for Mobile Wallet Protocol requests and handles them */} diff --git a/src/state/backups/backups.ts b/src/state/backups/backups.ts new file mode 100644 index 00000000000..ef1abf3ab23 --- /dev/null +++ b/src/state/backups/backups.ts @@ -0,0 +1,182 @@ +import { BackupFile, CloudBackups } from '@/model/backup'; +import { createRainbowStore } from '../internal/createRainbowStore'; +import { IS_ANDROID } from '@/env'; +import { fetchAllBackups, getGoogleAccountUserData, isCloudBackupAvailable, syncCloud } from '@/handlers/cloudBackup'; +import { RainbowError, logger } from '@/logger'; +import walletBackupTypes from '@/helpers/walletBackupTypes'; +import { getMostRecentCloudBackup, hasManuallyBackedUpWallet } from '@/screens/SettingsSheet/utils'; +import { Mutex } from 'async-mutex'; +import store from '@/redux/store'; + +const mutex = new Mutex(); + +export enum CloudBackupState { + Initializing = 'initializing', + Syncing = 'syncing', + Fetching = 'fetching', + FailedToInitialize = 'failed_to_initialize', + Ready = 'ready', + NotAvailable = 'not_available', + InProgress = 'in_progress', + Error = 'error', + Success = 'success', +} + +const DEFAULT_TIMEOUT = 10_000; +const MAX_RETRIES = 3; + +export const LoadingStates = [CloudBackupState.Initializing, CloudBackupState.Syncing, CloudBackupState.Fetching]; + +interface BackupsStore { + storedPassword: string; + setStoredPassword: (storedPassword: string) => void; + + backupProvider: string | undefined; + setBackupProvider: (backupProvider: string | undefined) => void; + + status: CloudBackupState; + setStatus: (status: CloudBackupState) => void; + + backups: CloudBackups; + setBackups: (backups: CloudBackups) => void; + + mostRecentBackup: BackupFile | undefined; + setMostRecentBackup: (backup: BackupFile | undefined) => void; + + password: string; + setPassword: (password: string) => void; + + syncAndFetchBackups: ( + retryOnFailure?: boolean, + retryCount?: number + ) => Promise<{ + success: boolean; + retry?: boolean; + }>; +} + +const returnEarlyIfLockedStates = [CloudBackupState.Syncing, CloudBackupState.Fetching]; + +export const backupsStore = createRainbowStore((set, get) => ({ + storedPassword: '', + setStoredPassword: storedPassword => set({ storedPassword }), + + backupProvider: undefined, + setBackupProvider: provider => set({ backupProvider: provider }), + + status: CloudBackupState.Initializing, + setStatus: status => set({ status }), + + backups: { files: [] }, + setBackups: backups => set({ backups }), + + mostRecentBackup: undefined, + setMostRecentBackup: backup => set({ mostRecentBackup: backup }), + + password: '', + setPassword: password => set({ password }), + + syncAndFetchBackups: async (retryOnFailure = true, retryCount = 0) => { + const { status } = get(); + + const timeoutPromise = new Promise<{ success: boolean; retry?: boolean }>(resolve => { + setTimeout(() => { + resolve({ success: false, retry: retryOnFailure }); + }, DEFAULT_TIMEOUT); + }); + + const syncAndPullFiles = async (): Promise<{ success: boolean; retry?: boolean }> => { + try { + const isAvailable = await isCloudBackupAvailable(); + if (!isAvailable) { + logger.debug('[backupsStore]: Cloud backup is not available'); + set({ backupProvider: undefined, status: CloudBackupState.NotAvailable, backups: { files: [] }, mostRecentBackup: undefined }); + return { + success: false, + retry: false, + }; + } + + if (IS_ANDROID) { + const gdata = await getGoogleAccountUserData(); + if (!gdata) { + logger.debug('[backupsStore]: Google account is not available'); + set({ backupProvider: undefined, status: CloudBackupState.NotAvailable, backups: { files: [] }, mostRecentBackup: undefined }); + return { + success: false, + retry: false, + }; + } + } + + set({ status: CloudBackupState.Syncing }); + logger.debug('[backupsStore]: Syncing with cloud'); + await syncCloud(); + + set({ status: CloudBackupState.Fetching }); + logger.debug('[backupsStore]: Fetching backups'); + const backups = await fetchAllBackups(); + + set({ backups }); + + const { wallets } = store.getState().wallets; + + // if the user has any cloud backups, set the provider to cloud + if (backups.files.length > 0) { + set({ + backupProvider: walletBackupTypes.cloud, + mostRecentBackup: getMostRecentCloudBackup(backups.files), + }); + } else if (hasManuallyBackedUpWallet(wallets)) { + set({ backupProvider: walletBackupTypes.manual }); + } else { + set({ backupProvider: undefined }); + } + + logger.debug(`[backupsStore]: Retrieved ${backups.files.length} backup files`); + + set({ status: CloudBackupState.Ready }); + return { + success: true, + retry: false, + }; + } catch (e) { + logger.error(new RainbowError('[backupsStore]: Failed to fetch all backups'), { + error: e, + }); + set({ status: CloudBackupState.FailedToInitialize }); + } + + return { + success: false, + retry: retryOnFailure, + }; + }; + + if (mutex.isLocked() || returnEarlyIfLockedStates.includes(status)) { + logger.debug('[backupsStore]: Mutex is locked or returnEarlyIfLockedStates includes status', { + status, + }); + return { + success: false, + retry: false, + }; + } + + const releaser = await mutex.acquire(); + logger.debug('[backupsStore]: Acquired mutex'); + const { success, retry } = await Promise.race([syncAndPullFiles(), timeoutPromise]); + releaser(); + logger.debug('[backupsStore]: Released mutex'); + if (retry && retryCount < MAX_RETRIES) { + logger.debug(`[backupsStore]: Retrying sync and fetch backups attempt: ${retryCount + 1}`); + return get().syncAndFetchBackups(retryOnFailure, retryCount + 1); + } + + if (retry && retryCount >= MAX_RETRIES) { + logger.error(new RainbowError('[backupsStore]: Max retry attempts reached. Sync failed.')); + } + + return { success, retry }; + }, +})); diff --git a/src/state/sync/BackupsSync.tsx b/src/state/sync/BackupsSync.tsx new file mode 100644 index 00000000000..a409490c205 --- /dev/null +++ b/src/state/sync/BackupsSync.tsx @@ -0,0 +1,12 @@ +import { useEffect, memo } from 'react'; +import { backupsStore } from '@/state/backups/backups'; + +const BackupsSyncComponent = () => { + useEffect(() => { + backupsStore.getState().syncAndFetchBackups(); + }, []); + + return null; +}; + +export const BackupsSync = memo(BackupsSyncComponent); diff --git a/src/state/walletLoading/walletLoading.ts b/src/state/walletLoading/walletLoading.ts new file mode 100644 index 00000000000..7391b78e760 --- /dev/null +++ b/src/state/walletLoading/walletLoading.ts @@ -0,0 +1,18 @@ +import { createRainbowStore } from '../internal/createRainbowStore'; +import { WalletLoadingStates } from '@/helpers/walletLoadingStates'; + +type WalletLoadingState = { + loadingState: WalletLoadingStates | null; + blockTouches: boolean; + Component: JSX.Element | null; + hide: () => void; + setComponent: (Component: JSX.Element, blockTouches?: boolean) => void; +}; + +export const walletLoadingStore = createRainbowStore(set => ({ + loadingState: null, + blockTouches: false, + Component: null, + hide: () => set({ blockTouches: false, Component: null }), + setComponent: (Component: JSX.Element, blockTouches = true) => set({ blockTouches, Component }), +}));