diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cbe0c2d00..2aee1bb5f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -60,6 +60,9 @@ + diff --git a/src/app/(dashboard)/dashboard/pos/receipts/[saleId]/receipt-print-actions.tsx b/src/app/(dashboard)/dashboard/pos/receipts/[saleId]/receipt-print-actions.tsx index 973e1a44d..313894525 100644 --- a/src/app/(dashboard)/dashboard/pos/receipts/[saleId]/receipt-print-actions.tsx +++ b/src/app/(dashboard)/dashboard/pos/receipts/[saleId]/receipt-print-actions.tsx @@ -1,8 +1,12 @@ "use client"; -import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { type MouseEvent, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { isAndroidInstalledClient, printThermalBle } from "@/lib/pos/thermal-ble"; -import { printViaNativeBridge } from "@/lib/pos/native-print"; +import { + nativePrintIsExpired, + prepareNativePrint, + type PreparedNativePrint, +} from "@/lib/pos/native-print"; import type { ThermalPaper, ThermalReceipt } from "@/lib/pos/thermal-receipt"; function subscribeInstalledApp(onChange: () => void) { @@ -48,6 +52,11 @@ export function ReceiptPrintActions({ ); const [printing, setPrinting] = useState(false); const [message, setMessage] = useState(null); + const [preparedNative, setPreparedNative] = useState< + (PreparedNativePrint & { saleId: string; paper: ThermalPaper }) | null + >(null); + const readyNative = + preparedNative?.saleId === saleId && preparedNative.paper === paper ? preparedNative : null; useEffect(() => { if (!auto || triggered.current || environment !== "browser") return; @@ -56,20 +65,33 @@ export function ReceiptPrintActions({ return () => window.clearTimeout(id); }, [auto, environment]); - async function printNative() { + async function prepareNative() { if (printing) return; setPrinting(true); + setPreparedNative(null); setMessage(null); try { - await printViaNativeBridge(saleId, paper); - setMessage("Opening the printer screen in the MenuMinute app…"); + const prepared = await prepareNativePrint(saleId, paper, window.location.href); + setPreparedNative({ ...prepared, saleId, paper }); + setMessage("Receipt ready for two minutes. Tap Open printer to choose your device."); } catch (error) { - setMessage(error instanceof Error ? error.message : "Could not start printing."); + setMessage(error instanceof Error ? error.message : "Could not prepare printing."); } finally { setPrinting(false); } } + // This callback runs synchronously on a fresh link click, with no awaited network work. + function openNative(event: MouseEvent) { + if (!readyNative || nativePrintIsExpired(readyNative)) { + event.preventDefault(); + setPreparedNative(null); + setMessage("This print request expired. Tap Prepare receipt again."); + return; + } + setMessage("Opening the MenuMinute printer screen…"); + } + async function printBluetooth() { if (printing) return; setPrinting(true); @@ -97,14 +119,21 @@ export function ReceiptPrintActions({ return ( {environment === "ble" && nativePrint && ( - void printNative()} - disabled={printing} - className="receipt-print-button" - > - Print thermal receipt (Bluetooth / USB) - + <> + void prepareNative()} + disabled={printing} + className="receipt-print-button" + > + {printing ? "Preparing receipt…" : "Prepare thermal receipt (Bluetooth / USB)"} + + {readyNative && ( + + Open printer (Bluetooth / USB) + + )} + > )} {environment === "ble" && ( { +/** Prepared asynchronously; the browser must launch the intent on a subsequent direct tap. */ +export interface PreparedNativePrint { + intentUrl: string; + expiresAtMs: number; +} + +/** Never navigate to intent:// after awaiting fetch: Chrome may have lost user activation. */ +export async function prepareNativePrint( + saleId: string, + paper: ThermalPaper, + fallbackUrl: string +): Promise { const response = await fetch( `/api/pos/receipts/${encodeURIComponent(saleId)}/native-print-grant`, { @@ -32,7 +42,22 @@ export async function printViaNativeBridge(saleId: string, paper: ThermalPaper): const problem = (await response.json().catch(() => null)) as { message?: string } | null; throw new Error(problem?.message ?? "Could not start printing. Use Print / save PDF."); } - const body = (await response.json()) as { grant?: unknown }; - if (typeof body.grant !== "string") throw new Error("Could not start printing."); - window.location.href = buildNativePrintIntentUrl(body.grant, window.location.href); + const body = (await response.json()) as { grant?: unknown; expiresAt?: unknown }; + const expiresAtMs = typeof body.expiresAt === "string" ? Date.parse(body.expiresAt) : NaN; + if ( + typeof body.grant !== "string" || + !Number.isFinite(expiresAtMs) || + expiresAtMs <= Date.now() + ) { + throw new Error("Could not prepare a valid print request. Please try again."); + } + return { + intentUrl: buildNativePrintIntentUrl(body.grant, fallbackUrl), + expiresAtMs, + }; +} + +/** Recheck expiry immediately before following the prepared link. */ +export function nativePrintIsExpired(prepared: PreparedNativePrint, nowMs = Date.now()): boolean { + return prepared.expiresAtMs <= nowMs + 1000; } diff --git a/tests/android-launch-manifest.test.ts b/tests/android-launch-manifest.test.ts index f567ac76f..40cd92a92 100644 --- a/tests/android-launch-manifest.test.ts +++ b/tests/android-launch-manifest.test.ts @@ -57,6 +57,15 @@ describe("Android Browser Helper startup manifest completeness", () => { expect(label.length).toBeLessThanOrEqual(14); }); + it("matches Android Browser Helper's literal navigation-divider metadata keys", () => { + // GoogleChrome/android-browser-helper LauncherActivityMetadata uses "androix" here. + // This is an upstream typo in the published contract, not a typo in our manifest. + for (const key of ["NAVIGATION_BAR_DIVIDER_COLOR", "NAVIGATION_BAR_DIVIDER_COLOR_DARK"]) { + expect(application).toContain(`android:name="androix.browser.trusted.${key}"`); + expect(application).not.toContain(`android:name="androidx.browser.trusted.${key}"`); + } + }); + it("keeps merchant deep links in the app and diner menus in the browser", () => { for (const prefix of ["/api/app/launch", "/app", "/login", "/dashboard", "/kitchen", "/invite"]) expect(application).toContain(`android:pathPrefix="${prefix}"`); diff --git a/tests/e2e/menu-product.spec.ts b/tests/e2e/menu-product.spec.ts index 214712b40..bb97e2c90 100644 --- a/tests/e2e/menu-product.spec.ts +++ b/tests/e2e/menu-product.spec.ts @@ -651,9 +651,15 @@ test("Tabletop Press is responsive and uses real QR media for all printable form await page.getByRole("button", { name: "Sign in", exact: true }).click(); await expect(page).toHaveURL(/dashboard/); await page.goto("/dashboard/qr-codes"); - const studio = page.getByTestId("tabletop-press"); + // Next App Router may briefly retain an inactive route tree during navigation. + // Require exactly one VISIBLE studio so genuine duplicate active UIs still fail, + // instead of matching both the visible section and a hidden retained tree. + const studio = page.locator('#workspace-main [data-testid="tabletop-press"]:visible'); + await expect(studio).toHaveCount(1); await expect(studio.getByRole("heading", { name: /Print once/ })).toBeVisible(); - await expect(studio.getByLabel("QR inventory at a glance")).toBeVisible(); + const inventory = studio.getByRole("region", { name: "QR inventory at a glance" }); + await expect(inventory).toHaveCount(1); + await expect(inventory).toBeVisible(); await expect(studio.getByRole("heading", { name: "Your QR library" })).toBeVisible(); await studio.locator('summary[aria-label="About Active codes"]').click(); await expect(studio.getByText("Currently usable permanent destination tokens.")).toBeVisible(); diff --git a/tests/native-print-client.test.ts b/tests/native-print-client.test.ts index 324864a21..1ebd840ee 100644 --- a/tests/native-print-client.test.ts +++ b/tests/native-print-client.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it } from "vitest"; -import { ANDROID_PACKAGE, buildNativePrintIntentUrl } from "../src/lib/pos/native-print"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ANDROID_PACKAGE, + buildNativePrintIntentUrl, + nativePrintIsExpired, + prepareNativePrint, +} from "../src/lib/pos/native-print"; + +afterEach(() => vi.unstubAllGlobals()); describe("native print intent URL", () => { it("is restricted to the MenuMinute package and carries only the grant", () => { @@ -19,3 +26,45 @@ describe("native print intent URL", () => { expect(() => buildNativePrintIntentUrl(bad, "https://x")).toThrow(); }); }); + +describe("two-tap native print handoff", () => { + it("prepares an intent without navigating after the asynchronous request", async () => { + const grant = "abc_DEF-123.sig_456-xyz"; + const expiresAt = new Date(Date.now() + 120_000).toISOString(); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ grant, expiresAt }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const prepared = await prepareNativePrint( + "sale-1", + "80", + "https://food.bababuilds.com/dashboard/pos/receipts/sale-1" + ); + expect(fetchMock).toHaveBeenCalledWith( + "/api/pos/receipts/sale-1/native-print-grant", + expect.objectContaining({ method: "POST", credentials: "same-origin" }) + ); + expect(prepared.intentUrl).toBe( + buildNativePrintIntentUrl(grant, "https://food.bababuilds.com/dashboard/pos/receipts/sale-1") + ); + expect(nativePrintIsExpired(prepared)).toBe(false); + expect(nativePrintIsExpired(prepared, prepared.expiresAtMs)).toBe(true); + }); + + it("rejects expired and malformed grant responses before exposing a print link", async () => { + for (const body of [ + { grant: "abc.def", expiresAt: new Date(Date.now() - 1000).toISOString() }, + { grant: "abc.def", expiresAt: "not-a-date" }, + { grant: 42, expiresAt: new Date(Date.now() + 120_000).toISOString() }, + ]) { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status: 200 })) + ); + await expect( + prepareNativePrint("sale-1", "58", "https://food.bababuilds.com/dashboard/pos") + ).rejects.toThrow(); + } + }); +});