diff --git a/apps/mobile/src/components/QRScannerView/QRScannerView.tsx b/apps/mobile/src/components/QRScannerView/QRScannerView.tsx index b6a504e639..75d506ab02 100644 --- a/apps/mobile/src/components/QRScannerView/QRScannerView.tsx +++ b/apps/mobile/src/components/QRScannerView/QRScannerView.tsx @@ -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' @@ -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(), @@ -116,6 +118,7 @@ export const QRScannerView = (props: QRScannerViewProps) => { scanningEnabled, permissionDenied, hasPermission, + isHandling, onBarcodeScanned, onError, } = useQRScannerView({ @@ -196,6 +199,30 @@ export const QRScannerView = (props: QRScannerViewProps) => { > {props.title ?? t('camera.find_qr.title')} + {/* 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 ? ( + + + + {t('camera.handling_code')} + + + ) : null} )} diff --git a/apps/mobile/src/components/QRScannerView/__tests__/useQRScannerView.spec.ts b/apps/mobile/src/components/QRScannerView/__tests__/useQRScannerView.spec.ts index 9fe59e4c0e..98ee14856a 100644 --- a/apps/mobile/src/components/QRScannerView/__tests__/useQRScannerView.spec.ts +++ b/apps/mobile/src/components/QRScannerView/__tests__/useQRScannerView.spec.ts @@ -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() @@ -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) + }) + }) }) diff --git a/apps/mobile/src/components/QRScannerView/styles.ts b/apps/mobile/src/components/QRScannerView/styles.ts index bdc8789f16..0983b3ea5d 100644 --- a/apps/mobile/src/components/QRScannerView/styles.ts +++ b/apps/mobile/src/components/QRScannerView/styles.ts @@ -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, diff --git a/apps/mobile/src/components/QRScannerView/useQRScannerView.ts b/apps/mobile/src/components/QRScannerView/useQRScannerView.ts index 63b1af13e8..2520eaf9ea 100644 --- a/apps/mobile/src/components/QRScannerView/useQRScannerView.ts +++ b/apps/mobile/src/components/QRScannerView/useQRScannerView.ts @@ -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() @@ -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 @@ -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 @@ -117,6 +130,7 @@ export const useQRScannerView = ({ onClose?.() }, () => { + setIsHandling(false) logger.debug( 'QRScannerView: Deep link handled successfully', { url }, @@ -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 }) } @@ -175,6 +191,7 @@ export const useQRScannerView = ({ hasPermission, scanningEnabled, permissionDenied, + isHandling, device, onBarcodeScanned, onError, diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 47359bba39..8d5967bdab 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -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."