From 61083c3915862610ea963d19272bacda72282352 Mon Sep 17 00:00:00 2001 From: CallMeBill Date: Fri, 7 Aug 2026 04:20:40 +0800 Subject: [PATCH] feat: add fallback parsing for pairing connection payloads in old Android WebViews (#2611) --- packages/ui/src/apps/mobileQrScan.test.ts | 36 ++++++++++++++++ packages/ui/src/apps/mobileQrScan.ts | 13 ++++-- packages/ui/src/lib/connectionPayload.test.ts | 43 +++++++++++++++++++ packages/ui/src/lib/connectionPayload.ts | 32 ++++++++++++++ 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/apps/mobileQrScan.test.ts b/packages/ui/src/apps/mobileQrScan.test.ts index cba12b20..14ca8a70 100644 --- a/packages/ui/src/apps/mobileQrScan.test.ts +++ b/packages/ui/src/apps/mobileQrScan.test.ts @@ -83,6 +83,42 @@ describe('scanConnectionQr on Android', () => { expect(removeCalls).toBe(2); }); + test('falls back to string parsing when the WebView URL parser rejects the link (old Android WebView)', async () => { + // Old Android WebViews resolve openchamber://connect?... with hostname "" and + // pathname "//connect", so the URL-based parse fails on an intact string. The test + // runtime's URL parser handles the canonical form fine, so simulate the rejection + // with a case variant the URL parser refuses while the string parser accepts. + const url = encodePairingConnectionPayload(buildPairingConnectionPayload({ + pairingId: 'pair_abc', + secret: 'one-time', + candidates: [{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 }], + })); + const mixedCase = url.replace('openchamber://connect', 'OpenChamber://CONNECT'); + const listeners = new Map }) => void>(); + const plugin = { + requestPermissions: mock(async () => ({ camera: 'granted' })), + startScan: mock(async () => { + listeners.get('barcodesScanned')?.({ barcodes: [{ rawValue: mixedCase }] }); + }), + stopScan: mock(async () => undefined), + addListener: mock((event: string, callback: (info: { barcodes?: Array<{ rawValue?: string }> }) => void) => { + listeners.set(event, callback); + return { remove: () => undefined }; + }), + }; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, + }); + + const result = await scanConnectionQr(); + expect(result.status).toBe('pairing'); + if (result.status === 'pairing') { + expect(result.pairing.pairingId).toBe('pair_abc'); + expect(result.pairing.candidates).toEqual([{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 }]); + } + }); + test('stops scanning when the caller aborts', async () => { let stopCalls = 0; const stopScan = async () => { stopCalls += 1; }; diff --git a/packages/ui/src/apps/mobileQrScan.ts b/packages/ui/src/apps/mobileQrScan.ts index e1980e99..2e54fec9 100644 --- a/packages/ui/src/apps/mobileQrScan.ts +++ b/packages/ui/src/apps/mobileQrScan.ts @@ -4,7 +4,7 @@ // 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'; +import { parsePairingConnectionPayload, parsePairingConnectionPayloadString, type PairingConnectionPayload } from '@/lib/connectionPayload'; export type MobileConnectionPayload = { url: string; @@ -65,8 +65,15 @@ export const parseConnectionPayload = (raw: string): MobileConnectionPayload | M return null; }; -const resultFromRawValue = (raw: string): QrScanResult => { +const resultFromRawValue = (raw: string, options?: { pairingStringFallback?: boolean }): QrScanResult => { const payload = parseConnectionPayload(raw); + if (!payload && options?.pairingStringFallback) { + // Old Android WebViews resolve openchamber://… with hostname "" / pathname "//connect", + // so the URL-based parse above fails even though the scanned string is intact. Retry + // with the URL-API-free string parser before declaring the scan invalid. + const pairing = parsePairingConnectionPayloadString(raw); + if (pairing) return { status: 'pairing', pairing }; + } if (!payload) return { status: 'invalid' }; if ('pairing' in payload) return { status: 'pairing', ...payload }; return { status: 'ok', ...payload }; @@ -100,7 +107,7 @@ const scanWithBundledAndroidScanner = async ( Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => { const barcode = barcodes?.[0]; const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim(); - if (raw) finish(resultFromRawValue(raw)); + if (raw) finish(resultFromRawValue(raw, { pairingStringFallback: true })); })).then((handle) => { barcodeListener = handle; }), Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' }))) .then((handle) => { errorListener = handle; }), diff --git a/packages/ui/src/lib/connectionPayload.test.ts b/packages/ui/src/lib/connectionPayload.test.ts index 5d11e173..e15105b5 100644 --- a/packages/ui/src/lib/connectionPayload.test.ts +++ b/packages/ui/src/lib/connectionPayload.test.ts @@ -4,6 +4,7 @@ import { buildPairingConnectionPayload, encodePairingConnectionPayload, parsePairingConnectionPayload, + parsePairingConnectionPayloadString, } from './connectionPayload'; const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const; @@ -103,3 +104,45 @@ describe('connection payload helpers', () => { expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${expired}`)).toBeNull(); }); }); + +describe('parsePairingConnectionPayloadString (Android WebView fallback)', () => { + const payload = buildPairingConnectionPayload({ + pairingId: 'pair_123', + secret: 'one-time-secret', + label: 'Desktop', + candidates: [ + { type: 'lan', url: 'http://192.168.1.20:4096', priority: 20 }, + { type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_abc', hostEncPubJwk, priority: 30 }, + ], + }); + const encoded = encodePairingConnectionPayload(payload); + + test('parses the canonical link identically to the URL-based parser', () => { + // Old Android WebViews resolve the same string with hostname "" / pathname "//connect"; + // the string parser must not depend on the URL API to succeed. + expect(parsePairingConnectionPayloadString(encoded)).toEqual(parsePairingConnectionPayload(encoded)); + }); + + test('recovers a link whose scheme/host case the URL parser would reject', () => { + const mixedCase = encoded.replace('openchamber://connect', 'OpenChamber://CONNECT'); + expect(parsePairingConnectionPayload(mixedCase)).toBeNull(); + expect(parsePairingConnectionPayloadString(mixedCase)).toEqual(parsePairingConnectionPayload(encoded)); + }); + + test('tolerates a trailing slash and reordered query params', () => { + const trailingSlash = encoded.replace('openchamber://connect?', 'openchamber://connect/?'); + expect(parsePairingConnectionPayloadString(trailingSlash)).toEqual(parsePairingConnectionPayload(encoded)); + + const p = encoded.slice(encoded.indexOf('p=') + 2); + expect(parsePairingConnectionPayloadString(`openchamber://connect?p=${p}&v=2`)).toEqual(parsePairingConnectionPayload(encoded)); + }); + + test('still rejects non-pairing and malformed payloads', () => { + expect(parsePairingConnectionPayloadString('')).toBeNull(); + expect(parsePairingConnectionPayloadString('hello world')).toBeNull(); + expect(parsePairingConnectionPayloadString('openchamber://connect')).toBeNull(); + expect(parsePairingConnectionPayloadString('openchamber:///connect?v=2&p=x')).toBeNull(); + expect(parsePairingConnectionPayloadString('openchamber://connect?v=1&server=http%3A%2F%2F192.168.1.10%3A2606&token=t')).toBeNull(); + expect(parsePairingConnectionPayloadString('openchamber://connect?v=2&p=not-json')).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/connectionPayload.ts b/packages/ui/src/lib/connectionPayload.ts index 88c7f1c7..d1c72526 100644 --- a/packages/ui/src/lib/connectionPayload.ts +++ b/packages/ui/src/lib/connectionPayload.ts @@ -212,3 +212,35 @@ export const parsePairingConnectionPayload = (value: string): PairingConnectionP return null; } }; + +// URL-string-only sibling of parsePairingConnectionPayload. Old Android WebViews +// (e.g. WebView 114) mis-parse non-special schemes: `new URL('openchamber://connect?...')` +// yields hostname "" and pathname "//connect", so the URL-based parser above rejects a +// perfectly valid pairing link. This parser never touches the URL/URLSearchParams APIs — +// it matches the head with a regex and reads `v`/`p` straight off the query string. +// Used by the Android QR-scan path after the standard parse fails; keeps every existing +// validation (version, payload length, base64url, candidate normalization). +export const parsePairingConnectionPayloadString = (value: string): PairingConnectionPayload | null => { + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_PAIRING_PAYLOAD_LENGTH) return null; + const question = trimmed.indexOf('?'); + if (question === -1 || !/^openchamber:\/\/connect\/?$/i.test(trimmed.slice(0, question))) return null; + let version: string | null = null; + let encoded: string | null = null; + for (const part of trimmed.slice(question + 1).split('&')) { + const eq = part.indexOf('='); + if (eq === -1) continue; + const key = part.slice(0, eq); + const value_ = part.slice(eq + 1); + if (key === 'v') version = value_; + else if (key === 'p') encoded = value_; + } + if (version !== '2' || !encoded || encoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null; + const decoded = base64UrlDecode(encoded); + if (!decoded || decoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null; + try { + return normalizePairingPayload(JSON.parse(decoded) as unknown); + } catch { + return null; + } +};