Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/146-bridge-scan.md
Original file line number Diff line number Diff line change
@@ -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.
61 changes: 61 additions & 0 deletions __tests__/components/ConsoleWebView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function renderConsole(overrides: Partial<ConsoleWebViewProps> = {}) {
haptic: jest.fn(),
openExternal: jest.fn().mockResolvedValue(undefined),
resetServer: jest.fn().mockResolvedValue(undefined),
scan: jest.fn().mockResolvedValue(null),
},
onOpenNative: jest.fn(),
onOpenExternal: jest.fn(),
Expand Down Expand Up @@ -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<string | null>((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 () => {
Expand Down Expand Up @@ -378,6 +438,7 @@ describe("ConsoleWebView", () => {
haptic: jest.fn(),
openExternal: jest.fn(),
resetServer: jest.fn(),
scan: jest.fn(),
},
onOpenNative: jest.fn(),
onOpenExternal: jest.fn(),
Expand Down
178 changes: 175 additions & 3 deletions __tests__/components/QrScanner.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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" };
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof requestScan>[0] = ["ean13", "qr"]) {
const answer = requestScan(formats, () => {});
render(<BridgeScanScreen />);
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(<BridgeScanScreen />);
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(<BridgeScanScreen />).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(<BridgeScanScreen />);
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(<ConnectScanScreen />);
expect(rootDirection()).toBe("rtl");
expect(screen.getByTestId("qr-scanner-manual")).toHaveTextContent("إدخال العنوان يدويًا");
screen.unmount();

void requestScan(["qr"], () => {});
render(<BridgeScanScreen />);
expect(rootDirection()).toBe("rtl");
expect(screen.getByTestId("qr-scanner-hint")).toHaveTextContent("ضع الرمز داخل الإطار");
});
});
Loading
Loading