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
19 changes: 16 additions & 3 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,27 @@ export default [
],
message:
'Avoid importing react-native-vector-icons directly. Use a shared icon from src/components/icons/ThemedIcons or the VectorIcon component so color and size stay theme-driven, and add a new definition there if one is missing.'
},
{
group: ['**/ScanModal', './ScanModal'],
message:
'Do not mount ScanModal directly. Use `showScanModal` from src/actions/ScanActions, which requests the camera permission and shows the first-use scam warning before the scanner opens. Mounting it directly skips both.'
}
]
}
]
}
},

// `showScanModal` is the sanctioned entry point, so it is the one place
// allowed to import ScanModal:
{
files: ['src/actions/ScanActions.tsx'],
rules: {
'no-restricted-imports': 'off'
}
},

// Allow the shared icon wrappers to import react-native-vector-icons
// directly, since they are the components that re-export it:
{
Expand Down Expand Up @@ -129,7 +143,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 +233,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 +323,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
96 changes: 96 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,89 @@ export function checkAndShowGetCryptoModal(
}
}
}

/**
* How long to wait for the app to return to the foreground before giving up
* and carrying on regardless. Without a bound, an app that never comes back
* would leave the caller awaiting a promise that never settles.
*/
const FOREGROUND_TIMEOUT_MS = 5000

/**
* Resolves once the app is in the foreground, or after
* `FOREGROUND_TIMEOUT_MS`, whichever comes first.
*
* 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

let subscription: ReturnType<typeof AppState.addEventListener> | undefined
let timeout: ReturnType<typeof setTimeout> | undefined
try {
// Whichever settles first wins; resolving twice is a no-op:
await new Promise<void>(resolve => {
subscription = AppState.addEventListener('change', state => {
if (state === 'active') resolve()
})
timeout = setTimeout(resolve, FOREGROUND_TIMEOUT_MS)
})
} finally {
if (timeout != null) clearTimeout(timeout)
if (subscription != null) subscription.remove()
}
}

/**
* 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'))

// Answering the prompt returns the app to the foreground. Wait for that
// before presenting anything of our own, on either branch -- the recovery
// modal is just as capable of flashing past as the warning is:
await waitForForeground()

if (
status !== RNPermissions.RESULTS.GRANTED &&
status !== RNPermissions.RESULTS.LIMITED
) {
await Airship.show(bridge => (
<CameraPermissionDeniedModal bridge={bridge} />
))
return undefined
}

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
// Best-effort: a logout between the acknowledgement and the write would
// reject, and re-showing the warning next time beats an error banner:
await writeCameraScamWarningShown(account).catch(() => {})
}

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>
)
}
Loading
Loading