diff --git a/.github/workflows/scanner.yml b/.github/workflows/scanner.yml index 9b89f1e..8271e91 100644 --- a/.github/workflows/scanner.yml +++ b/.github/workflows/scanner.yml @@ -37,6 +37,8 @@ jobs: cache-read-only: ${{ github.event_name == 'pull_request' }} - name: Verify source headers, whitespace, imports and JavaScript syntax run: node scripts/ci/check-source-standards.mjs + - name: Run no-device Echo fixture guard tests + run: node --test scripts/ci/scanner-echo-guards.test.mjs - name: Install required Android SDK packages run: | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;34.0.0" "build-tools;35.0.0" "platform-tools" diff --git a/AGENTS.md b/AGENTS.md index f2c1c89..201f6c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,4 +24,4 @@ Never log or place passwords, session cookies, or device tokens in process value ## Repository automation -CircleCI is managed by `kingsrook/qqq-orb` using `.qqq-ci.yml`; preserve that lane and its main-only library publication. GitHub `Scanner Android` checks build and deliver a debug APK artifact and never publish libraries or production releases. Do not replace managed CI with a stale local template. +The primary forge is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive, so make no ordinary GitHub writes. Protected `main` on Gitea has `.gitea/workflows/qrun-android-release.yaml` calling the shared `KofTwentyTwo/platform-ci` `android.yaml@qrun-release-v1` (not present in this feature working tree); pull-request checks come from central Gitea policy, not from that release workflow. CircleCI is managed by `kingsrook/qqq-orb` using `.qqq-ci.yml`; preserve that lane and its main-only library publication. The legacy `.github` `Scanner Android` workflow builds a debug APK artifact and never publishes libraries or production releases; it and the scanner release workflow are not a selected release path. Production signing/distribution is deferred: create no keys, tags or releases. Do not replace managed CI with a stale local template. diff --git a/README.md b/README.md index 0b4484b..0ee1caf 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # QQQ Android -[![Scanner Android](https://github.com/QRun-IO/qqq-android/actions/workflows/scanner.yml/badge.svg)](https://github.com/QRun-IO/qqq-android/actions/workflows/scanner.yml) - Native Kotlin/Compose clients for QQQ. The new `scanner-host` application runs server-defined QQQ apps and processes on Android handhelds, initially Zebra TC22. ## Modules @@ -17,7 +15,7 @@ The scanner host uses the versioned semantic UI API for app discovery, native pr ## First scanner application -Use the companion `qbit-mobile-scanners` server/demo with QQQ `4.1.0-RC.20`. The demo uses QQQ TABLE_BASED username/password authentication. Credentials are exchanged through the native QQQ session endpoint; the password is not stored. +Use the companion `qbit-mobile-scanners` server/demo with its current QQQ source default (`4.1.0-RC.21`; see that repository's POM). A local server may run an older qualified core; the host checks the advertised UI API and extension versions before login. The demo uses QQQ TABLE_BASED username/password authentication. Credentials are exchanged through the native QQQ session endpoint; the password is not stored. 1. Start the companion demo and install the debug APK. 2. Run `adb reverse tcp:8080 tcp:8080` for a USB-connected TC22. @@ -38,11 +36,11 @@ The same APK runs on other Android devices with explicit manual entry. When the The shared launcher name/icon remain QQQ Mobile. On connecting to a server, the host reads public `GET /qqq/v1/qbit/mobile-scanners/branding` (`displayName`, `primaryColor`, optional `logoUrl`). The name and Material theme apply to login, device approval, and native app screens. Names are limited to 80 printable characters and colors to `#RRGGBB`; malformed values fall back to QQQ defaults. Button foregrounds choose readable black/white contrast. Optional logos must use HTTPS, are fetched without session/device headers or cookies, and cannot prevent login if unavailable. Responses are size-limited, redirects are disabled, and branding is reset when changing servers. Reconnect to pick up server branding changes; no APK rebuild is needed. -The persistent footer shows **Checking**, **Connected**, or **Disconnected** with the last check time. It checks `GET /qqq/v1/qbit/mobile-scanners/status` every five seconds while foregrounded and on network changes; `Connected` means a valid `{ "status": "ok" }` response, not sign-in or device approval. Tap the footer for **Copy**, **Clear**, and **Close** diagnostics. The in-memory log holds the latest 100 fixed event codes with timestamps, HTTP status, and latency for branding, connection checks, native QQQ calls (including login/process requests), and device enrollment/status requests. A transport failure has no HTTP status; an HTTP response retains its status, including 401/403/500 errors. The log contains no credentials, scan values, request paths, queries, raw response text, or exception messages. Changing servers clears the displayed diagnostics. Monitoring never queues or retries a scan. +The persistent footer shows **Checking**, **Connected**, or **Disconnected** with the last check time. It checks `GET /qqq/v1/qbit/mobile-scanners/status` every five seconds while foregrounded and on network changes; `Connected` means a valid `{ "status": "ok" }` response, not sign-in or device approval. Tap the footer, or use the Diagnostics entry in the menu, to open a full-screen diagnostics view with a server connection summary (reachability only), the recent activity newest first in local time, **All** and **Problems** filters, **Pause updates** and **Resume live updates**, **Copy**, **Share** and **Clear**, and **Back**. While it is open, process input stays blocked. The in-memory log holds the latest 100 fixed event codes with timestamps, HTTP status, and latency for branding, connection checks, native QQQ calls (including login/process requests), and device enrollment/status requests. A transport failure has no HTTP status; an HTTP response retains its status, including 401/403/500 errors. The log contains no credentials, scan values, request paths, queries, raw response text, or exception messages. Changing servers clears the displayed diagnostics. Monitoring never queues or retries a scan. ## Online-only behavior -QQQ administrators can inspect session activity, end sessions, and request live viewing of the QQQ app from a session record. Viewing displays an indicator with **Stop viewing** on the handheld. Password/login screens and background apps cannot be mirrored. Foreground location reporting defaults on and can be disabled in Settings. It requires Android permission and enabled device location providers; scanning works without it. See [session behavior and limits](docs/UI-API.md#session-activity-and-screen-viewing). +QQQ administrators can inspect session activity, end sessions, and request live viewing of the QQQ app from a session record. Viewing displays an informational indicator on the handheld; the app has no operator pause or stop control. Password/login screens and background apps cannot be mirrored. Optional whole-device sharing still requires Android consent and honors Android's system stop controls. Foreground location reporting defaults on and can be disabled in Settings. It requires Android permission and enabled device location providers; scanning works without it. See [session behavior and limits](docs/UI-API.md#session-activity-and-screen-viewing). Open **Settings → About QQQ Mobile** for the app version, Query mascot, [Dogfooding](https://www.qrun.io/dogfooding), [Query](https://www.qrun.io/query) and bundled open-source licenses. Public website links open in the external browser without adding session or device information. @@ -52,7 +50,7 @@ Enrollment credentials are encrypted with Android Keystore and stored under `noB ## Build -Requirements: Java 17 or compatible newer runtime, Android SDK platform35, build tools34/35, and the included Gradle8.7 wrapper. Configure `ANDROID_HOME` or ignored `local.properties`. +Requirements: Java 21, Android SDK platform35, build tools34/35, and the included Gradle8.7 wrapper. Configure `ANDROID_HOME` or ignored `local.properties`. ```powershell .\gradlew.bat :scanner-host:testDebugUnitTest :scanner-host:assembleDebug :scanner-host:lintDebug @@ -66,9 +64,9 @@ See [emulator testing](docs/EMULATOR.md) for a local interactive demo, synthetic ## CI and delivery -The managed CircleCI Android profile owns shared-library verification and its existing main-only Maven publication. The GitHub **Scanner Android** workflow adds shared/scanner JVM tests, scanner lint, debug APK assembly, SHA256, and downloadable artifacts for pull requests and supported branches. The API35 emulator lane runs the shared native UI tests and real-server scanner E2E on pushes and pull requests. See [emulator testing](docs/EMULATOR.md) for the isolated test package and contract checks. +The primary forge for source, reviews and checks is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive with no ordinary writes. The protected `main` branch on Gitea contains `.gitea/workflows/qrun-android-release.yaml`, which calls the shared `KofTwentyTwo/platform-ci` Android workflow (`android.yaml@qrun-release-v1`); that file is not in this feature working tree. Pull-request checks come from central Gitea policy, not from that release workflow. Legacy CircleCI and `.github` workflow files remain in the tree and are not all retired; the managed CircleCI profile still describes shared-library verification and its main-only Maven publication. Run the build commands above locally. See [emulator testing](docs/EMULATOR.md) for the isolated test package and contract checks, and [FINISH-1.0](docs/FINISH-1.0.md) for current progress. -The GitHub workflow does not publish libraries or production releases. Signed APK distribution remains a separate release task. See [Contributing](CONTRIBUTING.md), [Security](SECURITY.md), and [Changelog](CHANGELOG.md). +Scanner APK signing and distribution are deliberately deferred and no distribution channel is selected; the retained CircleCI lane keeps its existing library publication; the `.github` scanner release automation is a candidate only (see [releasing](docs/RELEASING.md)). See [Contributing](CONTRIBUTING.md), [Security](SECURITY.md), and [Changelog](CHANGELOG.md). ## Extension points diff --git a/deployment/company-profile/DEVICE-OWNER-ENROLLMENT.md b/deployment/company-profile/DEVICE-OWNER-ENROLLMENT.md new file mode 100644 index 0000000..33bafb0 --- /dev/null +++ b/deployment/company-profile/DEVICE-OWNER-ENROLLMENT.md @@ -0,0 +1,12 @@ +# Device Owner enrollment (destructive, separate from the safe profile) + +Not generated and not part of `generate-company-profile.mjs`. Fully managed (Device Owner) provisioning is intended for a new or factory-reset handheld (`docs/DEVICE-PROVISIONING.md`, Android device management). Doing it wipes the device's data and settings, including the current scanner and printing setup. + +Do not start unless all of these are recorded and approved by the owner of the device fleet: + +1. The customer EMM/MDM, its enrollment method and Zebra OEMConfig package (`com.zebra.oemconfig.release` on Android 13+). +2. A spare device, not the development TC22, with the ordinary safe profile already reviewed. +3. The recovery route if the EMM or EHS locks the device out, and the factory-reset authority. +4. Confirmation that the QQQ device approval and Android management states stay separate. + +Enrollment credentials, tokens and QR payloads belong in the EMM, never in this repository. diff --git a/deployment/company-profile/generate-company-profile.mjs b/deployment/company-profile/generate-company-profile.mjs new file mode 100644 index 0000000..de94225 --- /dev/null +++ b/deployment/company-profile/generate-company-profile.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +// Renders a reviewable Zebra Enterprise Home Screen (EHS) config and, when the device is measured, a StageNow/MX +// AppMgr document from one non-secret JSON profile. Only elements documented by Zebra are emitted; see +// docs/COMPANY-PROFILE.md for the page citations. Nothing here installs, pushes or changes a device. +// +// Usage: node generate-company-profile.mjs [--require-ready] + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { pathToFileURL } from "node:url" + +export const EHS_PACKAGE = "com.zebra.mdna.enterprisehomescreen" +export const EHS_ACTIVITY = "com.zebra.mdna.enterprisehomescreen.HomeScreenActivity" +const PACKAGE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/ +const TITLE_TOKENS = new Set(["bluetooth_address", "bluetooth_name", "imei_number", "mac_address", "network_host_name", "serial_number", "device_name"]) +const LOGO_EXTENSIONS = [".bmp", ".gif", ".jpg", ".png", ".webp"] +const APP_ICON_EXTENSIONS = [".bmp", ".jpg", ".png"] +const COLOR_NAMES = new Set(["red", "blue", "green", "black", "white", "gray", "cyan", "magenta", "yellow", "lightgray", "darkgray"]) +// Keys and values consumed by the app's RestrictionsManager reader (scanner-host res/xml/app_restrictions.xml). +export const MANAGED_SERVER_KEY = "qqq_server_url" +export const MANAGED_ORIENTATION_KEY = "qqq_orientation" +export const MANAGED_ORIENTATIONS = ["SYSTEM", "PORTRAIT", "LANDSCAPE", "REVERSE_PORTRAIT", "REVERSE_LANDSCAPE"] +const BLOCKED_WILDCARDS =/^com\.(a|an|and|andr|andro|androi|android)\*?$/ + +/******************************************************************************* + ** Mirror the app's normalizeServerUrl for managed values: HTTPS origin only. Returns the normalized origin or null. + *******************************************************************************/ +export function managedServerUrl(value) { + if (typeof value !== "string" || value.length > 2048) return null + try { + const url = new URL(value.trim()) + if (url.protocol !== "https:" || !url.hostname || url.username || url.password || url.search || url.hash || url.pathname !== "/") return null + return `https://${url.host}/` + } catch { return null } +} + +/******************************************************************************* + ** Escape the five characters EHS's XML parser requires to be escaped. + *******************************************************************************/ +export function xml(value) { + return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'") +} + +function devicePath(value, extensions, label, errors) { + if (value == null) return null + if (typeof value !== "string" || !value.startsWith("/enterprise/usr/") || value.split("/").includes("..") || !extensions.some((e) => value.toLowerCase().endsWith(e))) { + errors.push(`${label} must be an absolute /enterprise/usr/ path (persists across Enterprise Reset) ending in ${extensions.join(", ")}`) + return null + } + return value +} + +function color(value, label, errors) { + if (value == null) return null + if (typeof value !== "string" || !(/^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(value) || COLOR_NAMES.has(value))) errors.push(`${label} must be #RRGGBB, #AARRGGBB or a documented color name`) + return value +} + +function validateTitle(title, errors) { + if (typeof title !== "string" || title.trim() === "") { errors.push("launcher.title is required"); return { text: "", dynamic: false } } + const tokens = [...title.matchAll(/\[([^\]]*)\]/g)].map((m) => m[1]) + for (const token of tokens) if (!TITLE_TOKENS.has(token)) errors.push(`launcher.title token [${token}] is not a documented device ID`) + if (new Set(tokens).size > 1 || tokens.length > 1) errors.push("launcher.title may contain at most one device ID token") + if (title.replace(/\[[^\]]*\]/g, "").match(/[\[\]]/)) errors.push("launcher.title square brackets are only allowed around a documented device ID") + return { text: title, dynamic: tokens.length === 1 } +} + +function validateApp(app, index, list, errors, { icons }) { + const where = `${list}[${index}]` + if (!app || typeof app !== "object") { errors.push(`${where} must be an object`); return null } + const pkg = app.package + if (typeof pkg !== "string" || !PACKAGE.test(pkg)) { + errors.push(`${where}.package must be an exact package name (wildcards are rejected: least privilege)`) + if (typeof pkg === "string" && (pkg.includes("*") && BLOCKED_WILDCARDS.test(pkg))) errors.push(`${where}.package wildcard is blocked by EHS`) + return null + } + if (pkg === EHS_PACKAGE) errors.push(`${where} must not list EHS itself`) + if (typeof app.label !== "string" || app.label.trim() === "") errors.push(`${where}.label is required`) + else if (app.label.length > 18) errors.push(`${where}.label is longer than 18 characters; EHS truncates it`) + if (app.activity != null && (typeof app.activity !== "string" || !PACKAGE.test(app.activity))) errors.push(`${where}.activity must be a class name`) + const icon = icons ? devicePath(app.icon, APP_ICON_EXTENSIONS, `${where}.icon`, errors) : null + return { label: app.label, package: pkg, activity: app.activity ?? "", icon } +} + +function applicationTag(app, indent) { + const icon = app.icon ? ` icon="${xml(app.icon)}"` : "" + return `${indent}` +} + +/******************************************************************************* + ** Validate a profile. Returns normalized values plus the inputs that are still missing before it can be installed. + *******************************************************************************/ +export function validate(profile) { + const errors = [] + const missing = [] + const warnings = [] + const p = profile ?? {} + if (typeof p.profileId !== "string" || !/^[a-z0-9][a-z0-9-]{1,62}$/.test(p.profileId)) errors.push("profileId must be lowercase letters, digits and hyphens") + if (!Number.isInteger(p.revision) || p.revision < 1) errors.push("revision must be a positive integer") + + const device = p.device ?? {} + for (const key of ["androidVersion", "mxVersion", "osBuild"]) if (!device[key]) missing.push(`device.${key}: measure on the target device before generating installable MX output`) + if (!p.ehs?.installedVersion) missing.push("ehs.installedVersion: select an EHS build supported on the measured firmware (docs checked: 8.0)") + + const q = p.qqq ?? {} + if (typeof q.package !== "string" || !PACKAGE.test(q.package)) errors.push("qqq.package must be the existing QQQ package name") + if (typeof q.activity !== "string" || !PACKAGE.test(q.activity)) errors.push("qqq.activity must be a fully qualified activity class") + if (typeof q.label !== "string" || q.label === "" || q.label.length > 18) errors.push("qqq.label is required (18 characters or fewer)") + const endpoint = managedServerUrl(q.endpoint) + if (endpoint === null) errors.push("qqq.endpoint must be an https origin with no credentials, path, query or fragment (the app rejects anything else)") + else if (new URL(endpoint).hostname.endsWith(".invalid")) missing.push("qqq.endpoint: replace the placeholder with the company QQQ server URL") + if (q.orientation != null && !MANAGED_ORIENTATIONS.includes(q.orientation)) errors.push(`qqq.orientation must be one of ${MANAGED_ORIENTATIONS.join(", ")} or omitted`) + + const l = p.launcher ?? {} + const title = validateTitle(l.title, errors) + const logo = devicePath(l.titleBarLogo, LOGO_EXTENSIONS, "launcher.titleBarLogo", errors) + const wallpaper = devicePath(l.wallpaper, LOGO_EXTENSIONS, "launcher.wallpaper", errors) + if (!logo) missing.push("launcher.titleBarLogo: company logo file and its /enterprise/usr path") + if (!wallpaper) missing.push("launcher.wallpaper: company wallpaper file and its /enterprise/usr path") + const textColor = color(l.iconLabelTextColor ?? "#FFFFFFFF", "launcher.iconLabelTextColor", errors) + const backgroundColor = color(l.iconLabelBackgroundColor ?? "#00FFFFFF", "launcher.iconLabelBackgroundColor", errors) + if (l.iconSize != null) errors.push("launcher.iconSize is unsupported: Zebra's snippet does not show where user_options sits in the config file") + + const apps = [] + if (!Array.isArray(p.permittedApps) || p.permittedApps.length === 0) errors.push("permittedApps must list at least the QQQ application") + else p.permittedApps.forEach((a, i) => { const v = validateApp(a, i, "permittedApps", errors, { icons: true }); if (v) apps.push(v) }) + if (!apps.some((a) => a.package === q.package)) errors.push("permittedApps must include the qqq.package application") + if (new Set(apps.map((a) => a.package)).size !== apps.length) errors.push("permittedApps contains a duplicate package") + if (apps.length === 1) missing.push("permittedApps: administrator list of required Android companion apps (camera/photo picker, printing); only QQQ is listed") + + const tools = [] + ;(p.tools ?? []).forEach((a, i) => { const v = validateApp(a, i, "tools", errors, { icons: false }); if (v) tools.push(v) }) + + const r = p.restrictions ?? {} + for (const [key, allowed] of [["systemSettingsRestricted", [0, 1, 3]], ["recentAppsAccessDisabled", [0, 1]], ["recentAppsButtonDisabled", [0, 1, 2, 3]]]) { + if (!allowed.includes(r[key])) errors.push(`restrictions.${key} must be one of ${allowed.join(", ")} (explicit decision required)`) + } + if (r.systemSettingsRestricted === 3) warnings.push("systemSettingsRestricted 3 requires MX 11.7+ and OSx 11.5.21+ on the device") + if (r.systemSettingsRestricted === 1) warnings.push("systemSettingsRestricted 1 (EHS default) hides most Settings panels; confirm Bluetooth printer pairing and Wi-Fi workflows on a spare device") + + const deploy = [] + ;(p.deploy ?? []).forEach((d, i) => { + const where = `deploy[${i}]` + const known = [EHS_PACKAGE, q.package] + if (!d || !known.includes(d.package)) errors.push(`${where}.package must be EHS or the QQQ package`) + else if (!["Install", "Upgrade"].includes(d.action)) errors.push(`${where}.action must be Install or Upgrade`) + else if (typeof d.apk !== "string" || !d.apk.startsWith("/") || d.apk.split("/").includes("..") || !d.apk.endsWith(".apk")) errors.push(`${where}.apk must be an absolute on-device .apk path already staged`) + else deploy.push({ package: d.package, action: d.action, apk: d.apk }) + }) + if (!deploy.some((d) => d.package === EHS_PACKAGE)) missing.push("deploy: EHS APK on-device path (artifact delivery method and checksum are an administrator decision)") + + return { errors, missing, warnings, normalized: { title, logo, wallpaper, textColor, backgroundColor, apps, tools, deploy, qqq: q, launcher: l, restrictions: r } } +} + +/******************************************************************************* + ** Render enterprisehomescreen.xml. Tags not parameterized keep the values of Zebra's documented default file. + *******************************************************************************/ +export function renderEhs(profile, n) { + const qqqApp = n.apps.find((a) => a.package === n.qqq.package) + const lines = ["", ``, ""] + lines.push(" ", applicationTag(qqqApp, " "), " ") + lines.push(" ", ...n.apps.map((a) => applicationTag(a, " ")), " ") + if (n.tools.length > 0) lines.push(" ", ...n.tools.map((a) => applicationTag(a, " ")), " ") + // EHS stores a hash here when an administrator sets the password in Admin Mode; it must never be authored. + lines.push(" ", " ", " ") + lines.push(" ") + lines.push(n.title.dynamic ? ` <value>${xml(n.title.text)}</value>` : ` ${xml(n.title.text)}`) + if (n.logo) { + lines.push(" ", " 0", ` ${xml(n.logo)}`) + lines.push(` ${n.launcher.reuseLogoOnEhsScreens ? 1 : 0}`, " ") + } + lines.push(` ${xml(n.backgroundColor)}`, ` ${xml(n.textColor)}`) + lines.push(" ", " 0", " 0") + lines.push(` ${xml(n.wallpaper ?? "")}`) + if (n.wallpaper) lines.push(` ${n.launcher.wallpaperStretch ? 1 : 0}`) + lines.push(" 0", " 1", " 1") + lines.push(" 1", ` ${n.restrictions.systemSettingsRestricted}`) + lines.push(` ${n.restrictions.recentAppsButtonDisabled}`, ` ${n.restrictions.recentAppsAccessDisabled}`) + lines.push(" ", ` ${profile.launcher?.showIpAddress ? 1 : 0}`, " 0", " 0", " 0", " 0", " ") + lines.push(" ", " 0", " 10", " 1", " ") + lines.push(" ") + lines.push("", "") + return lines.join("\n") +} + +/******************************************************************************* + ** Render the StageNow/MX AppMgr document: optional APK installs, then EHS as the default launcher. + *******************************************************************************/ +export function renderMx(profile, n) { + const lines = ["", ``, ""] + for (const d of n.deploy) lines.push(" ", ` `, ` `, " ") + lines.push(" ", " ", ` `, " ") + lines.push("", "") + return lines.join("\n") +} + +/******************************************************************************* + ** Validate then write outputs. MX output is withheld until the device is measured. + *******************************************************************************/ +export function generate(profile, outDir) { + const { errors, missing, warnings, normalized } = validate(profile) + const report = { profileId: profile?.profileId, revision: profile?.revision, ready: false, errors, missing, warnings, files: [] } + if (errors.length > 0) return report + mkdirSync(outDir, { recursive: true }) + const emit = (name, text) => { writeFileSync(join(outDir, name), text, "utf8"); report.files.push(name) } + emit("enterprisehomescreen.xml", renderEhs(profile, normalized)) + // Android managed configuration for the EMM; withheld while the endpoint is still a placeholder. + if (!missing.some((m) => m.startsWith("qqq.endpoint"))) { + const managed = { [MANAGED_SERVER_KEY]: managedServerUrl(profile.qqq.endpoint) } + if (profile.qqq.orientation != null) managed[MANAGED_ORIENTATION_KEY] = profile.qqq.orientation + emit("managed-configuration.json", JSON.stringify(managed, null, 2) + "\n") + } else report.warnings.push("managed-configuration.json withheld: qqq.endpoint is still a placeholder") + const mxReady =!missing.some((m) => m.startsWith("device.") || m.startsWith("deploy:")) + if (mxReady) emit("stagenow-mx-appmgr.xml", renderMx(profile, normalized)) + else report.warnings.push("stagenow-mx-appmgr.xml withheld: device measurements and EHS APK path are missing") + report.ready = missing.length === 0 + writeFileSync(join(outDir, "profile-report.json"), JSON.stringify(report, null, 2) + "\n", "utf8") + report.files.push("profile-report.json") + return report +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const [input, outDir, ...flags] = process.argv.slice(2) + if (!input || !outDir) { console.error("usage: generate-company-profile.mjs [--require-ready]"); process.exit(64) } + const report = generate(JSON.parse(readFileSync(input, "utf8")), outDir) + console.log(JSON.stringify(report, null, 2)) + if (report.errors.length > 0) process.exit(1) + if (flags.includes("--require-ready") && !report.ready) process.exit(2) +} diff --git a/deployment/company-profile/generate-company-profile.test.mjs b/deployment/company-profile/generate-company-profile.test.mjs new file mode 100644 index 0000000..a5bb913 --- /dev/null +++ b/deployment/company-profile/generate-company-profile.test.mjs @@ -0,0 +1,125 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +import assert from "node:assert/strict" +import { mkdtempSync, readFileSync, readdirSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" +import { EHS_PACKAGE, MANAGED_ORIENTATIONS, generate, managedServerUrl, validate, xml } from "./generate-company-profile.mjs" + +const example = () => JSON.parse(readFileSync(new URL("./profile.example.json", import.meta.url), "utf8")) +const measured = () => { + const p = example() + Object.assign(p.device, { androidVersion: "14", mxVersion: "14.0.0.0", osBuild: "measured-build" }) + p.ehs.installedVersion = "8.0" + p.qqq.endpoint = "https://qqq.company.test" + p.permittedApps.push({ label: "Camera", package: "com.example.camera", activity: null, icon: null }) + p.deploy = [{ package: EHS_PACKAGE, action: "Install", apk: "/enterprise/usr/persist/EHS.apk" }] + return p +} +const out = () => mkdtempSync(join(tmpdir(), "company-profile-")) + +test("example is valid but reports every unmeasured input and withholds MX output", () => { + const dir = out() + const report = generate(example(), dir) + assert.deepEqual(report.errors, []) + assert.equal(report.ready, false) + assert.ok(report.missing.some((m) => m.startsWith("device.mxVersion"))) + assert.ok(report.missing.some((m) => m.startsWith("permittedApps"))) + assert.ok(report.missing.some((m) => m.startsWith("qqq.endpoint"))) + assert.deepEqual(readdirSync(dir).sort(), ["enterprisehomescreen.xml", "profile-report.json"]) +}) + +test("measured profile is ready and emits documented EHS and MX elements only", () => { + const dir = out() + const report = generate(measured(), dir) + assert.deepEqual(report.errors, []) + assert.equal(report.ready, true) + const ehs = readFileSync(join(dir, "enterprisehomescreen.xml"), "utf8") + assert.match(ehs, /<value>Company \[serial_number\]<\/value><\/title>/) + assert.match(ehs, /<wallpaper>\/enterprise\/usr\/company\/wallpaper\.png<\/wallpaper>/) + assert.match(ehs, /<usb_debugging_disabled>1<\/usb_debugging_disabled>/) + assert.match(ehs, /<kiosk_mode_enabled>0<\/kiosk_mode_enabled>/) + assert.match(ehs, /<admin><\/admin>/) + const mx = readFileSync(join(dir, "stagenow-mx-appmgr.xml"), "utf8") + assert.match(mx, /value="SetDefaultLauncher"/) + assert.match(mx, new RegExp(`name="Package" value="${EHS_PACKAGE.replace(/\./g, "\\.")}"`)) + assert.doesNotMatch(ehs, /<admin>[^<]+<\/admin>/) + assert.doesNotMatch(mx + ehs, /token|secret/i) +}) + +test("security-weakening or undocumented inputs are rejected", () => { + const cases = [ + (p) => { p.permittedApps.push({ label: "All", package: "com.android.*" }) }, + (p) => { p.permittedApps.push({ label: "Wild", package: "com.mobility.*" }) }, + (p) => { p.launcher.wallpaper = "/sdcard/wallpaper.png" }, + (p) => { p.launcher.wallpaper = "/enterprise/usr/../x.png" }, + (p) => { p.qqq.endpoint = "http://qqq.company.test" }, + (p) => { p.qqq.endpoint = "https://user:pw@qqq.company.test" }, + (p) => { p.launcher.title = "Dynamic [username]" }, + (p) => { p.restrictions.systemSettingsRestricted = 2 }, + (p) => { delete p.restrictions.recentAppsAccessDisabled }, + (p) => { p.launcher.iconSize = "XL" }, + (p) => { p.permittedApps.push({ label: "A very long label over limit", package: "com.example.long" }) }, + (p) => { p.permittedApps = [{ label: "Other", package: "com.example.other" }] }, + (p) => { p.deploy = [{ package: "com.evil.app", action: "Install", apk: "/enterprise/usr/persist/x.apk" }] }, + ] + for (const mutate of cases) { + const p = measured() + mutate(p) + assert.ok(validate(p).errors.length > 0, mutate.toString()) + } +}) + +test("managed configuration uses the app's exact keys and is withheld for a placeholder endpoint", () => { + const withheld = out() + const placeholder = generate(example(), withheld) + assert.ok(!readdirSync(withheld).includes("managed-configuration.json")) + assert.ok(placeholder.warnings.some((w) => w.startsWith("managed-configuration.json withheld"))) + + const dir = out() + const p = measured() + p.qqq.endpoint = " https://QQQ.Company.test " + p.qqq.orientation = "REVERSE_LANDSCAPE" + assert.deepEqual(generate(p, dir).errors, []) + assert.deepEqual(JSON.parse(readFileSync(join(dir, "managed-configuration.json"), "utf8")), { + qqq_server_url: "https://qqq.company.test/", + qqq_orientation: "REVERSE_LANDSCAPE", + }) + + const noOrientation = out() + generate(measured(), noOrientation) + assert.deepEqual(Object.keys(JSON.parse(readFileSync(join(noOrientation, "managed-configuration.json"), "utf8"))), ["qqq_server_url"]) +}) + +test("managed server URL accepts only an https origin, mirroring the app", () => { + assert.equal(managedServerUrl("https://qqq.company.test"), "https://qqq.company.test/") + assert.equal(managedServerUrl("https://qqq.company.test:8443/"), "https://qqq.company.test:8443/") + for (const bad of ["http://qqq.company.test", "https://u:p@qqq.company.test", "https://qqq.company.test/app", "https://qqq.company.test/?x=1", "https://qqq.company.test/#f", "ftp://qqq.company.test", "nonsense", "", null, 7, `https://${"a".repeat(2100)}`]) { + assert.equal(managedServerUrl(bad), null, String(bad)) + const p = measured() + p.qqq.endpoint = bad + assert.ok(validate(p).errors.length > 0, String(bad)) + } +}) + +test("orientation must be an exact app value", () => { + for (const good of MANAGED_ORIENTATIONS) { + const p = measured() + p.qqq.orientation = good + assert.deepEqual(validate(p).errors, []) + } + for (const bad of ["portrait", "Landscape", "AUTO", "SENSOR", "", 90]) { + const p = measured() + p.qqq.orientation = bad + assert.ok(validate(p).errors.length > 0, String(bad)) + } +}) + +test("XML special characters in labels are escaped", () => { + assert.equal(xml(`a&b<c>"d"'e'`), "a&b<c>"d"'e'") + const p = measured() + p.permittedApps[1].label = "Pick & <Scan>" + const dir = out() + assert.deepEqual(generate(p, dir).errors, []) + assert.match(readFileSync(join(dir, "enterprisehomescreen.xml"), "utf8"), /label="Pick & <Scan>"/) +}) diff --git a/deployment/company-profile/profile.example.json b/deployment/company-profile/profile.example.json new file mode 100644 index 0000000..8cef516 --- /dev/null +++ b/deployment/company-profile/profile.example.json @@ -0,0 +1,41 @@ +{ + "profileId": "example-company", + "revision": 1, + "device": { + "model": "TC22", + "androidVersion": null, + "mxVersion": null, + "osBuild": null + }, + "ehs": { + "docsVersion": "8.0", + "installedVersion": null + }, + "qqq": { + "package": "io.qrun.mobile.scanner", + "activity": "io.qrun.mobile.scanner.ScannerActivity", + "label": "QQQ Mobile", + "endpoint": "https://qqq.example.invalid", + "orientation": null + }, + "launcher": { + "title": "Company [serial_number]", + "titleBarLogo": "/enterprise/usr/company/logo.png", + "reuseLogoOnEhsScreens": true, + "wallpaper": "/enterprise/usr/company/wallpaper.png", + "wallpaperStretch": false, + "iconLabelTextColor": "#FFFFFFFF", + "iconLabelBackgroundColor": "#00FFFFFF", + "iconSize": null + }, + "permittedApps": [ + { "label": "QQQ Mobile", "package": "io.qrun.mobile.scanner", "activity": "io.qrun.mobile.scanner.ScannerActivity", "icon": null } + ], + "tools": [], + "restrictions": { + "systemSettingsRestricted": 1, + "recentAppsAccessDisabled": 1, + "recentAppsButtonDisabled": 1 + }, + "deploy": [] +} diff --git a/docs/ADR-DEVICE-SCREEN-VIEWING.md b/docs/ADR-DEVICE-SCREEN-VIEWING.md index 96d7f5e..4886d9f 100644 --- a/docs/ADR-DEVICE-SCREEN-VIEWING.md +++ b/docs/ADR-DEVICE-SCREEN-VIEWING.md @@ -6,12 +6,12 @@ The existing mobile-scanners 1.1 transport captures only eligible QQQ app conten The additive 1.2 contract advertises supported capture modes in the session heartbeat. Missing capabilities and missing mode fields mean `APP_CONTENT`. `DEVICE_SCREEN` is selected only by an authenticated, approved device operator using Android's own consent dialog. An administrator cannot grant that consent. The existing admin-only viewer and current device/session authorization remain mandatory; a mode change invalidates old leases and pixels. Each new lease and frame carries its mode, preventing a full-display frame from being accepted as app-content capture. The session record exposes the current mode to the administrator. -Android uses a nonexported `mediaProjection` foreground service, an ongoing notification with Stop, one in-memory consent result, one virtual display and a bounded ImageReader. No audio, remote control, screen recording or saved screenshots are added. Frame size/rate/byte limits and ephemeral latest-only server retention stay unchanged. A single MobileSessionController owns heartbeats/uploads; device mode changes its capture source and allows background heartbeats while consent and the current authenticated session remain valid. Location telemetry retains its existing foreground-only rules. +Android uses a nonexported `mediaProjection` foreground service, an ongoing informational notification, one in-memory consent result, one virtual display and a bounded ImageReader. No audio, remote control, screen recording or saved screenshots are added. Frame size/rate/byte limits and ephemeral latest-only server retention stay unchanged. A single MobileSessionController owns heartbeats/uploads; device mode changes its capture source and allows background heartbeats while consent and the current authenticated session remain valid. Location telemetry retains its existing foreground-only rules. -Starting requires an explicit operator action and system approval for this sharing session, plus an enabled sharing notification. While waiting for an administrator's bounded lease, no frame is uploaded; absence of a lease times out after 30 seconds. Once viewing has begun, lease loss/expiry, network failure, operator/system Stop, device lock, approval loss, logout, server change or controller disposal invalidates capture immediately and releases projection/display/images/service. A service watchdog independently enforces the lease deadline even during encoding or an outstanding request. There is no automatic restart or remembered consent. Activity recreation may end sharing and require fresh consent; it must never silently broaden or revive a grant. Explicit/system Stop pauses all viewing; app-content viewing remains available through the existing Allow viewing control. +Starting requires an explicit operator action and system approval for this sharing session, plus an enabled sharing notification. While waiting for an administrator's bounded lease, no frame is uploaded; absence of a lease times out after 30 seconds. Once viewing has begun, lease loss/expiry, network failure, system Stop, device lock, approval loss, logout, server change or controller disposal invalidates capture immediately and releases projection/display/images/service. A service watchdog independently enforces the lease deadline even during encoding or an outstanding request. There is no automatic restart or remembered consent. Activity recreation may end sharing and require fresh consent; it must never silently broaden or revive a grant. System Stop suspends viewing until a fresh sharing grant is started. The app has no Stop viewing, Allow viewing, or custom notification stop action. Android system consent and stop controls remain available. Implementation files: Android SessionScreen/MobileSessionApi/MobileSessionController, new DeviceScreenCaptureService and operator control, ScannerActivity wiring and manifest; server MobileSessionService/MobileScreenService/MobileSessionSchemas/ScreenSpec, session metadata/H2 additive migration and extension version. Existing standard rendering and authenticated session APIs are reused; no parallel media server or business-screen framework is added. -Required tests cover old1.1 fallback, mode mismatch/lease rotation, revoked/ended sessions, bounded no-viewer timeout, late consent/capture after logout, denial/cancel/duplicate result, stale frame disposal, system Stop and notification Stop. Actual Android consent, background display capture, rotation/lock and protected-window behavior require a separate approved native test; synthetic tests do not establish physical capture acceptance. +Required tests cover old1.1 fallback, mode mismatch/lease rotation, revoked/ended sessions, bounded no-viewer timeout, late consent/capture after logout, denial/cancel/duplicate result, stale frame disposal, system Stop. Actual Android consent, background display capture, rotation/lock and protected-window behavior require a separate approved native test; synthetic tests do not establish physical capture acceptance. Android's [official MediaProjection guide](https://developer.android.com/media/grow/media-projection) requires new consent for each projection session, one-use projection tokens and callback cleanup. Android14+ requires the mediaProjection foreground-service type/permission and supports display or app selection. The device-screen control requests the default display where supported and keeps the system confirmation visible. Protected windows remain protected by Android; we do not attempt to bypass them. A consenting user may expose other apps and notifications, which the control must explain before starting. diff --git a/docs/CLAUDE-NATIVE-HANDOFF.md b/docs/CLAUDE-NATIVE-HANDOFF.md new file mode 100644 index 0000000..3cc032e --- /dev/null +++ b/docs/CLAUDE-NATIVE-HANDOFF.md @@ -0,0 +1,43 @@ +# Claude native handoff: Echo setup named possible values + +## Source-verified contract (core `qqq-wt-native-provider-logout`) +- operationId `searchProcessPossibleValues`: `POST /qqq/v1/processes/{processName}/possibleValues/{fieldName}` (`UiApiOperationIds`, `ProcessPossibleValuesSpecV1`, openapi.yaml ~12440). Server advertises feature `possibleValues` on `GET /version`. +- Request: `application/json` `{ "values": {other form values}, "processValues": {...}, "useCase": "form", "searchTerm"?, "ids"? }` (`PossibleValuesSpecUtils`). Not multipart. Same session cookie, `X-QQQ-Client` and device headers as other native calls (shared Retrofit client). +- Response `PossibleValuesResponseV1`: `{ "options": [{ "id", "label", ... }], "hasMore"? }`; `options` is omitted when empty. +- Field metadata (FieldMetaData v1): `possibleValueSourceName`, and for contingent fields `possibleValueSourceDependsOn: [names]` and `possibleValueSourceDependencyBehavior: DISABLE_UNTIL_SET | SHOW_ALL_UNTIL_SET` (docs/CONTINGENT-POSSIBLE-VALUES.md). +- Frontend rules (same doc): re-query when a dependency changes, clear a selection that no longer fits (transitively), disable with a hint until dependencies are set (default), ignore stale responses. + +A previous revision of this work invented the multipart shape from memory; it has been replaced entirely by the above. The route path happened to match; the body did not. + +## Root cause (unchanged, source inference) +Echo `setup` declares `printerId`/`mediaId` with named PVS (media `dependsOn printerId`); fixture uses ENUM PVS. The client rendered only inline choices, so Printer became a free-text "Printer *" field and submission was blocked as unsupported. That init is a synchronous COMPLETE (not ASYNC) is inferred from sources and the operations log; the native E2E remains the authority. The fixture is NOT changed to inline choices. + +## Implementation +- core: `QQQApiService.processPossibleValues`; `QQQRepository.processPossibleValues(process, field, values, processValues)` (default throws Unsupported so other fakes compile); `NetworkQQQRepository` builds the JSON body and decodes options (scalar ids kept); `QFieldMetaData` gains `possibleValueSourceDependsOn` / `possibleValueSourceDependencyBehavior`. +- mobileapp `ProcessViewModel`: `loadRemoteOptions`, `remoteOptionsBlockedReason` (DISABLE_UNTIL_SET -> "Choose Printer first." and no request), `fieldChanged` (dependents drop selection immediately, transitively, and re-query), per-field generation token so only the newest request publishes (a late reply for an old parent cannot restore options or a selection), post-load prune of a choice no longer offered, `fieldResets` so the control follows a VM-cleared value. Load failures are shown with "Retry choices", cancel the superseded job, never set client-work, so Cancel/Leave stay available. `reset()` clears all of it. +- `ProcessComponentEditForm`: select for named PVS (label text unchanged: "Printer", "Paper or label size", button "Choose a value"), disabled with a hint while blocked. +- `SemanticProcess.unsupported` accepts named PVS in EDIT_FORM only (widget INPUT_FIELD remains unsupported). +- E2E test: literal `waitFor("Printer")` unchanged (diagnostic text dump retained on timeout); added a readiness wait for the enabled media control before clicking it, since media is contingent. No assertion weakened. +- docs/UI-API.md updated. + +## Tests added/updated +- `NetworkQQQRepositoryTest.processPossibleValuesUsesThePublishedJsonOperation`: method/path/JSON body/use case, response with options, and the omitted-`options` empty response. +- `ProcessSubmissionTest.namedPossibleValueSourcesLoadWithDependentValuesAndReportFailures`: blocked dependent not searched; parent value sent; dependent selection cleared on parent change; late old-parent reply ignored; failure reported, no client-work block, retry succeeds. +- `SemanticProcessTest`: named PVS in EDIT_FORM supported; UNKNOWN type and widget PVS still unsupported. +Note: server response bodies in tests are minimal shapes taken from `PossibleValuesResponseV1`/`ProcessPossibleValuesSpecV1Test` (`options[].id/label`), not captured from a live server. + +## Commands for root (Java 21, wrapper) +``` +./gradlew :qqq:android:core:testDebugUnitTest :qqq:android:mobileapp:testDebugUnitTest :scanner-host:testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest +node --test scripts/ci/scanner-echo-guards.test.mjs +``` +Then re-approve rebuilt APK hashes and run the root native Echo helper. + +## Known gaps / risks +- Nothing compiled or run (no shell). Expect possible small compile fixes (Compose state/`mutableIntStateOf` delegate, imports). +- `possibleValues` is not added to the client's REQUIRED_FEATURES (all existing fixtures advertise only six features); a server lacking it fails the search with a visible error + Retry rather than at startup. +- Pre-existing and untested: decoding inline `inlinePossibleValueSource` ids typed `java.io.Serializable` via kotlinx. +- Whether the real Echo metadata publishes `possibleValueSourceDependsOn` for media is inferred from `withPossibleValueSourceDependsOn(List.of("printerId"))` in the producer; unverified against a live `/metaData/process/echoPrint` response. +- Untracked `native.md` at the repo root is an obsolete earlier handoff (a pointer to this file); delete it. + +Coordinator verification:169JVMtests pass; lint0errors/0warnings; E2EAPKs assemble; all8guards/sourcegate236pass. RealTC22 Echo native scenario completes all5stages with86/86documented semanticrequests,2accepted synthetic scans andsuccessfulcleanup. Printing remains simulated. diff --git a/docs/COMPANY-PROFILE.md b/docs/COMPANY-PROFILE.md new file mode 100644 index 0000000..190ede5 --- /dev/null +++ b/docs/COMPANY-PROFILE.md @@ -0,0 +1,85 @@ +# Company scanner profile package + +Status 2026-11-02: **prepared, not applied.** Nothing here has been installed on, pushed to or tested against a device. No device, setting, credential or server was changed. The target device's Android build, MX version and installed EHS version have **not been measured**; Zebra documentation versions below are the documentation reviewed, not device facts. Context and layering: `docs/DEVICE-PROVISIONING.md`. + +Files: `deployment/company-profile/` + +| File | Purpose | +| --- | --- | +| `profile.example.json` | Non-secret parameters. Unmeasured or unknown values are `null` or placeholders on purpose. | +| `generate-company-profile.mjs` | Validates the profile and renders `enterprisehomescreen.xml`, `managed-configuration.json` (once the endpoint is real) and `stagenow-mx-appmgr.xml` (only when the device is measured). No dependencies. | +| `generate-company-profile.test.mjs` | `node:test` cases for rendering, withholding and rejection. | +| `DEVICE-OWNER-ENROLLMENT.md` | The separate, destructive enrollment path. Not generated. | + +```text +node deployment/company-profile/generate-company-profile.mjs my-profile.json out/ [--require-ready] +node --test deployment/company-profile/ +``` + +Exit 1 on validation errors; 2 with `--require-ready` while any input is still missing. `out/profile-report.json` lists `missing` and `warnings`. One shared APK serves every customer: customer differences are only profile values (title, logo, wallpaper, app list), never a build. + +## What the generated EHS file contains + +Only tags from Zebra's EHS 8.0 Advanced Settings (`techdocs.zebra.com/ehs/latest/guide/settings/`): `kiosk`, `applications`, `tools`, `passwords`, and in `preferences` the `title` (static, or `<value>` with one documented device-ID token), `title_bar_icon` (`title_bar_icon_disabled`, `title_bar_icon_file`, `reuse_custom_icon`), icon label colours, `orientation`, `wallpaper`, `wallpaper_stretching_enabled`, the keyguard/USB/settings/recent-apps tags, `wireless_info` and `screen_blanking`. Tags not parameterised keep the values of Zebra's documented default file. Constraints enforced from that page: images only under `/enterprise/usr` (the persistent location), documented image formats, app labels of at most 18 characters, XML escaping, no wildcard packages (rejected for least privilege), no EHS self-listing. + +Layout: EHS shows apps in listed order (links first when both exist). Arbitrary pixel placement is not a documented feature; folders are not in the pages reviewed and are not generated. + +Explicit non-goals, enforced by the generator: `usb_debugging_disabled` is always `1`, `kiosk_mode_enabled` is always `0`, no `bypass keyguard`, `<passwords>` is always empty, `launcher.iconSize` is rejected (the Zebra snippet does not show where `user_options` sits). + +## What the generated MX file contains + +`AppMgr` characteristics only (`techdocs.zebra.com/mx/appmgr/`): optional `Install`/`Upgrade` of an APK already on the device, then `SetDefaultLauncher` for `com.zebra.mdna.enterprisehomescreen` (EHS setup page, `.../ehs/latest/guide/setup/`). Characteristic version `4.2` is the version used in Zebra's examples; `SetDefaultLauncher` is listed as MX 4.1+. **Not generated:** AccessMgr (allowlist and permission grants can lock out settings or break the scanner; Zebra's schema needs a device-specific review) and file delivery of the EHS XML and images to `/enterprise/usr` (Zebra recommends StageNow, DNA Cloud or an EMM for moving files; the StageNow file-delivery parameters were not in the cached pages, so they are not guessed). + +## Hazards to review before any device + +1. **EHS defaults disable USB debugging.** Zebra: `usb_debugging_disabled` 1 "or if left unspecified will prevent user access to the file system and all ADB functionality". Installing EHS on the current development TC22 will cut ADB. Use a spare device; Zebra advises restoring USB debugging before uninstalling EHS. +2. **Pushing the XML overwrites the existing file**, including the stored admin password hash. Set the EHS admin password in Admin Mode on a reference device; never author it. Decide whether to deploy that device's exported file. +3. **`system_settings_restricted` 1 (default) hides most Settings panels** and Zebra warns it might prevent some MX features. Confirm Bluetooth printer pairing and Wi-Fi workflows. The profile requires this value explicitly. +4. **Default launcher:** EHS must be re-designated after any reinstall. Test Home/Back/recovery before relying on it. +5. **MediaProjection consent is still per session** on Android 14; nothing here grants it. +6. **Managed bootstrap is consumed by the app, but only through an EMM.** `managed-configuration.json` is a reviewable key/value payload for the EMM's Android managed-configuration (app restrictions) field; StageNow/MX and the EHS XML do not deliver it, and no Zebra schema is claimed for it. See "Managed configuration" below. + +## Inputs still required (also in `profile-report.json`) + +- Measured on the target TC22: `device.androidVersion`, `device.mxVersion`, `device.osBuild`; choose and record `ehs.installedVersion` for that firmware (docs reviewed: EHS 8.0). +- Company logo and wallpaper image files, delivered to `/enterprise/usr/...`, and their paths. +- Real company `qqq.endpoint` (HTTPS origin) and optional `qqq.orientation`; the EMM to push them as app restrictions. +- Administrator list of permitted Android companion apps (camera/photo picker, printing) with exact package and activity names from the device. +- On-device EHS APK path and the artifact delivery/checksum method; the QQQ APK path if the profile should also deploy it. +- Decisions: `systemSettingsRestricted`, recent-apps values, `show IP` visibility, who sets the EHS admin password, EMM versus StageNow, and whether a device-owner fleet is wanted. + +## Managed configuration (implemented in the shared APK) + +The app declares `res/xml/app_restrictions.xml` (manifest `android.content.APP_RESTRICTIONS`) and reads it with `RestrictionsManager` (`ManagedConfiguration.kt`). Two keys, both optional; unmanaged installs behave exactly as before. + +| Key | Values | Behaviour | +| --- | --- | --- | +| `qqq_server_url` | HTTPS origin only: no credentials, path, query or fragment; HTTP is rejected even in debug builds | No saved server: the connect form is prefilled with it and says the administrator configured it; the person still taps Connect. Different saved server: never switched silently. Settings shows a notice with "Use administrator's server", which runs the existing confirmed Change-server path (ends the session, logs out) and then shows the prefilled form. | +| `qqq_orientation` | exactly `SYSTEM`, `PORTRAIT`, `LANDSCAPE`, `REVERSE_PORTRAIT`, `REVERSE_LANDSCAPE` | A bootstrap default applied when the managed value changes (last applied value is remembered); a later local choice stands until the administrator changes it again. Unknown values are rejected, not defaulted. | + +Changes are re-read when the host resumes and while it is resumed (system `ACTION_APPLICATION_RESTRICTIONS_CHANGED`, not exported). Rejected keys are tracked by name only; values are never logged. No credentials, business metadata or branding are accepted this way. In the profile: `qqq.endpoint` and optional `qqq.orientation` produce `managed-configuration.json` (withheld while the endpoint is a placeholder). The EMM must be told to push those two keys as app restrictions for `io.qrun.mobile.scanner`. + +## Dynamic status wallpaper (implemented, optional) + +EHS has no QQQ-user or IP field (title tokens are device IDs only; Wireless Info shows the IP when `show_ip_address` is 1, exposed as `launcher.showIpAddress`), so the shared APK carries an optional native live wallpaper: `QqqStatusWallpaperService`, declared with the system-only `BIND_WALLPAPER` guard. It is inactive until a person picks it: Settings → "Status wallpaper…" opens Android's standard live-wallpaper chooser preselected to it. Nothing is applied silently; no new permission and no second app. + +- **What it shows:** company brand name always (the server's public brand name/colour already used by the host, or the default). On the unlocked home screen only, also: signed-in display name, server host and the default network's IP (address and interface, via a network callback, no polling). Preview, lock screen, a locked keyguard, a missing keyguard service and lock-only wallpaper show the brand alone. Identity is withdrawn when the screen turns off or the wallpaper stops being visible. +- **Freshness is not overstated.** The existing heartbeat runs only while the app is foreground. Shown while foreground: "Signed in: NAME". After the app leaves the screen: the name with "Last confirmed HH:mm (app in background)". After 15 minutes in the background the name is replaced by "Session unverified". States are UNKNOWN (new process, destroyed host), SIGNED_OUT and SIGNED_IN; the snapshot lives only in memory, so process death and server change return it to UNKNOWN, and logout, revocation and unauthorized responses clear the name. It holds no token, cookie, credential or session id and logs nothing. +- **Limit:** a wallpaper is visible only where the launcher or a transparent window shows it. Whether EHS reveals it is unproven; EHS draws its own `<wallpaper>` image, so leave that empty when testing. + +## Status of the implementation + +| Item | Status | +| --- | --- | +| Wallpaper service, manifest/resources, managed configuration, settings entry, activity wiring | Compiled into the debug and E2E APKs built after the last source edit (2026-11-02 15:34). Not installed; not run on a device. | +| JVM unit tests `WallpaperStatusTest` (13), `ManagedConfigurationTest` (8) | Pass in the host unit-test run after the last source edit. | +| Generator tests for managed keys, URL and orientation | Seven generator tests pass (root run). | +| Visibility behind EHS, lock-screen behaviour, keyguard/wake timing, Android 14 wallpaper flags on a TC22, EMM delivery of restrictions | **Untested on hardware.** | + +APIs used that should be confirmed by the build and a device, because the SDK jar was not readable in this session: `WallpaperService.Engine.getWallpaperFlags()` (API 34, guarded), `KeyguardManager.isKeyguardLocked`, `ConnectivityManager.registerDefaultNetworkCallback` (API 24), `RestrictionsManager.getApplicationRestrictions`, `WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER` / `EXTRA_LIVE_WALLPAPER_COMPONENT`, `ContextCompat.registerReceiver` with `RECEIVER_NOT_EXPORTED` (core 1.13.1). + +## Validation status + +- Authored against the cached Zebra pages `qqq-zebra-docs-ehs-settings.md`, `-ehs-setup.md`, `-mx-appmgr.md` and `-mx-accessmgr.md` (untrusted reference content; facts above cite their official page). +- Seven generator tests pass (`node --test deployment/company-profile/`), including managed keys, URL and orientation. +- Nothing has been validated against Zebra's StageNow importer or a device. diff --git a/docs/ECHO-NATIVE-E2E.md b/docs/ECHO-NATIVE-E2E.md new file mode 100644 index 0000000..13fed55 --- /dev/null +++ b/docs/ECHO-NATIVE-E2E.md @@ -0,0 +1,42 @@ +# Echo native UI acceptance fixture + +The explicit Echo scenario exercises the real Android host, TABLE login, enrollment/approval, semantic printer/media/document choices, real Echo process and H2 persistence. Its test-only backend accepts **simulated** jobs. Passing it does not qualify EMDK, barcode rendering, printer adapters or physical output. + +The backend owner supplies `com.kingsrook.examples.echo.fixture.EchoHttpFixture` from the isolated optional Echo test module, launched with a free loopback port, `ECHO_FIXTURE_TYPE=echo-ui-e2e` and private `ECHO_FIXTURE_SCANNER_PASSWORD`/`ECHO_FIXTURE_ADMIN_PASSWORD`. Readiness is `ECHO_UI_E2E_READY port=N simulatedPrinting=true`. It uses in-memory H2 and no real printing/generation adapter. Root owns launch, the exact private dependency tuple and approval of the concrete APK/fixture cohort. + +The credential file must stay private and contain these fields with newly generated passwords of at least 16 characters: + +```json +{ + "fixtureType": "echo-ui-e2e", + "simulatedPrinting": true, + "baseUrl": "http://127.0.0.1:PRIVATE_FREE_PORT/", + "username": "scanner", + "password": "PRIVATE_GENERATED_PASSWORD", + "adminUsername": "admin", + "adminPassword": "PRIVATE_GENERATED_PASSWORD" +} +``` + +The runner refuses normal scanner ports 57832/51631 and printing ports 18081/18086, non-loopback/decorated URLs, the wrong purpose, unsimulated fixtures, incompatible core/mobile contracts and branding other than `Echo UI E2E — simulated printing`. It requires UI API1.10.0/mobile-scanners1.4.0 and checks APK package identities. Only a selected physical Zebra TC22 and absent disposable `io.qrun.mobile.scanner.e2e`/`.test` packages are accepted. Normal and QA packages/data remain untouched. Location reporting is disabled only in the new disposable package, and no screenshots are taken. + +Preparation requires no device execution: + +```text +node --test scripts/ci/scanner-echo-guards.test.mjs +./gradlew :scanner-host:testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest +``` + +Only after root launches the private fixture and approves the exact cohort: + +```text +node scripts/ci/scanner-device-echo-e2e.mjs --serial TC22_SERIAL --fixture PRIVATE_ECHO_FIXTURE.json +``` + +The native test signs in, enrolls and approves through existing helpers; opens Apps → Echo; selects the printer, media and an INTEGER Document through semantic possible-value controls, reselects the printer to verify stale dependent selections are cleared and Next is refused until reselection, then retains all three choices; injects two public synthetic values with CODE128/QR types through the E2E-only driver; checks `Print queued` and absent manual controls; rotates/recreates without another acceptance; then uses Apps/Leave and verifies the run closes. Independent reads use standard QQQ `echoScan` and read-only `echoFixtureRun` queries as the scanner with approved-device headers, retaining the real actor/device locks. No custom business verification endpoint or administrator bypass is used. + +The existing OpenAPI proxy audits all observed requests and requires native cancellation. Fixed stage markers and operation IDs/counts, APK hashes and a simulation-labelled `echo-proof.json` are written under `scanner-host/build/native-device-e2e/`; credentials, serial and opaque identifiers are redacted from instrumentation output. No normal app data, global animation settings, DataWedge profile or existing forwarding is replaced. The runner removes only its own reverse and newly installed disposable packages; cleanup failures fail the run and its proof. + +The user-authorized TC22 invocation completed successfully after the bounded compilation/guards. This fixture remains simulated printing, not physical printing evidence. + +Current recorded acceptance: all7 stages passed on TC22, including Document selection and stale selection clearing. Root verified99 focused/host JVM tests,8 Node guards, zero lint errors/warnings, and112/112 documented native requests. Printing is simulated. The proof explicitly leaves physicalScannerQualified and physicalPrintingQualified false. diff --git a/docs/FINISH-1.0.md b/docs/FINISH-1.0.md new file mode 100644 index 0000000..f31128a --- /dev/null +++ b/docs/FINISH-1.0.md @@ -0,0 +1,34 @@ +# Finish ledger for 1.0 (current: November 3, 2026) + +Nothing here is a 1.0 release. History is in SESSION-STATE.md. + +## Source published (not merged, not released) + +- Android `f72b90d08cf29d2785cef1d5c5e7eeaf75f9afd2`: signed application code published in PR 10 (read back over SSH before this documentation update). Gitea CI 3480 succeeded: unit-test, device-test, package, secrets, gate. A clean git merge-tree with `main` b75fdac was verified; nothing was merged. Pull requests #8 and #10 remain open. This documentation update leaves runtime code unchanged. +- Server `c0e27b36fd16c00eaca3451af3df43f4deedada1`: signed and published in Gitea PR 7, including the verified QQQ 4.1.0-RC.21 dependency update and Record Files test fix. +- Generation copies: signed `aac68907e67906aeef4e0cd687d28bc98b738f8c` on `feature/echo-generation-copies` (base PR 343 head `8f473fa`), 13 paths, 21/21 manifest match, real PostgreSQL 17 migration passes. Owner integration and publication are not acknowledged. + +## Implemented and installed (normal TC22 and local server 57832) + +- APK `4AAB6951D3B0ABDDD4F5A8DC6790DCD84233EE08179B68DB108BF77909AB850E`: same signer, no data clear. Connected, Apps/Settings, portrait, location on and the brand-only wallpaper preview were verified. No wallpaper or company profile was applied. The device shows Echo setup, Copies 1. +- Server on 57832: UI API 1.10, mobile-scanners 1.5, printing 2.3, mobile-record-files 1.0, with the original database, accounts, approval and printer. Echo Copies per scan (default 1, range 1-100, one job per scan). Its core is not upgraded to RC.21. + +## Tested + +- Android: 222 JVM tests, lint 0 errors/0 warnings (2 info), debug/E2E/test APK builds, 23 Node tests, 255-file standards. +- Disposable TC22 Echo ten stages, 109/109 semantic requests; inputs and printing are simulated. +- Server RC.21 gate PASS: 377 unique tests (Generation 179, Printing bridge 16, scanner 53, demo 8, mobile Record Files 18, Echo 68, Echo demo 35), zero failures/errors/skips, Checkstyle PASS, plus 15 real admin-browser E2E on public RC.21. Earlier MySQL timeouts did not recur. Evidence: `C:/Users/james/.cache/qqq-rc21-copies-gate-076d059d8d0641d9a85874ec64008def/verified-summary.json`. +- Honest scope: this is public core RC.21 plus qualified owner inputs. The Printing-bridge tests still resolve three private `pu507` libraries through the owner Printing POM, and other owner qbits are private inputs. It is not a fully public or released cohort. +- Real Authentik desktop acceptance passes with strict TLS (nonce, A, logout, fresh B). The native harness is compiled with 8 launcher guards passing. + +## Still open (none of these is claimed done) + +- Generation: canonical owner integration, then public reproducible inputs for the owner qbits and a coherent published cohort. +- Source: primary CI on the final source, merge, review completion and release. +- Manual on the TC22 (operator remote): physical trigger, scan value/type, AIM, label count, rescan, photo capture/upload, GPS, projection, company profile/wallpaper deployment. +- Native Authentik on the device: temporary CA installation, PIN interaction and the real callback. No trust bypass. +- Signing and distribution: explicitly last, by user decision. + +## Pointers + +Runtime `C:/Users/james/.cache/qqq-local-all-qbits-current.txt`; native proof `scanner-host/build/native-device-e2e/echo-proof.json`; Generation handoff `C:/Users/james/.cache/qqq-generation-copies-handoff.md`; Authentik `docs/REAL-AUTHENTIK-ACCEPTANCE.md`. diff --git a/docs/NAVIGATION-UX.md b/docs/NAVIGATION-UX.md index 553cab7..a7d2dd2 100644 --- a/docs/NAVIGATION-UX.md +++ b/docs/NAVIGATION-UX.md @@ -17,7 +17,7 @@ Every screen provides one **hamburger navigation drawer**, a clear current app/p | Diagnostics | Server reachability details, recent safe logs, retry/check connection and app/version information. Reachable from the connection footer and secondary menu. | | Administration | Approval and fleet management remain in QQQ administration. A handheld pending approval shows its short name and administrator instructions. | -The default-on foreground location reporting toggle, permission recovery and actual provider/fix status belong under device settings. Android location permission is requested once when an approved signed-in session first becomes foreground with reporting enabled; denial leaves scanning usable. Camera permission is requested when the operator chooses Take photo. A live viewing indicator and Stop viewing control remain visible while an administrator is viewing the app; they are not hidden in settings. +The default-on foreground location reporting toggle, permission recovery and actual provider/fix status belong under device settings. Android location permission is requested once when an approved signed-in session first becomes foreground with reporting enabled; denial leaves scanning usable. Camera permission is requested when the operator chooses Take photo. A live viewing indicator remains visible while an administrator is viewing the app. The app provides no pause or stop viewing control, including in Settings; Android's system consent and stop controls remain available for optional whole-device sharing. The owner also requires a device-local Orientation preference in Settings: System default, Portrait, Landscape, Reverse portrait and Reverse landscape. Persist the selection across app restarts and apply it across login, menus and processes. Use Android's activity orientation policy without changing system-wide rotation settings. Rotation or a policy change must preserve drafts and pending upload identity. Licenses belong in the same secondary Settings layer and must remain readable offline. diff --git a/docs/REAL-AUTHENTIK-ACCEPTANCE.md b/docs/REAL-AUTHENTIK-ACCEPTANCE.md new file mode 100644 index 0000000..c575fa5 --- /dev/null +++ b/docs/REAL-AUTHENTIK-ACCEPTANCE.md @@ -0,0 +1,68 @@ +# Real Authentik native acceptance (opt-in, physical TC22) + +This is the only test that runs the Android OAuth2 callback against a real provider. It is skipped by default +(`RealAuthentikAcceptanceTest` needs `-e realAuthentik true`) and is never part of the synthetic E2E scenarios. +Until it passes on the device, [AUTHENTICATION.md](AUTHENTICATION.md) makes no claim of an Android Authentik round trip. + +## What it exercises + +The unmodified flow: `oauth2.signIn` -> real AppAuth external browser -> the real provider -> the exported +`OAuth2RedirectActivity` (`io.qrun.mobile.scanner.e2e.oauth://callback/oauth2redirect`) -> the real QQQ code exchange -> +device pending -> real administrator approval (`mobileDeviceApprove`) -> catalog; then operator A's **Sign out of company** +(RP-initiated, through the browser) and a fresh login of operator B without clearing any browser state. It asserts the +principal QQQ shows (`Current User: ...`), exactly one ACTIVE session for the device, A's session no longer ACTIVE, B's +different, an optional exact server actor, and that no password form is ever offered. It injects no callback, ActivityResult, +token or discovery document. + +## Trust and transport + +- `scanner-host/src/e2e/res/xml/network_security_config.xml` (E2E package only) keeps system trust and the existing cleartext + `127.0.0.1` fixture traffic, and additionally lets the literal host `localhost` trust a **user-installed** CA over HTTPS only. + Nothing is pinned or bypassed; debug, qa and release builds are unchanged. +- The temporary fixture CA must be installed by the person holding the device, through Android settings (a screen lock/PIN is + required), and removed afterwards. Neither this test nor the launcher installs, probes or changes trust. The browser must + also trust that CA, otherwise it shows a certificate warning: do not continue past it. + +## Prerequisites + +The provider fixture and QQQ are running (QQQ `https://localhost:18443`, Authentik `https://localhost:19443`) with the exact E2E +callback registered for login and post-logout; a physical TC22 with `ANDROID_SERIAL` set only in the environment, unlocked and +awake; the disposable packages absent; and a private config file **outside the repository** (it holds a live administrator +session cookie): + +```json +{ + "fixtureType": "real-authentik-android", + "qqqOrigin": "https://localhost:18443", + "adminCookie": "<Cookie header value of an admin QQQ session; never printed>", + "operators": { + "A": { "display": "<exact Current User text for the first operator>", "actor": "<optional server actor value>" }, + "B": { "display": "<exact Current User text for the second operator>", "actor": "<optional server actor value>" } + }, + "sessionActorField": "<optional mobileSession column holding the actor; omit to skip>" +} +``` + +## Run + +```text +./gradlew :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest +node scripts/ci/real-authentik-android.mjs --config <private-config.json> --confirm-temporary-ca-installed +node --test scripts/ci/real-authentik-android.test.mjs # guard tests, no device needed +``` + +The launcher refuses emulators and any device that is not a Zebra TC22, refuses a config inside the checkout or with a different +origin, and refuses if `io.qrun.mobile.scanner.e2e` or its `.test` package already exists (it never uses `install -r`). It adds +`adb reverse` for `tcp:18443` and `tcp:19443` only when absent (never rebinding; a differing existing mapping is an error), +never touches `tcp:8080` or any other mapping, never reads or changes the normal `io.qrun.mobile.scanner` package or its data, +pushes the config through `run-as` stdin (never to shared storage), and afterwards removes only the pushed file, the two +disposable packages and the reverse mappings it created. Output is fixed stage text and a PASS/failure sentence: no serial, URL, +cookie, token or credential. The operator types provider credentials in the browser when an `ACTION:` line appears (bounds: 45 s +for the browser to appear, 600 s per login, 300 s for company logout, 40 min total). + +## Limits + +No browser automation is included, so the operator enters credentials. The test cannot verify CA installation; a missing CA +shows up as the browser never opening within the bound. Process death during the browser step loses the in-memory attempt by +design; rerun. A pass proves this provider/server/package combination only; it is not production-provider, release-build or +EMDK trigger qualification. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index ef0c75a..6809c73 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,12 +1,12 @@ # Scanner APK releases -Status: release automation is prepared, but the signing identity, complete native acceptance, final security review and first release are not yet qualified. Current installed demo APKs use a development certificate. Do not label them production releases. +Status: **no distribution channel is selected.** Everything below describes a candidate GitHub-based APK workflow that is prepared but not chosen; the primary forge is Gitea (`git.kof22.com/QRun-IO`) and GitHub is a shadow/archive, so treat the GitHub environment, tag and release steps as design notes to be re-decided, not instructions to follow now. The signing identity, complete native acceptance, final security review and first release are not yet qualified. Current installed demo APKs use a development certificate. Do not label them production releases. The [November 1 independent automation review](RELEASE-AUTOMATION-REVIEW.md) found no actionable finding in the exact reviewed workflow/build files. External GitHub protections, the production identity and actual signed-device acceptance remain unverified. Owner decision, October 31: leave signing and distribution until the final step because the distribution channel has not been chosen. Do not generate signing keys, populate production signing secrets, create release tags or treat the candidate GitHub APK workflow as a selected distribution channel. Complete implementation, audits, tests, documentation and ordinary CI first; then return to the owner for the distribution decision. -The shared library keeps its existing main-only CircleCI publication. The scanner application uses a separate `scanner-v<semantic-version>` tag and GitHub release. One APK serves customers through the selected server's runtime branding. The application ID and launcher identity remain `io.qrun.mobile.scanner` / QQQ Mobile. +The shared library keeps its existing main-only CircleCI publication. The candidate scanner workflow uses a separate `scanner-v<semantic-version>` tag and GitHub release (not selected). One APK serves customers through the selected server's runtime branding. The application ID and launcher identity remain `io.qrun.mobile.scanner` / QQQ Mobile. ## Release prerequisites @@ -40,4 +40,4 @@ Android accepts an in-place application update only with a compatible signing id For Zebra devices, qualify the device-provided EMDK scanner path described in [Zebra scanning](ZEBRA-SCANNING.md). Production must exclude the synthetic scan receiver and compile-only SDK implementation. Verify actual trigger capture, repeated and empty reads, rotation/reacquisition, background/leave/logout release, and restoration of scanner ownership to other applications. The production path requires no DataWedge profile changes; do not persistently disable DataWedge or treat its recipient signature check as sender authentication. Complete the separately signed ordinary-UID broadcast rejection test against the isolated production-path QA build, then qualify Back/Apps navigation, session termination, reconnect behavior and photo capture using the actual signed release APK. Subsequent customer updates should preserve server selection and device enrollment and keep the same signing identity. Customer branding comes from server metadata and does not require rebuilding the APK. -GitHub APK delivery supports manual or MDM distribution. Play distribution, managed enterprise rollout, signing-key rotation and a production backend deployment need their own qualified configuration; this workflow does not claim to establish them. +If a delivery path is later chosen, an APK from it could support manual or MDM distribution. Play distribution, managed enterprise rollout, signing-key rotation and a production backend deployment need their own qualified configuration; this workflow does not claim to establish them. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index ce1725a..e06c328 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,5 +1,87 @@ +## November 3 — verified widget-state reset fix + +`ProcessViewModel.reset()` now clears `widgetState` so last scanner feedback no longer carries into a new run. The regression proves ordinary step updates retain feedback and reset clears it. Root verified all 57 mobileapp JVM tests (zero failures/errors/skips), scanner lint (zero errors/warnings), debug APK assembly and the 255-file standards gate. Prior head `9cf789c` passed hosted CI3553 in 16m22s; this fix still needs its own hosted checks and normal PR10 → PR8 → main integration. The installed APK and manual acceptance are separate gates; signing/distribution remains deferred. + +## November 3 — current checkpoint (verified; supersedes all older entries below) + +- **Source:** `f72b90d08cf29d2785cef1d5c5e7eeaf75f9afd2` is the signed application-code commit published in PR 10, independently read back over SSH before this documentation update. Gitea CI 3480 **succeeded** on it (unit-test, device-test, package, secrets and gate; root read the UI). Root also verified a clean git merge-tree of `f72b90d` with actual `main` b75fdac and merged nothing. Pull requests #8 and #10 remain open; no 1.0 is released. This documentation update leaves runtime code unchanged from CI-passing `f72b90d`. +- **Installed:** the updated normal APK `4AAB6951D3B0ABDDD4F5A8DC6790DCD84233EE08179B68DB108BF77909AB850E` is installed with the same signer and no data clear. Connected, Apps/Settings, portrait, location on and the brand-only system wallpaper preview were verified. No wallpaper or company profile was applied. +- **Fresh verification:** 222 JVM tests, lint 0 errors/0 warnings (2 info), APK builds, 23 Node tests, 255-file source standards pass. A fresh disposable TC22 Echo ten-stage run passed 109/109 semantic requests (simulated inputs and printing). +- **Authentik:** fresh real desktop acceptance passes (nonce rejection, A login, logout with old-cookie replay rejected, fresh B without clearing cookies, logout) against the same isolated TLS fixture. The opt-in native harness is integrated and compiled, and its 8 launcher guards pass; the on-device run still needs the temporary CA and PIN interaction. No trust bypass. The normal server on 57832 is unchanged and ready. +- **Server side:** server `45f9cbd` is signed and pushed (Gitea PR 7); the public QQQ 4.1.0-RC.21 source update is prepared for the next signed commit. The final RC.21 gate passed: 377 unique tests (Generation 179, Printing bridge 16, scanner 53, demo 8, mobile Record Files 18, Echo 68, Echo demo 35), zero failures/errors/skips, Checkstyle PASS, plus 15 real admin-browser E2E on public RC.21; the earlier MySQL timeouts did not recur. This is public core RC.21 with qualified owner inputs (three private `pu507` libraries still resolve through the owner Printing POM), not a fully public or released cohort. The normal runtime core was not upgraded. +- **Still open (not claimed):** Generation canonical owner integration and public reproducible owner inputs; primary source CI, merge and release; manual TC22 trigger, value/type, AIM, label count, rescan, photo, GPS, projection and profile checks (operator remote); native Authentik CA, PIN and callback; signing/distribution, explicitly last. The normal TC22 shows Echo setup, Copies 1, Connected. + +## November 3 — final client installed; Authentik harness integrated (earlier; superseded by the checkpoint above) + +The verified debug APK 4AAB6951D3B0ABDDD4F5A8DC6790DCD84233EE08179B68DB108BF77909AB850E is installed on the normal TC22 with the same signer and install -r. Existing sign-in, device approval and Connected status survived. Navigation, portrait setting, foreground-location toggle and the brand-only system wallpaper preview were verified; wallpaper was not applied. Feedback, managed configuration and AIM code are now installed, with physical trigger/fidelity still unqualified. + +Fresh verification passes 222 JVM tests, lint zero errors/warnings, debug/E2E/test APK builds, 23 Node tests and the 255-file source standard check. The updated disposable TC22 Echo suite passed all ten stages with every request matching semantic operations; simulated input/printing only. The actual Authentik desktop test passed again on November 3 with strict TLS: nonce rejection, operator A, company logout, old-cookie rejection and fresh operator B. The opt-in real Authentik Android harness is now in the repository and compiles; native execution awaits temporary CA installation/device interaction. No normal app/server trust change was made. + +Echo server source is signed and published at 45f9cbd in Gitea PR7. Generation copies now has an isolated canonical Git integration, with additive PostgreSQL migration verified on real PostgreSQL17. Shared source/dependency publication and hosted CI remain separate gates; no production 1.0 release or physical print claim. Earlier installed-APK/uncompiled-harness notes below are historical. + +## November 2 — Echo copies live and native acceptance complete + +Echo setup now exposes optional **Copies per scan**, default 1, range 1–100. The server pins it for the run and submits one job with that copy count for each scan. The normal TC22 is connected and left on the fresh setup screen showing 1. Its installed APK remains D8109C61E71660D2195337DA988F7F692CC4A0DBE95F61230930D6549CAB4D9B; no client upgrade was needed for this generic semantic field. + +Disposable TC22 Echo acceptance passed ten stages and 116/116 documented requests, including default 1, rejecting 0/101, selected 3 across two scans, recreation and Apps/Leave. Inputs and printing are simulated; physical label count is unconfirmed. The combined source passes 222 JVM tests, lint with zero errors/warnings, APK compilation and 15 Node tests. New scan-feedback, managed-profile/wallpaper and AIM source is not installed on the normal app. + +The existing local server on port 57832 now advertises mobile-scanners 1.5 and mobile-record-files 1.0, retaining the original database, approval, accounts and printer settings. Server/owner gates passed 377 unique tests across the qualified module runs. Generation copies changes remain in the isolated owner handoff, awaiting canonical adoption/publication. Physical trigger/print/rescan, camera/GPS/projection and native Authentik remain open; the operator is remote and temporary CA installation is unconfirmed. Signing/distribution stays last. Older checkpoints below are historical; see FINISH-1.0.md for the current ledger. + +## November 2 — administrator viewing controls + +Removed app-authored Stop viewing / Allow viewing, Stop device screen sharing and the custom notification Stop sharing action. The visible indicator, Android consent/system revocation, password/lifecycle guards, server authorization and lease expiry remain unchanged. The existing native regression verifies an informational non-clickable indicator with no stop/allow buttons, continued JPEG frames after resume, background frame clearing, administrator session termination and fresh login. + +Verification: 185 JVM tests (44 core, 56 mobileapp, 85 host), eight Node guard tests, lint zero errors/warnings (two existing informational hints), debug/QA/test APK builds, and the real TC22 session-monitor scenario passed; all 112 native requests matched published semantic operations. The disposable QA packages were removed. Whole-device Android consent was not rerun for this UI removal. + +Installed the verified debug APK D8109C61E71660D2195337DA988F7F692CC4A0DBE95F61230930D6549CAB4D9B on the normal TC22 with install -r and no data clear. The process restart required sign-in; restored the existing scanner account and verified Connected, the Echo catalog entry and existing approval. Local server57832 remains HTTP200 and the print service remains Running. This APK also contains the previously verified optional raw-scan metadata client, gated off against the unchanged installed mobile1.4 server. Existing unrelated source/test changes are preserved. No commits, publication or broader release work resumed for this scoped request. + +## November 2 — current checkpoint + +Source candidate: immutable `ScannerCapture` retains SDK raw bytes as canonical Base64 before the callback returns and routes them through the ordinary semantic process payload. New optional fields are sent only after same-server discovery advertises mobile-scanners >=1.5,<2; older servers still receive the five legacy keys. Barcode text and symbology remain unchanged, scan/capture string representations are redacted, and a failed-submit regression proves the entire event is resent unchanged. Verification passes 185 JVM tests (44 core, 56 mobileapp, 85 host), lint with zero errors/warnings (two existing informational hints), debug/E2E APK builds and QA Android-test compilation. New native tests and operator installation have not run. The SDK configuration is unchanged and AIM extraction is not implemented by this transport slice. + +Completed and verified: + +- Android source `fb3cc5d` is signed and published as Gitea PR 10. It passes 174 JVM tests (44 core, 55 mobileapp, 75 host), lint with zero errors and zero warnings, and the debug APK build. +- The native `SameOriginInterceptor` fixes the cookie-bearing heartbeat that returned 403. The server's same-origin guard is not weakened. +- The normal TC22 installation was updated without clearing data. Existing login and device approval, heartbeat, the real print client's printer, fresh 4x1 media and published document selection, and Cancel then Leave all work against the one local server. +- That server is one QQQ demo on host port 57832. It runs Mobile Scanners/Echo, Collaboration/Record Files, Document Generation and Printing (API 1.10.0, mobile 1.4.0, printing 2.3.0). On this workstation, device port 8080 forwards to host port 57832. The original accounts, enrollment and approval are retained. +- The Printing owner has aligned the loaded 4x1 stock (101600 x 25400 microns at 300 dpi) and updated the real media revision. A real Echo 4x1 barcode document is published. The TC22 selected the actual printer, the fresh 4x1 media and that document, and Next succeeded and shows Scanner ready and Connected. +- The emulator is off because it stalls this workstation. Do not restart it automatically. + +Not yet done: + +- No physical Echo print or rescan has been proven. One separate synthetic HTTP Echo event reached the actual Windows spooler; physical paper and handheld print/rescan remain unconfirmed. One physical scan of QQQ-TEST-123456 has been requested from the user. +- Still open for 1.0: physical Code128 and leading-zero QR scan, print and rescan; full provider, camera, GPS, projection and device qualification; public coherent artifacts; primary Gitea CI; and final signing and distribution. Signing is deliberately last, by the user's decision. The first demo working does not collapse these requirements, and nothing here claims release readiness. +- Core native API merge continuation PR 1337 (a42f9e5) was reviewed with no actionable conflict regression and its 57 focused tests were verified. Normal CI and publication are not established, and default dependencies are not repinned. + +The entries below are historical. Where they describe the printer media report, the 4x1 document or the same-origin fix as pending, this checkpoint supersedes them. + +## November 2 — native cookie Origin compatibility verified on TC22 + +The combined Echo Demo on57832 (reached through device port8080) returned200 for login, then403 for the cookie-bearing session heartbeat POST, because Android's native QQQ requests omitted `Origin`. The server's same-origin guard remains enforced. Core now has a small `SameOriginInterceptor`: it sets `Origin` from the actual request URL's canonical scheme/host/port only (default ports omitted, IPv6 bracketed, never credentials/input/headers) and is installed last so additional headers cannot override it. It is used by `NetworkQQQRepository` and the `DeviceApi`, `MobileSessionApi` and `RecordFilesApi` clients. Version discovery, presentation/logo fetches, OIDC/browser/token requests and redirect refusal are unchanged. + +All174 JVM tests passed (44core/55mobileapp/75host), with zero failures/errors/skips. Lint has zero errors/warnings and two existing informational hints; debug APK build passed. MockWebServer tests cover exact Origin plus cookie on real POSTs, canonicalization, override resistance and redirect non-forwarding. Session and record-file host tests assert the header on their actual requests. + +The verified APK (SHA256 `0B872005B2FA84F693E73CC49133F5C1C02F732A034AA0635A2B50682A61A98E`) was installed on the normal TC22 without clearing its data. Existing scanner login and device approval work against the actual combined server; heartbeat remains connected. Echo opens its three required choices, the approved printer and its reported media load, and Cancel -> Leave returns to the app normally. This directly qualifies setup/session/cancel against the strict host, not a physical print. The earlier112-request/two-synthetic-scan acceptance remains separate. (Superseded: the printing owner has since reported the 4x1 media and the 4x1 document is published; see the current checkpoint above. Physical printing is still unproven.) Emulator stays off. + +## November 1 — required Document choice verified on the real TC22 + +Echo asks for printer, paper/label size and Document once; all subsequent scans use those server-pinned choices. Android production code is unchanged: the generic semantic INTEGER/PVS/dependency controls handle it. The updated separate E2E app passed all7 stages on the actual TC22 (select Document, clear stale choices on parent change, validate required fields, queue two scans, recreate without duplication, Apps/Leave/CLOSED). All112 requests matched published semantic operations. Disposable app/reverse/fixture cleanup passed, normal app/data preserved, emulator off. Printing and scanner inputs are simulated; physicalScannerQualified/physicalPrintingQualified remain false. + +Verification:99 focused/host JVM tests,8 Node guards, zero lint errors/warnings and both disposable APKs compiled; after a test-only fixed-footer click correction, the actual TC22 acceptance passed. Companion server source8da274e is signed/pushed with45 library and24 composed demo tests passing. Private live server53118 still has header fix101efc8 pending document-choice rollout; real printer pairing retry awaits the current Windows consent. Installed normal APK324227CC... remains sourcea2f0080, whose production code is identical to this test-only change. Full physical/provider/camera/GPS/projection/public artifact/signing gates remain open. +## November 1 — Echo native semantic choices verified on TC22 + +Claude implemented named process possible values using the actual published `searchProcessPossibleValues` JSON contract, dependent-field metadata, cancellation/error/retry and stale-response rejection. The unchanged Echo fixture now completes login, enrollment/approval, printer/media selection once, two accepted synthetic scans, rotation/recreation without duplicates, and Apps/Leave closing the run on the real TC22. All86observed requests matched published semantic operations. Disposable-package cleanup passed; normal package/data and real printers were preserved. + +Validation:169JVMtests,8Nodeguards,236-file source standards, zero lint errors/warnings and bothE2EAPKs pass. This is simulated printing, not EMDK or physical output qualification. The remaining immediate blocker is the real Generation/Printing adapter and template/host composition, followed by physical print/rescan. Normal installed APK remains625a275; normal server API1.9/mobile1.1. Production signing remains deferred. # Session state +## Echo semantic presentation source checkpoint + +The optional extension 1.4 acknowledgmentLabel and manualEntryEnabled hints are published on feature/echo-scanner-presentation (draft PR9). Existing defaults remain Saved scan/manual enabled; Echo uses Print queued/manual disabled. Strict compilation, 167 JVM cases, lint zero errors/warnings and debug assembly pass. Hardware, lifecycle and receipt predicates are unchanged. The server companion passes 69 consumer cases plus two contract cases; the optional Echo module passes 15 strict QQQ/H2 tests. + +The normal TC22 remains the separately installed 625a275 build (APK AF13778A…3E9216), preserving sign-in/data. Its About screen was opened and Query artwork, both public links and licenses verified through filtered accessibility data. New Echo-native UI/actual owner adapters/physical printing remain unqualified; printing and document/scripting owners have acknowledged their implementation lanes. The running normal server remains API1.9/mobile1.1. Production signing/distribution stays the owner's final step. + ## November 1 standards fixes and About update Applied the scoped standards review: copyright/SPDX, explicit imports, declaration headers, native official/three-space formatter profile, mechanical CI source gate and Kotlin warnings-as-errors. Strict compilation and163 JVM tests pass; all46 disposable sample native tests pass on TC22, and production QA instrumentation compiles. The native suite uses fixture providers; actual Android external-browser/provider acceptance remains separate. The existing formatter reports one unstable Gradle script and full formatter CI runner provisioning remains explicit in the review. diff --git a/docs/TODO.md b/docs/TODO.md index 0fa00f1..7a8d505 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,4 +1,36 @@ # TODO +Latest November 3 checkpoint (current; supersedes the older entries below): + +- [x] Android `f72b90d` the signed application-code commit published in PR 10 (SSH read-back verified); Gitea CI 3480 succeeded (unit-test, device-test, package, secrets, gate); clean merge-tree with `main` b75fdac, nothing merged. Pull requests #8 and #10 remain open; merge and release not done. This update changes documentation only. +- [x] Fresh disposable TC22 Echo ten-stage run: 109/109 semantic requests (simulated inputs/printing); installed APK `4AAB6951...AB850E` unchanged data. +- [x] Fresh real Authentik desktop acceptance passes; native harness compiled, 8 launcher guards pass. [ ] On-device run awaits the temporary CA and PIN; no trust bypass. +- [x] Server-side RC.21 gate PASS: 377 unique tests plus 15 real admin-browser E2E on public RC.21; the earlier MySQL timeouts did not recur. [ ] It is public core RC.21 with qualified private owner inputs, not a public/released cohort; the normal runtime core is not upgraded and Echo on RC.21 is not installed. + +- [x] Install final client over the normal TC22 with signature match/data preserved; existing sign-in/approval and Connected verified. Settings include portrait, location reporting and working wallpaper preview. +- [x] Fresh 222 JVM tests, lint zero errors/warnings, all APK builds, 23 Node tests and 255-file standards gate; updated ten-stage native Echo acceptance passes with simulated scans/printing. +- [x] Integrate and compile opt-in real Authentik Android acceptance; real-provider desktop acceptance rerun passes all six phases with strict TLS. +- [ ] Run real native Authentik after temporary CA installation/device interaction. Confirm physical scanner/label count/fidelity, camera/GPS/projection/profile behavior. Complete shared artifact publication/hosted CI; signing remains last. + +Current checkpoint (November 2, copies adoption; supersedes older source/runtime notes below): + +- [x] Verify optional Copies per scan through the actual generic native UI: default 1, range 1–100, selected 3 retained across two scans/recreation, and Apps/Leave; ten stages, 116 documented requests. Normal TC22 setup shows 1 against the updated local server. +- [x] Combined candidate gate: 222 JVM tests, lint zero errors/warnings, debug/E2E/test APK compilation and 15 Node tests. Normal installed APK remains D8109C61; newer feedback/profile/wallpaper/AIM code is not installed. +- [ ] Physically confirm label quantity and barcode fidelity. Native Authentik and camera/GPS/projection qualification remain open while the operator is remote; temporary CA installation is unconfirmed. +- [ ] Coordinate canonical Generation copies adoption and coherent publication/CI. Signing/distribution remains last. + +- [x] Implement negotiated mobile1.5 raw-byte transport with immutable capture, same-server capability gating and byte-identical failed-request retry;185 JVM tests, lint0errors/0warnings, debug/E2E builds and QA test compilation pass. +- [ ] Qualify new raw-byte delivery on TC22 and complete actual AIM extraction/decoder-profile checks. Current source leaves AIM absent and does not change hardware configuration; full Echo fidelity is still open. +- [x] Verify native `Origin` compatibility:174 JVM tests, lint0errors0warnings and APK build pass; normal TC22 login/approval/heartbeat/Echo setup/printer-media choices/Cancel-Leave pass against the strict local Echo host through8080->57832, preserving data. + +- [x] Verify generic INTEGER Document choice/dependency clearing on actual TC22:7 stages,112 semantic requests,99 focused/host tests and8 Node guards; simulated printing, normal data preserved. +- [x] Connect the installed print client to the single local server and publish the actual Echo4x1 document through native QQQ processes. +- [x] Resolve the printing agent's earlier 4x6 media report versus loaded 4x1 stock: the Printing owner aligned 101600 x 25400 micron/300 dpi media and the TC22 selected the real printer, fresh 4x1 media and published document; Next succeeded. +- [ ] Complete one physical Code128 (QQQ-TEST-123456) scan, print and rescan on the TC22, then leading-zero QR. One separate synthetic HTTP Echo event reached the actual Windows spooler; physical paper and handheld print/rescan remain unconfirmed. +- [ ] Qualify public coherent artifacts and primary Gitea CI; signing and distribution stay last by user decision. Core PR 1337 normal CI and publication are not established; do not repin default dependencies. + +- [x] Add metadata-driven Echo acknowledgment/manual-entry hints with old defaults; 167 JVM tests, strict compilation, lint zero errors/warnings and debug build pass. +- [ ] Qualify native Echo scan/print-acceptance with independently decoded physical output (setup and Leave against the real owner adapters are verified; source tests are not physical printing evidence). +- [x] Verify Query artwork, Dogfooding/Query links and licenses on the normal TC22 About screen using filtered accessibility data. - [x] Apply scoped copyright/import/header/formatter corrections, mechanical CI gate and Kotlin warnings-as-errors;163JVM plus46TC22 sample native cases pass. See [review](CODING-STANDARDS-REVIEW-2026-11-01.md) for the remaining formatter CI limitation. - [x] Add Query artwork, app version, Dogfooding/Query links and license access under Settings → About QQQ Mobile. @@ -87,3 +119,13 @@ Offline queue/background synchronization is deliberately outside version one. - [x] Add and run real-server scanner-host E2E: login, pending enrollment, approval, native app/process selection, manual scan persistence and revoked-device denial. Use an isolated app ID and database. - [x] Run and verify native real-server E2E on pushes and pull requests; current7de hosted checks pass. - [ ] Complete the final operator walkthrough on TC22 after updating the preserved demo/runtime, including outage/reconnect and real camera capture. + +- [x] Implement named semantic process choices and qualify Echo native setup/two scans/rotation/Apps Leave onTC22 (169JVM,8guards,86documentedrequests; printing simulated). +- [x] Real Echo host and owner adapters are composed and running in the single local server (see the Origin item above). +- [ ] Print a physical label and rescan the exact value and type (tracked above). + +- [x] Clear scanner feedback on process reset; 57 mobileapp JVM tests, zero-warning scanner lint, debug build and 255-file standards pass. Hosted checks and PR10 → PR8 → main integration remain pending. + +# Scoped administrator viewing update — November 2 + +- [x] Remove app-authored viewing stop controls, verify the existing native session-monitor flow and install on TC22 with app data preserved. Retain the visible indicator, Android consent/system stop controls, server leases and authorization. diff --git a/docs/UI-API.md b/docs/UI-API.md index de14196..6e86c39 100644 --- a/docs/UI-API.md +++ b/docs/UI-API.md @@ -18,7 +18,7 @@ The host first requests `GET /qqq/v1/version` through a public client with no se Compatibility is cached for the selected server's host lifetime and isolated between server origins. Missing features/extensions or Android channel, malformed discovery or unsupported versions stop startup with an explanation and retry option. Unknown optional properties and feature names are tolerated; supported enum models retain an unknown value so an added enum does not crash JSON decoding. Unknown authentication providers and required UI controls remain explicitly unsupported. -The app sends `X-QQQ-Client: android/qqq-mobile`. The QQQ software release and semantic API version are distinct: the local demo currently uses QQQ `4.1.0-RC.20` and advertises semantic API `1.8.0`. +The app sends `X-QQQ-Client: android/qqq-mobile`. The QQQ software release and semantic API version are distinct: clients use discovery rather than inferring compatibility from artifact versions. Current installed-runtime and source-qualification checkpoints are recorded in [session state](SESSION-STATE.md). ## API operations @@ -46,14 +46,14 @@ Core structured errors retain their code, message, field errors and trace ID. Fi | --- | --- | | App navigation | Server-defined apps and process links, including root processes and section app links | | Process lifecycle | Init, step submission, asynchronous polling, server-directed completion and backend cancellation | -| `EDIT_FORM` | Metadata field subsets, labels, hidden/editable/required flags, text/password, Boolean, integer/long/decimal, inline possible values and validation errors; explicit null clearing is preserved | +| `EDIT_FORM` | Metadata field subsets, labels, hidden/editable/required flags, text/password, Boolean, integer/long/decimal, inline possible values, named possible-value sources searched through the core operation `searchProcessPossibleValues` (`POST /qqq/v1/processes/{process}/possibleValues/{field}`, JSON `values`/`processValues`/`useCase: form`; contingent fields honor `possibleValueSourceDependsOn` and `possibleValueSourceDependencyBehavior`, re-query on parent change and drop stale selections) and validation errors; explicit null clearing is preserved | | Date/time fields | ISO text inputs with format hints and validation; no native date/time picker | | `VIEW_FORM` | Read-only values for the declared field subset | | Dynamic metadata | Updated frontend steps and updated fields applied to the active process | | `HELP_TEXT`, `HTML` | Plain semantic text, with text fallback for markup; no embedded web application | | `PROCESS_SUMMARY_RESULTS` | Status, counts and messages; link destinations displayed as text, without assuming a web route is a native action | | Composite/ad-hoc widgets | Existing text, button, input, image, audio, divider and nested composite controls | -| `SCANNER` extension block | Native DataWedge/manual input; server title, instructions, labels and maximum length; standard process submission and acknowledgment | +| `SCANNER` extension block | Trusted Zebra EMDK/manual input; server title, instructions, labels and maximum length; standard process submission and acknowledgment | | `RECORD_FILES_CAPTURE` extension block | Server-selected parent record; optional Record Files 1.x capability discovery; native camera/photo picker; checksummed resumable online upload and exact committed receipt | | Unsupported required controls | Explicit unsupported message; Next and process widget submission blocked | @@ -67,7 +67,7 @@ The approved, authenticated host sends foreground activity through the registere An administrator viewing the ordinary QQQ session record renews a short screen-viewing lease through its standard widget. The host uses PixelCopy on its own app-content rectangle only, at most once per second, bounded to 640×1280 pixels and 256 KiB JPEG. No OS screen, other application or remote control is exposed. Capture requires the resumed, focused, approved authenticated screen, completed rendering and a live lease. Password screens, loading transitions, backgrounding and logout block capture and clear the server's displayed frame. Screen/session state is checked after capture and immediately before upload. Lease timing uses server-relative duration and Android's monotonic clock, not the handheld wall clock. Frames are not saved locally. -The operator sees a screen-viewing indicator and can stop viewing until they allow it again. Foreground location reporting defaults on when no saved choice exists; an explicit saved off choice is preserved. Settings exposes the reporting toggle, precise/approximate Android permission, provider availability and fix status. The host requests coarse and fine Android permission together once, on the first approved signed-in foreground session with reporting enabled. Denial never prevents scans or triggers repeated automatic prompts; Settings links to Android permission settings for recovery. Precise permission permits GPS updates, while approximate permission uses available network location. Device location providers must also be enabled; the reporting preference cannot turn on Android GPS. No background location permission is requested or background tracking performed. Updates stop outside approved signed-in foreground sessions or when reporting is disabled. Heartbeats report coordinates, accuracy and capture time only when a foreground fix exists; QQQ shows the age of the last known observation separately. Earlier clients stored no reporting preference, so a previously missing Android grant is not treated as an explicit reporting opt-out. +The operator sees an informational screen-viewing indicator. The app provides no operator pause or stop controls; administrator viewing remains subject to server leases and authorization. Optional whole-device sharing still requires Android consent and honors system revocation. Foreground location reporting defaults on when no saved choice exists; an explicit saved off choice is preserved. Settings exposes the reporting toggle, precise/approximate Android permission, provider availability and fix status. The host requests coarse and fine Android permission together once, on the first approved signed-in foreground session with reporting enabled. Denial never prevents scans or triggers repeated automatic prompts; Settings links to Android permission settings for recovery. Precise permission permits GPS updates, while approximate permission uses available network location. Device location providers must also be enabled; the reporting preference cannot turn on Android GPS. No background location permission is requested or background tracking performed. Updates stop outside approved signed-in foreground sessions or when reporting is disabled. Heartbeats report coordinates, accuracy and capture time only when a foreground fix exists; QQQ shows the age of the last known observation separately. Earlier clients stored no reporting preference, so a previously missing Android grant is not treated as an explicit reporting opt-out. ## Record photos @@ -84,3 +84,7 @@ JVM checks cover compatibility rejection before credentials, origin isolation, a The native E2E runner in [EMULATOR.md](EMULATOR.md) forwards the real host's requests through a test-only OpenAPI audit. Scenarios cover incompatible core major, missing extension, missing Android channel, the real supported server scan flow, additive future metadata, native administrative forms, session activity/viewing/forced logout, and actual PixelCopy image contents. The audit records operation IDs and safe request metadata, never headers or bodies. Passing runs must prove that all observed requests match published operations and rejected servers receive no credentials. Precise revision boundaries and results are tracked in [SESSION-STATE.md](SESSION-STATE.md); implementation of a check is not evidence that its latest run passed. Server changes using existing supported components can reach the shared APK through metadata. New required native controls or an incompatible major contract require a shared client update and conformance tests. Customer name, theme and logo changes remain server configuration and do not require customer-specific builds. During this initial migration, update the companion server and APK together; preserve the demo database and install with `adb install -r` to retain local device enrollment. + +## Echo scanner presentation hints + +Mobile-scanners 1.4 adds optional SCANNER acknowledgmentLabel and manualEntryEnabled. Existing servers retain Saved scan and manual entry. Echo supplies Print queued and false, hiding manual controls because typed text has no decoder-provided symbology. Explicitly malformed labels or nonboolean flags disable the control. These presentation hints never change the matching event/scanSaved/scanId acknowledgment rules, and queue acceptance does not mean physical output completed. No application or process name selects this behavior. diff --git a/docs/ZEBRA-SCANNING.md b/docs/ZEBRA-SCANNING.md index f8debce..bb6c3f0 100644 --- a/docs/ZEBRA-SCANNING.md +++ b/docs/ZEBRA-SCANNING.md @@ -6,6 +6,8 @@ The app first requires the system's `com.symbol.emdk` shared library and enabled ## Lifecycle and process contract +When verified discovery for the current server advertises mobile-scanners 1.5 or newer within major 1, each capture can also send `rawDataBase64` and `aimIdentifier`. The EMDK callback encodes its exact `getRawData()` output once before returning; the immutable event survives HTTP retries unchanged. Older servers receive the legacy event fields. Raw input is bounded to 8192 bytes; null, empty or oversized raw input is omitted rather than truncated. These are SDK bytes, which may include framing, not a claim of original symbol codewords. Current hardware capture leaves AIM absent: no prefix inference or scanner configuration change is implemented. Decoder qualification and native raw-byte acceptance remain required for full Echo fidelity. + The adapter acquires the default scanner only while the server-defined SCANNER widget is visible and its Activity is resumed. HARD trigger reads are armed after IDLE, with one outstanding read. A decoded string is passed unchanged to the existing ScanEvent and semantic process submission, with its EMDK symbology and adapter-owned `zebra-emdk` source. New reads wait until the prior event is confirmed or explicitly abandoned by the existing process flow. Manual events remain `manual`. Neither source label is server-side attestation. An IDLE callback with no pending read also rearms after a trigger release or timeout without a decode. If IDLE precedes the data callback, accepting that data blocks further input and cancels any read armed in between. A rapid pause/resume during an earlier asynchronous manager-open request can temporarily encounter the one-manager guard; **Reconnect scanner** retries explicitly after the prior manager has released. An unconfirmed release remains blocked until the app restarts. diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt index 9129b3a..295201e 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt @@ -41,6 +41,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import retrofit2.Retrofit import java.util.TimeZone @@ -105,6 +106,8 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs } response } + // Last, so a caller-supplied additional header can never override the request's own origin. + .addInterceptor(SameOriginInterceptor()) .build() ) .baseUrl(baseUrl) @@ -291,6 +294,24 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs qqqApiServiceMap[baseUrl]!!.processCancel(processName, processUUID).close() } + /******************************************************************************* + ** Options use the semantic `{options:[{id,label}]}` response; ids stay JSON scalars so they round-trip unchanged. + *******************************************************************************/ + override suspend fun processPossibleValues(processName: String, fieldName: String, values: Map<String, Any?>, processValues: Map<String, Any?>): List<com.kingsrook.qqq.frontend.android.core.model.QPossibleValue> { + fun json(map: Map<String, Any?>) = JSONObject().apply { map.forEach { (key, value) -> put(key, value ?: JSONObject.NULL) } } + val request = JSONObject().put("values", json(values)).put("processValues", json(processValues)).put("useCase", "form") + val body = qqqApiServiceMap[baseUrl]!!.processPossibleValues( + processName, fieldName, request.toString().toRequestBody("application/json".toMediaType()) + ).use { JSONObject(it.string()) } + // The server omits `options` when empty. + val options = body.optJSONArray("options") ?: return emptyList() + return (0 until options.length()).map { options.getJSONObject(it) }.map { option -> + val id = option.get("id") + com.kingsrook.qqq.frontend.android.core.model.QPossibleValue( + id as? java.io.Serializable ?: id.toString(), option.optString("label", id.toString())) + } + } + /******************************************************************************* ** Poll the exact process/job identity supplied by the server instead of assuming background completion. *******************************************************************************/ diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt index 3aa3d3f..4687db6 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt @@ -164,6 +164,16 @@ interface QQQApiService { @Path(value = "jobUUID") jobUUID: String, ): ProcessStepResult + /******************************************************************************* + ** Published operation `searchProcessPossibleValues`: JSON body {values, processValues, useCase, searchTerm, ids}. + *******************************************************************************/ + @POST(BASE_PATH + "processes/{processName}/possibleValues/{fieldName}") + suspend fun processPossibleValues( + @Path(value = "processName") processName: String, + @Path(value = "fieldName") fieldName: String, + @Body body: RequestBody + ): ResponseBody + /******************************************************************************* ** Cancel the identified server process explicitly instead of dropping only client navigation. *******************************************************************************/ diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt index bf7506f..ea95a5d 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt @@ -123,6 +123,13 @@ interface QQQRepository { throw UnsupportedOperationException("Process cancellation is unavailable") } + /******************************************************************************* + ** Search a process field's server possible-value source (`searchProcessPossibleValues`) with the current form values + ** (`values`, used for dependsOn/filter inputs) and scalar process values, using the FORM use case. + *******************************************************************************/ + suspend fun processPossibleValues(processName: String, fieldName: String, values: Map<String, Any?>, processValues: Map<String, Any?> = emptyMap()): List<com.kingsrook.qqq.frontend.android.core.model.QPossibleValue> = + throw UnsupportedOperationException("Remote possible values are unavailable") + /******************************************************************************* ** Poll the server-issued job within its process instance instead of inferring asynchronous completion. *******************************************************************************/ diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/SameOriginInterceptor.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/SameOriginInterceptor.kt new file mode 100644 index 0000000..394583c --- /dev/null +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/SameOriginInterceptor.kt @@ -0,0 +1,31 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package com.kingsrook.qqq.frontend.android.core.controllers + +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response + +/******************************************************************************* + ** Native requests have no browser to supply `Origin`, yet cookie-bearing QQQ mutations must present their own origin. + ** The value is always the canonical scheme/host/port of the actual request URL: never credentials, input or header state. + ** Install it only on clients that talk to the selected QQQ server, never on provider, logo or download clients, and + ** after any header-adding interceptor so that it has the final say. + *******************************************************************************/ +class SameOriginInterceptor : Interceptor { + /******************************************************************************* + ** Replace any existing `Origin` with the origin of the URL being requested; redirects stay refused by the clients. + *******************************************************************************/ + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed(chain.request().newBuilder().header("Origin", httpOrigin(chain.request().url)).build()) + + companion object { + /******************************************************************************* + ** `scheme://host[:port]` with the scheme's default port omitted and IPv6 hosts bracketed. + *******************************************************************************/ + fun httpOrigin(url: HttpUrl): String { + val host = if (url.host.contains(':')) "[${url.host}]" else url.host + val port = if (url.port == HttpUrl.defaultPort(url.scheme)) "" else ":${url.port}" + return "${url.scheme}://$host$port" + } + } +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibility.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibility.kt index 5e97a89..a40dddb 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibility.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibility.kt @@ -44,6 +44,11 @@ class UiApiCompatibility( @Volatile private var verified: UiApiVersion? = null + /******************************************************************************* + ** Non-blocking view of an already verified result; never performs discovery, so it is safe on the UI thread. + *******************************************************************************/ + fun verifiedVersion(): UiApiVersion? = verified + /******************************************************************************* ** Call on an IO thread. Synchronization prevents competing startup requests bypassing discovery. *******************************************************************************/ diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFieldMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFieldMetaData.kt index ea60a56..2a50a8e 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFieldMetaData.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFieldMetaData.kt @@ -39,6 +39,9 @@ data class QFieldMetaData( val isHeavy: Boolean = false, val possibleValueSourceName: String? = null, val inlinePossibleValueSource: List<QPossibleValue>? = null, + // Contingent fields only: DISABLE_UNTIL_SET (default when depending) or SHOW_ALL_UNTIL_SET. + val possibleValueSourceDependsOn: List<String>? = null, + val possibleValueSourceDependencyBehavior: String? = null, val displayFormat: String? = null, val adornments: List<FieldAdornment>? = null, val helpContents: List<QHelpContent>? = null, diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt index de26af6..49920e0 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt @@ -40,6 +40,37 @@ class NetworkQQQRepositoryTest { } } + /******************************************************************************* + ** Published `searchProcessPossibleValues`: JSON body with the parent values and FORM use case; the response is + ** `{options:[{id,label}]}` and the server omits `options` entirely when nothing matches. + *******************************************************************************/ + @Test + fun processPossibleValuesUsesThePublishedJsonOperation() = runBlocking() + { + MockWebServer().use { server -> + server.enqueue(MockResponse().setBody(VERSION_FIXTURE)) + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"options":[{"id":"4x6","label":"4 x 6 in"},{"id":7,"label":"Seven"}]}""")) + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}")) + val origin = server.url("/").toString() + val repository = NetworkQQQRepository(origin) + repository.setEnvironment(Environment("test", origin)) + val options = repository.processPossibleValues("echoPrint", "mediaId", mapOf("printerId" to "printer"), mapOf("echoRunId" to "run")) + assertEquals("/qqq/v1/version", server.takeRequest().path) + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/qqq/v1/processes/echoPrint/possibleValues/mediaId", request.path) + assertTrue(request.getHeader("Content-Type")!!.startsWith("application/json")) + val body = org.json.JSONObject(request.body.readUtf8()) + assertEquals("printer", body.getJSONObject("values").getString("printerId")) + assertEquals("run", body.getJSONObject("processValues").getString("echoRunId")) + assertEquals("form", body.getString("useCase")) + assertEquals(listOf("4x6", "7"), options.map { it.id.toString() }) + assertTrue("A JSON integer id must stay an integer for INTEGER fields", options[1].id is Int) + assertEquals(listOf("4 x 6 in", "Seven"), options.map { it.label }) + assertTrue(repository.processPossibleValues("echoPrint", "mediaId", emptyMap(), emptyMap()).isEmpty()) + } + } + /******************************************************************************* ** *******************************************************************************/ @@ -109,6 +140,38 @@ class NetworkQQQRepositoryTest { } } + /******************************************************************************* + ** Cookie-bearing native mutations (session/process POSTs) carry the exact request origin, and a caller-supplied + ** additional header can neither add a different Origin nor remove the real one. Login (no cookie yet) carries it too. + *******************************************************************************/ + @Test + fun cookieBearingPostsCarryTheExactRequestOriginEvenIfAdditionalHeadersTryToSetOne() = runBlocking() + { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse().setBody(VERSION_FIXTURE)) + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"type":"COMPLETE","processUUID":"process","nextStep":"result","values":{}}""")) + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}")) + val origin = server.url("/").toString() + val repository = NetworkQQQRepository(origin, additionalHeaders = { mapOf("Origin" to "https://evil.example", "X-QQQ-Device-Id" to "test-device") }) + repository.setEnvironment(Environment("test", origin)) + repository.setSessionUUID("synthetic-session") + repository.processStep("edit", "process", "input", mapOf("a" to "b"), 1000) + repository.logout() + server.takeRequest() + // Forwarded debug servers may listen elsewhere (for example 8080): only the URL the client used matters. + val expected = "http://${server.hostName}:${server.port}" + listOf(server.takeRequest(), server.takeRequest()).forEach { request -> + assertEquals("POST", request.method) + assertEquals("sessionUUID=synthetic-session", request.getHeader("Cookie")) + assertEquals(expected, request.getHeader("Origin")) + assertEquals(1, request.headers.values("Origin").size) + assertEquals("http://" + request.getHeader("Host"), request.getHeader("Origin")) + assertEquals("test-device", request.getHeader("X-QQQ-Device-Id")) + } + } + } + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/SameOriginInterceptorTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/SameOriginInterceptorTest.kt new file mode 100644 index 0000000..69e921f --- /dev/null +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/SameOriginInterceptorTest.kt @@ -0,0 +1,70 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package com.kingsrook.qqq.frontend.android.core.controllers + +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/******************************************************************************* + ** Native clients present the exact origin of the URL they actually request, canonicalized like a browser. + *******************************************************************************/ +class SameOriginInterceptorTest { + /******************************************************************************* + ** The origin comes only from scheme/host/port: a debug loopback forward keeps its device-side port + ** (whatever the server listens on behind it), defaults are omitted, and IPv6 hosts stay bracketed. + *******************************************************************************/ + @Test + fun originIsTheCanonicalSchemeHostAndPortOfTheRequestUrl() { + fun origin(url: String) = SameOriginInterceptor.httpOrigin(url.toHttpUrl()) + assertEquals("http://127.0.0.1:57832", origin("http://127.0.0.1:57832/qqq/v1/manageSession")) + assertEquals("http://127.0.0.1", origin("http://127.0.0.1:80/")) + assertEquals("https://qqq.example.test", origin("https://qqq.example.test/")) + assertEquals("https://qqq.example.test", origin("https://qqq.example.test:443/path")) + assertEquals("https://qqq.example.test:8443", origin("https://qqq.example.test:8443/")) + assertEquals("http://qqq.example.test:443", origin("http://QQQ.Example.Test:443/")) + assertEquals("http://[::1]:57832", origin("http://[::1]:57832/")) + assertEquals("https://[2001:db8::1]", origin("https://[2001:db8::1]:443/")) + assertEquals("Credentials, path, query and fragment are never part of an origin", "http://127.0.0.1:57832", + origin("http://user:secret@127.0.0.1:57832/a/b?token=x#fragment")) + } + + /******************************************************************************* + ** The header the interceptor sets is final, even when an earlier layer tried to supply a different one. + *******************************************************************************/ + @Test + fun overridesAnyExistingOriginAndMatchesTheServerRequestHost() { + MockWebServer().use { server -> + server.enqueue(MockResponse().setBody("{}")) + server.start() + val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).addInterceptor(SameOriginInterceptor()).build() + client.newCall(Request.Builder().url(server.url("/")).header("Origin", "https://evil.example").build()).execute().close() + val request = server.takeRequest() + assertEquals("http://" + request.getHeader("Host"), request.getHeader("Origin")) + assertEquals(1, request.headers.values("Origin").size) + } + } + + /******************************************************************************* + ** A redirect is still refused, so neither the cookie nor this origin reaches another server. + *******************************************************************************/ + @Test + fun refusedRedirectNeverReachesAnotherOrigin() { + MockWebServer().use { first -> + MockWebServer().use { other -> + first.start() + other.start() + first.enqueue(MockResponse().setResponseCode(307).setHeader("Location", other.url("/stolen").toString())) + val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).addInterceptor(SameOriginInterceptor()).build() + client.newCall(Request.Builder().url(first.url("/")).header("Cookie", "sessionUUID=synthetic").build()).execute().use { assertEquals(307, it.code) } + assertEquals(1, first.requestCount) + assertEquals(0, other.requestCount) + assertNull(other.takeRequest(100, java.util.concurrent.TimeUnit.MILLISECONDS)) + } + } + } +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentEditForm.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentEditForm.kt index 07b5e77..0b93739 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentEditForm.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentEditForm.kt @@ -78,17 +78,25 @@ fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFro fun change(newValue: Any?) { value = newValue processViewModel.processValues[field.name] = newValue + // Dependent possible-value sources (for example media for a printer) re-query, disable or drop their selection. + processViewModel.fieldChanged(field.name) } + val remote = field.possibleValueSourceName != null && field.inlinePossibleValueSource.isNullOrEmpty() + val blocked = processViewModel.remoteOptionsBlockedReason(field) + val reset = processViewModel.fieldResets[field.name] LaunchedEffect(processViewModel.stepInstanceCounter, field.name) { if (!processViewModel.processValues.containsKey(field.name)) processViewModel.processValues[field.name] = value if (field.isEditable) processViewModel.registerFormFieldName(field.name) + if (remote) processViewModel.loadRemoteOptions(field) } + // The view model cleared this selection because its controlling field changed or the choice no longer exists. + LaunchedEffect(reset) { if (reset != null) value = processViewModel.processValues[field.name] } Column(Modifier.fillMaxWidth().padding(8.dp)) { - val options = field.inlinePossibleValueSource - if (!options.isNullOrEmpty()) { + val options = if (remote) processViewModel.remoteOptions[field.name].orEmpty() else field.inlinePossibleValueSource.orEmpty() + if (remote || options.isNotEmpty()) { var expanded by remember { mutableStateOf(false) } Text(field.label) - OutlinedButton(onClick = { expanded = true }, enabled = enabled) { + OutlinedButton(onClick = { expanded = true }, enabled = enabled && blocked == null) { Text(options.firstOrNull { it.id.toString() == value?.toString() }?.label ?: "Choose a value") } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { @@ -114,6 +122,11 @@ fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFro ) } if (error != null) Text(error, color = MaterialTheme.colorScheme.error) + blocked?.let { Text(it) } + processViewModel.remoteOptionErrors[field.name]?.let { message -> + Text(message, color = MaterialTheme.colorScheme.error) + OutlinedButton(onClick = { processViewModel.loadRemoteOptions(field) }) { Text("Retry choices") } + } semanticFieldHint(field)?.let { Text(it) } field.helpContents.orEmpty().forEach { Text(it.contentAsText ?: plainText(it.content)) } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessViewModel.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessViewModel.kt index a1b807f..460acb0 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessViewModel.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessViewModel.kt @@ -34,6 +34,8 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository +import com.kingsrook.qqq.frontend.android.core.model.QPossibleValue +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendStepMetaData import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance import com.kingsrook.qqq.frontend.android.core.model.metadata.QProcessMetaData @@ -67,8 +69,93 @@ open class ProcessViewModel : ViewModel() { private set var submissionError: String? by mutableStateOf(null) private set + /******************************************************************************* + ** Client-only widget state for this process instance. It survives step changes and recreation, is + ** replaced with the process, and is never submitted, saved, logged or shared between processes. + *******************************************************************************/ + val widgetState = mutableStateMapOf<String, Any>() private var processComplete: Boolean by mutableStateOf(false) private var initializing = false + + /** Options from server possible-value sources (`searchProcessPossibleValues`), keyed by field name. */ + val remoteOptions = mutableStateMapOf<String, List<QPossibleValue>>() + val remoteOptionErrors = mutableStateMapOf<String, String>() + + /** Incremented when this model clears a field's value, so the rendered control can follow. */ + val fieldResets = mutableStateMapOf<String, Int>() + private var dependencyVersion by mutableIntStateOf(0) + private val remoteOptionJobs = mutableMapOf<String, Job>() + private val remoteOptionGenerations = mutableMapOf<String, Int>() + + /******************************************************************************* + ** Fields whose choices come from a named possible-value source rather than inline metadata. + *******************************************************************************/ + private fun isRemote(field: QFieldMetaData) = field.possibleValueSourceName != null && field.inlinePossibleValueSource.isNullOrEmpty() + + /******************************************************************************* + ** DISABLE_UNTIL_SET (the default for contingent fields) offers no choices until every declared parent has a value. + *******************************************************************************/ + fun remoteOptionsBlockedReason(field: QFieldMetaData): String? { + dependencyVersion // process values are not observable state; this makes dependent controls recompose + if (!isRemote(field) || field.possibleValueSourceDependencyBehavior == "SHOW_ALL_UNTIL_SET") return null + val missing = field.possibleValueSourceDependsOn.orEmpty().filter { processValues[it].let { value -> value == null || value.toString().isEmpty() } } + if (missing.isEmpty()) return null + val labels = missing.map { name -> activeStep?.formFields?.firstOrNull { it.name == name }?.label ?: name } + return "Choose ${labels.joinToString(" and ")} first." + } + + /******************************************************************************* + ** Search the field's server source with the current form values. Only the newest request may publish, so a late + ** response for a replaced parent value can never restore choices (or a selection) that no longer apply. + *******************************************************************************/ + fun loadRemoteOptions(field: QFieldMetaData) { + if (!isRemote(field)) return + val generation = (remoteOptionGenerations[field.name] ?: 0) + 1 + remoteOptionGenerations[field.name] = generation + remoteOptionJobs.remove(field.name)?.cancel() + remoteOptionErrors.remove(field.name) + remoteOptions.remove(field.name) + if (remoteOptionsBlockedReason(field) != null) return + + val formNames = activeStep?.formFields.orEmpty().map { it.name }.toSet() + val values = formNames.filter { it != field.name }.associateWith { processValues[it] } + val scalars = processValues.filter { (key, value) -> key !in formNames && (value is String || value is Number || value is Boolean) } + remoteOptionJobs[field.name] = viewModelScope.launch { + try { + val options = qqqRepository.processPossibleValues(processName, field.name, values, scalars) + if (remoteOptionGenerations[field.name] != generation) return@launch + remoteOptions[field.name] = options + val chosen = processValues[field.name] + if (chosen != null && options.none { it.id.toString() == chosen.toString() }) clearSelection(field.name) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + if (remoteOptionGenerations[field.name] != generation) return@launch + remoteOptionErrors[field.name] = semanticError(e, "The available choices could not be loaded. Try again.") + } + } + } + + /******************************************************************************* + ** After a field changes, dependents re-query (or disable) and drop selections that belonged to the old parent value. + *******************************************************************************/ + fun fieldChanged(name: String) { + dependencyVersion++ + for (dependent in activeStep?.formFields.orEmpty().filter { isRemote(it) && name in it.possibleValueSourceDependsOn.orEmpty() }) { + if (processValues[dependent.name] != null) clearSelection(dependent.name) + loadRemoteOptions(dependent) + } + } + + /******************************************************************************* + ** Clear a selection that no longer fits, then apply the same rule to fields depending on it. + *******************************************************************************/ + private fun clearSelection(name: String) { + processValues[name] = null + fieldResets[name] = (fieldResets[name] ?: 0) + 1 + fieldChanged(name) + } + private val clientWork = mutableStateMapOf<String, String>() val clientWorkBlockReason: String? get() = clientWork.values.firstOrNull() @@ -248,6 +335,12 @@ open class ProcessViewModel : ViewModel() { *******************************************************************************/ fun reset() { viewModelScope.coroutineContext.cancelChildren() + remoteOptionJobs.clear() + remoteOptionGenerations.clear() + fieldResets.clear() + remoteOptions.clear() + remoteOptionErrors.clear() + widgetState.clear() initializing = false unconfirmedSubmission = false clientWork.clear() diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcess.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcess.kt index d53115d..2944886 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcess.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcess.kt @@ -58,8 +58,8 @@ internal object SemanticProcess { QComponentType.HELP_TEXT, QComponentType.HTML, QComponentType.PROCESS_SUMMARY_RESULTS, QComponentType.VIEW_FORM -> Unit QComponentType.EDIT_FORM -> { val unsupportedField = fields(step, component).firstOrNull { - it.type.name in setOf("BLOB", "UNKNOWN") || - (it.possibleValueSourceName != null && it.inlinePossibleValueSource == null) + // Named possible-value sources are resolved through the semantic possibleValues operation. + it.type.name in setOf("BLOB", "UNKNOWN") } if (unsupportedField != null) return "${unsupportedField.label} requires an input control unavailable in this client." } diff --git a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessSubmissionTest.kt b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessSubmissionTest.kt index b90c815..261a1f3 100644 --- a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessSubmissionTest.kt +++ b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessSubmissionTest.kt @@ -418,6 +418,37 @@ class ProcessSubmissionTest { } } + /******************************************************************************* + ** The optional fidelity keys are ordinary undeclared event values; a lost response must resend all of them. + *******************************************************************************/ + @Test + fun failedScanSubmissionResendsTheSameEntireEventMapIncludingFidelityValues() = runTest() + { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val repository = SubmissionRepository().apply { stepFailures = 1 } + val model = model(repository) + model.doInit() + advanceUntilIdle() + val event = mapOf( + "barcode" to "000123", "symbology" to "LABEL-TYPE-QRCODE", "scanSource" to "zebra-emdk", + "eventId" to "scan-1", "capturedAt" to "2026-01-01T00:00:00Z", + "aimIdentifier" to "]Q1", "rawDataBase64" to "AAECAw==" + ) + model.runStep(event, ProcessAcknowledgment("acknowledgedEventId", "scan-1")) + advanceUntilIdle() + assertTrue(model.unconfirmedSubmission) + assertEquals(event, model.pendingStepValues) + repository.responseValues = mapOf("acknowledgedEventId" to "scan-1") + model.retryPendingStep() + advanceUntilIdle() + assertEquals(listOf(event, event), repository.submissions) + assertNull(model.pendingStepValues) + } finally { + Dispatchers.resetMain() + } + } + /******************************************************************************* ** *******************************************************************************/ @@ -525,6 +556,217 @@ class ProcessSubmissionTest { } + /******************************************************************************* + ** Echo-style setup: named possible-value sources are resolved through the repository, and dependent + ** choices are requested with the current parent value rather than being rendered as free text. + *******************************************************************************/ + @Test + fun namedPossibleValueSourcesLoadWithDependentValuesAndReportFailures() = runTest() + { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + // As published by the server: media declares its explicit dependency on printerId (default DISABLE_UNTIL_SET). + val printer = QFieldMetaData("printerId", "Printer", type = QFieldType.STRING, isRequired = true, possibleValueSourceName = "printers") + val media = QFieldMetaData( + "mediaId", "Paper or label size", type = QFieldType.STRING, isRequired = true, possibleValueSourceName = "media", + possibleValueSourceDependsOn = listOf("printerId"), possibleValueSourceDependencyBehavior = "DISABLE_UNTIL_SET" + ) + val repository = SubmissionRepository().apply { + inputOverride = QFrontendStepMetaData("input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), formFields = listOf(printer, media)) + } + val model = model(repository) + model.doInit() + advanceUntilIdle() + assertNull("Remote choices are supported, not an unsupported control", model.unsupportedReason()) + + model.loadRemoteOptions(printer) + model.loadRemoteOptions(media) + advanceUntilIdle() + assertEquals(listOf("Simulated printer"), model.remoteOptions["printerId"]?.map { it.label }) + assertEquals("Choose Printer first.", model.remoteOptionsBlockedReason(media)) + assertEquals("A blocked dependent field is not searched", listOf("printerId"), repository.possibleValueRequests.map { it.first }) + + model.processValues["printerId"] = "printer" + model.fieldChanged("printerId") + advanceUntilIdle() + assertNull(model.remoteOptionsBlockedReason(media)) + assertEquals(listOf("4 x 6 in"), model.remoteOptions["mediaId"]?.map { it.label }) + assertEquals("printer", repository.possibleValueRequests.last().second["printerId"]) + assertFalse("The requested field's own value is not sent as a dependency", repository.possibleValueRequests.last().second.containsKey("mediaId")) + + // Changing the parent drops the dependent selection immediately, and a late reply for the old parent is ignored. + model.processValues["mediaId"] = "4x6" + val gate = CompletableDeferred<Unit>() + repository.possibleValueGate = gate + model.loadRemoteOptions(media) + runCurrent() + model.processValues["printerId"] = "other" + model.fieldChanged("printerId") + assertNull("Stale child selection is cleared when its parent changes", model.processValues["mediaId"]) + assertEquals(1, model.fieldResets["mediaId"]) + assertNull(model.remoteOptions["mediaId"]) + repository.possibleValueGate = null + gate.complete(Unit) + advanceUntilIdle() + assertEquals("Only the newest request may publish", emptyList<String>(), model.remoteOptions["mediaId"]?.map { it.label }) + + // A failed load is reported without blocking Cancel, and can be retried. + repository.possibleValueFailure = true + model.loadRemoteOptions(media) + advanceUntilIdle() + assertNull(model.remoteOptions["mediaId"]) + assertNotNull(model.remoteOptionErrors["mediaId"]) + assertNull(model.clientWorkBlockReason) + repository.possibleValueFailure = false + model.processValues["printerId"] = "printer" + model.loadRemoteOptions(media) + advanceUntilIdle() + assertNull(model.remoteOptionErrors["mediaId"]) + assertEquals(listOf("4 x 6 in"), model.remoteOptions["mediaId"]?.map { it.label }) + } finally { + Dispatchers.resetMain() + } + } + + /******************************************************************************* + ** Setup chain printer -> media -> document, where the document is an INTEGER-keyed choice depending on both parents. + ** Ancestor changes must clear the whole stale suffix, block Next until reselection, keep the integer id typed on + ** submission, and never let a late reply for the old parents restore the old document. + *******************************************************************************/ + @Test + fun integerDocumentChoiceDependsOnPrinterAndMediaAndNeverSurvivesAParentChange() = runTest() + { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + fun choice(id: Int, label: String) = com.kingsrook.qqq.frontend.android.core.model.QPossibleValue(id, label) + val printer = QFieldMetaData("printerId", "Printer", type = QFieldType.STRING, isRequired = true, possibleValueSourceName = "printers") + val media = QFieldMetaData( + "mediaId", "Paper or label size", type = QFieldType.STRING, isRequired = true, possibleValueSourceName = "media", + possibleValueSourceDependsOn = listOf("printerId"), possibleValueSourceDependencyBehavior = "DISABLE_UNTIL_SET" + ) + val document = QFieldMetaData( + "templateId", "Document", type = QFieldType.INTEGER, isRequired = true, possibleValueSourceName = "documents", + possibleValueSourceDependsOn = listOf("printerId", "mediaId"), possibleValueSourceDependencyBehavior = "DISABLE_UNTIL_SET" + ) + val repository = SubmissionRepository().apply { + inputOverride = QFrontendStepMetaData("input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), formFields = listOf(printer, media, document)) + possibleValueResolver = { field, values -> + when (field) { + "printerId" -> listOf(choice(1, "P1")) + "mediaId" -> if (values["printerId"] == "p1") listOf(choice(2, "M1")) else if (values["printerId"] == "p2") listOf(choice(3, "M2")) else emptyList() + else -> when (values["printerId"] to values["mediaId"]) { + "p1" to "m1" -> listOf(choice(7, "Seven"), choice(9, "Nine")) + "p1" to "m1b" -> listOf(choice(11, "Eleven")) + "p2" to "m2" -> listOf(choice(13, "Thirteen")) + else -> emptyList() + } + } + } + } + val model = model(repository) + model.doInit() + advanceUntilIdle() + listOf(printer, media, document).forEach { model.loadRemoteOptions(it) } + advanceUntilIdle() + assertEquals("Choose Printer and Paper or label size first.", model.remoteOptionsBlockedReason(document)) + + fun choose(name: String, value: Any?) { model.processValues[name] = value; model.fieldChanged(name); } + choose("printerId", "p1") + advanceUntilIdle() + assertEquals("Choose Paper or label size first.", model.remoteOptionsBlockedReason(document)) + choose("mediaId", "m1") + advanceUntilIdle() + assertNull(model.remoteOptionsBlockedReason(document)) + val requested = repository.possibleValueRequests.last() + assertEquals("documents is searched with both parent values", mapOf("printerId" to "p1", "mediaId" to "m1"), requested.second.filterKeys { it != "templateId" }.filterValues { it != null }) + assertEquals(listOf(7, 9), model.remoteOptions["templateId"]?.map { it.id }) + assertTrue("Choice ids stay true integers", model.remoteOptions["templateId"]!!.all { it.id is Int }) + + // Required: nothing selected means Next must not submit. + model.runStep() + advanceUntilIdle() + assertEquals("Document is required.", model.fieldErrors["templateId"]) + assertTrue(repository.submissions.isEmpty()) + + // Changing media clears the document and Next stays blocked until it is chosen again. + choose("templateId", 7) + choose("mediaId", "m1b") + assertNull("Document chosen for the old media is cleared", model.processValues["templateId"]) + assertEquals(1, model.fieldResets["templateId"]) + advanceUntilIdle() + assertEquals(listOf(11), model.remoteOptions["templateId"]?.map { it.id }) + model.runStep() + advanceUntilIdle() + assertEquals("Document is required.", model.fieldErrors["templateId"]) + assertTrue(repository.submissions.isEmpty()) + + // Changing the printer clears media and the document; the old document id is not an offered choice. + choose("mediaId", "m1") + advanceUntilIdle() + choose("templateId", 9) + val gate = CompletableDeferred<Unit>() + repository.possibleValueGate = gate + model.loadRemoteOptions(document) + runCurrent() + choose("printerId", "p2") + assertNull(model.processValues["mediaId"]) + assertNull(model.processValues["templateId"]) + assertEquals(2, model.fieldResets["templateId"]) + assertEquals("Choose Paper or label size first.", model.remoteOptionsBlockedReason(document)) + // A reply for the old printer/media that was already in flight is discarded, not republished. + repository.possibleValueGate = null + gate.complete(Unit) + advanceUntilIdle() + assertNull("Late old-parent reply cannot restore choices", model.remoteOptions["templateId"]) + assertNull("Late old-parent reply cannot restore the old document", model.processValues["templateId"]) + model.runStep() + advanceUntilIdle() + assertTrue(model.fieldErrors.containsKey("mediaId") && model.fieldErrors.containsKey("templateId")) + assertTrue(repository.submissions.isEmpty()) + + // After reselection the typed integer id is what is submitted. + choose("mediaId", "m2") + advanceUntilIdle() + assertEquals(listOf(13), model.remoteOptions["templateId"]?.map { it.id }) + choose("templateId", model.remoteOptions["templateId"]!!.single().id) + model.runStep() + advanceUntilIdle() + val submitted = repository.submissions.single() + assertEquals("p2", submitted["printerId"]) + assertEquals("m2", submitted["mediaId"]) + assertEquals(13, submitted["templateId"]) + assertTrue("Document is submitted as an integer, not text or a decimal", submitted["templateId"] is Int) + assertEquals("result", model.activeStepName) + } finally { + Dispatchers.resetMain() + } + } + + /******************************************************************************* + ** Last scanner feedback is per run: ordinary step updates keep it, a reset (new run) drops it. + *******************************************************************************/ + @Test + fun widgetStateSurvivesStepUpdatesButIsClearedOnReset() = runTest() + { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val repository = SubmissionRepository() + val model = model(repository) + model.doInit() + advanceUntilIdle() + model.widgetState["scanner.lastScan"] = "000123" + model.processValues["eventId"] = "ordinary-business-id" + model.runStep() + advanceUntilIdle() + assertEquals("result", model.activeStepName) + assertEquals("000123", model.widgetState["scanner.lastScan"]) + model.reset() + assertTrue(model.widgetState.isEmpty()) + } finally { + Dispatchers.resetMain() + } + } + /******************************************************************************* ** *******************************************************************************/ @@ -554,6 +796,10 @@ class ProcessSubmissionTest { var statusFailures = 0 var statusCalls = 0 var inputOverride: QFrontendStepMetaData? = null + val possibleValueRequests = mutableListOf<Pair<String, Map<String, Any?>>>() + var possibleValueFailure = false + var possibleValueGate: CompletableDeferred<Unit>? = null + var possibleValueResolver: ((String, Map<String, Any?>) -> List<com.kingsrook.qqq.frontend.android.core.model.QPossibleValue>)? = null private val input = QFrontendStepMetaData( "input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), formFields = listOf(QFieldMetaData("eventId", "Event", type = QFieldType.STRING, isRequired = true, isHidden = hiddenEventId)) @@ -587,6 +833,21 @@ class ProcessSubmissionTest { return if (asynchronous) QJobStarted("process", "job") else QJobComplete("process", responseValues, nextStep) } + /******************************************************************************* + ** Printer choices are independent; media choices depend on the supplied printer. + *******************************************************************************/ + override suspend fun processPossibleValues(processName: String, fieldName: String, values: Map<String, Any?>, processValues: Map<String, Any?>): List<com.kingsrook.qqq.frontend.android.core.model.QPossibleValue> { + possibleValueRequests.add(fieldName to values.toMap()) + possibleValueGate?.await() + if (possibleValueFailure) throw java.io.IOException("Choices unavailable") + possibleValueResolver?.let { return it(fieldName, values.toMap()) } + return when { + fieldName == "printerId" -> listOf(com.kingsrook.qqq.frontend.android.core.model.QPossibleValue("printer", "Simulated printer")) + values["printerId"] == "printer" -> listOf(com.kingsrook.qqq.frontend.android.core.model.QPossibleValue("4x6", "4 x 6 in")) + else -> emptyList() + } + } + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcessTest.kt b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcessTest.kt index f5a2a5a..b15301e 100644 --- a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcessTest.kt +++ b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcessTest.kt @@ -230,9 +230,10 @@ class SemanticProcessTest { ** *******************************************************************************/ @Test - fun remotePossibleValuesAndUnknownInputTypesAreExplicitlyUnavailable() { - for (unsupportedField in listOf(field.copy(possibleValueSourceName = "remote"), field.copy(type = QFieldType.UNKNOWN))) - assertNotNull(SemanticProcess.unsupported(step.copy(formFields = listOf(unsupportedField)), emptyMap(), null, emptySet())) + fun unknownInputTypesAreExplicitlyUnavailableButNamedPossibleValueSourcesAreRendered() { + assertNotNull(SemanticProcess.unsupported(step.copy(formFields = listOf(field.copy(type = QFieldType.UNKNOWN))), emptyMap(), null, emptySet())) + // Printer/media style fields load their choices through the semantic possibleValues operation. + assertNull(SemanticProcess.unsupported(step.copy(formFields = listOf(field.copy(possibleValueSourceName = "remote"))), emptyMap(), null, emptySet())) } /******************************************************************************* diff --git a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/RealAuthentikAcceptanceTest.kt b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/RealAuthentikAcceptanceTest.kt new file mode 100644 index 0000000..9fd4927 --- /dev/null +++ b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/RealAuthentikAcceptanceTest.kt @@ -0,0 +1,273 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import android.os.Bundle +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import java.net.URI +import java.util.concurrent.TimeUnit +import okhttp3.OkHttpClient +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/******************************************************************************* + ** Opt-in acceptance against the real fixture Authentik and real QQQ (docs/REAL-AUTHENTIK-ACCEPTANCE.md). + ** Skipped unless the instrumentation argument realAuthentik=true is passed, so ordinary runs never reach it. + ** Nothing here injects a callback, ActivityResult, token or discovery document, and nothing clears browser + ** state: the external browser, the exported callback activity and the QQQ code exchange run as in production. + ** The person holding the device types each operator's provider credentials into the browser themselves. + ** Progress is reported only as fixed stage names; no URL, query, token, cookie or credential is emitted. + *******************************************************************************/ +@RunWith(AndroidJUnit4::class) +class RealAuthentikAcceptanceTest { + @get:Rule + val ui = createAndroidComposeRule<ScannerActivity>() + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context get() = instrumentation.targetContext + private val client = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build() + private lateinit var admin: SessionAcceptanceClient + private lateinit var deviceId: String + private var actorField: String = "" + + /******************************************************************************* + ** A signs in through the browser, company sign-out runs, then B must authenticate afresh. + *******************************************************************************/ + @Test + fun browserCallbackThenCompanyLogoutThenFreshSecondOperator() { + assumeTrue(InstrumentationRegistry.getArguments().getString("realAuthentik") == "true") + check(context.packageName == "io.qrun.mobile.scanner.e2e") { "Requires the disposable E2E package" } + val file = File(context.filesDir, "real-auth.json") + val config = try { JSONObject(file.readText()) } catch (exception: Exception) { throw AssertionError("Fixture configuration is invalid") } + finally { file.delete() } + check(config.optString("fixtureType") == "real-authentik-android") + val server = URI(config.getString("qqqOrigin")) + check(server.scheme == "https" && server.host == "localhost" && server.port == 18443 && server.userInfo == null && + server.query == null && server.fragment == null && server.path in setOf("", "/")) + val origin = "https://localhost:18443" + val operators = config.getJSONObject("operators") + val a = operators.getJSONObject("A") + val b = operators.getJSONObject("B") + check(a.getString("display") != b.getString("display")) { "Operators must have distinct display names" } + actorField = config.optString("sessionActorField") + admin = SessionAcceptanceClient(origin, config.getString("adminCookie"), client) + + var lastSession: String? = null + try { + waitFor("QQQ server URL") + ui.onNodeWithText("QQQ server URL").performTextReplacement("$origin/") + click("Connect") + // OAuth2 is advertised: the browser button exists and no local password form is offered. + await("browser-login-offered") { ui.onAllNodesWithTag("oauth2.signIn").fetchSemanticsNodes().isNotEmpty() } + assertTrue("No password fallback may be offered", ui.onAllNodesWithText("Password").fetchSemanticsNodes().isEmpty()) + stage("login-screen-verified") + + // Operator A: real browser, real provider redirect, exported callback, real QQQ exchange. + browserLogin("A") + deviceId = DeviceCredentials(context, "$origin/").deviceId + assertTrue("A new device must wait for administrator approval", pendingShown()) + stage("device-pending-A") + approveAndOpenCatalog() + assertPrincipal(a.getString("display")) + val sessionA = activeSession(a.optString("actor")) + stage("operator-A-verified") + + // Optional company sign-out; no browser state is cleared by this harness. + companySignOut() + await("operator-A-session-ended") { sessionStatus(sessionA) != "ACTIVE" } + stage("operator-A-qqq-session-ended") + click("Log back in") + + // Operator B must authenticate again; a silent reuse of A would show A as the principal. + await("browser-login-offered-B") { ui.onAllNodesWithTag("oauth2.signIn").fetchSemanticsNodes().isNotEmpty() } + browserLogin("B") + if (pendingShown()) approveAndOpenCatalog() + else await("catalog-B", 60) { catalogShown() } + assertPrincipal(b.getString("display")) + lastSession = activeSession(b.optString("actor")) + assertNotEquals("B must hold a different QQQ session than A", sessionA, lastSession) + stage("operator-B-verified") + } finally { + // End only the disposable device session; the supplied admin session belongs to root and is left alone. + lastSession?.let { runCatching { admin.apply("mobileSessionEnd", JSONObject().put("sessionId", it)) } } + } + } + + /******************************************************************************* + ** Start AppAuth from the native button, require a real external browser, then wait for the operator. + *******************************************************************************/ + private fun browserLogin(label: String) { + click("oauth2.signIn", tag = true) + stage("browser-$label-requested") + var browser: String? = null + await("external-browser-foreground-$label", 45) { + browser = foreignPackage() + browser != null || ui.onAllNodesWithTag("oauth2.message").fetchSemanticsNodes().isNotEmpty() + } + assertTrue( + "Browser sign-in never reached an external browser. Check temporary CA trust, reverse mappings and server advertisement.", + browser != null + ) + stage("browser-$label-operator-enter-credentials") + // The provider's callback re-enters through the exported redirect activity; only then is the app foreground again. + await("callback-returned-$label", 600) { + activePackage() == context.packageName && ( + pendingShown() || catalogShown() || ui.onAllNodesWithTag("oauth2.message").fetchSemanticsNodes().isNotEmpty()) + } + assertTrue("Native sign-in did not complete after the browser returned", ui.onAllNodesWithTag("oauth2.message").fetchSemanticsNodes().isEmpty()) + stage("callback-exchange-$label-complete") + } + + /******************************************************************************* + ** Approve this device through the real administrator process, then re-check approval in the app. + *******************************************************************************/ + private fun approveAndOpenCatalog() { + admin.apply("mobileDeviceApprove", JSONObject().put("deviceId", deviceId)) + click("Check approval") + await("approved-catalog", 60) { catalogShown() } + } + + /******************************************************************************* + ** The principal shown by QQQ comes from the provider claims QQQ validated, not from this harness. + *******************************************************************************/ + private fun assertPrincipal(display: String) { + click("topBar.accountIcon", tag = true) + await("principal-visible") { ui.onAllNodesWithText("Current User: $display").fetchSemanticsNodes().isNotEmpty() } + click("Close") + } + + /******************************************************************************* + ** Run optional RP-initiated logout through the browser and require explicit completion. + *******************************************************************************/ + private fun companySignOut() { + click("topBar.accountIcon", tag = true) + await("company-signout-offered", 30) { ui.onAllNodesWithText("Sign out of company").fetchSemanticsNodes().isNotEmpty() } + click("Sign out of company") + // The confirmation dialog explains the effect; its button carries the same label as the Account item. + await("company-signout-confirm") { + ui.onAllNodesWithText("Sign out of QQQ and continue in your browser", substring = true).fetchSemanticsNodes().isNotEmpty() && + ui.onAllNodes(hasText("Sign out of company") and hasClickAction() and isEnabled()).fetchSemanticsNodes().size == 1 + } + click("Sign out of company") + stage("company-signout-requested") + await("company-signout-browser", 45) { foreignPackage() != null } + stage("company-signout-operator-confirm-if-asked") + await("company-signout-returned", 300) { + activePackage() == context.packageName && ui.onAllNodesWithTag("oauth2.logoutMessage").fetchSemanticsNodes().isNotEmpty() + } + await("company-signout-result", 30) { logoutMessage().contains("Company sign-out") } + val message = logoutMessage() + assertTrue("Company sign-out must be verified complete", message.contains("Company sign-out completed.") && !message.contains("did not complete")) + stage("company-signout-verified") + } + + /******************************************************************************* + ** The visible company sign-out progress/result text. + *******************************************************************************/ + private fun logoutMessage(): String = ui.onNodeWithTag("oauth2.logoutMessage").fetchSemanticsNode().config + .getOrNull(SemanticsProperties.Text)?.joinToString("") { it.text }.orEmpty() + + /******************************************************************************* + ** The single ACTIVE session for this device, optionally checked for its server-derived actor. + *******************************************************************************/ + private fun activeSession(expectedActor: String): String { + var found: JSONObject? = null + await("active-session", 30) { + found = admin.sessions().singleOrNull { it.optString("deviceId") == deviceId && it.optString("status") == "ACTIVE" } + found != null + } + val row = requireNotNull(found) + if (actorField.isNotBlank() && expectedActor.isNotBlank()) assertEquals("Server actor must match the provider user", expectedActor, row.optString(actorField)) + return row.getString("id") + } + + /******************************************************************************* + ** + *******************************************************************************/ + private fun sessionStatus(id: String) = admin.sessions().single { it.getString("id") == id }.optString("status") + + /******************************************************************************* + ** + *******************************************************************************/ + private fun pendingShown() = ui.onAllNodesWithText("An administrator must approve this device before you can run mobile apps.").fetchSemanticsNodes().isNotEmpty() + + /******************************************************************************* + ** + *******************************************************************************/ + private fun catalogShown() = ui.onAllNodes(SemanticsMatcher("test tag starts with appHome.rowForApp:") { + it.config.getOrNull(SemanticsProperties.TestTag)?.startsWith("appHome.rowForApp:") == true + }).fetchSemanticsNodes().isNotEmpty() + + /******************************************************************************* + ** The active window's package when it is neither this app nor system chrome (that is, the browser). + *******************************************************************************/ + private fun foreignPackage(): String? = activePackage()?.takeIf { it != context.packageName && it != "com.android.systemui" && it != "android" } + + /******************************************************************************* + ** + *******************************************************************************/ + private fun activePackage(): String? { + val root = instrumentation.uiAutomation.rootInActiveWindow ?: return null + return try { + root.packageName?.toString() + } finally { + @Suppress("DEPRECATION") root.recycle() + } + } + + /******************************************************************************* + ** + *******************************************************************************/ + private fun click(label: String, tag: Boolean = false) { + val matcher = (if (tag) hasTestTag(label) else hasText(label, substring = false)) and hasClickAction() + await("enabled-app-control") { ui.onAllNodes(matcher and isEnabled()).fetchSemanticsNodes().isNotEmpty() } + ui.onNode(matcher).assertIsEnabled().performClick() + } + + /******************************************************************************* + ** + *******************************************************************************/ + private fun waitFor(text: String) = await("app-control") { ui.onAllNodesWithText(text, substring = false).fetchSemanticsNodes().isNotEmpty() } + + /******************************************************************************* + ** Fixed stage identifiers only; the launcher maps them to operator instructions. + *******************************************************************************/ + private fun stage(name: String) = instrumentation.sendStatus(0, Bundle().apply { putString("qqqStage", name) }) + + /******************************************************************************* + ** Bounded wait that keeps Compose's clock moving while the app is in the background. + *******************************************************************************/ + private fun await(name: String, seconds: Long = 40, predicate: () -> Boolean) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds) + var previous = System.nanoTime() + do { + val now = System.nanoTime() + ui.mainClock.advanceTimeBy(TimeUnit.NANOSECONDS.toMillis(now - previous).coerceAtLeast(16)) + previous = now + if (predicate()) return + Thread.sleep(250) + } while (System.nanoTime() < deadline) + throw AssertionError("Real-provider stage did not complete within its bound: $name") + } +} diff --git a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/ScannerEndToEndTest.kt b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/ScannerEndToEndTest.kt index 9cff631..dd6ca47 100644 --- a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/ScannerEndToEndTest.kt +++ b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/ScannerEndToEndTest.kt @@ -13,14 +13,28 @@ import android.provider.MediaStore import android.util.Base64 import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHasNoClickAction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.hasSetTextAction +import androidx.core.view.WindowCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.isPopup import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot @@ -36,6 +50,7 @@ import androidx.core.content.FileProvider import java.io.File import java.io.ByteArrayOutputStream import java.util.UUID +import java.net.URI import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.TimeUnit import okhttp3.MediaType.Companion.toMediaType @@ -60,6 +75,241 @@ class ScannerEndToEndTest { private lateinit var origin: String private lateinit var adminCookie: String + /******************************************************************************* + ** Exercise real Echo process/PVS persistence with explicitly simulated print acceptance. + ** No printer, production scanner ingress, screenshots or private identifiers become evidence. + *******************************************************************************/ + @Test + fun echoSelectsOnceQueuesTwoScansAndClosesOnAppsLeave() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + check(context.packageName == "io.qrun.mobile.scanner.e2e") { "Echo requires the disposable E2E package" } + val configFile = File(context.filesDir, "e2e.json") + val config = try { JSONObject(configFile.readText()) } + catch(exception: Exception) { throw AssertionError("Echo fixture configuration is invalid") } + finally { configFile.delete() } + check(config.optString("fixtureType") == "echo-ui-e2e" && config.optBoolean("simulatedPrinting")) + origin = config.getString("serverUrl").trimEnd('/') + val endpoint = URI(origin) + check(endpoint.scheme == "http" && endpoint.host == "127.0.0.1" && endpoint.port in 1..65535 && + endpoint.port !in setOf(57832, 51631, 18081, 18086) && endpoint.path in setOf("", "/") && + endpoint.userInfo == null && endpoint.query == null && endpoint.fragment == null) + + waitFor("QQQ server URL") + ui.onNodeWithText("QQQ server URL").performTextReplacement("$origin/") + ui.onNodeWithText("Connect", substring = false).performClick() + waitFor("Username") + ui.onNodeWithText("Username").performTextReplacement("scanner") + ui.onNodeWithText("Password").performTextReplacement(config.getString("scannerPassword")) + ui.onNodeWithText("Password").performImeAction() + ui.onNode(hasText("Sign in") and hasClickAction()).performScrollTo().performClick() + waitFor("An administrator must approve this device before you can run mobile apps.") + val device = DeviceCredentials(context, "$origin/") + adminCookie = loginAdmin(config.getString("adminPassword")) + runAdmin("mobileDeviceApprove", device.deviceId) + ui.onNodeWithText("Check approval").performScrollTo().performClick() + waitForTag("appHome.rowForApp:echo") + val scannerCookie = loginAdmin(config.getString("scannerPassword"), "scanner") + + /******************************************************************************* + ** Verify persisted rows through ordinary QQQ reads under the operator's real actor/device locks. + *******************************************************************************/ + fun rows(table: String): List<JSONObject> { + check(table in setOf("echoScan", "echoFixtureRun")) + val request = Request.Builder().url("$origin/qqq/v1/table/$table/query").header("Cookie", scannerCookie) + .post("{}".toRequestBody("application/json".toMediaType())) + device.headers().forEach { (name, value) -> request.header(name, value) } + val records = executeJson(request.build()).getJSONArray("records") + return (0 until records.length()).map { records.getJSONObject(it).getJSONObject("values") } + } + + /******************************************************************************* + ** Discover choices through the same versioned `searchProcessPossibleValues` POST the renderer uses + ** ({values, processValues, useCase:form}), so no document id or label is assumed by this harness. + *******************************************************************************/ + fun choices(field: String, values: JSONObject): List<Pair<Any, String>> { + val request = Request.Builder().url("$origin/qqq/v1/processes/echoPrint/possibleValues/$field").header("Cookie", scannerCookie) + .post(JSONObject().put("values", values).put("processValues", JSONObject()).put("useCase", "form").toString().toRequestBody("application/json".toMediaType())) + device.headers().forEach { (name, value) -> request.header(name, value) } + val options = executeJson(request.build()).optJSONArray("options") ?: return emptyList() + return (0 until options.length()).map { options.getJSONObject(it) }.map { it.get("id") to it.getString("label") } + } + val printerChoice = choices("printerId", JSONObject().put("mediaId", JSONObject.NULL).put("templateId", JSONObject.NULL)).single { it.second == "Simulated printer" } + val mediaChoice = choices("mediaId", JSONObject().put("printerId", printerChoice.first).put("templateId", JSONObject.NULL)).single { it.second == "Simulated 4 x 6 in — 300 dpi" } + val documents = choices("templateId", JSONObject().put("printerId", printerChoice.first).put("mediaId", mediaChoice.first)) + assertTrue("Document choices must be offered for the selected printer/media with unique labels and true JSON integer ids", + documents.isNotEmpty() && documents.map { it.second }.toSet().size == documents.size && documents.all { it.first is Int || it.first is Long }) + // Choose a different document after the stale-selection check when more than one is offered, so restoring the old one is detectable. + val firstDocument = documents.first() + val finalDocument = documents.last() + // Persisted setup column holding the chosen document id; confirm against the server fixture schema. + val documentColumn = "templateId" + // Persisted setup column and process field for the optional Copies per scan integer. The Echo owner must expose + // both; the Printing service's own limit is 1..100, and this test does not hard-code any other bound. + val copiesColumn = "copies" + val selectedCopies = 3 + fun assertCopiesPinned(row: JSONObject) { + val pinnedCopies = row.opt(copiesColumn) + assertTrue("Setup must pin Copies per scan as the true JSON integer $selectedCopies the operator chose (found: $pinnedCopies); " + + "the Echo fixture run table must expose an integer '$copiesColumn' column", + pinnedCopies is Number && pinnedCopies.toLong() == selectedCopies.toLong()) + } + fun assertSetupPinned(row: JSONObject) { + assertTrue("Setup must pin the selected printer and media once", row.getString("state") == "OPEN" && + row.getString("printerRecordId") == "fixture-printer" && row.getString("mediaId") == "fixture-4x6") + val persisted = row.opt(documentColumn) + assertTrue("Setup must persist the chosen document as the same integer id the choices returned", + persisted is Number && persisted.toLong() == (finalDocument.first as Number).toLong()) + assertCopiesPinned(row) + } + + /******************************************************************************* + ** The metadata-driven integer control: a text field labelled by the server's field label, found by its set-text action. + *******************************************************************************/ + val copiesField = hasText("Copies per scan") and hasSetTextAction() + fun copiesValue(): String? = ui.onNode(copiesField).performScrollTo().fetchSemanticsNode().config.getOrNull(SemanticsProperties.EditableText)?.text + fun enterCopies(value: String) { + ui.onNode(copiesField).performScrollTo().performTextReplacement(value) + // Dismiss the keyboard as a person would, so later read-only scan-display assertions measure the app, not this input. + ui.activityRule.scenario.onActivity { WindowCompat.getInsetsController(it.window, it.window.decorView).hide(WindowInsetsCompat.Type.ime()) } + ui.waitForIdle() + } + + /******************************************************************************* + ** The only enabled, unselected choice control (earlier fields already hold labels). + *******************************************************************************/ + fun openOnlyRemainingChoice() { + ui.waitUntil(timeoutMillis = 60_000) { + ui.onAllNodesWithText("Choose a value").fetchSemanticsNodes().size == 1 && + ui.onAllNodes(hasText("Choose a value") and isEnabled()).fetchSemanticsNodes().size == 1 + } + ui.onNodeWithText("Choose a value").performScrollTo().performClick() + } + + val priorRuns = rows("echoFixtureRun").map { it.getString("id") }.toSet() + ui.onNodeWithTag("appHome.rowForApp:echo").performClick() + waitForTag("appHome.rowForProcess:echoPrint") + ui.onNodeWithTag("appHome.rowForProcess:echoPrint").performClick() + waitFor("Printer") + waitFor("Paper or label size") + waitFor("Document") + waitFor("Copies per scan") + assertEquals("Copies per scan must default to 1 from the server's field metadata", "1", copiesValue()) + stage("echo-copies-defaulted") + ui.onAllNodesWithText("Choose a value").onFirst().performScrollTo().performClick() + waitFor("Simulated printer") + ui.onNodeWithText("Simulated printer").performClick() + // Media is a contingent field: it stays disabled until the server returns choices for the selected printer. + ui.waitUntil(timeoutMillis = 60_000) { ui.onAllNodes(hasText("Choose a value") and isEnabled()).fetchSemanticsNodes().isNotEmpty() } + ui.onAllNodesWithText("Choose a value").onFirst().performScrollTo().performClick() + waitFor("Simulated 4 x 6 in — 300 dpi") + ui.onNodeWithText("Simulated 4 x 6 in — 300 dpi").performClick() + // Document depends on both printer and media; it is the only unselected control left once media holds its label. + openOnlyRemainingChoice() + waitFor(firstDocument.second) + ui.onNodeWithText(firstDocument.second).performClick() + waitFor(firstDocument.second) + stage("echo-document-selected") + + // Choosing a controlling field again drops every dependent selection and blocks Next until they are reselected. + ui.onNodeWithText("Simulated printer").performScrollTo().performClick() + ui.onNode(hasText("Simulated printer") and hasAnyAncestor(isPopup())).performClick() + ui.waitUntil(timeoutMillis = 60_000) { ui.onAllNodesWithText("Choose a value").fetchSemanticsNodes().size == 2 } + assertTrue("The stale document must not stay selected", ui.onAllNodesWithText(firstDocument.second).fetchSemanticsNodes().isEmpty()) + waitFor("Choose Paper or label size first.") + ui.onNodeWithText("Next", substring = false).performClick() + waitFor("Paper or label size is required.") + waitFor("Document is required.") + assertTrue("Next must not start setup without a reselected document", ui.onAllNodesWithText("Scanner ready. Press the handheld trigger.").fetchSemanticsNodes().isEmpty() && + rows("echoFixtureRun").none { it.getString("id") !in priorRuns && it.optString("printerRecordId").isNotBlank() }) + ui.onAllNodesWithText("Choose a value").onFirst().performScrollTo().performClick() + waitFor("Simulated 4 x 6 in — 300 dpi") + ui.onNodeWithText("Simulated 4 x 6 in — 300 dpi").performClick() + openOnlyRemainingChoice() + waitFor(finalDocument.second) + ui.onNodeWithText(finalDocument.second).performClick() + waitFor(finalDocument.second) + assertTrue("Only the reselected document may remain chosen", firstDocument == finalDocument || ui.onAllNodesWithText(firstDocument.second).fetchSemanticsNodes().isEmpty()) + stage("echo-stale-document-cleared") + assertEquals("Reselecting a controlling choice must not disturb the independent Copies per scan value", "1", copiesValue()) + // The field's advertised bounds (Printing's 1..100) are enforced by the renderer before any run is created. + for ((invalid, message) in listOf("0" to "Copies per scan is below the allowed minimum.", "101" to "Copies per scan exceeds the allowed maximum.")) { + enterCopies(invalid) + ui.onNodeWithText("Next", substring = false).performClick() + waitFor(message) + assertTrue("Invalid Copies per scan must not start setup", ui.onAllNodesWithText("Scanner ready. Press the handheld trigger.").fetchSemanticsNodes().isEmpty() && + rows("echoFixtureRun").none { it.getString("id") !in priorRuns && it.optString("printerRecordId").isNotBlank() }) + } + enterCopies(selectedCopies.toString()) + assertEquals("The chosen quantity must be held by the metadata-driven integer field", selectedCopies.toString(), copiesValue()) + stage("echo-copies-selected") + ui.onNodeWithText("Next", substring = false).performClick() + waitFor("Scanner ready. Press the handheld trigger.") + assertTrue("Echo must not offer manual barcode input", ui.onAllNodesWithText("Manual barcode").fetchSemanticsNodes().isEmpty()) + assertTrue("Echo must not offer manual submission", ui.onAllNodesWithText("Submit barcode").fetchSemanticsNodes().isEmpty()) + // The scan display is read-only and present although Echo disables manual entry; nothing opens the keyboard. + fun assertScanDisplay(barcode: String?, symbology: String?, status: String) { + ui.onNodeWithTag("scanner-barcode").performScrollTo().assertIsDisplayed() + .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.SetText)) + if (barcode != null) ui.onNodeWithTag("scanner-barcode").assert(hasText(barcode)) + ui.onNodeWithTag("scanner-symbology").assertTextEquals("Symbology: ${symbology ?: "—"}") + ui.onNodeWithTag("scanner-status").assertTextEquals(status) + assertTrue("Read-only scan display must not open the keyboard", + ViewCompat.getRootWindowInsets(ui.activity.window.decorView)?.isVisible(WindowInsetsCompat.Type.ime()) != true) + } + assertScanDisplay(null, null, "Waiting for scan") + val run = rows("echoFixtureRun").single { it.getString("id") !in priorRuns } + val runId = run.getString("id") + assertSetupPinned(run) + stage("echo-setup-selected") + + var receipt = "" + for ((index, sample) in listOf("001234567890" to "LABEL-TYPE-CODE128", "000-ECHO-QR" to "QRCODE").withIndex()) { + waitFor("Scanner ready. Press the handheld trigger.") + context.sendBroadcast(Intent("io.qrun.mobile.scanner.SCAN").setPackage(context.packageName) + .addCategory(Intent.CATEGORY_DEFAULT).putExtra("com.symbol.datawedge.data_string", sample.first) + .putExtra("com.symbol.datawedge.label_type", sample.second)) + var accepted: JSONObject? = null + waitForServer { + accepted = rows("echoScan").singleOrNull { it.optString("runId") == runId && it.optString("barcode") == sample.first && it.optString("state") == "QUEUED" } + accepted != null + } + val row = requireNotNull(accepted) + receipt = row.getString("mobileScanId") + waitFor("Print queued $receipt", substring = true) + assertScanDisplay(sample.first, sample.second, "Confirmed by server") + assertTrue("Accepted Echo scans must preserve exact type and approved-device attribution", + row.getString("symbology") == sample.second && row.getString("rendererSymbology") == (if (index == 0) "code128" else "qr") && + row.getString("scanSource") == "e2e-fixture" && row.getString("deviceId") == device.deviceId && row.getString("actorId") == "scanner") + assertTrue("Only a synthetic accepted job may acknowledge this fixture scan", + row.getString("id") == receipt && row.getString("printJobId") == "fixture-job-$receipt" && row.getString("eventId").isNotBlank()) + assertTrue("Each event must create exactly one accepted Echo scan", rows("echoScan").count { it.optString("runId") == runId } == index + 1) + assertSetupPinned(rows("echoFixtureRun").single { it.getString("id") == runId }) + stage(if (index == 0) "echo-first-queued" else "echo-second-queued") + } + + ui.activityRule.scenario.onActivity { OrientationPolicy.LANDSCAPE.select(it) } + waitFor("Print queued $receipt", substring = true) + ui.activityRule.scenario.recreate() + waitFor("Print queued $receipt", substring = true) + assertScanDisplay("000-ECHO-QR", "QRCODE", "Confirmed by server") + assertTrue("Recreation must retain scan phase without showing printer selection", ui.onAllNodesWithText("Choose a value").fetchSemanticsNodes().isEmpty()) + val acceptedRows = rows("echoScan").filter { it.optString("runId") == runId } + assertTrue("Rotation/recreation must not accept another event", acceptedRows.size == 2 && acceptedRows.map { it.getString("eventId") }.toSet().size == 2 && + acceptedRows.map { it.getString("printJobId") }.toSet().size == 2) + assertSetupPinned(rows("echoFixtureRun").single { it.getString("id") == runId }) + stage("echo-copies-persisted") + stage("echo-recreation-stable") + ui.onNodeWithTag("topBar.menuIcon").performClick() + ui.onNodeWithTag("drawer.home").performClick() + waitFor("Leave process") + ui.onNodeWithText("Leave process", substring = false).performClick() + waitForTag("appHome.rowForApp:echo") + waitForServer { rows("echoFixtureRun").single { it.getString("id") == runId }.getString("state") == "CLOSED" } + assertTrue("Leaving must close this run without accepting extra print work", rows("echoScan").count { it.optString("runId") == runId } == 2) + assertCopiesPinned(rows("echoFixtureRun").single { it.getString("id") == runId }) + stage("echo-run-closed") + } + /******************************************************************************* ** Real host/native activity-result boundaries and real upload APIs, using synthetic images only. *******************************************************************************/ @@ -292,9 +542,11 @@ class ScannerEndToEndTest { screenWidget() ui.onAllNodesWithText("Screen being viewed by an administrator").fetchSemanticsNodes().isNotEmpty() } - ui.onNodeWithText("Stop viewing").performClick() - waitFor("Screen viewing paused") - waitForServer { screenWidget().toString().contains("OPERATOR_PAUSED") && findImage(screenWidget()) == null } + // Scanner users have no in-app control to pause or disable administrator viewing. + assertTrue("No in-app Stop viewing control", ui.onAllNodesWithText("Stop viewing").fetchSemanticsNodes().isEmpty()) + assertTrue("No in-app Allow viewing control", ui.onAllNodesWithText("Allow viewing").fetchSemanticsNodes().isEmpty()) + ui.onNodeWithText("Screen being viewed by an administrator").assertHasNoClickAction() + waitForServer { findImage(screenWidget()).orEmpty().startsWith("data:image/jpeg;base64,") } val launch = processCall("mobileSessionEnd", "/init", JSONObject()) val applied = processCall("mobileSessionEnd", "/${launch.getString("processUUID")}/step/input", JSONObject().put("sessionId", sessionId)) if (applied.optString("nextStep") == "result") processCall("mobileSessionEnd", "/${launch.getString("processUUID")}/step/result", JSONObject()) @@ -522,11 +774,65 @@ class ScannerEndToEndTest { assertEquals("scanner", scans.single().getString("userId")) assertEquals("manual", scans.single().getString("scanSource")) assertTrue(scans.single().has("receivedAt")) + + // Scanner-sourced fixture event with exact raw bytes (NUL, high bytes, embedded ]Q1) and an explicit AIM + // identifier, delivered through the disposable e2e driver. Text, type and bytes are fixed and non-sensitive. + val scannedBarcode = "000-NATIVE-RAW-0123" + val scannedSymbology = "LABEL-TYPE-QRCODE" + val rawBytes = byteArrayOf(0x00, 0xFF.toByte(), 0x80.toByte(), ']'.code.toByte(), 'Q'.code.toByte(), '1'.code.toByte(), 'A'.code.toByte(), 0x00, 0xFE.toByte()) + // The same >=1.5 negotiation rule as production, read independently from public discovery of this scenario's server. + val fidelityNegotiated = mobileScannersAtLeast15() + fun scanRows() = jsonRequest("/qqq/v1/table/mobileScan/query", JSONObject()).getJSONArray("records") + val beforeScan = scanRows().length() + waitFor("Scanner ready. Press the handheld trigger.") + context.sendBroadcast(Intent("io.qrun.mobile.scanner.SCAN").setPackage(context.packageName) + .addCategory(Intent.CATEGORY_DEFAULT).putExtra("com.symbol.datawedge.data_string", scannedBarcode) + .putExtra("com.symbol.datawedge.label_type", scannedSymbology) + .putExtra("io.qrun.mobile.scanner.e2e.raw_data", rawBytes) + .putExtra("io.qrun.mobile.scanner.e2e.aim_identifier", "]Q1")) + var scanned: JSONObject? = null + waitForServer { + val rows = scanRows() + scanned = (0 until rows.length()).map { rows.getJSONObject(it).getJSONObject("values") } + .singleOrNull { it.optString("barcode") == scannedBarcode } + scanned != null + } + val persisted = requireNotNull(scanned) + // The acknowledgment names this event's id, so it cannot be the earlier manual acknowledgment. + waitFor("Saved scan ${persisted.getString("id")}", substring = true) + assertEquals("e2e-fixture", persisted.getString("scanSource")) + assertEquals(device.deviceId, persisted.getString("deviceId")) + assertEquals(scannedBarcode, persisted.getString("barcode")) + assertEquals(scannedSymbology, persisted.getString("symbology")) + if (fidelityNegotiated) { + assertTrue("Persisted raw bytes must equal the exact bytes sent, including NUL and high bytes", + rawBytes.contentEquals(Base64.decode(persisted.getString("rawDataBase64"), Base64.NO_WRAP))) + assertEquals("]Q1", persisted.getString("aimIdentifier")) + } else { + assertTrue("A server without scan fidelity must receive and persist legacy fields only", + persisted.isNull("rawDataBase64") && persisted.isNull("aimIdentifier")) + } + assertEquals("One scanner event must create exactly one durable scan", beforeScan + 1, scanRows().length()) + stage(if (fidelityNegotiated) "native-raw-scan-persisted" else "native-legacy-scan-persisted") + + val beforeRevoke = scanRows() runAdmin("mobileDeviceRevoke", device.deviceId) // The active heartbeat revokes the open UI as well as protected process submissions. waitFor("Log back in") - val afterRevoke = jsonRequest("/qqq/v1/table/mobileScan/query", JSONObject()).getJSONArray("records") - assertEquals("Revoked devices cannot save through an already-open process", records.length(), afterRevoke.length()) + val afterRevoke = scanRows() + assertEquals("Revoked devices cannot save through an already-open process", beforeRevoke.length(), afterRevoke.length()) + } + + /******************************************************************************* + ** Public, credential-free discovery: mobile-scanners at its fixed path with version >=1.5.0 and <2. + *******************************************************************************/ + private fun mobileScannersAtLeast15(): Boolean { + val discovery = executeJson(Request.Builder().url("$origin/qqq/v1/version").get().build()) + val extensions = discovery.optJSONArray("extensions") ?: return false + val extension = (0 until extensions.length()).map { extensions.getJSONObject(it) }.singleOrNull { it.optString("name") == "mobile-scanners" } ?: return false + if (extension.optString("pathPrefix") != "/qbit/mobile-scanners") return false + val parts = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)").matchEntire(extension.optString("version"))?.groupValues ?: return false + return parts[1] == "1" && (parts[2].toIntOrNull() ?: return false) >= 5 } /******************************************************************************* diff --git a/scanner-host/src/e2e/AndroidManifest.xml b/scanner-host/src/e2e/AndroidManifest.xml index 4aaa1b8..7f36407 100644 --- a/scanner-host/src/e2e/AndroidManifest.xml +++ b/scanner-host/src/e2e/AndroidManifest.xml @@ -1,6 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> - <!-- Isolated test application; the normal debug/release identities are untouched. --> + <!-- Isolated test application; the normal debug/release identities are untouched. + networkSecurityConfig supersedes usesCleartextTraffic on API 24+; the config itself keeps cleartext for + the 127.0.0.1 fixtures and adds user-CA trust for the host localhost only (see docs/REAL-AUTHENTIK-ACCEPTANCE.md). --> <application android:usesCleartextTraffic="true" android:label="QQQ Mobile E2E" + android:networkSecurityConfig="@xml/network_security_config" tools:replace="android:usesCleartextTraffic,android:label" /> </manifest> diff --git a/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt b/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt index 005a8a8..71922c6 100644 --- a/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt +++ b/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt @@ -8,6 +8,8 @@ import android.content.IntentFilter import androidx.core.content.ContextCompat internal const val HARDWARE_SCAN_SOURCE = "e2e-fixture" +internal const val E2E_RAW_DATA_EXTRA = "io.qrun.mobile.scanner.e2e.raw_data" +internal const val E2E_AIM_IDENTIFIER_EXTRA = "io.qrun.mobile.scanner.e2e.aim_identifier" /******************************************************************************* ** Disposable E2E package only. No debug/release source set contains this ingress. @@ -25,7 +27,10 @@ internal fun createScannerDriver(context: Context): ScannerDriver = object : Sca val data = intent.getStringExtra("com.symbol.datawedge.data_string") ?: return if (intent.getStringExtra("com.symbol.datawedge.decode_mode") == "multiple" || intent.hasExtra("com.symbol.datawedge.barcodes")) return pending = false - listener?.scanned(data, intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN") + // Test-only fixture metadata: exact bytes and an explicit AIM identifier, never derived from the text. + val raw = intent.getByteArrayExtra(E2E_RAW_DATA_EXTRA) + val aim = intent.getStringExtra(E2E_AIM_IDENTIFIER_EXTRA) + listener?.scanned(ScannerCapture.of(data, intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN", raw, aim)) listener?.idle() } } diff --git a/scanner-host/src/e2e/res/xml/network_security_config.xml b/scanner-host/src/e2e/res/xml/network_security_config.xml new file mode 100644 index 0000000..4f971c4 --- /dev/null +++ b/scanner-host/src/e2e/res/xml/network_security_config.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 --> +<!-- E2E package only. Keeps the existing cleartext 127.0.0.1 fixture traffic and system trust everywhere, and lets + ONLY the literal host "localhost" additionally trust a user-installed (temporary, user-approved) test CA. + localhost is HTTPS-only. Debug, qa and release builds never see this file. No certificate is pinned or bypassed. --> +<network-security-config> + <base-config cleartextTrafficPermitted="true"> + <trust-anchors> + <certificates src="system" /> + </trust-anchors> + </base-config> + <domain-config cleartextTrafficPermitted="false"> + <domain includeSubdomains="false">localhost</domain> + <trust-anchors> + <certificates src="system" /> + <certificates src="user" /> + </trust-anchors> + </domain-config> +</network-security-config> diff --git a/scanner-host/src/main/AndroidManifest.xml b/scanner-host/src/main/AndroidManifest.xml index 092c92a..069b3eb 100644 --- a/scanner-host/src/main/AndroidManifest.xml +++ b/scanner-host/src/main/AndroidManifest.xml @@ -16,6 +16,15 @@ android:icon="@drawable/ic_scanner" android:label="QQQ Mobile" android:theme="@android:style/Theme.Material.Light.NoActionBar"> <uses-library android:name="com.symbol.emdk" android:required="false" /> + <meta-data android:name="android.content.APP_RESTRICTIONS" android:resource="@xml/app_restrictions" /> + <!-- Optional live wallpaper; inactive until chosen in Android's wallpaper chooser. Only the system may bind it. --> + <service android:name=".QqqStatusWallpaperService" android:exported="true" android:label="@string/status_wallpaper_label" + android:permission="android.permission.BIND_WALLPAPER"> + <intent-filter> + <action android:name="android.service.wallpaper.WallpaperService" /> + </intent-filter> + <meta-data android:name="android.service.wallpaper" android:resource="@xml/status_wallpaper" /> + </service> <service android:name=".DeviceScreenCaptureService" android:foregroundServiceType="mediaProjection" android:exported="false" /> <provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.photos" android:exported="false" android:grantUriPermissions="true"> diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/AimOutputProfile.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/AimOutputProfile.kt new file mode 100644 index 0000000..b5e4476 --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/AimOutputProfile.kt @@ -0,0 +1,52 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +/******************************************************************************* + ** What a configuration read-back proves after the AIM output profile was requested. + *******************************************************************************/ +internal enum class AimReadback { CONFIRMED, UNCHANGED, INDETERMINATE } + +/******************************************************************************* + ** Classify the value the scanner reports (null when it could not be read) against the value it held before the + ** request and the AIM value requested. AIM wins even when the original was already AIM; only exactly the original + ** counts as unchanged; an unreadable or any other value is indeterminate and must be restored or failed, never + ** trusted. Generic so the rule is independent of the vendor SDK's enum. + *******************************************************************************/ +internal fun <T : Any> classifyAimReadback(original: T, aim: T, effective: T?): AimReadback = when { + effective == null -> AimReadback.INDETERMINATE + effective == aim -> AimReadback.CONFIRMED + effective == original -> AimReadback.UNCHANGED + else -> AimReadback.INDETERMINATE +} + +/******************************************************************************* + ** Strict reading of the application-owned EMDK "AIM code identifier" output profile. Only a driver that + ** applied ScannerConfig.scanParams.codeIdType=AIM and read the same value back may call [split]; without + ** that confirmation the SDK text is user payload and must never be stripped or interpreted. + *******************************************************************************/ +internal object AimOutputProfile { + private val AIM = Regex("\\][A-Za-z][0-9A-Za-z]") + + // AIM symbology character each known EMDK label must report; the modifier character is not checked here. + private val FAMILY = mapOf( + "CODE128" to 'C', "CODE39" to 'A', "CODE93" to 'G', "I2OF5" to 'I', "QRCODE" to 'Q', "DATAMATRIX" to 'd', + "PDF417" to 'L', "AZTEC" to 'z', "EAN13" to 'E', "EAN8" to 'E', "UPCA" to 'E', "UPCE0" to 'E', "UPCE1" to 'E' + ) + + /******************************************************************************* + ** The configured identifier and the user payload that followed it, taken exactly as the SDK delivered them. + *******************************************************************************/ + internal data class Split(val aimIdentifier: String, val payload: String) + + /******************************************************************************* + ** Remove exactly the first three characters when they form an AIM identifier consistent with the label. + ** A missing, malformed or label-inconsistent prefix returns null: the scan is not trusted, never guessed. + ** An empty payload is refused, and a payload that itself begins with an identifier is left intact. + *******************************************************************************/ + fun split(text: String, label: String): Split? { + if (text.length < 4 || !AIM.matches(text.substring(0, 3))) return null + val family = FAMILY[label.removePrefix("LABEL-TYPE-")] + if (family != null && text[1] != family) return null + return Split(text.substring(0, 3), text.substring(3)) + } +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt index 7ddbbe5..6a2fc69 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt @@ -8,6 +8,7 @@ import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT import android.os.Build import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor +import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -33,7 +34,7 @@ class DeviceApi( var displayName: String = "" private set private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) - .addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).build() + .addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).addInterceptor(SameOriginInterceptor()).build() /******************************************************************************* ** Check enrollment on demand; approval remains a server-side decision. diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenCaptureService.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenCaptureService.kt index f73ef93..2200ee3 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenCaptureService.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenCaptureService.kt @@ -6,7 +6,6 @@ import android.app.KeyguardManager import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager -import android.app.PendingIntent import android.app.Service import android.content.BroadcastReceiver import android.content.Context @@ -81,9 +80,6 @@ class DeviceScreenCaptureService : Service() { ** Establish the visible sharing notification before accepting consent; never restart projection automatically. *******************************************************************************/ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent?.action == STOP) { - finishProjection(); return START_NOT_STICKY - } try { val manager = getSystemService(NotificationManager::class.java) manager.createNotificationChannel(NotificationChannel(CHANNEL, "Device screen sharing", NotificationManager.IMPORTANCE_LOW)) @@ -91,13 +87,9 @@ class DeviceScreenCaptureService : Service() { finishProjection() return START_NOT_STICKY } - val stop = PendingIntent.getService( - this, 0, Intent(this, DeviceScreenCaptureService::class.java).setAction(STOP), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) val notification = Notification.Builder(this, CHANNEL).setSmallIcon(R.drawable.ic_scanner) .setContentTitle("QQQ device screen sharing").setContentText("Other apps and notifications may be visible to your administrator.") - .setOngoing(true).addAction(Notification.Action.Builder(null, "Stop sharing", stop).build()).build() + .setOngoing(true).build() if (Build.VERSION.SDK_INT >= 29) startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) else startForeground(NOTIFICATION_ID, notification) foregroundReady = true @@ -340,11 +332,10 @@ class DeviceScreenCaptureService : Service() { } /******************************************************************************* - ** Keep notification and stop-action identifiers private to the nonexported projection service. + ** Keep notification identifiers private to the nonexported projection service. *******************************************************************************/ private companion object { const val CHANNEL = "qqq-device-screen" - const val STOP = "io.qrun.mobile.scanner.STOP_DEVICE_SCREEN" const val NOTIFICATION_ID = 8716 } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenSharing.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenSharing.kt index a427bea..2c4a7a7 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenSharing.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenSharing.kt @@ -187,7 +187,6 @@ internal fun DeviceScreenSharingControl(sharing: DeviceScreenSharing, controller } if (sharing.active) { Text("Sharing this device screen, including other apps and notifications.") - TextButton(onClick = { controller.pauseViewing() }) { Text("Stop device screen sharing") } } else if (controller.supportsDeviceScreen) { val canNotify = remember(permissionRevision) { Build.VERSION.SDK_INT < 33 || @@ -201,7 +200,7 @@ internal fun DeviceScreenSharingControl(sharing: DeviceScreenSharing, controller } if (disclosure) AlertDialog( onDismissRequest = { disclosure = false }, title = { Text("Share this device screen?") }, - text = { Text("An authorized administrator can see other apps and notifications while viewing this session. Android will ask for permission. Sharing stops if the viewer disconnects, you sign out, or this activity restarts. Use Stop sharing in the notification or Android's sharing indicator at any time.") }, + text = { Text("An authorized administrator can see other apps and notifications while viewing this session. Android will ask for permission. Sharing stops if the viewer disconnects, you sign out, or this activity restarts. Android shows its own sharing indicator while this device screen is shared.") }, dismissButton = { TextButton(onClick = { disclosure = false }) { Text("Cancel") } }, confirmButton = { TextButton(onClick = { diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ManagedConfiguration.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ManagedConfiguration.kt new file mode 100644 index 0000000..3dbda38 --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ManagedConfiguration.kt @@ -0,0 +1,90 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import android.content.Context +import android.content.RestrictionsManager +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +/** Android managed-configuration keys; they must match res/xml/app_restrictions.xml and the company profile generator. */ +internal const val MANAGED_SERVER_URL = "qqq_server_url" +internal const val MANAGED_ORIENTATION = "qqq_orientation" + +/******************************************************************************* + ** Validated administrator bootstrap values. Absent or rejected keys are null, which leaves unmanaged behaviour unchanged. + ** Only key names, never values, are recorded for rejected input. + *******************************************************************************/ +internal data class ManagedValues(val serverUrl: String? = null, val orientation: OrientationPolicy? = null, val rejectedKeys: List<String> = emptyList()) + +/******************************************************************************* + ** What a managed server URL means for the current installation. + *******************************************************************************/ +internal enum class ManagedServerAction { NONE, PREFILL, OFFER_CHANGE } + +/******************************************************************************* + ** Pure validation and reconciliation rules for managed configuration. + *******************************************************************************/ +internal object ManagedConfiguration { + private const val MAX_URL = 2048 + + /******************************************************************************* + ** Accept only an HTTPS origin without credentials, path, query or fragment (managed values never allow HTTP, even + ** in debug builds), and only an exact existing orientation name. Anything else is rejected, never defaulted. + *******************************************************************************/ + fun parse(serverUrl: String?, orientation: String?): ManagedValues { + val rejected = mutableListOf<String>() + val url = serverUrl?.takeIf { it.isNotBlank() }?.let { raw -> + try { + require(raw.length <= MAX_URL) { "URL too long" } + normalizeServerUrl(raw, allowHttp = false) + } catch (exception: Exception) { + rejected += MANAGED_SERVER_URL + null + } + } + val policy = orientation?.takeIf { it.isNotBlank() }?.let { raw -> + OrientationPolicy.entries.firstOrNull { it.name == raw } ?: run { rejected += MANAGED_ORIENTATION; null } + } + return ManagedValues(url, policy, rejected) + } + + /******************************************************************************* + ** No saved server: offer the managed URL in the connect form. A different saved server is never switched silently; + ** the person is offered the existing change-server path (confirm, end session, sign out). + *******************************************************************************/ + fun serverAction(savedServer: String?, managedServer: String?): ManagedServerAction = when { + managedServer == null -> ManagedServerAction.NONE + savedServer == null -> ManagedServerAction.PREFILL + savedServer == managedServer -> ManagedServerAction.NONE + else -> ManagedServerAction.OFFER_CHANGE + } + + /******************************************************************************* + ** Managed orientation is a bootstrap default applied only when the administrator's value changes. A later local + ** choice stands until the managed value changes again. Null means nothing to apply. + *******************************************************************************/ + fun orientationToApply(managed: OrientationPolicy?, lastApplied: String?): OrientationPolicy? = + managed?.takeIf { it.name != lastApplied } +} + +/******************************************************************************* + ** Read this application's current restrictions; a missing service or no restrictions yields no managed values. + *******************************************************************************/ +internal fun readManagedValues(context: Context): ManagedValues { + val manager = context.getSystemService(Context.RESTRICTIONS_SERVICE) as? RestrictionsManager ?: return ManagedValues() + val restrictions = manager.applicationRestrictions + return ManagedConfiguration.parse(restrictions.getString(MANAGED_SERVER_URL), restrictions.getString(MANAGED_ORIENTATION)) +} + +/******************************************************************************* + ** Settings notice shown only when the managed server differs from the connected one. The button runs the caller's + ** existing confirmed change-server flow; this composable changes nothing itself. + *******************************************************************************/ +@Composable +internal fun ManagedServerNotice(connectedServer: String, managedServer: String?, onChange: () -> Unit) { + if (ManagedConfiguration.serverAction(connectedServer, managedServer) != ManagedServerAction.OFFER_CHANGE) return + Text("Your administrator configured a different server: $managedServer", style = MaterialTheme.typography.bodySmall) + TextButton(onClick = onChange) { Text("Use administrator's server") } +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionApi.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionApi.kt index 31fda6d..680a53a 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionApi.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionApi.kt @@ -5,6 +5,7 @@ import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT import com.kingsrook.qqq.frontend.android.core.controllers.MOBILE_SCANNERS_PATH import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor +import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException import java.io.IOException @@ -53,7 +54,7 @@ internal class MobileSessionApi( onOutcome: (RequestOutcome) -> Unit = {}, ) : MobileSessionTransport { private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) - .callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).build() + .callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build() /******************************************************************************* ** Send the current eligible session metadata and accept only the server's bounded lease policy. diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/QqqStatusWallpaperService.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/QqqStatusWallpaperService.kt new file mode 100644 index 0000000..57e91b4 --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/QqqStatusWallpaperService.kt @@ -0,0 +1,297 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import android.app.Activity +import android.app.KeyguardManager +import android.app.WallpaperManager +import android.content.ActivityNotFoundException +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.service.wallpaper.WallpaperService +import android.view.SurfaceHolder +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/******************************************************************************* + ** The one in-process publisher. The wallpaper engine runs in this application's main process, so the snapshot + ** is shared by reference and disappears with the process; nothing is written to disk. + *******************************************************************************/ +internal object WallpaperStatus { + val store = WallpaperStatusStore(SystemClock::elapsedRealtime, System::currentTimeMillis) +} + +/******************************************************************************* + ** Optional live wallpaper showing the company name and, on the unlocked home screen only, the approved session + ** display name and current IP. Inactive until a person selects it in Android's wallpaper chooser. + *******************************************************************************/ +class QqqStatusWallpaperService : WallpaperService() { + /******************************************************************************* + ** One engine per wallpaper surface; the framework destroys it when the wallpaper is replaced. + *******************************************************************************/ + override fun onCreateEngine(): Engine = StatusEngine() + + /******************************************************************************* + ** Draws text with a single Canvas pass. Network, keyguard and store observation exist only while visible. + *******************************************************************************/ + private inner class StatusEngine : Engine(), WallpaperStatusListener { + private val main = Handler(Looper.getMainLooper()) + private val connectivity = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + private val keyguard = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val timeFormat = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()) + private var observing = false + private var destroyed = false + private var networkCallback: ConnectivityManager.NetworkCallback? = null + private var currentNetwork: Network? = null + private var networkKnown = false + private var networkAddresses: String? = null + private val redraw = Runnable { draw() } + private val screenReceiver = object : BroadcastReceiver() { + /******************************************************************************* + ** Withdraw identity the moment the screen turns off; redraw once the user unlocks. + *******************************************************************************/ + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_SCREEN_OFF) drawBrandOnly() else draw() + } + } + + /******************************************************************************* + ** Observe only while visible. Becoming invisible first paints brand-only so a stale frame cannot flash on wake. + *******************************************************************************/ + override fun onVisibilityChanged(visible: Boolean) { + if (destroyed) return + if (visible) { + startObserving() + draw() + } else { + drawBrandOnly() + stopObserving() + } + } + + /******************************************************************************* + ** Repaint when the surface size changes. + *******************************************************************************/ + override fun onSurfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + super.onSurfaceChanged(holder, format, width, height) + if (observing) draw() else drawBrandOnly() + } + + /******************************************************************************* + ** Release the network callback, receiver, store listener and pending timers; nothing outlives the engine. + *******************************************************************************/ + override fun onDestroy() { + destroyed = true + stopObserving() + super.onDestroy() + } + + /******************************************************************************* + ** The store may notify from any thread; repaint on the main looper. + *******************************************************************************/ + override fun changed() { + main.post(redraw) + } + + /******************************************************************************* + ** Start network, unlock and store observation once per visible period. + *******************************************************************************/ + private fun startObserving() { + if (observing) return + observing = true + WallpaperStatus.store.addListener(this) + ContextCompat.registerReceiver( + this@QqqStatusWallpaperService, screenReceiver, + IntentFilter().apply { addAction(Intent.ACTION_USER_PRESENT); addAction(Intent.ACTION_SCREEN_OFF) }, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + val callback = object : ConnectivityManager.NetworkCallback() { + /******************************************************************************* + ** Addresses are reported here after the default network becomes available and whenever they change. + *******************************************************************************/ + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { + val text = formatNetworkAddresses(linkProperties.interfaceName, linkProperties.linkAddresses.mapNotNull { it.address.hostAddress }) + main.post { if (observing) { currentNetwork = network; networkKnown = true; networkAddresses = text; draw() } } + } + + /******************************************************************************* + ** Ignore the loss of a network that already stopped being the default. + *******************************************************************************/ + override fun onLost(network: Network) { + main.post { if (observing && network == currentNetwork) { currentNetwork = null; networkKnown = true; networkAddresses = null; draw() } } + } + } + try { + connectivity?.registerDefaultNetworkCallback(callback) + networkCallback = callback + } catch (exception: RuntimeException) { + networkKnown = false + } + } + + /******************************************************************************* + ** Undo everything startObserving did and cancel scheduled work. Safe to call repeatedly. + *******************************************************************************/ + private fun stopObserving() { + main.removeCallbacks(redraw) + if (!observing) return + observing = false + WallpaperStatus.store.removeListener(this) + try { + unregisterReceiver(screenReceiver) + } catch (exception: IllegalArgumentException) { /* Already unregistered. */ + } + networkCallback?.let { + try { + connectivity?.unregisterNetworkCallback(it) + } catch (exception: IllegalArgumentException) { /* Already unregistered. */ + } + } + networkCallback = null + currentNetwork = null + networkKnown = false + networkAddresses = null + } + + /******************************************************************************* + ** Preview and lock-only wallpaper never show identity. A locked keyguard, or no keyguard service, is treated + ** as locked. The wallpaper-flag check is additive: only an engine bound to the lock screen alone is certain. + *******************************************************************************/ + private fun currentContext(): WallpaperContext { + if (isPreview) return WallpaperContext.PREVIEW + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && getWallpaperFlags() == WallpaperManager.FLAG_LOCK) return WallpaperContext.LOCKED + return if (keyguard?.isKeyguardLocked == false) WallpaperContext.HOME else WallpaperContext.LOCKED + } + + /******************************************************************************* + ** Paint the frame for the current context and schedule the single timer that withdraws an aging name. + *******************************************************************************/ + private fun draw() { + if (destroyed) return + main.removeCallbacks(redraw) + val snapshot = WallpaperStatus.store.snapshot + val now = SystemClock.elapsedRealtime() + val context = currentContext() + val frame = WallpaperPresentation.render( + snapshot, context, WallpaperPresentation.networkLine(networkKnown, networkAddresses), now + ) { timeFormat.format(Instant.ofEpochMilli(it)) } + paintFrame(frame) + if (context == WallpaperContext.HOME && observing) WallpaperPresentation.millisUntilStale(snapshot, now)?.let { main.postDelayed(redraw, it + 50) } + } + + /******************************************************************************* + ** Brand-only frame used on invisibility, screen off and teardown. + *******************************************************************************/ + private fun drawBrandOnly() { + if (destroyed) return + main.removeCallbacks(redraw) + val snapshot = WallpaperStatus.store.snapshot + paintFrame(WallpaperPresentation.render(snapshot, WallpaperContext.LOCKED, "", 0L) { "" }) + } + + /******************************************************************************* + ** Draw the header and lines centred in the upper third, shortening any line to fit. Never logs text. + *******************************************************************************/ + private fun paintFrame(frame: WallpaperFrame) { + val holder = surfaceHolder + if (!holder.surface.isValid) return + val canvas: Canvas? = try { + holder.lockCanvas() + } catch (exception: RuntimeException) { + null + } + if (canvas == null) return + try { + draw(canvas, frame) + } finally { + holder.unlockCanvasAndPost(canvas) + } + } + + /******************************************************************************* + ** Canvas layout kept deliberately plain: solid brand colour, bold header, regular status lines. + *******************************************************************************/ + private fun draw(canvas: Canvas, frame: WallpaperFrame) { + val width = canvas.width.toFloat() + canvas.drawColor(Color.parseColor(frame.colorHex)) + paint.color = if (frame.darkText) Color.BLACK else Color.WHITE + paint.textAlign = Paint.Align.CENTER + val maxWidth = width * 0.9f + var y = canvas.height * 0.22f + paint.typeface = Typeface.DEFAULT_BOLD + paint.textSize = width * 0.07f + canvas.drawText(fit(frame.header, maxWidth), width / 2, y, paint) + paint.typeface = Typeface.DEFAULT + paint.textSize = width * 0.045f + frame.lines.forEach { line -> + y += paint.textSize * 1.6f + canvas.drawText(fit(line, maxWidth), width / 2, y, paint) + } + } + + /******************************************************************************* + ** Truncate with an ellipsis so long names cannot run off the display. + *******************************************************************************/ + private fun fit(text: String, maxWidth: Float): String { + val count = paint.breakText(text, true, maxWidth, null) + return if (count >= text.length) text else text.take((count - 1).coerceAtLeast(0)) + "…" + } + } +} + +/******************************************************************************* + ** The standard Android live-wallpaper chooser, preselected to this wallpaper. Activation still needs the person's + ** confirmation there; nothing is applied silently. + *******************************************************************************/ +internal fun wallpaperChooserIntent(context: Context): Intent = Intent(WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER) + .putExtra(WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT, ComponentName(context, QqqStatusWallpaperService::class.java)) + +/******************************************************************************* + ** Open the chooser; false when the device offers none or refuses the request. + *******************************************************************************/ +internal fun openWallpaperChooser(activity: Activity): Boolean = try { + activity.startActivity(wallpaperChooserIntent(activity)) + true +} catch (exception: ActivityNotFoundException) { + false +} catch (exception: SecurityException) { + false +} + +/******************************************************************************* + ** Settings entry that opens the wallpaper chooser. It states exactly what the wallpaper will and will not show. + *******************************************************************************/ +@Composable +internal fun WallpaperSetting(activity: Activity) { + var unavailable by remember { mutableStateOf(false) } + TextButton(onClick = { unavailable = !openWallpaperChooser(activity) }) { Text("Status wallpaper…") } + Text( + "Optional live wallpaper: company name only on the lock screen; your name and this device's IP on the unlocked home screen. You confirm it in Android.", + style = MaterialTheme.typography.bodySmall + ) + if (unavailable) Text("This device has no live wallpaper chooser.") +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordFilesApi.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordFilesApi.kt index 2679f85..5eb1f04 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordFilesApi.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordFilesApi.kt @@ -4,6 +4,7 @@ package io.qrun.mobile.scanner import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor +import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException import java.io.File @@ -156,7 +157,7 @@ internal class RecordFilesApi( private val compatibility: UiApiCompatibility, onOutcome: (RequestOutcome) -> Unit = {} ) : RecordFilesTransport { private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) - .callTimeout(30, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).build() + .callTimeout(30, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build() @Volatile private var captureContractVerified = false diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanEvent.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanEvent.kt index e895acc..96d65d4 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanEvent.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanEvent.kt @@ -7,18 +7,36 @@ import java.util.UUID /******************************************************************************* ** One physical scan remains one event even when its HTTP request is retried. *******************************************************************************/ -data class ScanEvent( - val barcode: String, - val symbology: String, +internal data class ScanEvent( + val capture: ScannerCapture, val scanSource: String, val eventId: String = UUID.randomUUID().toString(), val capturedAt: String = Instant.now().toString() ) { + constructor(barcode: String, symbology: String, scanSource: String) : + this(ScannerCapture.of(barcode, symbology), scanSource) + + val barcode: String get() = capture.barcode + val symbology: String get() = capture.symbology + /******************************************************************************* - ** A fixed schema prevents arbitrary intent extras becoming process inputs. + ** A fixed schema prevents arbitrary intent extras becoming process inputs. The optional fidelity + ** keys are added only for servers verified to accept them; absent values are omitted, never blank. *******************************************************************************/ - fun values(): Map<String, Any> = mapOf( - "barcode" to barcode, "symbology" to symbology, "scanSource" to scanSource, - "eventId" to eventId, "capturedAt" to capturedAt - ) + fun values(includeFidelity: Boolean = false): Map<String, Any> { + val values = linkedMapOf<String, Any>( + "barcode" to barcode, "symbology" to symbology, "scanSource" to scanSource, + "eventId" to eventId, "capturedAt" to capturedAt + ) + if (includeFidelity) { + capture.aimIdentifier?.let { values["aimIdentifier"] = it } + capture.rawDataBase64?.let { values["rawDataBase64"] = it } + } + return values + } + + /******************************************************************************* + ** Scan content must not appear in logs; the event identity is an opaque correlation value. + *******************************************************************************/ + override fun toString(): String = "ScanEvent(redacted, source=$scanSource, eventId=$eventId)" } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanFidelityCapability.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanFidelityCapability.kt new file mode 100644 index 0000000..c7ebedd --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanFidelityCapability.kt @@ -0,0 +1,28 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import com.kingsrook.qqq.frontend.android.core.controllers.UiApiVersion + +/******************************************************************************* + ** The optional aimIdentifier/rawDataBase64 inputs exist from mobile-scanners 1.5.0; older servers + ** must receive exactly the legacy keys. Derived only from already-verified discovery data. + *******************************************************************************/ +internal object ScanFidelityCapability { + private val VERSION = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)") + + /******************************************************************************* + ** Require the single registered mobile-scanners extension at its fixed path with version >=1.5.0,<2. + *******************************************************************************/ + fun supports(version: UiApiVersion?): Boolean { + val extension = version?.extensions?.singleOrNull { it.name == "mobile-scanners" } ?: return false + if (extension.pathPrefix != "/qbit/mobile-scanners") return false + val parts = VERSION.matchEntire(extension.version)?.groupValues ?: return false + return parts[1] == "1" && (parts[2].toIntOrNull() ?: return false) >= 5 && parts[3].toIntOrNull() != null + } + + /******************************************************************************* + ** Discovery data from another server never grants capability to this server's widgets. + *******************************************************************************/ + fun available(server: String, discoveredServer: String, version: UiApiVersion?): Boolean = + server == discoveredServer && supports(version) +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt index aa4a412..368890c 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt @@ -1,9 +1,14 @@ /* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ package io.qrun.mobile.scanner +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.os.Build import android.os.Bundle import android.graphics.Rect +import androidx.core.content.ContextCompat import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement @@ -74,6 +79,14 @@ class ScannerActivity : ComponentActivity() { private var focused by mutableStateOf(false) private var captureBounds by mutableStateOf<Rect?>(null) private val windowFocusObserver = android.view.ViewTreeObserver.OnWindowFocusChangeListener { focused = it } + private var managed by mutableStateOf(ManagedValues()) + private var watchingManagedConfiguration = false + private val restrictionsReceiver = object : BroadcastReceiver() { + /******************************************************************************* + ** Reread administrator restrictions when the system reports a change while this host is visible. + *******************************************************************************/ + override fun onReceive(context: Context, intent: Intent) = refreshManagedConfiguration() + } /******************************************************************************* ** Mark the host resumed so visible scanner ownership and foreground telemetry can be reevaluated. @@ -82,13 +95,44 @@ class ScannerActivity : ComponentActivity() { super.onResume() resumed = true window.decorView.post { if (resumed) focused = window.decorView.hasWindowFocus() } + startWatchingManagedConfiguration() } /******************************************************************************* ** Withdraw foreground eligibility before Android pauses the activity and scanner input. *******************************************************************************/ override fun onPause() { - resumed = false; super.onPause() + resumed = false; stopWatchingManagedConfiguration(); super.onPause() + } + + /******************************************************************************* + ** Reread managed values (also covers changes missed while paused) and apply a changed orientation default. + *******************************************************************************/ + private fun refreshManagedConfiguration() { + managed = readManagedValues(this) + val preferences = getSharedPreferences("host", MODE_PRIVATE) + val target = ManagedConfiguration.orientationToApply(managed.orientation, preferences.getString("managed_orientation_applied", null)) ?: return + preferences.edit().putString("managed_orientation_applied", target.name).apply() + target.select(this) + } + + /******************************************************************************* + ** Observe restriction changes only while the host is resumed; the system broadcast is not exported to other apps. + *******************************************************************************/ + private fun startWatchingManagedConfiguration() { + refreshManagedConfiguration() + if (watchingManagedConfiguration) return + ContextCompat.registerReceiver(this, restrictionsReceiver, IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED), ContextCompat.RECEIVER_NOT_EXPORTED) + watchingManagedConfiguration = true + } + + /******************************************************************************* + ** Stop observing before the activity leaves the foreground. + *******************************************************************************/ + private fun stopWatchingManagedConfiguration() { + if (!watchingManagedConfiguration) return + unregisterReceiver(restrictionsReceiver) + watchingManagedConfiguration = false } /******************************************************************************* @@ -121,6 +165,7 @@ class ScannerActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) window.decorView.viewTreeObserver.addOnWindowFocusChangeListener(windowFocusObserver) + refreshManagedConfiguration() OrientationPolicy.read(this).apply(this) val preferences = getSharedPreferences("host", MODE_PRIVATE) setContent() @@ -135,7 +180,7 @@ class ScannerActivity : ComponentActivity() { var server by remember { mutableStateOf(preferences.getString("server", null)) } val currentServer = server if (currentServer == null) { - ServerForm { value -> preferences.edit().putString("server", value).apply(); server = value } + ServerForm(managed.serverUrl) { value -> preferences.edit().putString("server", value).apply(); server = value } } else { key(currentServer) { @@ -217,6 +262,16 @@ class ScannerActivity : ComponentActivity() { withFrameNanos { } renderedScreen = currentScreen } + // Display-only wallpaper status: approved name and sign-in state, never the session id. Cleared when this host leaves composition. + val signedIn = approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested + val serverHost = remember(server) { runCatching { URI(server).host }.getOrNull() } + LaunchedEffect(signedIn, resumed, qViewModel.sessionUserFullName, brand) + { + WallpaperStatus.store.setBrand(brand.displayName, brand.primaryColor) + if (signedIn) WallpaperStatus.store.signedIn(qViewModel.sessionUserFullName, serverHost, resumed) + else WallpaperStatus.store.signedOutIfSignedIn() + } + DisposableEffect(server) { onDispose { WallpaperStatus.store.clear() } } val sessionApi = remember(server, diagnostics) { MobileSessionApi(server, credentials::headers, compatibility, diagnostics::sessionRequest) } val deviceSharing = remember(server, qViewModel.sessionUUID) { DeviceScreenSharing(applicationContext) } val sessions = remember(sessionApi, qViewModel.sessionUUID) @@ -259,6 +314,10 @@ class ScannerActivity : ComponentActivity() { LocalBusinessNavigationEnabled provides approved, LocalDeviceSettings provides { OrientationSetting(this@ScannerActivity) + WallpaperSetting(this@ScannerActivity) + ManagedServerNotice(server, managed.serverUrl) { + qViewModel.qNavigator.requestLeave("Change server and sign out?", "Change server", alwaysConfirm = true) { qViewModel.changeServerAction?.invoke() } + } DeviceScreenSharingControl(deviceSharing, sessions) { approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested } @@ -281,7 +340,10 @@ class ScannerActivity : ComponentActivity() { }, LocalCustomWidgetRenderer provides { parameters, modifier, disabled -> if (parameters.widgetBlock.blockType == "SCANNER") { - ScannerWidget(parameters, modifier, disabled) + ScannerWidget( + parameters, modifier, disabled, + fidelityAvailable = { ScanFidelityCapability.available(server, compatibility.server, compatibility.verifiedVersion()) } + ) true } else if (parameters.widgetBlock.blockType == "RECORD_FILES_CAPTURE") { RecordPhotoWidget(parameters, recordFiles, modifier, disabled) @@ -311,12 +373,13 @@ internal fun normalizeServerUrl(input: String, allowHttp: Boolean): String { ** Initial server configuration is editable until the host connects. *******************************************************************************/ @Composable -private fun ServerForm(onConnect: (String) -> Unit) { - var url by remember { mutableStateOf(if (BuildConfig.DEBUG) "http://127.0.0.1:8080/" else "https://") } +private fun ServerForm(managedUrl: String?, onConnect: (String) -> Unit) { + var url by remember(managedUrl) { mutableStateOf(managedUrl ?: if (BuildConfig.DEBUG) "http://127.0.0.1:8080/" else "https://") } var error by remember { mutableStateOf("") } Column(Modifier.imePadding().verticalScroll(rememberScrollState()).padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Text("Connect QQQ Mobile", style = MaterialTheme.typography.headlineMedium) + if (managedUrl != null) Text("Your administrator configured this server. Review it, then connect.") OutlinedTextField(url, { url = it }, label = { Text("QQQ server URL") }, singleLine = true) Button(onClick = { try { diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerCapture.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerCapture.kt new file mode 100644 index 0000000..92991b5 --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerCapture.kt @@ -0,0 +1,46 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import java.util.Base64 + +/******************************************************************************* + ** One immutable decoded read: the SDK's text and symbology, the SDK's raw bytes already encoded once as + ** canonical padded Base64, and an AIM identifier only when a qualified driver supplied it explicitly. + ** Raw bytes are exact SDK output (possibly with SDK framing), not a claim of complete symbol capture. + *******************************************************************************/ +internal data class ScannerCapture( + val barcode: String, val symbology: String, val rawDataBase64: String? = null, val aimIdentifier: String? = null +) { + init { + require(aimIdentifier == null || AIM.matches(aimIdentifier)) { "Invalid AIM identifier." } + } + + /******************************************************************************* + ** Scan content is business data and must not reach logs or crash reports. + *******************************************************************************/ + override fun toString(): String = "ScannerCapture(redacted, raw=${if (rawDataBase64 == null) "absent" else "present"}, aim=${if (aimIdentifier == null) "absent" else "present"})" + + /******************************************************************************* + ** Raw limits live here so every source builds captures under the same transport limits. + *******************************************************************************/ + companion object { + const val MAX_RAW_BYTES = 8192 + private val AIM = Regex("\\][A-Za-z][0-9A-Za-z]") + + /******************************************************************************* + ** Encode SDK bytes once; the caller's array is never retained. Null, empty or oversize raw data is + ** omitted rather than truncated, so an optional field is either exact SDK output or absent. + *******************************************************************************/ + fun of(barcode: String, symbology: String, rawData: ByteArray? = null, aimIdentifier: String? = null): ScannerCapture = + ScannerCapture( + barcode, symbology, + if (rawData == null || rawData.isEmpty() || rawData.size > MAX_RAW_BYTES) null else Base64.getEncoder().encodeToString(rawData), + aimIdentifier + ) + + /******************************************************************************* + ** Manual entry has no scanner evidence, so it carries neither raw bytes nor an AIM identifier. + *******************************************************************************/ + fun manual(barcode: String) = ScannerCapture(barcode, "MANUAL") + } +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerFeedback.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerFeedback.kt new file mode 100644 index 0000000..79440b9 --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerFeedback.kt @@ -0,0 +1,85 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessAcknowledgment + +/******************************************************************************* + ** The most recent submitted read, kept in memory only so it stays visible while awaiting the next scan. + ** Manual entry has no symbology; it is never labelled with a guessed one. + *******************************************************************************/ +internal data class LastScan(val eventId: String, val barcode: String, val symbology: String?) { + /******************************************************************************* + ** Scan content is business data and must not reach logs or crash reports. + *******************************************************************************/ + override fun toString(): String = "LastScan(redacted, eventId=$eventId)" + + /******************************************************************************* + *******************************************************************************/ + companion object { + /******************************************************************************* + ** Manual submissions carry no scanner evidence, so their symbology is absent. + *******************************************************************************/ + fun of(event: ScanEvent) = LastScan(event.eventId, event.barcode, if (event.scanSource == "manual") null else event.symbology) + } +} + +/******************************************************************************* + ** What the read-only scan display shows: the exact value, its symbology, and where the request stands. + *******************************************************************************/ +internal data class ScanFeedback(val barcode: String, val symbology: String, val details: String, val status: String) { + /******************************************************************************* + ** Scan content is business data and must not reach logs or crash reports. + *******************************************************************************/ + override fun toString(): String = "ScanFeedback(redacted, status=$status)" +} + +/******************************************************************************* + ** Derives scan feedback from the process's own pending/acknowledgment state; it adds no state machine. + *******************************************************************************/ +internal object ScannerFeedback { + const val WAITING = "Waiting for scan" + const val STATE_KEY = "scanner.lastScan" + + /******************************************************************************* + ** The one receipt definition: submission and display must agree on what counts as confirmation. + *******************************************************************************/ + fun acknowledgment(eventId: String) = ProcessAcknowledgment( + "acknowledgedEventId", eventId, requiredValues = mapOf("scanSaved" to true), nonEmptyStringFields = setOf("scanId") + ) + const val MANUAL_SYMBOLOGY = "Not applicable (manual entry)" + + /******************************************************************************* + ** A pending request (including a retryable one) is authoritative because retries resend it unchanged; + ** otherwise the last submitted read stays visible, whether confirmed, rejected or cancelled. + *******************************************************************************/ + fun derive( + last: LastScan?, pendingValues: Map<String, Any?>?, responseValues: Map<String, Any?>, waiting: Boolean, cancelling: Boolean + ): ScanFeedback? { + val pendingId = pendingValues?.get("eventId") as? String + val pendingBarcode = pendingValues?.get("barcode") as? String + val shown = if (pendingId != null && pendingBarcode != null) + LastScan( + pendingId, pendingBarcode, + if (pendingValues["scanSource"] == "manual") null else pendingValues["symbology"] as? String + ) + else last + if (shown == null) return null + val status = when { + acknowledgment(shown.eventId).matches(responseValues) -> "Confirmed by server" + cancelling -> "Cancelling. Scan was not confirmed." + pendingId == shown.eventId && waiting -> "Sending scan…" + pendingId == shown.eventId -> "Not confirmed. Retry the same request or leave the process; do not assume it was saved." + else -> "Not confirmed. Follow the process message before submitting again." + } + return ScanFeedback(shown.barcode, shown.symbology ?: MANUAL_SYMBOLOGY, describe(shown.barcode), status) + } + + /******************************************************************************* + ** Control characters are invisible in a text field, so report them without altering the value. + *******************************************************************************/ + fun describe(barcode: String): String { + val controls = barcode.count { Character.isISOControl(it) } + val length = "${barcode.length} ${if (barcode.length == 1) "character" else "characters"}" + return if (controls == 0) length else "$length, $controls control ${if (controls == 1) "character" else "characters"}" + } +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerInputSession.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerInputSession.kt index 18623ba..ff4520d 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerInputSession.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerInputSession.kt @@ -17,7 +17,7 @@ internal interface ScannerDriver { /******************************************************************************* ** Preserve raw barcode data and reject callbacks after ownership, readiness or generation changes. *******************************************************************************/ - fun scanned(barcode: String, symbology: String) + fun scanned(capture: ScannerCapture) /******************************************************************************* ** Distinguish ordinary empty reads from rejected multi-barcode reads without fabricating a scan. @@ -61,7 +61,7 @@ internal interface ScannerDriver { *******************************************************************************/ internal class ScannerInputSession( private val createDriver: () -> ScannerDriver?, - private val onScan: (String, String) -> Unit, + private val onScan: (ScannerCapture) -> Unit, private val onStatus: (String, Boolean) -> Unit, private val canAcceptInput: () -> Boolean = { true }, ) { @@ -122,13 +122,13 @@ internal class ScannerInputSession( /******************************************************************************* ** Preserve raw barcode data and reject callbacks after ownership, readiness or generation changes. *******************************************************************************/ - override fun scanned(barcode: String, symbology: String) { + override fun scanned(capture: ScannerCapture) { if (!isCurrent() || !accepting || !armed || !canAcceptInput()) return armed = false accepting = false readNotice = null onStatus("Waiting for scan confirmation…", false) - onScan(barcode, symbology) + onScan(capture) // IDLE may precede data. Stop any read armed in that interval before accepting more input. try { if (driver === current && current.isReadPending()) current.cancelRead() diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerMetaData.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerMetaData.kt index 237ebbb..1904995 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerMetaData.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerMetaData.kt @@ -4,7 +4,7 @@ package io.qrun.mobile.scanner /******************************************************************************* ** The optional presentation hints published by mobile-scanners 1.x. *******************************************************************************/ -internal data class ScannerMetaData(val manualLabel: String = "Manual barcode", val submitLabel: String = "Submit barcode", val maxLength: Int = 4096) { +internal data class ScannerMetaData(val manualLabel: String = "Manual barcode", val submitLabel: String = "Submit barcode", val maxLength: Int = 4096, val acknowledgmentLabel: String = "Saved scan", val manualEntryEnabled: Boolean = true) { /******************************************************************************* ** Centralize scanner metadata validation before the widget accepts server-provided controls. *******************************************************************************/ @@ -21,10 +21,25 @@ internal data class ScannerMetaData(val manualLabel: String = "Manual barcode", { "SCANNER maxLength must be an integer between 1 and 4096." } number.toInt() } + val acknowledgmentLabel = if (!values.containsKey("acknowledgmentLabel")) "Saved scan" else { + val label = values["acknowledgmentLabel"] + require(label is String && label.length in 1..80 && label.isNotBlank() && label == label.trim() && label.none { + it.isISOControl() || it.category == CharCategory.FORMAT || it.category == CharCategory.LINE_SEPARATOR || + it.category == CharCategory.PARAGRAPH_SEPARATOR || it == '<' || it == '>' + }) { "SCANNER acknowledgmentLabel must be a plain, single-line label between 1 and 80 characters." } + label + } + val manualEntryEnabled = if (!values.containsKey("manualEntryEnabled")) true else { + val enabled = values["manualEntryEnabled"] + require(enabled is Boolean) { "SCANNER manualEntryEnabled must be a boolean." } + enabled + } return ScannerMetaData( manualLabel = (values["manualLabel"] as? String)?.takeIf { it.isNotBlank() } ?: "Manual barcode", submitLabel = (values["submitLabel"] as? String)?.takeIf { it.isNotBlank() } ?: "Submit barcode", maxLength = maxLength, + acknowledgmentLabel = acknowledgmentLabel, + manualEntryEnabled = manualEntryEnabled, ) } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt index e7692ef..0b381dd 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt @@ -4,6 +4,7 @@ package io.qrun.mobile.scanner import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -22,6 +23,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalFocusManager @@ -32,7 +34,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBlockParameters -import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessAcknowledgment import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed /******************************************************************************* @@ -41,7 +42,8 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed @Composable internal fun ScannerWidget( parameters: WidgetBlockParameters, modifier: Modifier, disabled: Boolean, - driverFactory: (Context) -> ScannerDriver? = ::createScannerDriver + driverFactory: (Context) -> ScannerDriver? = ::createScannerDriver, + fidelityAvailable: () -> Boolean = { false } ) { val context = LocalContext.current val view = LocalView.current @@ -82,7 +84,8 @@ internal fun ScannerWidget( /******************************************************************************* ** Each new physical scan has an identity; retries are handled by ProcessViewModel. *******************************************************************************/ - fun submit(barcode: String, symbology: String, source: String): Boolean { + fun submit(capture: ScannerCapture, source: String): Boolean { + val barcode = capture.barcode if (process == null) { notice = "This scanner control requires an active process." return false @@ -96,12 +99,11 @@ internal fun ScannerWidget( return false } notice = "Sending scan…" - val event = ScanEvent(barcode, symbology, source) + val event = ScanEvent(capture, source) + process.widgetState[ScannerFeedback.STATE_KEY] = LastScan.of(event) + // Capability is read from cached verified discovery on the UI thread; it never triggers HTTP. process.runStep( - event.values(), ProcessAcknowledgment( - "acknowledgedEventId", event.eventId, - requiredValues = mapOf("scanSaved" to true), nonEmptyStringFields = setOf("scanId") - ) + event.values(includeFidelity = fidelityAvailable()), ScannerFeedback.acknowledgment(event.eventId) ) // runStep can reject local metadata/validation before accepting an event. // A synchronous test repository can also finish and acknowledge it immediately. @@ -111,12 +113,12 @@ internal fun ScannerWidget( return accepted } - val currentSubmit by rememberUpdatedState<(String, String, String) -> Boolean>(::submit) + val currentSubmit by rememberUpdatedState<(ScannerCapture, String) -> Boolean>(::submit) val input = remember(context, process, driverFactory) { ScannerInputSession( { driverFactory(context) }, - { barcode, symbology -> currentSubmit(barcode, symbology, HARDWARE_SCAN_SOURCE) }, + { capture -> currentSubmit(capture, HARDWARE_SCAN_SOURCE) }, { message, reconnect -> scannerStatus = message; canReconnect = reconnect }, canAcceptInput = { currentInputReady() }) } @@ -140,22 +142,45 @@ internal fun ScannerWidget( { Text(parameters.widgetBlock.values["title"]?.toString() ?: "Scan a barcode") Text(parameters.widgetBlock.values["instructions"]?.toString() ?: "Press the handheld scan trigger. Wait for server confirmation before scanning again.") + val feedback = ScannerFeedback.derive( + process?.widgetState?.get(ScannerFeedback.STATE_KEY) as? LastScan, process?.pendingStepValues, parameters.values, + process?.waitingOnBackend == true, process?.cancelling == true + ) + ScanFeedbackPanel(feedback) if (parameters.values["scanSaved"] == true && notice.isEmpty()) { - Text("Saved scan ${parameters.values["scanId"] ?: ""}") + Text("${scannerMetaData.acknowledgmentLabel} ${parameters.values["scanId"] ?: ""}") } if (notice.isNotEmpty()) Text(notice) if (scannerStatus.isNotEmpty()) Text(scannerStatus) if (canReconnect) Button( enabled = ready, onClick = { input.reconnect() }) { Text("Reconnect scanner") } - OutlinedTextField( - manual, { manual = it }, label = { Text(scannerMetaData.manualLabel) }, enabled = ready, singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); keyboard?.hide() }) - ) - Button(enabled = ready && manual.isNotEmpty(), onClick = { - focusManager.clearFocus(); keyboard?.hide() - if (submit(manual, "MANUAL", "manual")) manual = "" - }) { Text(scannerMetaData.submitLabel) } + if (scannerMetaData.manualEntryEnabled) { + OutlinedTextField( + manual, { manual = it }, label = { Text(scannerMetaData.manualLabel) }, enabled = ready, singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); keyboard?.hide() }) + ) + Button(enabled = ready && manual.isNotEmpty(), onClick = { + focusManager.clearFocus(); keyboard?.hide() + if (submit(ScannerCapture.manual(manual), "manual")) manual = "" + }) { Text(scannerMetaData.submitLabel) } + } } } + +/******************************************************************************* + ** Shown for every SCANNER widget, including those with manual entry disabled. A read-only field never + ** requests focus or opens the keyboard, and the value is displayed exactly as received. + *******************************************************************************/ +@Composable +internal fun ScanFeedbackPanel(feedback: ScanFeedback?) { + OutlinedTextField( + feedback?.barcode ?: "", {}, modifier = Modifier.fillMaxWidth().testTag("scanner-barcode"), + label = { Text("Barcode") }, readOnly = true, singleLine = true, + placeholder = { Text(ScannerFeedback.WAITING) }, + supportingText = feedback?.let { { Text(it.details) } } + ) + Text("Symbology: ${feedback?.symbology ?: "—"}", Modifier.testTag("scanner-symbology")) + Text(feedback?.status ?: ScannerFeedback.WAITING, Modifier.testTag("scanner-status")) +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/SessionHostUi.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/SessionHostUi.kt index 5b643f9..2bd21cb 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/SessionHostUi.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/SessionHostUi.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,17 +43,15 @@ private fun containsPasswordWidget(value: Any?): Boolean = when (value) { } /******************************************************************************* - ** Keep the operator informed of active viewing and expose immediate pause/resume control. + ** Keep the operator informed of active or suspended viewing; this indicator is informational and offers no operator control. *******************************************************************************/ @Composable internal fun ScreenViewingIndicator(controller: MobileSessionController) { if (controller.viewing || controller.operatorPaused) Surface(color = MaterialTheme.colorScheme.secondaryContainer) { - Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { Text(if (controller.operatorPaused) "Screen viewing paused" else "Screen being viewed by an administrator", Modifier.weight(1f)) - TextButton(onClick = { if (controller.operatorPaused) controller.allowViewing() else controller.pauseViewing() }) - { Text(if (controller.operatorPaused) "Allow viewing" else "Stop viewing") } } } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/WallpaperStatus.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/WallpaperStatus.kt new file mode 100644 index 0000000..92830c5 --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/WallpaperStatus.kt @@ -0,0 +1,193 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +/******************************************************************************* + ** What this process currently knows about the operator's session. Never persisted: a new process starts UNKNOWN. + *******************************************************************************/ +internal enum class WallpaperSessionState { UNKNOWN, SIGNED_OUT, SIGNED_IN } + +/******************************************************************************* + ** Immutable display-only snapshot. Holds no credential, token, cookie or session identifier. + *******************************************************************************/ +internal data class WallpaperSnapshot( + val state: WallpaperSessionState = WallpaperSessionState.UNKNOWN, + val displayName: String? = null, + val serverHost: String? = null, + val brandName: String = HostBrand().displayName, + val brandColor: String = HostBrand().primaryColor, + val foreground: Boolean = false, + val confirmedAtElapsed: Long = 0L, + val confirmedAtWall: Long = 0L, +) + +/******************************************************************************* + ** Observer notified after a snapshot changes; it may be called on any thread. + *******************************************************************************/ +internal fun interface WallpaperStatusListener { + /******************************************************************************* + ** Read the new snapshot from the store; the callback carries no data. + *******************************************************************************/ + fun changed() +} + +/******************************************************************************* + ** In-memory publisher of the approved session display name. Identity is dropped on sign-out and clear. + *******************************************************************************/ +internal class WallpaperStatusStore(private val elapsed: () -> Long, private val wall: () -> Long) { + private val lock = Any() + private val listeners = LinkedHashSet<WallpaperStatusListener>() + @Volatile + var snapshot = WallpaperSnapshot() + private set + + /******************************************************************************* + ** Register until removed; the wallpaper engine removes itself when it stops being visible or is destroyed. + *******************************************************************************/ + fun addListener(listener: WallpaperStatusListener) = synchronized(lock) { listeners.add(listener) } + + /******************************************************************************* + ** Stop notifying a listener; safe to call when it was never added. + *******************************************************************************/ + fun removeListener(listener: WallpaperStatusListener) = synchronized(lock) { listeners.remove(listener) } + + /******************************************************************************* + ** Apply the validated public brand of the selected server; invalid values keep the current brand. + *******************************************************************************/ + fun setBrand(name: String, color: String) { + val cleanName = cleanText(name, 80) ?: return + if (!BRAND_COLOR.matches(color)) return + update { it.copy(brandName = cleanName, brandColor = color.uppercase()) } + } + + /******************************************************************************* + ** Record a signed-in, device-approved session. Each call confirms the session as of now; the caller invokes it + ** again with foreground=false as the app leaves the screen, which is the last time the server was reachable. + *******************************************************************************/ + fun signedIn(displayName: String?, serverHost: String?, foreground: Boolean) { + update { + it.copy( + state = WallpaperSessionState.SIGNED_IN, displayName = cleanText(displayName, MAX_NAME), serverHost = cleanText(serverHost, 80), + foreground = foreground, confirmedAtElapsed = elapsed(), confirmedAtWall = wall() + ) + } + } + + /******************************************************************************* + ** Drop identity when a signed-in session ends (logout, unauthorized, revocation); leave other states alone so + ** startup does not claim "signed out" before restoration finishes. + *******************************************************************************/ + fun signedOutIfSignedIn() { + if (snapshot.state == WallpaperSessionState.SIGNED_IN) update { it.signedOutCopy() } + } + + /******************************************************************************* + ** Forget everything session-related (activity destroyed, server change): the state becomes UNKNOWN, not signed out. + *******************************************************************************/ + fun clear() = update { it.copy(state = WallpaperSessionState.UNKNOWN, displayName = null, serverHost = null, foreground = false, confirmedAtElapsed = 0L, confirmedAtWall = 0L) } + + private fun WallpaperSnapshot.signedOutCopy() = + copy(state = WallpaperSessionState.SIGNED_OUT, displayName = null, serverHost = null, foreground = false, confirmedAtElapsed = 0L, confirmedAtWall = 0L) + + /******************************************************************************* + ** Swap the snapshot and notify outside the lock so a listener cannot deadlock the publisher. + *******************************************************************************/ + private fun update(change: (WallpaperSnapshot) -> WallpaperSnapshot) { + val toNotify = synchronized(lock) { + val next = change(snapshot) + if (next == snapshot) return + snapshot = next + listeners.toList() + } + toNotify.forEach { it.changed() } + } + + /******************************************************************************* + ** Single-line presentation text only: no control characters, bounded length, null when empty. + *******************************************************************************/ + companion object { + const val MAX_NAME = 40 + private val BRAND_COLOR = Regex("#[0-9a-fA-F]{6}") + + /******************************************************************************* + ** Normalize display text before it can reach a canvas. + *******************************************************************************/ + fun cleanText(raw: String?, max: Int): String? = raw?.filterNot { it.isISOControl() }?.trim()?.take(max)?.takeIf { it.isNotEmpty() } + } +} + +/******************************************************************************* + ** Where the wallpaper is being drawn. Identity and IP are shown only for HOME. + *******************************************************************************/ +internal enum class WallpaperContext { PREVIEW, LOCKED, HOME } + +/******************************************************************************* + ** Text and colours for one frame. + *******************************************************************************/ +internal data class WallpaperFrame(val header: String, val lines: List<String>, val colorHex: String, val darkText: Boolean) + +/******************************************************************************* + ** Pure rules that decide what the wallpaper may say, so they can be tested without a device. + *******************************************************************************/ +internal object WallpaperPresentation { + /** The heartbeat only runs while the app is foreground; beyond this the name is no longer shown. */ + const val STALE_AFTER_MILLIS = 15 * 60 * 1000L + + /******************************************************************************* + ** A signed-in session whose app has been in the background past the window is unverified, not signed in. + *******************************************************************************/ + fun isStale(snapshot: WallpaperSnapshot, nowElapsed: Long) = + snapshot.state == WallpaperSessionState.SIGNED_IN && !snapshot.foreground && nowElapsed - snapshot.confirmedAtElapsed >= STALE_AFTER_MILLIS + + /******************************************************************************* + ** Milliseconds until a displayed, not-yet-stale name must be withdrawn, or null when no timer is needed. + *******************************************************************************/ + fun millisUntilStale(snapshot: WallpaperSnapshot, nowElapsed: Long): Long? = + if (snapshot.state == WallpaperSessionState.SIGNED_IN && !snapshot.foreground && !isStale(snapshot, nowElapsed)) + STALE_AFTER_MILLIS - (nowElapsed - snapshot.confirmedAtElapsed) else null + + /******************************************************************************* + ** Compose the IP line from what the network callback last reported. + *******************************************************************************/ + fun networkLine(known: Boolean, addresses: String?) = when { + !known -> "IP: unknown" + addresses == null -> "IP: no network address" + else -> "IP: $addresses" + } + + /******************************************************************************* + ** Brand only for PREVIEW and LOCKED. HOME adds session state, the approved display name only while it is fresh, + ** the server host, and the IP. The wording never claims the server just verified a background session. + *******************************************************************************/ + fun render(snapshot: WallpaperSnapshot, context: WallpaperContext, networkLine: String, nowElapsed: Long, formatTime: (Long) -> String): WallpaperFrame { + val dark = useBlackOnPrimary(snapshot.brandColor) + if (context != WallpaperContext.HOME) return WallpaperFrame(snapshot.brandName, emptyList(), snapshot.brandColor, dark) + val lines = mutableListOf<String>() + when (snapshot.state) { + WallpaperSessionState.UNKNOWN -> lines += "Session: not available" + WallpaperSessionState.SIGNED_OUT -> lines += "Signed out" + WallpaperSessionState.SIGNED_IN -> if (isStale(snapshot, nowElapsed)) { + lines += "Session unverified" + lines += "Last confirmed ${formatTime(snapshot.confirmedAtWall)}" + } else { + lines += snapshot.displayName?.let { "Signed in: $it" } ?: "Signed in" + if (!snapshot.foreground) lines += "Last confirmed ${formatTime(snapshot.confirmedAtWall)} (app in background)" + snapshot.serverHost?.let { lines += "Server: $it" } + } + } + lines += networkLine + return WallpaperFrame(snapshot.brandName, lines, snapshot.brandColor, dark) + } +} + +/******************************************************************************* + ** Summarize the default network's link addresses for display: interface label plus up to two usable addresses, + ** IPv4 first. Loopback and IPv6 link-local addresses are omitted; scope suffixes are stripped. Null when none remain. + *******************************************************************************/ +internal fun formatNetworkAddresses(interfaceName: String?, addresses: List<String>): String? { + val usable = addresses.map { it.substringBefore('%') } + .filter { it.isNotBlank() && !it.startsWith("fe80", ignoreCase = true) && it != "::1" && !it.startsWith("127.") } + .distinct() + if (usable.isEmpty()) return null + val label = interfaceName?.filter { it.isLetterOrDigit() || it == '.' || it == '_' || it == '-' }?.take(16)?.takeIf { it.isNotEmpty() } + return (label?.let { "$it " } ?: "") + usable.sortedBy { it.contains(':') }.take(2).joinToString(", ") +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ZebraScannerDriver.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ZebraScannerDriver.kt index 9db3214..b9a6b3f 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ZebraScannerDriver.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ZebraScannerDriver.kt @@ -8,6 +8,7 @@ import com.symbol.emdk.EMDKManager import com.symbol.emdk.EMDKResults import com.symbol.emdk.barcode.BarcodeManager import com.symbol.emdk.barcode.Scanner +import com.symbol.emdk.barcode.ScannerConfig import com.symbol.emdk.barcode.StatusData /******************************************************************************* @@ -29,27 +30,62 @@ internal class ZebraScannerDriver(private val context: Context) : ScannerDriver private var closed = false private var opening = false private var ownsClaim = false + + /******************************************************************************* + ** The scanner's output format as this driver has proven it. Text is accepted only in AIM (prefix present and + ** confirmed by read-back) or PLAIN (unchanged or restored, confirmed by read-back); PENDING and FAILED are + ** indeterminate, so any decode, and any readiness signal, is withheld. + *******************************************************************************/ + private enum class Profile { PENDING, AIM, PLAIN, FAILED } + + // Application-owned, temporary: AIM is set only after applying codeIdType=AIM and reading the same value back. + // The original value is restored (and read back) before the scanner is released. + @Volatile private var profile = Profile.PENDING + private var idleSeenBeforeProfile = false + private var originalCodeIdType: ScannerConfig.CodeIdType? = null + private var codeIdTypeChanged = false private val dataListener = Scanner.DataListener { collection -> + // Copy SDK-owned data now; the collection must not be read after this callback returns. AIM is read only + // under the confirmed profile, by AimOutputProfile; without it the text is user payload and stays untouched. + // This runs on an SDK thread, possibly while the open sequence is still configuring: an unresolved or failed + // format drops the decode instead of guessing. The hardware read completes and the next IDLE re-arms it. + val mode = profile + if (mode != Profile.AIM && mode != Profile.PLAIN) return@DataListener + val scans = collection?.scanData + var capture: ScannerCapture? = null + var multiple = false + if (collection?.result == com.symbol.emdk.barcode.ScannerResults.SUCCESS && !scans.isNullOrEmpty()) { + if (scans.size != 1) multiple = true + else { + val scan = scans.single() + val barcode = scan?.data + val label = scan?.labelType?.toString() ?: "UNKNOWN" + if (!barcode.isNullOrEmpty()) { + if (mode == Profile.PLAIN) capture = ScannerCapture.of(barcode, label, scan.rawData) + else { + // A missing or inconsistent prefix under the confirmed profile drops the read; it is never guessed. + // Raw bytes stay exactly what the SDK returned, whether or not they carry the same prefix. + val split = AimOutputProfile.split(barcode, label) + if (split != null) capture = ScannerCapture.of(split.payload, label, scan.rawData, split.aimIdentifier) + } + } + } + } + val delivered = capture main.post { if (!closed) { - val scans = collection?.scanData - if (collection?.result != com.symbol.emdk.barcode.ScannerResults.SUCCESS || scans.isNullOrEmpty()) - listener?.noData(false) - else if (scans.size != 1) listener?.noData(true) - else { - val scan = scans.single() - val barcode = scan?.data - if (barcode.isNullOrEmpty()) listener?.noData(false) - else listener?.scanned(barcode, scan.labelType?.toString() ?: "UNKNOWN") - } + if (delivered != null) listener?.scanned(delivered) else listener?.noData(multiple) } } } private val statusListener = Scanner.StatusListener { status -> main.post { if (!closed) { - if (status.state == StatusData.ScannerStates.IDLE) listener?.idle() - else if (status.state == StatusData.ScannerStates.ERROR) listener?.failed() + if (status.state == StatusData.ScannerStates.IDLE) { + // Readiness arms a read. It is withheld until the output format is proven, then replayed once. + if (profile == Profile.AIM || profile == Profile.PLAIN) listener?.idle() + else if (profile == Profile.PENDING) idleSeenBeforeProfile = true + } else if (status.state == StatusData.ScannerStates.ERROR) listener?.failed() } } } @@ -91,7 +127,15 @@ internal class ZebraScannerDriver(private val context: Context) : ScannerDriver device.addStatusListener(statusListener) device.triggerType = Scanner.TriggerType.HARD device.enable() + if (!applyAimProfile(device)) { + profile = Profile.FAILED + this@ZebraScannerDriver.listener?.failed() + } else if (idleSeenBeforeProfile) { + idleSeenBeforeProfile = false + this@ZebraScannerDriver.listener?.idle() + } } catch (exception: Exception) { + profile = Profile.FAILED this@ZebraScannerDriver.listener?.failed() } } @@ -151,31 +195,100 @@ internal class ZebraScannerDriver(private val context: Context) : ScannerDriver scanner = null manager = null var failure: Exception? = null + var releaseFailure: Exception? = null /******************************************************************************* ** Attempt every resource release even when an earlier cleanup fails, then propagate the recorded failure. + ** Only a failed device or manager release means ownership is unknown; other failures are still reported. *******************************************************************************/ - fun attempt(action: () -> Unit) { + fun attempt(isRelease: Boolean = false, action: () -> Unit) { try { action() } catch (exception: Exception) { failure = exception + if (isRelease) releaseFailure = exception } } if (device != null) { attempt { device.removeDataListener(dataListener) } attempt { device.removeStatusListener(statusListener) } attempt { if (device.isReadPending) device.cancelRead() } - attempt { device.release() } + attempt { check(restoreCodeIdType(device)) { "Scanner output profile restoration could not be verified" } } + attempt(isRelease = true) { device.release() } } if (owner != null) { - attempt { owner.release(EMDKManager.FEATURE_TYPE.BARCODE) } - attempt { owner.release() } + attempt(isRelease = true) { owner.release(EMDKManager.FEATURE_TYPE.BARCODE) } + attempt(isRelease = true) { owner.release() } } - if (!opening && failure == null) releaseClaim() + // A confirmed device and manager release frees the application-wide claim even when restoring the scanner's + // output format failed; an unconfirmed release keeps it, and a pending asynchronous bind still resolves it later. + if (!opening && releaseFailure == null) releaseClaim() failure?.let { throw it } } + /******************************************************************************* + ** Ask the enabled scanner to prefix each decode with its AIM identifier and prove the resulting format by + ** reading it back, whatever the setter reported. A setter that threw is not trusted either way: the read-back + ** decides. AIM confirmed -> AIM; unchanged or restored-and-verified -> PLAIN; anything else -> false (the caller + ** fails the open), because prefixed text would then be indistinguishable from user data. An unsupported setting + ** changes nothing and leaves AIM absent (PLAIN). Reading the original value may throw; the caller fails the open. + *******************************************************************************/ + private fun applyAimProfile(device: Scanner): Boolean { + val config = device.config + if (!config.isParamSupported("config.scanParams.codeIdType")) { + profile = Profile.PLAIN + return true + } + val original = config.scanParams.codeIdType + originalCodeIdType = original + codeIdTypeChanged = true + config.scanParams.codeIdType = ScannerConfig.CodeIdType.AIM + try { + device.config = config + } catch (exception: Exception) { + android.util.Log.w("Scanner", "AIM output profile could not be applied") + } + val effective = try { + device.config.scanParams.codeIdType + } catch (exception: Exception) { + null + } + return when (classifyAimReadback(original, ScannerConfig.CodeIdType.AIM, effective)) { + AimReadback.CONFIRMED -> { + // Nothing to undo when the scanner already reported AIM before this driver touched it. + codeIdTypeChanged = original != ScannerConfig.CodeIdType.AIM + profile = Profile.AIM + true + } + AimReadback.UNCHANGED -> { + codeIdTypeChanged = false + profile = Profile.PLAIN + true + } + AimReadback.INDETERMINATE -> restoreCodeIdType(device).also { if (it) profile = Profile.PLAIN } + } + } + + /******************************************************************************* + ** Put back the value read before this driver changed it and prove it by reading it back; setter success alone is + ** not proof. The profile is FAILED until proven, so no decode is trusted while the format is indeterminate. + *******************************************************************************/ + private fun restoreCodeIdType(device: Scanner): Boolean { + val original = originalCodeIdType + if (!codeIdTypeChanged || original == null) return true + profile = Profile.FAILED + val verified = try { + val config = device.config + config.scanParams.codeIdType = original + device.config = config + device.config.scanParams.codeIdType == original + } catch (exception: Exception) { + false + } + if (verified) codeIdTypeChanged = false + return verified + } + /******************************************************************************* ** Relinquish only this driver's manager claim after asynchronous opening and cleanup are resolved. *******************************************************************************/ diff --git a/scanner-host/src/main/res/values/company_profile.xml b/scanner-host/src/main/res/values/company_profile.xml new file mode 100644 index 0000000..2a49923 --- /dev/null +++ b/scanner-host/src/main/res/values/company_profile.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="status_wallpaper_label">QQQ status wallpaper</string> + <string name="status_wallpaper_description">Company name on the lock screen. On the unlocked home screen it also shows the signed-in display name and the device IP address.</string> + <string name="restriction_server_url_title">QQQ server URL</string> + <string name="restriction_server_url_description">HTTPS origin only, for example https://qqq.example.com/. No credentials, path, query or fragment. A different connected server is never switched silently.</string> + <string name="restriction_orientation_title">Screen orientation</string> + <string name="restriction_orientation_description">Applied when this value changes; the operator may change it locally afterwards.</string> + <string-array name="restriction_orientation_entries"> + <item>System default</item> + <item>Portrait</item> + <item>Landscape</item> + <item>Reverse portrait</item> + <item>Reverse landscape</item> + </string-array> + <string-array name="restriction_orientation_values"> + <item>SYSTEM</item> + <item>PORTRAIT</item> + <item>LANDSCAPE</item> + <item>REVERSE_PORTRAIT</item> + <item>REVERSE_LANDSCAPE</item> + </string-array> +</resources> diff --git a/scanner-host/src/main/res/xml/app_restrictions.xml b/scanner-host/src/main/res/xml/app_restrictions.xml new file mode 100644 index 0000000..4820aba --- /dev/null +++ b/scanner-host/src/main/res/xml/app_restrictions.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<restrictions xmlns:android="http://schemas.android.com/apk/res/android"> + <restriction android:key="qqq_server_url" android:title="@string/restriction_server_url_title" + android:description="@string/restriction_server_url_description" android:restrictionType="string" /> + <restriction android:key="qqq_orientation" android:title="@string/restriction_orientation_title" + android:description="@string/restriction_orientation_description" android:restrictionType="choice" + android:entries="@array/restriction_orientation_entries" android:entryValues="@array/restriction_orientation_values" /> +</restrictions> diff --git a/scanner-host/src/main/res/xml/status_wallpaper.xml b/scanner-host/src/main/res/xml/status_wallpaper.xml new file mode 100644 index 0000000..812e096 --- /dev/null +++ b/scanner-host/src/main/res/xml/status_wallpaper.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<wallpaper xmlns:android="http://schemas.android.com/apk/res/android" + android:thumbnail="@drawable/ic_scanner" android:description="@string/status_wallpaper_description" /> diff --git a/scanner-host/src/qaAndroidTest/java/io/qrun/mobile/scanner/ScannerOverlayTest.kt b/scanner-host/src/qaAndroidTest/java/io/qrun/mobile/scanner/ScannerOverlayTest.kt index 8b875a9..60a399b 100644 --- a/scanner-host/src/qaAndroidTest/java/io/qrun/mobile/scanner/ScannerOverlayTest.kt +++ b/scanner-host/src/qaAndroidTest/java/io/qrun/mobile/scanner/ScannerOverlayTest.kt @@ -139,7 +139,7 @@ class ScannerOverlayTest { ** *******************************************************************************/ fun emit(value: String) { - pending = false; listener.scanned(value, "QRCODE"); listener.idle() + pending = false; listener.scanned(ScannerCapture.of(value, "QRCODE")); listener.idle() } } diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/AimOutputProfileTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/AimOutputProfileTest.kt new file mode 100644 index 0000000..8e52152 --- /dev/null +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/AimOutputProfileTest.kt @@ -0,0 +1,61 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/******************************************************************************* + ** The confirmed AIM output profile removes only a consistent three-character prefix. + *******************************************************************************/ +class AimOutputProfileTest { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun splitsConsistentPrefixAndKeepsPayloadExact() { + assertEquals(AimOutputProfile.Split("]C0", "001234"), AimOutputProfile.split("]C0001234", "LABEL-TYPE-CODE128")) + assertEquals(AimOutputProfile.Split("]Q1", "001234567890"), AimOutputProfile.split("]Q1001234567890", "QRCODE")) + assertEquals(AimOutputProfile.Split("]E0", "5901234123457"), AimOutputProfile.split("]E05901234123457", "LABEL-TYPE-EAN13")) + assertEquals(AimOutputProfile.Split("]E4", "12345670"), AimOutputProfile.split("]E412345670", "EAN8")) + assertEquals(AimOutputProfile.Split("]d1", " A "), AimOutputProfile.split("]d1 A ", "DATAMATRIX")) + assertEquals(AimOutputProfile.Split("]X0", "x"), AimOutputProfile.split("]X0x", "LABEL-TYPE-SOMETHING-ELSE")) + } + + /******************************************************************************* + ** Only the first three characters are removed, even when the user payload looks like an identifier. + *******************************************************************************/ + @Test + fun payloadBeginningWithAnIdentifierIsNotStrippedTwice() { + assertEquals(AimOutputProfile.Split("]C0", "]C0123"), AimOutputProfile.split("]C0]C0123", "CODE128")) + assertEquals(AimOutputProfile.Split("]C0", "]Q1abc"), AimOutputProfile.split("]C0]Q1abc", "CODE128")) + assertEquals(AimOutputProfile.Split("]Q1", "]"), AimOutputProfile.split("]Q1]", "QRCODE")) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun malformedOrInconsistentPrefixIsNotTrusted() { + for (text in listOf("", "]", "]C", "]C0", "C0001234", "[C0001234", "]00001234", "]C-001234", "]éC0123", " ]C0123", "001234")) { + assertNull(text, AimOutputProfile.split(text, "CODE128")) + } + // A well-formed prefix that belongs to another symbology family means the label or prefix is not what was configured. + assertNull(AimOutputProfile.split("]Q1001234", "CODE128")) + assertNull(AimOutputProfile.split("]E0001234567890", "LABEL-TYPE-CODE128")) + assertNull(AimOutputProfile.split("]C0001234", "QRCODE")) + assertNull(AimOutputProfile.split("]C0001234", "EAN13")) + } + + /******************************************************************************* + ** The capture constructor accepts exactly what the parser produces. + *******************************************************************************/ + @Test + fun parsedIdentifierIsAValidCaptureIdentifier() { + val split = AimOutputProfile.split("]z0Hello", "AZTEC")!! + val capture = ScannerCapture.of(split.payload, "AZTEC", "]z0Hello".toByteArray(), split.aimIdentifier) + assertEquals("Hello", capture.barcode) + assertEquals("]z0", capture.aimIdentifier) + assertEquals(java.util.Base64.getEncoder().encodeToString("]z0Hello".toByteArray()), capture.rawDataBase64) + } +} diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/AimReadbackTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/AimReadbackTest.kt new file mode 100644 index 0000000..999a2d6 --- /dev/null +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/AimReadbackTest.kt @@ -0,0 +1,47 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import org.junit.Assert.assertEquals +import org.junit.Test + +/******************************************************************************* + ** The driver may accept scanner text only in a format proven by read-back; everything else must fail closed. + *******************************************************************************/ +class AimReadbackTest { + private val aim = "AIM" + private val original = "NONE" + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun reportedAimIsConfirmedEvenIfTheSetterThrew() { + assertEquals(AimReadback.CONFIRMED, classifyAimReadback(original, aim, aim)) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun aScannerAlreadyReportingAimIsConfirmedNotUnchanged() { + assertEquals(AimReadback.CONFIRMED, classifyAimReadback(aim, aim, aim)) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun exactlyTheOriginalValueIsUnchanged() { + assertEquals(AimReadback.UNCHANGED, classifyAimReadback(original, aim, original)) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun anUnreadableOrForeignValueIsIndeterminate() { + assertEquals(AimReadback.INDETERMINATE, classifyAimReadback(original, aim, null)) + assertEquals(AimReadback.INDETERMINATE, classifyAimReadback(original, aim, "SYMBOL")) + assertEquals(AimReadback.INDETERMINATE, classifyAimReadback(aim, aim, null)) + } +} diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/ManagedConfigurationTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/ManagedConfigurationTest.kt new file mode 100644 index 0000000..e2de791 --- /dev/null +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/ManagedConfigurationTest.kt @@ -0,0 +1,100 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/******************************************************************************* + ** Managed configuration is a bootstrap only: HTTPS origins and exact known orientation names, never silently defaulted. + *******************************************************************************/ +class ManagedConfigurationTest { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun noRestrictionsLeavesEverythingUnmanaged() { + assertEquals(ManagedValues(), ManagedConfiguration.parse(null, null)) + assertEquals(ManagedValues(), ManagedConfiguration.parse("", " ")) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun acceptsOnlyANormalizedHttpsOrigin() { + assertEquals("https://qqq.example.com/", ManagedConfiguration.parse(" https://QQQ.example.com ", null).serverUrl) + assertEquals("https://qqq.example.com:8443/", ManagedConfiguration.parse("https://qqq.example.com:8443/", null).serverUrl) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun rejectsHttpCredentialsPathsQueriesAndGarbageWithoutRecordingTheValue() { + listOf( + "http://qqq.example.com", "http://127.0.0.1:8080/", "https://user:pw@qqq.example.com", "https://qqq.example.com/app", + "https://qqq.example.com/?t=1", "https://qqq.example.com/#f", "ftp://qqq.example.com", "not a url", "https://", "https://" + "a".repeat(2100) + ).forEach { value -> + val parsed = ManagedConfiguration.parse(value, null) + assertNull(value, parsed.serverUrl) + assertEquals(listOf(MANAGED_SERVER_URL), parsed.rejectedKeys) + } + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun orientationAcceptsEveryExistingNameExactly() { + OrientationPolicy.entries.forEach { assertEquals(it, ManagedConfiguration.parse(null, it.name).orientation) } + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun unknownOrientationIsRejectedNotDefaultedToPortrait() { + listOf("portrait", "Landscape", "AUTO", "SENSOR", "90", "REVERSE-PORTRAIT").forEach { value -> + val parsed = ManagedConfiguration.parse(null, value) + assertNull(value, parsed.orientation) + assertEquals(listOf(MANAGED_ORIENTATION), parsed.rejectedKeys) + } + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun aBadValueDoesNotBlockTheGoodOne() { + val parsed = ManagedConfiguration.parse("http://bad.example.com", "LANDSCAPE") + assertNull(parsed.serverUrl) + assertEquals(OrientationPolicy.LANDSCAPE, parsed.orientation) + assertEquals(listOf(MANAGED_SERVER_URL), parsed.rejectedKeys) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun serverIsNeverSwitchedSilently() { + val managed = "https://managed.example.com/" + assertEquals(ManagedServerAction.NONE, ManagedConfiguration.serverAction("https://other.example.com/", null)) + assertEquals(ManagedServerAction.NONE, ManagedConfiguration.serverAction(null, null)) + assertEquals(ManagedServerAction.PREFILL, ManagedConfiguration.serverAction(null, managed)) + assertEquals(ManagedServerAction.NONE, ManagedConfiguration.serverAction(managed, managed)) + assertEquals(ManagedServerAction.OFFER_CHANGE, ManagedConfiguration.serverAction("https://other.example.com/", managed)) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun orientationIsAppliedOnlyWhenTheManagedValueChanges() { + assertNull(ManagedConfiguration.orientationToApply(null, null)) + assertNull(ManagedConfiguration.orientationToApply(null, "LANDSCAPE")) + assertEquals(OrientationPolicy.LANDSCAPE, ManagedConfiguration.orientationToApply(OrientationPolicy.LANDSCAPE, null)) + assertNull(ManagedConfiguration.orientationToApply(OrientationPolicy.LANDSCAPE, "LANDSCAPE")) + assertEquals(OrientationPolicy.SYSTEM, ManagedConfiguration.orientationToApply(OrientationPolicy.SYSTEM, "LANDSCAPE")) + } +} diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/MobileSessionTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/MobileSessionTest.kt index b5e2740..0b37522 100644 --- a/scanner-host/src/test/java/io/qrun/mobile/scanner/MobileSessionTest.kt +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/MobileSessionTest.kt @@ -328,7 +328,16 @@ class MobileSessionTest { val screen = server.takeRequest() assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/screen", screen.path) assertEquals(4, JSONObject(screen.body.readUtf8()).getLong("sequence")) - assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/end", server.takeRequest().path) + val end = server.takeRequest() + assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/end", end.path) + // Cookie-bearing native POSTs must present the exact origin of the URL used, like a browser. + val expectedOrigin = origin.trimEnd('/') + listOf(heartbeat, screen, end).forEach { request -> + assertEquals("POST", request.method) + assertEquals("sessionUUID=synthetic-session", request.getHeader("Cookie")) + assertEquals(expectedOrigin, request.getHeader("Origin")) + assertEquals(1, request.headers.values("Origin").size) + } } } diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/RecordFilesApiTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/RecordFilesApiTest.kt index 57d6945..5d6a59c 100644 --- a/scanner-host/src/test/java/io/qrun/mobile/scanner/RecordFilesApiTest.kt +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/RecordFilesApiTest.kt @@ -109,7 +109,15 @@ class RecordFilesApiTest { val finalize = server.takeRequest() assertEquals(intent.requestId, finalize.getHeader("Idempotency-Key")) assertEquals(0L, finalize.bodySize) - assertEquals("GET", server.takeRequest().method) + val status = server.takeRequest() + assertEquals("GET", status.method) + // Every native record-file request (including the cookie-bearing mutations) presents its own exact origin. + val expectedOrigin = origin.trimEnd('/') + listOf(descriptor, capabilities, first, second, chunk, finalize, status).forEach { + assertEquals(expectedOrigin, it.getHeader("Origin")) + assertEquals(1, it.headers.values("Origin").size) + } + listOf(capabilities, first, second, chunk, finalize, status).forEach { assertEquals("sessionUUID=synthetic-session", it.getHeader("Cookie")) } } } diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/ScanEventTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/ScanEventTest.kt index 569c3f0..ef42b73 100644 --- a/scanner-host/src/test/java/io/qrun/mobile/scanner/ScanEventTest.kt +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/ScanEventTest.kt @@ -1,14 +1,31 @@ /* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ package io.qrun.mobile.scanner +import com.kingsrook.qqq.frontend.android.core.controllers.UiApiExtension +import com.kingsrook.qqq.frontend.android.core.controllers.UiApiVersion +import java.util.Base64 +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test /******************************************************************************* ** Retrying a submission must not turn it into a second captured scan. *******************************************************************************/ class ScanEventTest { + private val legacyKeys = setOf("barcode", "symbology", "scanSource", "eventId", "capturedAt") + + /******************************************************************************* + ** + *******************************************************************************/ + private fun version(scanners: String?, path: String = "/qbit/mobile-scanners") = UiApiVersion( + "1.8.0", extensions = listOfNotNull(scanners?.let { UiApiExtension("mobile-scanners", it, path) }) + ) + /******************************************************************************* ** *******************************************************************************/ @@ -19,4 +36,126 @@ class ScanEventTest { assertEquals("001234", event.values()["barcode"]) assertNotEquals(event.eventId, ScanEvent("001234", "LABEL-TYPE-CODE128", "scanner").eventId) } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun oldServerGetsExactlyTheLegacyFiveKeysEvenWhenFidelityIsCaptured() { + val event = ScanEvent(ScannerCapture.of("A", "QRCODE", byteArrayOf(1, 2), "]Q1"), "zebra-emdk") + assertEquals(legacyKeys, event.values().keys) + assertEquals(legacyKeys, event.values(includeFidelity = false).keys) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun fidelityValuesAreFlatStringsAndMissingKeysAreOmitted() { + val raw = byteArrayOf(0x1d, 0x41) + val full = ScanEvent(ScannerCapture.of("A", "QRCODE", raw, "]Q1"), "zebra-emdk").values(true) + assertEquals("]Q1", full["aimIdentifier"]) + assertEquals(Base64.getEncoder().encodeToString(raw), full["rawDataBase64"]) + val rawOnly = ScanEvent(ScannerCapture.of("A", "QRCODE", raw), "zebra-emdk").values(true) + assertEquals(legacyKeys + "rawDataBase64", rawOnly.keys) + assertEquals(legacyKeys, ScanEvent(ScannerCapture.manual("A"), "manual").values(true).keys) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun retryPayloadIsUnchangedAcrossRepeatedValueBuilding() { + val source = byteArrayOf(0, 1, 2) + val event = ScanEvent(ScannerCapture.of("A", "QRCODE", source, "]Q1"), "zebra-emdk") + val first = event.values(true) + source[0] = 9 + assertEquals(first, event.values(true)) + assertEquals(first, event.copy().values(true)) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun captureCopiesInputAndAllByteValuesRoundTrip() { + val all = ByteArray(256) { it.toByte() } + val capture = ScannerCapture.of("A", "QRCODE", all) + all.fill(0) + val expected = ByteArray(256) { it.toByte() } + assertArrayEquals(expected, Base64.getDecoder().decode(capture.rawDataBase64)) + assertEquals(capture, ScannerCapture.of("A", "QRCODE", expected)) + assertEquals(capture.hashCode(), ScannerCapture.of("A", "QRCODE", expected).hashCode()) + assertNotEquals(capture, ScannerCapture.of("A", "QRCODE", expected, "]Q1")) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun rawLengthLimitsOmitInsteadOfTruncating() { + assertNull(ScannerCapture.of("A", "Q", null).rawDataBase64) + assertNull(ScannerCapture.of("A", "Q", ByteArray(0)).rawDataBase64) + assertNull(ScannerCapture.of("A", "Q", ByteArray(8193)).rawDataBase64) + val max = ScannerCapture.of("A", "Q", ByteArray(8192) { 1 }) + assertEquals(10924, max.rawDataBase64!!.length) + assertEquals("AA==", ScannerCapture.of("A", "Q", ByteArray(1)).rawDataBase64) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun aimMustBeOriginalWellFormedIdentifier() { + listOf("]Q1", "]c0", "]A0", "]z9").forEach { assertEquals(it, ScannerCapture.of("A", "Q", null, it).aimIdentifier) } + listOf("", "Q1", "]Q", "]Q11", "]11", "]Q!", "]Q1 ", "]Qé").forEach { + assertThrows(IllegalArgumentException::class.java) { ScannerCapture.of("A", "Q", null, it) } + } + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun stringFormsNeverLeakScanContent() { + val event = ScanEvent(ScannerCapture.of("SECRET-BARCODE", "QRCODE", "SECRET-RAW".toByteArray(), "]Q1"), "zebra-emdk") + listOf(event.toString(), event.capture.toString()).forEach { + assertFalse(it, it.contains("SECRET")) + assertFalse(it, it.contains(Base64.getEncoder().encodeToString("SECRET-RAW".toByteArray()))) + assertFalse(it, it.contains("]Q1")) + } + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun capabilityRequiresMobileScanners15WithinMajor1AtRegisteredPath() { + assertTrue(ScanFidelityCapability.supports(version("1.5.0"))) + assertTrue(ScanFidelityCapability.supports(version("1.12.3"))) + listOf("1.4.9", "1.1.0", "2.0.0", "2.5.0", "1.5", "1.5.0-rc1", "01.5.0", "1.5.0.1", "", "1.99999999999.0").forEach { + assertFalse(it, ScanFidelityCapability.supports(version(it))) + } + assertFalse(ScanFidelityCapability.supports(version("1.5.0", "/qbit/other"))) + assertFalse(ScanFidelityCapability.supports(version(null))) + assertFalse(ScanFidelityCapability.supports(null)) + val duplicate = UiApiVersion( + "1.8.0", extensions = listOf( + UiApiExtension("mobile-scanners", "1.5.0", "/qbit/mobile-scanners"), + UiApiExtension("mobile-scanners", "1.5.0", "/qbit/mobile-scanners") + ) + ) + assertFalse(ScanFidelityCapability.supports(duplicate)) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun capabilityIsScopedToTheServerThatWasDiscovered() { + val v = version("1.5.0") + assertTrue(ScanFidelityCapability.available("https://a/", "https://a/", v)) + assertFalse(ScanFidelityCapability.available("https://b/", "https://a/", v)) + assertFalse(ScanFidelityCapability.available("https://a/", "https://a/", null)) + } } diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/ScannerFeedbackTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/ScannerFeedbackTest.kt new file mode 100644 index 0000000..04aeb9a --- /dev/null +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/ScannerFeedbackTest.kt @@ -0,0 +1,121 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/******************************************************************************* + ** The visible scan must survive acknowledgment and retry unchanged, never invent a symbology, and + ** claim success only on the same receipt the process requires. + *******************************************************************************/ +class ScannerFeedbackTest { + private val event = ScanEvent(ScannerCapture.of("00123\u001d45", "LABEL-TYPE-CODE128"), "zebra-emdk") + private val receipt = mapOf("acknowledgedEventId" to event.eventId, "scanSaved" to true, "scanId" to "7") + + /******************************************************************************* + ** + *******************************************************************************/ + private fun derive(last: LastScan?, pending: Map<String, Any?>?, response: Map<String, Any?>, waiting: Boolean = false, cancelling: Boolean = false) = + ScannerFeedback.derive(last, pending, response, waiting, cancelling) + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun nothingScannedYieldsNoFeedbackSoThePanelShowsWaiting() { + assertNull(derive(null, null, emptyMap())) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun lastCaptureStaysVisibleAfterFullReceiptClearsPendingState() { + val feedback = derive(LastScan.of(event), null, receipt)!! + assertEquals("00123\u001d45", feedback.barcode) + assertEquals("LABEL-TYPE-CODE128", feedback.symbology) + assertEquals("Confirmed by server", feedback.status) + assertEquals("8 characters, 1 control character", feedback.details) + } + + /******************************************************************************* + ** The event id alone is not a receipt: scanSaved=true and a non-blank scanId are also required. + *******************************************************************************/ + @Test + fun incompleteReceiptsAreNeverReportedAsConfirmed() { + val partial = listOf( + mapOf("acknowledgedEventId" to event.eventId), + receipt + ("scanSaved" to false), + receipt + ("scanId" to " "), + receipt - "scanId", + ) + for (response in partial) assertEquals( + "Not confirmed. Follow the process message before submitting again.", derive(LastScan.of(event), null, response)!!.status + ) + assertEquals( + "Not confirmed. Retry the same request or leave the process; do not assume it was saved.", + derive(null, event.values(), partial[1])!!.status + ) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun retryShowsTheSameValueAndTypeFromThePendingRequest() { + val pending = event.values() + val sending = derive(null, pending, emptyMap(), waiting = true)!! + val failed = derive(null, pending, emptyMap())!! + assertEquals("Sending scan…", sending.status) + assertTrue(failed.status.startsWith("Not confirmed. Retry the same request")) + for (feedback in listOf(sending, failed)) { + assertEquals(event.barcode, feedback.barcode) + assertEquals(event.symbology, feedback.symbology) + } + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun nextCaptureReplacesAnEarlierConfirmedOne() { + val next = ScanEvent(ScannerCapture.of("999", "QRCODE"), "zebra-emdk") + val feedback = derive(LastScan.of(event), next.values(), receipt, waiting = true)!! + assertEquals("999", feedback.barcode) + assertEquals("QRCODE", feedback.symbology) + assertEquals("Sending scan…", feedback.status) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun cancellationIsNotReportedAsConfirmation() { + assertEquals("Cancelling. Scan was not confirmed.", derive(LastScan.of(event), event.values(), emptyMap(), cancelling = true)!!.status) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun manualEntryNeverReceivesAGuessedSymbology() { + val manual = ScanEvent(ScannerCapture.manual("00042"), "manual") + assertNull(LastScan.of(manual).symbology) + assertEquals(ScannerFeedback.MANUAL_SYMBOLOGY, derive(LastScan.of(manual), null, emptyMap())!!.symbology) + val pending = derive(null, manual.values(), emptyMap())!! + assertEquals(ScannerFeedback.MANUAL_SYMBOLOGY, pending.symbology) + assertEquals("00042", pending.barcode) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun scanContentIsRedactedFromToString() { + assertFalse(LastScan.of(event).toString().contains("00123")) + assertFalse(derive(LastScan.of(event), null, receipt)!!.toString().contains("00123")) + } +} diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/ScannerInputSessionTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/ScannerInputSessionTest.kt index bf279ef..362d28c 100644 --- a/scanner-host/src/test/java/io/qrun/mobile/scanner/ScannerInputSessionTest.kt +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/ScannerInputSessionTest.kt @@ -65,8 +65,8 @@ class ScannerInputSessionTest { /******************************************************************************* ** *******************************************************************************/ - fun scan(value: String = "001234567890") { - pending = false; listener.scanned(value, "QRCODE") + fun scan(value: String = "001234567890", raw: ByteArray? = null) { + pending = false; listener.scanned(ScannerCapture.of(value, "QRCODE", raw)) } } @@ -76,10 +76,11 @@ class ScannerInputSessionTest { private class Fixture { val devices = mutableListOf<Device>() val scans = mutableListOf<Pair<String, String>>() + val captures = mutableListOf<ScannerCapture>() val statuses = mutableListOf<String>() val session = ScannerInputSession( { Device().also { devices.add(it) } }, - { value, type -> scans.add(value to type) }, { message, _ -> statuses.add(message) }) + { capture -> captures.add(capture); scans.add(capture.barcode to capture.symbology) }, { message, _ -> statuses.add(message) }) /******************************************************************************* ** @@ -97,7 +98,7 @@ class ScannerInputSessionTest { @Test fun missingHardwareKeepsManualFallback() { val statuses = mutableListOf<String>() - val session = ScannerInputSession({ null }, { _, _ -> error("Unexpected scan") }, { message, _ -> statuses.add(message) }) + val session = ScannerInputSession({ null }, { error("Unexpected scan") },{ message, _ -> statuses.add(message) }) session.setAccepting(true) session.setActive(true) assertTrue(statuses.single().contains("manually")) @@ -111,7 +112,7 @@ class ScannerInputSessionTest { val device = Device() var allowed = true val scans = mutableListOf<String>() - val session = ScannerInputSession({ device }, { value, _ -> scans.add(value) }, { _, _ -> }, { allowed }) + val session = ScannerInputSession({ device }, { capture -> scans.add(capture.barcode) }, { _, _ -> }, { allowed }) session.setAccepting(true) session.setActive(true) device.idle() @@ -135,7 +136,7 @@ class ScannerInputSessionTest { fun hiddenSurfaceNeverArmsEvenWhenOldReadinessFlagIsTrue() { val device = Device() var allowed = false - val session = ScannerInputSession({ device }, { _, _ -> error("Unexpected scan") }, { _, _ -> }, { allowed }) + val session = ScannerInputSession({ device }, { error("Unexpected scan") }, { _, _ -> }, { allowed }) session.setAccepting(true) session.setActive(true) device.idle() @@ -176,6 +177,27 @@ class ScannerInputSessionTest { assertEquals(2, device.reads) } + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun rawBytesReachTheSinkUnchangedAndStaleOwnersDeliverNothing() { + val fixture = Fixture() + val first = fixture.start() + first.idle() + val raw = ByteArray(256) { it.toByte() } + fixture.session.setActive(false) + fixture.session.setActive(true) + first.scan("stale", raw) + assertTrue(fixture.captures.isEmpty()) + val second = fixture.devices.last() + second.idle() + second.scan("current", raw) + assertEquals("current", fixture.captures.single().barcode) + assertEquals(java.util.Base64.getEncoder().encodeToString(raw), fixture.captures.single().rawDataBase64) + assertTrue(fixture.captures.single().aimIdentifier == null) + } + /******************************************************************************* ** *******************************************************************************/ @@ -188,7 +210,7 @@ class ScannerInputSessionTest { device.idle() assertEquals(2, device.reads) // Deliver the first read's data while the second read is pending. - device.listener.scanned("001234567890", "QRCODE") + device.listener.scanned(ScannerCapture.of("001234567890", "QRCODE")) assertEquals(1, device.cancels) assertEquals(1, fixture.scans.size) device.idle() @@ -261,7 +283,7 @@ class ScannerInputSessionTest { device.idle() device.pending = false device.idle() - device.listener.scanned("001234567890", "QRCODE") + device.listener.scanned(ScannerCapture.of("001234567890", "QRCODE")) device.listener.noData(false) device.idle() assertEquals(1, device.cancels) diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/ScannerMetaDataTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/ScannerMetaDataTest.kt index 3d11068..74d5f73 100644 --- a/scanner-host/src/test/java/io/qrun/mobile/scanner/ScannerMetaDataTest.kt +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/ScannerMetaDataTest.kt @@ -15,9 +15,55 @@ class ScannerMetaDataTest { @Test fun serverDefinesLabelsAndLengthWithContractDefaults() { assertEquals(ScannerMetaData(), ScannerMetaData.parse(emptyMap())) + assertEquals("Saved scan", ScannerMetaData.parse(emptyMap()).acknowledgmentLabel) + assertEquals(true, ScannerMetaData.parse(emptyMap()).manualEntryEnabled) assertEquals(ScannerMetaData("Item barcode", "Save item", 30), ScannerMetaData.parse(mapOf("manualLabel" to "Item barcode", "submitLabel" to "Save item", "maxLength" to 30L, "future" to true))) } + /******************************************************************************* + ** Manual input stays available by default and can be hidden independently of receipt presentation. + *******************************************************************************/ + @Test + fun manualEntryVisibilityUsesExplicitBooleanHints() { + assertEquals(true, ScannerMetaData.parse(mapOf("manualEntryEnabled" to true)).manualEntryEnabled) + assertEquals(false, ScannerMetaData.parse(mapOf("manualEntryEnabled" to false)).manualEntryEnabled) + assertEquals(ScannerMetaData(acknowledgmentLabel = "Print queued", manualEntryEnabled = false), + ScannerMetaData.parse(mapOf("acknowledgmentLabel" to "Print queued", "manualEntryEnabled" to false))) + } + + /******************************************************************************* + ** Supplied flags must be JSON booleans, without number or string coercion. + *******************************************************************************/ + @Test + fun explicitlyInvalidManualEntryHintsFailClosed() { + for (value in listOf(null, 0, 1, 1.0, "true", "false", "", listOf(true), mapOf("enabled" to true))) { + assertThrows(IllegalArgumentException::class.java) { ScannerMetaData.parse(mapOf("manualEntryEnabled" to value)) } + } + } + + /******************************************************************************* + ** A server may describe accepted work without changing the scan receipt contract. + *******************************************************************************/ + @Test + fun acknowledgmentLabelsDescribeTheAcceptedOperation() { + assertEquals("Print queued", ScannerMetaData.parse(mapOf("acknowledgmentLabel" to "Print queued")).acknowledgmentLabel) + assertEquals("Étiquette prête", ScannerMetaData.parse(mapOf("acknowledgmentLabel" to "Étiquette prête")).acknowledgmentLabel) + val longestLabel = "Q".repeat(80) + assertEquals(longestLabel, ScannerMetaData.parse(mapOf("acknowledgmentLabel" to longestLabel)).acknowledgmentLabel) + } + + /******************************************************************************* + ** Reject malformed supplied hints rather than silently displaying an unrelated success label. + *******************************************************************************/ + @Test + fun explicitlyInvalidAcknowledgmentLabelsFailClosed() { + for (value in listOf(null, 42, false, "", " ", " Print queued", "Print queued ", "Q".repeat(81), + "Print\nqueued", "Print\rqueued", "Print\tqueued", "Print\u0000queued", "Print\u2028queued", "Print\u2029queued", + "Print\u202Equeued", "<b>Print queued</b>")) { + assertThrows(IllegalArgumentException::class.java) { ScannerMetaData.parse(mapOf("acknowledgmentLabel" to value)) } + } + } + /******************************************************************************* ** *******************************************************************************/ diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/WallpaperStatusTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/WallpaperStatusTest.kt new file mode 100644 index 0000000..9e4816f --- /dev/null +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/WallpaperStatusTest.kt @@ -0,0 +1,189 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/******************************************************************************* + ** The wallpaper may show identity and IP only on the unlocked home screen, only while fresh, and never keeps either + ** after the session ends. + *******************************************************************************/ +class WallpaperStatusTest { + private var elapsed = 1_000L + private var wall = 1_700_000_000_000L + private val store = WallpaperStatusStore({ elapsed }, { wall }) + private val ip = "IP: wlan0 10.0.0.5" + + private fun render(context: WallpaperContext, now: Long = elapsed) = + WallpaperPresentation.render(store.snapshot, context, ip, now) { "HH:mm" } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun newProcessIsUnknownAndShowsNoIdentity() { + assertEquals(WallpaperSessionState.UNKNOWN, store.snapshot.state) + assertEquals(listOf("Session: not available", ip), render(WallpaperContext.HOME).lines) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun previewAndLockedShowBrandOnlyEvenWhenSignedIn() { + store.signedIn("Ada Lovelace", "qqq.example.com", foreground = true) + listOf(WallpaperContext.PREVIEW, WallpaperContext.LOCKED).forEach { + val frame = render(it) + assertEquals(HostBrand().displayName, frame.header) + assertTrue(frame.lines.isEmpty()) + } + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun homeShowsNameServerAndIpWhileForeground() { + store.signedIn("Ada Lovelace", "qqq.example.com", foreground = true) + assertEquals(listOf("Signed in: Ada Lovelace", "Server: qqq.example.com", ip), render(WallpaperContext.HOME).lines) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun backgroundSessionIsLabelledLastConfirmedNotLive() { + store.signedIn("Ada Lovelace", "qqq.example.com", foreground = true) + store.signedIn("Ada Lovelace", "qqq.example.com", foreground = false) + val lines = render(WallpaperContext.HOME, elapsed + 60_000).lines + assertTrue(lines.contains("Signed in: Ada Lovelace")) + assertTrue(lines.contains("Last confirmed HH:mm (app in background)")) + assertFalse(lines.any { it.contains("verified", ignoreCase = true) && !it.contains("unverified", ignoreCase = true) }) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun nameIsWithdrawnAfterTheStaleWindow() { + store.signedIn("Ada Lovelace", "qqq.example.com", foreground = false) + val justBefore = elapsed + WallpaperPresentation.STALE_AFTER_MILLIS - 1 + val atWindow = elapsed + WallpaperPresentation.STALE_AFTER_MILLIS + assertTrue(render(WallpaperContext.HOME, justBefore).lines.contains("Signed in: Ada Lovelace")) + assertEquals(1L, WallpaperPresentation.millisUntilStale(store.snapshot, justBefore)) + val stale = render(WallpaperContext.HOME, atWindow).lines + assertEquals(listOf("Session unverified", "Last confirmed HH:mm", ip), stale) + assertNull(WallpaperPresentation.millisUntilStale(store.snapshot, atWindow)) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun foregroundSessionNeverGoesStaleOrNeedsATimer() { + store.signedIn("Ada Lovelace", null, foreground = true) + assertFalse(WallpaperPresentation.isStale(store.snapshot, elapsed + 10 * WallpaperPresentation.STALE_AFTER_MILLIS)) + assertNull(WallpaperPresentation.millisUntilStale(store.snapshot, elapsed)) + assertEquals(listOf("Signed in: Ada Lovelace", ip), render(WallpaperContext.HOME).lines) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun signOutDropsNameAndServerButClearReturnsToUnknown() { + store.signedIn("Ada Lovelace", "qqq.example.com", foreground = true) + store.signedOutIfSignedIn() + assertEquals(WallpaperSessionState.SIGNED_OUT, store.snapshot.state) + assertNull(store.snapshot.displayName) + assertNull(store.snapshot.serverHost) + assertEquals(listOf("Signed out", ip), render(WallpaperContext.HOME).lines) + store.signedIn("Grace Hopper", "qqq.example.com", foreground = true) + store.clear() + assertEquals(WallpaperSessionState.UNKNOWN, store.snapshot.state) + assertNull(store.snapshot.displayName) + assertFalse(render(WallpaperContext.HOME).lines.any { it.contains("Grace") }) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun startupDoesNotClaimSignedOutBeforeAnySession() { + store.signedOutIfSignedIn() + assertEquals(WallpaperSessionState.UNKNOWN, store.snapshot.state) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun displayTextIsSingleLineAndBounded() { + store.signedIn("Ada\nLovelace\u0007 ${"x".repeat(100)}", "host", foreground = true) + val name = store.snapshot.displayName!! + assertTrue(name.length <= WallpaperStatusStore.MAX_NAME) + assertTrue(name.none { it.isISOControl() }) + store.signedIn(" ", "host", foreground = true) + assertNull(store.snapshot.displayName) + assertEquals(listOf("Signed in", "Server: host", ip), render(WallpaperContext.HOME).lines) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun brandIsValidatedAndFallsBackToTheDefault() { + store.setBrand("Acme Corp", "#aa5500") + assertEquals("Acme Corp", store.snapshot.brandName) + assertEquals("#AA5500", store.snapshot.brandColor) + store.setBrand("Bad", "red") + store.setBrand("", "#000000") + assertEquals("Acme Corp", store.snapshot.brandName) + assertEquals("#AA5500", store.snapshot.brandColor) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun listenersAreNotifiedUntilRemovedAndOnlyOnRealChange() { + var calls = 0 + val listener = WallpaperStatusListener { calls++ } + store.addListener(listener) + store.signedIn("Ada", null, foreground = true) + elapsed += 1 + store.signedIn("Ada", null, foreground = true) + store.clear() + assertEquals(3, calls) + store.clear() + assertEquals(3, calls) + store.removeListener(listener) + store.signedIn("Ada", null, foreground = true) + assertEquals(3, calls) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun networkLineDistinguishesUnknownFromNoAddress() { + assertEquals("IP: unknown", WallpaperPresentation.networkLine(false, null)) + assertEquals("IP: no network address", WallpaperPresentation.networkLine(true, null)) + assertEquals("IP: wlan0 10.0.0.5", WallpaperPresentation.networkLine(true, "wlan0 10.0.0.5")) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun addressFormattingOmitsLoopbackAndLinkLocalAndPrefersIpv4() { + assertEquals("wlan0 10.0.0.5, 2001:db8::1", formatNetworkAddresses("wlan0", listOf("fe80::1%wlan0", "2001:db8::1%wlan0", "10.0.0.5", "127.0.0.1", "::1"))) + assertEquals("10.0.0.5", formatNetworkAddresses(null, listOf("10.0.0.5"))) + assertEquals("tun0x 10.8.0.2", formatNetworkAddresses("tun0 \n<x>", listOf("10.8.0.2"))) + assertNull(formatNetworkAddresses("wlan0", listOf("fe80::1%wlan0", "127.0.0.1"))) + assertNull(formatNetworkAddresses("wlan0", emptyList())) + } +} diff --git a/scripts/ci/real-authentik-android.mjs b/scripts/ci/real-authentik-android.mjs new file mode 100644 index 0000000..6cb7968 --- /dev/null +++ b/scripts/ci/real-authentik-android.mjs @@ -0,0 +1,216 @@ +// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 +// Opt-in real Authentik native acceptance on ONE physical Zebra TC22 using only the disposable E2E package. +// See docs/REAL-AUTHENTIK-ACCEPTANCE.md. Never touches the normal scanner package or its data, reverse tcp:8080, any +// reverse mapping it did not add, or any trust store; never prints the serial, config, cookies or URLs with queries. +import { spawn } from 'node:child_process'; +import { access, readFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve, isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const E2E_PACKAGE = 'io.qrun.mobile.scanner.e2e'; +export const TEST_PACKAGE = `${E2E_PACKAGE}.test`; +export const TEST_CLASS = 'io.qrun.mobile.scanner.RealAuthentikAcceptanceTest'; +export const REVERSE_PORTS = ['18443', '19443']; +export const QQQ_ORIGIN = 'https://localhost:18443'; +const INSTRUMENT_MS = 40 * 60_000; + +/** Operator-facing text for the fixed stage names the instrumentation emits; nothing dynamic is ever printed. */ +export const STAGES = { + 'login-screen-verified': 'Browser login offered, no password fallback.', + 'browser-A-requested': 'Opening external browser for operator A.', + 'browser-A-operator-enter-credentials': 'ACTION: in the browser sign in as scanner-a (type the credentials yourself).', + 'callback-exchange-A-complete': 'Callback and QQQ exchange completed for A.', + 'device-pending-A': 'Device is pending approval (expected).', + 'operator-A-verified': 'Principal A verified, session ACTIVE.', + 'company-signout-requested': 'Company sign-out requested.', + 'company-signout-operator-confirm-if-asked': 'ACTION: confirm provider logout in the browser if it asks.', + 'company-signout-verified': 'Company sign-out reported complete.', + 'operator-A-qqq-session-ended': 'QQQ session for A ended.', + 'browser-B-requested': 'Opening external browser for operator B.', + 'browser-B-operator-enter-credentials': 'ACTION: the provider must ask for credentials; sign in as scanner-b.', + 'callback-exchange-B-complete': 'Callback and QQQ exchange completed for B.', + 'operator-B-verified': 'Principal B verified, distinct session ACTIVE.', +}; + +/******************************************************************************* + ** Admit only the exact fixture descriptor, before any adb call. Values are never echoed in errors. + *******************************************************************************/ +export function validateConfig(config) { + const text = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\r\n\u0000]/.test(value); + if (config?.fixtureType !== 'real-authentik-android' || config.qqqOrigin !== QQQ_ORIGIN) throw new Error('Config must describe the real-authentik-android fixture on its exact HTTPS origin.'); + if (!text(config.adminCookie, 4096)) throw new Error('Config needs the administrator session cookie header value.'); + const a = config.operators?.A?.display, b = config.operators?.B?.display; + if (!text(a, 200) || !text(b, 200) || a === b) throw new Error('Config needs distinct display names for operators A and B.'); + for (const key of ['A', 'B']) { + const actor = config.operators[key].actor; + if (actor !== undefined && !text(actor, 200)) throw new Error('Operator actor values must be short single-line text.'); + } + if (config.sessionActorField !== undefined && !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(config.sessionActorField)) throw new Error('sessionActorField must be a column name.'); + return config; +} + +/******************************************************************************* + ** The config holds a live administrator session, so it must never sit inside a repository checkout. + *******************************************************************************/ +export function configOutsideRepository(configPath, repository) { + const relativePath = relative(resolve(repository), resolve(configPath)); + return relativePath.startsWith('..') || isAbsolute(relativePath); +} + +/******************************************************************************* + ** Only the physical Zebra TC22 is a valid target; an emulator or any other device is refused. + *******************************************************************************/ +export function validateTarget(serial, properties) { + if (!serial || !/^[A-Za-z0-9._:-]+$/.test(serial) || serial.startsWith('emulator-')) throw new Error('ANDROID_SERIAL must name the physical TC22 (emulators are prohibited).'); + if (properties.qemu === '1' || !/^zebra/i.test(properties.manufacturer ?? '') || !/^TC22/i.test(properties.model ?? '')) throw new Error('The selected device is not the physical Zebra TC22.'); +} + +/******************************************************************************* + ** Disposable packages are never replaced or reused; the normal package is not consulted. + *******************************************************************************/ +export function refuseExistingDisposablePackages(installedLines) { + const installed = new Set(installedLines.map(line => line.trim())); + for (const pkg of [E2E_PACKAGE, TEST_PACKAGE]) { + if (installed.has(`package:${pkg}`)) throw new Error(`${pkg} already exists. Remove it deliberately before this run; it will not be replaced.`); + } +} + +/******************************************************************************* + ** Parse `adb reverse --list` into "tcp:A tcp:B" mappings (the device-name column is ignored). + *******************************************************************************/ +export function parseReverseList(output) { + return output.split(/\r?\n/).map(line => line.trim().split(/\s+/)).filter(parts => parts.length >= 3).map(parts => `${parts[parts.length - 2]} ${parts[parts.length - 1]}`); +} + +/******************************************************************************* + ** Decide which of the two fixture ports to add. A pre-existing identical mapping is left in place and not owned; + ** a differing one is refused rather than changed. Any other mapping (including 8080) is never considered. + *******************************************************************************/ +export function planReverse(existing) { + const add = []; + for (const port of REVERSE_PORTS) { + const mappings = existing.filter(mapping => mapping.startsWith(`tcp:${port} `)); + if (mappings.length === 0) add.push(port); + else if (mappings.some(mapping => mapping !== `tcp:${port} tcp:${port}`)) throw new Error(`Existing reverse mapping for ${port} differs; refusing to change it.`); + } + return add; +} + +/******************************************************************************* + ** Classify one instrumentation output line: a fixed stage, the final verdict, a safe failure sentence, or nothing. + ** Anything URL-like or query-like is dropped, so no callback, token or credential can be printed. + *******************************************************************************/ +export function classifyLine(line) { + const stage = /^INSTRUMENTATION_STATUS: qqqStage=([A-Za-z0-9-]+)$/.exec(line); + if (stage) return { stage: stage[1], text: STAGES[stage[1]] ?? `Stage ${stage[1]}` }; + if (/^(OK \(\d+ tests?\)|FAILURES!!!|Tests run:.*)$/.test(line)) return { verdict: line }; + const assertion = /AssertionError: ([^\r\n]{1,160})/.exec(line); + if (assertion && !/[?&=]|https?:/i.test(assertion[1])) return { failure: assertion[1] }; + return {}; +} + +/******************************************************************************* + ** The instrumentation command line the launcher runs; fixed, with no value from the config. + *******************************************************************************/ +export function instrumentArguments() { + return ['shell', 'am', 'instrument', '-w', '-r', '-e', 'realAuthentik', 'true', '-e', 'class', TEST_CLASS, `${TEST_PACKAGE}/androidx.test.runner.AndroidJUnitRunner`]; +} + +/******************************************************************************* + ** Run adb for the selected device without a shell; never echo its output. + *******************************************************************************/ +function adb(adbPath, serial, argv, { input, timeout = 120_000, onLine } = {}) { + return new Promise((done, reject) => { + const child = spawn(adbPath, ['-s', serial, ...argv], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + let out = '', pending = ''; + const timer = setTimeout(() => { child.kill(); reject(new Error('adb operation timed out')); }, timeout); + child.stdout.on('data', chunk => { + out += chunk; + if (onLine) { pending += chunk; const lines = pending.split(/\r?\n/); pending = lines.pop(); lines.forEach(onLine); } + }); + child.stderr.on('data', () => {}); + child.on('error', () => { clearTimeout(timer); reject(new Error('adb could not start')); }); + child.on('close', code => { clearTimeout(timer); code === 0 ? done(out) : reject(new Error(`adb exited ${code}`)); }); + child.stdin.end(input ?? ''); + }); +} + +/******************************************************************************* + ** Orchestrate one run. Cleanup removes only what this run added: the config file, the two disposable packages + ** and the reverse mappings it created. The operator's trust store is never touched. + *******************************************************************************/ +async function main(args, environment) { + const option = name => { const index = args.indexOf(name); return index < 0 ? undefined : args[index + 1]; }; + const repository = resolve(option('--repo') ?? join(dirname(fileURLToPath(import.meta.url)), '../..')); + const serial = environment.ANDROID_SERIAL; + const sdk = environment.ANDROID_HOME ?? environment.ANDROID_SDK_ROOT; + const adbPath = sdk ? join(sdk, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb') : 'adb'; + const say = text => console.log(String(text).replaceAll(serial ?? '\u0000', '<DEVICE>')); + + if (!args.includes('--confirm-temporary-ca-installed')) throw new Error('Install the temporary test CA through Android settings, then pass --confirm-temporary-ca-installed.'); + const configPath = option('--config') ?? (() => { throw new Error('--config <owner-private json> is required.'); })(); + if (!configOutsideRepository(configPath, repository)) throw new Error('Keep the config outside the repository checkout.'); + const configText = await readFile(configPath, 'utf8'); + let config; + try { config = JSON.parse(configText); } catch { throw new Error('Config is not valid JSON.'); } + validateConfig(config); + const apks = [ + join(repository, 'scanner-host/build/outputs/apk/e2e/scanner-host-e2e.apk'), + join(repository, 'scanner-host/build/outputs/apk/androidTest/e2e/scanner-host-e2e-androidTest.apk'), + ]; + for (const apk of apks) await access(apk).catch(() => { throw new Error('Build :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest first.'); }); + + if (!serial || !/^[A-Za-z0-9._:-]+$/.test(serial) || serial.startsWith('emulator-')) throw new Error('ANDROID_SERIAL must name the physical TC22 (emulators are prohibited).'); + const property = async name => (await adb(adbPath, serial, ['shell', 'getprop', name])).trim(); + validateTarget(serial, { qemu: await property('ro.kernel.qemu'), manufacturer: await property('ro.product.manufacturer'), model: await property('ro.product.model') }); + say(`Device: physical TC22, Android ${await property('ro.build.version.release')}`); + + const installed = (await adb(adbPath, serial, ['shell', 'pm', 'list', 'packages', '-u'])).split(/\r?\n/); + refuseExistingDisposablePackages(installed); + say('Disposable packages are absent; the normal scanner app is neither read nor changed.'); + + const reverseList = async () => parseReverseList(await adb(adbPath, serial, ['reverse', '--list'])); + const before = await reverseList(); + const owned = planReverse(before); + const installedHere = []; + let configPushed = false; + try { + for (const port of owned) await adb(adbPath, serial, ['reverse', '--no-rebind', `tcp:${port}`, `tcp:${port}`]); + say(`Reverse mappings added by this run: ${owned.length ? owned.join(', ') : 'none (already present, left in place)'}.`); + for (const [apk, pkg] of [[apks[0], E2E_PACKAGE], [apks[1], TEST_PACKAGE]]) { + await adb(adbPath, serial, ['install', '--no-streaming', apk], { timeout: 300_000 }); + installedHere.push(pkg); + } + await adb(adbPath, serial, ['shell', 'run-as', E2E_PACKAGE, 'mkdir', '-p', 'files']); + await adb(adbPath, serial, ['shell', 'run-as', E2E_PACKAGE, 'sh', '-c', "'cat > files/real-auth.json'"], { input: configText }); + configPushed = true; + + say('Starting. Keep the TC22 unlocked and awake; follow the ACTION lines.'); + let verdict = '', failure = ''; + await adb(adbPath, serial, instrumentArguments(), { + timeout: INSTRUMENT_MS, + onLine: line => { + const result = classifyLine(line); + if (result.stage) say(result.text); + else if (result.verdict) verdict = result.verdict; + else if (result.failure && !failure) failure = result.failure; + }, + }); + if (!/^OK \(/.test(verdict)) throw new Error(`Acceptance did not pass${failure ? `: ${failure}` : '.'}`); + say(`PASS: ${verdict}`); + } finally { + if (configPushed) await adb(adbPath, serial, ['shell', 'run-as', E2E_PACKAGE, 'rm', '-f', 'files/real-auth.json']).catch(() => say('WARNING: could not remove the pushed config; uninstalling the package removes it.')); + for (const pkg of [...installedHere].reverse()) await adb(adbPath, serial, ['uninstall', pkg]).catch(() => say(`WARNING: could not uninstall ${pkg}.`)); + for (const port of owned) await adb(adbPath, serial, ['reverse', '--remove', `tcp:${port}`]).catch(() => say(`WARNING: could not remove reverse ${port}.`)); + const after = await reverseList().catch(() => null); + if (after && JSON.stringify([...after].sort()) !== JSON.stringify([...before].sort())) say('WARNING: reverse mappings differ from the starting set; review manually.'); + say('Reminder: remove the temporary test CA through Android settings when finished.'); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2), process.env).catch(error => { + console.error(`FAILED: ${String(error.message).replaceAll(process.env.ANDROID_SERIAL ?? '\u0000', '<DEVICE>')}`); + process.exitCode = 1; + }); +} diff --git a/scripts/ci/real-authentik-android.test.mjs b/scripts/ci/real-authentik-android.test.mjs new file mode 100644 index 0000000..5c06010 --- /dev/null +++ b/scripts/ci/real-authentik-android.test.mjs @@ -0,0 +1,99 @@ +// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + classifyLine, configOutsideRepository, instrumentArguments, parseReverseList, planReverse, refuseExistingDisposablePackages, + validateConfig, validateTarget, +} from './real-authentik-android.mjs'; + +const config = () => ({ + fixtureType: 'real-authentik-android', qqqOrigin: 'https://localhost:18443', adminCookie: 'synthetic-cookie-value', + operators: { A: { display: 'Operator A' }, B: { display: 'Operator B' } }, +}); + +/******************************************************************************* + ** A wrong origin, fixture type or identical operators must stop the run before any adb call. + *******************************************************************************/ +test('config is admitted only for the exact fixture origin with distinct operators', () => { + assert.doesNotThrow(() => validateConfig(config())); + for (const bad of [ + { ...config(), qqqOrigin: 'https://localhost:18443/' }, { ...config(), qqqOrigin: 'http://localhost:18443' }, + { ...config(), qqqOrigin: 'https://127.0.0.1:18443' }, { ...config(), fixtureType: 'echo-ui-e2e' }, { ...config(), adminCookie: '' }, + { ...config(), adminCookie: 'a\nb' }, { ...config(), operators: { A: { display: 'Same' }, B: { display: 'Same' } } }, + { ...config(), sessionActorField: 'a; DROP' }, + ]) assert.throws(() => validateConfig(bad)); +}); + +/******************************************************************************* + ** Errors must not echo the secret they reject. + *******************************************************************************/ +test('a rejected config never leaks the cookie into the error', () => { + const bad = { ...config(), adminCookie: 'secret-cookie\nvalue' }; + assert.throws(() => validateConfig(bad), error => !error.message.includes('secret-cookie')); +}); + +/******************************************************************************* + ** The config holds a live administrator session; a checkout path must be refused. + *******************************************************************************/ +test('config inside the repository is refused, outside is allowed', () => { + assert.equal(configOutsideRepository('/work/repo/scripts/config.json', '/work/repo'), false); + assert.equal(configOutsideRepository('/work/repo/config.json', '/work/repo'), false); + assert.equal(configOutsideRepository('/work/private/config.json', '/work/repo'), true); + assert.equal(configOutsideRepository('/work/repository-other/config.json', '/work/repo'), true); +}); + +/******************************************************************************* + ** Only the physical Zebra TC22 may be targeted. + *******************************************************************************/ +test('emulators and other devices are refused as targets', () => { + assert.doesNotThrow(() => validateTarget('SYNTHETIC_SERIAL', { qemu: '', manufacturer: 'Zebra Technologies', model: 'TC22' })); + assert.throws(() => validateTarget('emulator-5554', { qemu: '1', manufacturer: 'Google', model: 'sdk' })); + assert.throws(() => validateTarget('SYNTHETIC_SERIAL', { qemu: '1', manufacturer: 'Zebra', model: 'TC22' })); + assert.throws(() => validateTarget('SYNTHETIC_SERIAL', { qemu: '', manufacturer: 'Samsung', model: 'TC22' })); + assert.throws(() => validateTarget('SYNTHETIC_SERIAL', { qemu: '', manufacturer: 'Zebra', model: 'TC27' })); + assert.throws(() => validateTarget('bad serial; rm', { qemu: '', manufacturer: 'Zebra', model: 'TC22' })); + assert.throws(() => validateTarget(undefined, { qemu: '', manufacturer: 'Zebra', model: 'TC22' })); +}); + +/******************************************************************************* + ** An existing disposable package could hold another run's state; it is never replaced. + *******************************************************************************/ +test('existing disposable packages are refused and the normal package is irrelevant', () => { + assert.doesNotThrow(() => refuseExistingDisposablePackages(['package:io.qrun.mobile.scanner', 'package:com.android.chrome'])); + assert.throws(() => refuseExistingDisposablePackages(['package:io.qrun.mobile.scanner.e2e'])); + assert.throws(() => refuseExistingDisposablePackages(['package:io.qrun.mobile.scanner.e2e.test'])); +}); + +/******************************************************************************* + ** Only 18443 and 19443 may ever be added; 8080 and every other mapping are invisible to the plan. + *******************************************************************************/ +test('reverse plan adds only missing fixture ports and never considers other mappings', () => { + const existing = parseReverseList('host-1 tcp:8080 tcp:57832\nhost-1 tcp:18081 tcp:18081\n'); + assert.deepEqual(planReverse(existing), ['18443', '19443']); + assert.deepEqual(planReverse(parseReverseList('host-1 tcp:18443 tcp:18443\n')), ['19443']); + assert.deepEqual(planReverse(parseReverseList('host-1 tcp:18443 tcp:18443\nhost-1 tcp:19443 tcp:19443\n')), []); + assert.throws(() => planReverse(parseReverseList('host-1 tcp:19443 tcp:9999\n'))); + assert.ok(planReverse([]).every(port => ['18443', '19443'].includes(port))); +}); + +/******************************************************************************* + ** Output handling must map fixed stages and drop anything carrying a URL or query. + *******************************************************************************/ +test('instrumentation output exposes only fixed stages, the verdict and a safe failure', () => { + assert.deepEqual(classifyLine('INSTRUMENTATION_STATUS: qqqStage=operator-A-verified'), { stage: 'operator-A-verified', text: 'Principal A verified, session ACTIVE.' }); + assert.deepEqual(classifyLine('OK (1 test)'), { verdict: 'OK (1 test)' }); + assert.deepEqual(classifyLine('java.lang.AssertionError: Native sign-in did not complete'), { failure: 'Native sign-in did not complete' }); + assert.deepEqual(classifyLine('java.lang.AssertionError: bad https://host/cb?code=abc'), {}); + assert.deepEqual(classifyLine('INSTRUMENTATION_STATUS: stack=https://localhost:19443/x?code=secret'), {}); + assert.deepEqual(classifyLine('INSTRUMENTATION_STATUS: qqqStage=bad stage'), {}); +}); + +/******************************************************************************* + ** The launched class is fixed, opts in explicitly and carries nothing from the config. + *******************************************************************************/ +test('instrumentation targets only the opt-in class in the disposable test package', () => { + const joined = instrumentArguments().join(' '); + assert.match(joined, /-e realAuthentik true/); + assert.match(joined, /io\.qrun\.mobile\.scanner\.e2e\.test\/androidx\.test\.runner\.AndroidJUnitRunner$/); + assert.equal(joined.includes('cookie'), false); +}); diff --git a/scripts/ci/scanner-device-echo-e2e.mjs b/scripts/ci/scanner-device-echo-e2e.mjs new file mode 100644 index 0000000..83b01e1 --- /dev/null +++ b/scripts/ci/scanner-device-echo-e2e.mjs @@ -0,0 +1,8 @@ +// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 +// Explicit disposable native Echo acceptance; the fixture's printer/jobs are simulated. +import { runScannerE2e } from './scanner-e2e-runner.mjs'; +const args = process.argv.slice(2); +if (args.length !== 4 || args[0] !== '--serial' || args[2] !== '--fixture' || !args[1] || !args[3]) { + throw new Error('Usage: node scripts/ci/scanner-device-echo-e2e.mjs --serial TC22_SERIAL --fixture PRIVATE_ECHO_FIXTURE.json'); +} +await runScannerE2e({ physical: true, serial: args[1], scenario: 'echo', fixture: args[3] }); diff --git a/scripts/ci/scanner-e2e-runner.mjs b/scripts/ci/scanner-e2e-runner.mjs index 0935090..e352ed2 100644 --- a/scripts/ci/scanner-e2e-runner.mjs +++ b/scripts/ci/scanner-e2e-runner.mjs @@ -9,6 +9,48 @@ import { basename, delimiter, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { startSemanticApiProxy } from './semantic-api-proxy.mjs'; +/******************************************************************************* + ** Admit only the explicitly simulated private Echo fixture, before any ADB call. + *******************************************************************************/ +export function validateEchoFixtureDescriptor(fixture) { + let url; + try { url = new URL(fixture?.baseUrl); } catch { throw new Error('Echo requires its private isolated loopback fixture.'); } + if (fixture.fixtureType !== 'echo-ui-e2e' || fixture.simulatedPrinting !== true || + url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || ['57832', '51631', '18081', '18086'].includes(url.port) || + url.username || url.password || url.search || url.hash || url.pathname !== '/' || + fixture.username !== 'scanner' || fixture.adminUsername !== 'admin' || + typeof fixture.password !== 'string' || fixture.password.length < 16 || typeof fixture.adminPassword !== 'string' || fixture.adminPassword.length < 16) { + throw new Error('Echo requires fixtureType echo-ui-e2e, simulated printing and isolated private credentials; normal and printing runtimes are forbidden.'); + } + return url; +} + +/******************************************************************************* + ** An Echo descriptor cannot substitute an ordinary demo or another contract. + *******************************************************************************/ +export function validateEchoFixtureContract(version, branding) { + const extension = Array.isArray(version?.extensions) ? version.extensions.find(value => value.name === 'mobile-scanners') : null; + if (version?.apiVersion !== '1.10.0' || !['1.4.0', '1.5.0'].includes(extension?.version) || extension.pathPrefix !== '/qbit/mobile-scanners' || + branding?.displayName !== 'Echo UI E2E — simulated printing') { + throw new Error('Echo requires the simulated Echo fixture with core UI API1.10 and mobile-scanners1.4 or 1.5.'); + } +} + +/******************************************************************************* + ** Compare raw identity only internally; never return it as command evidence. + *******************************************************************************/ +export function physicalSerialMatches(expected, output) { + return typeof expected === 'string' && expected.length > 0 && typeof output === 'string' && output.trim() === expected; +} + +/******************************************************************************* + ** Strip selected device and opaque identifiers from Echo reports and errors. + *******************************************************************************/ +export function redactEchoOutput(text, serial) { + return text.replaceAll(serial, '<DEVICE>').replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<ID>') + .replace(/\b[0-9a-f]{32,64}\b/gi, '<ID>').replace(/\bscanner-[0-9a-f]{24}\b/gi, '<DEVICE>'); +} + /** Shared implementation; physical execution is exposed only by the explicit device entry point. */ export async function runScannerE2e(options = {}) { const repository = resolve(fileURLToPath(new URL('../..', import.meta.url))); @@ -19,14 +61,18 @@ export async function runScannerE2e(options = {}) { const selectedScenario = options.scenario ?? process.env.E2E_SCENARIO; const physical = options.physical === true; const deviceScreen = selectedScenario === 'device-screen'; + const echo = selectedScenario === 'echo'; + if (echo && (!physical || !options.fixture || !serial || serial.startsWith('emulator-') || !/^[A-Za-z0-9._:-]+$/.test(serial))) { + throw new Error('Echo acceptance requires an explicit TC22 serial and private Echo fixture.'); + } if (deviceScreen && (!physical || options.operatorConsent !== true || !options.fixture || !serial || !/^[A-Za-z0-9._:-]+$/.test(serial))) { throw new Error('Device-screen acceptance requires an explicit physical serial, private fixture and operator-consent opt-in.'); } const fixture = options.fixture ? JSON.parse(await readFile(options.fixture, 'utf8')) : null; if (fixture) { - const url = new URL(fixture.baseUrl); + const url = echo ? validateEchoFixtureDescriptor(fixture) : new URL(fixture.baseUrl); const purposeValid = deviceScreen ? fixture.purpose === 'device-screen-qa' && !['57832', '51631'].includes(url.port) : - selectedScenario === 'photos' && fixture.processName === 'mobileRecordPhotos' && fixture.tableName === 'document' && String(fixture.recordId) === '1'; + echo || selectedScenario === 'photos' && fixture.processName === 'mobileRecordPhotos' && fixture.tableName === 'document' && String(fixture.recordId) === '1'; if (!physical || !purposeValid || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password || url.search || url.hash || url.pathname !== '/' || fixture.username !== 'scanner' || fixture.adminUsername !== 'admin' || typeof fixture.password !== 'string' || fixture.password.length < 16 || typeof fixture.adminPassword !== 'string' || fixture.adminPassword.length < 16) { @@ -41,8 +87,11 @@ export async function runScannerE2e(options = {}) { const stages = new Set(); const adminPassword = fixture?.adminPassword ?? randomBytes(24).toString('base64url'); const scannerPassword = fixture?.password ?? randomBytes(24).toString('base64url'); - const redact = (text) => text.replaceAll(adminPassword, '<REDACTED>').replaceAll(scannerPassword, '<REDACTED>'); - const run = (command, args, input) => new Promise((resolveRun, reject) => { + const redact = (text) => { + const safe = text.replaceAll(adminPassword, '<REDACTED>').replaceAll(scannerPassword, '<REDACTED>'); + return echo ? redactEchoOutput(safe, serial) : safe; + }; + const run = (command, args, input, checkOutput) => new Promise((resolveRun, reject) => { const child = spawn(command, args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); // Native Compose instrumentation includes emulator startup and transport shutdown after the test finishes. const timeout = args.includes('instrument') ? (deviceScreen ? 720_000 : 300_000) : 180_000; @@ -68,7 +117,7 @@ export async function runScannerE2e(options = {}) { child.on('error', (error) => { clearTimeout(deadline); reject(error); }); child.on('close', (code) => { clearTimeout(deadline); - if (code === 0) resolveRun(redact(output)); else reject(new Error(redact(`${command} failed (${code}): ${output}`))); + if (code === 0) resolveRun(checkOutput ? checkOutput(output) === true : redact(output)); else reject(new Error(redact(`${command} failed (${code}): ${output}`))); }); child.stdin.end(input); }); @@ -76,14 +125,15 @@ export async function runScannerE2e(options = {}) { const grantE2eLocationPermissions = async () => { // These scenarios exercise scanner/navigation behavior, not the Android permission dialog. // Never grant permissions to the operator's normal app or the separate consent-based QA app. - if (packageName !== 'io.qrun.mobile.scanner.e2e') return; + if (packageName !== 'io.qrun.mobile.scanner.e2e' || echo) return; await adbRun('shell', 'pm', 'grant', packageName, 'android.permission.ACCESS_COARSE_LOCATION'); await adbRun('shell', 'pm', 'grant', packageName, 'android.permission.ACCESS_FINE_LOCATION'); }; const emulated = (await adbRun('shell', 'getprop', 'ro.kernel.qemu')).trim() === '1'; if (physical) { - if (!serial || serial.startsWith('emulator-') || emulated || (await adbRun('get-serialno')).trim() !== serial) { + if (!serial || serial.startsWith('emulator-') || emulated || + !await run(adb, [...adbPrefix, 'get-serialno'], undefined, output => physicalSerialMatches(serial, output))) { throw new Error('Physical acceptance requires an explicitly selected non-emulator serial.'); } const manufacturer = (await adbRun('shell', 'getprop', 'ro.product.manufacturer')).trim(); @@ -114,7 +164,7 @@ export async function runScannerE2e(options = {}) { } console.log(`Native ${scenario} acceptance passed (local UI, no server).`); }; - const allowedScenarios = [...(physical ? ['navigation', 'photos', 'device-screen'] : []), ...Object.keys(localScenarios), 'pixel-copy', 'unsupported-api', 'missing-extension', 'missing-channel', 'compatible', 'additive', 'admin-forms', 'session-monitor']; + const allowedScenarios = [...(physical ? ['navigation', 'photos', 'device-screen', 'echo'] : []), ...Object.keys(localScenarios), 'pixel-copy', 'unsupported-api', 'missing-extension', 'missing-channel', 'compatible', 'additive', 'admin-forms', 'session-monitor']; if (selectedScenario && !allowedScenarios.includes(selectedScenario)) throw new Error('Unknown native E2E scenario.'); if (Object.hasOwn(localScenarios, selectedScenario)) { const temporary = []; @@ -172,6 +222,8 @@ export async function runScannerE2e(options = {}) { let reverseAdded = false; let screenEvidence; let screenArtifacts; + let echoEvidence; + let echoArtifacts; server?.on('error', (error) => { serverError = error; }); try { for (const setting of physical ? [] : ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale']) { @@ -192,22 +244,34 @@ export async function runScannerE2e(options = {}) { proxy = await startSemanticApiProxy(origin); const apkPaths = [join(repository, `scanner-host/build/outputs/apk/${variant}/scanner-host-${variant}.apk`), join(repository, `scanner-host/build/outputs/apk/androidTest/${variant}/scanner-host-${variant}-androidTest.apk`)]; - if (deviceScreen) { + if (deviceScreen || echo) { if (!sdk) throw new Error('ANDROID_HOME is required for APK identity verification.'); const tool = join(sdk, 'build-tools', '35.0.0', process.platform === 'win32' ? 'aapt2.exe' : 'aapt2'); for (const [index, apk] of apkPaths.entries()) { const actual = /^package: name='([^']+)'/m.exec(await run(tool, ['dump', 'badging', apk]))?.[1]; - if (actual !== packageName + (index ? '.test' : '')) throw new Error('Refusing an APK outside the disposable QA target.'); + if (actual !== packageName + (index ? '.test' : '')) throw new Error('Refusing an APK outside the exact disposable acceptance target.'); } const version = await fetch(`${origin}/qqq/v1/version`, { signal: AbortSignal.timeout(10_000) }).then(response => { if (!response.ok) throw new Error('Fixture discovery failed.'); return response.json(); }); const extension = version.extensions?.find(value => value.name === 'mobile-scanners'); - if (extension?.version !== '1.2.0' || extension.pathPrefix !== '/qbit/mobile-scanners') throw new Error('Device-screen fixture must serve the qualified mobile-scanners1.2 contract.'); - screenArtifacts = await Promise.all(apkPaths.map(async (apk, index) => ({ applicationId: packageName + (index ? '.test' : ''), sha256: createHash('sha256').update(await readFile(apk)).digest('hex') }))); - screenEvidence = join(repository, `scanner-host/build/native-device-screen-${Date.now()}`); - await mkdir(screenEvidence, { recursive: true }); - await writeFile(join(screenEvidence, 'proof.json'), JSON.stringify({ status: 'PREPARING', artifacts: screenArtifacts }, null, 2)); + if (deviceScreen) { + if (extension?.version !== '1.2.0' || extension.pathPrefix !== '/qbit/mobile-scanners') throw new Error('Device-screen fixture must serve the qualified mobile-scanners1.2 contract.'); + screenArtifacts = await Promise.all(apkPaths.map(async (apk, index) => ({ applicationId: packageName + (index ? '.test' : ''), sha256: createHash('sha256').update(await readFile(apk)).digest('hex') }))); + screenEvidence = join(repository, `scanner-host/build/native-device-screen-${Date.now()}`); + await mkdir(screenEvidence, { recursive: true }); + await writeFile(join(screenEvidence, 'proof.json'), JSON.stringify({ status: 'PREPARING', artifacts: screenArtifacts }, null, 2)); + } else { + const branding = await fetch(`${origin}/qqq/v1/qbit/mobile-scanners/branding`, { signal: AbortSignal.timeout(10_000) }).then(response => { + if (!response.ok) throw new Error('Echo fixture branding discovery failed.'); return response.json(); + }); + validateEchoFixtureContract(version, branding); + if (['57832', '51631', '18081', '18086'].includes(String(proxy.port))) throw new Error('Refusing a protected reverse port.'); + echoArtifacts = await Promise.all(apkPaths.map(async (apk, index) => ({ applicationId: packageName + (index ? '.test' : ''), sha256: createHash('sha256').update(await readFile(apk)).digest('hex') }))); + echoEvidence = join(repository, 'scanner-host/build/native-device-e2e'); + await mkdir(echoEvidence, { recursive: true }); + await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({ status: 'PREPARING', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts }, null, 2)); + } } await adbRun('reverse', '--no-rebind', `tcp:${proxy.port}`, `tcp:${proxy.port}`); reverseAdded = true; @@ -260,13 +324,20 @@ export async function runScannerE2e(options = {}) { { mode: 'compatible', name: 'admin-forms', test: 'adminFormsApproveRevokeAndCancel', requireCancel: true }, { mode: 'compatible', name: 'session-monitor', test: 'sessionActivityMirrorsActualScreenAndAdminEndForcesFreshLogin', requireSession: true }, ...(fixture ? [{ mode: 'compatible', name: 'photos', test: 'cameraAutoUploadsAndPendingGallerySurvivesRotationThenCleansOnRemoteLogout', requirePhotos: true }] : []), + ...(echo ? [{ mode: 'compatible', name: 'echo', test: 'echoSelectsOnceQueuesTwoScansAndClosesOnAppsLeave', requireCancel: true }] : []), ]) { if (selectedScenario && selectedScenario !== (phase.name ?? phase.mode)) continue; proxy.setMode(phase.mode); await adbRun('shell', 'pm', 'clear', packageName); // Clearing disposable app data also resets runtime permissions on fresh Android images. - if ((phase.name ?? phase.mode) === 'session-monitor' && packageName === 'io.qrun.mobile.scanner.e2e') { + if (echo) { + // Echo does not qualify GPS: keep reporting off only in this new disposable app. + await adbRun('shell', 'run-as', packageName, 'mkdir', '-p', 'shared_prefs'); + await run(adb, [...adbPrefix, 'shell', 'run-as', packageName, 'sh', '-c', "'cat > shared_prefs/host.xml'"], + '<?xml version="1.0" encoding="utf-8" standalone="yes" ?><map><boolean name="locationReportingEnabled" value="false" /><boolean name="locationPermissionRequested" value="true" /></map>'); + } else if ((phase.name ?? phase.mode) === 'session-monitor') { // Preserve this scenario's real denied-location telemetry assertion without an OS dialog. + // Package selection already limits this to the disposable e2e or physical qa application. await adbRun('shell', 'pm', 'revoke', packageName, 'android.permission.ACCESS_FINE_LOCATION'); await adbRun('shell', 'pm', 'revoke', packageName, 'android.permission.ACCESS_COARSE_LOCATION'); await adbRun('shell', 'run-as', packageName, 'mkdir', '-p', 'shared_prefs'); @@ -277,7 +348,8 @@ export async function runScannerE2e(options = {}) { } await adbRun('shell', 'run-as', packageName, 'mkdir', '-p', 'files'); await run(adb, [...adbPrefix, 'shell', 'run-as', packageName, 'sh', '-c', "'cat > files/e2e.json'"], - JSON.stringify({ serverUrl: `http://127.0.0.1:${proxy.port}/`, adminPassword, scannerPassword, expectedError: phase.expectedError })); + JSON.stringify({ serverUrl: `http://127.0.0.1:${proxy.port}/`, adminPassword, scannerPassword, expectedError: phase.expectedError, + ...(echo ? { fixtureType: 'echo-ui-e2e', simulatedPrinting: true } : {}) })); const test = phase.test ?? (phase.expectedError ? 'incompatibleServerStopsBeforeLogin' : 'loginEnrollApproveCaptureAndRevoke'); const output = await adbRun('shell', 'am', 'instrument', '-w', '-r', '-e', 'class', `io.qrun.mobile.scanner.ScannerEndToEndTest#${test}`, @@ -288,29 +360,40 @@ export async function runScannerE2e(options = {}) { throw new Error(`Native scanner E2E (${phase.mode}) failed:\n${output}`); } reports.push({ scenario: phase.name ?? phase.mode, ...proxy.verify(!phase.expectedError, Boolean(phase.requireCancel), Boolean(phase.requireSession), Boolean(phase.requirePhotos)) }); + if (echo) { + const expectedStages = ['echo-copies-defaulted', 'echo-document-selected', 'echo-stale-document-cleared', 'echo-copies-selected', 'echo-setup-selected', 'echo-first-queued', 'echo-second-queued', 'echo-recreation-stable', 'echo-copies-persisted', 'echo-run-closed']; + if (expectedStages.some(stage => !stages.has(stage))) throw new Error('Echo did not complete every setup/queue/recreation/cancellation stage.'); + await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({ status: 'PASSED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, + artifacts: echoArtifacts, completedStages: expectedStages, acceptedScans: 2, ...reports.at(-1), physicalScannerQualified: false, physicalPrintingQualified: false }, null, 2)); + } console.log(`Native semantic E2E passed: ${phase.name ?? phase.mode}.`); } await writeFile(join(evidence, 'semantic-contract.json'), `${JSON.stringify(reports, null, 2)}\n`); console.log('All native requests in the selected scenarios matched the published semantic API.'); } catch (error) { + if (echo && echoEvidence) await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({ + status: 'FAILED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts, completedStages: [...stages], + }, null, 2)); if (deviceScreen && screenEvidence) await writeFile(join(screenEvidence, 'proof.json'), JSON.stringify({ status: 'FAILED', artifacts: screenArtifacts, completedStages: [...stages], physicalDisplayVerified: false, }, null, 2)); throw error; } finally { const screenCleanupFailures = []; + if (echo && echoEvidence && proxy) await writeFile(join(echoEvidence, 'echo-operations.json'), JSON.stringify(proxy.evidence(), null, 2)) + .catch(() => { screenCleanupFailures.push('Echo semantic evidence'); }); if (deviceScreen && screenEvidence && proxy) await writeFile(join(screenEvidence, 'operations.json'), JSON.stringify(proxy.evidence(), null, 2)) .catch(() => { screenCleanupFailures.push('semantic evidence'); }); for (const [setting, previous] of animationScales) { if (previous === 'null') await adbRun('shell', 'settings', 'delete', 'global', setting).catch(() => {}); else await adbRun('shell', 'settings', 'put', 'global', setting, previous).catch(() => {}); } - if (installedByRun.has(packageName)) await adbRun('shell', 'run-as', packageName, 'rm', '-f', 'files/e2e.json').catch(() => {}); + if (installedByRun.has(packageName)) await adbRun('shell', 'run-as', packageName, 'rm', '-f', 'files/e2e.json').catch(() => { if (echo) screenCleanupFailures.push('Echo credential file'); }); if (deviceScreen && installedByRun.has(packageName)) await adbRun('shell', 'run-as', packageName, 'rm', '-f', 'files/qa-screen.json') .catch(() => { screenCleanupFailures.push('QA credential file'); }); - for (const installed of [...installedByRun].reverse()) await adbRun('uninstall', installed).catch(() => { if (deviceScreen) screenCleanupFailures.push('QA package'); }); + for (const installed of [...installedByRun].reverse()) await adbRun('uninstall', installed).catch(() => { if (deviceScreen || echo) screenCleanupFailures.push('disposable package'); }); if (proxy) { - if (reverseAdded) await adbRun('reverse', '--remove', `tcp:${proxy.port}`).catch(() => { if (deviceScreen) screenCleanupFailures.push('owned reverse'); }); + if (reverseAdded) await adbRun('reverse', '--remove', `tcp:${proxy.port}`).catch(() => { if (deviceScreen || echo) screenCleanupFailures.push('owned reverse'); }); await proxy.close(); } const stopped = new Promise((done) => { if (!server || server.exitCode !== null || serverError) done(); else server.once('exit', done); }); @@ -323,7 +406,13 @@ export async function runScannerE2e(options = {}) { throw new Error('Refusing cleanup outside the isolated demo temporary directory.'); } if (directory) await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); - if (screenCleanupFailures.length) throw new Error(`QA cleanup/evidence incomplete: ${screenCleanupFailures.join(', ')}`); + if (screenCleanupFailures.length) { + if (echo && echoEvidence) await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({ + status: 'CLEANUP_FAILED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts, + completedStages: [...stages], cleanupFailures: screenCleanupFailures, + }, null, 2)); + throw new Error(`Disposable acceptance cleanup/evidence incomplete: ${screenCleanupFailures.join(', ')}`); + } } } diff --git a/scripts/ci/scanner-echo-guards.test.mjs b/scripts/ci/scanner-echo-guards.test.mjs new file mode 100644 index 0000000..1e946c7 --- /dev/null +++ b/scripts/ci/scanner-echo-guards.test.mjs @@ -0,0 +1,94 @@ +// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { runScannerE2e, validateEchoFixtureDescriptor, validateEchoFixtureContract, physicalSerialMatches, redactEchoOutput } from './scanner-e2e-runner.mjs'; + +const fixture = { + fixtureType: 'echo-ui-e2e', simulatedPrinting: true, baseUrl: 'http://127.0.0.1:49152/', + username: 'scanner', password: 'synthetic-scanner-password', adminUsername: 'admin', adminPassword: 'synthetic-admin-password', +}; +const version = { apiVersion: '1.10.0', extensions: [{ name: 'mobile-scanners', version: '1.4.0', pathPrefix: '/qbit/mobile-scanners' }] }; +const branding = { displayName: 'Echo UI E2E — simulated printing' }; + +/******************************************************************************* + ** Identity is checked before evidence redaction; only the comparison escapes. + *******************************************************************************/ +test('raw serial matches exactly and a different physical device is refused', () => { + const serial = 'SYNTHETIC_TC22_SERIAL'; + assert.equal(physicalSerialMatches(serial, `${serial}\r\n`), true); + assert.equal(physicalSerialMatches(serial, 'ANOTHER_DEVICE\r\n'), false); + assert.equal(physicalSerialMatches(serial, redactEchoOutput(`${serial}\r\n`, serial)), false); +}); + +/******************************************************************************* + ** Serial-bearing successful output and errors remain safe to persist as reports. + *******************************************************************************/ +test('Echo artifacts and error text contain no selected serial', () => { + const serial = 'SYNTHETIC_TC22_SERIAL'; + const artifact = JSON.stringify({ output: redactEchoOutput(`${serial}\nOK (1 test)`, serial), + error: redactEchoOutput(`Device ${serial} failed`, serial) }); + assert.equal(artifact.includes(serial), false); + assert.equal(artifact.includes('<DEVICE>'), true); +}); + +/******************************************************************************* + ** A valid descriptor is inspected without contacting a server or any device. + *******************************************************************************/ +test('explicit simulated Echo descriptor is accepted', () => { + assert.equal(validateEchoFixtureDescriptor(fixture).port, '49152'); + validateEchoFixtureContract(version, branding); + validateEchoFixtureContract({ ...version, extensions: [{ ...version.extensions[0], version: '1.5.0' }] }, branding); +}); + +/******************************************************************************* + ** Existing scanner and printing listeners can never become this test's target. + *******************************************************************************/ +test('all protected normal and printing ports are refused', () => { + for (const port of [57832, 51631, 18081, 18086]) { + assert.throws(() => validateEchoFixtureDescriptor({ ...fixture, baseUrl: `http://127.0.0.1:${port}/` })); + } +}); + +/******************************************************************************* + ** URL decoration cannot turn the private fixture into another host or route. + *******************************************************************************/ +test('non-private or decorated fixture origins are refused', () => { + for (const baseUrl of ['https://127.0.0.1:49152/', 'http://example.invalid:49152/', 'http://127.0.0.1/', + 'http://127.0.0.1:49152/path', 'http://127.0.0.1:49152/?value=1', 'http://127.0.0.1:49152/#fragment', + 'http://scanner:secret@127.0.0.1:49152/', 'not-a-url']) { + assert.throws(() => validateEchoFixtureDescriptor({ ...fixture, baseUrl })); + } +}); + +/******************************************************************************* + ** Purpose, simulation and private account declarations must all match exactly. + *******************************************************************************/ +test('wrong-purpose or incomplete credentials are refused', () => { + for (const changed of [{ fixtureType: 'device-screen-qa' }, { simulatedPrinting: false }, { simulatedPrinting: 'true' }, + { username: 'operator' }, { adminUsername: 'owner' }, { password: null }, { password: 'short' }, { adminPassword: 'short' }]) { + assert.throws(() => validateEchoFixtureDescriptor({ ...fixture, ...changed })); + } +}); + +/******************************************************************************* + ** Another real demo or an unqualified contract cannot masquerade as this fixture. + *******************************************************************************/ +test('contract and simulated branding must match the prepared cohort', () => { + assert.throws(() => validateEchoFixtureContract({ ...version, apiVersion: '1.9.0' }, branding)); + assert.throws(() => validateEchoFixtureContract({ ...version, extensions: [{ ...version.extensions[0], version: '1.3.0' }] }, branding)); + for (const unsupported of ['1.6.0', '1.5.1', '1.4.1', '2.0.0', '1.10.0', '1.5']) { + assert.throws(() => validateEchoFixtureContract({ ...version, extensions: [{ ...version.extensions[0], version: unsupported }] }, branding)); + } + assert.throws(() => validateEchoFixtureContract({ ...version, extensions: [{ ...version.extensions[0], version: '1.5.0', pathPrefix: '/other' }] }, branding)); + assert.throws(() => validateEchoFixtureContract(version, { displayName: 'QQQ Mobile' })); +}); + +/******************************************************************************* + ** These calls stop before reading a fixture or spawning ADB, even when selected + ** through the generic emulator entry or an incomplete physical invocation. + *******************************************************************************/ +test('Echo requires its explicit physical entry inputs before device operations', async () => { + await assert.rejects(runScannerE2e({ scenario: 'echo' }), /explicit TC22/); + await assert.rejects(runScannerE2e({ scenario: 'echo', physical: true, fixture: 'unused.json' }), /explicit TC22/); + await assert.rejects(runScannerE2e({ scenario: 'echo', physical: true, fixture: 'unused.json', serial: 'emulator-5554' }), /explicit TC22/); +});