fix(mobile): support QR scanning without Play Services

This commit is contained in:
Bohdan Triapitsyn
2026-08-02 19:09:46 +03:00
parent 2b446dd164
commit a96a2310a9
7 changed files with 369 additions and 131 deletions
+125 -2
View File
@@ -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<string, (event: { barcodes?: Array<{ rawValue?: string }> }) => 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<void>((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);
});
});