@@ -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' };
}