diff --git a/CHANGELOG.md b/CHANGELOG.md index f2464f4f9b5..cd3180c89f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- fixed: Show the QR scanner scam warning after the camera permission is granted, instead of behind the OS permission prompt where it flashed away, and show only the Settings recovery guidance when camera access is denied. + ## 4.51.0 (staging) - added: Push info-server attestation tokens into edge-core-js via `setAttestationToken` so the login server can skip CAPTCHA for attested devices, and allow `LOGIN_SERVER` / `INFO_SERVER` env overrides for local E2E stacks. diff --git a/eslint.config.mjs b/eslint.config.mjs index a818fa26926..7b00b302676 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -129,7 +129,6 @@ export default [ 'src/actions/RecoveryReminderActions.tsx', 'src/actions/ScamWarningActions.tsx', - 'src/actions/ScanActions.tsx', 'src/actions/SoundActions.ts', 'src/actions/TokenTermsActions.tsx', @@ -220,7 +219,7 @@ export default [ 'src/components/modals/RadioListModal.tsx', 'src/components/modals/RawTextModal.tsx', 'src/components/modals/ScamWarningModal.tsx', - 'src/components/modals/ScanModal.tsx', + 'src/components/modals/StateProvinceListModal.tsx', 'src/components/modals/TransferModal.tsx', @@ -310,7 +309,7 @@ export default [ 'src/components/scenes/SwapSuccessScene.tsx', 'src/components/scenes/WalletRestoreScene.tsx', - 'src/components/scenes/WcConnectionsScene.tsx', + 'src/components/scenes/WcConnectScene.tsx', 'src/components/scenes/WcDisconnectScene.tsx', 'src/components/scenes/WebViewScene.tsx', diff --git a/src/__tests__/actions/RequestReviewActions.test.ts b/src/__tests__/actions/RequestReviewActions.test.ts index e064e05d111..15ee57474c7 100644 --- a/src/__tests__/actions/RequestReviewActions.test.ts +++ b/src/__tests__/actions/RequestReviewActions.test.ts @@ -51,6 +51,7 @@ const defaultReviewTrigger: ReviewTriggerData = { daysSinceUpgrade: [] } const defaultSettings: LocalAccountSettings = { + cameraScamWarningShown: false, contactsPermissionShown: false, developerModeOn: false, isAccountBalanceVisible: true, diff --git a/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap b/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap index a575e85bf19..cb8d242d971 100644 --- a/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap +++ b/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap @@ -110,6 +110,7 @@ exports[`initialState 1`] = ` "ip2FaNotifShown": false, }, "autoLogoutTimeInSeconds": 3600, + "cameraScamWarningShown": false, "changesLocked": true, "contactsPermissionShown": false, "countryCode": "", diff --git a/src/actions/LocalSettingsActions.ts b/src/actions/LocalSettingsActions.ts index 964b42f509b..0abec2343d5 100644 --- a/src/actions/LocalSettingsActions.ts +++ b/src/actions/LocalSettingsActions.ts @@ -264,6 +264,18 @@ export const writeNymWarningShown = async ( return await writeLocalAccountSettings(account, updatedSettings) } +/** + * Persists the user's acknowledgment of the QR scanner scam warning so it is + * only shown once per account, on the first use of the camera. + */ +export const writeCameraScamWarningShown = async ( + account: EdgeAccount +): Promise => { + const settings = await getLocalAccountSettings(account) + const updatedSettings = { ...settings, cameraScamWarningShown: true } + return await writeLocalAccountSettings(account, updatedSettings) +} + /** * Tracks whether a token gas requirement warning has been shown per a * particular currency plugin. If the plugin id exists in this array, the diff --git a/src/actions/ScanActions.tsx b/src/actions/ScanActions.tsx index 166856d4dfd..70b5b8955d6 100644 --- a/src/actions/ScanActions.tsx +++ b/src/actions/ScanActions.tsx @@ -8,11 +8,16 @@ import { type EdgeTokenId } from 'edge-core-js' import * as React from 'react' +import { AppState } from 'react-native' +import RNPermissions from 'react-native-permissions' import { sprintf } from 'sprintf-js' import URL from 'url-parse' import { ButtonsModal } from '../components/modals/ButtonsModal' +import { CameraPermissionDeniedModal } from '../components/modals/CameraPermissionDeniedModal' import { ConfirmContinueModal } from '../components/modals/ConfirmContinueModal' +import { ScanModal, type ScanModalProps } from '../components/modals/ScanModal' +import { ScanScamWarningModal } from '../components/modals/ScanScamWarningModal' import { WalletListModal, type WalletListResult @@ -23,6 +28,7 @@ import { showError, showWarning } from '../components/services/AirshipInstance' +import { checkAndRequestPermission } from '../components/services/PermissionsManager' import { getSpecialCurrencyInfo } from '../constants/WalletAndCurrencyConstants' import { lstrings } from '../locales/strings' import { getExchangeDenom } from '../selectors/DenominationSelectors' @@ -42,6 +48,10 @@ import { import { toListString, zeroString } from '../util/utils' import { cleanQueryFlags, openBrowserUri } from '../util/WebUtils' import { checkAndShowLightBackupModal } from './BackupModalActions' +import { + getLocalAccountSettings, + writeCameraScamWarningShown +} from './LocalSettingsActions' const RUNONCE_KEY_PREFIX = 'shownWalletGetCryptoModal:' @@ -568,3 +578,68 @@ export function checkAndShowGetCryptoModal( } } } + +/** + * Resolves once the app is in the foreground. + * + * The OS permission prompt backgrounds the app, and its callback fires while + * the app is still inactive behind the system alert. Presenting a modal in + * that window is what makes it flash past during the return transition. + */ +const waitForForeground = async (): Promise => { + if (AppState.currentState === 'active') return + await new Promise(resolve => { + const subscription = AppState.addEventListener('change', state => { + if (state === 'active') { + subscription.remove() + resolve() + } + }) + }) +} + +/** + * Opens the QR scanner, sequencing the camera permission, the first-use scam + * warning, and the scanner itself so that none of them can overlap: + * + * 1. Request the OS camera permission, with no Edge modal on screen behind it. + * 2. If it is denied or blocked, show the Settings recovery guidance and stop. + * 3. On the first camera use only, show the scam warning and wait for the user + * to explicitly acknowledge it. + * 4. Only then mount the scanner. + * + * Resolves to the scanned string, or `undefined` if the user backed out at any + * point. + */ +export const showScanModal = + (props: ScanModalProps): ThunkAction> => + async (dispatch, getState) => { + const status = await dispatch(checkAndRequestPermission('camera')) + if ( + status !== RNPermissions.RESULTS.GRANTED && + status !== RNPermissions.RESULTS.LIMITED + ) { + await Airship.show(bridge => ( + + )) + return undefined + } + + await waitForForeground() + + const account = getState().core.account + const { cameraScamWarningShown } = await getLocalAccountSettings(account) + if (!cameraScamWarningShown) { + const acknowledged = await Airship.show(bridge => ( + + )) + // The warning is only dismissable by acknowledging it, so anything else + // means we were torn down (a logout, say). Don't record it as shown: + if (!acknowledged) return undefined + await writeCameraScamWarningShown(account) + } + + return await Airship.show(bridge => ( + + )) + } diff --git a/src/components/modals/CameraPermissionDeniedModal.tsx b/src/components/modals/CameraPermissionDeniedModal.tsx new file mode 100644 index 00000000000..1101084ca43 --- /dev/null +++ b/src/components/modals/CameraPermissionDeniedModal.tsx @@ -0,0 +1,44 @@ +import * as React from 'react' +import type { AirshipBridge } from 'react-native-airship' +import { openSettings } from 'react-native-permissions' + +import { lstrings } from '../../locales/strings' +import { ModalButtons } from '../buttons/ModalButtons' +import { showError } from '../services/AirshipInstance' +import { Paragraph } from '../themed/EdgeText' +import { EdgeModal } from './EdgeModal' + +interface Props { + bridge: AirshipBridge +} + +/** + * Recovery guidance shown when the camera permission is denied or blocked. + * + * This deliberately carries no scam warning: the warning has its own trigger + * (the first successful use of the camera) and its own modal, so that neither + * one can hide or cut short the other. + */ +export const CameraPermissionDeniedModal: React.FC = props => { + const { bridge } = props + + const handleClose = (): void => { + bridge.resolve() + } + + const handleSettings = (): void => { + openSettings().catch((error: unknown) => { + showError(error) + }) + handleClose() + } + + return ( + + {lstrings.scan_camera_permission_denied} + + + ) +} diff --git a/src/components/modals/ScanModal.tsx b/src/components/modals/ScanModal.tsx index 5d5d2dc3578..3eede46cb45 100644 --- a/src/components/modals/ScanModal.tsx +++ b/src/components/modals/ScanModal.tsx @@ -1,8 +1,7 @@ import * as React from 'react' -import { Linking, View } from 'react-native' +import { View } from 'react-native' import { type AirshipBridge, AirshipModal } from 'react-native-airship' import { launchImageLibrary } from 'react-native-image-picker' -import RNPermissions from 'react-native-permissions' import { useSafeAreaFrame } from 'react-native-safe-area-context' import Ionicon from 'react-native-vector-icons/Ionicons' import { @@ -12,16 +11,11 @@ import { useCodeScanner } from 'react-native-vision-camera' import RNQRGenerator from 'rn-qr-generator' -import { sprintf } from 'sprintf-js' import { useLayout } from '../../hooks/useLayout' import { lstrings } from '../../locales/strings' -import { config } from '../../theme/appConfig' -import { useDispatch, useSelector } from '../../types/reactRedux' import { triggerHaptic } from '../../util/haptic' import { logActivity } from '../../util/logger' -import { ModalButtons } from '../buttons/ModalButtons' -import { AlertCardUi4 } from '../cards/AlertCard' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { QrPeephole } from '../common/QrPeephole' import { TextInputModal } from '../modals/TextInputModal' @@ -31,16 +25,12 @@ import { showError, showToast } from '../services/AirshipInstance' -import { checkAndRequestPermission } from '../services/PermissionsManager' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' -import { EdgeText, Paragraph } from '../themed/EdgeText' +import { EdgeText } from '../themed/EdgeText' import { ModalFooter } from '../themed/ModalParts' import { SceneHeaderUi4 } from '../themed/SceneHeaderUi4' -import { EdgeModal } from './EdgeModal' - -interface Props { - bridge: AirshipBridge +export interface ScanModalProps { // The initial ScanModal title scanModalTitle: string @@ -52,6 +42,19 @@ interface Props { textModalTitle?: string } +interface Props extends ScanModalProps { + bridge: AirshipBridge +} + +/** + * The QR scanner camera sheet. + * + * This assumes the camera permission has already been granted and any + * first-use scam warning acknowledged -- `showScanModal` in + * `src/actions/ScanActions.tsx` owns that sequencing. Branching on the + * permission here is what used to make the scam warning flash away the instant + * the OS prompt was answered. + */ export const ScanModal: React.FC = props => { const { bridge, @@ -62,7 +65,6 @@ export const ScanModal: React.FC = props => { scanModalTitle } = props - const dispatch = useDispatch() const theme = useTheme() const styles = getStyles(theme) @@ -76,7 +78,6 @@ export const ScanModal: React.FC = props => { handleBarCodeRead(codes) } }) - const cameraPermission = useSelector(state => state.permissions.camera) const [torchEnabled, setTorchEnabled] = React.useState(false) const [scanEnabled, setScanEnabled] = React.useState(false) @@ -88,13 +89,10 @@ export const ScanModal: React.FC = props => { // Mount effects React.useEffect(() => { setScanEnabled(true) - dispatch(checkAndRequestPermission('camera')).catch((error: unknown) => { - showError(error) - }) return () => { setScanEnabled(false) } - }, [dispatch]) + }, []) const handleBarCodeRead = (codes: Code[]): void => { setScanEnabled(false) @@ -102,11 +100,6 @@ export const ScanModal: React.FC = props => { bridge.resolve(codes[0].value) } - const handleSettings = async (): Promise => { - triggerHaptic('impactLight') - await Linking.openSettings() - } - const handleTextInput = async (): Promise => { triggerHaptic('impactLight') const uri = await Airship.show(bridge => ( @@ -276,8 +269,7 @@ export const ScanModal: React.FC = props => { ) } - return cameraPermission === RNPermissions.RESULTS.GRANTED || - cameraPermission === RNPermissions.RESULTS.LIMITED ? ( + return ( = props => { - ) : ( - - {lstrings.scan_camera_permission_denied} - - - ) } const getStyles = cacheStyles((theme: Theme) => ({ bottomSpace: { height: theme.rem(1.5) }, - cameraPermissionContainer: { - padding: theme.rem(0.5) - }, // Camera View cameraContainer: { position: 'absolute', diff --git a/src/components/modals/ScanScamWarningModal.tsx b/src/components/modals/ScanScamWarningModal.tsx new file mode 100644 index 00000000000..85b66203aec --- /dev/null +++ b/src/components/modals/ScanScamWarningModal.tsx @@ -0,0 +1,60 @@ +import * as React from 'react' +import type { AirshipBridge } from 'react-native-airship' +import { sprintf } from 'sprintf-js' + +import { lstrings } from '../../locales/strings' +import { config } from '../../theme/appConfig' +import { Paragraph, WarningText } from '../themed/EdgeText' +import { ConfirmContinueModal } from './ConfirmContinueModal' + +interface Props { + bridge: AirshipBridge +} + +/** + * Scam warning shown before the first use of the QR scanner camera. + * + * This is deliberately separate from the "enable Camera access" recovery + * modal, so that the two have independent lifecycles. `ConfirmContinueModal` + * is not skippable, so there is no backdrop tap, swipe, close button or + * hardware back that can dismiss this: the user has to tick the checkbox and + * confirm. + */ +export const ScanScamWarningModal: React.FC = props => { + const { bridge } = props + + // A non-skippable `ConfirmContinueModal` passes no `onCancel`, so `EdgeModal` + // ignores Airship's global `clear` event. Without this the modal would + // outlive a logout and leave the caller awaiting a promise that never + // settles: + React.useEffect( + () => + bridge.on('clear', () => { + bridge.resolve(false) + }), + [bridge] + ) + + const warningMessage = [ + sprintf(lstrings.warning_scam_message_financial_advice_s, config.appName), + lstrings.warning_scam_message_irreversibility, + lstrings.warning_scam_message_unknown_recipients + ] + .map(bullet => `• ${bullet}`) + .join('\n\n') + + return ( + + + {warningMessage} + + + {sprintf(lstrings.warning_scam_footer_s, config.supportEmail)} + + + ) +} diff --git a/src/components/scenes/WcConnectionsScene.tsx b/src/components/scenes/WcConnectionsScene.tsx index b4178d4762f..8f5060ae2c1 100644 --- a/src/components/scenes/WcConnectionsScene.tsx +++ b/src/components/scenes/WcConnectionsScene.tsx @@ -8,6 +8,7 @@ import AntDesignIcon from 'react-native-vector-icons/AntDesign' import { sprintf } from 'sprintf-js' import { checkAndShowLightBackupModal } from '../../actions/BackupModalActions' +import { showScanModal } from '../../actions/ScanActions' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' import { SPECIAL_CURRENCY_INFO } from '../../constants/WalletAndCurrencyConstants' import { useAsyncEffect } from '../../hooks/useAsyncEffect' @@ -18,12 +19,11 @@ import { walletConnectClient } from '../../hooks/useWalletConnect' import { lstrings } from '../../locales/strings' -import { useSelector } from '../../types/reactRedux' +import { useDispatch, useSelector } from '../../types/reactRedux' import type { EdgeAppSceneProps, NavigationBase } from '../../types/routerTypes' import type { EdgeAsset, WcConnectionInfo } from '../../types/types' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { SceneWrapper } from '../common/SceneWrapper' -import { ScanModal } from '../modals/ScanModal' import { WalletListModal, type WalletListResult @@ -41,7 +41,7 @@ export interface WcConnectionsParams { uri?: string } -export const WcConnectionsScene = (props: Props) => { +export const WcConnectionsScene: React.FC = props => { const { navigation, route } = props const { uri } = route.params ?? {} const theme = useTheme() @@ -53,11 +53,12 @@ export const WcConnectionsScene = (props: Props) => { >(new Map()) const account = useSelector(state => state.core.account) + const dispatch = useDispatch() const walletConnect = useWalletConnect() useMount(() => { if (uri != null) - onScanSuccess(uri).catch(err => { + onScanSuccess(uri).catch((err: unknown) => { showError(err) }) }) @@ -72,7 +73,7 @@ export const WcConnectionsScene = (props: Props) => { 'WcConnectionsScene' ) - const onScanSuccess = async (qrResult: string) => { + const onScanSuccess = async (qrResult: string): Promise => { setConnecting(true) try { let proposal = sessionProposal.get(qrResult) @@ -119,22 +120,23 @@ export const WcConnectionsScene = (props: Props) => { setConnecting(false) } - const handleActiveConnectionPress = (wcConnectionInfo: WcConnectionInfo) => { + const handleActiveConnectionPress = ( + wcConnectionInfo: WcConnectionInfo + ): void => { navigation.navigate('wcDisconnect', { wcConnectionInfo }) } - const handleNewConnectionPress = async () => { + const handleNewConnectionPress = async (): Promise => { if (checkAndShowLightBackupModal(account, navigation as NavigationBase)) { await Promise.resolve() } else { - const result = await Airship.show(bridge => ( - - )) + const result = await dispatch( + showScanModal({ + scanModalTitle: lstrings.scan_qr_label, + textModalHint: lstrings.wc_scan_modal_text_modal_hint, + textModalTitle: lstrings.wc_scan_modal_text_modal_title + }) + ) if (result != null) { await onScanSuccess(result) } diff --git a/src/components/themed/SideMenu.tsx b/src/components/themed/SideMenu.tsx index 0dece84236f..4e20eead521 100644 --- a/src/components/themed/SideMenu.tsx +++ b/src/components/themed/SideMenu.tsx @@ -33,6 +33,7 @@ import { navigateToGiftCards } from '../../actions/GiftCardActions' import { useNotifCount } from '../../actions/LocalSettingsActions' import { getRootNavigation, logoutRequest } from '../../actions/LoginActions' import { executePluginAction } from '../../actions/PluginActions' +import { showScanModal } from '../../actions/ScanActions' import { Fontello } from '../../assets/vector' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' import { ENV } from '../../env' @@ -52,7 +53,6 @@ import { styled } from '../hoc/styled' import { IconBadge } from '../icons/IconBadge' import { ChevronDownIcon, CloseIcon } from '../icons/ThemedIcons' import { ButtonsModal } from '../modals/ButtonsModal' -import { ScanModal } from '../modals/ScanModal' import { Airship, showError } from '../services/AirshipInstance' import { Services } from '../services/Services' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' @@ -164,16 +164,15 @@ export function SideMenuComponent(props: Props): React.ReactElement { const handleScanQr = (): void => { navigation.dispatch(DrawerActions.closeDrawer()) - Airship.show(bridge => ( - - )) + dispatch( + showScanModal({ + scanModalTitle: lstrings.scan_qr_label, + textModalAutoFocus: false, + textModalTitle: lstrings.enter_any_title, + textModalBody: lstrings.enter_any_body, + textModalHint: lstrings.enter_any_input_hint + }) + ) .then(async (result: string | undefined) => { if (result != null && result !== '') { const deepLink = parseDeepLink(result) diff --git a/src/components/tiles/AddressTile2.tsx b/src/components/tiles/AddressTile2.tsx index 4ce6c84385a..7483a5af4f8 100644 --- a/src/components/tiles/AddressTile2.tsx +++ b/src/components/tiles/AddressTile2.tsx @@ -14,12 +14,12 @@ import FontAwesome5 from 'react-native-vector-icons/FontAwesome5' import { sprintf } from 'sprintf-js' import { launchPaymentProto } from '../../actions/PaymentProtoActions' -import { addressWarnings } from '../../actions/ScanActions' +import { addressWarnings, showScanModal } from '../../actions/ScanActions' import { useHandler } from '../../hooks/useHandler' import { useMount } from '../../hooks/useMount' import { lstrings } from '../../locales/strings' import { PaymentProtoError } from '../../types/PaymentProtoError' -import { useSelector } from '../../types/reactRedux' +import { useDispatch, useSelector } from '../../types/reactRedux' import type { NavigationBase } from '../../types/routerTypes' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { parseDeepLink } from '../../util/DeepLinkParser' @@ -33,7 +33,6 @@ import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { AddressModal } from '../modals/AddressModal' import { showFullScreenSpinner } from '../modals/AirshipFullScreenSpinner' import { ConfirmContinueModal } from '../modals/ConfirmContinueModal' -import { ScanModal } from '../modals/ScanModal' import { WalletListModal, type WalletListResult @@ -148,6 +147,7 @@ export const AddressTile2 = React.forwardRef( // Selectors: const account = useSelector(state => state.core.account) + const dispatch = useDispatch() const fioPlugin = account.currencyConfig.fio const currencyCode = getCurrencyCode(coreWallet, tokenId) @@ -392,15 +392,14 @@ export const AddressTile2 = React.forwardRef( lstrings.send_scan_modal_text_modal_message_s, currencyCode ) - Airship.show(bridge => ( - - )) + dispatch( + showScanModal({ + scanModalTitle: lstrings.scan_qr_label, + textModalHint: lstrings.send_scan_modal_text_modal_hint, + textModalBody: message, + textModalTitle: title + }) + ) .then(async (result: string | undefined) => { if (result == null) return await changeAddress(result, 'scan') diff --git a/src/types/types.ts b/src/types/types.ts index 2ec12aa32be..5a482dd2eb6 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -205,6 +205,7 @@ export const asReviewTriggerData = asObject({ }) const asLocalAccountSettingsInner = asObject({ + cameraScamWarningShown: asMaybe(asBoolean, false), contactsPermissionShown: asMaybe(asBoolean, false), developerModeOn: asMaybe(asBoolean, false), notifState: asMaybe(asNotifState, asNotifState({})),