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
3 changes: 3 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@
<meta-data
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR_DARK"
android:resource="@color/mm_navigation_bar_dark" />
<!-- Intentional upstream Android Browser Helper spelling:
LauncherActivityMetadata uses "androix", not "androidx", for divider keys.
Renaming this apparent typo makes ABH ignore the theme divider colours. -->
<meta-data
android:name="androix.browser.trusted.NAVIGATION_BAR_DIVIDER_COLOR"
android:resource="@color/mm_navigation_divider" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -48,6 +52,11 @@ export function ReceiptPrintActions({
);
const [printing, setPrinting] = useState(false);
const [message, setMessage] = useState<string | null>(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;
Expand All @@ -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<HTMLAnchorElement>) {
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);
Expand Down Expand Up @@ -97,14 +119,21 @@ export function ReceiptPrintActions({
return (
<div className="receipt-print-actions">
{environment === "ble" && nativePrint && (
<button
type="button"
onClick={() => void printNative()}
disabled={printing}
className="receipt-print-button"
>
Print thermal receipt (Bluetooth / USB)
</button>
<>
<button
type="button"
onClick={() => void prepareNative()}
disabled={printing}
className="receipt-print-button"
>
{printing ? "Preparing receipt…" : "Prepare thermal receipt (Bluetooth / USB)"}
</button>
{readyNative && (
<a href={readyNative.intentUrl} onClick={openNative} className="receipt-print-button">
Open printer (Bluetooth / USB)
</a>
)}
</>
)}
{environment === "ble" && (
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@
font-weight: 700;
cursor: pointer;
}
.receipt-toolbar a.receipt-print-button {
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
text-decoration: none;
}
.receipt-paper {
width: 72mm;
max-width: 100%;
Expand Down
35 changes: 30 additions & 5 deletions src/lib/pos/native-print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,18 @@ export function buildNativePrintIntentUrl(grant: string, fallbackUrl: string): s
);
}

/** Must be called from a user gesture: Chrome only follows intent: navigations after a tap. */
export async function printViaNativeBridge(saleId: string, paper: ThermalPaper): Promise<void> {
/** 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<PreparedNativePrint> {
const response = await fetch(
`/api/pos/receipts/${encodeURIComponent(saleId)}/native-print-grant`,
{
Expand All @@ -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;
}
9 changes: 9 additions & 0 deletions tests/android-launch-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}"`);
Expand Down
10 changes: 8 additions & 2 deletions tests/e2e/menu-product.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
53 changes: 51 additions & 2 deletions tests/native-print-client.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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();
}
});
});
Loading