feat: add fallback parsing for pairing connection payloads in old Android WebViews (#2611)
This commit is contained in:
@@ -83,6 +83,42 @@ describe('scanConnectionQr on Android', () => {
|
|||||||
expect(removeCalls).toBe(2);
|
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<string, (event: { barcodes?: Array<{ rawValue?: string }> }) => 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 () => {
|
test('stops scanning when the caller aborts', async () => {
|
||||||
let stopCalls = 0;
|
let stopCalls = 0;
|
||||||
const stopScan = async () => { stopCalls += 1; };
|
const stopScan = async () => { stopCalls += 1; };
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// scan() activity, this path bundles the barcode model in the app and does not need
|
// 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.
|
// 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 = {
|
export type MobileConnectionPayload = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -65,8 +65,15 @@ export const parseConnectionPayload = (raw: string): MobileConnectionPayload | M
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resultFromRawValue = (raw: string): QrScanResult => {
|
const resultFromRawValue = (raw: string, options?: { pairingStringFallback?: boolean }): QrScanResult => {
|
||||||
const payload = parseConnectionPayload(raw);
|
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 (!payload) return { status: 'invalid' };
|
||||||
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
||||||
return { status: 'ok', ...payload };
|
return { status: 'ok', ...payload };
|
||||||
@@ -100,7 +107,7 @@ const scanWithBundledAndroidScanner = async (
|
|||||||
Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => {
|
Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => {
|
||||||
const barcode = barcodes?.[0];
|
const barcode = barcodes?.[0];
|
||||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||||
if (raw) finish(resultFromRawValue(raw));
|
if (raw) finish(resultFromRawValue(raw, { pairingStringFallback: true }));
|
||||||
})).then((handle) => { barcodeListener = handle; }),
|
})).then((handle) => { barcodeListener = handle; }),
|
||||||
Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' })))
|
Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' })))
|
||||||
.then((handle) => { errorListener = handle; }),
|
.then((handle) => { errorListener = handle; }),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildPairingConnectionPayload,
|
buildPairingConnectionPayload,
|
||||||
encodePairingConnectionPayload,
|
encodePairingConnectionPayload,
|
||||||
parsePairingConnectionPayload,
|
parsePairingConnectionPayload,
|
||||||
|
parsePairingConnectionPayloadString,
|
||||||
} from './connectionPayload';
|
} from './connectionPayload';
|
||||||
|
|
||||||
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
|
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();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -212,3 +212,35 @@ export const parsePairingConnectionPayload = (value: string): PairingConnectionP
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user