Skip to content
Draft
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
29 changes: 28 additions & 1 deletion apps/mobile/src/components/QRScannerView/QRScannerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@

import { useStyles } from './styles'
import CameraOverlay from '@assets/images/camera-overlay.svg'
import { Modal } from 'react-native'
import { ActivityIndicator, Modal } from 'react-native'
import { useTheme } from '@rneui/themed'
import { Suspense, createRef, lazy, useCallback, useState } from 'react'
import { type NotifierRoot, NotifierWrapper } from 'react-native-notifier'
import { useLanguage } from '@hooks/useLanguage'
Expand Down Expand Up @@ -100,6 +101,7 @@ export const QRScannerView = (props: QRScannerViewProps) => {
const insets = useSafeAreaInsets()
const styles = useStyles(insets)
const { t } = useLanguage()
const { theme } = useTheme()

const [QRCameraScanner, setQRCameraScanner] = useState(() =>
createQRCameraScanner(),
Expand All @@ -116,6 +118,7 @@ export const QRScannerView = (props: QRScannerViewProps) => {
scanningEnabled,
permissionDenied,
hasPermission,
isHandling,
onBarcodeScanned,
onError,
} = useQRScannerView({
Expand Down Expand Up @@ -196,6 +199,30 @@ export const QRScannerView = (props: QRScannerViewProps) => {
>
{props.title ?? t('camera.find_qr.title')}
</PWText>
{/* Hand-rolled rather than PWLoadingOverlay: that
renders through PWOverlay → rneui Overlay, i.e.
its own Modal, and nesting a Modal inside this
one is the layering trap the comment above
describes.

Last in document order among the scanner's own
layers, so it covers the stilled camera frame —
but still before NotifierWrapper's toast, which
must stay on top. */}
{isHandling ? (
<PWView style={styles.handlingOverlay}>
<ActivityIndicator
size='large'
color={theme.colors.textWhite}
/>
<PWText
variant='body'
style={styles.handlingLabel}
>
{t('camera.handling_code')}
</PWText>
</PWView>
) : null}
</BaseErrorBoundary>
)}
</NotifierWrapper>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook } from '@testing-library/react'
import { renderHook, act } from '@testing-library/react'
import { useQRScannerView } from '../useQRScannerView'

const mockHandleDeepLink = vi.fn()
Expand Down Expand Up @@ -92,4 +92,65 @@ describe('useQRScannerView', () => {
result.current.onBarcodeScanned([{ rawValue: VALID_ADDRESS }])
expect(onSuccess).toHaveBeenCalledTimes(1)
})

// The camera stills as soon as a code is accepted, so without this flag a
// multi-second WalletConnect pairing looks like a hang (PERA-4748).
describe('isHandling', () => {
const renderScanner = (onSuccess = vi.fn()) =>
renderHook(() =>
useQRScannerView({
isVisible: true,
onSuccess,
skipDeepLinkHandler: false,
}),
)

it('is false before a scan', () => {
const { result } = renderScanner()
expect(result.current.isHandling).toBe(false)
})

it('is true while the deeplink is in flight', () => {
const { result } = renderScanner()
act(() => {
result.current.onBarcodeScanned([{ rawValue: VALID_ADDRESS }])
})
expect(result.current.isHandling).toBe(true)
})

it.each([
['failure', 3],
['success', 4],
['rejection', 5],
])(
'clears on %s so the frame is never left dimmed',
(_label, argIndex) => {
const { result } = renderScanner()
act(() => {
result.current.onBarcodeScanned([
{ rawValue: VALID_ADDRESS },
])
})
const callback = mockHandleDeepLink.mock.calls[0]?.[
argIndex
] as () => void
act(() => callback())
expect(result.current.isHandling).toBe(false)
},
)

it('stays false on the skipDeepLinkHandler path, which is synchronous', () => {
const { result } = renderHook(() =>
useQRScannerView({
isVisible: true,
onSuccess: vi.fn(),
skipDeepLinkHandler: true,
}),
)
act(() => {
result.current.onBarcodeScanned([{ rawValue: VALID_ADDRESS }])
})
expect(result.current.isHandling).toBe(false)
})
})
})
17 changes: 17 additions & 0 deletions apps/mobile/src/components/QRScannerView/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ export const useStyles = makeStyles((theme, insets: EdgeInsets) => {
marginTop: theme.spacing.xxl,
marginBottom: theme.spacing.xl,
},
handlingOverlay: {
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
gap: theme.spacing.lg,
// Dims the stilled camera frame so the spinner reads as the app
// working rather than the preview having died.
backgroundColor: theme.colors.backdropModalBg,
},
handlingLabel: {
color: theme.colors.textWhite,
textAlign: 'center',
},
icon: {
marginTop: insets.top,
marginLeft: theme.spacing.xl,
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/components/QRScannerView/useQRScannerView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ export const useQRScannerView = ({
const { hasPermission, requestPermission } = useCameraPermission()
const [scanningEnabled, setScanningEnabled] = useState(true)
const [permissionDenied, setPermissionDenied] = useState(false)
// Camera goes still the moment a code is accepted, and a WalletConnect
// pairing then spends seconds on the network before any of the callbacks
// below fire. Without this the frozen frame is the only feedback, so a slow
// connect is indistinguishable from a hang (PERA-4748).
//
// Set for every handled type, not just WalletConnect — the browser paths
// are also slow and ASSET_OPT_IN dispatches detached. Hence the neutral
// copy: most types resolve in a frame, so the overlay is a brief flash and
// must not claim to be "connecting" to anything.
const [isHandling, setIsHandling] = useState(false)

const { handleDeepLink, isValidDeepLink } = useDeepLink()

Expand All @@ -71,6 +81,7 @@ export const useQRScannerView = ({
handlingRef.current = false
setScanningEnabled(true)
}
setIsHandling(false)
}, [isVisible])

// Handlers passed down to QRCameraScanner, which wires them into the native
Expand Down Expand Up @@ -102,11 +113,13 @@ export const useQRScannerView = ({
// `replace` here would discard the screen the user was on,
// leaving destinations like Staking / AssetDetails with no
// back path.
setIsHandling(true)
void handleDeepLink(
url,
false,
'qr',
() => {
setIsHandling(false)
// Dispatcher toasted the failure where a toast
// applies (capability-gated CARDS/SELL drop silently —
// toast follow-up tracked). Close the
Expand All @@ -117,6 +130,7 @@ export const useQRScannerView = ({
onClose?.()
},
() => {
setIsHandling(false)
logger.debug(
'QRScannerView: Deep link handled successfully',
{ url },
Expand All @@ -131,11 +145,13 @@ export const useQRScannerView = ({
// network). The provider already surfaced a toast on
// this Modal's own notifier, so keep the scanner open
// and re-arm it for another scan instead of closing.
setIsHandling(false)
handlingRef.current = false
setScanningEnabled(true)
},
)
} catch (error) {
setIsHandling(false)
handlingRef.current = false
logger.error('QRScannerView: QR scanner error:', { error })
}
Expand Down Expand Up @@ -175,6 +191,7 @@ export const useQRScannerView = ({
hasPermission,
scanningEnabled,
permissionDenied,
isHandling,
device,
onBarcodeScanned,
onError,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@
}
},
"camera": {
"handling_code": "Just a moment...",
"no_camera_device_found": {
"title": "No camera device found or missing permissions.",
"body": "Please double check in your system settings that you have enabled camera permissions for the app."
Expand Down
Loading