import { useState, useRef, useCallback } from 'react' import { View, Text, StyleSheet, Pressable, ActivityIndicator, Linking } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { CameraView, useCameraPermissions } from 'expo-camera' import { useRouter } from 'expo-router' import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native' import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing' import { connect } from '../src/transport/rpc-client' import { saveHost, getNextHostName } from '../src/transport/host-store' import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { TextInputModal } from '../src/components/TextInputModal' import { ConnectionLog } from '../src/components/ConnectionLog' // Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken // route surfaces as a real error with the log visible instead of a // silent infinite spinner. const PAIRING_OVERALL_TIMEOUT_MS = 25_000 function Step({ number, text }: { number: number; text: string }) { return ( {number} {text} ) } export default function PairScanScreen() { const router = useRouter() const insets = useSafeAreaInsets() const [permission, requestPermission] = useCameraPermissions() const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning') const [errorMessage, setErrorMessage] = useState('') const [pasteVisible, setPasteVisible] = useState(false) const [logs, setLogs] = useState([]) const logsRef = useRef([]) const processingRef = useRef(false) const handleBarCodeScanned = useCallback( ({ data }: { data: string }) => { if (processingRef.current) return processingRef.current = true const offer = decodePairingUrl(data) if (!offer) { setStatus('error') setErrorMessage('Not a valid Orca QR code') processingRef.current = false return } void testAndSave(offer) }, [router] ) const handlePasteSubmit = useCallback((input: string) => { setPasteVisible(false) if (processingRef.current) return processingRef.current = true const offer = parsePairingCode(input) if (!offer) { setStatus('error') setErrorMessage('Not a valid pairing code — copy it from your computer and paste again') processingRef.current = false return } void testAndSave(offer) }, []) async function testAndSave(offer: PairingOffer) { setStatus('connecting') logsRef.current = [] setLogs([]) let client: ReturnType | null = null // Why: split the try/catch around the network call vs the local save // so a Keychain or AsyncStorage failure doesn't masquerade as a // "Cannot connect — same network?" error. Pairing reached the // desktop fine; the failure is local persistence. let response: RpcResponse let timedOut = false const overallTimer = setTimeout(() => { timedOut = true client?.close() }, PAIRING_OVERALL_TIMEOUT_MS) try { client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, { onLog: (entry) => { logsRef.current = [...logsRef.current, entry] setLogs(logsRef.current) } }) response = await client.sendRequest('status.get') clearTimeout(overallTimer) client.close() client = null } catch (err) { clearTimeout(overallTimer) console.warn('[pair] connect failed', err) setStatus('error') setErrorMessage( timedOut ? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled` : 'Cannot connect — check that your computer is on the same network' ) processingRef.current = false client?.close() return } if (!response.ok) { if (response.error.code === 'unauthorized') { setStatus('error') setErrorMessage('Authentication failed — token may be expired') processingRef.current = false return } setStatus('error') setErrorMessage(`Server error: ${response.error.message}`) processingRef.current = false return } try { const hostId = `host-${Date.now()}` const hostName = await getNextHostName() await saveHost({ id: hostId, name: hostName, endpoint: offer.endpoint, deviceToken: offer.deviceToken, publicKeyB64: offer.publicKeyB64, lastConnected: Date.now() }) router.replace(`/h/${hostId}`) } catch (err) { console.warn('[pair] save failed', err) setStatus('error') setErrorMessage( `Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}` ) processingRef.current = false } } function retry() { setStatus('scanning') setErrorMessage('') logsRef.current = [] setLogs([]) processingRef.current = false } // Why: bottom inset accounts for Android 3-button nav bars and iOS // home-indicator areas that would otherwise overlap the 'Or paste // pairing code' button at the bottom of the scan screen. const containerPadding = { paddingTop: insets.top + spacing.sm, paddingBottom: insets.bottom + spacing.sm } if (!permission) { return ( ) } if (!permission.granted) { const canAskAgain = permission.canAskAgain !== false return ( router.back()}> {canAskAgain ? 'Pair with desktop' : 'Camera Access Disabled'} {canAskAgain ? 'Scan the QR code from Orca on your desktop, or paste the pairing code instead.' : 'Enable camera access in Settings, or paste the pairing code instead.'} void Linking.openSettings()} > {canAskAgain && } {canAskAgain ? 'Continue' : 'Open Settings'} [styles.pasteButton, pressed && styles.pasteButtonPressed]} onPress={() => setPasteVisible(true)} > Paste code instead setPasteVisible(false)} /> ) } return ( router.back()}> {status === 'scanning' && ( <> {/* Why: unmount the camera while the paste sheet is open. The user has clearly chosen the paste path; keeping the camera streaming behind a sheet wastes power and looks weird if they cancel the sheet and the QR was scanned silently in the meantime. */} {!pasteVisible && ( )} {pasteVisible && } [styles.pasteButton, pressed && styles.pasteButtonPressed]} onPress={() => setPasteVisible(true)} > Or paste pairing code )} {status === 'connecting' && ( Connecting… )} {status === 'error' && ( {errorMessage} {logs.length > 0 && ( )} Try Again [ styles.secondaryButton, pressed && styles.pasteButtonPressed ]} onPress={() => { retry() setPasteVisible(true) }} > Paste code instead )} setPasteVisible(false)} /> ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgBase, padding: spacing.lg }, backButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', marginBottom: spacing.sm }, steps: { gap: spacing.sm, marginBottom: spacing.lg, marginLeft: 7 }, step: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, stepBadge: { width: 22, height: 22, borderRadius: 11, backgroundColor: colors.bgRaised, alignItems: 'center', justifyContent: 'center' }, stepNumber: { fontSize: 12, fontWeight: '700', color: colors.textSecondary }, stepText: { fontSize: typography.bodySize, color: colors.textSecondary }, cameraWrap: { flex: 1, borderRadius: radii.camera, overflow: 'hidden' }, // Why: holds the layout slot while the camera is unmounted during // paste, so the bottom action button doesn't snap up to fill the // empty space. cameraPlaceholder: { flex: 1, backgroundColor: colors.bgPanel, borderRadius: radii.camera }, camera: { ...StyleSheet.absoluteFillObject }, reticle: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' }, corner: { position: 'absolute', width: 28, height: 28, borderColor: 'rgba(255,255,255,0.7)' }, cornerTL: { top: '30%', left: '20%', borderTopWidth: 2.5, borderLeftWidth: 2.5, borderTopLeftRadius: 6 }, cornerTR: { top: '30%', right: '20%', borderTopWidth: 2.5, borderRightWidth: 2.5, borderTopRightRadius: 6 }, cornerBL: { bottom: '30%', left: '20%', borderBottomWidth: 2.5, borderLeftWidth: 2.5, borderBottomLeftRadius: 6 }, cornerBR: { bottom: '30%', right: '20%', borderBottomWidth: 2.5, borderRightWidth: 2.5, borderBottomRightRadius: 6 }, centered: { flex: 1, alignItems: 'center', justifyContent: 'center' }, title: { fontSize: typography.titleSize, fontWeight: '600', color: colors.textPrimary, marginBottom: spacing.sm }, subtitle: { maxWidth: 310, fontSize: typography.bodySize, color: colors.textSecondary, textAlign: 'center', marginBottom: spacing.xl, lineHeight: 20 }, connectingText: { color: colors.textSecondary, fontSize: typography.bodySize, marginTop: spacing.lg }, logSlot: { width: '100%', marginTop: spacing.lg, paddingHorizontal: spacing.sm }, errorText: { color: colors.statusRed, fontSize: typography.bodySize, textAlign: 'center', marginBottom: spacing.xl, lineHeight: 20 }, primaryButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.xs, backgroundColor: colors.textPrimary, paddingHorizontal: spacing.xl, paddingVertical: spacing.sm + 2, borderRadius: radii.button }, primaryButtonText: { color: colors.bgBase, fontSize: typography.bodySize, fontWeight: '600' }, pasteButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.xs, marginTop: spacing.md, paddingVertical: spacing.sm, borderRadius: radii.button }, pasteButtonPressed: { opacity: 0.6 }, pasteButtonText: { color: colors.textSecondary, fontSize: typography.bodySize, fontWeight: '500' }, errorActions: { alignItems: 'center', gap: spacing.sm }, secondaryButton: { paddingHorizontal: spacing.xl, paddingVertical: spacing.sm, borderRadius: radii.button }, secondaryButtonText: { color: colors.textSecondary, fontSize: typography.bodySize, fontWeight: '500' } })