Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 2 additions & 3 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/actions/RequestReviewActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const defaultReviewTrigger: ReviewTriggerData = {
daysSinceUpgrade: []
}
const defaultSettings: LocalAccountSettings = {
cameraScamWarningShown: false,
contactsPermissionShown: false,
developerModeOn: false,
isAccountBalanceVisible: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ exports[`initialState 1`] = `
"ip2FaNotifShown": false,
},
"autoLogoutTimeInSeconds": 3600,
"cameraScamWarningShown": false,
"changesLocked": true,
"contactsPermissionShown": false,
"countryCode": "",
Expand Down
12 changes: 12 additions & 0 deletions src/actions/LocalSettingsActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalAccountSettings> => {
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
Expand Down
75 changes: 75 additions & 0 deletions src/actions/ScanActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -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:'

Expand Down Expand Up @@ -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<void> => {
if (AppState.currentState === 'active') return
await new Promise<void>(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<Promise<string | undefined>> =>
async (dispatch, getState) => {
const status = await dispatch(checkAndRequestPermission('camera'))
if (
status !== RNPermissions.RESULTS.GRANTED &&
status !== RNPermissions.RESULTS.LIMITED
) {
await Airship.show(bridge => (
<CameraPermissionDeniedModal bridge={bridge} />
))
return undefined
}

await waitForForeground()

const account = getState().core.account
const { cameraScamWarningShown } = await getLocalAccountSettings(account)
if (!cameraScamWarningShown) {
const acknowledged = await Airship.show<boolean>(bridge => (
<ScanScamWarningModal bridge={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<string | undefined>(bridge => (
<ScanModal bridge={bridge} {...props} />
))
}
44 changes: 44 additions & 0 deletions src/components/modals/CameraPermissionDeniedModal.tsx
Original file line number Diff line number Diff line change
@@ -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<void>
}

/**
* 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> = props => {
const { bridge } = props

const handleClose = (): void => {
bridge.resolve()
}

const handleSettings = (): void => {
openSettings().catch((error: unknown) => {
showError(error)
})
handleClose()
}

return (
<EdgeModal bridge={bridge} onCancel={handleClose}>
<Paragraph>{lstrings.scan_camera_permission_denied}</Paragraph>
<ModalButtons
primary={{ onPress: handleSettings, label: lstrings.open_settings }}
/>
</EdgeModal>
)
}
67 changes: 18 additions & 49 deletions src/components/modals/ScanModal.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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'
Expand All @@ -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<string | undefined>

export interface ScanModalProps {
// The initial ScanModal title
scanModalTitle: string

Expand All @@ -52,6 +42,19 @@ interface Props {
textModalTitle?: string
}

interface Props extends ScanModalProps {
bridge: AirshipBridge<string | undefined>
}

/**
* 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> = props => {
const {
bridge,
Expand All @@ -62,7 +65,6 @@ export const ScanModal: React.FC<Props> = props => {
scanModalTitle
} = props

const dispatch = useDispatch()
const theme = useTheme()
const styles = getStyles(theme)

Expand All @@ -76,7 +78,6 @@ export const ScanModal: React.FC<Props> = props => {
handleBarCodeRead(codes)
}
})
const cameraPermission = useSelector(state => state.permissions.camera)
const [torchEnabled, setTorchEnabled] = React.useState(false)
const [scanEnabled, setScanEnabled] = React.useState(false)

Expand All @@ -88,25 +89,17 @@ export const ScanModal: React.FC<Props> = 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)
triggerHaptic('impactLight')
bridge.resolve(codes[0].value)
}

const handleSettings = async (): Promise<void> => {
triggerHaptic('impactLight')
await Linking.openSettings()
}

const handleTextInput = async (): Promise<void> => {
triggerHaptic('impactLight')
const uri = await Airship.show<string | undefined>(bridge => (
Expand Down Expand Up @@ -276,8 +269,7 @@ export const ScanModal: React.FC<Props> = props => {
)
}

return cameraPermission === RNPermissions.RESULTS.GRANTED ||
cameraPermission === RNPermissions.RESULTS.LIMITED ? (
return (
<AirshipModal
bridge={bridge}
margin={[airshipMarginTop, 0, 0]}
Expand All @@ -291,34 +283,11 @@ export const ScanModal: React.FC<Props> = props => {
<ModalFooter onPress={handleClose} />
<View style={styles.bottomSpace} />
</AirshipModal>
) : (
<EdgeModal bridge={bridge} onCancel={handleClose}>
<Paragraph>{lstrings.scan_camera_permission_denied}</Paragraph>
<AlertCardUi4
title={lstrings.warning_scam_title}
type="warning"
body={[
sprintf(
lstrings.warning_scam_message_financial_advice_s,
config.appName
),
lstrings.warning_scam_message_irreversibility,
lstrings.warning_scam_message_unknown_recipients
]}
footer={sprintf(lstrings.warning_scam_footer_s, config.supportEmail)}
/>
<ModalButtons
primary={{ onPress: handleSettings, label: lstrings.open_settings }}
/>
</EdgeModal>
)
}

const getStyles = cacheStyles((theme: Theme) => ({
bottomSpace: { height: theme.rem(1.5) },
cameraPermissionContainer: {
padding: theme.rem(0.5)
},
// Camera View
cameraContainer: {
position: 'absolute',
Expand Down
Loading
Loading