diff --git a/.changeset/146-bridge-scan.md b/.changeset/146-bridge-scan.md new file mode 100644 index 0000000..4c476dd --- /dev/null +++ b/.changeset/146-bridge-scan.md @@ -0,0 +1,25 @@ +--- +"mobile": patch +--- + +Clause-②: no +Metadata-protocol: none + +The bridge can scan codes for the Console. `ObjectStackNative.scan(opts?)`, +announced as the `scan` capability, opens the native full-screen scanner (the +same one as "connect by QR code", with close and torch) and resolves the +decoded text, or `null` when the user closes it. + +- `formats` narrows what the camera reads, with `expo-camera`'s names (`qr`, + `ean13`, `code128`, …); without it every listed format is read. An empty list, + an unknown format or any other option is refused as `bad_args`. +- A refused camera permission fails the call with `failed` + ("Camera permission denied"); a second `scan()` while one is open fails with + "A scan is already open" and leaves the first alone. +- Only pages on the configured server's origin can call it, like every method. + +Bridge answers now go only to the document that made the call. + +The scanner screen lays out right-to-left under Arabic on its own root. The +camera prompt text now names both uses (connect code, and codes for Console +fields) in all ten languages. diff --git a/__tests__/components/ConsoleWebView.test.tsx b/__tests__/components/ConsoleWebView.test.tsx index 8b2c687..b480793 100644 --- a/__tests__/components/ConsoleWebView.test.tsx +++ b/__tests__/components/ConsoleWebView.test.tsx @@ -71,6 +71,7 @@ function renderConsole(overrides: Partial = {}) { haptic: jest.fn(), openExternal: jest.fn().mockResolvedValue(undefined), resetServer: jest.fn().mockResolvedValue(undefined), + scan: jest.fn().mockResolvedValue(null), }, onOpenNative: jest.fn(), onOpenExternal: jest.fn(), @@ -271,6 +272,65 @@ describe("ConsoleWebView", () => { ); }); + describe("an answer whose document is gone", () => { + /** A `scan()` from the loaded page, left open until the test settles it. */ + function openScan() { + let settle: (text: string | null) => void = () => {}; + const scan = jest.fn( + () => new Promise((resolve) => (settle = resolve)) + ); + renderConsole({ + capabilities: { + getPushToken: jest.fn().mockResolvedValue(null), + haptic: jest.fn(), + openExternal: jest.fn().mockResolvedValue(undefined), + resetServer: jest.fn().mockResolvedValue(undefined), + scan, + }, + }); + act(() => mockWebView.props.onLoadStart()); + finishLoad(); + act(() => { + mockWebView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: "call", id: "d1.1", method: "scan" }), + url: `${SERVER}/_console/apps/crm`, + }, + }); + }); + expect(scan).toHaveBeenCalledTimes(1); + return (text: string | null) => act(async () => settle(text)); + } + + it("is injected into the page that asked while that page is still there", async () => { + const settle = openScan(); + await settle("4006381333931"); + expect(mockWebView.injectJavaScript).toHaveBeenCalledWith( + expect.stringContaining('"id":"d1.1","ok":true,"value":"4006381333931"') + ); + }); + + it("still settles the call after a load start in the same WebView (an in-app navigation)", async () => { + // Android reports a load start for every SPA history change. A page that + // really was replaced cannot match the answer: its call ids carry a + // token of their own document (bridge-scripts.test.ts). + const settle = openScan(); + act(() => mockWebView.props.onLoadStart()); + await settle("4006381333931"); + expect(mockWebView.injectJavaScript).toHaveBeenCalledWith( + expect.stringContaining('"id":"d1.1","ok":true,"value":"4006381333931"') + ); + }); + + it("is dropped when the WebView was re-created meanwhile", async () => { + const settle = openScan(); + act(() => mockWebView.props.onRenderProcessGone()); + expect(mockWebView.mounts).toBe(2); + await settle("4006381333931"); + expect(mockWebView.injectJavaScript).not.toHaveBeenCalled(); + }); + }); + it("injects nothing in answer to a page from another origin", async () => { renderConsole(); await act(async () => { @@ -378,6 +438,7 @@ describe("ConsoleWebView", () => { haptic: jest.fn(), openExternal: jest.fn(), resetServer: jest.fn(), + scan: jest.fn(), }, onOpenNative: jest.fn(), onOpenExternal: jest.fn(), diff --git a/__tests__/components/QrScanner.test.tsx b/__tests__/components/QrScanner.test.tsx index 2cfbb1d..8184c2b 100644 --- a/__tests__/components/QrScanner.test.tsx +++ b/__tests__/components/QrScanner.test.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { Linking } from "react-native"; +import { Linking, StyleSheet } from "react-native"; import { act, fireEvent, render, screen } from "@testing-library/react-native"; // Types for the matchers jest.config registers through setupFilesAfterEnv. import type {} from "@testing-library/react-native/extend-expect"; -import "~/lib/i18n"; +import i18n from "~/lib/i18n"; interface MockPermission { granted: boolean; @@ -41,12 +41,27 @@ jest.mock("react-native-safe-area-context", () => { const mockBack = jest.fn(); const mockDismissTo = jest.fn(); +const mockReplace = jest.fn(); +let mockCanGoBack = true; jest.mock("expo-router", () => ({ - useRouter: () => ({ back: mockBack, dismissTo: mockDismissTo, push: jest.fn() }), + useRouter: () => ({ + back: mockBack, + dismissTo: mockDismissTo, + push: jest.fn(), + replace: mockReplace, + canGoBack: () => mockCanGoBack, + }), })); import { QrScanner, REJECT_MESSAGE_MS } from "~/components/shell/QrScanner"; import ConnectScanScreen from "~/app/connect-scan"; +import BridgeScanScreen from "~/app/bridge-scan"; +import { + SCAN_DENIED_MESSAGE, + currentScan, + requestScan, + settleScan, +} from "~/lib/shell/bridge/scan-session"; const GRANTED: MockPermission = { granted: true, canAskAgain: true, status: "granted" }; const UNDETERMINED: MockPermission = { granted: false, canAskAgain: true, status: "undetermined" }; @@ -95,6 +110,18 @@ beforeEach(() => { mockCamera.request.mockReset().mockResolvedValue(GRANTED); mockBack.mockReset(); mockDismissTo.mockReset(); + mockReplace.mockReset(); + mockCanGoBack = true; +}); + +afterEach(async () => { + const open = currentScan(); + if (open) settleScan(open.token, { kind: "cancel" }); + if (i18n.language !== "en") { + await act(async () => { + await i18n.changeLanguage("en"); + }); + } }); describe("QrScanner — camera allowed", () => { @@ -188,6 +215,37 @@ describe("QrScanner — camera allowed", () => { }); }); +describe("QrScanner — caller options", () => { + it("reads the code formats the caller names", () => { + renderScanner({ barcodeTypes: ["ean13", "code128"] }); + expect(mockCamera.props?.barcodeScannerSettings).toEqual({ + barcodeTypes: ["ean13", "code128"], + }); + }); + + it("shows the caller's permission copy, asking and refused for good", () => { + const permissionText = { body: "Allow it for codes", blocked: "Off; Settings only" }; + mockCamera.permission = UNDETERMINED; + renderScanner({ permissionText }); + expect(screen.getByTestId("qr-scanner-permission")).toHaveTextContent("Allow it for codes", { + exact: false, + }); + screen.unmount(); + mockCamera.permission = BLOCKED; + renderScanner({ permissionText }); + expect(screen.getByTestId("qr-scanner-permission")).toHaveTextContent("Off; Settings only", { + exact: false, + }); + }); + + it("tells the caller the permission status it read", () => { + const onPermissionStatus = jest.fn(); + mockCamera.permission = BLOCKED; + renderScanner({ onPermissionStatus }); + expect(onPermissionStatus).toHaveBeenLastCalledWith("denied"); + }); +}); + describe("QrScanner — camera permission", () => { it("shows no camera while the permission is still being read", () => { mockCamera.permission = null; @@ -274,3 +332,117 @@ describe("ConnectScanScreen (screen 6)", () => { expect(mockBack).toHaveBeenCalledTimes(2); }); }); + +describe("BridgeScanScreen (bridge scan())", () => { + /** Open a scan through the session, as the Console screen's capability does. */ + function openScan(formats: Parameters[0] = ["ean13", "qr"]) { + const answer = requestScan(formats, () => {}); + render(); + return answer; + } + + it("reads the formats of the open scan, with the code copy and no manual way out", () => { + void openScan(["ean13", "code128"]); + expect(mockCamera.props?.barcodeScannerSettings).toEqual({ + barcodeTypes: ["ean13", "code128"], + }); + expect(screen.getByTestId("qr-scanner-hint")).toHaveTextContent( + "Place the code inside the frame" + ); + expect(screen.queryByTestId("qr-scanner-manual")).toBeNull(); + expect(screen.getByTestId("qr-scanner-torch")).toBeTruthy(); + }); + + it("resolves the first code read, once, and closes", async () => { + const answer = openScan(); + scan("4006381333931"); + scan("other"); + await expect(answer).resolves.toBe("4006381333931"); + expect(mockBack).toHaveBeenCalledTimes(1); + expect(currentScan()).toBeNull(); + }); + + it("resolves null when closed", async () => { + const answer = openScan(); + fireEvent.press(screen.getByTestId("qr-scanner-close")); + await expect(answer).resolves.toBeNull(); + expect(mockBack).toHaveBeenCalledTimes(1); + }); + + it("resolves null when left another way (Android back, the iOS swipe)", async () => { + const answer = requestScan(["qr"], () => {}); + const view = render(); + view.unmount(); + await expect(answer).resolves.toBeNull(); + }); + + it("fails when the camera permission is refused, with the code copy", async () => { + mockCamera.permission = BLOCKED; + const answer = openScan(); + expect(screen.getByTestId("qr-scanner-permission")).toHaveTextContent( + "Turn it on in Settings to scan codes", + { exact: false } + ); + fireEvent.press(screen.getByTestId("qr-scanner-close")); + await expect(answer).rejects.toThrow(SCAN_DENIED_MESSAGE); + }); + + it("fails when left another way after the permission was refused", async () => { + mockCamera.permission = BLOCKED; + const answer = requestScan(["qr"], () => {}); + render().unmount(); + await expect(answer).rejects.toThrow(SCAN_DENIED_MESSAGE); + }); + + it("resolves null when closed before the permission was answered", async () => { + mockCamera.permission = UNDETERMINED; + const answer = openScan(); + fireEvent.press(screen.getByTestId("qr-scanner-close")); + await expect(answer).resolves.toBeNull(); + }); + + it("closes at once when opened with no scan for it (a link to the route)", () => { + mockCanGoBack = false; + render(); + expect(screen.queryByTestId("qr-scanner-camera")).toBeNull(); + expect(mockReplace).toHaveBeenCalledWith("/"); + }); +}); + +describe("scanner layout direction", () => { + /** The direction on the outermost host view the screen renders. */ + function rootDirection(): unknown { + const tree = screen.toJSON(); + const root = Array.isArray(tree) ? tree[0] : tree; + return StyleSheet.flatten(root?.props.style)?.direction; + } + + it.each([ + ["the camera", GRANTED], + ["the permission panel", BLOCKED], + ])("lays out %s right-to-left under ar and left-to-right under en", async (_label, permission) => { + mockCamera.permission = permission; + renderScanner(); + expect(rootDirection()).toBe("ltr"); + await act(async () => { + await i18n.changeLanguage("ar"); + }); + expect(rootDirection()).toBe("rtl"); + expect(screen.getByTestId("qr-scanner-close").props.accessibilityLabel).toBe("إغلاق"); + }); + + it("sets it on the connect scan and the bridge scan screen roots, in Arabic", async () => { + await act(async () => { + await i18n.changeLanguage("ar"); + }); + render(); + expect(rootDirection()).toBe("rtl"); + expect(screen.getByTestId("qr-scanner-manual")).toHaveTextContent("إدخال العنوان يدويًا"); + screen.unmount(); + + void requestScan(["qr"], () => {}); + render(); + expect(rootDirection()).toBe("rtl"); + expect(screen.getByTestId("qr-scanner-hint")).toHaveTextContent("ضع الرمز داخل الإطار"); + }); +}); diff --git a/__tests__/lib/shell/bridge-handler.test.ts b/__tests__/lib/shell/bridge-handler.test.ts index dc91094..2dd4320 100644 --- a/__tests__/lib/shell/bridge-handler.test.ts +++ b/__tests__/lib/shell/bridge-handler.test.ts @@ -1,4 +1,13 @@ import { createBridgeHandler, type BridgeDeps } from "~/lib/shell/bridge/handler"; +import { SCAN_FORMATS } from "~/lib/shell/bridge/protocol"; +import { + SCAN_BUSY_MESSAGE, + SCAN_DENIED_MESSAGE, + currentScan, + requestScan, + settleScan, + type ScanOutcome, +} from "~/lib/shell/bridge/scan-session"; const SERVER = "https://acme.example.com"; const PAGE = `${SERVER}/_console/home`; @@ -11,6 +20,7 @@ function setup(overrides: Partial = {}) { openExternal: jest.fn().mockResolvedValue(undefined), navigate: jest.fn(), resetServer: jest.fn().mockResolvedValue(undefined), + scan: jest.fn().mockResolvedValue("4006381333931"), onEvent: jest.fn(), ...overrides, }; @@ -134,6 +144,107 @@ describe("bridge handler", () => { }); }); + describe("scan", () => { + it("reads every format when the call names none", async () => { + const { handle, deps } = setup(); + for (const args of [undefined, null, {}]) { + await expect(handle(call("scan", args), PAGE)).resolves.toEqual({ + id: "c1", + ok: true, + value: "4006381333931", + }); + } + expect(deps.scan).toHaveBeenCalledTimes(3); + for (const [formats] of (deps.scan as jest.Mock).mock.calls) { + expect(formats).toEqual(SCAN_FORMATS); + } + }); + + it("reads only the formats the call names", async () => { + const { handle, deps } = setup(); + await expect( + handle(call("scan", { formats: ["ean13", "qr", "ean13"] }), PAGE) + ).resolves.toMatchObject({ ok: true }); + expect(deps.scan).toHaveBeenCalledWith(["qr", "ean13"]); + }); + + it.each([ + ["an empty format list", { formats: [] }], + ["an unknown format", { formats: ["qr", "barcode"] }], + ["a format that is not a string", { formats: ["qr", 5] }], + ["formats that are not a list", { formats: "qr" }], + ["an unknown option", { format: ["qr"] }], + ["an extra option", { formats: ["qr"], torch: true }], + ["options that are a list", ["qr"]], + ["options that are a string", "qr"], + ])("refuses %s as bad_args without opening the scanner", async (_label, args) => { + const { handle, deps } = setup(); + await expect(handle(call("scan", args), PAGE)).resolves.toMatchObject({ + ok: false, + error: { code: "bad_args" }, + }); + expect(deps.scan).not.toHaveBeenCalled(); + }); + + it("answers nothing, and opens no scanner, for a page on another origin", async () => { + const { handle, deps } = setup(); + await expect(handle(call("scan"), "https://evil.example.com/")).resolves.toBeNull(); + expect(deps.scan).not.toHaveBeenCalled(); + }); + + describe("through the scan session", () => { + const show = jest.fn(); + const scanThroughSession = () => + setup({ scan: (formats) => requestScan(formats, show) }).handle; + + /** Settle the open scan once the handler has opened it. */ + async function settleOpen(outcome: ScanOutcome) { + await Promise.resolve(); + const open = currentScan(); + if (!open) throw new Error("no scan open"); + settleScan(open.token, outcome); + } + + beforeEach(() => show.mockReset()); + + it("resolves the decoded text", async () => { + const answer = scanThroughSession()(call("scan", { formats: ["code128"] }), PAGE); + await settleOpen({ kind: "result", text: "SKU-0042" }); + await expect(answer).resolves.toEqual({ id: "c1", ok: true, value: "SKU-0042" }); + expect(show).toHaveBeenCalledTimes(1); + }); + + it("resolves null on cancel", async () => { + const answer = scanThroughSession()(call("scan"), PAGE); + await settleOpen({ kind: "cancel" }); + await expect(answer).resolves.toEqual({ id: "c1", ok: true, value: null }); + }); + + it("fails when the camera permission is denied", async () => { + const answer = scanThroughSession()(call("scan"), PAGE); + await settleOpen({ kind: "denied" }); + await expect(answer).resolves.toEqual({ + id: "c1", + ok: false, + error: { code: "failed", message: SCAN_DENIED_MESSAGE }, + }); + }); + + it("refuses a second scan while one is open, and leaves the open one alone", async () => { + const handle = scanThroughSession(); + const first = handle(call("scan", undefined, "c1"), PAGE); + await expect(handle(call("scan", undefined, "c2"), PAGE)).resolves.toEqual({ + id: "c2", + ok: false, + error: { code: "failed", message: SCAN_BUSY_MESSAGE }, + }); + expect(show).toHaveBeenCalledTimes(1); + await settleOpen({ kind: "result", text: "first" }); + await expect(first).resolves.toEqual({ id: "c1", ok: true, value: "first" }); + }); + }); + }); + it("turns a thrown capability into a failed result", async () => { const { handle } = setup({ getPushToken: jest.fn().mockRejectedValue(new Error("APNs down")) }); await expect(handle(call("getPushToken"), PAGE)).resolves.toEqual({ diff --git a/__tests__/lib/shell/bridge-protocol.test.ts b/__tests__/lib/shell/bridge-protocol.test.ts index d5701a4..c66ce83 100644 --- a/__tests__/lib/shell/bridge-protocol.test.ts +++ b/__tests__/lib/shell/bridge-protocol.test.ts @@ -2,11 +2,13 @@ import { BRIDGE_CAPABILITIES, BRIDGE_METHODS, MAX_MESSAGE_LENGTH, + SCAN_FORMATS, PUSH_TOKEN_CHANGED_EVENT, SHELL_EVENTS, parseInbound, shellEventType, } from "~/lib/shell/bridge/protocol"; +import type { BarcodeType } from "expo-camera"; describe("capabilities", () => { it("announces every method and every shell event", () => { @@ -14,6 +16,13 @@ describe("capabilities", () => { expect(BRIDGE_CAPABILITIES).toContain("pushTokenChanged"); }); + it("announces scan, with formats named as the camera names them", () => { + expect(BRIDGE_CAPABILITIES).toContain("scan"); + // Compile-time: every scan format is a format expo-camera reads. + const readable: readonly BarcodeType[] = SCAN_FORMATS; + expect(new Set(readable).size).toBe(SCAN_FORMATS.length); + }); + it("names a shell event's DOM type after its capability", () => { expect(shellEventType("pushTokenChanged")).toBe(PUSH_TOKEN_CHANGED_EVENT); expect(PUSH_TOKEN_CHANGED_EVENT).toBe("objectstack-native:pushTokenChanged"); diff --git a/__tests__/lib/shell/bridge-scripts.test.ts b/__tests__/lib/shell/bridge-scripts.test.ts index 97f50a1..9517d60 100644 --- a/__tests__/lib/shell/bridge-scripts.test.ts +++ b/__tests__/lib/shell/bridge-scripts.test.ts @@ -42,6 +42,9 @@ function makePage(origin = "https://acme.example.com") { return { window, posted, cssVars, listeners, run }; } +/** The id of a call the page posted. */ +const idOf = (message: unknown) => (message as { id: string }).id; + describe("toScriptLiteral", () => { it("escapes characters that could break out of a script", () => { expect(toScriptLiteral("\u2028\u2029")).toBe('"\\u003c/script>\\u2028\\u2029"'); @@ -112,18 +115,80 @@ describe("bootstrap script", () => { page.run(buildBootstrapScript(info, SERVER_URL)); const pending = page.window.ObjectStackNative.haptic("success"); expect(page.posted).toEqual([ - { type: "call", id: "c1", method: "haptic", args: { kind: "success" } }, + { type: "call", id: expect.any(String), method: "haptic", args: { kind: "success" } }, ]); - page.run(buildResolveScript({ id: "c1", ok: true, value: null })); + page.run(buildResolveScript({ id: idOf(page.posted[0]), ok: true, value: null })); await expect(pending).resolves.toBeNull(); }); + it("gives every call of a document its own id, under a token of that document", () => { + const page = makePage(); + page.run(buildBootstrapScript(info, SERVER_URL)); + void page.window.ObjectStackNative.getPushToken(); + void page.window.ObjectStackNative.getPushToken(); + const [first, second] = page.posted.map(idOf); + expect(first).toMatch(/^[a-z0-9]+\.1$/); + expect(second).toBe(first.replace(/1$/, "2")); + expect(first.length).toBeLessThanOrEqual(64); + }); + + it("settles no call of a later document with an answer meant for an earlier one", async () => { + // A scan stays open while the page reloads, recovers or goes to a sign-in + // host; each new document numbers its calls from 1 again. + const before = makePage(); + before.run(buildBootstrapScript(info, SERVER_URL)); + void before.window.ObjectStackNative.scan(); + const staleAnswer = buildResolveScript({ id: idOf(before.posted[0]), ok: true, value: "CODE" }); + + for (const origin of ["https://acme.example.com", "https://login.idp.example"]) { + const after = makePage(origin); + after.run(buildBootstrapScript(info, SERVER_URL)); + let settled = false; + const pending = after.window.ObjectStackNative.getPushToken().then(() => (settled = true)); + after.run(staleAnswer); + await Promise.resolve(); + expect(settled).toBe(false); + after.run(buildResolveScript({ id: idOf(after.posted[0]), ok: true, value: null })); + await pending; + expect(settled).toBe(true); + } + }); + + it("posts scan calls with the caller's options, or none", () => { + const page = makePage(); + page.run(buildBootstrapScript({ ...info, capabilities: ["scan"] }, SERVER_URL)); + const api = page.window.ObjectStackNative; + expect(api.has("scan")).toBe(true); + void api.scan(); + void api.scan({ formats: ["ean13", "qr"] }); + expect(page.posted).toEqual([ + { type: "call", id: expect.any(String), method: "scan" }, + { type: "call", id: expect.any(String), method: "scan", args: { formats: ["ean13", "qr"] } }, + ]); + }); + + it("resolves a scan with the decoded text, or null on cancel", async () => { + const page = makePage(); + page.run(buildBootstrapScript(info, SERVER_URL)); + const read = page.window.ObjectStackNative.scan(); + const cancelled = page.window.ObjectStackNative.scan(); + const [readId, cancelId] = page.posted.map(idOf); + page.run(buildResolveScript({ id: readId, ok: true, value: "4006381333931" })); + page.run(buildResolveScript({ id: cancelId, ok: true, value: null })); + await expect(read).resolves.toBe("4006381333931"); + await expect(cancelled).resolves.toBeNull(); + }); + it("rejects failed calls with the error code", async () => { const page = makePage(); page.run(buildBootstrapScript(info, SERVER_URL)); const pending = page.window.ObjectStackNative.navigate("bad"); page.run( - buildResolveScript({ id: "c1", ok: false, error: { code: "bad_args", message: "nope" } }) + buildResolveScript({ + id: idOf(page.posted[0]), + ok: false, + error: { code: "bad_args", message: "nope" }, + }) ); await expect(pending).rejects.toMatchObject({ message: "nope", code: "bad_args" }); }); diff --git a/__tests__/lib/shell/scan-session.test.ts b/__tests__/lib/shell/scan-session.test.ts new file mode 100644 index 0000000..eda81ab --- /dev/null +++ b/__tests__/lib/shell/scan-session.test.ts @@ -0,0 +1,82 @@ +import { + SCAN_BUSY_MESSAGE, + SCAN_DENIED_MESSAGE, + currentScan, + requestScan, + settleScan, +} from "~/lib/shell/bridge/scan-session"; + +/** Settle whatever scan is open; fails the test when none is. */ +function settleOpen(outcome: Parameters[1]) { + const open = currentScan(); + if (!open) throw new Error("no scan open"); + expect(settleScan(open.token, outcome)).toBe(true); +} + +afterEach(() => { + const open = currentScan(); + if (open) settleScan(open.token, { kind: "cancel" }); +}); + +describe("scan session", () => { + it("shows the scanner with the formats asked for, and resolves the decoded text", async () => { + const show = jest.fn(() => { + expect(currentScan()?.formats).toEqual(["ean13"]); + }); + const scan = requestScan(["ean13"], show); + expect(show).toHaveBeenCalledTimes(1); + settleOpen({ kind: "result", text: "4006381333931" }); + await expect(scan).resolves.toBe("4006381333931"); + expect(currentScan()).toBeNull(); + }); + + it("resolves null on cancel", async () => { + const scan = requestScan(["qr"], jest.fn()); + settleOpen({ kind: "cancel" }); + await expect(scan).resolves.toBeNull(); + }); + + it("rejects when the camera permission is denied", async () => { + const scan = requestScan(["qr"], jest.fn()); + settleOpen({ kind: "denied" }); + await expect(scan).rejects.toThrow(SCAN_DENIED_MESSAGE); + }); + + it("refuses a second scan while one is open, without showing it or touching the first", async () => { + const show = jest.fn(); + const first = requestScan(["qr"], show); + const token = currentScan()?.token; + await expect(requestScan(["ean13"], show)).rejects.toThrow(SCAN_BUSY_MESSAGE); + expect(show).toHaveBeenCalledTimes(1); + expect(currentScan()).toEqual({ token, formats: ["qr"] }); + settleOpen({ kind: "result", text: "first" }); + await expect(first).resolves.toBe("first"); + }); + + it("settles once: a second settle of the same scan changes nothing", async () => { + const scan = requestScan(["qr"], jest.fn()); + const token = currentScan()!.token; + expect(settleScan(token, { kind: "result", text: "a" })).toBe(true); + expect(settleScan(token, { kind: "cancel" })).toBe(false); + await expect(scan).resolves.toBe("a"); + }); + + it("ignores a settle for a scan that is no longer the open one", async () => { + const old = requestScan(["qr"], jest.fn()); + const oldToken = currentScan()!.token; + settleScan(oldToken, { kind: "cancel" }); + await old; + const next = requestScan(["qr"], jest.fn()); + expect(settleScan(oldToken, { kind: "result", text: "stale" })).toBe(false); + settleOpen({ kind: "result", text: "fresh" }); + await expect(next).resolves.toBe("fresh"); + }); + + it("rejects, and leaves nothing open, when the scanner cannot be shown", async () => { + const scan = requestScan(["qr"], () => { + throw new Error("no navigator"); + }); + await expect(scan).rejects.toThrow("no navigator"); + expect(currentScan()).toBeNull(); + }); +}); diff --git a/app/bridge-scan.tsx b/app/bridge-scan.tsx new file mode 100644 index 0000000..6c0babe --- /dev/null +++ b/app/bridge-scan.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; +import type { PermissionStatus } from "expo-camera"; +import { QrScanner } from "~/components/shell/QrScanner"; +import { currentScan, settleScan, type ScanOutcome } from "~/lib/shell/bridge/scan-session"; + +const acceptAny = (code: string) => (code.length > 0 ? code : null); + +/** + * The scanner a Console page opens with the bridge's `scan()`: reads the + * formats of the open scan (`lib/shell/bridge/scan-session.ts`) and settles it + * with the first code read, `null` when the user closes it, or a failure when + * the camera permission is refused. + * + * Every way out settles the scan — the close control, Android back and the + * iOS swipe alike, the last two through the unmount. Opened with no scan for + * it (a link to the route), it closes at once. + */ +export default function BridgeScanScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const scan = React.useRef(currentScan()).current; + const deniedRef = React.useRef(false); + + const leave = React.useCallback(() => { + if (router.canGoBack()) router.back(); + else router.replace("/"); + }, [router]); + + const giveUp = React.useCallback((): ScanOutcome => { + return deniedRef.current ? { kind: "denied" } : { kind: "cancel" }; + }, []); + + React.useEffect(() => { + if (!scan) { + leave(); + return; + } + return () => { + settleScan(scan.token, giveUp()); + }; + }, [scan, leave, giveUp]); + + const onPermissionStatus = React.useCallback((status: PermissionStatus) => { + deniedRef.current = (status as string) === "denied"; + }, []); + + const onResult = React.useCallback( + (text: string) => { + if (scan) settleScan(scan.token, { kind: "result", text }); + leave(); + }, + [scan, leave] + ); + + const onClose = React.useCallback(() => { + if (scan) settleScan(scan.token, giveUp()); + leave(); + }, [scan, leave, giveUp]); + + const barcodeTypes = React.useMemo(() => (scan ? [...scan.formats] : []), [scan]); + + if (!scan) return null; + + return ( + + ); +} diff --git a/app/console.tsx b/app/console.tsx index d585217..36a9bac 100644 --- a/app/console.tsx +++ b/app/console.tsx @@ -10,6 +10,7 @@ import { acquireDeviceToken } from "~/lib/push-notifications"; import { triggerHaptic } from "~/lib/haptics"; import { shellConfig } from "~/lib/shell/config"; import { openExternalUrl } from "~/lib/shell/external"; +import { requestScan } from "~/lib/shell/bridge/scan-session"; import { DEFAULT_ROUTE_RULES } from "~/lib/shell/route-owner"; import { AVAILABLE_NATIVE_SCREENS, NATIVE_SCREENS } from "~/lib/shell/native-screens"; import { useServerStore } from "~/stores/server-store"; @@ -48,6 +49,7 @@ export default function ConsoleScreen() { await reset(); router.replace("/server-config"); }, + scan: (formats) => requestScan(formats, () => router.push("/bridge-scan")), }), [reset, router] ); diff --git a/components/shell/ConsoleWebView.tsx b/components/shell/ConsoleWebView.tsx index 8724d3e..4da0a2a 100644 --- a/components/shell/ConsoleWebView.tsx +++ b/components/shell/ConsoleWebView.tsx @@ -114,6 +114,12 @@ export const ConsoleWebView = forwardRef(null); + // Counts WebView instances: a call's answer is never sent to a re-created + // WebView. A `scan()` stays open as long as the user takes, and recovery + // can re-create the WebView meanwhile. Load starts do not count: Android + // reports one for every SPA history change too. A page replaced within the + // same WebView is covered by the bootstrap's per-document call ids. + const documentRef = useRef(0); const goTo = useCallback( (route: string) => { @@ -175,6 +181,7 @@ export const ConsoleWebView = forwardRef i + 1); }, []); @@ -282,8 +289,10 @@ export const ConsoleWebView = forwardRef { + const caller = documentRef.current; void handleMessage(event.nativeEvent.data, event.nativeEvent.url).then((result) => { - if (result) webviewRef.current?.injectJavaScript(buildResolveScript(result)); + if (!result || documentRef.current !== caller) return; + webviewRef.current?.injectJavaScript(buildResolveScript(result)); }); }, [handleMessage] diff --git a/components/shell/QrScanner.tsx b/components/shell/QrScanner.tsx index c277796..d48f693 100644 --- a/components/shell/QrScanner.tsx +++ b/components/shell/QrScanner.tsx @@ -1,10 +1,17 @@ import React from "react"; import { Linking, Pressable, StyleSheet, Text, View } from "react-native"; -import { CameraView, useCameraPermissions, type BarcodeScanningResult } from "expo-camera"; +import { + CameraView, + useCameraPermissions, + type BarcodeScanningResult, + type BarcodeType, + type PermissionStatus, +} from "expo-camera"; import { SafeAreaView } from "react-native-safe-area-context"; import { useTranslation } from "react-i18next"; import { Flashlight, FlashlightOff, X } from "lucide-react-native"; import { Button } from "~/components/ui/Button"; +import { useLayoutDirection } from "~/lib/rtl"; /** How long "not a … code" stays up after a refused code leaves the frame. */ export const REJECT_MESSAGE_MS = 3000; @@ -27,8 +34,19 @@ export interface QrScannerProps { note?: string; /** A way to finish without the camera, e.g. "Enter address manually". */ manualAction?: { label: string; onPress: () => void }; + /** Code formats the camera reads; defaults to QR codes only. */ + barcodeTypes?: BarcodeType[]; + /** + * The permission panel's copy when the default (which speaks of the QR code + * and of entering the address) does not fit the caller. + */ + permissionText?: { body: string; blocked: string }; + /** Told the camera permission's status each time it is read or changes. */ + onPermissionStatus?: (status: PermissionStatus) => void; } +const QR_ONLY: BarcodeType[] = ["qr"]; + const WHITE = "#ffffff"; /** @@ -48,8 +66,14 @@ export function QrScanner({ hint, note, manualAction, + barcodeTypes = QR_ONLY, + permissionText, + onPermissionStatus, }: QrScannerProps) { const { t } = useTranslation(); + // On the screen root, as every native screen does: right-to-left under `ar` + // without relying on the app root's direction reaching a pushed screen. + const direction = useLayoutDirection(); const [permission, requestPermission] = useCameraPermissions(); const [torch, setTorch] = React.useState(false); const [rejected, setRejected] = React.useState(false); @@ -68,6 +92,11 @@ export function QrScanner({ void requestPermission(); }, [permission, requestPermission]); + const status = permission?.status; + React.useEffect(() => { + if (status) onPermissionStatus?.(status); + }, [status, onPermissionStatus]); + React.useEffect( () => () => { if (rejectTimer.current) clearTimeout(rejectTimer.current); @@ -127,7 +156,7 @@ export function QrScanner({ if (!permission || !permission.granted) { const blocked = !!permission && !permission.canAskAgain; return ( - + {closeButton} {permission ? ( @@ -135,7 +164,9 @@ export function QrScanner({ {t("scanner.permission.title")} - {t(blocked ? "scanner.permission.blocked" : "scanner.permission.body")} + {permissionText + ? permissionText[blocked ? "blocked" : "body"] + : t(blocked ? "scanner.permission.blocked" : "scanner.permission.body")} {blocked ? (