From 8d590ed2cd624e6300dad45a697b715c2df66ace Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 19 May 2026 01:34:15 -0800 Subject: [PATCH 1/3] fix(qr-scan): gate CameraView on permission + surface unrecognized QR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related iOS bugs observed on a personal-team dev build: 1. CameraView mounted before permission resolved → black preview that never recovers until app restart. On iOS the component caches its permission state at mount; flipping null→granted at runtime doesn't re-init the preview. Gate the mount on `permission.granted === true` so the conditional flip causes a fresh mount with permission in place. 2. PeersDrawer's QR onResult silently dropped any QR that wasn't a plain LXMF hash — group QRs, Solana addresses, malformed/empty payloads all closed the modal with no feedback, leaving users thinking "scanner is broken." Surface explicit alerts: - lxmf-group → "looks like a channel QR, use Join channel" - solana → "wallet address, use Send screen" - unknown → show first 64 chars of raw payload so user can debug Also adds a __DEV__ console.log of the parsed result so we can adb logcat-trace exactly what came out of the scanner. --- .../components/messages/PeersDrawer.tsx | 19 +++++++++++++++++++ .../components/messages/QRScannerModal.tsx | 18 ++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/mobile_app/components/messages/PeersDrawer.tsx b/mobile_app/components/messages/PeersDrawer.tsx index cb8e4f65..57310354 100644 --- a/mobile_app/components/messages/PeersDrawer.tsx +++ b/mobile_app/components/messages/PeersDrawer.tsx @@ -323,8 +323,27 @@ export const PeersDrawer = memo(function PeersDrawer({ onClose={() => setScannerOpen(false)} onResult={result => { setScannerOpen(false); + if (__DEV__) console.log('[QR scan/PeersDrawer]', JSON.stringify(result)); if (result.type === 'lxmf') { onNewHash?.(result.hash); + } else if (result.type === 'lxmf-group') { + // Group QR scanned in peer-finder flow — route to join modal instead. + Alert.alert( + 'Looks like a channel QR', + 'Open "Join channel" and scan the same QR to join the channel.', + ); + } else if (result.type === 'solana') { + Alert.alert( + 'Solana address scanned', + 'This is a wallet address. Use the Send screen to send funds.', + ); + } else { + // unknown — surface the raw payload so the user can debug what they scanned + const preview = result.raw.length > 64 ? result.raw.slice(0, 64) + '…' : result.raw; + Alert.alert( + 'Unrecognized QR', + `Not a peer or channel QR. Scanned content:\n\n${preview}`, + ); } }} /> diff --git a/mobile_app/components/messages/QRScannerModal.tsx b/mobile_app/components/messages/QRScannerModal.tsx index 697639ef..be5ce2f9 100644 --- a/mobile_app/components/messages/QRScannerModal.tsx +++ b/mobile_app/components/messages/QRScannerModal.tsx @@ -117,7 +117,14 @@ export function QRScannerModal({ visible, onResult, onClose }: Props) { if (!visible) return null; - const denied = permission && !permission.granted && !permission.canAskAgain; + const denied = permission && !permission.granted && !permission.canAskAgain; + // Mount CameraView ONLY after permission is explicitly granted. On iOS the + // CameraView component caches its initial permission state at mount time — + // if we render it before the OS prompt resolves, the preview stays black + // even after the user taps "Allow," and an app restart is needed to recover. + // Gating on permission.granted means the flip from null→granted re-mounts + // CameraView fresh with the new permission in place. + const granted = permission?.granted === true; return ( @@ -129,13 +136,20 @@ export function QRScannerModal({ visible, onResult, onClose }: Props) { Camera permission denied.{'\n'}Enable it in Settings. - ) : ( + ) : granted ? ( + ) : ( + + + + Requesting camera access… + + )} {/* Viewfinder */} From 590ae74b92ca3e2746785189125d6d33d815a23f Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 20 May 2026 04:14:22 -0800 Subject: [PATCH 2/3] build(deps): add expo-audio as expo-camera 17 peer dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expo-camera@17 split audio recording into a separate package (expo-audio). iOS native side hard-links the ExpoAudio module even when the JS-level use is barcode-scanning only, so the dev-client build fails with "Cannot find native module expoAudio" without the peer dep installed. `npx expo install expo-audio` added 1.1.1 to deps and registered the config plugin in app.json. No source code changes — this is pure native bridge availability. --- mobile_app/app.json | 3 ++- mobile_app/package-lock.json | 14 ++++++++++++++ mobile_app/package.json | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/mobile_app/app.json b/mobile_app/app.json index 32110b2d..16a957b2 100644 --- a/mobile_app/app.json +++ b/mobile_app/app.json @@ -105,7 +105,8 @@ ], "@magicred-1/react-native-lxmf", "./plugins/withAndroidForegroundService", - "expo-web-browser" + "expo-web-browser", + "expo-audio" ], "experiments": { "typedRoutes": true, diff --git a/mobile_app/package-lock.json b/mobile_app/package-lock.json index 4ee904e2..e5110c77 100644 --- a/mobile_app/package-lock.json +++ b/mobile_app/package-lock.json @@ -29,6 +29,7 @@ "bs58": "^6.0.0", "buffer": "^6.0.3", "expo": "~54.0.34", + "expo-audio": "~1.1.1", "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", @@ -7607,6 +7608,7 @@ "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz", "integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==", "license": "MIT", + "peer": true, "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.13" @@ -7617,6 +7619,18 @@ "react-native": "*" } }, + "node_modules/expo-audio": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-1.1.1.tgz", + "integrity": "sha512-CPCpJ+0AEHdzWROc0f00Zh6e+irLSl2ALos/LPvxEeIcJw1APfBa4DuHPkL4CQCWsVe7EnUjFpdwpqsEUWcP0g==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "expo-asset": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-camera": { "version": "17.0.10", "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz", diff --git a/mobile_app/package.json b/mobile_app/package.json index 63f07bce..95852ebc 100644 --- a/mobile_app/package.json +++ b/mobile_app/package.json @@ -38,6 +38,7 @@ "bs58": "^6.0.0", "buffer": "^6.0.3", "expo": "~54.0.34", + "expo-audio": "~1.1.1", "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", From 36dfd23119a33ff94d1b481d85015dcdc8bac56c Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Mon, 25 May 2026 01:25:25 -0800 Subject: [PATCH 3/3] refactor(scanner): present QR scanner as a route, not a nested modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QRScannerModal was a free-floating . Rendered inside the join-channel sheet (itself a ) it stacked two native iOS windows — black camera preview, and corrupted touch handling on dismiss (frozen screen, camera never released). A single modal (Send, peers drawer) worked; only nesting broke. - app/scan.tsx route + scan() promise API (src/services/qrScan), usable identically from any screen or modal; one camera/permission lifecycle; re-entrancy-guarded so overlapping calls can't stack routes. - Convert the join-channel sheet to app/join-channel.tsx (transparentModal owning its slide, like receive) so the scanner composes over it natively — no nested-modal conflict. - Update RecipientPicker, PeersDrawer, and the join flow to await scan(). Delete QRScannerModal and JoinGroupModal. --- mobile_app/app/_layout.tsx | 8 + mobile_app/app/join-channel.tsx | 236 ++++++++++++++++++ mobile_app/app/scan.tsx | 161 ++++++++++++ .../components/messages/JoinGroupModal.tsx | 232 ----------------- .../components/messages/PeersDrawer.tsx | 63 +++-- .../components/messages/QRScannerModal.tsx | 227 ----------------- .../components/send/RecipientPicker.tsx | 43 ++-- mobile_app/screens/MessagesScreen.tsx | 15 +- mobile_app/src/services/qrScan.ts | 103 ++++++++ 9 files changed, 560 insertions(+), 528 deletions(-) create mode 100644 mobile_app/app/join-channel.tsx create mode 100644 mobile_app/app/scan.tsx delete mode 100644 mobile_app/components/messages/JoinGroupModal.tsx delete mode 100644 mobile_app/components/messages/QRScannerModal.tsx create mode 100644 mobile_app/src/services/qrScan.ts diff --git a/mobile_app/app/_layout.tsx b/mobile_app/app/_layout.tsx index 838e2018..61f7db80 100644 --- a/mobile_app/app/_layout.tsx +++ b/mobile_app/app/_layout.tsx @@ -112,6 +112,14 @@ function AppShell() { + {/* QR scanner — a route, not a , so it presents above + everything (including bottom-sheet modals) without stacking + native windows. Driven by scan() in src/services/qrScan. */} + + {/* Join channel — a transparentModal route that owns its slide + animation (same pattern as receive), so the scanner route can + compose over it without a nested- conflict. */} + diff --git a/mobile_app/app/join-channel.tsx b/mobile_app/app/join-channel.tsx new file mode 100644 index 00000000..6c8f2bd6 --- /dev/null +++ b/mobile_app/app/join-channel.tsx @@ -0,0 +1,236 @@ +import { router } from 'expo-router'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Animated, BackHandler, View, Text, TextInput, Pressable, + StyleSheet, ActivityIndicator, Platform, Keyboard, +} from 'react-native'; +import { Feather } from '@expo/vector-icons'; +import { fontFamily, useTheme } from '@/theme'; +import { useGlass } from '@/hooks/useGlass'; +import { useLxmfContext } from '@/context/LxmfContext'; +import { scan } from '@/src/services/qrScan'; + +const ADDR_RE = /^[0-9a-fA-F]{32}$/; +const KEY_RE = /^[0-9a-fA-F]{32}$/; + +// Join-channel as a route, not a . It owns its slide-up/down animation +// exactly like app/receive.tsx: presentation 'transparentModal' + animation +// 'none' (set in _layout) means there's no native modal motion to fight. +// Being a route is what lets the QR scanner route (scan()) compose cleanly on +// top — pushing a route over a route never conflicts, so the old "hide the +// sheet while scanning" workaround is gone. The screen behind stays mounted +// underneath; popping the scanner reveals this sheet instantly. +export default function JoinChannelScreen() { + const { colors } = useTheme(); + const baseGlass = useGlass(); + const softGlass = useGlass('soft'); + const { joinGroup } = useLxmfContext(); + + const [addrHex, setAddrHex] = useState(''); + const [keyHex, setKeyHex] = useState(''); + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const sheetAnim = useRef(new Animated.Value(0)).current; + const kbOffset = useRef(new Animated.Value(0)).current; + const closingRef = useRef(false); + + // Enter: slide up on mount. + useEffect(() => { + Animated.spring(sheetAnim, { toValue: 1, useNativeDriver: true, bounciness: 4 }).start(); + }, [sheetAnim]); + + // Keyboard avoidance — lift the sheet by the keyboard height. + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const show = Keyboard.addListener(showEvent, e => { + Animated.timing(kbOffset, { + toValue: e.endCoordinates.height, + duration: Platform.OS === 'ios' ? e.duration : 150, + useNativeDriver: false, + }).start(); + }); + const hide = Keyboard.addListener(hideEvent, e => { + Animated.timing(kbOffset, { + toValue: 0, + duration: Platform.OS === 'ios' ? e.duration : 150, + useNativeDriver: false, + }).start(); + }); + return () => { show.remove(); hide.remove(); }; + }, [kbOffset]); + + // Exit: slide down, then pop the route. animation:'none' on the route means + // router.back() is instant — the slide-down is the only motion the user sees. + const dismiss = useCallback(() => { + if (closingRef.current) return; + closingRef.current = true; + Keyboard.dismiss(); + Animated.timing(sheetAnim, { toValue: 0, duration: 220, useNativeDriver: true }).start(() => { + if (router.canGoBack()) router.back(); + }); + }, [sheetAnim]); + + // Android hardware back → animated dismiss (not an instant pop). + useEffect(() => { + const sub = BackHandler.addEventListener('hardwareBackPress', () => { dismiss(); return true; }); + return () => sub.remove(); + }, [dismiss]); + + async function handleScan() { + const r = await scan(); + if (!r) return; + if (r.type === 'lxmf-group') { + setAddrHex(r.addrHex); + setKeyHex(r.keyHex); + const nm = r.name; + if (nm) setName(prev => (prev ? prev : nm)); + setError(null); + } else if (r.type === 'lxmf') { + setAddrHex(r.hash); + setError(null); + } + } + + const addrOk = ADDR_RE.test(addrHex.trim()); + const keyOk = KEY_RE.test(keyHex.trim()); + const canJoin = addrOk && keyOk && !loading; + + async function handleJoin() { + if (!canJoin) return; + setError(null); + setLoading(true); + try { + const ok = await joinGroup(addrHex.trim(), keyHex.trim(), name.trim() || undefined); + if (ok) { dismiss(); } + else { setError('could not join — check address and key'); } + } catch { + setError('join failed — try again'); + } finally { + setLoading(false); + } + } + + const sheetY = sheetAnim.interpolate({ inputRange: [0, 1], outputRange: [500, 0], extrapolate: 'clamp' }); + const overlayOp = sheetAnim.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }); + + return ( + + + + + + + + + + + + CHANNELS + join channel + + + + + + + + {/* Prominent QR scan button */} + + + SCAN QR CODE + + + + + OR ENTER MANUALLY + + + + CHANNEL ADDRESS + + { setAddrHex(t); setError(null); }} + autoCapitalize="none" + autoCorrect={false} + /> + + + ENCRYPTION KEY + + { setKeyHex(t); setError(null); }} + autoCapitalize="none" + autoCorrect={false} + /> + + + + NICKNAME{' '}(optional) + + + + + + + {!!error && ( + {error} + )} + + + {loading + ? + : JOIN CHANNEL + } + + + + + + ); +} + +const S = StyleSheet.create({ + scanBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 14, borderRadius: 12 }, + scanBtnText: { fontFamily: fontFamily.sansMd, fontSize: 12, fontWeight: '600', letterSpacing: 2, textTransform: 'uppercase' }, + orRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + orLine: { flex: 1, height: 0.5 }, + orText: { fontFamily: fontFamily.sansMd, fontSize: 9, letterSpacing: 1.5, textTransform: 'uppercase' }, + sheetWrap: { position: 'absolute', bottom: 0, left: 0, right: 0 }, + sheet: { borderRadius: 20, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, padding: 14, paddingBottom: 32, borderWidth: 0.5 }, + grab: { width: 36, height: 4, borderRadius: 99, alignSelf: 'center', marginBottom: 14 }, + header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }, + tag: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2, textTransform: 'uppercase' }, + title: { fontSize: 18, marginTop: 4, letterSpacing: -0.3 }, + closeBtn: { width: 30, height: 30, borderRadius: 15, alignItems: 'center', justifyContent: 'center' }, + fieldLabel: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2, textTransform: 'uppercase' }, + inputRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: Platform.OS === 'ios' ? 13 : 10, borderRadius: 12 }, + input: { flex: 1, fontSize: 14, fontFamily: fontFamily.sansMd, padding: 0 }, + hint: { fontFamily: fontFamily.sansMd, fontSize: 10.5, letterSpacing: 0.2 }, + actionBtn: { padding: 13, borderRadius: 12, alignItems: 'center' }, + actionBtnText: { fontFamily: fontFamily.sansMd, fontSize: 11, fontWeight: '600', letterSpacing: 2.5, textTransform: 'uppercase' }, +}); diff --git a/mobile_app/app/scan.tsx b/mobile_app/app/scan.tsx new file mode 100644 index 00000000..12f8a4b8 --- /dev/null +++ b/mobile_app/app/scan.tsx @@ -0,0 +1,161 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState, View, Text, Pressable, StyleSheet, Platform } from 'react-native'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { Feather } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import { fontFamily, useTheme } from '@/theme'; +import { parseScannedAddress, resolveScan, type ScannedAddress } from '@/src/services/qrScan'; + +// Full-screen QR scanner route. Presented via `scan()` (src/services/qrScan). +// Being a navigator route — not a nested — is what makes this safe to +// open from anywhere, including from inside a bottom-sheet modal, without the +// stacked-window camera/touch bug that a nested causes on iOS. +export default function ScanScreen() { + const { colors } = useTheme(); + const [permission, requestPermission, getPermission] = useCameraPermissions(); + const scannedRef = useRef(false); + const settledRef = useRef(false); + const flashTimer = useRef | null>(null); + const [label, setLabel] = useState(null); + + // Resolve the pending scan() promise exactly once, then leave the route. + const finish = useCallback((result: ScannedAddress | null) => { + if (settledRef.current) return; + settledRef.current = true; + resolveScan(result); + if (router.canGoBack()) router.back(); + }, []); + + // Safety net: if the route unmounts without an explicit outcome (swipe- or + // hardware-back), still resolve null so the caller's promise never hangs. + useEffect(() => () => { + if (flashTimer.current) clearTimeout(flashTimer.current); + if (!settledRef.current) { settledRef.current = true; resolveScan(null); } + }, []); + + useEffect(() => { + if (permission && !permission.granted && permission.canAskAgain) requestPermission(); + }, [permission, requestPermission]); + + // Re-read permission when returning to foreground (deny → Settings → grant + // → back). getPermission reads OS state silently — no prompt. + useEffect(() => { + const sub = AppState.addEventListener('change', (next) => { + if (next === 'active') getPermission(); + }); + return () => sub.remove(); + }, [getPermission]); + + const onBarcodeScanned = useCallback(({ data }: { data: string }) => { + if (scannedRef.current) return; + scannedRef.current = true; + + const result = parseScannedAddress(data); + const hint = + result.type === 'lxmf' ? `LXMF · ${result.hash.slice(0, 8)}…` : + result.type === 'lxmf-group' ? `channel · ${result.name ?? result.addrHex.slice(0, 8)}…` : + result.type === 'solana' ? `Solana · ${result.address.slice(0, 8)}…` : + 'unknown format'; + setLabel(hint); + + // Brief flash of the recognized label, then hand the result back. + flashTimer.current = setTimeout(() => finish(result), 350); + }, [finish]); + + const denied = permission && !permission.granted && !permission.canAskAgain; + const granted = permission?.granted === true; + + return ( + + {denied ? ( + + + + Camera permission denied.{'\n'}Enable it in Settings. + + + ) : granted ? ( + + ) : ( + + + + Requesting camera access… + + + )} + + {/* Viewfinder */} + + + + + + + + {label && ( + + {label} + + )} + + + POINT AT A QR CODE + + + { if (!scannedRef.current) finish(null); }} hitSlop={12}> + + + + ); +} + +const CORNER = 28; +const BORDER = 3; + +const S = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#000' }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, paddingHorizontal: 32 }, + deniedText:{ fontFamily: fontFamily.sansMd, fontSize: 13, textAlign: 'center', lineHeight: 20 }, + + overlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', justifyContent: 'center', + }, + corner: { + position: 'absolute', width: CORNER, height: CORNER, + borderColor: '#fff', + top: '35%', left: '20%', + borderTopWidth: BORDER, borderLeftWidth: BORDER, borderRadius: 4, + }, + cornerTR: { left: undefined, right: '20%', borderLeftWidth: 0, borderRightWidth: BORDER }, + cornerBL: { top: undefined, bottom: '35%', borderTopWidth: 0, borderBottomWidth: BORDER }, + cornerBR: { top: undefined, bottom: '35%', left: undefined, right: '20%', borderTopWidth: 0, borderLeftWidth: 0, borderBottomWidth: BORDER, borderRightWidth: BORDER }, + + labelWrap: { + position: 'absolute', bottom: '38%', left: 0, right: 0, + alignItems: 'center', + }, + labelText: { + fontFamily: fontFamily.sansMd, fontSize: 12, letterSpacing: 1.5, + color: '#fff', backgroundColor: 'rgba(0,0,0,0.55)', + paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8, + }, + hint: { + position: 'absolute', bottom: 100, left: 0, right: 0, alignItems: 'center', + }, + hintText: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2.5, color: 'rgba(255,255,255,0.55)' }, + close: { + position: 'absolute', + top: Platform.OS === 'ios' ? 60 : 40, + right: 20, + width: 38, height: 38, borderRadius: 19, + backgroundColor: 'rgba(0,0,0,0.45)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/mobile_app/components/messages/JoinGroupModal.tsx b/mobile_app/components/messages/JoinGroupModal.tsx deleted file mode 100644 index 99f124be..00000000 --- a/mobile_app/components/messages/JoinGroupModal.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { - Modal, View, Text, TextInput, Pressable, - StyleSheet, Animated, ActivityIndicator, Platform, Keyboard, -} from 'react-native'; -import { Feather } from '@expo/vector-icons'; -import { fontFamily, useTheme } from '@/theme'; -import { useGlass } from '@/hooks/useGlass'; -import { QRScannerModal } from './QRScannerModal'; - -interface Props { - readonly visible: boolean; - readonly onClose: () => void; - readonly onJoin: (addrHex: string, keyHex: string, name?: string) => Promise; -} - -const ADDR_RE = /^[0-9a-fA-F]{32}$/; -const KEY_RE = /^[0-9a-fA-F]{32}$/; - -export function JoinGroupModal({ visible, onClose, onJoin }: Props) { - const { colors } = useTheme(); - const baseGlass = useGlass(); - const softGlass = useGlass('soft'); - - const [addrHex, setAddrHex] = useState(''); - const [keyHex, setKeyHex] = useState(''); - const [name, setName] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [scanner, setScanner] = useState(false); - - const sheetAnim = useRef(new Animated.Value(0)).current; - const kbOffset = useRef(new Animated.Value(0)).current; - - useEffect(() => { - if (visible) { - Animated.spring(sheetAnim, { toValue: 1, useNativeDriver: true, bounciness: 4 }).start(); - } - }, [visible, sheetAnim]); - - useEffect(() => { - const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; - const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; - const show = Keyboard.addListener(showEvent, e => { - Animated.timing(kbOffset, { - toValue: e.endCoordinates.height, - duration: Platform.OS === 'ios' ? e.duration : 150, - useNativeDriver: false, - }).start(); - }); - const hide = Keyboard.addListener(hideEvent, e => { - Animated.timing(kbOffset, { - toValue: 0, - duration: Platform.OS === 'ios' ? e.duration : 150, - useNativeDriver: false, - }).start(); - }); - return () => { show.remove(); hide.remove(); }; - }, [kbOffset]); - - const addrOk = ADDR_RE.test(addrHex.trim()); - const keyOk = KEY_RE.test(keyHex.trim()); - const canJoin = addrOk && keyOk && !loading; - - async function handleJoin() { - if (!canJoin) return; - setError(null); - setLoading(true); - try { - const ok = await onJoin(addrHex.trim(), keyHex.trim(), name.trim() || undefined); - if (ok) { dismiss(); } - else { setError('could not join — check address and key'); } - } catch { - setError('join failed — try again'); - } finally { - setLoading(false); - } - } - - function dismiss() { - Keyboard.dismiss(); - Animated.timing(sheetAnim, { toValue: 0, duration: 220, useNativeDriver: true }).start(() => { - setAddrHex(''); - setKeyHex(''); - setName(''); - setError(null); - onClose(); - }); - } - - const sheetY = sheetAnim.interpolate({ inputRange: [0, 1], outputRange: [500, 0], extrapolate: 'clamp' }); - const overlayOp = sheetAnim.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }); - - return ( - <> - - - - - - - - - - - - - CHANNELS - join channel - - - - - - - - {/* Prominent QR scan button */} - setScanner(true)} style={[S.scanBtn, baseGlass]}> - - SCAN QR CODE - - - - - OR ENTER MANUALLY - - - - CHANNEL ADDRESS - - { setAddrHex(t); setError(null); }} - autoCapitalize="none" - autoCorrect={false} - /> - - - ENCRYPTION KEY - - { setKeyHex(t); setError(null); }} - autoCapitalize="none" - autoCorrect={false} - /> - - - - NICKNAME{' '}(optional) - - - - - - - {!!error && ( - {error} - )} - - - {loading - ? - : JOIN CHANNEL - } - - - - - - - - setScanner(false)} - onResult={r => { - setScanner(false); - if (r.type === 'lxmf-group') { - setAddrHex(r.addrHex); - setKeyHex(r.keyHex); - if (r.name && !name) setName(r.name); - setError(null); - } else if (r.type === 'lxmf') { - setAddrHex(r.hash); - setError(null); - } - }} - /> - - ); -} - -const S = StyleSheet.create({ - scanBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 14, borderRadius: 12 }, - scanBtnText: { fontFamily: fontFamily.sansMd, fontSize: 12, fontWeight: '600', letterSpacing: 2, textTransform: 'uppercase' }, - orRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, - orLine: { flex: 1, height: 0.5 }, - orText: { fontFamily: fontFamily.sansMd, fontSize: 9, letterSpacing: 1.5, textTransform: 'uppercase' }, - sheetWrap: { position: 'absolute', bottom: 0, left: 0, right: 0 }, - sheet: { borderRadius: 20, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, padding: 14, paddingBottom: 32, borderWidth: 0.5 }, - grab: { width: 36, height: 4, borderRadius: 99, alignSelf: 'center', marginBottom: 14 }, - header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }, - tag: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2, textTransform: 'uppercase' }, - title: { fontSize: 18, marginTop: 4, letterSpacing: -0.3 }, - closeBtn: { width: 30, height: 30, borderRadius: 15, alignItems: 'center', justifyContent: 'center' }, - fieldLabel: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2, textTransform: 'uppercase' }, - inputRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: Platform.OS === 'ios' ? 13 : 10, borderRadius: 12 }, - input: { flex: 1, fontSize: 14, fontFamily: fontFamily.sansMd, padding: 0 }, - hint: { fontFamily: fontFamily.sansMd, fontSize: 10.5, letterSpacing: 0.2 }, - actionBtn: { padding: 13, borderRadius: 12, alignItems: 'center' }, - actionBtnText: { fontFamily: fontFamily.sansMd, fontSize: 11, fontWeight: '600', letterSpacing: 2.5, textTransform: 'uppercase' }, -}); diff --git a/mobile_app/components/messages/PeersDrawer.tsx b/mobile_app/components/messages/PeersDrawer.tsx index 57310354..f3b9920c 100644 --- a/mobile_app/components/messages/PeersDrawer.tsx +++ b/mobile_app/components/messages/PeersDrawer.tsx @@ -10,7 +10,7 @@ import { fontFamily, useTheme } from '@/theme'; import { Pill } from '@/components/ui/Pill'; import { Skeleton } from '@/components/ui/Skeleton'; import { useGlass } from '../../hooks/useGlass'; -import { QRScannerModal } from './QRScannerModal'; +import { scan } from '@/src/services/qrScan'; import { type Peer } from './constants'; interface Props { @@ -144,8 +144,34 @@ export const PeersDrawer = memo(function PeersDrawer({ const dmPeers = peers.filter(p => !p.isGroup); const online = dmPeers.filter(p => p.online).length; - const [input, setInput] = useState(''); - const [scannerOpen, setScannerOpen] = useState(false); + const [input, setInput] = useState(''); + + const handleScan = useCallback(async () => { + const result = await scan(); + if (!result) return; + if (__DEV__) console.log('[QR scan/PeersDrawer]', JSON.stringify(result)); + if (result.type === 'lxmf') { + onNewHash?.(result.hash); + } else if (result.type === 'lxmf-group') { + // Group QR scanned in peer-finder flow — route to join modal instead. + Alert.alert( + 'Looks like a channel QR', + 'Open "Join channel" and scan the same QR to join the channel.', + ); + } else if (result.type === 'solana') { + Alert.alert( + 'Solana address scanned', + 'This is a wallet address. Use the Send screen to send funds.', + ); + } else { + // unknown — surface the raw payload so the user can debug what they scanned + const preview = result.raw.length > 64 ? result.raw.slice(0, 64) + '…' : result.raw; + Alert.alert( + 'Unrecognized QR', + `Not a peer or channel QR. Scanned content:\n\n${preview}`, + ); + } + }, [onNewHash]); const isHash = /^[0-9a-fA-F]{16,}$/.test(input.trim()); const canStart = isHash; @@ -198,7 +224,7 @@ export const PeersDrawer = memo(function PeersDrawer({ )} - setScannerOpen(true)} hitSlop={8}> + @@ -318,35 +344,6 @@ export const PeersDrawer = memo(function PeersDrawer({ )} - setScannerOpen(false)} - onResult={result => { - setScannerOpen(false); - if (__DEV__) console.log('[QR scan/PeersDrawer]', JSON.stringify(result)); - if (result.type === 'lxmf') { - onNewHash?.(result.hash); - } else if (result.type === 'lxmf-group') { - // Group QR scanned in peer-finder flow — route to join modal instead. - Alert.alert( - 'Looks like a channel QR', - 'Open "Join channel" and scan the same QR to join the channel.', - ); - } else if (result.type === 'solana') { - Alert.alert( - 'Solana address scanned', - 'This is a wallet address. Use the Send screen to send funds.', - ); - } else { - // unknown — surface the raw payload so the user can debug what they scanned - const preview = result.raw.length > 64 ? result.raw.slice(0, 64) + '…' : result.raw; - Alert.alert( - 'Unrecognized QR', - `Not a peer or channel QR. Scanned content:\n\n${preview}`, - ); - } - }} - /> ); }); diff --git a/mobile_app/components/messages/QRScannerModal.tsx b/mobile_app/components/messages/QRScannerModal.tsx deleted file mode 100644 index be5ce2f9..00000000 --- a/mobile_app/components/messages/QRScannerModal.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { AppState, Modal, View, Text, Pressable, StyleSheet, Platform } from 'react-native'; -import { CameraView, useCameraPermissions } from 'expo-camera'; -import { Feather } from '@expo/vector-icons'; -import { fontFamily, useTheme } from '@/theme'; -import { parseSolanaPayUri } from '@/src/services/solanaPayUri'; - -export type ScannedAddress = - | { type: 'lxmf'; hash: string } - | { type: 'lxmf-group'; addrHex: string; keyHex: string; name?: string } - | { - type: 'solana'; - address: string; - amount?: string; - splToken?: string; - label?: string; - message?: string; - memo?: string; - reference?: string[]; - } - | { type: 'unknown'; raw: string }; - -// 32-byte LXMF/Reticulum address = 64 hex chars (raw) or 32 (short hash shown in UI) -const LXMF_RE = /^[0-9a-f]{32}([0-9a-f]{32})?$/i; -const HEX32_RE = /^[0-9a-fA-F]{32}$/; - -function parse(raw: string): ScannedAddress { - const s = raw.trim(); - - // lxmf://group//?name= - if (s.startsWith('lxmf://group/')) { - const rest = s.slice('lxmf://group/'.length); - const [body, query] = rest.split('?') as [string, string | undefined]; - const parts = body.split('/'); - const addrHex = parts[0] ?? ''; - const keyHex = parts[1] ?? ''; - if (HEX32_RE.test(addrHex) && HEX32_RE.test(keyHex)) { - const nameParam = query?.split('&').find(p => p.startsWith('name='))?.slice(5); - const name = nameParam ? decodeURIComponent(nameParam) : undefined; - return { type: 'lxmf-group', addrHex: addrHex.toLowerCase(), keyHex: keyHex.toLowerCase(), name }; - } - } - - if (s.startsWith('lxmf://') || s.startsWith('reticulum://')) { - const hash = s.split('://')[1]?.split('?')[0] ?? ''; - if (LXMF_RE.test(hash)) return { type: 'lxmf', hash }; - } - - if (LXMF_RE.test(s)) return { type: 'lxmf', hash: s.toLowerCase() }; - - // Solana Pay URI or bare base58 — single source of truth in solanaPayUri.ts. - const pay = parseSolanaPayUri(s); - if (pay) { - return { - type: 'solana', - address: pay.recipient, - amount: pay.amount, - splToken: pay.splToken, - label: pay.label, - message: pay.message, - memo: pay.memo, - reference: pay.reference, - }; - } - - return { type: 'unknown', raw: s }; -} - -interface Props { - readonly visible: boolean; - readonly onResult: (result: ScannedAddress) => void; - readonly onClose: () => void; -} - -export function QRScannerModal({ visible, onResult, onClose }: Props) { - const { colors } = useTheme(); - const [permission, requestPermission, getPermission] = useCameraPermissions(); - const scannedRef = useRef(false); - const [label, setLabel] = useState(null); - - useEffect(() => { - if (visible) { scannedRef.current = false; setLabel(null); } - }, [visible]); - - useEffect(() => { - if (visible && permission && !permission.granted && permission.canAskAgain) { - requestPermission(); - } - }, [visible, permission, requestPermission]); - - // Refresh permission state when the app returns to foreground while the - // scanner is open — covers the "deny → open Settings → grant → return" - // flow. getPermission reads OS state silently (no prompt), so this is a - // no-op when nothing changed and grants live without a re-mount. - useEffect(() => { - if (!visible) return; - const sub = AppState.addEventListener('change', (next) => { - if (next === 'active') getPermission(); - }); - return () => sub.remove(); - }, [visible, getPermission]); - - const onBarcodeScanned = useCallback(({ data }: { data: string }) => { - if (scannedRef.current) return; - scannedRef.current = true; - - const result = parse(data); - const hint = - result.type === 'lxmf' ? `LXMF · ${result.hash.slice(0, 8)}…` : - result.type === 'lxmf-group' ? `channel · ${result.name ?? result.addrHex.slice(0, 8)}…` : - result.type === 'solana' ? `Solana · ${result.address.slice(0, 8)}…` : - 'unknown format'; - setLabel(hint); - - setTimeout(() => { onResult(result); }, 350); - }, [onResult]); - - if (!visible) return null; - - const denied = permission && !permission.granted && !permission.canAskAgain; - // Mount CameraView ONLY after permission is explicitly granted. On iOS the - // CameraView component caches its initial permission state at mount time — - // if we render it before the OS prompt resolves, the preview stays black - // even after the user taps "Allow," and an app restart is needed to recover. - // Gating on permission.granted means the flip from null→granted re-mounts - // CameraView fresh with the new permission in place. - const granted = permission?.granted === true; - - return ( - - - {denied ? ( - - - - Camera permission denied.{'\n'}Enable it in Settings. - - - ) : granted ? ( - - ) : ( - - - - Requesting camera access… - - - )} - - {/* Viewfinder */} - - - - - - - - {/* Label */} - {label && ( - - {label} - - )} - - {/* Instructions */} - - POINT AT A QR CODE - - - {/* Close */} - - - - - - ); -} - -const CORNER = 28; -const BORDER = 3; - -const S = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#000' }, - center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, paddingHorizontal: 32 }, - deniedText:{ fontFamily: fontFamily.sansMd, fontSize: 13, textAlign: 'center', lineHeight: 20 }, - - overlay: { - ...StyleSheet.absoluteFillObject, - alignItems: 'center', justifyContent: 'center', - }, - corner: { - position: 'absolute', width: CORNER, height: CORNER, - borderColor: '#fff', - top: '35%', left: '20%', - borderTopWidth: BORDER, borderLeftWidth: BORDER, borderRadius: 4, - }, - cornerTR: { left: undefined, right: '20%', borderLeftWidth: 0, borderRightWidth: BORDER }, - cornerBL: { top: undefined, bottom: '35%', borderTopWidth: 0, borderBottomWidth: BORDER }, - cornerBR: { top: undefined, bottom: '35%', left: undefined, right: '20%', borderTopWidth: 0, borderLeftWidth: 0, borderBottomWidth: BORDER, borderRightWidth: BORDER }, - - labelWrap: { - position: 'absolute', bottom: '38%', left: 0, right: 0, - alignItems: 'center', - }, - labelText: { - fontFamily: fontFamily.sansMd, fontSize: 12, letterSpacing: 1.5, - color: '#fff', backgroundColor: 'rgba(0,0,0,0.55)', - paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8, - }, - hint: { - position: 'absolute', bottom: 100, left: 0, right: 0, alignItems: 'center', - }, - hintText: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2.5, color: 'rgba(255,255,255,0.55)' }, - close: { - position: 'absolute', - top: Platform.OS === 'ios' ? 60 : 40, - right: 20, - width: 38, height: 38, borderRadius: 19, - backgroundColor: 'rgba(0,0,0,0.45)', - alignItems: 'center', justifyContent: 'center', - }, -}); diff --git a/mobile_app/components/send/RecipientPicker.tsx b/mobile_app/components/send/RecipientPicker.tsx index 1088139e..a27d1388 100644 --- a/mobile_app/components/send/RecipientPicker.tsx +++ b/mobile_app/components/send/RecipientPicker.tsx @@ -15,7 +15,7 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { QRScannerModal } from "@/components/messages/QRScannerModal"; +import { scan } from "@/src/services/qrScan"; import { DepthButton, TokenLogo } from "@/components/primitives"; import { TokenPicker, tokenByName } from "@/components/send/TokenPicker"; import type { TokenOption } from "@/components/send/TokenPicker"; @@ -93,7 +93,6 @@ export function RecipientPicker() { const [address, setAddress] = useState(typeof params.to === "string" ? params.to : ""); const [selectedSymbol, setSelectedSymbol] = useState("SOL"); const [pickerOpen, setPickerOpen] = useState(false); - const [scannerOpen, setScannerOpen] = useState(false); const [poisonAck, setPoisonAck] = useState(false); const { tokens } = useWalletBalance(); const { entries: addressBook, deleteRecipient } = useAddressBook(); @@ -138,9 +137,24 @@ export function RecipientPicker() { pushToAmount(trimmedAddress); } - function handleScan() { + async function handleScan() { haptics.tap(); - setScannerOpen(true); + const result = await scan(); + if (!result) return; + if (result.type !== "solana") { + Alert.alert( + "QR not recognised", + "Scan a Solana address or a Solana Pay code.", + ); + return; + } + haptics.confirm(); + handleAddressChange(result.address); + // SPL send is gated off (TokenPicker.isSendable allows SOL only), + // so ignore amount when an spl-token mint was specified. + if (result.amount && !result.splToken) { + pushToAmount(result.address, result.amount); + } } function handleSelectToken(next: TokenOption) { @@ -452,27 +466,6 @@ export function RecipientPicker() { onClose={() => setPickerOpen(false)} /> - setScannerOpen(false)} - onResult={(result) => { - setScannerOpen(false); - if (result.type !== "solana") { - Alert.alert( - "QR not recognised", - "Scan a Solana address or a Solana Pay code.", - ); - return; - } - haptics.confirm(); - handleAddressChange(result.address); - // SPL send is gated off (TokenPicker.isSendable allows SOL only), - // so ignore amount when an spl-token mint was specified. - if (result.amount && !result.splToken) { - pushToAmount(result.address, result.amount); - } - }} - /> ); } diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx index bec0875a..2096c17e 100644 --- a/mobile_app/screens/MessagesScreen.tsx +++ b/mobile_app/screens/MessagesScreen.tsx @@ -1,7 +1,7 @@ import "@/polyfills"; import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; -import { useFocusEffect, useLocalSearchParams } from 'expo-router'; +import { router, useFocusEffect, useLocalSearchParams } from 'expo-router'; import { View, ScrollView, Text, Image, TouchableOpacity, StyleSheet, KeyboardAvoidingView, Platform, Keyboard, Dimensions, @@ -24,7 +24,6 @@ import { Composer } from '@/components/messages/Composer'; import { ThreadHeader } from '@/components/messages/ThreadHeader'; import { PeersDrawer } from '@/components/messages/PeersDrawer'; import { CreateGroupModal } from '@/components/messages/CreateGroupModal'; -import { JoinGroupModal } from '@/components/messages/JoinGroupModal'; import { ChannelShareSheet } from '@/components/messages/ChannelShareSheet'; import { GroupMembersSheet } from '@/components/messages/GroupMembersSheet'; import { type Peer } from '@/components/messages/constants'; @@ -259,7 +258,7 @@ export default function MessagesScreen() { const { isRunning, displayName, peers: lxmfPeers, events, send, getDisplayName, getPeerIdentity, getPeerMessages, myAddress, - groups, createGroup, joinGroup, leaveGroup, getGroupMembers, + groups, createGroup, leaveGroup, getGroupMembers, } = useLxmfContext(); const insets = useSafeAreaInsets(); @@ -270,7 +269,6 @@ export default function MessagesScreen() { const [activePeerHex, setActivePeerHex] = useState(null); const [actionGridVisible, setActionGridVisible] = useState(false); const [createGroupVisible, setCreateGroupVisible] = useState(false); - const [joinGroupVisible, setJoinGroupVisible] = useState(false); const [shareSheetOpen, setShareSheetOpen] = useState(false); const [membersSheetOpen, setMembersSheetOpen] = useState(false); const [seqStates, setSeqStates] = useState>(new Map()); @@ -650,7 +648,7 @@ export default function MessagesScreen() { colors={colors} bottomInset={insets.bottom} onCreateGroup={() => setCreateGroupVisible(true)} - onJoinGroup={() => setJoinGroupVisible(true)} + onJoinGroup={() => router.push('/join-channel')} /> ) : ( setCreateGroupVisible(true)} - onJoinGroup={() => setJoinGroupVisible(true)} + onJoinGroup={() => router.push('/join-channel')} onLeaveGroup={addrHex => leaveGroup(addrHex)} /> )} @@ -735,11 +733,6 @@ export default function MessagesScreen() { onClose={() => setCreateGroupVisible(false)} onCreate={createGroup} /> - setJoinGroupVisible(false)} - onJoin={joinGroup} - /> setShareSheetOpen(false)} diff --git a/mobile_app/src/services/qrScan.ts b/mobile_app/src/services/qrScan.ts new file mode 100644 index 00000000..7c8fce48 --- /dev/null +++ b/mobile_app/src/services/qrScan.ts @@ -0,0 +1,103 @@ +import { router } from 'expo-router'; +import { parseSolanaPayUri } from './solanaPayUri'; + +// Result of a QR scan. Discriminated on `type` so callers narrow safely. +export type ScannedAddress = + | { type: 'lxmf'; hash: string } + | { type: 'lxmf-group'; addrHex: string; keyHex: string; name?: string } + | { + type: 'solana'; + address: string; + amount?: string; + splToken?: string; + label?: string; + message?: string; + memo?: string; + reference?: string[]; + } + | { type: 'unknown'; raw: string }; + +// 32-byte LXMF/Reticulum address = 64 hex chars (raw) or 32 (short hash shown in UI) +const LXMF_RE = /^[0-9a-f]{32}([0-9a-f]{32})?$/i; +const HEX32_RE = /^[0-9a-fA-F]{32}$/; + +export function parseScannedAddress(raw: string): ScannedAddress { + const s = raw.trim(); + + // lxmf://group//?name= + if (s.startsWith('lxmf://group/')) { + const rest = s.slice('lxmf://group/'.length); + const [body, query] = rest.split('?') as [string, string | undefined]; + const parts = body.split('/'); + const addrHex = parts[0] ?? ''; + const keyHex = parts[1] ?? ''; + if (HEX32_RE.test(addrHex) && HEX32_RE.test(keyHex)) { + const nameParam = query?.split('&').find(p => p.startsWith('name='))?.slice(5); + const name = nameParam ? decodeURIComponent(nameParam) : undefined; + return { type: 'lxmf-group', addrHex: addrHex.toLowerCase(), keyHex: keyHex.toLowerCase(), name }; + } + } + + if (s.startsWith('lxmf://') || s.startsWith('reticulum://')) { + const hash = s.split('://')[1]?.split('?')[0] ?? ''; + if (LXMF_RE.test(hash)) return { type: 'lxmf', hash }; + } + + if (LXMF_RE.test(s)) return { type: 'lxmf', hash: s.toLowerCase() }; + + // Solana Pay URI or bare base58 — single source of truth in solanaPayUri.ts. + const pay = parseSolanaPayUri(s); + if (pay) { + return { + type: 'solana', + address: pay.recipient, + amount: pay.amount, + splToken: pay.splToken, + label: pay.label, + message: pay.message, + memo: pay.memo, + reference: pay.reference, + }; + } + + return { type: 'unknown', raw: s }; +} + +// ── Scanner presentation ────────────────────────────────────────────────── +// The scanner is a navigator route (`app/scan.tsx`), never a nested . +// That is the whole point: a core RN opens a separate native window, +// so rendering one inside another (e.g. a scanner inside a bottom-sheet modal) +// stacks two windows and breaks the camera preview + touch handling on iOS. +// Presenting via the navigator means there is exactly one scanner, mounted +// above everything, callable identically from any screen OR modal. + +let pendingResolve: ((result: ScannedAddress | null) => void) | null = null; +let pendingPromise: Promise | null = null; + +/** + * Open the full-screen QR scanner and resolve with the scanned result, or + * `null` if the user dismissed it (close button, back gesture, hardware back). + * Always resolves — the route's unmount guarantees it. + * + * Re-entrancy guard: there is exactly one scanner route at a time. A second + * call while one is already in flight (double-tap, overlapping flows) returns + * the SAME promise instead of pushing a duplicate `/scan` route — pushing a + * second route would orphan a ghost camera on the stack and let the + * module-global resolver cross-resolve the wrong caller with null. + */ +export function scan(): Promise { + if (pendingPromise) return pendingPromise; + pendingPromise = new Promise((resolve) => { + pendingResolve = resolve; + }); + router.push('/scan'); + return pendingPromise; +} + +/** Called by the scanner route to deliver its outcome exactly once. */ +export function resolveScan(result: ScannedAddress | null): void { + const resolve = pendingResolve; + pendingResolve = null; + pendingPromise = null; + resolve?.(result); +}