diff --git a/packages/mobile/HANDOFF.md b/packages/mobile/HANDOFF.md index 92fa01e7..47b16577 100644 --- a/packages/mobile/HANDOFF.md +++ b/packages/mobile/HANDOFF.md @@ -81,9 +81,10 @@ iOS Simulator helpers: `mobile:sim:{boot,install,launch,run,serve,list,kill}` (s - **Connection onboarding** — server URL entry, password unlock for locked servers, client-token issuance, saved connections, `Instances` management sheet, auto-connect to the last instance on launch. Deleting the active instance resets the runtime to the connect screen. -- **QR pairing** — `@capacitor-mlkit/barcode-scanning`. Android's Google code scanner module is - downloaded on first scan (needs Play Services + network); `mobileQrScan.ts` installs/awaits it - and retries. CAMERA permission + `NSCameraUsageDescription` declared. +- **QR pairing** — `@capacitor-mlkit/barcode-scanning`. Android uses the CameraX-backed + `startScan()` flow with the barcode model bundled in the app, so scanning works offline and + without Google Play Services. iOS uses the plugin's native scanner. CAMERA permission + + `NSCameraUsageDescription` declared. - **Secure storage** — `@aparajita/capacitor-secure-storage` for connection tokens. - **Deep links** — `openchamber://` URL scheme; a reusable intent vocabulary (`apps/deepLinks.ts`) used by notification taps, widgets, and Control Center. Cold-launch intents are stashed. @@ -128,8 +129,7 @@ iOS Simulator helpers: `mobile:sim:{boot,install,launch,run,serve,list,kill}` (s brings `firebase-messaging`. - Manifest: permissions `INTERNET`, `CAMERA` (+ optional camera feature), `POST_NOTIFICATIONS` (Android 13+; older versions allow notifications by default). `windowSoftInputMode=adjustResize`. - ML Kit `com.google.mlkit.vision.DEPENDENCIES=barcode_ui` meta (preloads the code scanner). FCM - `default_notification_icon=@drawable/ic_stat_notify`. + FCM `default_notification_icon=@drawable/ic_stat_notify`. - Adaptive launcher icon: full-bleed color background + `ic_launcher_foreground` (sources under `packages/mobile/assets/`, regenerable with `@capacitor/assets`). diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml index d00b87da..dba5d6b5 100644 --- a/packages/mobile/android/app/src/main/AndroidManifest.xml +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -35,13 +35,6 @@ - - - (null); const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); // QR pairing is the primary flow; the manual URL form stays collapsed unless // scanning is unavailable (web build) or the user asks for it. @@ -60,19 +63,27 @@ export const MobileConnectionWelcome: React.FC<{ }, [conn]); const handleScanQr = React.useCallback(async () => { - if (isScanning || isBusy) return; + if (scanAbortRef.current || isBusy) return; conn.setError(null); setIsScanning(true); + const controller = new AbortController(); + scanAbortRef.current = controller; try { - const result = await scanConnectionQr(); + const result = await scanConnectionQr({ signal: controller.signal }); + if (scanAbortRef.current === controller) { + scanAbortRef.current = null; + setIsScanning(false); + } switch (result.status) { case 'ok': + setIsCompletingScan(true); setServerUrl(result.url); if (result.label) setConnectionName(result.label); if (result.clientToken) setClientToken(result.clientToken); await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label }); break; case 'pairing': + setIsCompletingScan(true); await conn.redeemPairingConnection(result.pairing); break; case 'permission-denied': @@ -92,9 +103,15 @@ export const MobileConnectionWelcome: React.FC<{ break; } } finally { - setIsScanning(false); + setIsCompletingScan(false); + if (scanAbortRef.current === controller) { + scanAbortRef.current = null; + setIsScanning(false); + } } - }, [conn, isBusy, isScanning, t]); + }, [conn, isBusy, t]); + + React.useEffect(() => () => scanAbortRef.current?.abort(), []); const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { event.preventDefault(); @@ -107,6 +124,9 @@ export const MobileConnectionWelcome: React.FC<{ }, [conn]); return ( + <> + {isScanning ? scanAbortRef.current?.abort()} /> : null} + {isCompletingScan ? : null}
@@ -297,5 +317,6 @@ export const MobileConnectionWelcome: React.FC<{ )}
+ ); }; diff --git a/packages/ui/src/apps/MobileInstancesSurface.tsx b/packages/ui/src/apps/MobileInstancesSurface.tsx index f8a9a318..21aa230d 100644 --- a/packages/ui/src/apps/MobileInstancesSurface.tsx +++ b/packages/ui/src/apps/MobileInstancesSurface.tsx @@ -9,6 +9,7 @@ import { cn } from '@/lib/utils'; import { connectionDisplayUrl, isActiveRuntimeConnection, useMobileConnection } from './mobileConnections'; import { isQrScanSupported, scanConnectionQr } from './mobileQrScan'; import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi'; +import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay'; export const MobileInstancesSurface: React.FC<{ onConnect: () => void; @@ -28,6 +29,8 @@ export const MobileInstancesSurface: React.FC<{ const [clientToken, setClientToken] = React.useState(''); const [password, setPassword] = React.useState(''); const [isScanning, setIsScanning] = React.useState(false); + const [isCompletingScan, setIsCompletingScan] = React.useState(false); + const scanAbortRef = React.useRef(null); const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); // The manual add/edit form is hidden until asked for — the sheet leads with // the list of instances (with live status), not a wall of inputs. @@ -61,11 +64,17 @@ export const MobileInstancesSurface: React.FC<{ // Scan a pairing QR into the add/edit form fields (does not change edit mode, so // the form-reset effect doesn't wipe the scanned values). The user reviews + saves. const handleScanInstance = React.useCallback(async () => { - if (isScanning) return; + if (scanAbortRef.current) return; setError(null); setIsScanning(true); + const controller = new AbortController(); + scanAbortRef.current = controller; try { - const result = await scanConnectionQr(); + const result = await scanConnectionQr({ signal: controller.signal }); + if (scanAbortRef.current === controller) { + scanAbortRef.current = null; + setIsScanning(false); + } switch (result.status) { case 'ok': // Legacy token QR: prefill the manual form for review before saving. @@ -75,6 +84,7 @@ export const MobileInstancesSurface: React.FC<{ setFormOpen(true); break; case 'pairing': + setIsCompletingScan(true); await conn.redeemPairingConnection(result.pairing); break; case 'permission-denied': @@ -94,9 +104,15 @@ export const MobileInstancesSurface: React.FC<{ break; } } finally { - setIsScanning(false); + setIsCompletingScan(false); + if (scanAbortRef.current === controller) { + scanAbortRef.current = null; + setIsScanning(false); + } } - }, [conn, isScanning, setError, t]); + }, [conn, setError, t]); + + React.useEffect(() => () => scanAbortRef.current?.abort(), []); const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { event.preventDefault(); @@ -170,6 +186,9 @@ export const MobileInstancesSurface: React.FC<{ } return ( + <> + {isScanning ? scanAbortRef.current?.abort()} /> : null} + {isCompletingScan ? : null}
@@ -358,5 +377,6 @@ export const MobileInstancesSurface: React.FC<{
+ ); }; diff --git a/packages/ui/src/apps/MobileQrScannerOverlay.tsx b/packages/ui/src/apps/MobileQrScannerOverlay.tsx new file mode 100644 index 00000000..f1aec6a8 --- /dev/null +++ b/packages/ui/src/apps/MobileQrScannerOverlay.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; + +import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; +import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; +import { useI18n } from '@/lib/i18n'; + +export const MobileQrScannerOverlay: React.FC<{ onCancel: () => void }> = ({ onCancel }) => { + const { t } = useI18n(); + const overlayRef = React.useRef(null); + + React.useLayoutEffect(() => { + const htmlBackground = { + value: document.documentElement.style.getPropertyValue('background-color'), + priority: document.documentElement.style.getPropertyPriority('background-color'), + }; + const bodyBackground = { + value: document.body.style.getPropertyValue('background-color'), + priority: document.body.style.getPropertyPriority('background-color'), + }; + // The app's reduced-transparency theme deliberately uses an !important + // background. Use an inline important color while CameraX is behind the + // WebView; the CSS minifier collapses `background: transparent` in a way + // that does not reset that important background color on Android WebView. + document.documentElement.style.setProperty('background-color', 'rgba(0, 0, 0, 0)', 'important'); + document.body.style.setProperty('background-color', 'rgba(0, 0, 0, 0)', 'important'); + + // startScan() places CameraX behind the WebView. OpenChamber has several + // independent portal roots, so hiding only #root (or relying on inherited + // visibility) can leave a sheet/sidebar painted over the preview. Opacity on + // each top-level sibling is composited for its whole subtree and cannot be + // overridden by descendants. + const hidden = new Map(); + const hideBodySibling = (node: Node) => { + if (!(node instanceof HTMLElement) || node === overlayRef.current || hidden.has(node)) return; + hidden.set(node, { opacity: node.style.opacity, pointerEvents: node.style.pointerEvents }); + node.style.setProperty('opacity', '0'); + node.style.setProperty('pointer-events', 'none'); + }; + Array.from(document.body.children).forEach(hideBodySibling); + const observer = new MutationObserver((records) => { + records.forEach((record) => record.addedNodes.forEach(hideBodySibling)); + }); + observer.observe(document.body, { childList: true }); + + return () => { + observer.disconnect(); + hidden.forEach((previous, element) => { + element.style.opacity = previous.opacity; + element.style.pointerEvents = previous.pointerEvents; + }); + if (htmlBackground.value) { + document.documentElement.style.setProperty('background-color', htmlBackground.value, htmlBackground.priority); + } else { + document.documentElement.style.removeProperty('background-color'); + } + if (bodyBackground.value) { + document.body.style.setProperty('background-color', bodyBackground.value, bodyBackground.priority); + } else { + document.body.style.removeProperty('background-color'); + } + }; + }, []); + + React.useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') onCancel(); + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [onCancel]); + + return createPortal( +
+
+
+

+ {t('mobile.connect.welcome.scanHint')} +

+
+ +
, + document.body, + ); +}; + +export const MobileQrConnectionLoading: React.FC = () => { + const { t } = useI18n(); + return createPortal( +
+ +
+ + {t('mobile.connect.connecting')} +
+
, + document.body, + ); +}; diff --git a/packages/ui/src/apps/mobileQrScan.test.ts b/packages/ui/src/apps/mobileQrScan.test.ts index 927461fd..cba12b20 100644 --- a/packages/ui/src/apps/mobileQrScan.test.ts +++ b/packages/ui/src/apps/mobileQrScan.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, test } from 'bun:test'; +import { afterEach, describe, expect, mock, test } from 'bun:test'; import { encodePairingConnectionPayload, buildPairingConnectionPayload } from '@/lib/connectionPayload'; -import { parseConnectionPayload } from './mobileQrScan'; +import { parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const; @@ -40,3 +40,126 @@ describe('parseConnectionPayload', () => { expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay#offer=eyJ2IjoxfQ')).toBeNull(); }); }); + +describe('scanConnectionQr on Android', () => { + const originalWindow = globalThis.window; + + afterEach(() => { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + }); + + test('uses the bundled startScan flow and cleans up after a result', async () => { + const listeners = new Map }) => void>(); + let removeCalls = 0; + let stopCalls = 0; + let scanCalls = 0; + let startOptions: unknown; + const remove = () => { removeCalls += 1; }; + const stopScan = async () => { stopCalls += 1; }; + const scan = async () => { scanCalls += 1; return { barcodes: [] }; }; + const startScan = async (options?: unknown) => { + startOptions = options; + listeners.get('barcodesScanned')?.({ barcodes: [{ rawValue: 'https://oc.example' }] }); + }; + const plugin = { + requestPermissions: mock(async () => ({ camera: 'granted' })), + scan, + startScan, + stopScan, + addListener: mock((event: string, callback: (info: { barcodes?: Array<{ rawValue?: string }> }) => void) => { + listeners.set(event, callback); + return Promise.resolve({ remove }); + }), + }; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, + }); + + expect(await scanConnectionQr()).toEqual({ status: 'ok', url: 'https://oc.example' }); + expect(startOptions).toEqual({ formats: ['QR_CODE'] }); + expect(scanCalls).toBe(0); + expect(stopCalls).toBe(1); + expect(removeCalls).toBe(2); + }); + + test('stops scanning when the caller aborts', async () => { + let stopCalls = 0; + const stopScan = async () => { stopCalls += 1; }; + const plugin = { + requestPermissions: mock(async () => ({ camera: 'granted' })), + startScan: mock(async () => undefined), + stopScan, + addListener: mock(async () => ({ remove: mock(() => undefined) })), + }; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, + }); + const controller = new AbortController(); + const result = scanConnectionQr({ signal: controller.signal }); + await Promise.resolve(); + controller.abort(); + + expect(await result).toEqual({ status: 'cancelled' }); + expect(stopCalls).toBe(1); + }); + + test('waits for listener setup to finish before cleaning up an aborted scan', async () => { + let finishListenerSetup: (() => void) | undefined; + let removeCalls = 0; + let startCalls = 0; + let stopCalls = 0; + const listenerSetup = new Promise((resolve) => { finishListenerSetup = resolve; }); + const remove = () => { removeCalls += 1; }; + const startScan = async () => { startCalls += 1; }; + const plugin = { + requestPermissions: mock(async () => ({ camera: 'granted' })), + startScan, + stopScan: async () => { stopCalls += 1; }, + addListener: mock(async () => { + await listenerSetup; + return { remove }; + }), + }; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, + }); + const controller = new AbortController(); + const result = scanConnectionQr({ signal: controller.signal }); + await Promise.resolve(); + controller.abort(); + finishListenerSetup?.(); + + expect(await result).toEqual({ status: 'cancelled' }); + expect(startCalls).toBe(0); + expect(removeCalls).toBe(2); + expect(stopCalls).toBe(1); + }); + + test('cleans up successful listener registration when the other listener fails', async () => { + let removeCalls = 0; + let startCalls = 0; + let stopCalls = 0; + const remove = () => { removeCalls += 1; }; + const plugin = { + requestPermissions: mock(async () => ({ camera: 'granted' })), + startScan: async () => { startCalls += 1; }, + stopScan: async () => { stopCalls += 1; }, + addListener: mock(async (event: string) => { + if (event === 'scanError') throw new Error('listener setup failed'); + return { remove }; + }), + }; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, + }); + + expect(await scanConnectionQr()).toEqual({ status: 'failed' }); + expect(startCalls).toBe(0); + expect(removeCalls).toBe(1); + expect(stopCalls).toBe(1); + }); +}); diff --git a/packages/ui/src/apps/mobileQrScan.ts b/packages/ui/src/apps/mobileQrScan.ts index e4b12c3f..e1980e99 100644 --- a/packages/ui/src/apps/mobileQrScan.ts +++ b/packages/ui/src/apps/mobileQrScan.ts @@ -1,14 +1,8 @@ // Connection payload parsing + native QR scanning for the dedicated mobile app. // -// Pairing v2 links (openchamber://connect?v=2&p=) carry a one-time -// secret and a list of transport candidates (lan / tunnel / relay); they are -// redeemed server-side over whichever candidate connects first. We also accept a -// bare http(s) URL so a QR encoding only the server address works. -// -// QR scanning is delegated to a Capacitor barcode-scanner plugin if the native -// shell registered one (`window.Capacitor.Plugins.BarcodeScanner`). We resolve it -// at runtime instead of importing the package so the web build stays dependency-free -// and the browser-hosted mobile UI degrades to `unsupported` cleanly. +// Android uses the plugin's CameraX-backed startScan() flow. Unlike its ready-made +// scan() activity, this path bundles the barcode model in the app and does not need +// Google Play Services. iOS keeps the native ready-made scanner. import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload'; @@ -32,74 +26,16 @@ export type QrScanResult = | { status: 'failed' }; type ScannedBarcode = { rawValue?: string; displayValue?: string }; - -type ModuleInstallProgress = { state?: number }; -type ListenerHandle = { remove: () => void }; - +type ListenerHandle = { remove: () => void | Promise }; type BarcodeScannerPlugin = { requestPermissions?: () => Promise<{ camera?: string } | undefined>; scan?: (options?: { formats?: string[] }) => Promise<{ barcodes?: ScannedBarcode[] } | undefined>; - // Android-only: the Google code scanner used by scan() needs the ML Kit barcode module, - // which Play Services must download once before the first scan. Absent on iOS. - isGoogleBarcodeScannerModuleAvailable?: () => Promise<{ available?: boolean } | undefined>; - installGoogleBarcodeScannerModule?: () => Promise; + startScan?: (options?: { formats?: string[] }) => Promise; + stopScan?: () => Promise; addListener?: ( - event: 'googleBarcodeScannerModuleInstallProgress', - cb: (info: ModuleInstallProgress) => void, - ) => Promise; -}; - -// Google's ModuleInstallProgress states: 4 = COMPLETED, 3 = CANCELED, 5 = FAILED. -const MODULE_STATE_COMPLETED = 4; -const MODULE_STATE_CANCELED = 3; -const MODULE_STATE_FAILED = 5; -const MODULE_INSTALL_TIMEOUT_MS = 90_000; - -// Ensure the Android Google barcode module is downloaded before scanning. No-op on platforms -// where these methods don't exist (iOS) or when it's already available. Resolves once the module -// is usable; rejects if the install is canceled, fails, or times out. -const ensureScannerModule = async (plugin: BarcodeScannerPlugin): Promise => { - const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; - if ( - capacitor?.getPlatform?.() !== 'android' || - !plugin.isGoogleBarcodeScannerModuleAvailable || - !plugin.installGoogleBarcodeScannerModule - ) { - return; - } - const status = await plugin.isGoogleBarcodeScannerModuleAvailable().catch(() => undefined); - if (status?.available) return; - - await new Promise((resolve, reject) => { - let handle: ListenerHandle | undefined; - const finish = (fn: () => void) => { - window.clearTimeout(timer); - handle?.remove(); - fn(); - }; - const timer = window.setTimeout( - () => finish(() => reject(new Error('module install timed out'))), - MODULE_INSTALL_TIMEOUT_MS, - ); - // addListener may return a handle synchronously OR a Promise depending on the - // Capacitor proxy — normalize with Promise.resolve so a non-thenable handle doesn't throw - // and abort the install call below. - Promise.resolve( - plugin.addListener?.('googleBarcodeScannerModuleInstallProgress', (info) => { - if (info?.state === MODULE_STATE_COMPLETED) finish(resolve); - else if (info?.state === MODULE_STATE_CANCELED || info?.state === MODULE_STATE_FAILED) { - finish(() => reject(new Error('module install failed'))); - } - }), - ) - .then((h) => { - handle = h as ListenerHandle | undefined; - }) - .catch(() => undefined); - Promise.resolve(plugin.installGoogleBarcodeScannerModule?.()).catch((error) => - finish(() => reject(error instanceof Error ? error : new Error('module install failed'))), - ); - }); + event: 'barcodesScanned' | 'scanError', + cb: (info: { barcodes?: ScannedBarcode[]; message?: string }) => void, + ) => Promise | ListenerHandle; }; const getScannerPlugin = (): BarcodeScannerPlugin | null => { @@ -108,7 +44,12 @@ const getScannerPlugin = (): BarcodeScannerPlugin | null => { Capacitor?: { Plugins?: Record }; }).Capacitor; const plugin = capacitor?.Plugins?.BarcodeScanner as BarcodeScannerPlugin | undefined; - return plugin && typeof plugin.scan === 'function' ? plugin : null; + return plugin && (typeof plugin.scan === 'function' || typeof plugin.startScan === 'function') ? plugin : null; +}; + +const isAndroid = (): boolean => { + const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; + return capacitor?.getPlatform?.() === 'android'; }; export const parseConnectionPayload = (raw: string): MobileConnectionPayload | MobilePairingPayload | null => { @@ -124,54 +65,85 @@ export const parseConnectionPayload = (raw: string): MobileConnectionPayload | M return null; }; -// The Google code scanner can briefly still throw "module not available" in the moments right -// after its install completes. Detect that specific error so we can re-ensure + retry rather -// than surfacing a failure the user would have to manually tap through. -const isModuleUnavailableError = (error: unknown): boolean => { - const message = - typeof error === 'object' && error && 'message' in error - ? String((error as { message?: unknown }).message ?? '') - : String(error ?? ''); - return /module/i.test(message) && /not\s*available|unavailable/i.test(message); +const resultFromRawValue = (raw: string): QrScanResult => { + const payload = parseConnectionPayload(raw); + if (!payload) return { status: 'invalid' }; + if ('pairing' in payload) return { status: 'pairing', ...payload }; + return { status: 'ok', ...payload }; +}; + +const scanWithBundledAndroidScanner = async ( + plugin: BarcodeScannerPlugin, + signal?: AbortSignal, +): Promise => { + if (!plugin.startScan || !plugin.stopScan || !plugin.addListener) return { status: 'unsupported' }; + if (signal?.aborted) return { status: 'cancelled' }; + + let barcodeListener: ListenerHandle | undefined; + let errorListener: ListenerHandle | undefined; + let settled = false; + let resolveResult: (result: QrScanResult) => void = () => undefined; + + const result = new Promise((resolve) => { + resolveResult = resolve; + }); + const finish = (scanResult: QrScanResult) => { + if (settled) return; + settled = true; + resolveResult(scanResult); + }; + const abort = () => finish({ status: 'cancelled' }); + signal?.addEventListener('abort', abort, { once: true }); + + try { + const listenerResults = await Promise.allSettled([ + Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => { + const barcode = barcodes?.[0]; + const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim(); + if (raw) finish(resultFromRawValue(raw)); + })).then((handle) => { barcodeListener = handle; }), + Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' }))) + .then((handle) => { errorListener = handle; }), + ]); + + if (listenerResults.some(({ status }) => status === 'rejected')) { + finish({ status: 'failed' }); + } else if (!settled) { + void plugin.startScan({ formats: ['QR_CODE'] }).catch(() => finish({ status: 'failed' })); + } + + return await result; + } finally { + signal?.removeEventListener('abort', abort); + await Promise.allSettled([ + Promise.resolve(barcodeListener?.remove()), + Promise.resolve(errorListener?.remove()), + plugin.stopScan(), + ]); + } }; export const isQrScanSupported = (): boolean => getScannerPlugin() !== null; -export const scanConnectionQr = async (): Promise => { +export const scanConnectionQr = async (options?: { signal?: AbortSignal }): Promise => { const plugin = getScannerPlugin(); - if (!plugin?.scan) return { status: 'unsupported' }; + if (!plugin) return { status: 'unsupported' }; try { if (plugin.requestPermissions) { const permission = await plugin.requestPermissions(); const camera = permission?.camera; - if (camera && camera !== 'granted' && camera !== 'limited') { - return { status: 'permission-denied' }; - } + if (camera && camera !== 'granted' && camera !== 'limited') return { status: 'permission-denied' }; } - // First scan on Android downloads the Google barcode module (the button stays in its - // scanning state for the whole wait). The module can still report "not available" for a - // moment right after install, so re-ensure + retry within this same call instead of erroring - // out — the user shouldn't have to guess to tap again. - for (let attempt = 0; attempt < 3; attempt++) { - try { - await ensureScannerModule(plugin); - const result = await plugin.scan({ formats: ['QR_CODE'] }); - const barcode = result?.barcodes?.[0]; - const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim(); - if (!raw) return { status: 'cancelled' }; + if (options?.signal?.aborted) return { status: 'cancelled' }; + if (isAndroid()) return scanWithBundledAndroidScanner(plugin, options?.signal); + if (!plugin.scan) return { status: 'unsupported' }; - const payload = parseConnectionPayload(raw); - if (!payload) return { status: 'invalid' }; - if ('pairing' in payload) return { status: 'pairing', ...payload }; - return { status: 'ok', ...payload }; - } catch (error) { - if (!isModuleUnavailableError(error) || attempt === 2) return { status: 'failed' }; - await new Promise((resolve) => window.setTimeout(resolve, 600)); - } - } - return { status: 'failed' }; + const result = await plugin.scan({ formats: ['QR_CODE'] }); + const barcode = result?.barcodes?.[0]; + const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim(); + return raw ? resultFromRawValue(raw) : { status: 'cancelled' }; } catch { return { status: 'failed' }; }