From 6bf5bb8d4ef79e87dd6da70cc0bd3323a57e904e Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Duy Date: Thu, 17 Aug 2023 17:36:41 +0700 Subject: [PATCH] [issue-444, issue-430, issue-910] Use Keychain for biometric auth, new login screen, update reset account --- android/app/src/main/AndroidManifest.xml | 1 + ios/Podfile.lock | 16 +- package.json | 4 +- src/AppNavigator.tsx | 31 +- src/AppNew.tsx | 143 ++++----- src/assets/fingerprint-simple.svg | 10 + src/assets/index.ts | 4 + src/assets/subwallet-styled.svg | 3 + .../common/Field/Password/InlinePassword.tsx | 113 +++++++ src/components/common/Field/Password/index.ts | 1 + .../common/Field/Password/styles/index.ts | 42 +++ .../common/ForgotPasswordModal/index.tsx | 16 +- .../common/Modal/UnlockModal/index.tsx | 141 +++++++-- .../common/Modal/UnlockModal/style/index.ts | 4 + .../design-system-ui/modal/ActionHeader.tsx | 38 +++ .../design-system-ui/modal/ModalBaseV2.tsx | 14 +- .../design-system-ui/modal/SwModal.tsx | 4 +- .../design-system-ui/modal/index.tsx | 2 + .../design-system-ui/modal/style/index.ts | 13 + src/hooks/modal/useUnlockModal.ts | 13 +- src/hooks/useAppLock.ts | 31 +- src/messaging/index.ts | 4 + src/routes/index.ts | 2 - src/screens/Home/Crypto/ServiceModal.tsx | 3 +- src/screens/Home/index.tsx | 4 +- src/screens/LockScreen.tsx | 186 ----------- .../ChangeMasterPassword/index.tsx | 12 + .../CreateMasterPassword/index.tsx | 7 + src/screens/MasterPassword/Login/index.tsx | 299 +++++++++++++++--- .../MasterPassword/Login/styles/index.ts | 28 +- .../MigrateToKeychainPasswordModal/index.tsx | 48 +++ .../style/index.ts | 29 ++ src/screens/Settings/Security/PinCode.tsx | 47 --- .../Settings/Security/PinCodeScreen.tsx | 109 ------- src/screens/Settings/Security/index.tsx | 123 ++++--- src/screens/Settings/index.tsx | 13 +- src/stores/Browser.ts | 8 + src/stores/MobileSettings.ts | 29 +- src/stores/types.ts | 17 +- src/utils/account.ts | 57 ++++ src/utils/i18n/en_US.ts | 14 +- src/utils/i18n/vi_VN.ts | 14 +- src/utils/i18n/zh_CN.ts | 14 +- src/utils/permission/biometric.ts | 46 +++ yarn.lock | 18 +- 45 files changed, 1116 insertions(+), 659 deletions(-) create mode 100644 src/assets/fingerprint-simple.svg create mode 100644 src/assets/subwallet-styled.svg create mode 100644 src/components/common/Field/Password/InlinePassword.tsx create mode 100644 src/components/common/Field/Password/index.ts create mode 100644 src/components/common/Field/Password/styles/index.ts create mode 100644 src/components/design-system-ui/modal/ActionHeader.tsx delete mode 100644 src/screens/LockScreen.tsx create mode 100644 src/screens/MasterPassword/MigrateToKeychainPasswordModal/index.tsx create mode 100644 src/screens/MasterPassword/MigrateToKeychainPasswordModal/style/index.ts delete mode 100644 src/screens/Settings/Security/PinCode.tsx delete mode 100644 src/screens/Settings/Security/PinCodeScreen.tsx create mode 100644 src/utils/permission/biometric.ts diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 13f1144b8..eebc00f4c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + 1.0) - SDWebImage/Core (~> 5.10) - SocketRocket (0.6.1) - - TouchID (4.4.1): - - React - Yoga (1.14.0) - YogaKit (1.18.1): - Yoga (~> 1.14) @@ -613,6 +613,7 @@ DEPENDENCIES: - react-native-restart (from `../node_modules/react-native-restart`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - "react-native-segmented-control (from `../node_modules/@react-native-community/segmented-control`)" + - react-native-sensitive-info (from `../node_modules/react-native-sensitive-info`) - "react-native-slider (from `../node_modules/@react-native-community/slider`)" - react-native-splash-screen (from `../node_modules/react-native-splash-screen`) - react-native-static-server (from `../node_modules/react-native-static-server`) @@ -651,7 +652,6 @@ DEPENDENCIES: - RNScreens (from `../node_modules/react-native-screens`) - RNSVG (from `../node_modules/react-native-svg`) - RNVectorIcons (from `../node_modules/react-native-vector-icons`) - - TouchID (from `../node_modules/react-native-touch-id`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -742,6 +742,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-safe-area-context" react-native-segmented-control: :path: "../node_modules/@react-native-community/segmented-control" + react-native-sensitive-info: + :path: "../node_modules/react-native-sensitive-info" react-native-slider: :path: "../node_modules/@react-native-community/slider" react-native-splash-screen: @@ -818,8 +820,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-svg" RNVectorIcons: :path: "../node_modules/react-native-vector-icons" - TouchID: - :path: "../node_modules/react-native-touch-id" Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" @@ -872,6 +872,7 @@ SPEC CHECKSUMS: react-native-restart: 45c8dca02491980f2958595333cbccd6877cb57e react-native-safe-area-context: 9697629f7b2cda43cf52169bb7e0767d330648c2 react-native-segmented-control: 65df6cd0619b780b3843d574a72d4c7cec396097 + react-native-sensitive-info: d44e909d065f9c0e15734245e5dd6a24b82e3dcd react-native-slider: 33b8d190b59d4f67a541061bb91775d53d617d9d react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457 react-native-static-server: 201b2a945a35096be3ae7f43e367c65bcbd61343 @@ -903,7 +904,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: dec4645026e7401a0899f2846d864403478ff6a5 RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364 - RNKeychain: ff836453cba46938e0e9e4c22e43d43fa2c90333 + RNKeychain: a65256b6ca6ba6976132cc4124b238a5b13b3d9c RNPermissions: 8231416ed851ad4f9ddc220494467c8f1f79c5df RNQrGenerator: 90461ba3ca88c1d38ef73da50fade35d9648215d RNReanimated: f186e85d9f28c9383d05ca39e11dd194f59093ec @@ -913,7 +914,6 @@ SPEC CHECKSUMS: SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 - TouchID: ba4c656d849cceabc2e4eef722dea5e55959ecf4 Yoga: 39310a10944fc864a7550700de349183450f8aaa YogaKit: f782866e155069a2cca2517aafea43200b01fd5a ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb diff --git a/package.json b/package.json index e59585f36..64ba13093 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "react-native-gesture-handler": "^2.9.0", "react-native-image-picker": "^5.0.1", "react-native-inappbrowser-reborn": "^3.7.0", - "react-native-keychain": "^8.1.1", + "react-native-keychain": "^8.1.2", "react-native-linear-gradient": "^2.6.2", "react-native-localization": "^2.3.1", "react-native-mmkv": "^2.10.1", @@ -105,6 +105,7 @@ "react-native-restart": "^0.0.24", "react-native-safe-area-context": "^4.5.0", "react-native-screens": "^3.19.0", + "react-native-sensitive-info": "^6.0.0-alpha.9", "react-native-skeleton-placeholder": "^5.2.4", "react-native-splash-screen": "^3.3.0", "react-native-static-server": "^0.5.0", @@ -113,7 +114,6 @@ "react-native-svg-transformer": "^1.0.0", "react-native-tab-view": "^3.5.2", "react-native-toast-notifications": "^3.3.1", - "react-native-touch-id": "^4.4.1", "react-native-vector-icons": "^9.2.0", "react-native-version-number": "^0.3.6", "react-native-video": "^5.2.1", diff --git a/src/AppNavigator.tsx b/src/AppNavigator.tsx index 92cbc9154..9654cbea7 100644 --- a/src/AppNavigator.tsx +++ b/src/AppNavigator.tsx @@ -23,7 +23,6 @@ import { DAppAccessScreen } from 'screens/Settings/Security/DAppAccess'; import { DAppAccessDetailScreen } from 'screens/Settings/Security/DAppAccess/DAppAccessDetailScreen'; import { Languages } from 'screens/Settings/Languages'; import { Security } from 'screens/Settings/Security'; -import { PinCodeScreen } from 'screens/Settings/Security/PinCodeScreen'; import { AccountExport } from 'screens/Account/AccountExport'; import { CustomTokenSetting } from 'screens/Tokens'; import { NetworkConfig } from 'screens/Settings/NetworkConfig'; @@ -68,7 +67,7 @@ import { ConnectionList } from 'screens/Settings/WalletConnect/ConnectionList'; import { ConnectWalletConnect } from 'screens/Settings/WalletConnect/ConnectWalletConnect'; import { ConnectionDetail } from 'screens/Settings/WalletConnect/ConnectionDetail'; import useAppLock from 'hooks/useAppLock'; -import { LockScreen } from 'screens/LockScreen'; +import LoginScreen from 'screens/MasterPassword/Login'; import { STATUS_BAR_LIGHT_CONTENT } from 'styles/sharedStyles'; import { UnlockModal } from 'components/common/Modal/UnlockModal'; import { AppModalContext } from 'providers/AppModalContext'; @@ -143,6 +142,7 @@ const AppNavigator = ({ isAppReady }: Props) => { const { hasConfirmations } = useSelector((state: RootState) => state.requestState); const { accounts, hasMasterPassword } = useSelector((state: RootState) => state.accountState); const { isLocked } = useAppLock(); + const [isNavigationReady, setNavigationReady] = useState(false); const appModalContext = useContext(AppModalContext); const needMigrate = useMemo( @@ -179,7 +179,6 @@ const AppNavigator = ({ isAppReady }: Props) => { return () => { amount = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasConfirmations, navigationRef, currentRoute]); useEffect(() => { @@ -195,16 +194,12 @@ const AppNavigator = ({ isAppReady }: Props) => { }, [currentRoute, hasMasterPassword, navigationRef, needMigrate]); useEffect(() => { - let amount = true; - if (isLocked && amount) { + if (isLocked && !!accounts.length && isNavigationReady) { appModalContext.hideConfirmModal(); - setTimeout(() => navigationRef.current?.navigate('Login'), 500); + setTimeout(() => navigationRef.current?.navigate('Login'), 300); } - return () => { - amount = false; - }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLocked, navigationRef]); + }, [isLocked, isNavigationReady, accounts]); useEffect(() => { if (isEmptyAccounts) { @@ -215,8 +210,17 @@ const AppNavigator = ({ isAppReady }: Props) => { } }, [isEmptyAccounts, navigationRef]); + const onNavigationReady = () => { + setNavigationReady(true); + }; + return ( - + { - { component={Confirmations} options={{ gestureEnabled: false, animationDuration: 100 }} /> - - {} + {!!accounts.length && } + )} diff --git a/src/AppNew.tsx b/src/AppNew.tsx index 4eab99e7f..228c04001 100644 --- a/src/AppNew.tsx +++ b/src/AppNew.tsx @@ -5,7 +5,7 @@ import { QrSignerContextProvider } from 'providers/QrSignerContext'; import { ScannerContextProvider } from 'providers/ScannerContext'; import { SigningContextProvider } from 'providers/SigningContext'; import React, { useEffect } from 'react'; -import { AppState, Platform, StatusBar, StyleProp, View } from 'react-native'; +import { AppState, StatusBar, StyleProp, View } from 'react-native'; import { ThemeContext } from 'providers/contexts'; import { THEME_PRESET } from 'styles/themes'; import { ToastProvider } from 'react-native-toast-notifications'; @@ -20,17 +20,16 @@ import { LoadingScreen } from 'screens/LoadingScreen'; import { ColorMap } from 'styles/color'; import { AutoLockState } from 'utils/autoLock'; import useStoreBackgroundService from 'hooks/store/useStoreBackgroundService'; -import { HIDE_MODAL_DURATION, TOAST_DURATION } from 'constants/index'; +import { TOAST_DURATION } from 'constants/index'; import AppNavigator from './AppNavigator'; -import { keyringLock } from 'messaging/index'; -import { updateShowZeroBalanceState } from 'stores/utils'; -import { setBuildNumber } from './stores/AppVersion'; -import { getBuildNumber } from 'react-native-device-info'; import { AppModalContextProvider } from './providers/AppModalContext'; import { CustomToast } from 'components/design-system-ui/toast'; import { PortalProvider } from '@gorhom/portal'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { LockTimeout } from 'stores/types'; +import { keyringLock } from './messaging'; +import { updateAutoLockTime } from 'stores/MobileSettings'; const layerScreenStyle: StyleProp = { top: 0, @@ -42,54 +41,65 @@ const layerScreenStyle: StyleProp = { zIndex: 10, }; -AutoLockState.isPreventAutoLock = false; +const gestureRootStyle: StyleProp = { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + width: '100%', + height: '100%', + zIndex: 9999, +}; + +// AutoLockState.isPreventAutoLock = false; const autoLockParams: { - pinCodeEnabled: boolean; - faceIdEnabled: boolean; - autoLockTime?: number; + hasMasterPassword: boolean; + isUseBiometric: boolean; + timeAutoLock?: number; lock: () => void; isPreventLock: boolean; + isMasterPasswordLocked: boolean; } = { - pinCodeEnabled: false, - faceIdEnabled: false, - isPreventLock: false, - autoLockTime: undefined, + hasMasterPassword: false, + isUseBiometric: false, + timeAutoLock: undefined, lock: () => {}, + isPreventLock: false, + isMasterPasswordLocked: false, }; -let timeout: NodeJS.Timeout | undefined; +// let timeout: NodeJS.Timeout | undefined; let lockWhenActive = false; AppState.addEventListener('change', (state: string) => { - const { pinCodeEnabled, faceIdEnabled, autoLockTime, lock, isPreventLock } = autoLockParams; - - if (state === 'background' && !isPreventLock) { - keyringLock().catch((e: Error) => console.log(e)); - } + const { isUseBiometric, timeAutoLock, lock, isMasterPasswordLocked } = autoLockParams; - if (!pinCodeEnabled || autoLockTime === undefined) { + if (timeAutoLock === undefined) { return; } if (state === 'background') { - timeout = setTimeout(() => { - if (AutoLockState.isPreventAutoLock) { - return; - } - if (faceIdEnabled) { - lockWhenActive = true; - } else { - lockWhenActive = false; - Platform.OS === 'android' ? setTimeout(() => lock(), HIDE_MODAL_DURATION) : lock(); + if (timeAutoLock === LockTimeout.ALWAYS) { + // Always lock incase always require + keyringLock().catch((e: Error) => console.log(e)); + } + if (AutoLockState.isPreventAutoLock) { + return; + } + if (isUseBiometric) { + lockWhenActive = true; + } else { + lockWhenActive = false; + if (isMasterPasswordLocked) { + lock(); } - }, autoLockTime); + } } else if (state === 'active') { if (lockWhenActive) { - if (!AutoLockState.isPreventAutoLock) { - Platform.OS === 'android' ? setTimeout(() => lock(), HIDE_MODAL_DURATION) : lock(); + if (isMasterPasswordLocked) { + lock(); } lockWhenActive = false; } - timeout && clearTimeout(timeout); - timeout = undefined; } }); @@ -100,53 +110,54 @@ export const AppNew = () => { const theme = isDarkMode ? THEME_PRESET.dark : THEME_PRESET.light; StatusBar.setBarStyle(isDarkMode ? 'light-content' : 'dark-content'); - const { pinCodeEnabled, faceIdEnabled, autoLockTime, isPreventLock } = useSelector( - (state: RootState) => state.mobileSettings, - ); - const { hasMasterPassword } = useSelector((state: RootState) => state.accountState); - const { buildNumber } = useSelector((state: RootState) => state.appVersion); - const { lock } = useAppLock(); + const { isUseBiometric, timeAutoLock, isPreventLock } = useSelector((state: RootState) => state.mobileSettings); + const { hasMasterPassword, isLocked } = useSelector((state: RootState) => state.accountState); + const { lock, unlockApp } = useAppLock(); const dispatch = useDispatch(); - const isCryptoReady = useCryptoReady(); const isI18nReady = useSetupI18n().isI18nReady; useStoreBackgroundService(); // Enable lock screen on the start app useEffect(() => { - if (!firstTimeCheckPincode && pinCodeEnabled) { + if (!firstTimeCheckPincode && isLocked) { lock(); } + if (!isLocked) { + unlockApp(); + } firstTimeCheckPincode = true; - }, [lock, pinCodeEnabled]); + autoLockParams.isMasterPasswordLocked = isLocked; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLocked]); useEffect(() => { autoLockParams.lock = lock; - autoLockParams.autoLockTime = autoLockTime; - autoLockParams.pinCodeEnabled = pinCodeEnabled; - autoLockParams.faceIdEnabled = faceIdEnabled; + autoLockParams.timeAutoLock = timeAutoLock; + autoLockParams.hasMasterPassword = hasMasterPassword; + autoLockParams.isUseBiometric = isUseBiometric; autoLockParams.isPreventLock = isPreventLock; - }, [autoLockTime, faceIdEnabled, isPreventLock, lock, pinCodeEnabled]); + }, [timeAutoLock, isUseBiometric, isPreventLock, lock, hasMasterPassword]); const isRequiredStoresReady = true; + // When update from v1.0.15, time auto lock could be wrong. We can remove this effect later + useEffect(() => { + if (!Object.values(LockTimeout).includes(timeAutoLock)) { + dispatch(updateAutoLockTime(LockTimeout._15MINUTE)); + } + }, [dispatch, timeAutoLock]); + useEffect(() => { setTimeout(() => { SplashScreen.hide(); }, 100); - }, []); - useEffect(() => { - if (buildNumber === 1) { - // Set default value on the first time install - updateShowZeroBalanceState(false); - const buildNumberInt = parseInt(getBuildNumber(), 10); - dispatch(setBuildNumber(buildNumberInt)); - } - if (hasMasterPassword) { - keyringLock().catch((e: Error) => console.log(e)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps + // if (buildNumber === 1) { + // Set default value on the first time install + // const buildNumberInt = parseInt(getBuildNumber(), 10); + // dispatch(setBuildNumber(buildNumberInt)); + // } }, []); const isAppReady = isRequiredStoresReady && isCryptoReady && isI18nReady; @@ -170,17 +181,7 @@ export const AppNew = () => { - + diff --git a/src/assets/fingerprint-simple.svg b/src/assets/fingerprint-simple.svg new file mode 100644 index 000000000..ea31b49fd --- /dev/null +++ b/src/assets/fingerprint-simple.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/index.ts b/src/assets/index.ts index 7afe77a33..8bf126a0f 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -8,13 +8,16 @@ const CheckBoxFilledIcon = React.lazy(() => import('./checkbox-filled.svg')); const NftIcon = React.lazy(() => import('./logo-nft.svg')); const Logo = React.lazy(() => import('./subwallet-logo.svg')); const LogoGradient = React.lazy(() => import('./subwallet-logo-gradient.svg')); +const SubwalletStyled = React.lazy(() => import('./subwallet-styled.svg')); const MenuBarLogo = React.lazy(() => import('./menu-bar.svg')); const IcHalfSquare = React.lazy(() => import('./ic-half-square.svg')); const WalletConnect = React.lazy(() => import('./wallet-connect.svg')); +const Fingerprint = React.lazy(() => import('./fingerprint-simple.svg')); export const SVGImages = { Logo, LogoGradient, + SubwalletStyled, CheckBoxIcon, CheckBoxFilledIcon, NftIcon, @@ -23,6 +26,7 @@ export const SVGImages = { MenuBarLogo, IcHalfSquare, WalletConnect, + Fingerprint, }; export const Images = { diff --git a/src/assets/subwallet-styled.svg b/src/assets/subwallet-styled.svg new file mode 100644 index 000000000..45aa0178b --- /dev/null +++ b/src/assets/subwallet-styled.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/common/Field/Password/InlinePassword.tsx b/src/components/common/Field/Password/InlinePassword.tsx new file mode 100644 index 000000000..3fd4f9f05 --- /dev/null +++ b/src/components/common/Field/Password/InlinePassword.tsx @@ -0,0 +1,113 @@ +import React, { forwardRef, useMemo, useState } from 'react'; +import { TextInput, View, ViewStyle } from 'react-native'; +import { DisabledStyle } from 'styles/sharedStyles'; +import { FieldBaseProps } from 'components/Field/Base'; +import { Warning } from 'components/Warning'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import { Button, Icon, Input } from 'components/design-system-ui'; +import { Eye, EyeSlash, Key } from 'phosphor-react-native'; +import createStyles from './styles'; + +interface Props extends FieldBaseProps { + onChangeText?: (text: string) => void; + onEndEditing?: () => void; + onBlur?: () => void; + errorMessages?: string[]; + isBusy?: boolean; + autoFocus?: boolean; + onSubmitField?: () => void; + defaultValue?: string; + showEyeButton?: boolean; + placeholder?: string; + containerStyle?: ViewStyle; + disabled?: boolean; +} + +const InlinePassword = forwardRef((passwordFieldProps: Props, ref: React.Ref) => { + const { + defaultValue, + onChangeText, + onEndEditing, + onBlur, + errorMessages, + isBusy, + autoFocus, + onSubmitField, + showEyeButton = true, + placeholder, + containerStyle, + disabled, + } = passwordFieldProps; + const [isShowPassword, setShowPassword] = useState(false); + const [isFocus, setFocus] = useState(false); + const theme = useSubWalletTheme().swThemes; + const styles = useMemo( + () => createStyles(theme, !(errorMessages && errorMessages.length), undefined, isFocus), + [theme, errorMessages, isFocus], + ); + + const onInputFocus = () => { + setFocus(true); + }; + const onInputBlur = () => { + onBlur && onBlur(); + setFocus(false); + }; + + return ( + <> + + } + leftPartStyle={styles.leftInputStyle} + inputStyle={styles.textInput} + rightPart={ + showEyeButton && + (isShowPassword ? ( + + {isUseBiometric && ( + + )} {!isKeyboardVisible && } @@ -127,4 +220,4 @@ export const UnlockModal = () => { ); -}; +}); diff --git a/src/components/common/Modal/UnlockModal/style/index.ts b/src/components/common/Modal/UnlockModal/style/index.ts index 0154ba95d..602998448 100644 --- a/src/components/common/Modal/UnlockModal/style/index.ts +++ b/src/components/common/Modal/UnlockModal/style/index.ts @@ -3,15 +3,18 @@ import { ThemeTypes } from 'styles/themes'; import { FontSemiBold } from 'styles/sharedStyles'; export interface ComponentStyle { + root: ViewStyle; footer: ViewStyle; wrapper: ViewStyle; separator: ViewStyle; header: TextStyle; container: ViewStyle; + flex1: ViewStyle; } export default (theme: ThemeTypes) => { return StyleSheet.create({ + root: { flex: 1, flexDirection: 'column', justifyContent: 'flex-end' }, container: { width: '100%', backgroundColor: theme.colorBgDefault, @@ -42,5 +45,6 @@ export default (theme: ThemeTypes) => { textAlign: 'center', marginBottom: theme.margin, }, + flex1: { flex: 1 }, }); }; diff --git a/src/components/design-system-ui/modal/ActionHeader.tsx b/src/components/design-system-ui/modal/ActionHeader.tsx new file mode 100644 index 000000000..cf952a38b --- /dev/null +++ b/src/components/design-system-ui/modal/ActionHeader.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import Typography from '../typography'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import ModalStyle from './style'; + +interface ActionHeaderProps { + title: string; + renderLeftAction?: React.ReactNode; + renderRightAction?: React.ReactNode; + onPressLeft?: () => void; + onPressRight?: () => void; +} +const ActionHeader: React.FC = ({ + title, + renderLeftAction, + renderRightAction, + onPressLeft, + onPressRight, +}) => { + const theme = useSubWalletTheme().swThemes; + const _styles = ModalStyle(theme); + return ( + + + {renderLeftAction} + + + {title} + + + {renderRightAction} + + + ); +}; + +export default ActionHeader; diff --git a/src/components/design-system-ui/modal/ModalBaseV2.tsx b/src/components/design-system-ui/modal/ModalBaseV2.tsx index 5a39282d9..30e88653c 100644 --- a/src/components/design-system-ui/modal/ModalBaseV2.tsx +++ b/src/components/design-system-ui/modal/ModalBaseV2.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useImperativeHandle, useState } from 'react'; -import { Dimensions, StyleProp, TouchableOpacity, View, ViewStyle } from 'react-native'; +import { DeviceEventEmitter, Dimensions, StyleProp, TouchableOpacity, View, ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import ModalStyles from './styleV2'; @@ -27,6 +27,8 @@ export type SWModalRefProps = { close: () => void; }; +export const FORCE_HIDDEN_EVENT = 'modalV2ForceHidden'; + const ModalBaseV2 = React.forwardRef( ( { @@ -48,6 +50,16 @@ const ModalBaseV2 = React.forwardRef( const _styles = ModalStyles(theme, level); const { numberOfConfirmations } = useConfirmationsInfo(); const [isForcedHidden, setForcedHidden] = useState(false); + + useEffect(() => { + const hiddenEvent = DeviceEventEmitter.addListener(FORCE_HIDDEN_EVENT, (isHidden: boolean) => { + setForcedHidden(isHidden); + }); + return () => { + hiddenEvent.remove(); + }; + }, []); + useEffect(() => { if (isUseForceHidden && !!numberOfConfirmations) { setForcedHidden(true); diff --git a/src/components/design-system-ui/modal/SwModal.tsx b/src/components/design-system-ui/modal/SwModal.tsx index 488fb7208..40a6b8513 100644 --- a/src/components/design-system-ui/modal/SwModal.tsx +++ b/src/components/design-system-ui/modal/SwModal.tsx @@ -27,6 +27,7 @@ export interface SWModalProps { modalBaseV2Ref?: React.RefObject; level?: number; isUseSafeAreaView?: boolean; + renderHeader?: React.ReactNode; } const getSubWalletModalContainerStyle = (isFullHeight: boolean): StyleProp => { @@ -77,6 +78,7 @@ const SwModal = React.forwardRef( modalBaseV2Ref, level, isUseSafeAreaView = true, + renderHeader, }, ref, ) => { @@ -178,7 +180,7 @@ const SwModal = React.forwardRef( contentContainerStyle, ]}> - {renderTitle()} + {renderHeader ? renderHeader : renderTitle()} {children} diff --git a/src/components/design-system-ui/modal/index.tsx b/src/components/design-system-ui/modal/index.tsx index 86d6e7aa7..7a03e92f4 100644 --- a/src/components/design-system-ui/modal/index.tsx +++ b/src/components/design-system-ui/modal/index.tsx @@ -1,9 +1,11 @@ import SWModal from './SwModal'; +import ActionHeader from './ActionHeader'; export type { SWModalProps as SWModalProps } from './SwModal'; const Modal = { SWModal, + ActionHeader, }; export default Modal; diff --git a/src/components/design-system-ui/modal/style/index.ts b/src/components/design-system-ui/modal/style/index.ts index 999b6d474..e918d3cc5 100644 --- a/src/components/design-system-ui/modal/style/index.ts +++ b/src/components/design-system-ui/modal/style/index.ts @@ -7,6 +7,9 @@ export interface ModalStyle { footerModalStyle: ViewStyle; deleteModalConfirmationStyle: TextStyle; deleteModalMessageTextStyle: TextStyle; + actionWrapper: ViewStyle; + actionContainer: ViewStyle; + headerTitle: TextStyle; } export default (theme: ThemeTypes) => @@ -28,4 +31,14 @@ export default (theme: ThemeTypes) => ...FontMedium, textAlign: 'center', }, + // Action Header + actionContainer: { + width: '100%', + marginBottom: 16, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + actionWrapper: { width: 30 }, + headerTitle: { color: theme.colorTextLight1 }, }); diff --git a/src/hooks/modal/useUnlockModal.ts b/src/hooks/modal/useUnlockModal.ts index 0f4209935..38febb0f7 100644 --- a/src/hooks/modal/useUnlockModal.ts +++ b/src/hooks/modal/useUnlockModal.ts @@ -9,13 +9,14 @@ import { RootStackParamList } from 'routes/index'; interface Result { onPress: (onComplete: VoidFunction) => () => Promise | undefined; - onPasswordComplete: VoidFunction; - onHideModal: VoidFunction; + onHideModal: () => void; } const useUnlockModal = ( navigation: NativeStackNavigationProp, setLoading?: (arg: boolean) => void, + onUnlockComplete?: (arg: string) => void, + onCloseModal?: () => void, ): Result => { const { isLocked, hasMasterPassword } = useSelector((state: RootState) => state.accountState); const onCompleteRef = useRef(noop); @@ -26,8 +27,10 @@ const useUnlockModal = ( useEffect(() => { DeviceEventEmitter.addListener('unlockModal', data => { if (data.type === 'onComplete') { + !!onUnlockComplete && onUnlockComplete(data.password); onPasswordComplete(); } else { + !!onCloseModal && onCloseModal(); onHideModal(); } }); @@ -48,7 +51,7 @@ const useUnlockModal = ( setTimeout(() => { onCompleteRef.current = onComplete; - if (hasMasterPassword && isLocked) { + if ((hasMasterPassword && isLocked) || !!onUnlockComplete) { navigation.navigate('UnlockModal'); promiseRef.current = new Promise((resolve, reject) => { resolveRef.current = resolve; @@ -64,7 +67,8 @@ const useUnlockModal = ( } }; }, - [hasMasterPassword, isLocked, navigation], + // eslint-disable-next-line react-hooks/exhaustive-deps + [isLocked], ); const onPasswordComplete = useCallback(() => { @@ -85,7 +89,6 @@ const useUnlockModal = ( return { onPress, - onPasswordComplete, onHideModal, }; }; diff --git a/src/hooks/useAppLock.ts b/src/hooks/useAppLock.ts index ad5c1b43e..b49984e98 100644 --- a/src/hooks/useAppLock.ts +++ b/src/hooks/useAppLock.ts @@ -1,33 +1,29 @@ import { useDispatch, useSelector } from 'react-redux'; import { RootState } from 'stores/index'; import { useCallback } from 'react'; -import bcrypt from 'react-native-bcrypt'; import { updateLockState } from 'stores/AppState'; -import { updateFaceIdEnable, updatePinCode, updatePinCodeEnable } from 'stores/MobileSettings'; +import { resetBrowserSetting } from 'stores/Browser'; +import { updateUseBiometric } from 'stores/MobileSettings'; export interface UseAppLockOptions { isLocked: boolean; - unlock: (code: string) => boolean; - unlockWithBiometric: () => void; + unlock: () => void; + unlockApp: () => void; lock: () => void; resetPinCode: () => void; } export default function useAppLock(): UseAppLockOptions { const isLocked = useSelector((state: RootState) => state.appState.isLocked); - const { pinCode } = useSelector((state: RootState) => state.mobileSettings); const dispatch = useDispatch(); - const unlock = useCallback( - (code: string) => { - const compareRs = bcrypt.compareSync(code, pinCode); - dispatch(updateLockState(!compareRs)); - return compareRs; - }, - [dispatch, pinCode], - ); + const unlock = useCallback(() => { + // const compareRs = bcrypt.compareSync(code, pinCode); + // dispatch(updateLockState(!compareRs)); + // return compareRs; + }, []); - const unlockWithBiometric = useCallback(() => { + const unlockApp = useCallback(() => { dispatch(updateLockState(false)); }, [dispatch]); @@ -36,11 +32,10 @@ export default function useAppLock(): UseAppLockOptions { }, [dispatch]); const resetPinCode = useCallback(() => { - dispatch(updatePinCode('')); dispatch(updateLockState(false)); - dispatch(updatePinCodeEnable(false)); - dispatch(updateFaceIdEnable(false)); + dispatch(updateUseBiometric(false)); + dispatch(resetBrowserSetting()); }, [dispatch]); - return { isLocked, unlock, lock, unlockWithBiometric, resetPinCode }; + return { isLocked, unlock, lock, resetPinCode, unlockApp }; } diff --git a/src/messaging/index.ts b/src/messaging/index.ts index d43173f06..de0d79d49 100644 --- a/src/messaging/index.ts +++ b/src/messaging/index.ts @@ -633,6 +633,10 @@ export async function approveSignPasswordV2(request: RequestSigningApprovePasswo return sendMessage('pri(signing.approve.passwordV2)', request); } +export async function saveAutoLockTime(value: number): Promise { + return sendMessage('pri(settings.saveAutoLockTime)', { autoLockTime: value }); +} + export async function approveSignSignature(id: string, signature: HexString): Promise { return sendMessage('pri(signing.approve.signature)', { id, signature }); } diff --git a/src/routes/index.ts b/src/routes/index.ts index 1b10b7201..175f51c44 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -50,7 +50,6 @@ export type RootStackParamList = { Drawer: NavigatorScreenParams; Languages: undefined; Security: undefined; - PinCode: { screen: 'NewPinCode' | 'ChangePinCode' | 'TurnoffPinCode' }; AccountExport: { address: string }; ExportJson: { address: string }; BrowserHome?: NavigatorScreenParams | undefined; @@ -95,7 +94,6 @@ export type RootRouteProps = NavigationProps['route']; export type CreateAccountProps = NativeStackScreenProps; export type CreatePasswordProps = NativeStackScreenProps; export type ImportSecretPhraseProps = NativeStackScreenProps; -export type PinCodeProps = NativeStackScreenProps; export type AccountsScreenProps = NativeStackScreenProps; export type SendFundProps = NativeStackScreenProps; export type EditAccountProps = NativeStackScreenProps; diff --git a/src/screens/Home/Crypto/ServiceModal.tsx b/src/screens/Home/Crypto/ServiceModal.tsx index 5f9d313db..8bf5df72c 100644 --- a/src/screens/Home/Crypto/ServiceModal.tsx +++ b/src/screens/Home/Crypto/ServiceModal.tsx @@ -8,7 +8,6 @@ import { RootState } from 'stores/index'; import { InAppBrowser } from 'react-native-inappbrowser-reborn'; import { ServiceSelectItem } from 'components/ServiceSelectItem'; import { HIDE_MODAL_DURATION } from 'constants/index'; -import useAppLock from 'hooks/useAppLock'; import { PREDEFINED_TRANSAK_TOKEN, PREDEFINED_TRANSAK_TOKEN_BY_SLUG } from '../../../predefined/transak'; import { _getChainSubstrateAddressPrefix } from '@subwallet/extension-base/services/chain-service/utils'; import { ImageLogosMap } from 'assets/logo'; @@ -77,7 +76,7 @@ export const ServiceModal = ({ ? address : reformatAddress(address, networkPrefix === undefined ? -1 : networkPrefix); }, [token, address, networkPrefix]); - const { isLocked } = useAppLock(); + const { isLocked } = useSelector((state: RootState) => state.accountState); const url = useMemo((): string => { const host = HOST.PRODUCTION; diff --git a/src/screens/Home/index.tsx b/src/screens/Home/index.tsx index 4c8a9471e..e73c8f162 100644 --- a/src/screens/Home/index.tsx +++ b/src/screens/Home/index.tsx @@ -22,7 +22,6 @@ import { useDispatch, useSelector } from 'react-redux'; import { RootState } from 'stores/index'; import { ActivityIndicator } from 'components/design-system-ui'; import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; -import useAppLock from 'hooks/useAppLock'; import { createDrawerNavigator, DrawerContentComponentProps } from '@react-navigation/drawer'; import { WrapperParamList } from 'routes/wrapper'; import { Settings } from 'screens/Settings'; @@ -179,8 +178,7 @@ interface Props { } export const Home = ({ navigation }: Props) => { const isEmptyAccounts = useCheckEmptyAccounts(); - const { hasMasterPassword, isReady } = useSelector((state: RootState) => state.accountState); - const { isLocked } = useAppLock(); + const { hasMasterPassword, isReady, isLocked } = useSelector((state: RootState) => state.accountState); const [isLoading, setLoading] = useState(true); const isFirstOpen = useRef(true); const toast = useToast(); diff --git a/src/screens/LockScreen.tsx b/src/screens/LockScreen.tsx deleted file mode 100644 index 9b2e9d137..000000000 --- a/src/screens/LockScreen.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import React, { Suspense, useCallback, useEffect, useState } from 'react'; -import { ImageBackground, SafeAreaView, View } from 'react-native'; -import Text from 'components/Text'; -import { FontMedium, FontSemiBold, sharedStyles } from 'styles/sharedStyles'; -import { PinCodeField } from 'components/PinCodeField'; -import { ColorMap } from 'styles/color'; -import i18n from 'utils/i18n/i18n'; -import { useBlurOnFulfill } from 'react-native-confirmation-code-field'; -import { CELL_COUNT } from 'constants/index'; -import useAppLock from 'hooks/useAppLock'; -import TouchID from 'react-native-touch-id'; -import { useSelector } from 'react-redux'; -import { RootState } from 'stores/index'; -import { Images, SVGImages } from 'assets/index'; -import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; -import { Button, WarningText } from 'components/design-system-ui'; -import { resetWallet } from 'messaging/index'; -import { useToast } from 'react-native-toast-notifications'; -import { ForgotPasswordModal } from 'components/common/ForgotPasswordModal'; -import { useNavigation } from '@react-navigation/native'; -import { RootNavigationProps } from 'routes/index'; - -const optionalConfigObject = { - title: 'Authentication Required', // Android - imageColor: '#e00606', // Android - imageErrorColor: '#ff0000', // Android - sensorDescription: 'Touch sensor', // Android - sensorErrorDescription: 'Failed', // Android - cancelText: 'Cancel', // Android - fallbackLabel: 'Enter Password', // iOS (if empty, then label is hidden) - unifiedErrors: false, // use unified error messages (default false) - passcodeFallback: false, // iOS - allows the device to fall back to using the passcode, if faceid/touch is not available. this does not mean that if touchid/faceid fails the first few times it will revert to passcode, rather that if the former are not enrolled, then it will use the passcode. -}; - -export const LockScreen = () => { - const theme = useSubWalletTheme().swThemes; - const { unlock, resetPinCode } = useAppLock(); - const faceIdEnabled = useSelector((state: RootState) => state.mobileSettings.faceIdEnabled); - const [value, setValue] = useState(''); - const [modalVisible, setModalVisible] = useState(false); - const [error, setError] = useState(''); - const [authMethod, setAuthMethod] = useState<'biometric' | 'pinCode'>(faceIdEnabled ? 'biometric' : 'pinCode'); - const ref = useBlurOnFulfill({ value, cellCount: CELL_COUNT }); - const toast = useToast(); - const [resetAccLoading, setAccLoading] = useState(false); - const [eraseAllLoading, setEraseAllLoading] = useState(false); - const navigation = useNavigation(); - - const unlockWithBiometric = useAppLock().unlockWithBiometric; - - useEffect(() => { - const _authMethod = faceIdEnabled ? 'biometric' : 'pinCode'; - if (_authMethod === 'biometric') { - TouchID.isSupported() - .then(currentType => { - TouchID.authenticate(`Sign in with ${currentType}`, optionalConfigObject) - .then(() => { - unlockWithBiometric(); - navigation.canGoBack() ? navigation.goBack() : navigation.navigate('Home'); - }) - .catch(() => { - setAuthMethod('pinCode'); - }); - }) - .catch(() => setAuthMethod('pinCode')); - } - setAuthMethod(_authMethod); - }, [faceIdEnabled, navigation, unlockWithBiometric]); - - useEffect(() => { - if (value.length === 6) { - if (unlock(value)) { - setValue(''); - navigation.canGoBack() ? navigation.goBack() : navigation.navigate('Home'); - } else { - setValue(''); - setError(i18n.errorMessage.invalidPinCode); - ref.current?.focus(); - } - } - }, [navigation, ref, unlock, value]); - - const onReset = useCallback( - (resetAll: boolean) => { - return () => { - const _setLoading = resetAll ? setEraseAllLoading : setAccLoading; - _setLoading(true); - - setTimeout(() => { - _setLoading(false); - resetWallet({ - resetAll: resetAll, - }) - .then(rs => { - if (!rs.status) { - toast.show(rs.errors[0], { type: 'danger' }); - } - resetPinCode(); - navigation.reset({ - index: 0, - routes: [{ name: 'Home' }], - }); - }) - .catch((e: Error) => { - toast.show(e.message, { type: 'danger' }); - }) - .finally(() => { - _setLoading(false); - setModalVisible(false); - }); - }, 300); - }; - }, - [navigation, resetPinCode, toast], - ); - - return ( - - - - - - - - - - {i18n.welcomeScreen.welcomeBackTitle} - - {authMethod === 'pinCode' && ( - <> - - {i18n.common.enterPinToUnlock} - - - - )} - - {!!error && } - - - - setModalVisible(false)} - resetAccLoading={resetAccLoading} - eraseAllLoading={eraseAllLoading} - /> - - - - - ); -}; diff --git a/src/screens/MasterPassword/ChangeMasterPassword/index.tsx b/src/screens/MasterPassword/ChangeMasterPassword/index.tsx index 9fe3c6f04..4d7abb569 100644 --- a/src/screens/MasterPassword/ChangeMasterPassword/index.tsx +++ b/src/screens/MasterPassword/ChangeMasterPassword/index.tsx @@ -18,6 +18,9 @@ import useGoHome from 'hooks/screen/useGoHome'; import i18n from 'utils/i18n/i18n'; import AlertBox from 'components/design-system-ui/alert-box'; import { FontSemiBold } from 'styles/sharedStyles'; +import { useSelector } from 'react-redux'; +import { RootState } from 'stores/index'; +import { createKeychainPassword, resetKeychainPassword } from 'utils/account'; function checkValidateForm(isValidated: Record) { return isValidated.password && isValidated.repeatPassword; @@ -27,6 +30,7 @@ type PageStep = 'OldPassword' | 'NewPassword'; const ChangeMasterPassword = () => { const navigation = useNavigation(); + const { isUseBiometric } = useSelector((state: RootState) => state.mobileSettings); const theme = useSubWalletTheme().swThemes; const goHome = useGoHome(); const _style = ChangeMasterPasswordStyle(theme); @@ -62,6 +66,13 @@ const ChangeMasterPassword = () => { backToHome(goHome); }, [goHome]); + async function handleUpdateKeychain(password: string) { + if (isUseBiometric) { + await resetKeychainPassword(); + createKeychainPassword(password); + } + } + const onSubmit = () => { if (checkValidateForm(formState.isValidated)) { const password = formState.data.password; @@ -78,6 +89,7 @@ const ChangeMasterPassword = () => { if (!res.status) { setErrors(res.errors); } else { + handleUpdateKeychain(password); _backToHome(); } }) diff --git a/src/screens/MasterPassword/CreateMasterPassword/index.tsx b/src/screens/MasterPassword/CreateMasterPassword/index.tsx index 930d4f23e..0e3695801 100644 --- a/src/screens/MasterPassword/CreateMasterPassword/index.tsx +++ b/src/screens/MasterPassword/CreateMasterPassword/index.tsx @@ -15,6 +15,9 @@ import { KeypairType } from '@polkadot/util-crypto/types'; import useHandlerHardwareBackPress from 'hooks/screen/useHandlerHardwareBackPress'; import AlertBox from 'components/design-system-ui/alert-box'; import i18n from 'utils/i18n/i18n'; +import { RootState } from 'stores/index'; +import { useSelector } from 'react-redux'; +import { createKeychainPassword } from 'utils/account'; function checkValidateForm(isValidated: Record) { return isValidated.password && isValidated.repeatPassword; @@ -26,6 +29,7 @@ const CreateMasterPassword = ({ }, }: CreatePasswordProps) => { const navigation = useNavigation(); + const { isUseBiometric } = useSelector(({ mobileSettings }: RootState) => mobileSettings); const theme = useSubWalletTheme().swThemes; const _style = CreateMasterPasswordStyle(theme); const [isBusy, setIsBusy] = useState(false); @@ -79,6 +83,9 @@ const CreateMasterPassword = ({ } else { onComplete(); // TODO: complete + if (isUseBiometric) { + createKeychainPassword(password); + } } }) .catch(e => { diff --git a/src/screens/MasterPassword/Login/index.tsx b/src/screens/MasterPassword/Login/index.tsx index d5685e6be..0d93a47e0 100644 --- a/src/screens/MasterPassword/Login/index.tsx +++ b/src/screens/MasterPassword/Login/index.tsx @@ -1,32 +1,85 @@ -import { ContainerWithSubHeader } from 'components/ContainerWithSubHeader'; -import { Button, Icon } from 'components/design-system-ui'; -import { PasswordField } from 'components/Field/Password'; +import { Button, Typography } from 'components/design-system-ui'; import useFormControl from 'hooks/screen/useFormControl'; -import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; -import { CheckCircle } from 'phosphor-react-native'; -import React, { useEffect, useMemo, useState } from 'react'; -import { View } from 'react-native'; -import { validatePassword } from 'screens/Shared/AccountNamePasswordCreation'; +import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { + DeviceEventEmitter, + ImageBackground, + Keyboard, + KeyboardAvoidingView, + Platform, + StyleProp, + TouchableOpacity, + TouchableWithoutFeedback, + View, +} from 'react-native'; import i18n from 'utils/i18n/i18n'; -import { keyringUnlock } from 'messaging/index'; +import { keyringUnlock, resetWallet } from 'messaging/index'; +import { Images, SVGImages } from 'assets/index'; +import { InlinePassword } from 'components/common/Field/Password'; +import createStyles from './styles'; +import useAppLock from 'hooks/useAppLock'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from 'stores/index'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { ForgotPasswordModal } from 'components/common/ForgotPasswordModal'; +import { useToast } from 'react-native-toast-notifications'; +import useHandlerHardwareBackPress from 'hooks/screen/useHandlerHardwareBackPress'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RootStackParamList } from 'routes/index'; +import { createKeychainPassword, getKeychainPassword, getSupportedBiometryType } from 'utils/account'; +import { updateFaceIdEnable, updateUseBiometric } from 'stores/MobileSettings'; +import { FORCE_HIDDEN_EVENT } from 'components/design-system-ui/modal/ModalBaseV2'; +import MigrateToKeychainPasswordModal from '../MigrateToKeychainPasswordModal'; +import { mmkvStore } from 'utils/storage'; -type Props = {}; +interface LoginProps { + navigation: NativeStackNavigationProp; +} +type AuthMethod = 'biometric' | 'master-password'; -const Login: React.FC = (props: Props) => { - const {} = props; - const theme = useSubWalletTheme().swThemes; +const imageBackgroundStyle: StyleProp = { + flex: 1, + alignItems: 'center', + paddingHorizontal: 16, + paddingBottom: Platform.OS === 'ios' ? 56 : 20, + position: 'relative', + backgroundColor: 'black', +}; +// on Android, react navigation modal stacks doesn't in root level, it could be overlap +function forceCloseModalV2(isForceClose: boolean) { + if (Platform.OS === 'android') { + DeviceEventEmitter.emit(FORCE_HIDDEN_EVENT, isForceClose); + } +} +// Deprecated: This key only exist in keychain version +const isKeychainEnabled = mmkvStore.getBoolean('isKeychainEnabled'); +const BEFORE_KEYCHAIN_BUILD_NUMBER = 211; + +const Login: React.FC = ({ navigation }) => { + const { faceIdEnabled, isUseBiometric } = useSelector((state: RootState) => state.mobileSettings); + const { buildNumber } = useSelector((state: RootState) => state.appVersion); const [loading, setLoading] = useState(false); + const [modalVisible, setModalVisible] = useState(false); + const [modalMigrateVisible, setModalMigrateVisible] = useState(false); + const [resetAccLoading, setAccLoading] = useState(false); + const [eraseAllLoading, setEraseAllLoading] = useState(false); + const [isBiometricEnabled, setIsBiometricEnabled] = useState(isUseBiometric); + const dispatch = useDispatch(); + + const toast = useToast(); + const [authMethod, setAuthMethod] = useState(isUseBiometric ? 'biometric' : 'master-password'); + const styles = createStyles(); + const { unlockApp, resetPinCode } = useAppLock(); const formConfig = { password: { name: i18n.common.walletPassword, value: '', - validateFunc: validatePassword, - require: true, + require: false, }, }; + useHandlerHardwareBackPress(true); - const onSubmit = () => { - const password = formState.data.password; + const onUnlock = useCallback((password: string) => { setLoading(true); setTimeout(() => { keyringUnlock({ @@ -35,15 +88,101 @@ const Login: React.FC = (props: Props) => { .then(data => { if (!data.status) { onUpdateErrors('password')([i18n.errorMessage.invalidMasterPassword]); + return; + } + unlockApp(); + if (faceIdEnabled && !isUseBiometric) { + // Migrate use biometrics + createKeychainPassword(password) + .then(res => { + if (res) { + dispatch(updateFaceIdEnable(false)); + dispatch(updateUseBiometric(true)); + } else { + dispatch(updateUseBiometric(false)); + } + }) + .finally(() => { + forceCloseModalV2(false); + navigation.goBack(); + }); + } else { + navigation.goBack(); + forceCloseModalV2(false); } }) .catch((e: Error) => { + console.log(e, 'error'); onUpdateErrors('password')([e.message]); }) .finally(() => { setLoading(false); }); }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // Deprecated: Migrate master password for biometric user + if (!isKeychainEnabled && buildNumber <= BEFORE_KEYCHAIN_BUILD_NUMBER && buildNumber > 1) { + setModalMigrateVisible(true); + mmkvStore.set('isKeychainEnabled', true); + } + }, [buildNumber]); + useEffect(() => { + if (authMethod === 'master-password') { + focus('password')(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authMethod]); + useEffect(() => forceCloseModalV2(true), []); + useEffect(() => { + if (!isUseBiometric) { + return; + } + if (Platform.OS === 'ios') { + // Because only iOS-Face ID is require permission, then we need to check permission's availbility + (async () => { + try { + const isBiometricAvailable = await getSupportedBiometryType(); + if (isBiometricAvailable) { + requestUnlockWithBiometric(); + } else { + setIsBiometricEnabled(false); + setAuthMethod('master-password'); + } + } catch (e) { + setAuthMethod('master-password'); + console.error(e); + } + })(); + return; + } + requestUnlockWithBiometric(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function requestUnlockWithBiometric() { + try { + const password = await getKeychainPassword(); + if (password) { + onUnlock(password); + } + } catch (e) { + console.warn(e); + if (JSON.stringify(e).indexOf('Biometry is not available') !== -1) { + setIsBiometricEnabled(false); + setAuthMethod('master-password'); + } else { + setAuthMethod('master-password'); + } + } + } + + const onSubmit = () => { + const password = formState.data.password; + onUnlock(password); }; const { formState, onChangeValue, onSubmitField, focus, onUpdateErrors } = useFormControl(formConfig, { @@ -54,38 +193,106 @@ const Login: React.FC = (props: Props) => { return loading || !formState.data.password || formState.errors.password.length > 0; }, [formState.data.password, formState.errors.password.length, loading]); - useEffect(() => { - focus('password')(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const onReset = useCallback( + (resetAll: boolean) => { + return () => { + const _setLoading = resetAll ? setEraseAllLoading : setAccLoading; + _setLoading(true); + + setTimeout(() => { + _setLoading(false); + resetWallet({ + resetAll: resetAll, + }) + .then(rs => { + if (!rs.status) { + toast.show(rs.errors[0], { type: 'danger' }); + } + }) + .catch((e: Error) => { + toast.show(e.message, { type: 'danger' }); + }) + .finally(() => { + _setLoading(false); + setModalVisible(false); + resetAll && resetPinCode(); + }); + }, 300); + }; + }, + [toast, resetPinCode], + ); + const onToggleModal = () => setModalVisible(state => !state); + + const dismissKeyboard = () => Keyboard.dismiss(); return ( - - onChangeValue('password')(value)} - errorMessages={formState.errors.password} - onSubmitField={onSubmitField('password')} - /> - - + {isUseBiometric && isBiometricEnabled && ( + + )} + + )} + + - } - onPress={onSubmit}> - {i18n.buttonTitles.apply} - - - + + + + {/* Deprecated: Migrate master password for biometric user */} + {buildNumber <= BEFORE_KEYCHAIN_BUILD_NUMBER && ( + + )} + ); }; diff --git a/src/screens/MasterPassword/Login/styles/index.ts b/src/screens/MasterPassword/Login/styles/index.ts index 926e5f830..2e09239e9 100644 --- a/src/screens/MasterPassword/Login/styles/index.ts +++ b/src/screens/MasterPassword/Login/styles/index.ts @@ -1,5 +1,27 @@ -import { StyleSheet } from 'react-native'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import { StyleSheet, TextStyle, ViewStyle } from 'react-native'; -export interface LoginStyle {} +export interface LoginStyle { + container: ViewStyle; + subLogo: ViewStyle; + subTitle: ViewStyle; + submitButton: ViewStyle; + forgotpasswordText: TextStyle; + forgotpasswordButton: ViewStyle; + fullscreen: ViewStyle; + fullWidth: ViewStyle; +} -export default () => StyleSheet.create({}); +export default () => { + const theme = useSubWalletTheme().swThemes; + return StyleSheet.create({ + container: { width: '100%', alignItems: 'center', paddingTop: 93 }, + subLogo: { paddingTop: 20, paddingBottom: 12 }, + subTitle: { marginBottom: 40, color: theme.colorTextLabel }, + submitButton: { width: '100%', marginTop: 8 }, + fullscreen: { width: '100%', height: '100%' }, + fullWidth: { width: '100%' }, + forgotpasswordText: { color: theme.colorTextDescription }, + forgotpasswordButton: { alignSelf: 'flex-end', height: 35, justifyContent: 'center' }, + }); +}; diff --git a/src/screens/MasterPassword/MigrateToKeychainPasswordModal/index.tsx b/src/screens/MasterPassword/MigrateToKeychainPasswordModal/index.tsx new file mode 100644 index 000000000..a2accd2db --- /dev/null +++ b/src/screens/MasterPassword/MigrateToKeychainPasswordModal/index.tsx @@ -0,0 +1,48 @@ +/** This is a popup notice user use master password to unlock app, instead of PIN code */ +import React from 'react'; +import { Button, Icon, PageIcon, SwModal, Typography } from 'components/design-system-ui'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import ModalStyle from './style'; +import { View } from 'react-native'; +import { ArrowCircleRight, ShieldStar } from 'phosphor-react-native'; +import i18n from 'utils/i18n/i18n'; + +interface Props { + modalVisible: boolean; + setModalVisible: (arg: boolean) => void; + isBiometricV1Enabled: boolean; +} + +const MigrateToKeychainPasswordModal = ({ modalVisible, setModalVisible, isBiometricV1Enabled }: Props) => { + const theme = useSubWalletTheme().swThemes; + const _style = ModalStyle(theme); + + const onPressButton = () => { + setModalVisible(false); + }; + + return ( + + + + + {isBiometricV1Enabled ? i18n.message.migrateMasterPasswordForBiometric : i18n.message.noticeForNewLoginMethod} + + + + + + ); +}; + +export default MigrateToKeychainPasswordModal; diff --git a/src/screens/MasterPassword/MigrateToKeychainPasswordModal/style/index.ts b/src/screens/MasterPassword/MigrateToKeychainPasswordModal/style/index.ts new file mode 100644 index 000000000..219a78ed7 --- /dev/null +++ b/src/screens/MasterPassword/MigrateToKeychainPasswordModal/style/index.ts @@ -0,0 +1,29 @@ +import { StyleSheet, TextStyle, ViewStyle } from 'react-native'; +import { ThemeTypes } from 'styles/themes'; +import { FontMedium, MarginBottomForSubmitButton } from 'styles/sharedStyles'; + +export interface CreateMasterPasswordStyle { + modalWrapper: ViewStyle; + textStyle: TextStyle; + footerAreaStyle: ViewStyle; +} + +export default (theme: ThemeTypes) => + StyleSheet.create({ + modalWrapper: { width: '100%', alignItems: 'center', paddingTop: 10 }, + textStyle: { + marginTop: 15, + paddingHorizontal: theme.paddingContentHorizontal, + fontSize: theme.fontSize, + lineHeight: theme.fontSize * theme.lineHeight, + color: theme.colorTextLight4, + textAlign: 'center', + ...FontMedium, + }, + + footerAreaStyle: { + marginTop: theme.margin, + width: '100%', + ...MarginBottomForSubmitButton, + }, + }); diff --git a/src/screens/Settings/Security/PinCode.tsx b/src/screens/Settings/Security/PinCode.tsx deleted file mode 100644 index 87e6fc246..000000000 --- a/src/screens/Settings/Security/PinCode.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import { StyleProp, View } from 'react-native'; -import { PinCodeField } from 'components/PinCodeField'; -import { useBlurOnFulfill } from 'react-native-confirmation-code-field'; -import { CELL_COUNT } from 'constants/index'; -import i18n from 'utils/i18n/i18n'; -import { Button } from 'components/design-system-ui'; - -const bottomAreaStyle: StyleProp = { - flexDirection: 'row', - width: '100%', - paddingHorizontal: 16, - paddingBottom: 18, - paddingTop: 118, -}; - -const cancelButtonStyle: StyleProp = { flex: 1, marginRight: 6 }; -const continueButtonStyle: StyleProp = { flex: 1, marginLeft: 6 }; -interface Props { - pinCode: string; - onChangePinCode: (text: string) => void; - onPressBack: () => void; - onPressContinue: () => void; - isPinCodeValid?: boolean; -} - -export const PinCode = ({ pinCode, onChangePinCode, onPressBack, onPressContinue, isPinCodeValid }: Props) => { - const ref = useBlurOnFulfill({ value: pinCode, cellCount: CELL_COUNT }); - return ( - <> - - - - - - - - - ); -}; diff --git a/src/screens/Settings/Security/PinCodeScreen.tsx b/src/screens/Settings/Security/PinCodeScreen.tsx deleted file mode 100644 index 05e8b1a81..000000000 --- a/src/screens/Settings/Security/PinCodeScreen.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React, { useState } from 'react'; -import { ContainerWithSubHeader } from 'components/ContainerWithSubHeader'; -import { PinCode } from 'screens/Settings/Security/PinCode'; -import { updateFaceIdEnable, updatePinCode, updatePinCodeEnable } from 'stores/MobileSettings'; -import { useDispatch, useSelector } from 'react-redux'; -import { useNavigation } from '@react-navigation/native'; -import { PinCodeProps, RootNavigationProps } from 'routes/index'; -import { RootState } from 'stores/index'; -import bcrypt from 'react-native-bcrypt'; -import i18n from 'utils/i18n/i18n'; - -const ViewStep = { - VALIDATE_PIN_CODE: 1, - PIN_CODE: 2, - REPEAT_PIN_CODE: 3, -}; - -export const PinCodeScreen = ({ - route: { - params: { screen }, - }, -}: PinCodeProps) => { - const pinCode = useSelector((state: RootState) => state.mobileSettings.pinCode); - const navigation = useNavigation(); - const [currentViewStep, setCurrentViewStep] = useState( - screen === 'NewPinCode' ? ViewStep.PIN_CODE : ViewStep.VALIDATE_PIN_CODE, - ); - const [title, setTitle] = useState(screen ? i18n.common.pinCode : i18n.common.newPinCode); - const [validatePinCode, setValidatePinCode] = useState(''); - const [newPinCode, setNewPinCode] = useState(''); - const [repeatPinCode, setRepeatPinCode] = useState(''); - const dispatch = useDispatch(); - const onSavePinCode = () => { - const salt = bcrypt.genSaltSync(6); - const hash = bcrypt.hashSync(newPinCode, salt); - dispatch(updatePinCode(hash)); - dispatch(updatePinCodeEnable(true)); - navigation.navigate('Security'); - }; - - const onPressBack = () => { - if (currentViewStep === ViewStep.VALIDATE_PIN_CODE) { - navigation.navigate('Security'); - } else if (currentViewStep === ViewStep.PIN_CODE) { - navigation.navigate('Security'); - } else { - setCurrentViewStep(ViewStep.PIN_CODE); - setTitle(i18n.common.newPinCode); - setRepeatPinCode(''); - } - }; - - return ( - - <> - {currentViewStep === ViewStep.VALIDATE_PIN_CODE && ( - navigation.navigate('Security')} - onPressContinue={() => { - if (screen === 'TurnoffPinCode') { - dispatch(updatePinCodeEnable(false)); - dispatch(updateFaceIdEnable(false)); - dispatch(updatePinCode('')); - navigation.navigate('Security'); - } else { - setCurrentViewStep(ViewStep.PIN_CODE); - setTitle(i18n.common.newPinCode); - } - }} - pinCode={validatePinCode} - onChangePinCode={setValidatePinCode} - isPinCodeValid={ - validatePinCode.length === 6 ? !!pinCode && bcrypt.compareSync(validatePinCode, pinCode) : true - } - /> - )} - - {currentViewStep === ViewStep.PIN_CODE && ( - { - navigation.navigate('Security'); - }} - onPressContinue={() => { - setCurrentViewStep(ViewStep.REPEAT_PIN_CODE); - setTitle(i18n.common.repeatPinCode); - }} - isPinCodeValid={true} - /> - )} - - {currentViewStep === ViewStep.REPEAT_PIN_CODE && ( - { - setCurrentViewStep(ViewStep.PIN_CODE); - setTitle(i18n.common.newPinCode); - setRepeatPinCode(''); - }} - onPressContinue={onSavePinCode} - pinCode={repeatPinCode} - onChangePinCode={setRepeatPinCode} - isPinCodeValid={repeatPinCode.length === 6 ? newPinCode === repeatPinCode : true} - /> - )} - - - ); -}; diff --git a/src/screens/Settings/Security/index.tsx b/src/screens/Settings/Security/index.tsx index 415050bfb..b81baf2f5 100644 --- a/src/screens/Settings/Security/index.tsx +++ b/src/screens/Settings/Security/index.tsx @@ -5,80 +5,101 @@ import { RootNavigationProps } from 'routes/index'; import { ToggleItem } from 'components/ToggleItem'; import { View } from 'react-native'; import { sharedStyles } from 'styles/sharedStyles'; -import { CaretRight, Globe, Key, Password, Scan, ShieldCheck } from 'phosphor-react-native'; +import { CaretRight, Globe, Key, Scan, ShieldCheck } from 'phosphor-react-native'; import { useDispatch, useSelector } from 'react-redux'; import { RootState } from 'stores/index'; -import { updateAutoLockTime, updateFaceIdEnable } from 'stores/MobileSettings'; +import { updateAutoLockTime, updateUseBiometric } from 'stores/MobileSettings'; import i18n from 'utils/i18n/i18n'; import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; import { Icon, SelectItem, SwModal } from 'components/design-system-ui'; import { useToast } from 'react-native-toast-notifications'; import { SWModalRefProps } from 'components/design-system-ui/modal/ModalBaseV2'; +import useUnlockModal from 'hooks/modal/useUnlockModal'; +import { createKeychainPassword, getSupportedBiometryType, resetKeychainPassword } from 'utils/account'; +import { saveAutoLockTime } from 'messaging/index'; +import { requestFaceIDPermission } from 'utils/permission/biometric'; +import { LockTimeout } from 'stores/types'; export const Security = () => { const theme = useSubWalletTheme().swThemes; const toast = useToast(); - const { pinCode, pinCodeEnabled, autoLockTime, faceIdEnabled } = useSelector( - (state: RootState) => state.mobileSettings, - ); + const { timeAutoLock, isUseBiometric } = useSelector((state: RootState) => state.mobileSettings); const [iShowAutoLockModal, setIsShowAutoLockModal] = useState(false); const navigation = useNavigation(); const dispatch = useDispatch(); const modalRef = useRef(null); - const AUTO_LOCK_LIST: { text: string; value: number | undefined }[] = [ - { - text: i18n.settings.immediately, - value: 0, - }, + const AUTO_LOCK_LIST: { text: string; value: number }[] = [ { - text: i18n.settings.ifLeftFor15Seconds, - value: 15 * 1000, - }, - { - text: i18n.settings.ifLeftFor30Seconds, - value: 30 * 1000, + text: i18n.settings.alwaysRequire, + value: LockTimeout.ALWAYS, }, { text: i18n.settings.ifLeftFor1Minute, - value: 60 * 1000, + value: LockTimeout._1MINUTE, }, { text: i18n.settings.ifLeftFor5Minutes, - value: 5 * 60 * 1000, + value: LockTimeout._5MINUTE, + }, + { + text: i18n.settings.ifLeftFor10Minutes, + value: LockTimeout._10MINUTE, }, { text: i18n.settings.ifLeftFor15Minutes, - value: 15 * 60 * 1000, + value: LockTimeout._15MINUTE, }, { text: i18n.settings.ifLeftFor30Minutes, - value: 30 * 60 * 1000, + value: LockTimeout._30MINUTE, }, { text: i18n.settings.ifLeftFor1Hour, - value: 60 * 60 * 1000, + value: LockTimeout._60MINUTE, }, { - text: i18n.settings.whenCloseApp, - value: undefined, + text: i18n.settings.neverRequire, + value: LockTimeout.NEVER, }, ]; - const onValueChangePinCode = () => { - if (!pinCodeEnabled) { - navigation.navigate('PinCode', { screen: 'NewPinCode' }); - } else { - navigation.navigate('PinCode', { screen: 'TurnoffPinCode' }); - } + const onPasswordComplete = (password: string) => { + createKeychainPassword(password).then(res => { + if (res) { + dispatch(updateUseBiometric(true)); + } else { + dispatch(updateUseBiometric(false)); + } + }); }; + const { onPress: onPressSubmit } = useUnlockModal(navigation, () => {}, onPasswordComplete); const onValueChangeFaceId = () => { - dispatch(updateFaceIdEnable(!faceIdEnabled)); + if (isUseBiometric) { + dispatch(updateUseBiometric(false)); + resetKeychainPassword(); + } else { + (async () => { + const isBiometricEnabled = await getSupportedBiometryType(); + if (isBiometricEnabled) { + onPressSubmit(() => {})(); + return; + } + // if Face ID permission denied + const result = await requestFaceIDPermission(); + if (result) { + onPressSubmit(() => {})(); + } + })(); + } }; - const onChangeAutoLockTime = (value: number | undefined) => { - dispatch(updateAutoLockTime(value)); + const onChangeAutoLockTime = (value: number) => { + if (value === LockTimeout.NEVER) { + toast.show(i18n.notificationMessage.warningNeverRequirePassword, { type: 'warning', duration: 3500 }); + } + saveAutoLockTime(value).then(() => dispatch(updateAutoLockTime(value))); modalRef?.current?.close(); }; @@ -90,39 +111,16 @@ export const Security = () => { navigation.goBack(); }}> - - navigation.navigate('PinCode', { screen: 'ChangePinCode' })} - rightIcon={ - - } - disabled={!pinCode} - /> - { backgroundColor={theme['green-6']} label={i18n.settings.appLock} onPress={() => setIsShowAutoLockModal(true)} - rightIcon={ - - } - disabled={!pinCode} + rightIcon={} /> @@ -171,12 +162,12 @@ export const Security = () => { setVisible={setIsShowAutoLockModal} modalVisible={iShowAutoLockModal} onBackButtonPress={() => modalRef?.current?.close()} - modalTitle={i18n.common.autoLock}> + modalTitle={i18n.settings.appLock}> {AUTO_LOCK_LIST.map(item => ( onChangeAutoLockTime(item.value)} /> diff --git a/src/screens/Settings/index.tsx b/src/screens/Settings/index.tsx index c9270fcee..b0bbf44ba 100644 --- a/src/screens/Settings/index.tsx +++ b/src/screens/Settings/index.tsx @@ -24,8 +24,6 @@ import { } from 'phosphor-react-native'; import { FontMedium, FontSemiBold, sharedStyles } from 'styles/sharedStyles'; import { ColorMap } from 'styles/color'; -import { useSelector } from 'react-redux'; -import { RootState } from 'stores/index'; import { RootNavigationProps } from 'routes/index'; import i18n from 'utils/i18n/i18n'; import { @@ -73,7 +71,6 @@ type settingItemType = { export const Settings = ({ navigation: drawerNavigation }: DrawerContentComponentProps) => { const navigation = useNavigation(); const theme = useSubWalletTheme().swThemes; - const pinCodeEnabled = useSelector((state: RootState) => state.mobileSettings.pinCodeEnabled); const { lock } = useAppLock(); const [hiddenCount, setHiddenCount] = useState(0); @@ -285,17 +282,9 @@ export const Settings = ({ navigation: drawerNavigation }: DrawerContentComponen diff --git a/src/stores/Browser.ts b/src/stores/Browser.ts index 84accdd4d..db009cccc 100644 --- a/src/stores/Browser.ts +++ b/src/stores/Browser.ts @@ -117,7 +117,15 @@ const browserSlice = createSlice({ removeBookmark: (state, { payload }: PayloadAction) => { state.bookmarks = state.bookmarks.filter(t => t.url !== payload.url); }, + resetBrowserSetting: state => { + state.activeTab = null; + state.tabs = []; + state.whitelist = []; + state.history = []; + state.bookmarks = []; + }, }, }); +export const { resetBrowserSetting } = browserSlice.actions; export default browserSlice.reducer; diff --git a/src/stores/MobileSettings.ts b/src/stores/MobileSettings.ts index bbba694ac..6ea35f7fd 100644 --- a/src/stores/MobileSettings.ts +++ b/src/stores/MobileSettings.ts @@ -1,13 +1,13 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { MobileSettingsSlice } from 'stores/types'; +import { LockTimeout, MobileSettingsSlice } from 'stores/types'; const MOBILE_SETTINGS_STORE_DEFAULT: MobileSettingsSlice = { language: 'en', - pinCode: '', pinCodeEnabled: false, - faceIdEnabled: false, + faceIdEnabled: false, // deprecated + isUseBiometric: false, isPreventLock: false, - autoLockTime: 15 * 1000, + timeAutoLock: LockTimeout._15MINUTE, }; const mobileSettingsSlice = createSlice({ @@ -22,17 +22,14 @@ const mobileSettingsSlice = createSlice({ updateLanguage(state, action: PayloadAction) { state.language = action.payload; }, - updatePinCode(state, action: PayloadAction) { - state.pinCode = action.payload; - }, - updatePinCodeEnable(state, action: PayloadAction) { - state.pinCodeEnabled = action.payload; - }, updateFaceIdEnable(state, action: PayloadAction) { state.faceIdEnabled = action.payload; }, + updateUseBiometric(state, action: PayloadAction) { + state.isUseBiometric = action.payload; + }, updateAutoLockTime(state, action: PayloadAction) { - state.autoLockTime = action.payload; + state.timeAutoLock = action.payload; }, updatePreventLock(state, action: PayloadAction) { state.isPreventLock = action.payload; @@ -40,12 +37,6 @@ const mobileSettingsSlice = createSlice({ }, }); -export const { - updateLanguage, - updatePinCode, - updatePinCodeEnable, - updateFaceIdEnable, - updateAutoLockTime, - updatePreventLock, -} = mobileSettingsSlice.actions; +export const { updateLanguage, updateFaceIdEnable, updateUseBiometric, updateAutoLockTime, updatePreventLock } = + mobileSettingsSlice.actions; export default mobileSettingsSlice.reducer; diff --git a/src/stores/types.ts b/src/stores/types.ts index dca2dc75c..06b73c241 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -81,13 +81,24 @@ export type ConfirmationSlice = { export type MobileSettingsSlice = { language: string; - pinCode: string; pinCodeEnabled: boolean; - faceIdEnabled: boolean; - autoLockTime: number | undefined; + faceIdEnabled: boolean; // deprecated + isUseBiometric: boolean; + timeAutoLock: LockTimeout; isPreventLock: boolean; }; +export enum LockTimeout { + NEVER = -1, + ALWAYS = 0, + _1MINUTE = 1, + _5MINUTE = 5, + _10MINUTE = 10, + _15MINUTE = 15, + _30MINUTE = 30, + _60MINUTE = 60, +} + export type SiteInfo = { name: string; url: string; diff --git a/src/utils/account.ts b/src/utils/account.ts index 68f1b2aba..6c1626c73 100644 --- a/src/utils/account.ts +++ b/src/utils/account.ts @@ -11,6 +11,9 @@ import { AccountSignMode } from 'types/signer'; import { AccountAddressType } from 'types/index'; import { _ChainInfo } from '@subwallet/chain-list/types'; import { Recoded } from 'types/ui-types'; +import SInfo, { RNSensitiveInfoOptions } from 'react-native-sensitive-info'; +import { Alert } from 'react-native'; +import i18n from './i18n/i18n'; export const findAccountByAddress = (accounts: AccountJson[], address?: string): AccountJson | null => { try { @@ -193,3 +196,57 @@ export const findContactByAddress = (contacts: AbstractAddressJson[], address?: return null; } }; + +// Keychain configuration +const keychainConfig: RNSensitiveInfoOptions = { + touchID: true, + showModal: true, + kSecAccessControl: 'kSecAccessControlBiometryCurrentSet', + sharedPreferencesName: 'swSharedPrefs', + keychainService: 'swKeychain', + kSecAttrAccessible: 'kSecAttrAccessibleWhenUnlocked', + kSecUseOperationPrompt: 'Unlock app using biometric', +}; +const username = 'sw-user'; +export const createKeychainPassword = async (password: string) => { + try { + await SInfo.setItem(username, password, keychainConfig); + return true; + } catch (e) { + console.warn('set keychain failed', e); + return false; + } +}; + +export const getKeychainPassword = async () => { + try { + const password = await SInfo.getItem(username, keychainConfig); + return password; + } catch (e) { + if (JSON.stringify(e).indexOf('Biometry is locked out') !== -1) { + Alert.alert(i18n.common.tooManyAttemps); + } + throw e; + } +}; + +export const resetKeychainPassword = async () => { + try { + // return await Keychain.resetGenericPassword(); + SInfo.deleteItem(username, keychainConfig); + return true; + } catch (e) { + console.warn('reset keychain failed:', e); + return false; + } +}; + +export const getSupportedBiometryType = async () => { + try { + const result = await SInfo.isSensorAvailable(); + return result; + } catch (e) { + console.warn('Get failed!'); + return null; + } +}; diff --git a/src/utils/i18n/en_US.ts b/src/utils/i18n/en_US.ts index 5d64715f6..810533aa3 100644 --- a/src/utils/i18n/en_US.ts +++ b/src/utils/i18n/en_US.ts @@ -12,6 +12,7 @@ export const en = { cannotScanQRCodeWithoutPermission: 'SubWallet needs access to camera on your device to scan QR code for actions such as account creation, data verification or dApp connection.', goToSetting: 'Go to setting', + noFaceIdPermission: 'This app use Face ID to unlock password', scan: 'Scan', toSendFund: 'to send fund', toSendAsset: 'to send asset', @@ -200,6 +201,7 @@ export const en = { qrSignerAccount: 'QR signer account', watchOnlyAccount: 'Watch-only account', unknownAccount: 'Unknown account', + tooManyAttemps: 'Too many failed attempts. Please try again later or enter password.', }, title: { exportAccount: 'Export account', @@ -393,6 +395,8 @@ export const en = { applyAccounts: (account: number) => `Apply ${account} accounts`, createOne: 'Create one', reload: 'Reload', + unlockWithBiometric: 'Unlock with your biometric', + enterMasterPassword: 'Enter master password', }, inputLabel: { selectAcc: 'Select account', @@ -613,6 +617,10 @@ export const en = { failToDisconnect: 'Fail to disconnect', unableToFetchInformation: (validatorTitle: string) => `Unable to fetch ${validatorTitle} information`, unknownNetworks: (unsupportedNumber: number) => `${unsupportedNumber} unknown network`, + noticeForNewLoginMethod: + 'For easier password management, master password will be applied in place of PIN code in previous versions. You need to enter your master password to unlock SubWallet.', + migrateMasterPasswordForBiometric: + 'For easier password management, master password will be applied in place of PIN code in previous versions. To keep using biometric authentication, please enter your master password and verify your biometric again.', }, filterOptions: { polkadotParachain: 'Polkadot parachain', @@ -954,10 +962,13 @@ export const en = { termOfService: 'Terms of service', webViewDebugger: 'Web view debugger', immediately: 'Immediately', + neverRequire: 'Never', + alwaysRequire: 'Always', ifLeftFor15Seconds: 'If left for 15 seconds', ifLeftFor30Seconds: 'If left for 30 seconds', ifLeftFor1Minute: 'If left for 1 minute', ifLeftFor5Minutes: 'If left for 5 minutes', + ifLeftFor10Minutes: 'If left for 10 minutes', ifLeftFor15Minutes: 'If left for 15 minutes', ifLeftFor30Minutes: 'If left for 30 minutes', ifLeftFor1Hour: 'If left for 1 hour', @@ -971,7 +982,7 @@ export const en = { changePassword: 'Change password', manageWebsiteAccess: 'Manage website access', manageWalletConnectDapp: 'Manage WalletConnect Dapp', - appLock: 'App lock', + appLock: 'Require unlock', walletTheme: 'Wallet theme', language: 'Language', notifications: 'Notifications', @@ -1091,6 +1102,7 @@ export const en = { deleteChainSuccessfully: 'Deleted chain successfully', addProviderSuccessfully: 'Added a provider successfully', addTokenSuccessfully: 'Added token successfully', + warningNeverRequirePassword: 'Choosing this option can affect your wallet security', }, browser: { searchWithDuckduckgo: 'Search with Duckduckgo', diff --git a/src/utils/i18n/vi_VN.ts b/src/utils/i18n/vi_VN.ts index 4422f6eec..10636ab39 100644 --- a/src/utils/i18n/vi_VN.ts +++ b/src/utils/i18n/vi_VN.ts @@ -12,6 +12,7 @@ export const vi = { cannotScanQRCodeWithoutPermission: 'SubWallet cần sử dụng máy ảnh trên thiết bị của bạn để quét mã QR nhằm thực hiện các hành động như tạo tài khoản, xác thực dữ liệu hoặc kết nối dApp', goToSetting: 'Đi đến Cài đặt', + noFaceIdPermission: 'This app use Face ID to unlock password', scan: 'Quét', toSendFund: 'để gửi tài sản ', toSendAsset: 'để gửi tài sản', @@ -200,6 +201,7 @@ export const vi = { qrSignerAccount: 'Tài khoản QR signer', watchOnlyAccount: 'Tài khoản chỉ xem', unknownAccount: 'Tài khoản không xác định', + tooManyAttemps: 'Thất bại quá nhiều lần. Vui lòng thử lại sau hoặc nhập mật khẩu', }, title: { exportAccount: 'Xuất tài khoản ', @@ -392,6 +394,8 @@ export const vi = { applyAccounts: (account: number) => `Kết nối ${account} tài khoản`, createOne: 'Tạo tài khoản', reload: 'Tải lại', + unlockWithBiometric: 'Mở khoá bằng sinh trắc học', + enterMasterPassword: 'Nhập master password', }, inputLabel: { selectAcc: 'Chọn tài khoản', @@ -611,6 +615,10 @@ export const vi = { failToDisconnect: 'Ngắt kết nối thất bại', unableToFetchInformation: (validatorTitle: string) => `Không thể lấy thông tin của ${validatorTitle}`, unknownNetworks: (unsupportedNumber: number) => `${unsupportedNumber} mạng không xác định`, + noticeForNewLoginMethod: + 'Để việc quản lý mật khẩu trở nên dễ dàng hơn, master password sẽ được áp dụng thay thế PIN code trong bản cũ. Bạn cần nhập master password để mở khoá ứng dụng.', + migrateMasterPasswordForBiometric: + 'Để việc quản lý mật khẩu trở nên dễ dàng hơn, master password sẽ được áp dụng thay thế PIN code trong bản cũ. Bạn đang sử dụng phương thức mở khóa bằng sinh trắc học, để tiếp tục áp dụng phương thức này, bạn cần nhập master password và xác nhận lại sinh trắc học.', }, filterOptions: { polkadotParachain: 'Polkadot Parachain', @@ -953,10 +961,13 @@ export const vi = { termOfService: 'Điều khoản dịch vụ', webViewDebugger: 'Trình gỡ lỗi web view', immediately: 'Ngay lập tức', + neverRequire: 'Không bao giờ', + alwaysRequire: 'Luôn luôn', ifLeftFor15Seconds: 'Sau 15 giây', ifLeftFor30Seconds: 'Sau 30 giây', ifLeftFor1Minute: 'Sau 1 phút', ifLeftFor5Minutes: 'Sau 5 phút', + ifLeftFor10Minutes: 'Sau 10 phút', ifLeftFor15Minutes: 'Sau 15 phút', ifLeftFor30Minutes: 'Sau 30 phút', ifLeftFor1Hour: 'Sau 1 giờ', @@ -970,7 +981,7 @@ export const vi = { changePassword: 'Đổi mật khẩu', manageWebsiteAccess: 'Quản lý truy cập', manageWalletConnectDapp: 'Quản lý WalletConnect dApp', - appLock: 'Khóa tự động', + appLock: 'Yêu cầu mở khoá', walletTheme: 'Chế độ nền', language: 'Ngôn ngữ', notifications: 'Thông báo', @@ -1090,6 +1101,7 @@ export const vi = { deleteChainSuccessfully: 'Xóa mạng thành công', addProviderSuccessfully: 'Đã thêm một provider thành công', addTokenSuccessfully: 'Thêm token thành công', + warningNeverRequirePassword: 'Lựa chọn này có thể ảnh hưởng đến bảo mật ví của bạn.', }, browser: { searchWithDuckduckgo: 'Tìm trên Duckduckgo', diff --git a/src/utils/i18n/zh_CN.ts b/src/utils/i18n/zh_CN.ts index 9bb378e05..8e23f716f 100644 --- a/src/utils/i18n/zh_CN.ts +++ b/src/utils/i18n/zh_CN.ts @@ -12,6 +12,7 @@ export const zh = { cannotScanQRCodeWithoutPermission: 'SubWallet 需要访问您设备上的摄像头来扫描二维码以执行帐户创建、数据验证或 dApp 连接等操作。', goToSetting: '前往设置', + noFaceIdPermission: 'This app use Face ID to unlock password', scan: '扫描', toSendFund: '以发送资金', toSendAsset: '以发送资产', @@ -198,6 +199,7 @@ export const zh = { qrSignerAccount: '二维码登录的账户', watchOnlyAccount: '仅观看账户', unknownAccount: '未知账户', + tooManyAttemps: '尝试的失败次数过多。请稍后重试或输入密码。', }, title: { exportAccount: '导出账户', @@ -388,6 +390,8 @@ export const zh = { applyAccounts: (account: number) => `应用${account}账户`, createOne: '创建', reload: '重新加载', + unlockWithBiometric: '生物识别解锁', + enterMasterPassword: '输入主密码', }, inputLabel: { selectAcc: '选择账户', @@ -604,6 +608,10 @@ export const zh = { failToDisconnect: '断开失败', unableToFetchInformation: (validatorTitle: string) => `无法获取${validatorTitle}资料`, unknownNetworks: (unsupportedNumber: number) => `${unsupportedNumber}未知网络`, + noticeForNewLoginMethod: + '为了更方便地进行密码管理,将使用主密码来代替以前版本中的 PIN 码。 您需要输入主密码才能解锁 SubWallet。', + migrateMasterPasswordForBiometric: + '为了更方便地进行密码管理,将使用主密码来代替以前版本中的 PIN 码。 要继续使用生物识别身份验证,请输入您的主密码并再次验证您的生物识别。', }, filterOptions: { polkadotParachain: 'Polkadot平行链', @@ -943,10 +951,13 @@ export const zh = { termOfService: '服务条款', webViewDebugger: '网页视图排查者', immediately: '立即', + neverRequire: '永不', + alwaysRequire: '始终', ifLeftFor15Seconds: '若离开15秒', ifLeftFor30Seconds: '若离开30秒', ifLeftFor1Minute: '若离开1分钟', ifLeftFor5Minutes: '若离开5分钟', + ifLeftFor10Minutes: '若离开10分钟', ifLeftFor15Minutes: '若离开15分钟', ifLeftFor30Minutes: '若离开30分钟', ifLeftFor1Hour: '若离开1小时', @@ -960,7 +971,7 @@ export const zh = { changePassword: '更改密码', manageWebsiteAccess: '管理网站访问', manageWalletConnectDapp: '管理WalletConnect Dapp', - appLock: 'APP封锁', + appLock: '解锁要求', walletTheme: '钱包主题', language: '语言', notifications: '通知', @@ -1073,6 +1084,7 @@ export const zh = { deleteChainSuccessfully: '删除链接成功', addProviderSuccessfully: '添加提供商成功', addTokenSuccessfully: '添加通证成功', + warningNeverRequirePassword: '此选项会影响您的钱包安全。', }, browser: { searchWithDuckduckgo: '通过Duckduckgo搜索', diff --git a/src/utils/permission/biometric.ts b/src/utils/permission/biometric.ts new file mode 100644 index 000000000..5590377fa --- /dev/null +++ b/src/utils/permission/biometric.ts @@ -0,0 +1,46 @@ +import { check, PERMISSIONS, request, RESULTS } from 'react-native-permissions'; +import { Alert, Linking, Platform } from 'react-native'; +import { AutoLockState } from 'utils/autoLock'; +import i18n from 'utils/i18n/i18n'; + +export const requestFaceIDPermission = async (onPressCancel?: () => void) => { + if (Platform.OS === 'android') { + return; + } + try { + AutoLockState.isPreventAutoLock = true; + const result = await check(PERMISSIONS.IOS.FACE_ID); + AutoLockState.isPreventAutoLock = false; + + switch (result) { + case RESULTS.UNAVAILABLE: + // Images: This feature is not available (on this device / in this context) + break; + case RESULTS.DENIED: + request(PERMISSIONS.IOS.FACE_ID).then(() => onPressCancel && onPressCancel()); + // Images: The permission has not been requested / is denied but requestable + break; + case RESULTS.GRANTED: + // Images: The permission is granted + return result; + case RESULTS.BLOCKED: + Alert.alert(i18n.common.notify, i18n.common.noFaceIdPermission, [ + { + text: i18n.buttonTitles.cancel, + onPress: onPressCancel, + }, + { + text: i18n.common.goToSetting, + onPress: () => { + onPressCancel && onPressCancel(); + Linking.openSettings(); + }, + }, + ]); + return null; + } + } catch (e) { + console.log(e); + return null; + } +}; diff --git a/yarn.lock b/yarn.lock index 79b5cfd0b..eaa8fad4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15110,10 +15110,10 @@ react-native-inappbrowser-reborn@^3.7.0: invariant "^2.2.4" opencollective-postinstall "^2.0.3" -react-native-keychain@^8.1.1: - version "8.1.1" - resolved "https://registry.yarnpkg.com/react-native-keychain/-/react-native-keychain-8.1.1.tgz#3bb5e37946b964a7bcf7df2fe470dd244e01a340" - integrity sha512-8fxgeHKwGcL657eAYpdBTkDIxNhbIHI+kyyO0Yac2dgVAN184JoIwQcW2z6snahwDaCObQOu0biLFHnsH+4KSg== +react-native-keychain@^8.1.2: + version "8.1.2" + resolved "https://registry.yarnpkg.com/react-native-keychain/-/react-native-keychain-8.1.2.tgz#34291ae472878e5124d081211af5ede7d810e64f" + integrity sha512-bhHEui+yMp3Us41NMoRGtnWEJiBE0g8tw5VFpq4mpmXAx6XJYahuM6K3WN5CsUeUl83hYysSL9oFZNKSTPSvYw== react-native-linear-gradient@^2.6.2: version "2.8.0" @@ -15239,6 +15239,11 @@ react-native-screens@^3.19.0: react-freeze "^1.0.0" warn-once "^0.1.0" +react-native-sensitive-info@^6.0.0-alpha.9: + version "6.0.0-alpha.9" + resolved "https://registry.yarnpkg.com/react-native-sensitive-info/-/react-native-sensitive-info-6.0.0-alpha.9.tgz#b488fab715d36efdf80b377a21545cfe888ab8d2" + integrity sha512-W2R1gUHgBAmy+wVl0BvA7s01SqqTitnG4Sdj2zmck0OS5a54kq+pTnXRqDljMa2NQs4Mue2IHyvd+Tf0X1ZL6Q== + react-native-size-matters@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/react-native-size-matters/-/react-native-size-matters-0.3.1.tgz#24d0cfc335a2c730f6d58bd7b43ea5a41be4b49f" @@ -15294,11 +15299,6 @@ react-native-toast-notifications@^3.3.1: resolved "https://registry.yarnpkg.com/react-native-toast-notifications/-/react-native-toast-notifications-3.3.1.tgz#c3d6f3b63a4df81c2912560d27878ea056672981" integrity sha512-yc1Q2nOdIYvAf0GAIlmg8q42hiwpEHnLxkxJ6P+tN6jpcKZ1qzMXlgnmNdyF9cm9VOyHQexEP8952IKNAv1Olw== -react-native-touch-id@^4.4.1: - version "4.4.1" - resolved "https://registry.yarnpkg.com/react-native-touch-id/-/react-native-touch-id-4.4.1.tgz#8b1bb2d04c30bac36bb9696d2d723e719c4a8b08" - integrity sha512-1jTl8fC+0fxvqegy/XXTyo6vMvPhjzkoDdaqoYZx0OH8AT250NuXnNPyKktvigIcys3+2acciqOeaCall7lrvg== - react-native-vector-icons@^9.2.0: version "9.2.0" resolved "https://registry.yarnpkg.com/react-native-vector-icons/-/react-native-vector-icons-9.2.0.tgz#3c0c82e95defd274d56363cbe8fead8d53167ebd"