diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d78edfcd66..a8cef320a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- fixed: Keep the QR scanner scam warning on screen until the user dismisses it, instead of letting the camera permission prompt replace it. + ## 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 eb5c9ca5d92..f30ebb43785 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -219,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', diff --git a/src/components/modals/ScanModal.tsx b/src/components/modals/ScanModal.tsx index 5d5d2dc3578..41997649186 100644 --- a/src/components/modals/ScanModal.tsx +++ b/src/components/modals/ScanModal.tsx @@ -38,6 +38,12 @@ import { ModalFooter } from '../themed/ModalParts' import { SceneHeaderUi4 } from '../themed/SceneHeaderUi4' import { EdgeModal } from './EdgeModal' +/** + * How long the scam warning is on screen before the system camera permission + * prompt is requested on top of it. + */ +const PERMISSION_PROMPT_DELAY_MS = 750 + interface Props { bridge: AirshipBridge @@ -79,6 +85,13 @@ export const ScanModal: React.FC = props => { const cameraPermission = useSelector(state => state.permissions.camera) const [torchEnabled, setTorchEnabled] = React.useState(false) const [scanEnabled, setScanEnabled] = React.useState(false) + const [isPermissionResolved, setIsPermissionResolved] = React.useState(false) + const [isWarningAcknowledged, setIsWarningAcknowledged] = + React.useState(false) + + const isCameraAllowed = + cameraPermission === RNPermissions.RESULTS.GRANTED || + cameraPermission === RNPermissions.RESULTS.LIMITED const handleFlash = (): void => { triggerHaptic('impactLight') @@ -88,10 +101,22 @@ export const ScanModal: React.FC = props => { // Mount effects React.useEffect(() => { setScanEnabled(true) - dispatch(checkAndRequestPermission('camera')).catch((error: unknown) => { - showError(error) - }) + + // Show the scam warning first and give it a beat to land, so the system + // permission prompt covers a modal the user has already seen and the + // warning is still there once the prompt is dismissed. + const timeoutId = setTimeout(() => { + dispatch(checkAndRequestPermission('camera')) + .catch((error: unknown) => { + showError(error) + }) + .finally(() => { + setIsPermissionResolved(true) + }) + }, PERMISSION_PROMPT_DELAY_MS) + return () => { + clearTimeout(timeoutId) setScanEnabled(false) } }, [dispatch]) @@ -107,6 +132,11 @@ export const ScanModal: React.FC = props => { await Linking.openSettings() } + const handleAcknowledgeWarning = (): void => { + triggerHaptic('impactLight') + setIsWarningAcknowledged(true) + } + const handleTextInput = async (): Promise => { triggerHaptic('impactLight') const uri = await Airship.show(bridge => ( @@ -276,8 +306,18 @@ export const ScanModal: React.FC = props => { ) } - return cameraPermission === RNPermissions.RESULTS.GRANTED || - cameraPermission === RNPermissions.RESULTS.LIMITED ? ( + // The scam warning gates the scanner: it stays up until the user dismisses + // it, whichever way the camera permission prompt was answered. + const primaryButton = + isPermissionResolved && !isCameraAllowed + ? { onPress: handleSettings, label: lstrings.open_settings } + : { + onPress: handleAcknowledgeWarning, + label: lstrings.string_got_it, + disabled: !isCameraAllowed + } + + return isCameraAllowed && isWarningAcknowledged ? ( = props => { ) : ( - {lstrings.scan_camera_permission_denied} + {isPermissionResolved && !isCameraAllowed ? ( + {lstrings.scan_camera_permission_denied} + ) : null} = props => { ]} footer={sprintf(lstrings.warning_scam_footer_s, config.supportEmail)} /> - + ) }