diff --git a/src/App.tsx b/src/App.tsx index d59b7ed7296..80a5f7140a2 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,7 +9,7 @@ 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'; @@ -22,7 +22,7 @@ import * as keychain from '@/model/keychain'; import { Navigation } from '@/navigation'; import { PersistQueryClientProvider, persistOptions, queryClient } from '@/react-query'; import store, { AppDispatch, type AppState } from '@/redux/store'; -import { MainThemeProvider, useTheme } from '@/theme/ThemeContext'; +import { MainThemeProvider } from '@/theme/ThemeContext'; import { addressKey } from '@/utils/keychainConstants'; import { SharedValuesProvider } from '@/helpers/SharedValuesContext'; import { InitialRouteContext } from '@/navigation/initialRoute'; @@ -42,7 +42,7 @@ import { Address } from 'viem'; import { IS_ANDROID, IS_DEV } from '@/env'; import { prefetchDefaultFavorites } from '@/resources/favorites'; import Routes from '@/navigation/Routes'; -import { BackendNetworks } from '@/components/BackendNetworks'; +import { BackupsSync } from '@/state/sync/BackupsSync'; if (IS_DEV) { reactNativeDisableYellowBox && LogBox.ignoreAllLogs(); @@ -70,12 +70,11 @@ function App({ walletReady }: AppProps) { }, []); return ( - + <> {initialRoute && ( - )} @@ -83,14 +82,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/PortalConsumer.js b/src/components/PortalConsumer.js index 351b2271f02..7644dff645b 100644 --- a/src/components/PortalConsumer.js +++ b/src/components/PortalConsumer.js @@ -2,17 +2,17 @@ import React, { useEffect } from 'react'; import { LoadingOverlay } from './modal'; import { useWallets } from '@/hooks'; import { sheetVerticalOffset } from '@/navigation/effects'; -import { usePortal } from '@/react-native-cool-modals/Portal'; +import { portalStore } from '@/state/portal/portal'; export default function PortalConsumer() { const { isWalletLoading } = useWallets(); - const { setComponent, hide } = usePortal(); + useEffect(() => { if (isWalletLoading) { - setComponent(, true); + portalStore.getState().setComponent(, true); } - return hide; - }, [hide, isWalletLoading, setComponent]); + return portalStore.getState().hide; + }, [isWalletLoading]); return null; } diff --git a/src/components/backup/AddWalletToCloudBackupStep.tsx b/src/components/backup/AddWalletToCloudBackupStep.tsx index 62f92a99e2f..24bfc56bf44 100644 --- a/src/components/backup/AddWalletToCloudBackupStep.tsx +++ b/src/components/backup/AddWalletToCloudBackupStep.tsx @@ -9,38 +9,40 @@ 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'; +import { useCreateBackup } from '@/components/backup/useCreateBackup'; +import { backupsStore } from '@/state/backups/backups'; +import { executeFnIfCloudBackupAvailable } from '@/model/backup'; const imageSize = 72; export default function AddWalletToCloudBackupStep() { const { goBack } = useNavigation(); - const { wallets, selectedWallet } = useWallets(); + const { selectedWallet } = useWallets(); + const createBackup = useCreateBackup(); - 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 { mostRecentBackup } = backupsStore(state => ({ + mostRecentBackup: state.mostRecentBackup, + })); const potentiallyLoginAndSubmit = useCallback(async () => { - await login(); - return onSubmit({}); - }, [onSubmit]); + const result = await executeFnIfCloudBackupAvailable({ + fn: () => + createBackup({ + walletId: selectedWallet.id, + navigateToRoute: { + route: Routes.SETTINGS_SHEET, + params: { + screen: Routes.SETTINGS_SECTION_BACKUP, + }, + }, + }), + }); + + if (result) { + goBack(); + } + }, [createBackup, goBack, selectedWallet.id]); const onMaybeLater = useCallback(() => goBack(), [goBack]); @@ -70,7 +72,7 @@ export default function AddWalletToCloudBackupStep() { - potentiallyLoginAndSubmit().then(success => success && goBack())}> + @@ -105,13 +107,13 @@ export default function AddWalletToCloudBackupStep() { - {lastBackupDate && ( + {mostRecentBackup && ( {lang.t(lang.l.back_up.cloud.latest_backup, { - date: format(lastBackupDate, "M/d/yy 'at' h:mm a"), + date: format(new Date(mostRecentBackup.lastModified), "M/d/yy 'at' h:mm a"), })} diff --git a/src/components/backup/BackupChooseProviderStep.tsx b/src/components/backup/BackupChooseProviderStep.tsx index 38325639704..c576626c541 100644 --- a/src/components/backup/BackupChooseProviderStep.tsx +++ b/src/components/backup/BackupChooseProviderStep.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useCreateBackup } from '@/components/backup/useCreateBackup'; import { Bleed, Box, Inline, Inset, Separator, Stack, Text } from '@/design-system'; import * as lang from '@/languages'; import { ImgixImage } from '../images'; @@ -16,11 +15,9 @@ import { SETTINGS_BACKUP_ROUTES } from '@/screens/SettingsSheet/components/Backu import { useWallets } from '@/hooks'; 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'; const imageSize = 72; @@ -28,62 +25,24 @@ 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), + const onCloudBackup = () => { + executeFnIfCloudBackupAvailable({ + fn: () => + createBackup({ + walletId: selectedWallet.id, + navigateToRoute: { + route: Routes.SETTINGS_SHEET, + params: { + screen: Routes.SETTINGS_SECTION_BACKUP, }, - ] - ); - return; - } - } - - onSubmit({}); + }, + }), + }); }; const onManualBackup = async () => { @@ -117,7 +76,7 @@ export default function BackupSheetSectionNoProvider() { {/* replace this with BackUpMenuButton */} - + diff --git a/src/components/backup/BackupSheet.tsx b/src/components/backup/BackupSheet.tsx index 5b6a0a4300a..b21487470e2 100644 --- a/src/components/backup/BackupSheet.tsx +++ b/src/components/backup/BackupSheet.tsx @@ -8,7 +8,6 @@ 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: { @@ -40,19 +39,17 @@ export default function BackupSheet() { }, [step]); return ( - - - {({ backgroundColor }) => ( - - {renderStep()} - - )} - - + + {({ backgroundColor }) => ( + + {renderStep()} + + )} + ); } 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..186f318c18b 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,11 +16,17 @@ 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 { addressSetSelected, setAllWalletsWithIdsAsBackedUp, walletsLoadState, walletsSetSelected } from '@/redux/wallets'; +import { useDimensions, useInitializeWallet, useWallets } from '@/hooks'; +import { Navigation, useNavigation } from '@/navigation'; +import { + addressSetSelected, + setAllWalletsWithIdsAsBackedUp, + setIsWalletLoading, + walletsLoadState, + walletsSetSelected, +} from '@/redux/wallets'; import Routes from '@/navigation/routesNames'; import styled from '@/styled-thing'; import { padding } from '@/styles'; @@ -36,7 +41,9 @@ 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 { WalletLoadingStates } from '@/helpers/walletLoadingStates'; +import { isEmpty } from 'lodash'; +import { backupsStore } from '@/state/backups/backups'; const Title = styled(Text).attrs({ size: 'big', @@ -76,33 +83,39 @@ const KeyboardSizeView = styled(KeyboardArea)({ type RestoreCloudStepParams = { RestoreSheet: { - selectedBackup: Backup; + selectedBackup: BackupFile; }; }; export default function RestoreCloudStep() { const { params } = useRoute>(); - - const { userData } = useCloudBackups(); + const { password } = backupsStore(state => ({ + password: state.password, + })); 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(); + const { isWalletLoading } = useWallets(); useEffect(() => { const fetchPasswordIfPossible = async () => { - const pwd = await fetchBackupPassword(); + const pwd = await getLocalBackupPassword(); if (pwd) { - setPassword(pwd); + backupsStore.getState().setPassword(pwd); } }; fetchPasswordIfPossible(); @@ -118,35 +131,36 @@ 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()); - + dispatch(setIsWalletLoading(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); + await saveLocalBackupPassword(pwd); } InteractionManager.runAfterInteractions(async () => { const newWalletsState = await dispatch(walletsLoadState()); - let filename = selectedBackup.name; if (IS_ANDROID && filename) { filename = normalizeAndroidBackupFilename(filename); } @@ -188,14 +202,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, + }, + false + ); + } else { + Navigation.handleAction(Routes.WALLET_SCREEN, {}); + } } else { switch (status) { case RestoreCloudBackupResultStates.incorrectPassword: @@ -211,18 +232,15 @@ export default function RestoreCloudStep() { } } catch (e) { Alert.alert(lang.t('back_up.restore_cloud.error_while_restoring')); + } finally { + dispatch(setIsWalletLoading(null)); } - - setLoading(false); - }, [selectedBackup.name, password, userData, dispatch, initializeWallet, dangerouslyGetState, navigate, replace]); + }, [password, dispatch, selectedBackup.name, initializeWallet, onRestoreSuccess]); const onPasswordSubmit = useCallback(() => { validPassword && onSubmit(); }, [onSubmit, validPassword]); - const isPasswordValid = - (password !== '' && password.length < cloudBackupPasswordMinLength && !passwordRef?.current?.isFocused()) || incorrectPassword; - return ( @@ -248,8 +266,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) => { + const hasSavedPassword = await getLocalBackupPassword(); + if (!hasSavedPassword && password.trim()) { + await saveLocalBackupPassword(password); + } + 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) => { InteractionManager.runAfterInteractions(async () => { DelayedAlert({ title: msg }, 500); - setLoadingStateWithTimeout('error', 5000); + 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'); + backupsStore.getState().setStatus(CloudBackupState.Error); return; } backupAllWalletsToCloud({ - wallets: wallets as AllRainbowWallets, + wallets, password, - latestBackup, onError, onSuccess, dispatch, @@ -94,12 +99,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 +110,12 @@ 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); return password; } @@ -126,32 +124,37 @@ 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(); + const createBackup = useCallback( + async (props: UseCreateBackupProps) => { + if (backupsStore.getState().status !== CloudBackupState.Ready) { + return false; + } + + const password = await getPassword(props); if (password) { onConfirmBackup({ password, - type, + ...props, }); return true; } - setLoadingStateWithTimeout('error'); + setLoadingStateWithTimeout({ + state: CloudBackupState.Ready, + }); return false; }, [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/remote-promo-sheet/runChecks.ts b/src/components/remote-promo-sheet/runChecks.ts index f83170eecce..d25902adcfa 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 { runFeatureAndLocalCampaignChecks } 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 runFeatureAndLocalCampaignChecks()) 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/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..38cef55fefe 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,17 @@ 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 { backupsStore } from '@/state/backups/backups'; +import { IS_TEST } from '@/env'; export const runKeychainIntegrityChecks = async () => { const keychainIntegrityState = await getKeychainIntegrityState(); @@ -27,59 +25,29 @@ 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 { selected } = store.getState().wallets; + if (!selected || IS_TEST) 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 - ); - }); - - if (!rainbowWalletsNotBackedUp.length) return; - - logger.debug('[walletReadyEvents]: there is a rainbow wallet not backed up'); - - const hasSelectedWallet = rainbowWalletsNotBackedUp.find(notBackedUpWallet => notBackedUpWallet.id === selected!.id); - logger.debug('[walletReadyEvents]: rainbow wallet not backed up that is selected?', { - hasSelectedWallet, - }); - - // if one of them is selected, show the default BackupSheet - if (selected && hasSelectedWallet && IS_TESTING !== 'true') { + const isSelectedWalletBackedUp = selected.backedUp && !selected.damaged && selected.type !== WalletTypes.readOnly; + if (!isSelectedWalletBackedUp) { + logger.debug('[walletReadyEvents]: Selected wallet is not backed up, prompting backup sheet'); + const provider = backupsStore.getState().backupProvider; let stepType: string = WalletBackupStepTypes.no_provider; - if (backupProvider === walletBackupTypes.cloud) { + if (provider === walletBackupTypes.cloud) { stepType = WalletBackupStepTypes.backup_now_to_cloud; - } else if (backupProvider === walletBackupTypes.manual) { + } else if (provider === walletBackupTypes.manual) { stepType = WalletBackupStepTypes.backup_now_manually; } - setTimeout(() => { - logger.debug(`[walletReadyEvents]: showing ${stepType} backup sheet for selected wallet`); + InteractionManager.runAfterInteractions(() => { + logger.debug(`[walletReadyEvents]: BackupSheet: showing ${stepType} for selected wallet`); triggerOnSwipeLayout(() => Navigation.handleAction(Routes.BACKUP_SHEET, { step: stepType, }) ); - }, BACKUP_SHEET_DELAY_MS); - return; + }); } - return; }; export const runFeatureUnlockChecks = async (): Promise => { @@ -107,19 +75,18 @@ 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(); + const showingFeatureUnlock = await runFeatureUnlockChecks(); if (!showingFeatureUnlock) { - await runLocalCampaignChecks(); + return await runLocalCampaignChecks(); } + return false; }; diff --git a/src/helpers/walletLoadingStates.ts b/src/helpers/walletLoadingStates.ts new file mode 100644 index 00000000000..44e2d94c039 --- /dev/null +++ b/src/helpers/walletLoadingStates.ts @@ -0,0 +1,10 @@ +export const WalletLoadingStates = { + BACKING_UP_WALLET: 'Backing up...', + CREATING_WALLET: 'Creating wallet...', + FETCHING_PASSWORD: 'Fetching Password...', + IMPORTING_WALLET: 'Importing...', + IMPORTING_WALLET_SILENTLY: '', + RESTORING_WALLET: 'Restoring...', +} as const; + +export type WalletLoadingState = (typeof WalletLoadingStates)[keyof typeof WalletLoadingStates]; 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 e78fa43a2e8..5d4b134bc50 100644 --- a/src/hooks/useImportingWallet.ts +++ b/src/hooks/useImportingWallet.ts @@ -29,9 +29,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 '@/chains/types'; +import { backupsStore } from '@/state/backups/backups'; export default function useImportingWallet({ showImportModal = true } = {}) { const { accountAddress } = useAccountSettings(); @@ -52,6 +52,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 +295,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,8 +350,6 @@ 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; @@ -414,6 +416,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 b46134e68a8..bf7348f4791 100644 --- a/src/hooks/useInitializeWallet.ts +++ b/src/hooks/useInitializeWallet.ts @@ -64,7 +64,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'); @@ -90,7 +90,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..d5e6bc73f58 100644 --- a/src/hooks/useManageCloudBackups.ts +++ b/src/hooks/useManageCloudBackups.ts @@ -3,12 +3,19 @@ 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'; export default function useManageCloudBackups() { const dispatch = useDispatch(); @@ -48,10 +55,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.`), { @@ -94,7 +112,7 @@ export default function useManageCloudBackups() { if (_buttonIndex === 1 && IS_ANDROID) { logoutFromGoogleDrive(); setAccountDetails(undefined); - removeBackupStateFromAllWallets().then(() => loginToGoogleDrive()); + loginToGoogleDrive(); } } ); diff --git a/src/hooks/useWalletCloudBackup.ts b/src/hooks/useWalletCloudBackup.ts index 57b9caac681..3ef3d819ac6 100644 --- a/src/hooks/useWalletCloudBackup.ts +++ b/src/hooks/useWalletCloudBackup.ts @@ -1,16 +1,15 @@ -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 +40,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 ({ @@ -53,36 +51,57 @@ export default function useWalletCloudBackup() { handleNoLatestBackup?: () => void; handlePasswordNotFound?: () => void; onError?: (error: string) => void; - onSuccess?: () => 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'), - }, - ]); - return false; + 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( + 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'), + }, + ] + ); + return false; + } } // For Android devices without biometrics enabled, we need to ask for PIN @@ -101,23 +120,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 +144,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 +158,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..7194f701fe5 100644 --- a/src/hooks/useWallets.ts +++ b/src/hooks/useWallets.ts @@ -7,7 +7,7 @@ import { AppState } from '@/redux/store'; const walletSelector = createSelector( ({ wallets: { isWalletLoading, selected = {} as RainbowWallet, walletNames, wallets } }: AppState) => ({ isWalletLoading, - selectedWallet: selected as any, + selectedWallet: selected, walletNames, wallets, }), diff --git a/src/languages/en_US.json b/src/languages/en_US.json index e49299f792b..902997e943f 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -115,6 +115,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 +139,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", diff --git a/src/model/backup.ts b/src/model/backup.ts index 2eb50a7c297..8bef43d6347 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,18 @@ 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'; 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 +54,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 +65,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,6 +95,54 @@ interface BackedUpData { export interface BackupUserData { wallets: AllRainbowWallets; } +type MaybePromise = T | Promise; + +export const executeFnIfCloudBackupAvailable = async ({ fn, logout = false }: { fn: () => MaybePromise; logout?: boolean }) => { + if (IS_ANDROID) { + try { + if (logout) { + await logoutFromGoogleDrive(); + } + await login(); + const userData = await getGoogleAccountUserData(); + if (!userData) { + Alert.alert(i18n.t(i18n.l.back_up.errors.no_account_found)); + return; + } + // execute the function + 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)); + } + } 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; + } + + // execute the function + return await fn(); + } +}; async function extractSecretsForWallet(wallet: RainbowWallet) { const allKeys = await keychain.loadAllKeys(); @@ -100,17 +180,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(); @@ -157,49 +235,23 @@ 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, - }; - }); + let updatedBackupFile: string | null = null; - 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); + updatedBackupFile = await encryptAndSaveDataToCloud(data, password, `backup_${now}.json`); const walletIdsToUpdate = Object.keys(wallets); await dispatch(setAllWalletsWithIdsAsBackedUp(walletIdsToUpdate, WalletBackupTypes.cloud, updatedBackupFile)); @@ -209,16 +261,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 +305,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 +381,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 +409,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 +441,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 +544,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; @@ -695,7 +646,7 @@ export async function getDeviceUUID(): Promise { } const FailureAlert = () => - Alert({ + NativeAlert({ buttons: [ { style: 'cancel', 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..4fadb8753ef --- /dev/null +++ b/src/react-native-cool-modals/Portal.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { IS_IOS } from '@/env'; +import { portalStore } from '@/state/portal/portal'; +import { requireNativeComponent, StyleSheet, View } from 'react-native'; + +const NativePortal = IS_IOS ? requireNativeComponent('WindowPortal') : View; +const Wrapper = IS_IOS ? ({ children }: { children: React.ReactNode }) => children : View; + +export function Portal() { + const { blockTouches, Component } = portalStore(state => ({ + blockTouches: state.blockTouches, + Component: state.Component, + })); + + return ( + + + {Component} + + + ); +} + +const sx = StyleSheet.create({ + wrapper: { + ...StyleSheet.absoluteFillObject, + }, +}); diff --git a/src/redux/wallets.ts b/src/redux/wallets.ts index deb49a5ea9b..f55da2f26c1 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,8 @@ import { AppGetState, AppState } from './store'; import { fetchReverseRecord } from '@/handlers/ens'; import { lightModeThemeColors } from '@/styles'; import { RainbowError, logger } from '@/logger'; +import { parseTimestampFromBackupFile } from '@/model/backup'; +import { WalletLoadingState } from '@/helpers/walletLoadingStates'; // -- Types ---------------------------------------- // @@ -40,7 +40,7 @@ interface WalletsState { /** * The current loading state of the wallet. */ - isWalletLoading: any; + isWalletLoading: WalletLoadingState | null; /** * The currently selected wallet. @@ -130,90 +130,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. @@ -241,6 +239,18 @@ export const walletsSetSelected = (wallet: RainbowWallet) => async (dispatch: Di }); }; +/** + * Updates the wallet loading state. + * + * @param val The new loading state. + */ +export const setIsWalletLoading = (val: WalletsState['isWalletLoading']) => (dispatch: Dispatch) => { + dispatch({ + payload: val, + type: WALLETS_SET_IS_LOADING, + }); +}; + /** * Marks all wallets with passed ids as backed-up * using a specified method and file in storage @@ -252,21 +262,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 +286,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 +295,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])); - } }; /** diff --git a/src/screens/AddWalletSheet.tsx b/src/screens/AddWalletSheet.tsx index 6021f7ad295..69350fac4e0 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 { createAccountForWallet, walletsLoadState } from '@/redux/wallets'; -import WalletBackupTypes from '@/helpers/walletBackupTypes'; +import { InteractionManager } from 'react-native'; +import { createAccountForWallet, setIsWalletLoading, walletsLoadState } from '@/redux/wallets'; import { createWallet } from '@/model/wallet'; import WalletTypes from '@/helpers/walletTypes'; import { logger, RainbowError } from '@/logger'; @@ -18,22 +17,13 @@ import CreateNewWallet from '@/assets/CreateNewWallet.png'; import PairHairwareWallet from '@/assets/PairHardwareWallet.png'; import ImportSecretPhraseOrPrivateKey from '@/assets/ImportSecretPhraseOrPrivateKey.png'; import WatchWalletIcon from '@/assets/watchWallet.png'; -import { captureException } from '@sentry/react-native'; 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'; const TRANSLATIONS = i18n.l.wallet.new.add_wallet_sheet; @@ -53,7 +43,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(); @@ -84,6 +73,8 @@ export const AddWalletSheet = () => { }, onCloseModal: async (args: any) => { if (args) { + dispatch(setIsWalletLoading(WalletLoadingStates.CREATING_WALLET)); + const name = args?.name ?? ''; const color = args?.color ?? null; // Check if the selected wallet is the primary @@ -114,31 +105,19 @@ 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 + // TODO: Make sure the new wallet is marked as not backed up } 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) { @@ -150,6 +129,8 @@ export const AddWalletSheet = () => { showWalletErrorAlert(); }, 1000); } + } finally { + dispatch(setIsWalletLoading(null)); } } creatingWallet.current = false; @@ -198,47 +179,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/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..fa38313f32f 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,34 +5,37 @@ 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 === 'success') { for (let i = 0; i < 20; i++) { setTimeout(() => { emojiTrigger?.(); }, 100 * i); } } - }, [emojiTrigger, loading]); + }, [emojiTrigger, backupState]); const accentColor = useMemo(() => { - switch (loading) { + switch (backupState) { case 'success': return colors.green; case 'error': @@ -41,30 +43,30 @@ export const BackUpMenuItem = ({ 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 ( <> @@ -86,8 +88,9 @@ export const BackUpMenuItem = ({ ) : ( 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..f98e6b73297 100644 --- a/src/screens/SettingsSheet/components/Backups/ViewWalletBackup.tsx +++ b/src/screens/SettingsSheet/components/Backups/ViewWalletBackup.tsx @@ -29,31 +29,22 @@ 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, setIsWalletLoading } 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'; type ViewWalletBackupParams = { ViewWalletBackup: { walletId: string; title: string; imported?: boolean }; @@ -126,107 +117,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,38 +187,19 @@ const ViewWalletBackup = () => { }, onCloseModal: async (args: any) => { if (args) { + dispatch(setIsWalletLoading(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)); + 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)); - // @ts-expect-error - no params - await initializeWallet(); + // TODO: mark the newly created wallet as not backed up } } catch (e) { logger.error(new RainbowError(`[ViewWalletBackup]: Error while trying to add account`), { @@ -307,6 +210,8 @@ const ViewWalletBackup = () => { showWalletErrorAlert(); }, 1000); } + } finally { + dispatch(setIsWalletLoading(null)); } } creatingWallet.current = false; @@ -324,7 +229,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 +291,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 +352,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={ + + } + /> + + @@ -560,15 +465,17 @@ const ViewWalletBackup = () => { {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..57c61ecedcd 100644 --- a/src/screens/SettingsSheet/components/Backups/WalletsAndBackup.tsx +++ b/src/screens/SettingsSheet/components/Backups/WalletsAndBackup.tsx @@ -15,8 +15,8 @@ import { useNavigation } from '@/navigation'; import { abbreviations } 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,21 +25,17 @@ 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 { setIsWalletLoading, walletsLoadState } from '@/redux/wallets'; import { RainbowError, logger } from '@/logger'; -import { IS_ANDROID, IS_IOS } from '@/env'; -import { BackupTypes, useCreateBackup } from '@/components/backup/useCreateBackup'; +import { IS_IOS } from '@/env'; +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'; type WalletPillProps = { account: RainbowAccount; @@ -94,15 +90,17 @@ const getAccountSectionHeight = (numAccounts: number) => { export const WalletsAndBackup = () => { const { navigate } = useNavigation(); const { wallets } = useWallets(); - const profilesEnabled = useExperimentalFlag(PROFILES); - const { backups } = useCloudBackups(); const dispatch = useDispatch(); - const initializeWallet = useInitializeWallet(); + const createBackup = useCreateBackup(); + const { status, backupProvider, backups, mostRecentBackup } = backupsStore(state => ({ + status: state.status, + backupProvider: state.backupProvider, + backups: state.backups, + mostRecentBackup: state.mostRecentBackup, + })); - const { onSubmit, loading } = useCreateBackup({ - walletId: undefined, // NOTE: This is not used when backing up All wallets - }); + const initializeWallet = useInitializeWallet(); const { manageCloudBackups } = useManageCloudBackups(); @@ -111,52 +109,15 @@ export const WalletsAndBackup = () => { privateKey: 0, }; - const { allBackedUp, backupProvider } = useMemo(() => checkWalletsForBackupStatus(wallets), [wallets]); - - 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; - } + const { allBackedUp } = useMemo(() => checkLocalWalletsForBackupStatus(wallets), [wallets]); - 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, @@ -167,47 +128,10 @@ 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 }); - } - 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]); + executeFnIfCloudBackupAvailable({ + fn: () => createBackup({}), + }); + }, [createBackup]); const onViewCloudBackups = useCallback(async () => { navigate(SETTINGS_BACKUP_ROUTES.VIEW_CLOUD_BACKUPS, { @@ -223,13 +147,15 @@ export const WalletsAndBackup = () => { onCloseModal: async ({ name }: { name: string }) => { const nameValue = name.trim() !== '' ? name.trim() : ''; try { + dispatch(setIsWalletLoading(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 +163,12 @@ export const WalletsAndBackup = () => { logger.error(new RainbowError(`[WalletsAndBackup]: Failed to create new secret phrase`), { error: err, }); + } finally { + dispatch(setIsWalletLoading(null)); } }, }); - }, [dispatch, initializeWallet, navigate, profilesEnabled, walletTypeCount.phrase]); + }, [dispatch, initializeWallet, navigate, walletTypeCount.phrase]); const onPressLearnMoreAboutCloudBackups = useCallback(() => { navigate(Routes.LEARN_WEB_VIEW_SCREEN, { @@ -263,6 +191,48 @@ export const WalletsAndBackup = () => { [navigate, wallets] ); + const { status: iconStatusType, text } = useMemo<{ status: StatusType; text: string }>(() => { + if (!backupProvider) { + if (!allBackedUp) { + return { + status: 'out-of-date', + text: 'Out of Date', + }; + } + + return { + status: 'up-to-date', + text: 'Up to date', + }; + } + + if (status === CloudBackupState.FailedToInitialize || status === CloudBackupState.NotAvailable) { + return { + status: 'not-enabled', + text: 'Not Enabled', + }; + } + + if (status !== CloudBackupState.Ready) { + return { + status: 'out-of-sync', + text: 'Syncing', + }; + } + + if (!allBackedUp) { + return { + status: 'out-of-date', + text: 'Out of Date', + }; + } + + return { + status: 'up-to-date', + text: 'Up to date', + }; + }, [backupProvider, status, allBackedUp]); + const renderView = useCallback(() => { switch (backupProvider) { default: @@ -275,7 +245,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 && ( { {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 => ( - + {addresses.map(address => ( + ))} } @@ -361,6 +335,7 @@ export const WalletsAndBackup = () => { ); })} + { titleComponent={} /> - - - } - onPress={onViewCloudBackups} - size={52} - titleComponent={ - - } - /> - } - onPress={manageCloudBackups} - size={52} - titleComponent={ - - } - /> - ); @@ -416,12 +360,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 }); + console.log('isBackedUp', isBackedUp); + 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 => ( - + {addresses.map(address => ( + ))} } @@ -581,12 +522,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 => ( - + {addresses.map(address => ( + ))} } @@ -645,26 +587,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,17 +617,18 @@ export const WalletsAndBackup = () => { } }, [ backupProvider, - loading, + status, backupAllNonBackedUpWalletsTocloud, sortedWallets, onCreateNewSecretPhrase, - onViewCloudBackups, - manageCloudBackups, navigate, onNavigateToWalletView, allBackedUp, + iconStatusType, + text, mostRecentBackup, - lastBackupDate, + onViewCloudBackups, + manageCloudBackups, onPressLearnMoreAboutCloudBackups, ]); 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/MenuHeader.tsx b/src/screens/SettingsSheet/components/MenuHeader.tsx index fe3ee059881..344fc516f01 100644 --- a/src/screens/SettingsSheet/components/MenuHeader.tsx +++ b/src/screens/SettingsSheet/components/MenuHeader.tsx @@ -64,7 +64,7 @@ const Selection = ({ children }: SelectionProps) => ( ); -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..19a73f10071 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,13 @@ const SettingsSection = ({ const isLanguageSelectionEnabled = useExperimentalFlag(LANGUAGE_SETTINGS); const isNotificationsEnabled = useExperimentalFlag(NOTIFICATIONS); + const { backupProvider } = backupsStore(state => ({ + backupProvider: state.backupProvider, + })); + const { isDarkMode, setTheme, colorScheme } = useTheme(); const onSendFeedback = useSendFeedback(); - const { backupProvider } = useMemo(() => checkWalletsForBackupStatus(wallets), [wallets]); const onPressReview = useCallback(async () => { if (ios) { @@ -85,7 +90,7 @@ const SettingsSection = ({ const onPressLearn = useCallback(() => Linking.openURL(SettingsExternalURLs.rainbowLearn), []); - const { allBackedUp, canBeBackedUp } = useMemo(() => checkWalletsForBackupStatus(wallets), [wallets]); + const { allBackedUp, canBeBackedUp } = useMemo(() => checkLocalWalletsForBackupStatus(wallets), [wallets]); const themeMenuConfig = useMemo(() => { return { @@ -215,7 +220,7 @@ const SettingsSection = ({ } - 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..824feb0bb26 100644 --- a/src/screens/SettingsSheet/useVisibleWallets.ts +++ b/src/screens/SettingsSheet/useVisibleWallets.ts @@ -4,6 +4,7 @@ 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 { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; type WalletByKey = { [key: string]: RainbowWallet; @@ -19,20 +20,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 +35,25 @@ 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), + }; + }); }; diff --git a/src/screens/SettingsSheet/utils.ts b/src/screens/SettingsSheet/utils.ts index 08fa3e03e22..b92b5a8c4e3 100644 --- a/src/screens/SettingsSheet/utils.ts +++ b/src/screens/SettingsSheet/utils.ts @@ -1,118 +1,126 @@ import WalletBackupTypes from '@/helpers/walletBackupTypes'; import WalletTypes from '@/helpers/walletTypes'; +import { useWallets } from '@/hooks'; +import { isEmpty } from 'lodash'; +import { BackupFile, 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 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 hasManuallyBackedUpWallet = (wallets: ReturnType['wallets']) => { + if (!wallets) return false; + return Object.values(wallets).some(wallet => wallet.backupType === WalletBackupTypes.manual); }; -export const checkWalletsForBackupStatus = (wallets: WalletsByKey | null): WalletBackupStatus => { - if (!wallets) +export const checkLocalWalletsForBackupStatus = (wallets: ReturnType['wallets']): 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) { + const backupFiles = backupsStore.getState().backups; + return Object.values(wallets).reduce( + (acc, wallet) => { + const isBackupEligible = wallet.type !== WalletTypes.readOnly && wallet.type !== WalletTypes.bluetooth; + const hasBackupFile = backupFiles.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, - }; -}; -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; + return prev; + }, cloudBackups[0]); }; -export const fetchBackupPasswordAndNavigate = async () => { - const password = await getLocalBackupPassword(); +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, + }), +}; - return new Promise(resolve => { - return Navigation.handleAction(Routes.BACKUP_SHEET, { - step: WalletBackupStepTypes.backup_cloud, - password, - onSuccess: async (password: string) => { - resolve(password); - }, - }); +export const isWalletBackedUpForCurrentAccount = ({ backupType, backedUp, backupFile }: Partial) => { + console.log({ + backupType, + backedUp, + backupFile, }); + if (!backupType || !backupFile) { + return false; + } + + if (IS_IOS || backupType === WalletBackupTypes.manual) { + return backedUp; + } + + console.log('backupFile', backupFile); + + // 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 026acd32b4e..03ddf4442c8 100644 --- a/src/screens/WalletScreen/index.tsx +++ b/src/screens/WalletScreen/index.tsx @@ -29,6 +29,7 @@ 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'; function WalletScreen() { const { params } = useRoute>(); @@ -37,7 +38,6 @@ function WalletScreen() { const [initialized, setInitialized] = useState(!!params?.initialized); const initializeWallet = useInitializeWallet(); const { network: currentNetwork, accountAddress, appIcon } = useAccountSettings(); - const loadAccountLateData = useLoadAccountLateData(); const loadGlobalLateData = useLoadGlobalLateData(); const insets = useSafeAreaInsets(); @@ -71,11 +71,14 @@ function WalletScreen() { } }, [initializeWallet, initialized, params, setParams]); + useEffect(() => { + runWalletBackupStatusChecks(); + }, []); + useEffect(() => { if (walletReady) { loadAccountLateData(); loadGlobalLateData(); - runWalletBackupStatusChecks(); } }, [loadAccountLateData, loadGlobalLateData, walletReady]); @@ -111,6 +114,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..a54a6e759fc --- /dev/null +++ b/src/state/backups/backups.ts @@ -0,0 +1,163 @@ +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 sleep = (ms: number) => + new Promise(resolve => { + setTimeout(resolve, ms); + }); + +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', +} + +export const LoadingStates = [CloudBackupState.Initializing, CloudBackupState.Syncing, CloudBackupState.Fetching]; + +interface BackupsStore { + 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) => Promise<{ + success: boolean; + retry?: boolean; + }>; +} + +const returnEarlyIfLockedStates = [CloudBackupState.Syncing, CloudBackupState.Fetching]; + +export const backupsStore = createRainbowStore((set, get) => ({ + 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) => { + const { status } = get(); + const syncAndPullFiles = async (): Promise<{ success: boolean; retry?: boolean }> => { + try { + const isAvailable = await isCloudBackupAvailable(); + if (!isAvailable) { + logger.debug('[backupsStore]: Cloud backup is not available'); + set({ status: CloudBackupState.NotAvailable }); + return { + success: false, + retry: false, + }; + } + + if (IS_ANDROID) { + const gdata = await getGoogleAccountUserData(); + if (!gdata) { + logger.debug('[backupsStore]: Google account is not available'); + set({ 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 syncAndPullFiles(); + releaser(); + logger.debug('[backupsStore]: Released mutex'); + if (retry) { + await sleep(5_000); + return get().syncAndFetchBackups(retryOnFailure); + } + return { success, retry }; + }, +})); diff --git a/src/state/portal/portal.ts b/src/state/portal/portal.ts new file mode 100644 index 00000000000..51e0f96ccba --- /dev/null +++ b/src/state/portal/portal.ts @@ -0,0 +1,15 @@ +import { createRainbowStore } from '../internal/createRainbowStore'; + +type PortalState = { + blockTouches: boolean; + Component: JSX.Element | null; + hide: () => void; + setComponent: (Component: JSX.Element, blockTouches?: boolean) => void; +}; + +export const portalStore = createRainbowStore(set => ({ + blockTouches: false, + Component: null, + hide: () => set({ blockTouches: false, Component: null }), + setComponent: (Component: JSX.Element, blockTouches?: boolean) => set({ blockTouches, Component }), +})); 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);