Compare commits

...
Author SHA1 Message Date
james.maesandClaude Sonnet 5.5 9e06e1f3e9 test(scanner): verify immediate photo availability and preview
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (pull_request) Successful in 4m1s
QRun-IO/ci-policy: Shared Android CI / android-test (pull_request) Successful in 9m49s
QRun-IO/ci-policy: Shared Android CI / package (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / android-0 (pull_request) Successful in 14m34s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / secrets (pull_request) Successful in 18s
QRun-IO/ci-policy: Shared Android CI / gate (pull_request) Successful in 12s
Assert the Echo fixture's available captures and generic hosts' quarantine
behavior. Scope thumbnail and preview checks to the newly uploaded photo.

Update release and installation guidance with signed MC9400 qualification
and the remaining native authentication and publication gates.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 11:55:19 -06:00
james.maes c782aaaaba Merge mobile record browsing and photo Actions
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
Shared Android CI / unit-test (push) Successful in 5m49s
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
Shared Android CI / secrets (push) Successful in 31s
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Android CI / android-test (push) Successful in 16m2s
QRun-IO/ci-policy: Shared Android CI / android-test (push) Successful in 16m52s
QRun-IO/ci-policy: Shared Android CI / secrets (push) Successful in 26s
Shared Android CI / package (push) Successful in 5m1s
QRun-IO/ci-policy: Shared Android CI / package (push) Successful in 5m42s
Shared Android CI / gate (push) Successful in 15s
QRun Android lifecycle / release (push) Successful in 57m20s
QRun-IO/ci-policy: Shared Android CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 9s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 60e5a80ed7a5a67877bfb3e129b1929c83464b6e] (push) Successful in 11s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (push) Successful in 6m26s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Successful in 35m8s
Native table/record browsing, grouped and related records, record Actions photo attachment, saved-image previews, GS1 AIM metadata and navigation protection. Paired scanner/server review: QRun-IO/qbit-mobile-scanners#19.
2026-11-05 12:55:47 +00:00
james.maesandCodex ab6e44cddd docs: link record UX reviews and preserve archive navigation
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 60e5a80ed7a5a67877bfb3e129b1929c83464b6e] (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (pull_request) Successful in 6m5s
QRun-IO/ci-policy: Shared Android CI / android-test (pull_request) Successful in 14m58s
QRun-IO/ci-policy: Shared Android CI / package (pull_request) Successful in 18s
QRun-IO/ci-policy: ci / android-0 (pull_request) Successful in 22m11s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / secrets (pull_request) Successful in 28s
QRun-IO/ci-policy: Shared Android CI / gate (pull_request) Successful in 16s
Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-11-05 06:29:24 -06:00
james.maesandCodex 476210ae22 feat: browse mobile records and attach photos through record actions
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 60e5a80ed7a5a67877bfb3e129b1929c83464b6e] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / package (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Android CI / android-test (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Android CI / unit-test (pull_request) Canceled after 4m31s
QRun-IO/ci-policy: ci / android-0 (pull_request) Canceled after 4m31s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Android CI / secrets (pull_request) Canceled after 0s
Preserve table search, filters and paging; add grouped details, related-record tabs, formatted values and parent-scoped saved photo previews. Retain upload guards across rotation and support GS1 AIM metadata.

Validate with 232 unit tests, clean lint, isolated native semantic acceptance and actual TC22 visual checks.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-11-05 06:23:25 -06:00
james.maes 464814f922 Merge pull request 'docs: publish signed Mobile preview installation details' (#14) from docs/scanner-preview-publication into main
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
Shared Android CI / unit-test (push) Successful in 3m44s
Shared Android CI / android-test (push) Successful in 9m44s
Shared Android CI / secrets (push) Successful in 18s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: Shared Android CI / android-test (push) Successful in 14m13s
QRun-IO/ci-policy: Shared Android CI / secrets (push) Successful in 25s
Shared Android CI / package (push) Successful in 4m38s
QRun-IO/ci-policy: Shared Android CI / package (push) Successful in 4m37s
Shared Android CI / gate (push) Successful in 13s
QRun Android lifecycle / release (push) Successful in 43m20s
QRun-IO/ci-policy: Shared Android CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (push) Successful in 5m24s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Successful in 29m48s
2026-11-05 00:10:27 +00:00
james.maesandClaude Sonnet 5.5 c555d9a9ec docs: publish signed mobile preview installation details
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: Shared Android CI / unit-test (pull_request) Successful in 3m58s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / android-test (pull_request) Successful in 9m38s
QRun-IO/ci-policy: Shared Android CI / package (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / android-0 (pull_request) Successful in 14m21s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / secrets (pull_request) Successful in 17s
QRun-IO/ci-policy: Shared Android CI / gate (pull_request) Successful in 11s
Record the byte-verified Gitea preview assets while retaining the outstanding production HTTPS qualification.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 17:53:49 -06:00
44 changed files with 1542 additions and 129 deletions

No files matched your search

+1 -1
View File
@@ -59,7 +59,7 @@ See [emulator testing](docs/EMULATOR.md) for an emulator demo, synthetic scans a
### Signed APK (direct install / MDM) ### Signed APK (direct install / MDM)
For real devices. Requires an **HTTPS** server URL and the production-signed APK built with the QRun-owned key. Follow the [installation guide](docs/INSTALLATION.md) and [release readiness](docs/RELEASING.md). A signed 1.0.0 (versionCode 2) APK exists and was installed and launched on one MC9400, but it has not been qualified end to end, distributed or published, and no release exists. The development-signed and production-signed builds cannot update each other in place. Requires an **HTTPS** server URL and the production-signed APK built with the QRun-owned key. Follow the [installation guide](docs/INSTALLATION.md) and [release readiness](docs/RELEASING.md). The published download remains the [signed 1.0.0 preview](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease, forge sign-in required). The signed 1.0.1 candidate has passed trusted-HTTPS enrollment, physical scanning, navigation and an in-place update on the MC9400, preserving login and enrollment. Its publication, native Authentik qualification and final dependency-cohort checks remain open. Development-signed installations require an explicit migration to the production signer.
## Build and test ## Build and test
+3 -3
View File
@@ -1,6 +1,6 @@
# Finish ledger for 1.0 # Finish ledger for 1.0
As of 2026-11-04. This is not a release and no 1.0 is published. Older ledgers are in [docs/history](history/README.md); the checklist is [TODO](TODO.md). As of 2026-11-04. A signed 1.0.0 preview is published (prerelease `scanner-v1.0.0`); there is no stable or fully qualified 1.0. Older ledgers are in [docs/history](history/README.md); the checklist is [TODO](TODO.md).
| Area | State | Evidence / limit | | Area | State | Evidence / limit |
| --- | --- | --- | | --- | --- | --- |
@@ -8,8 +8,8 @@ As of 2026-11-04. This is not a release and no 1.0 is published. Older ledgers a
| Local demo | Accepted | Server on port 57832; TC22 development APK; operator-reported manual acceptance ALL PASS 2026-11-04 incl. label count/fidelity, camera, GPS, screen viewing (not instrumented, no per-case artifacts) | | Local demo | Accepted | Server on port 57832; TC22 development APK; operator-reported manual acceptance ALL PASS 2026-11-04 incl. label count/fidelity, camera, GPS, screen viewing (not instrumented, no per-case artifacts) |
| Echo | Local semantic flow qualified | Printing and inputs simulated in disposable runs; physical label count/fidelity operator-reported pass (not instrumented) | | Echo | Local semantic flow qualified | Printing and inputs simulated in disposable runs; physical label count/fidelity operator-reported pass (not instrumented) |
| MC9400 | Demo-configured and approved | Physical trigger scan to backend not yet verified | | MC9400 | Demo-configured and approved | Physical trigger scan to backend not yet verified |
| Signed APK | Built and verified locally, not distributed | 1.0.0 / versionCode 2; installed and launched once on an MC9400 before the demo switch; no end-to-end qualification | | Signed APK | Published as signed preview | [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), 1.0.0 / versionCode 2; APK and `SHA256SUMS` only, download-verified; installed and launched once on an MC9400 before the demo switch; no end-to-end qualification |
| Release | Not done | Needs HTTPS server, device qualification and distribution; no tag, no publication, no signing CI | | Release | Preview only, not stable | Needs HTTPS server and device qualification (enrollment, scanning, in-place update) before stable; no signing CI |
| Authentik | Desktop passes; on-device callback unverified | No separate native provider/callback evidence | | Authentik | Desktop passes; on-device callback unverified | No separate native provider/callback evidence |
| Dependencies | Qualified inputs, not a public cohort | Generation, Record Files, Scripting owner inputs behind the local server | | Dependencies | Qualified inputs, not a public cohort | Generation, Record Files, Scripting owner inputs behind the local server |
| MDM / company profile | Prepared, unverified | Nothing applied on a device | | MDM / company profile | Prepared, unverified | Nothing applied on a device |
+19 -7
View File
@@ -4,26 +4,38 @@ Use this for the **production-signed** APK on a small number of devices. Move to
This guide is not the local demo. The demo uses the development-signed debug APK, plain HTTP and `adb reverse` port forwarding to a workstation server; see [README](../README.md#installing-on-a-device). The two builds have different signers and cannot update each other. This guide is not the local demo. The demo uses the development-signed debug APK, plain HTTP and `adb reverse` port forwarding to a workstation server; see [README](../README.md#installing-on-a-device). The two builds have different signers and cannot update each other.
**Current release status (November 5, 2026):** the only published release is the signed preview 1.0.0 below. A signed **1.0.1 (versionCode 3) candidate** exists but is **not published and has no download link**; this guide does not point to one. Use the table below for what is actually hosted. When 1.0.1 is published, use the file, checksum and link given by the release owner, and verify the same signer.
| Candidate (not published) | Value |
| --- | --- |
| Version | 1.0.1, versionCode 3 |
| APK SHA-256 | `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F` (15015419 bytes) |
| Signing certificate SHA-256 | Same as below (`BDDB2D2F...5B17A9FF`) |
Details and qualification status: [RELEASING](RELEASING.md).
| Item | Value | | Item | Value |
| --- | --- | | --- | --- |
| File | `QQQ-Mobile-1.0.0.apk` (locally signed) | | Source | [Signed preview release scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease; forge sign-in required) |
| File | `QQQ-Mobile-1.0.0.apk`, with `SHA256SUMS` on the same release page |
| Application ID | `io.qrun.mobile.scanner` | | Application ID | `io.qrun.mobile.scanner` |
| Version | 1.0.0, versionCode 2 | | Version | 1.0.0, versionCode 2 |
| APK SHA-256 | `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51` | | APK SHA-256 | `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51` |
| Signing certificate SHA-256 | `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF` |
The same APK is used for every customer; branding comes from the server at runtime. Compare the SHA-256 before installing. The checksum shows the file is intact, not who signed it. The same APK is used for every customer; branding comes from the server at runtime. Download the APK and `SHA256SUMS` from the release page and compare the SHA-256 before installing, then verify the signing certificate matches the value above. The checksum shows the file is intact, not who signed it. This is a preview, not a stable release. Only the APK and `SHA256SUMS` are hosted; there is no hosted install kit.
This APK was installed and launched once on a fresh Zebra MC9400 (Android 13): install succeeded, the Connect screen appeared and the installed bytes matched the hash above. It has **not** been enrolled, scanned or navigated, because no HTTPS server was available, so these steps are not hardware-qualified until the first install is confirmed (below). That MC9400 now runs the development demo build instead. **Device evidence.** *Historical (November 4):* this APK was installed and launched once on a fresh Zebra MC9400 (Android 13) and showed the Connect screen; it was not yet enrolled. *Current (November 5):* with the user's explicit authorization, the MC9400 was migrated: 1.0.0 enrollment over trusted HTTPS passed, then an in-place 1.0.1 update preserved login and enrollment. Apps/ScanCapture navigation and EMDK Scanner-ready were observed, and the operator confirmed a physical Saved scan that the backend verified. These checks are done and need not be repeated for this device. The 1.0.1 candidate is still unpublished, native Authentik login/logout/account switching is not yet qualified with the production APK, and the TC22 keeps its original development-signed installation.
## Before you start ## Before you start
- The device is connected to Wi-Fi. - The device is connected to Wi-Fi.
- You have an **HTTPS** QQQ server URL the device can reach. Release builds reject HTTP. The server is not chosen yet: use the address you are given, and do not guess one. - You have a customer **HTTPS** QQQ server URL with a trusted certificate that the device can reach. Release builds reject HTTP, and certificate checks must not be weakened. Use the address you are given (`https://<your-server>`); do not guess one. Local-network qualification used a temporary private network and is not a customer deployment.
- You have the QQQ credentials for the sign-in the server offers. None are in this guide. - You have the QQQ credentials for the sign-in the server offers. None are in this guide.
## Install: copy the file (recommended) ## Install: copy the file (recommended)
1. Copy `QQQ-Mobile-1.0.0.apk` to the device's **Downloads** folder. 1. Copy the verified `QQQ-Mobile-1.0.0.apk` to the device's **Downloads** folder.
2. Open **Files**, go to Downloads, and tap the APK. 2. Open **Files**, go to Downloads, and tap the APK.
3. If Android asks, allow that Files app to install apps, then continue. You can turn the permission off again afterward. 3. If Android asks, allow that Files app to install apps, then continue. You can turn the permission off again afterward.
@@ -50,7 +62,7 @@ Grant location when Android asks; scanning works without it. Zebra devices with
## Confirm the first new install ## Confirm the first new install
On the first device only, check each of these: On the first device of a new customer deployment, check each of these (already done for the MC9400 qualification above):
- The footer shows **Connected**. - The footer shows **Connected**.
- One scan is captured and the server keeps it (**Saved scan**). - One scan is captured and the server keeps it (**Saved scan**).
@@ -65,7 +77,7 @@ For a later build signed by the same key with a higher versionCode, run the same
## Devices running the development-signed build ## Devices running the development-signed build
Devices running the development-signed demo build (the TC22, and the MC9400 in demo mode) cannot be updated by this APK, because the signer differs. Android is expected to refuse the install. This is not a fault: do not uninstall the app or reset the device to get around it. Moving that device needs a planned one-time migration that includes re-enrollment. Devices running the development-signed demo build (the TC22) cannot be updated by this APK, because the signer differs. Android is expected to refuse the install. This is not a fault: do not uninstall the app or reset the device to get around it. Moving that device needs an explicitly authorized, planned one-time migration that includes re-enrollment.
## References ## References
+46
View File
@@ -0,0 +1,46 @@
# Record browsing and photo UX — November 5
The existing scanner application now supports permitted tables, grouped record details, related records and record-linked photo capture through standard semantic APIs. The companion Echo demo supplies 1,000 synthetic orders, 12,000 items and 3,000 history entries.
Source review: [Android PR 15](https://git.kof22.com/QRun-IO/qqq-android/pulls/15), paired with [scanner/server PR 19](https://git.kof22.com/QRun-IO/qbit-mobile-scanners/pulls/19).
## User flow
Open **Test Data → Test Orders**, search or filter, and select a record. Back preserves page/search/filter context. A fixed identity heading sits above Details, Items, History and Photos. Groups collapse and can be selected from Sections. Related items show concise reference/quantity/SKU labels; timestamps include timezone context and server currency/units are preserved.
Use **Actions → Add photo → Take photo / Choose photo**. The selected record remains visible in the chooser. Saved attachments appear under Photos with review status. Available images have thumbnails and previews; quarantined originals remain unavailable. The client follows the server's receipt: a host that admits approved-device captures immediately returns `AVAILABLE`, so the app shows "Saved photo."; hosts that retain quarantine return `QUARANTINED` ("Awaiting server review."). The maintained Echo host now uses Record Files' scoped `quarantineCapturedFiles` setting. The app has no admission policy of its own and no client change was needed. Photos already uploaded stay quarantined until the server releases them; the three earlier demo captures were released through the normal audited admin process. Photo transfer remains online-only, idempotent and protected by parent permissions and device/session authorization.
The signed MC9400 app's gallery upload returned "Saved photo." for a new synthetic PNG; server readback verified `AVAILABLE`, the intended parent, media type, byte count and SHA256. Updated native acceptance on the TC22 also passed synthetic camera/gallery uploads, the newly captured photo's thumbnail and full preview, parent scoping, rotation and forced-logout cleanup. All requests passed semantic API checks; the disposable app was removed afterward. This is not a camera-hardware qualification. The captured-photo policy still needs its public dependency release.
## Verification
- 232 unit tests: core 52, mobileapp 57, host 123; zero failures/errors/skips. Scanner lint: zero errors/warnings. Debug, E2E and instrumentation APK builds passed; eight runner guard tests passed.
- Isolated MC9400 rich-record native acceptance passed paging, groups, related queries/navigation, search/filter/Back preservation, camera/gallery persistence, approved preview, parent-scoped gallery, rotation, pending-photo navigation protection and forced-logout cleanup. All HTTP requests matched the semantic API. Existing process-photo acceptance also passed.
- The final date fallback handles default `%s` metadata and differing raw/display JSON values; its unit/lint/debug-build gate passed. Actual TC22 inspection confirmed localized dates/timezones, currency, Actions/capture chooser, direct tabs and order 26 → Android Back → page two. The full native suite preceded this date-only follow-up.
- Updated the ordinary TC22 app in place with matching signer and installed bytes verified: SHA-256 `F71033C835AC3EB361A04D78C842947C5A04C85CE1AB1B999AA8CEFBFC9D7A95`. No app-data reset, replacement login or separate manual QA app was needed.
Native camera/gallery inputs in automated acceptance were synthetic. This visual pass did not capture an operator photo, print a label or qualify physical GS1 reproduction. The November 4 operator report remains accepted separately.
## Actual TC22 screenshots
All displayed records are synthetic. These are screenshots of the installed app, not mockups.
| Record and actions | Capture chooser |
| --- | --- |
| ![Record Actions menu](images/record-ux-2026-11-05/tc22-ux-actions.png) | ![Take or choose a record photo](images/record-ux-2026-11-05/tc22-ux-add-photo.png) |
| Related items | Related history |
| --- | --- |
| ![Items with quantity, SKU and currency](images/record-ux-2026-11-05/tc22-ux-items.png) | ![History with formatted timestamps](images/record-ux-2026-11-05/tc22-ux-history.png) |
| Saved-photo empty state | Table after Android Back |
| --- | --- |
| ![Photos tab with Add photo guidance](images/record-ux-2026-11-05/tc22-ux-photos.png) | ![Page two retained after returning from a record](images/record-ux-2026-11-05/tc22-ux-back-preserved.png) |
## Implementation boundaries
Record Files intentionally denies aggregate counts; photo pages use bounded queries with one lookahead row. Downloads use the owner's authenticated tracked-delivery endpoint, exact size/hash verification, same-origin credentials, no redirects, bounded bitmap decoding and no image disk cache. Hidden fields stay out of native summaries/search.
Pending-upload Back handling is registered inside the record destination, after NavHost's callback. Configuration disposal does not clear retained upload guards; authentication teardown does. Tests use a disposable application ID, fresh temporary database and isolated accounts, preserving normal device/demo data.
No new business routes, generic screen framework, offline queue, raw record editing, production signing or artifact publication is introduced. Remaining qualification is tracked in [TODO](TODO.md).
+37 -9
View File
@@ -1,6 +1,18 @@
# Scanner APK release readiness # Scanner APK release readiness
Status (November 4, 2026): **a QRun-owned production signing key exists with a verified backup, and a locally signed, verified 1.0.0 (versionCode 2) APK exists. It was installed and launched once on a fresh MC9400, but it has not been qualified end to end, distributed or published, and no release exists.** The owner selected direct signed-APK / MDM distribution. Current local demo devices (TC22 and, by the user's later choice, the MC9400) run the development-signed debug APK (0.1.0, versionCode 1) against the local HTTP server; that is not a production release. Manual TC22 acceptance was reported all-pass by the operator on November 4 (operator report, no per-case artifacts) for the development-signed build; it does not qualify the production signer and is not requested again for that build. ## Current status (November 5, 2026)
- **Published:** the only published release is still [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), a **signed preview** (Gitea prerelease, not a stable release). It is unchanged.
- **Candidate, not published:** QQQ Mobile **1.0.1 (versionCode 3)** is a signed candidate. Its release qualification is incomplete, and no tag or download link is available yet.
- **Signer:** the candidate is signed with the existing permanent QRun key (certificate SHA-256 `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`). No new or replacement key was created; the existing recovery backup was verified earlier.
- **Candidate APK:** SHA-256 `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F`, 15015419 bytes.
- **Source:** Android PR 15 `ab6e44c`, merged as `c782aaaaba3f62422232436a9000af9d4b38fc82` with the same app-source tree. Installed APK bytes, signature, non-debuggable flag and 16 KiB alignment were verified. `lintRelease` reported 0 errors, 0 warnings and 5 informational findings; the release build passed.
- **Distribution:** maintainers use the existing manual signing path for direct APK/MDM distribution. Automated signing CI is optional.
- **Open:** see [Qualification status](#qualification-status) and [TODO](TODO.md).
## Historical status (November 4, 2026, superseded where noted)
Dated evidence below is preserved. As of November 4: a QRun-owned production signing key existed with a verified backup, and the signed 1.0.0 (versionCode 2) APK was published as a signed preview: [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), release ID 1361, tag on protected `main` `3eef6431ca39b6450cf764424d921a02b2afb9ab`. It had been installed and launched once on a fresh MC9400; at that time enrollment, scanning and update were unqualified. *(Superseded November 5: the MC9400 was migrated to the signed build; see below. The statement that the MC9400 ran the development-signed debug APK is no longer current. The TC22 still runs its original development-signed installation.)* Manual TC22 acceptance was reported all-pass by the operator on November 4 (operator report, no per-case artifacts) for the development-signed build; it does not qualify the production signer and is not rerun.
## Decisions (made) ## Decisions (made)
@@ -28,16 +40,32 @@ Status (November 4, 2026): **a QRun-owned production signing key exists with a v
Version `1.0.0`, `versionCode` 2 was built unsigned (`lintRelease` and `assembleRelease`: 0 errors, 0 warnings, 2 information) from source matching protected `main` `0ae2101f082c241ca6a70462dc9ec3057aa6c906`; only documentation differs. It was then signed in isolation with `apksigner` on 2026-11-04 (16:22:05 UTC). Version `1.0.0`, `versionCode` 2 was built unsigned (`lintRelease` and `assembleRelease`: 0 errors, 0 warnings, 2 information) from source matching protected `main` `0ae2101f082c241ca6a70462dc9ec3057aa6c906`; only documentation differs. It was then signed in isolation with `apksigner` on 2026-11-04 (16:22:05 UTC).
**Signed artifact (local, complete):** `QQQ-Mobile-1.0.0.apk`, application ID `io.qrun.mobile.scanner`, SHA-256 `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51`. Verification passed: signature schemes v2 and v3, exactly one signer whose certificate SHA-256 is `BDDB2D2F...5B17A9FF` (the fingerprint above), 16 KiB alignment, non-debuggable. The checksum shows integrity only, not signer correctness. The APK is not published or distributed and is kept outside this repository. **Signed artifact (local, complete):** `QQQ-Mobile-1.0.0.apk`, application ID `io.qrun.mobile.scanner`, SHA-256 `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51`. Verification passed: signature schemes v2 and v3, exactly one signer whose certificate SHA-256 is `BDDB2D2F...5B17A9FF` (the fingerprint above), 16 KiB alignment, non-debuggable. The checksum shows integrity only, not signer correctness.
**Published preview (2026-11-04):** release [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (Gitea ID 1361, prerelease, signed Git tag at `3eef6431`; the tag adds documentation only over app source `0ae2101f`) hosts exactly two assets: `QQQ-Mobile-1.0.0.apk` (14933499 bytes) and `SHA256SUMS` (88 bytes). Both were downloaded back and matched the same SHA-256. The forge requires sign-in to download. The local installation kit ZIP was **not** published and no bundle is hosted. Link the release page, not an individual asset URL. The APK is kept outside this repository.
**Device evidence (MC9400, November 4):** the signed APK was installed on a fresh MC9400 (Android 13, EMDK present), launched without a startup crash, showed the Connect screen, and its installed bytes read back with the exact hash above. It was not enrolled: release builds require HTTPS and the only server available was the local HTTP demo. The same key and signer are unchanged. The user then chose to run the MC9400 in demo mode like the TC22, so that fresh, unenrolled production install was replaced with the development debug APK and port forwarding to the local server. No signed-build enrollment, scan or navigation was tested. The TC22 and its data were untouched throughout. **Device evidence (MC9400, November 4):** the signed APK was installed on a fresh MC9400 (Android 13, EMDK present), launched without a startup crash, showed the Connect screen, and its installed bytes read back with the exact hash above. It was not enrolled: release builds require HTTPS and the only server available was the local HTTP demo. The same key and signer are unchanged. The user then chose to run the MC9400 in demo mode like the TC22, so that fresh, unenrolled production install was replaced with the development debug APK and port forwarding to the local server. No signed-build enrollment, scan or navigation was tested. The TC22 and its data were untouched throughout.
Remaining steps: *(The November 4 MC9400 paragraph above is historical: the fresh install was later replaced by the migration recorded below.)*
1. Qualify the **actual production-signed APK** on a device: install, enrollment, scanner path ([Zebra scanning](ZEBRA-SCANNING.md)), Back/Apps navigation, and update over a prior production build. Use a planned device migration or a spare device; do not use a demo device implicitly, and note that moving a demo device to the signed build removes its development install and enrollment. ## Qualification status
2. Provide an **HTTPS** server. Source release builds disallow cleartext; HTTP is limited to debug builds. The local demo does not need HTTPS.
3. Use the first direct installation to confirm the device flow, then distribute the same APK to the remaining devices. MDM can be introduced later. Done (not to be repeated as unchecked work):
4. Automated scanner-APK signing/release workflow is not configured and is separate from manual distribution; create no tags or releases without current authorization.
- **MC9400 migration:** the user explicitly authorized it. Release 1.0.0 enrollment over trusted HTTPS passed, then an in-place 1.0.1 update preserved login and enrollment. Apps/ScanCapture navigation and EMDK Scanner-ready state were observed.
- **Physical scan:** the operator confirmed a physical Saved scan. The backend verified CODE128, AIM identifier, raw bytes, user and device. The actual barcode differed from the requested literal.
- **TC22:** the original development-signed installation and its data are retained. The accepted November 4 manual all-pass is not rerun.
- **Server discovery:** against the real shared Authentik-backed server, UI API 1.10 discovery with strict nonce passed for both scanner and printing channels, extensions and callbacks.
- **Photo policy (live):** a signed MC9400 gallery upload returned Saved photo with `AVAILABLE` status and exact parent, type, size and hash; the previous three photos were released normally. Updated native acceptance on the TC22 passed synthetic camera/gallery uploads, the captured photo's thumbnail and full preview, parent scoping, rotation and forced-logout cleanup. All requests passed the semantic API checks. The disposable app was removed and the normal app reopened. This does not establish camera-hardware or native-Authentik acceptance.
- **Local network:** the local QQQ server remains on a PC and was reached over trusted HTTPS temporarily through a user-approved private Tailscale network. This is local qualification, not a customer production deployment. Customer instructions still require a customer HTTPS URL with a trusted certificate. TLS must not be weakened, and workstation hostnames, IPs, ports, device IDs, vault IDs, private file paths and passwords are not published.
Open:
1. **Native Authentik** login, logout and account switching with the actual production APK remain open, awaiting the MC wireless-debug endpoint. No native-Authentik PASS is claimed.
2. **Printing:** the print nonce fix (PR 11, merged `abc3d0d`, 532 hosted tests passed) postdates the published print 1.1.1; the next installer remains with the active printing owner.
3. Public compatible Core/Collab/document/files/scripting/bridges, the final public cohort gate, GS1 physical print/rescan, and full release-asset publication and distribution.
4. Company profiles and wallpaper are tracked separately.
5. Publish the 1.0.1 tag, release, APK, checksum and installation instructions after its remaining qualification. The published download remains the 1.0.0 signed preview; automated signing CI is optional.
## Release requirements ## Release requirements
@@ -49,8 +77,8 @@ Remaining steps:
Android updates in place only when the signer matches and `versionCode` does not decrease. Losing this key, or its backup, means installed copies can no longer be updated in place; they would have to be uninstalled and reinstalled, which removes enrollment and session state. Keep the vault backup recoverable and re-verify it (hash, open, certificate comparison) after any change to it. Android updates in place only when the signer matches and `versionCode` does not decrease. Losing this key, or its backup, means installed copies can no longer be updated in place; they would have to be uninstalled and reinstalled, which removes enrollment and session state. Keep the vault backup recoverable and re-verify it (hash, open, certificate comparison) after any change to it.
Development-signed demo installations (TC22, and the MC9400 in demo mode) cannot be upgraded by the production key. Moving one requires an explicit migration: preserve its test evidence, plan re-enrollment, and never uninstall or reset a device or application implicitly. No update or key-rotation policy beyond keeping this one permanent signer is defined here. Development-signed demo installations (the TC22) cannot be upgraded by the production key. Moving one requires an explicit migration: preserve its test evidence, plan re-enrollment, and never uninstall or reset a device or application implicitly. The MC9400 was migrated under explicit user authorization (see above). No update or key-rotation policy beyond keeping this one permanent signer is defined here.
## Not yet established ## Not yet established
Production-signed-APK device qualification, first distribution, an HTTPS production server, an automated scanner release workflow and its secret handling, and key-rotation planning. SDK publication by the central workflow is done and its completed audits are not reopened here. Other pending items are in [TODO](TODO.md). Publication of the 1.0.1 candidate, native Authentik qualification with the production APK, a customer production HTTPS deployment, stable-release promotion, an automated scanner release workflow (optional) and its secret handling, and key-rotation planning. SDK publication by the central workflow is done and its completed audits are not reopened here. Other pending items are in [TODO](TODO.md).
+29 -24
View File
@@ -1,36 +1,41 @@
# Current status # Current status
As of 2026-11-04. Scope: the scanner host and the local Echo workflow only. This is the authoritative current view; earlier rolling notes are historical and kept in [docs/history](history/README.md). Nothing here claims a finished or released 1.0. Checklist: [TODO](TODO.md). Ledger: [FINISH-1.0](FINISH-1.0.md). As of **2026-11-05**. Scope: scanner host and maintained local Echo workflow. [TODO](TODO.md) tracks remaining work; [history](history/README.md) preserves earlier evidence. This is not a final-release or full-qualification claim. See [Release facts](#release-facts-2026-11-05) below.
## Source ## Release facts (2026-11-05)
- Protected `main` contains all application work (PR 8 and PR 10 merged). Application source is unchanged by the documentation and signing work after that merge. - **Published:** still `scanner-v1.0.0`, a signed preview. Signed candidate **1.0.1 (versionCode 3)** is **not published**, has no tag and no download link, and is not production-complete. Signer: existing permanent QRun key (certificate SHA-256 `BDDB2D2F...5B17A9FF`); no new key. APK SHA-256 `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F`, 15015419 bytes. Details: [RELEASING](RELEASING.md).
- The Gitea workflow `.gitea/workflows/qrun-android-release.yaml` exists on `main`; it calls the shared platform Android workflow and is not a scanner-APK release pipeline (see [RELEASING](RELEASING.md)). - **Source:** Android PR 15 `ab6e44c`, merged `c782aaaaba3f62422232436a9000af9d4b38fc82`, same app-source tree. Root verified installed APK bytes/signature/non-debuggable/16 KiB alignment; `lintRelease` 0 errors, 0 warnings, 5 informational; release build passed.
- Documentation is published separately (signing guide and this reconciliation). Shared CI/CD redesign is outside this repository's local use and does not gate the demo. - **Done:** MC9400 migration (user-authorized): 1.0.0 enrollment over trusted HTTPS, then in-place 1.0.1 update preserving login/enrollment; Apps/ScanCapture and EMDK Scanner-ready observed; operator-confirmed physical Saved scan verified in the backend (CODE128/AIM/raw bytes/user/device; actual barcode differed from the requested literal). Not repeat work. TC22 keeps its original development-signed installation and data; the accepted November 4 manual all-pass is not rerun.
- **Network:** the local QQQ server stays on a PC, reached over trusted HTTPS temporarily through a user-approved private Tailscale network. This is not a customer production deployment; customer instructions require a customer HTTPS URL with a trusted certificate. TLS is not weakened; no hostnames, IPs, ports, device/vault IDs, private paths or passwords are published.
- **Server/Authentik:** real shared Authentik server discovery verified (UI API 1.10, strict nonce, scanner and printing channels/extensions/callbacks). Production-APK native login/logout/account switching is **open**, awaiting the MC wireless-debug endpoint; no native-Authentik PASS is claimed. Print nonce fix PR 11 (merged `abc3d0d`, 532 hosted tests passed) postdates published print 1.1.1; the next installer remains with the active printing owner.
- **Photo policy:** live. A signed MC9400 gallery synthetic PNG became a Saved photo, `AVAILABLE`, with exact parent/type/size/hash; the previous three photos were released normally. The app has no admission policy; the server receipt controls the message. The root is running the updated disposable TC22 E2E; no full new-preview or native PASS is claimed until it completes.
- **Open:** public compatible Core/Collab/document/files/scripting/bridges, final public cohort gate, GS1 physical print/rescan, full release-asset publication/distribution. Company profiles/wallpaper are tracked separately; signing CI is optional and not blocking a direct APK release. Release authorizations already exist.
## Local demo: accepted Statements below were written before these facts and are historical where they conflict (for example "debug update", MC9400 trigger check "outstanding", signed-build HTTPS qualification "open").
- Local server on the workstation (port 57832): public QQQ 4.1.0-RC.21 plus released Printing 1.0 and qualified document, files and scripting owner inputs. UI API 1.10, mobile-scanners 1.5, printing 2.3, mobile-record-files 1.0. ## Record browsing and photos
- TC22 runs the development-signed debug APK (version 0.1.0, versionCode 1, SHA-256 `38A11155BF6A9C089A1EE6D9E44401E8FF39497B7150B51A85F1F916148BE60D`), reaching the server through device port 8080 forwarded to the workstation.
- The operator reported the outstanding manual TC22 acceptance group ALL PASS on 2026-11-04, including label count/fidelity, camera and GPS, and screen viewing. This is operator-reported acceptance, not instrumented or per-case evidence; no per-case artifacts are recorded and the checks are not requested again.
- Desktop-browser Authentik acceptance passed with strict TLS (nonce rejection, login, company logout, fresh second operator).
## MC9400 (second device) The existing TC22 app now opens permitted tables and records through the standard semantic metadata/query/get API. Tables support search, exact text filters, counts and stable paging; Back restores page/search/filter context. Records retain identity above Details, related-record and Photos tabs, with collapsible groups and a section selector. Dates/timezones, currency and empty values are readable; identifiers keep leading zeros.
- A production-signed 1.0.0 (versionCode 2) APK was installed on a fresh MC9400 (Android 13, EMDK present) and launched; installed bytes matched the signed APK hash. No fatal crash at startup. **Record → Actions → Add photo** opens Take photo / Choose photo. Saved photos show parent-scoped lifecycle status and approved thumbnails/previews. Quarantined originals remain unavailable. Availability is decided only by the server receipt/lifecycle; focused tests cover both `AVAILABLE` and `QUARANTINED`, and the native E2E expects `AVAILABLE` for the configured Echo fixture while retaining quarantine assertions for the generic fixture. Thirteen focused photo/API tests passed. A fresh synthetic gallery upload through the signed MC9400 app returned "Saved photo."; server readback verified availability, parent, type, byte count and SHA256. The three earlier demo captures were released through the normal audited admin process. Updated TC22 native photo acceptance passed synthetic camera/gallery upload, the captured photo thumbnail/full preview, parent scoping, rotation and forced-logout cleanup, with all requests checked against the semantic API. The disposable test packages were removed and the normal app reopened. Public captured-photo dependency publication and native Authentik qualification remain open. Existing upload identity, retry handling, server authorization, pending-navigation guards and logout cleanup are preserved.
- At the user's request the MC9400 now runs the same development APK as the TC22 in demo mode, with device port 8080 forwarded to the workstation server over paired wireless ADB. It signed in and was approved through the ordinary mobile device approval process (status APPROVED).
- **Remaining check:** a physical MC9400 trigger scan confirmed in the backend. Not yet verified; no scan claim is made.
## Signed release The maintained companion demo supplies Test Data: 1,000 orders, 39 fields in six groups, 12,000 items and 3,000 history entries. See [screenshots and verification](RECORD-UX-2026-11-05.md).
- Direct signed-APK / MDM distribution was chosen. A QRun-owned key exists (alias `qqq-mobile-release`, certificate SHA-256 `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`) with a verified backup. Key, passwords and vault details are not in this repository. ## Verification and installation
- Signed `QQQ-Mobile-1.0.0.apk` (versionCode 2, SHA-256 `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51`) exists locally, built from `main` application source: lint 0 errors/0 warnings, v2/v3 signature with the exact certificate, alignment and non-debuggable verified.
- It is **not** published, tagged or distributed, and no production release exists. No automatic signing CI is configured. The signed artifact is kept outside the repository.
- Production qualification needs an HTTPS server and a full device pass of the signed build ([RELEASING](RELEASING.md), [INSTALLATION](INSTALLATION.md)). The local demo does not need HTTPS; release builds reject HTTP.
## Open (not claimed) - **232 unit tests** pass: core 52, mobileapp 57, host 123; lint reports **0 errors/0 warnings**. Debug, E2E and instrumentation builds pass. Eight runner guard tests pass.
- Isolated MC9400 native acceptance passes rich-record paging/groups/relations/search/filter/Back, camera/gallery persistence, parent-scoped saved photos, approved preview, rotation, pending-photo navigation protection and forced-logout cleanup. All requests match the semantic API. Process-photo regression passes separately.
- The final date fallback recognizes default `%s` server metadata. It received unit/lint/debug-build gates and actual TC22 checks; the full native suite preceded that date-only follow-up.
- Existing TC22 app updated in place with signer and installed bytes verified: SHA-256 `F71033C835AC3EB361A04D78C842947C5A04C85CE1AB1B999AA8CEFBFC9D7A95`. No uninstall/data clear or separate manual QA app. Existing login, enrollment and server configuration survived.
- Actual TC22 inspection confirms Actions and capture chooser, direct tabs, date/timezone and currency formatting, empty Photos state and order 26 → Android Back → page two. Automated camera/gallery inputs are synthetic; no new physical camera/print claim is made.
- Release qualification of the signed APK end to end (HTTPS server, enrollment, scan, navigation, update). ## Source and runtime
- Actual on-device Authentik callback: unverified; no separate evidence, remains production qualification.
- Dependency publication: the Generation, Record Files and Scripting owner inputs behind the local server are qualified inputs, not a fully public, reproducible or released cohort. Protected `main` contains PRs 8/10, with signing/documentation through PR 14 (`464814f`). The user authorized commit, push and merge of the GS1/record/UX work through normal Gitea checks. No new artifact release was requested. The maintained server uses public QQQ RC.21 and Printing 1.0.0 plus qualified document/files/scripting owner inputs. Contract versions remain UI API 1.10, mobile-scanners 1.5, printing 2.3 and mobile-record-files 1.0. Device loopback 8080 forwards to the existing host port 57832.
- Company profile / MDM rollout: nothing applied or verified on a device ([DEVICE-PROVISIONING](DEVICE-PROVISIONING.md)).
The QRun-owned signing key and [signed preview `scanner-v1.0.0`](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) remain unchanged. That preview predates the record UX update; candidate 1.0.1 is unpublished. The preview is not a stable release or an automatic signing/publication pipeline; see [RELEASING](RELEASING.md).
## Accepted and outstanding hardware work
The November 4 TC22 operator group pass remains accepted; do not request it again. MC9400's physical scan/backend check and signed-build trusted-HTTPS enrollment/update are done (see Release facts). Physical GS1 print/rescan, native provider callback, public owner-dependency adoption and fleet/MDM rollout remain open. Historical hashes, signed-preview provenance and older tests are preserved in [the prior checkpoint](history/SESSION-STATE-before-record-ux-2026-11-05.md).
+16 -26
View File
@@ -1,35 +1,25 @@
# Checklist # Checklist
As of 2026-11-04, scanner host and local Echo scope only. Narrative: [SESSION-STATE](SESSION-STATE.md). Completed and superseded items are kept in [docs/history](history/README.md). Open work only, as of **2026-11-05**. Current evidence: [SESSION-STATE](SESSION-STATE.md). Completed and superseded items: [history](history/README.md).
## Local demo (accepted) ## Hardware and release qualification
- [x] Application work merged to protected `main`; `.gitea/workflows/qrun-android-release.yaml` present. - [ ] Qualify actual GS1 label media and physical printed-label rescan. Synthetic capture/render evidence does not close this check.
- [x] Local server on port 57832 (QQQ 4.1.0-RC.21, Printing 1.0, qualified owner inputs) running UI API 1.10, mobile 1.5, printing 2.3, record-files 1.0. - [ ] Verify actual production-APK native Authentik login, logout and account switching; awaiting the MC wireless-debug endpoint. Real-server discovery (UI API 1.10, strict nonce, scanner and printing channels) is already verified; no native-Authentik PASS is claimed.
- [x] TC22 on the development APK against that server; operator reported the outstanding manual acceptance group ALL PASS (2026-11-04), including label count/fidelity, camera, GPS and screen viewing; operator-reported, not instrumented or per-case evidence. - [ ] Publish signed candidate 1.0.1 (versionCode 3, APK SHA-256 `5124716B...AC2717F`, same permanent signer), checksum and installation instructions after remaining qualification. The published release is still the 1.0.0 signed preview.
- [x] Echo setup (printer, label size, document, copies 1–100) and semantic native flow qualified, with simulated inputs/printing in disposable runs. - [ ] Publish and distribute full release assets to remaining devices; validate MDM/company profile and wallpaper separately. A customer deployment needs a customer HTTPS URL with a trusted certificate.
- [x] Real desktop Authentik acceptance with strict TLS. - [ ] Printing: the next installer remains with the active printing owner (print nonce fix PR 11 postdates published print 1.1.1).
- [ ] Optional, non-blocking: automatic APK signing CI. Direct APK release does not depend on it.
## MC9400 remaining check Done (November 5, do not repeat): MC9400 migration with release 1.0.0 trusted-HTTPS enrollment then in-place 1.0.1 update preserving login/enrollment; Apps/ScanCapture navigation and EMDK Scanner-ready observed; operator-confirmed physical Saved scan verified in the backend (CODE128, AIM, raw bytes, user, device; actual barcode differed from the requested literal). Live photo policy verified with a signed MC9400 synthetic gallery photo (`AVAILABLE`, exact parent/type/size/hash).
- [x] Production-signed APK installed and launched on a fresh MC9400; hash read back. Updated TC22 native photo acceptance passed: synthetic camera/gallery uploads, the newly captured photo's thumbnail/full preview, parent scoping, rotation and forced-logout cleanup, with semantic API checks. The disposable test packages were removed and the normal app reopened. Native Authentik remains a separate open check.
- [x] Switched to the TC22 development APK demo configuration at the user's request; signed in and approved (APPROVED).
- [ ] Physical MC9400 trigger scan reaches the backend (Saved scan and a matching server record).
## Release qualification ## Dependencies and broader coverage
- [x] Signed-APK/MDM channel chosen; QRun-owned key created with verified backup; signed 1.0.0 (versionCode 2) built and verified locally. - [ ] Adopt public compatible Core/Collab/document/files/scripting/bridges inputs through a reviewed companion-server change, and pass the final public cohort gate.
- [x] Direct-install guide written ([INSTALLATION](INSTALLATION.md)). - [ ] Verify company profile / EHS / OEMConfig / MDM deployment ([DEVICE-PROVISIONING](DEVICE-PROVISIONING.md), [COMPANY-PROFILE](COMPANY-PROFILE.md)).
- [ ] Qualify the production-signed APK on a device against an HTTPS server (enrollment, scanner path, navigation, update). - [ ] Resolve applicable [legacy source follow-ups](LEGACY-SOURCE-FOLLOWUPS.md).
- [ ] First distribution to the remaining devices; MDM later. No tag or release exists. - [ ] Extend specialized forms, reports and other unsupported [native components](UI-API.md) with conformance and E2E coverage as needed. Read-only table/record browsing and record photo attachment are implemented.
- [ ] Automatic signing/publication pipeline: not configured; owned by the shared CI design, not needed for manual distribution.
- [ ] Actual on-device Authentik callback (unverified; no separate evidence).
## Dependencies and provisioning Offline queueing and background synchronization remain outside version one. The November 4 TC22 operator pass is accepted and should not be requested again.
- [ ] Public, reproducible publication of the Generation, Record Files and Scripting owner inputs (currently qualified inputs).
- [ ] Company profile / EHS / OEMConfig / MDM deployment verified on a device ([DEVICE-PROVISIONING](DEVICE-PROVISIONING.md), [COMPANY-PROFILE](COMPANY-PROFILE.md)).
- [ ] Legacy source follow-ups ([LEGACY-SOURCE-FOLLOWUPS](LEGACY-SOURCE-FOLLOWUPS.md)).
- [ ] Extend the [native component boundary](UI-API.md) (possible values, record lists, specialized forms, reports) with conformance and E2E coverage as needed.
Offline queueing and background synchronization are outside version one.
+2
View File
@@ -1,5 +1,7 @@
# Historical status notes # Historical status notes
November 5 record UX evidence is in [the completion note](../RECORD-UX-2026-11-05.md). The preceding [session state](SESSION-STATE-before-record-ux-2026-11-05.md) and [checklist](TODO-before-record-ux-2026-11-05.md) preserve the earlier local-preview and signing details.
These files are historical records, not current status. They preserve the rolling notes as they stood at the end of 2026-11-04, including statements that were later superseded (for example signing deferral, open pull requests, and a missing release workflow). For the current view see [status](../SESSION-STATE.md), [checklist](../TODO.md) and [finish ledger](../FINISH-1.0.md). These files are historical records, not current status. They preserve the rolling notes as they stood at the end of 2026-11-04, including statements that were later superseded (for example signing deferral, open pull requests, and a missing release workflow). For the current view see [status](../SESSION-STATE.md), [checklist](../TODO.md) and [finish ledger](../FINISH-1.0.md).
- [SESSION-STATE-through-2026-11-04.md](SESSION-STATE-through-2026-11-04.md): rolling session log, newest first. - [SESSION-STATE-through-2026-11-04.md](SESSION-STATE-through-2026-11-04.md): rolling session log, newest first.
@@ -0,0 +1,50 @@
Historical checkpoint before the November 5 record UX update. Current state is in [session state](../SESSION-STATE.md).
## November 5 rich-record preview
The same TC22 app now has grouped fields and direct related-record navigation through standard semantic metadata/query APIs. The maintained Echo demo supplies Test Data: 1,000 orders (39 fields, six sections), 12,000 items and 3,000 history entries. Android unit tests, lint and debug/E2E builds passed. Isolated MC9400 native acceptance passed paging, grouping, filtered child navigation, camera/gallery attachments to order 26, rotation and remote logout cleanup; all requests matched the semantic API. Installed TC22 APK SHA-256: `65FB631F0CFF9B68A0F3FC01D37D656FD69D690F1BA63F8A34FB314A2B03B97F`, matching signer and installed bytes verified. Existing app data, enrollment and server address were retained. These source changes remain local/uncommitted; no new release was published. This supersedes the earlier preview/server-selection status below.
## November 5 TC22 preview update
At the user's request, the existing TC22 app was updated in place to the local records/photo debug build, with matching signer verified before installation and installed APK SHA-256 `79E6CD0559419E059EC253DD0364C13BDDB3C27F566F0DA9A478FBB70D99A038` verified afterward. No uninstall, data clear, separate QA app or server change occurred. Server selection and hands-on record/photo acceptance are pending. The older acceptance evidence below applies to the prior development build.
# Current status
As of 2026-11-05. Scope: the scanner host and the local Echo workflow only. This is the authoritative current view; earlier rolling notes are historical and kept in [docs/history](README.md). Nothing here claims a stable or fully qualified 1.0; a signed preview is published. Checklist: [TODO](../TODO.md). Ledger: [FINISH-1.0](../FINISH-1.0.md).
## Local feature verification
Uncommitted GS1 and record-photo work adds permitted-table browsing, record details, and camera/gallery attachments using existing semantic APIs. Android unit tests, lint and debug/E2E builds passed. Disposable MC9400 tests passed synthetic GS1 capture with exact HRI and saved-record browsing, plus record-linked camera/gallery upload, rotation, navigation blocking and remote-logout cleanup. These tests used isolated apps/databases and simulated barcode input/printing. The companion composed server cohort passed 188 tests and Checkstyle; the existing photo-process regression and eight runner guard tests also passed. The ordinary scanner app is unchanged; physical GS1 print/rescan and deployment remain outstanding.
## Source
- Protected `main` contains all application work (PR 8 and PR 10 merged). Application source is unchanged by the documentation and signing work after that merge.
- The Gitea workflow `.gitea/workflows/qrun-android-release.yaml` exists on `main`; it calls the shared platform Android workflow and is not a scanner-APK release pipeline (see [RELEASING](../RELEASING.md)).
- Documentation is published separately (signing guide and this reconciliation). Shared CI/CD redesign is outside this repository's local use and does not gate the demo.
## Local demo: accepted
- Local server on the workstation (port 57832): public QQQ 4.1.0-RC.21 plus released Printing 1.0 and qualified document, files and scripting owner inputs. UI API 1.10, mobile-scanners 1.5, printing 2.3, mobile-record-files 1.0.
- TC22 runs the development-signed debug APK (version 0.1.0, versionCode 1, SHA-256 `38A11155BF6A9C089A1EE6D9E44401E8FF39497B7150B51A85F1F916148BE60D`), reaching the server through device port 8080 forwarded to the workstation.
- The operator reported the outstanding manual TC22 acceptance group ALL PASS on 2026-11-04, including label count/fidelity, camera and GPS, and screen viewing. This is operator-reported acceptance, not instrumented or per-case evidence; no per-case artifacts are recorded and the checks are not requested again.
- Desktop-browser Authentik acceptance passed with strict TLS (nonce rejection, login, company logout, fresh second operator).
## MC9400 (second device)
- A production-signed 1.0.0 (versionCode 2) APK was installed on a fresh MC9400 (Android 13, EMDK present) and launched; installed bytes matched the signed APK hash. No fatal crash at startup.
- At the user's request the MC9400 now runs the same development APK as the TC22 in demo mode, with device port 8080 forwarded to the workstation server over paired wireless ADB. It signed in and was approved through the ordinary mobile device approval process (status APPROVED).
- **Remaining check:** a physical MC9400 trigger scan confirmed in the backend. Not yet verified; no scan claim is made.
## Signed release
- Direct signed-APK / MDM distribution was chosen. A QRun-owned key exists (alias `qqq-mobile-release`, certificate SHA-256 `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`) with a verified backup. Key, passwords and vault details are not in this repository.
- Signed `QQQ-Mobile-1.0.0.apk` (versionCode 2, SHA-256 `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51`) exists locally, built from `main` application source: lint 0 errors/0 warnings, v2/v3 signature with the exact certificate, alignment and non-debuggable verified.
- It is published as a **signed preview**: Gitea prerelease [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (ID 1361, signed tag on protected `main` `3eef6431ca39b6450cf764424d921a02b2afb9ab`; app source matches `0ae2101f`, the tag adds documentation only). Hosted assets are the APK (14933499 bytes) and `SHA256SUMS` (88 bytes), both downloaded back and matching the hash above. The local installation kit ZIP was not published. Forge sign-in is required. This is not a stable release. No automatic signing CI is configured, and SDK CI publication is a separate path.
- Production qualification (still pending) needs an HTTPS server and a full device pass of the signed build ([RELEASING](../RELEASING.md), [INSTALLATION](../INSTALLATION.md)). The local demo does not need HTTPS; release builds reject HTTP.
## Open (not claimed)
- Release qualification of the signed APK end to end (HTTPS server, enrollment, scan, navigation, update).
- Actual on-device Authentik callback: unverified; no separate evidence, remains production qualification.
- Dependency publication: the Generation, Record Files and Scripting owner inputs behind the local server are qualified inputs, not a fully public, reproducible or released cohort.
- Company profile / MDM rollout: nothing applied or verified on a device ([DEVICE-PROVISIONING](../DEVICE-PROVISIONING.md)).
@@ -0,0 +1,44 @@
Historical checkpoint before the November 5 record UX update. Current state is in [session state](../SESSION-STATE.md).
# Checklist
As of 2026-11-04, scanner host and local Echo scope only. Narrative: [SESSION-STATE](../SESSION-STATE.md). Completed and superseded items are kept in [docs/history](README.md).
## Active feature delivery
- [ ] Complete final review and deployment preparation for GS1 duplication and record photos; composed-server and native checks passed.
- [ ] Qualify actual GS1 label media, physical trigger and printed-label rescan.
- [ ] Deploy the reviewed app/server changes only with authorization, preserving ordinary app data and enrollment.
## Local demo (accepted)
- [x] Application work merged to protected `main`; `.gitea/workflows/qrun-android-release.yaml` present.
- [x] Local server on port 57832 (QQQ 4.1.0-RC.21, Printing 1.0, qualified owner inputs) running UI API 1.10, mobile 1.5, printing 2.3, record-files 1.0.
- [x] TC22 on the development APK against that server; operator reported the outstanding manual acceptance group ALL PASS (2026-11-04), including label count/fidelity, camera, GPS and screen viewing; operator-reported, not instrumented or per-case evidence.
- [x] Echo setup (printer, label size, document, copies 1–100) and semantic native flow qualified, with simulated inputs/printing in disposable runs.
- [x] Real desktop Authentik acceptance with strict TLS.
## MC9400 remaining check
- [x] Production-signed APK installed and launched on a fresh MC9400; hash read back.
- [x] Switched to the TC22 development APK demo configuration at the user's request; signed in and approved (APPROVED).
- [ ] Physical MC9400 trigger scan reaches the backend (Saved scan and a matching server record).
## Release qualification
- [x] Signed-APK/MDM channel chosen; QRun-owned key created with verified backup; signed 1.0.0 (versionCode 2) built and verified locally.
- [x] Direct-install guide written ([INSTALLATION](../INSTALLATION.md)).
- [ ] Qualify the production-signed APK on a device against an HTTPS server (enrollment, scanner path, navigation, update).
- [x] Signed preview published: Gitea prerelease `scanner-v1.0.0` (APK and `SHA256SUMS` only; local install kit not hosted).
- [ ] Distribution to the remaining devices after qualification; MDM later. Promotion to a stable release needs the qualification above.
- [ ] Automatic signing/publication pipeline: not configured; owned by the shared CI design, not needed for manual distribution.
- [ ] Actual on-device Authentik callback (unverified; no separate evidence).
## Dependencies and provisioning
- [ ] Public, reproducible publication of the Generation, Record Files and Scripting owner inputs (currently qualified inputs).
- [ ] Company profile / EHS / OEMConfig / MDM deployment verified on a device ([DEVICE-PROVISIONING](../DEVICE-PROVISIONING.md), [COMPANY-PROFILE](../COMPANY-PROFILE.md)).
- [ ] Legacy source follow-ups ([LEGACY-SOURCE-FOLLOWUPS](../LEGACY-SOURCE-FOLLOWUPS.md)).
- [ ] Extend the [native component boundary](../UI-API.md) (possible values, record lists, specialized forms, reports) with conformance and E2E coverage as needed.
Offline queueing and background synchronization are outside version one.
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -120,6 +120,48 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
sessionUUIDs[baseUrl] = sessionUUID sessionUUIDs[baseUrl] = sessionUUID
} }
/*******************************************************************************
** Bounded, stably ordered read-only pages retain normal session/device guards.
*******************************************************************************/
override suspend fun getTableMetaData(tableName: String) = qqqApiServiceMap[baseUrl]!!.getTableMetaData(tableName)
override suspend fun queryRecords(tableName: String, primaryKey: String, offset: Int, limit: Int): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> {
require(offset >= 0 && limit in 1..100 && primaryKey.isNotBlank())
val filter = JSONObject().put("skip", offset).put("limit", limit)
.put("orderBys", org.json.JSONArray().put(JSONObject().put("fieldName", primaryKey).put("isAscending", true)))
val body = JSONObject().put("filter", filter).toString().toRequestBody("application/json".toMediaType())
return qqqApiServiceMap[baseUrl]!!.queryRecords(tableName, body).records
}
override suspend fun getRecord(tableName: String, recordId: String) = qqqApiServiceMap[baseUrl]!!.getRecord(tableName, recordId).record
override suspend fun searchRecords(tableName: String, primaryKey: String, search: com.kingsrook.qqq.frontend.android.core.model.RecordSearch, offset: Int, limit: Int): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> {
require(offset >= 0 && limit in 1..100 && primaryKey.isNotBlank())
val filter = search.filter().put("skip", offset).put("limit", limit)
.put("orderBys", org.json.JSONArray().put(JSONObject().put("fieldName", primaryKey).put("isAscending", true)))
val body = JSONObject().put("filter", filter).toString().toRequestBody("application/json".toMediaType())
return qqqApiServiceMap[baseUrl]!!.queryRecords(tableName, body).records
}
override suspend fun countRecords(tableName: String, search: com.kingsrook.qqq.frontend.android.core.model.RecordSearch): Long {
val body = JSONObject().put("filter", search.filter()).toString().toRequestBody("application/json".toMediaType())
return qqqApiServiceMap[baseUrl]!!.countRecords(tableName, body).count.also { require(it >= 0) }
}
override suspend fun queryRelatedRecords(tableName: String, primaryKey: String, matches: Map<String, kotlinx.serialization.json.JsonElement>, offset: Int, limit: Int): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> {
require(offset >= 0 && limit in 1..100 && primaryKey.isNotBlank() && matches.isNotEmpty())
val criteria = org.json.JSONArray()
matches.forEach { (field, value) ->
require(field.isNotBlank() && value is kotlinx.serialization.json.JsonPrimitive && value != kotlinx.serialization.json.JsonNull)
criteria.put(JSONObject().put("fieldName", field).put("operator", "EQUALS")
.put("values", org.json.JSONArray("[$value]")))
}
val filter = JSONObject().put("skip", offset).put("limit", limit).put("booleanOperator", "AND").put("criteria", criteria)
.put("orderBys", org.json.JSONArray().put(JSONObject().put("fieldName", primaryKey).put("isAscending", true)))
val body = JSONObject().put("filter", filter).toString().toRequestBody("application/json".toMediaType())
return qqqApiServiceMap[baseUrl]!!.queryRecords(tableName, body).records
}
/******************************************************************************* /*******************************************************************************
*******************************************************************************/ *******************************************************************************/
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData { override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData {
@@ -134,6 +134,21 @@ interface QQQApiService {
@GET(BASE_PATH + "metaData/process/{processName}") @GET(BASE_PATH + "metaData/process/{processName}")
suspend fun getProcessMetaData(@Path(value = "processName") processName: String): QProcessMetaData suspend fun getProcessMetaData(@Path(value = "processName") processName: String): QProcessMetaData
/*******************************************************************************
** Registered table metadata/query/get operations; no alternative record API.
*******************************************************************************/
@GET(BASE_PATH + "metaData/table/{tableName}")
suspend fun getTableMetaData(@Path("tableName") tableName: String): com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
@POST(BASE_PATH + "table/{tableName}/query")
suspend fun queryRecords(@Path("tableName") tableName: String, @Body body: RequestBody): com.kingsrook.qqq.frontend.android.core.model.RecordQueryResponse
@POST(BASE_PATH + "table/{tableName}/count")
suspend fun countRecords(@Path("tableName") tableName: String, @Body body: RequestBody): com.kingsrook.qqq.frontend.android.core.model.RecordCountResponse
@GET(BASE_PATH + "table/{tableName}/{recordId}")
suspend fun getRecord(@Path("tableName") tableName: String, @Path("recordId") recordId: String): com.kingsrook.qqq.frontend.android.core.model.RecordGetResponse
/******************************************************************************* /*******************************************************************************
** Start process execution with explicit record selection and values, returning authoritative server step state. ** Start process execution with explicit record selection and values, returning authoritative server step state.
*******************************************************************************/ *******************************************************************************/
@@ -106,6 +106,27 @@ interface QQQRepository {
*******************************************************************************/ *******************************************************************************/
suspend fun getProcessMetaData(processName: String): QProcessMetaData suspend fun getProcessMetaData(processName: String): QProcessMetaData
/*******************************************************************************
** Read-only table navigation uses the core API and its normal permissions.
*******************************************************************************/
suspend fun getTableMetaData(tableName: String): com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData =
throw UnsupportedOperationException("Table browsing is unavailable")
suspend fun queryRecords(tableName: String, primaryKey: String, offset: Int, limit: Int = 25): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> =
throw UnsupportedOperationException("Table browsing is unavailable")
suspend fun getRecord(tableName: String, recordId: String): com.kingsrook.qqq.frontend.android.core.model.RecordData =
throw UnsupportedOperationException("Record viewing is unavailable")
suspend fun searchRecords(tableName: String, primaryKey: String, search: com.kingsrook.qqq.frontend.android.core.model.RecordSearch, offset: Int, limit: Int = 25): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> =
throw UnsupportedOperationException("Record search is unavailable")
suspend fun countRecords(tableName: String, search: com.kingsrook.qqq.frontend.android.core.model.RecordSearch): Long =
throw UnsupportedOperationException("Record counts are unavailable")
suspend fun queryRelatedRecords(tableName: String, primaryKey: String, matches: Map<String, kotlinx.serialization.json.JsonElement>, offset: Int, limit: Int = 10): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> =
throw UnsupportedOperationException("Related records are unavailable")
/******************************************************************************* /*******************************************************************************
** Start server-defined process execution with explicit values and record selection; return authoritative step state. ** Start server-defined process execution with explicit values and record selection; return authoritative step state.
*******************************************************************************/ *******************************************************************************/
@@ -0,0 +1,30 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.core.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
/*******************************************************************************
** Versioned table responses preserve opaque primary keys and display values.
*******************************************************************************/
@Serializable
data class RecordData(
val tableName: String? = null,
val recordLabel: String? = null,
val values: Map<String, JsonElement> = emptyMap(),
val displayValues: Map<String, JsonElement> = emptyMap(),
) {
fun id(primaryKey: String): String? = (values[primaryKey] as? JsonPrimitive)?.contentOrNull
fun display(field: String): String = ((displayValues[field] ?: values[field]) as? JsonPrimitive)?.contentOrNull.orEmpty()
}
/*******************************************************************************
** Query records are unwrapped; a get response wraps exactly one record.
*******************************************************************************/
@Serializable
data class RecordQueryResponse(val records: List<RecordData> = emptyList())
@Serializable
data class RecordGetResponse(val record: RecordData)
@@ -0,0 +1,41 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.core.model
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.QRecordAssociation
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
data class RecordFieldGroup(val label: String, val fields: List<QFieldMetaData>)
/** Retain metadata order, suppress hidden sections, and show ungrouped fields once. */
fun QTableMetaData.recordGroups(): List<RecordFieldGroup> {
val sections = sections.orEmpty()
val hidden = sections.filter { it.isHidden || it.hidden }.flatMap { it.fieldNames.orEmpty() }.toSet()
val remaining = fields.filterValues { !it.isHidden && !it.isHeavy }.toMutableMap()
hidden.forEach(remaining::remove)
val groups = sections.filter { !it.isHidden && !it.hidden }.mapNotNull { section ->
val members = section.fieldNames.orEmpty().mapNotNull(remaining::remove)
if (members.isEmpty()) null else RecordFieldGroup(section.label ?: section.name, members)
}.toMutableList()
if (remaining.isNotEmpty()) groups.add(RecordFieldGroup(if (groups.isEmpty()) "Details" else "Other fields", remaining.values.toList()))
return groups
}
/** Resolve only declared, direct equality joins. Missing values must never become an unfiltered query. */
fun QRecordAssociation.parentMatches(parentTable: String, record: RecordData): Map<String, JsonElement>? {
val forward = join.leftTable == parentTable && join.rightTable == associatedTableName
val reverse = join.rightTable == parentTable && join.leftTable == associatedTableName
if ((!forward && !reverse) || join.joinOns.isEmpty()) return null
val matches = linkedMapOf<String, JsonElement>()
for (on in join.joinOns) {
val source = if (forward) on.leftField else on.rightField
val target = if (forward) on.rightField else on.leftField
val value = record.values[source]
if (target.isBlank() || source.isBlank() || value !is JsonPrimitive || value == JsonNull || target in matches) return null
matches[target] = value
}
return matches
}
@@ -0,0 +1,74 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.core.model
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldType
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import org.json.JSONArray
import org.json.JSONObject
import java.text.NumberFormat
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/** The same predicate is used by page and count requests. Parent matches remain mandatory. */
data class RecordSearch(
val text: String = "",
val searchFields: List<String> = emptyList(),
val matches: Map<String, JsonElement> = emptyMap(),
) {
fun filter(): JSONObject {
val criteria = JSONArray()
matches.forEach { (name, value) ->
require(name.isNotBlank() && value is JsonPrimitive && value != JsonNull)
criteria.put(JSONObject().put("fieldName", name).put("operator", "EQUALS").put("values", JSONArray("[$value]")))
}
val result = JSONObject().put("booleanOperator", "AND").put("criteria", criteria)
if (text.isNotBlank()) {
require(searchFields.isNotEmpty() && searchFields.all(String::isNotBlank))
val search = JSONArray()
searchFields.forEach { search.put(JSONObject().put("fieldName", it).put("operator", "CONTAINS").put("values", JSONArray().put(text))) }
result.put("subFilters", JSONArray().put(JSONObject().put("booleanOperator", "OR").put("criteria", search)))
}
return result
}
}
@Serializable
data class RecordCountResponse(val count: Long)
/** Search only visible scalar text fields; server metadata defines preferred fields. */
fun QTableMetaData.textFields(): List<QFieldMetaData> = recordGroups().flatMap { it.fields }
.filter { it.type == QFieldType.STRING || it.type == QFieldType.TEXT }
fun QTableMetaData.recordSearchFields(): List<String> {
val visible = textFields().map { it.name }
return searchFields.orEmpty().filter { it in visible }.ifEmpty { visible.take(3) }
}
/** Preserve server formatting and identifiers. Only typed dates and numbers receive a local fallback. */
fun RecordData.formatted(field: QFieldMetaData): String {
val value = display(field.name)
if (value.isBlank()) return "—"
if (!field.displayFormat.isNullOrBlank() && field.displayFormat != "%s") return value
return runCatching {
when (field.type) {
QFieldType.DATE -> LocalDate.parse(value).format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
QFieldType.DATE_TIME -> Instant.parse(value).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("MMM d, yyyy · h:mm a z"))
QFieldType.DECIMAL -> NumberFormat.getNumberInstance().apply { maximumFractionDigits = 8 }.format(value.toBigDecimal())
QFieldType.BOOLEAN -> when (value) { "true" -> "Yes"; "false" -> "No"; else -> value }
else -> value
}
}.getOrDefault(value)
}
/** Compact summaries follow metadata order, omit the primary key and never expose hidden groups. */
fun RecordData.summary(table: QTableMetaData): String = table.recordGroups().flatMap { it.fields }
.filter { it.name != table.primaryKeyField && display(it.name).isNotBlank() && display(it.name).length <= 80 && recordLabel?.contains(display(it.name)) != true }
.take(3).joinToString(" · ") { "${it.label}: ${formatted(it)}" }
@@ -32,5 +32,36 @@ data class QTableMetaData(
val label: String, val label: String,
val icon: QIcon? = null, val icon: QIcon? = null,
val isHidden: Boolean = false, val isHidden: Boolean = false,
val primaryKeyField: String? = null,
val fields: Map<String, QFieldMetaData> = emptyMap(),
val sections: List<QRecordSection>? = null,
val associations: List<QRecordAssociation>? = null,
val searchFields: List<String>? = null,
) { ) {
} }
@Serializable
data class QRecordSection(
val name: String,
val label: String? = null,
val fieldNames: List<String>? = null,
val isHidden: Boolean = false,
val hidden: Boolean = false,
)
@Serializable
data class QRecordAssociation(
val name: String,
val associatedTableName: String,
val join: QRecordJoin,
)
@Serializable
data class QRecordJoin(
val leftTable: String,
val rightTable: String,
val joinOns: List<QRecordJoinOn> = emptyList(),
)
@Serializable
data class QRecordJoinOn(val leftField: String, val rightField: String)
@@ -15,6 +15,93 @@ import org.junit.Test
** Modern servers return HttpOnly session cookies; both credentials and redirects remain origin-scoped. ** Modern servers return HttpOnly session cookies; both credentials and redirects remain origin-scoped.
*******************************************************************************/ *******************************************************************************/
class NetworkQQQRepositoryTest { class NetworkQQQRepositoryTest {
@Test
fun searchAndCountKeepIdenticalPredicatesAndOpaqueParentKeys() = runBlocking() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setBody("{\"count\":1}"))
server.enqueue(MockResponse().setBody("{\"records\":[]}"))
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin)
repository.setEnvironment(Environment("test", origin))
val search = com.kingsrook.qqq.frontend.android.core.model.RecordSearch("a\"b", listOf("reference", "name"), mapOf("parent" to kotlinx.serialization.json.JsonPrimitive("0007")))
assertEquals(1L, repository.countRecords("items", search))
repository.searchRecords("items", "id", search, 25)
server.takeRequest()
val counted = server.takeRequest()
assertEquals("/qqq/v1/table/items/count", counted.path)
val expected = org.json.JSONObject(counted.body.readUtf8()).getJSONObject("filter")
val query = org.json.JSONObject(server.takeRequest().body.readUtf8()).getJSONObject("filter")
assertEquals(25, query.remove("skip"))
assertEquals(25, query.remove("limit"))
query.remove("orderBys")
assertTrue(expected.similar(query))
assertEquals("0007", expected.getJSONArray("criteria").getJSONObject(0).getJSONArray("values").getString(0))
assertEquals("OR", expected.getJSONArray("subFilters").getJSONObject(0).getString("booleanOperator"))
}
}
@Test
fun relatedQueriesAreBoundedAndCannotDropTheParentFilter() = runBlocking() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setBody("""{"records":[]}"""))
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin)
repository.setEnvironment(Environment("test", origin))
repository.queryRelatedRecords("lines", "id", mapOf("parent" to kotlinx.serialization.json.JsonPrimitive("0007")), 10, 10)
server.takeRequest()
val request = server.takeRequest()
assertEquals("/qqq/v1/table/lines/query", request.path)
val filter = org.json.JSONObject(request.body.readUtf8()).getJSONObject("filter")
assertEquals(10, filter.getInt("skip"))
assertEquals(10, filter.getInt("limit"))
assertEquals("AND", filter.getString("booleanOperator"))
val criteria = filter.getJSONArray("criteria").getJSONObject(0)
assertEquals("parent", criteria.getString("fieldName"))
assertEquals("EQUALS", criteria.getString("operator"))
assertEquals("0007", criteria.getJSONArray("values").getString(0))
assertTrue(runCatching { repository.queryRelatedRecords("lines", "id", emptyMap(), 0) }.isFailure)
assertTrue(runCatching { repository.queryRelatedRecords("lines", "id", mapOf("parent" to kotlinx.serialization.json.JsonNull), 0) }.isFailure)
}
}
/*******************************************************************************
** Native browsing retains server identity, opaque ids, bounded paging and read-only routes.
*******************************************************************************/
@Test
fun recordBrowsingUsesSemanticRoutesAndPreservesIds() = runBlocking() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setBody("""{"name":"items","label":"Items","primaryKeyField":"key","fields":{"key":{"name":"key","label":"Key","type":"STRING"}}}"""))
server.enqueue(MockResponse().setBody("""{"records":[{"tableName":"items","recordLabel":"Item","values":{"key":"0007","quantity":0},"displayValues":{"quantity":"Zero"}}]}"""))
server.enqueue(MockResponse().setBody("""{"record":{"tableName":"items","values":{"key":"0007"}}}"""))
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin, additionalHeaders = { mapOf("X-QQQ-Device-Id" to "fixture-device") })
repository.setEnvironment(Environment("test", origin))
repository.setSessionUUID("fixture-session")
assertEquals("key", repository.getTableMetaData("items").primaryKeyField)
val records = repository.queryRecords("items", "key", 25, 25)
assertEquals("0007", records.single().id("key"))
assertEquals("Zero", records.single().display("quantity"))
assertEquals("0007", repository.getRecord("items", "0007").id("key"))
assertEquals("/qqq/v1/version", server.takeRequest().path)
assertEquals("/qqq/v1/metaData/table/items", server.takeRequest().path)
val query = server.takeRequest()
assertEquals("/qqq/v1/table/items/query", query.path)
assertEquals("fixture-device", query.getHeader("X-QQQ-Device-Id"))
assertEquals("sessionUUID=fixture-session", query.getHeader("Cookie"))
val filter = org.json.JSONObject(query.body.readUtf8()).getJSONObject("filter")
assertEquals(25, filter.getInt("skip"))
assertEquals(25, filter.getInt("limit"))
assertEquals("key", filter.getJSONArray("orderBys").getJSONObject(0).getString("fieldName"))
val get = server.takeRequest()
assertEquals("GET", get.method)
assertEquals("/qqq/v1/table/items/0007", get.path)
assertTrue(runCatching { repository.queryRecords("items", "key", 0, 1000) }.isFailure)
}
}
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
@@ -0,0 +1,41 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.core.model
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.QRecordAssociation
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class RecordLayoutTest {
@Test
fun sectionsPreserveOrderWithoutLeakingHiddenOrDuplicatingFields() {
val table = Json.decodeFromString<QTableMetaData>("""{
"name":"orders","label":"Orders","fields":{
"id":{"name":"id","label":"ID","type":"INTEGER"},
"secret":{"name":"secret","label":"Secret","type":"STRING"},
"other":{"name":"other","label":"Other","type":"STRING"}},
"sections":[{"name":"hidden","hidden":true,"fieldNames":["secret"]},
{"name":"overview","label":"Overview","fieldNames":["id","id","missing"]}]
}""")
assertEquals(listOf("Overview", "Other fields"), table.recordGroups().map { it.label })
assertEquals(listOf("id", "other"), table.recordGroups().flatMap { it.fields }.map { it.name })
}
@Test
fun relationshipsRespectDirectionTypesAndMissingParentKeys() {
val association = Json.decodeFromString<QRecordAssociation>("""{
"name":"Lines","associatedTableName":"lines",
"join":{"leftTable":"orders","rightTable":"lines","joinOns":[{"leftField":"key","rightField":"orderKey"}]}
}""")
val record = RecordData(values = mapOf("key" to JsonPrimitive("0007")))
assertEquals(mapOf("orderKey" to JsonPrimitive("0007")), association.parentMatches("orders", record))
assertNull(association.parentMatches("unrelated", record))
assertNull(association.parentMatches("orders", RecordData()))
val reversed = association.copy(join = association.join.copy(leftTable = "lines", rightTable = "orders",
joinOns = association.join.joinOns.map { it.copy(leftField = it.rightField, rightField = it.leftField) }))
assertEquals(association.parentMatches("orders", record), reversed.parentMatches("orders", record))
}
}
@@ -0,0 +1,46 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.core.model
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldType
import com.kingsrook.qqq.frontend.android.core.model.metadata.QRecordSection
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonArray
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RecordPresentationTest {
@Test
fun typedDatesFormatEvenWhenTheirRawJsonRepresentationDiffersFromDisplayText() {
val record = RecordData(values = mapOf("date" to JsonArray(listOf(JsonPrimitive(2026), JsonPrimitive(1), JsonPrimitive(2)))),
displayValues = mapOf("date" to JsonPrimitive("2026-01-02"), "time" to JsonPrimitive("2026-01-02T11:30:00Z")))
// The semantic server supplies %s for fields without a custom display format.
val date = record.formatted(QFieldMetaData("date", "Date", type = QFieldType.DATE, displayFormat = "%s"))
assertTrue(date.contains("2026"))
assertFalse(date.contains("2026-01-02"))
assertFalse(record.formatted(QFieldMetaData("time", "Time", type = QFieldType.DATE_TIME, displayFormat = "%s")).contains("T11:30"))
}
@Test
fun formattingPreservesIdentifiersAndServerCurrencyAndMakesEmptyValuesExplicit() {
val record = RecordData(values = mapOf("key" to JsonPrimitive("000026"), "amount" to JsonPrimitive(3.5), "enabled" to JsonPrimitive(true)), displayValues = mapOf("amount" to JsonPrimitive("$3.50")))
assertEquals("000026", record.formatted(QFieldMetaData("key", "Key", type = QFieldType.STRING)))
assertEquals("$3.50", record.formatted(QFieldMetaData("amount", "Amount", type = QFieldType.DECIMAL, displayFormat = "$%,.2f")))
assertEquals("Yes", record.formatted(QFieldMetaData("enabled", "Enabled", type = QFieldType.BOOLEAN)))
assertEquals("—", record.formatted(QFieldMetaData("missing", "Missing", type = QFieldType.STRING)))
}
@Test
fun searchAndSummaryNeverExposeHiddenSectionFields() {
val field = QFieldMetaData("visible", "Visible", type = QFieldType.STRING)
val hidden = field.copy(name = "secret", label = "Secret")
val table = QTableMetaData("test", "Test", fields = mapOf("visible" to field, "secret" to hidden),
searchFields = listOf("secret", "visible"), sections = listOf(QRecordSection("hidden", fieldNames = listOf("secret"), hidden = true)))
val record = RecordData(values = mapOf("visible" to JsonPrimitive("public"), "secret" to JsonPrimitive("private")))
assertEquals(listOf("visible"), table.recordSearchFields())
assertEquals("Visible: public", record.summary(table))
}
}
@@ -165,6 +165,10 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter?
LaunchedEffect(qViewModel.isAuthenticated, qViewModel.isLogoutRequested, businessReady) LaunchedEffect(qViewModel.isAuthenticated, qViewModel.isLogoutRequested, businessReady)
{ {
if (!businessReady) topLevelAppState.closeNavDrawer() if (!businessReady) topLevelAppState.closeNavDrawer()
if (!qViewModel.isAuthenticated || qViewModel.isLogoutRequested) {
qViewModel.qNavigator.recordWorkBlock = null
qViewModel.qNavigator.blockedNavigationMessage = null
}
if (qViewModel.isLogoutRequested) { if (qViewModel.isLogoutRequested) {
settingsOpen.value = false settingsOpen.value = false
diagnosticsOpen.value = false diagnosticsOpen.value = false
@@ -258,6 +262,11 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter?
title = { Text("Diagnostics") }, text = { Text("Server: ${qViewModel.environment.baseUrl}\nApplication version: ${QViewModel.applicationVersion}") }, title = { Text("Diagnostics") }, text = { Text("Server: ${qViewModel.environment.baseUrl}\nApplication version: ${QViewModel.applicationVersion}") },
confirmButton = { TextButton(onClick = { diagnosticsOpen.value = false }) { Text("Close") } }) confirmButton = { TextButton(onClick = { diagnosticsOpen.value = false }) { Text("Close") } })
LeaveProcessDialog(qViewModel) LeaveProcessDialog(qViewModel)
qViewModel.qNavigator.blockedNavigationMessage?.let { message ->
AlertDialog(onDismissRequest = { qViewModel.qNavigator.blockedNavigationMessage = null },
title = { Text("Photo in progress") }, text = { Text(message) },
confirmButton = { TextButton(onClick = { qViewModel.qNavigator.blockedNavigationMessage = null }) { Text("Return to photo") } })
}
} }
} }
@@ -34,6 +34,8 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.AppRoute import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.AppRoute
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.HomeRoute import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.HomeRoute
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.ProcessRoute import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.ProcessRoute
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.TableRoute
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.RecordRoute
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
@@ -63,6 +65,9 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
var leaveRequest: LeaveRequest? by mutableStateOf(null) var leaveRequest: LeaveRequest? by mutableStateOf(null)
private set private set
var recordWorkBlock: String? by mutableStateOf(null)
var blockedNavigationMessage: String? by mutableStateOf(null)
/******************************************************************************* /*******************************************************************************
** Dismiss confirmation only while cancellation is idle, preserving the in-flight process ownership decision. ** Dismiss confirmation only while cancellation is idle, preserving the in-flight process ownership decision.
*******************************************************************************/ *******************************************************************************/
@@ -74,6 +79,7 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
** Apply the same guarded process-exit policy before every destination or sign-out action. ** Apply the same guarded process-exit policy before every destination or sign-out action.
*******************************************************************************/ *******************************************************************************/
fun requestLeave(title: String, confirmLabel: String = "Leave process", alwaysConfirm: Boolean = false, action: () -> Unit) { fun requestLeave(title: String, confirmLabel: String = "Leave process", alwaysConfirm: Boolean = false, action: () -> Unit) {
recordWorkBlock?.let { blockedNavigationMessage = it; return }
if (leaveRequest != null || qViewModel.logoutInProgress) return if (leaveRequest != null || qViewModel.logoutInProgress) return
val process = qViewModel.activeProcessViewModel val process = qViewModel.activeProcessViewModel
if (alwaysConfirm || process != null && !process.getNoMoreSteps()) if (alwaysConfirm || process != null && !process.getNoMoreSteps())
@@ -156,7 +162,9 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
val instance = (qViewModel.qInstanceLoadState as? LoadState.Success<QInstance>)?.value val instance = (qViewModel.qInstanceLoadState as? LoadState.Success<QInstance>)?.value
val processName = if (entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().name else null val processName = if (entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().name else null
val appName = if (entry.destination.hasRoute<AppRoute>()) entry.toRoute<AppRoute>().name else val appName = if (entry.destination.hasRoute<AppRoute>()) entry.toRoute<AppRoute>().name else
if (entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().appName else null if (entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().appName else
if (entry.destination.hasRoute<TableRoute>()) entry.toRoute<TableRoute>().appName else
if (entry.destination.hasRoute<RecordRoute>()) entry.toRoute<RecordRoute>().appName else null
atHome = entry.destination.hasRoute<HomeRoute>() atHome = entry.destination.hasRoute<HomeRoute>()
navDepth = if (atHome) 0 else 1 navDepth = if (atHome) 0 else 1
currentAppName = if (atHome) null else appName currentAppName = if (atHome) null else appName
@@ -187,13 +195,25 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
/******************************************************************************* /*******************************************************************************
*******************************************************************************/ *******************************************************************************/
fun navigateToApp(name: String, label: String) { fun navigateToApp(name: String, label: String) {
if (currentAppName == name && qViewModel.activeProcessViewModel == null) return if (currentAppName == name && navController?.currentDestination?.hasRoute<AppRoute>() == true) return
requestLeave("Switch app?") { requestLeave("Switch app?") {
homeNow() homeNow()
navigateToRoute(AppRoute(name), label) navigateToRoute(AppRoute(name), label)
} }
} }
/*******************************************************************************
** Records use ordinary navigation, preserving any active work before leaving.
*******************************************************************************/
fun navigateToTable(name: String, label: String) = requestLeave("Open table?") {
navigateToRoute(TableRoute(name, currentAppName), label)
}
fun navigateToRecord(table: String, id: String, hasParent: Boolean = false) = requestLeave("Open record?") {
// Navigation titles feed session activity. Never place record values in telemetry.
navigateToRoute(RecordRoute(table, id, currentAppName, hasParent), "Record")
}
/******************************************************************************* /*******************************************************************************
** As part of preserving the process view-model in case of activity restarts ** As part of preserving the process view-model in case of activity restarts
** (e.g., when screen orientation changes) - manage the lifecycle of the ** (e.g., when screen orientation changes) - manage the lifecycle of the
@@ -137,9 +137,10 @@ fun QAppHomeGridLayout(qInstance: QInstance, modifier: Modifier = Modifier, app:
} }
for (child in otherChildren) { for (child in otherChildren) {
val processViewModel: ProcessViewModel = viewModel(factory = ProcessViewModel.factory) val processViewModel: ProcessViewModel = viewModel(factory = ProcessViewModel.factory)
Tile(child.label, child.icon, generalSize, "appHome.rowFor${child.type}:" + child.name) Tile(child.label, child.icon, generalSize, "appHome.rowFor${child.type.name.lowercase().replaceFirstChar { it.uppercase() }}:" + child.name)
{ {
if (child.type == QAppNodeType.PROCESS) qNavigator?.navigateToProcess(child.name, child.label, processViewModel) if (child.type == QAppNodeType.PROCESS) qNavigator?.navigateToProcess(child.name, child.label, processViewModel)
else if (child.type == QAppNodeType.TABLE) qNavigator?.navigateToTable(child.name, child.label)
else { else {
notYetImplementedDialogTitle.value = child.label notYetImplementedDialogTitle.value = child.label
notYetImplementedDialogBody.value = "This type of screen is unavailable in this client." notYetImplementedDialogBody.value = "This type of screen is unavailable in this client."
@@ -193,9 +194,7 @@ fun QAppHomeGridLayout(qInstance: QInstance, modifier: Modifier = Modifier, app:
{ {
Tile(table.label, table.icon, generalSize, "appHome.rowForTable:" + tableName) Tile(table.label, table.icon, generalSize, "appHome.rowForTable:" + tableName)
{ {
notYetImplementedDialogTitle.value = table.label qNavigator?.navigateToTable(table.name, table.label)
notYetImplementedDialogBody.value = "Tables are not yet supported in this application frontend."
isNotYetImplementedDialogOpen.value = true
} }
} }
} }
@@ -99,6 +99,15 @@ fun QApplicationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier, q
} }
} }
composable<TableRoute>() { entry ->
val route: TableRoute = entry.toRoute()
com.kingsrook.qqq.frontend.android.mobileapp.ui.records.TableScreen(qViewModel, route.name)
}
composable<RecordRoute>() { entry ->
val route: RecordRoute = entry.toRoute()
com.kingsrook.qqq.frontend.android.mobileapp.ui.records.RecordScreen(qViewModel, route.tableName, route.recordId, route.hasParent)
}
} }
} }
} }
@@ -136,3 +145,9 @@ data class AppRoute(val name: String)
@Serializable @Serializable
data class ProcessRoute(val name: String, val appName: String? = null) data class ProcessRoute(val name: String, val appName: String? = null)
@Serializable
data class TableRoute(val name: String, val appName: String? = null)
@Serializable
data class RecordRoute(val tableName: String, val recordId: String, val appName: String? = null, val hasParent: Boolean = false)
@@ -0,0 +1,283 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.mobileapp.ui.records
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.kingsrook.qqq.frontend.android.core.model.RecordData
import com.kingsrook.qqq.frontend.android.core.model.RecordSearch
import com.kingsrook.qqq.frontend.android.core.model.formatted
import com.kingsrook.qqq.frontend.android.core.model.parentMatches
import com.kingsrook.qqq.frontend.android.core.model.recordGroups
import com.kingsrook.qqq.frontend.android.core.model.recordSearchFields
import com.kingsrook.qqq.frontend.android.core.model.summary
import com.kingsrook.qqq.frontend.android.core.model.textFields
import com.kingsrook.qqq.frontend.android.core.model.metadata.QRecordAssociation
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalNavigationBackAction
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.json.JsonPrimitive
/** Attachment state stays mounted even when another tab is selected. */
data class RecordAttachmentUi(val photosVisible: Boolean, val captureVisible: Boolean, val closeCapture: () -> Unit, val available: (Boolean) -> Unit, val recordLabel: String)
val LocalRecordAttachments = compositionLocalOf<@Composable (QViewModel, String, String, RecordAttachmentUi) -> Unit> { { _, _, _, _ -> } }
/** Save page, search, filter and scroll state in this navigation entry. */
@Composable
fun TableScreen(host: QViewModel, tableName: String) {
var offset by rememberSaveable(tableName, host.sessionUUID) { mutableStateOf(0) }
var search by rememberSaveable(tableName, host.sessionUUID) { mutableStateOf("") }
var filterField by rememberSaveable(tableName, host.sessionUUID) { mutableStateOf("") }
var filterValue by rememberSaveable(tableName, host.sessionUUID) { mutableStateOf("") }
var filterOpen by remember { mutableStateOf(false) }
var reload by remember { mutableStateOf(0) }
var table by remember(tableName, host.sessionUUID) { mutableStateOf<QTableMetaData?>(null) }
var rows by remember { mutableStateOf<List<RecordData>>(emptyList()) }
var count by remember { mutableStateOf(0L) }
var loading by remember { mutableStateOf(true) }
var failed by remember { mutableStateOf(false) }
val scroll = rememberLazyListState()
LaunchedEffect(tableName, offset, search, filterField, filterValue, reload, host.sessionUUID) {
loading = true; failed = false
try {
if (search.isNotEmpty()) delay(300)
val metadata = table ?: host.qqqRepository.getTableMetaData(tableName).also { table = it }
val predicate = RecordSearch(search, metadata.recordSearchFields(), if (filterField.isNotEmpty() && filterValue.isNotEmpty()) mapOf(filterField to JsonPrimitive(filterValue)) else emptyMap())
count = host.qqqRepository.countRecords(tableName, predicate)
if (offset > 0 && offset >= count) { offset = 0; return@LaunchedEffect }
rows = host.qqqRepository.searchRecords(tableName, requireNotNull(metadata.primaryKeyField), predicate, offset)
} catch (cancelled: CancellationException) { throw cancelled }
catch (exception: Exception) { failed = true; rows = emptyList() }
finally { loading = false }
}
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
OutlinedTextField(search, { search = it; offset = 0 }, label = { Text("Search records") }, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("records.search"))
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = { filterOpen = true }, enabled = table?.textFields()?.isNotEmpty() == true) { Text(if (filterValue.isEmpty()) "Filter" else "Filter applied") }
if (search.isNotEmpty() || filterValue.isNotEmpty()) TextButton(onClick = { search = ""; filterField = ""; filterValue = ""; offset = 0 }) { Text("Clear") }
TextButton(onClick = { reload++ }) { Text("Refresh") }
}
if (loading) Text("Loading records…")
if (failed) Text("Records could not be loaded. Check your connection and access.")
if (!loading && !failed && rows.isEmpty()) Text("No matching records.")
if (loading || failed) Spacer(Modifier.weight(1f)) else LazyColumn(state = scroll, modifier = Modifier.weight(1f)) {
if (!loading && !failed) items(rows, key = { requireNotNull(table?.primaryKeyField?.let(it::id)) }) { row ->
val id = requireNotNull(table?.primaryKeyField?.let(row::id))
RecordRow(row, table!!, "records.row:$id") { host.qNavigator.navigateToRecord(tableName, id) }
}
}
if (!failed) Paging(offset, rows.size, count, 25, loading, { offset = it })
}
if (filterOpen && table != null) RecordFilterDialog(table!!, filterField, filterValue, { filterOpen = false }) { field, value ->
filterField = field; filterValue = value; offset = 0; filterOpen = false
}
}
@Composable
private fun RecordFilterDialog(table: QTableMetaData, initialField: String, initialValue: String, close: () -> Unit, apply: (String, String) -> Unit) {
val fields = table.textFields()
var field by remember { mutableStateOf(fields.firstOrNull { it.name == initialField } ?: fields.first()) }
var value by remember { mutableStateOf(initialValue) }
var choose by remember { mutableStateOf(false) }
AlertDialog(onDismissRequest = close, title = { Text("Filter records") }, text = {
Column {
Box {
TextButton(onClick = { choose = true }) { Text("Field: ${field.label}") }
DropdownMenu(choose, { choose = false }) { fields.forEach { item -> DropdownMenuItem(text = { Text(item.label) }, onClick = { field = item; choose = false }) } }
}
OutlinedTextField(value, { value = it }, label = { Text("Equals") }, singleLine = true, modifier = Modifier.testTag("records.filter.value"))
}
}, confirmButton = { TextButton(onClick = { apply(field.name, value) }) { Text("Apply filter") } }, dismissButton = { TextButton(onClick = close) { Text("Cancel") } })
}
@Composable
private fun Paging(offset: Int, size: Int, count: Long, pageSize: Int, loading: Boolean, change: (Int) -> Unit) {
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(if (loading) "Loading…" else if (count == 0L) "0 records" else "${offset + 1}–${offset + size} of $count", style = MaterialTheme.typography.labelLarge, modifier = Modifier.testTag("records.count"))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
TextButton(enabled = !loading && offset > 0, onClick = { change((offset - pageSize).coerceAtLeast(0)) }) { Text("Previous") }
TextButton(enabled = !loading && offset + size < count, onClick = { change(offset + pageSize) }) { Text("Next") }
}
}
}
@Composable
private fun RecordRow(record: RecordData, table: QTableMetaData, tag: String, open: () -> Unit) {
Row(Modifier.fillMaxWidth().testTag(tag).clickable(onClickLabel = "Open record", onClick = open).padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(record.recordLabel ?: table.primaryKeyField?.let(record::id).orEmpty(), style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(record.summary(table), style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
Text("›", Modifier.padding(start = 8.dp), style = MaterialTheme.typography.titleLarge)
}
HorizontalDivider()
}
/** Identity and Actions remain fixed above content. Record values never enter navigation telemetry. */
@Composable
fun RecordScreen(host: QViewModel, tableName: String, recordId: String, hasParent: Boolean = false) {
// Register inside the destination, after NavHost's own callback, as process screens do.
val hostBack = LocalNavigationBackAction.current
BackHandler { if (hostBack != null) hostBack() else host.qNavigator.popStack() }
var record by remember(tableName, recordId, host.sessionUUID) { mutableStateOf<RecordData?>(null) }
var table by remember(tableName, recordId, host.sessionUUID) { mutableStateOf<QTableMetaData?>(null) }
var failed by remember { mutableStateOf(false) }
var reload by remember { mutableStateOf(0) }
var tab by rememberSaveable(tableName, recordId, host.sessionUUID) { mutableStateOf(0) }
var group by rememberSaveable(tableName, recordId, host.sessionUUID) { mutableStateOf(0) }
var actions by remember { mutableStateOf(false) }
var sections by remember { mutableStateOf(false) }
var capture by rememberSaveable(tableName, recordId, host.sessionUUID) { mutableStateOf(false) }
var photoAvailable by remember(tableName, recordId, host.sessionUUID) { mutableStateOf(false) }
val attachments = LocalRecordAttachments.current
val tabState = rememberSaveableStateHolder()
LaunchedEffect(tableName, recordId, host.sessionUUID, reload) {
failed = false
try {
val metadata = host.qqqRepository.getTableMetaData(tableName)
val loaded = host.qqqRepository.getRecord(tableName, recordId)
check(metadata.primaryKeyField?.let(loaded::id) == recordId)
check(loaded.tableName == null || loaded.tableName == tableName)
table = metadata; record = loaded
} catch (cancelled: CancellationException) { throw cancelled }
catch (exception: Exception) { failed = true }
}
val current = record
if (failed) Column(Modifier.padding(16.dp)) {
Text("This record could not be loaded. It may be unavailable or access may have changed.")
Button(onClick = { reload++ }) { Text("Retry") }
} else if (current == null) Text("Loading record…", Modifier.padding(16.dp))
else {
val metadata = table!!
val related = metadata.associations.orEmpty()
val groups = metadata.recordGroups()
val photoTab = related.size + 1
Column(Modifier.fillMaxSize().testTag("record.details")) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(current.recordLabel ?: recordId, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(metadata.label, style = MaterialTheme.typography.labelMedium)
}
Box {
TextButton(onClick = { actions = true }) { Text("Actions") }
DropdownMenu(actions, { actions = false }) {
DropdownMenuItem(text = { Text("Add photo") }, enabled = photoAvailable, onClick = { actions = false; capture = true })
DropdownMenuItem(text = { Text("Refresh record") }, onClick = { actions = false; reload++ })
}
}
}
if (hasParent) TextButton(onClick = { host.qNavigator.requestLeave("Back to parent?") { host.qNavigator.navController?.popBackStack() } }) { Text("Back to parent record") }
ScrollableTabRow(tab.coerceAtMost(photoTab), edgePadding = 0.dp) {
(listOf("Details") + related.map { it.name } + "Photos").forEachIndexed { index, label ->
Tab(tab == index, onClick = { tab = index }, text = { Text(label) })
}
}
Box(Modifier.weight(1f)) {
if (tab == 0) Column(Modifier.fillMaxSize()) {
Box {
TextButton(onClick = { sections = true }) { Text("Sections (${groups.size})") }
DropdownMenu(sections, { sections = false }) { groups.forEachIndexed { index, value ->
DropdownMenuItem(text = { Text(value.label) }, onClick = { group = index; sections = false })
} }
}
Column(Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 16.dp)) {
groups.forEachIndexed { index, value ->
TextButton(onClick = { group = if (group == index) -1 else index }, modifier = Modifier.fillMaxWidth().testTag("record.section:${value.label}")) {
Text("${if (group == index) "−" else "+"} ${value.label} (${value.fields.size})", Modifier.fillMaxWidth(), style = MaterialTheme.typography.titleMedium)
}
if (group == index) value.fields.forEach { field ->
Column(Modifier.padding(vertical = 6.dp)) {
Text(field.label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(current.formatted(field))
}
}
HorizontalDivider()
}
}
}
if (tab in 1..related.size) tabState.SaveableStateProvider("related:$tab") { RelatedRecords(host, tableName, recordId, current, related[tab - 1]) }
attachments(host, tableName, recordId, RecordAttachmentUi(tab == photoTab, capture, { capture = false }, { photoAvailable = it }, current.recordLabel ?: recordId))
}
}
}
}
/** Child queries and counts always use the declared parent join. */
@Composable
private fun RelatedRecords(host: QViewModel, parentTable: String, parentId: String, parent: RecordData, association: QRecordAssociation) {
var offset by rememberSaveable(parentTable, parentId, association.name, host.sessionUUID) { mutableStateOf(0) }
var reload by remember { mutableStateOf(0) }
var rows by remember { mutableStateOf<List<RecordData>>(emptyList()) }
var table by remember { mutableStateOf<QTableMetaData?>(null) }
var count by remember { mutableStateOf(0L) }
var loading by remember { mutableStateOf(true) }
var failed by remember { mutableStateOf(false) }
val scroll = rememberLazyListState()
LaunchedEffect(parentTable, parentId, association, offset, reload, host.sessionUUID) {
loading = true; failed = false
try {
val matches = requireNotNull(association.parentMatches(parentTable, parent))
check(matches.isNotEmpty())
val child = host.qqqRepository.getTableMetaData(association.associatedTableName)
check(matches.keys.all { it in child.fields })
table = child
val predicate = RecordSearch(matches = matches)
count = host.qqqRepository.countRecords(child.name, predicate)
rows = host.qqqRepository.searchRecords(child.name, requireNotNull(child.primaryKeyField), predicate, offset, 10)
} catch (cancelled: CancellationException) { throw cancelled }
catch (exception: Exception) { failed = true; rows = emptyList() }
finally { loading = false }
}
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp).testTag("record.related:${association.associatedTableName}")) {
if (loading) Text("Loading related records…")
if (failed) { Text("Related records are unavailable."); TextButton(onClick = { reload++ }) { Text("Retry") } }
if (!loading && !failed && rows.isEmpty()) Text("No related records.")
LazyColumn(state = scroll, modifier = Modifier.weight(1f)) {
if (!loading && !failed) items(rows) { row ->
val id = table?.primaryKeyField?.let(row::id)
if (id != null) RecordRow(row, table!!, "record.related.row:${association.associatedTableName}:$id") { host.qNavigator.navigateToRecord(association.associatedTableName, id, true) }
}
}
if (!failed) Paging(offset, rows.size, count, 10, loading, { offset = it })
}
}
@@ -16,6 +16,9 @@ import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.assert import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertHasNoClickAction import androidx.compose.ui.test.assertHasNoClickAction
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.assertTextContains
@@ -74,6 +77,44 @@ class ScannerEndToEndTest {
private val client = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build() private val client = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build()
private lateinit var origin: String private lateinit var origin: String
private lateinit var adminCookie: String private lateinit var adminCookie: String
/*******************************************************************************
** Explicitly prepare a disposable QA app for an operator's manual record/photo test.
** Uses real sign-in and approval; does not alter the ordinary scanner application.
*******************************************************************************/
@Test
fun prepareManualRecordPhotoPreview() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
check(context.packageName == "io.qrun.mobile.scanner.qa")
val configFile = File(context.filesDir, "manual-photo-preview.json")
val config = try { JSONObject(configFile.readText()) } finally { configFile.delete() }
check(config.optBoolean("manualPhotoPreview"))
origin = config.getString("serverUrl").trimEnd('/')
val endpoint = URI(origin)
check(endpoint.scheme == "http" && endpoint.host == "127.0.0.1" && endpoint.port in 1024..65535 &&
endpoint.port !in setOf(8080, 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:mobileScanApp")
ui.onNodeWithTag("appHome.rowForApp:mobileScanApp").performClick()
waitForTag("appHome.rowForTable:document")
ui.onNodeWithTag("appHome.rowForTable:document").performClick()
waitForTag("records.row:1")
ui.onNodeWithTag("records.row:1").performClick()
waitFor("Take photo")
stage("manual-photo-preview-ready")
}
/******************************************************************************* /*******************************************************************************
** Exercise real Echo process/PVS persistence with explicitly simulated print acceptance. ** Exercise real Echo process/PVS persistence with explicitly simulated print acceptance.
@@ -263,11 +304,17 @@ class ScannerEndToEndTest {
stage("echo-setup-selected") stage("echo-setup-selected")
var receipt = "" var receipt = ""
for ((index, sample) in listOf("001234567890" to "LABEL-TYPE-CODE128", "000-ECHO-QR" to "QRCODE").withIndex()) { val gs1Payload = "01095011015300031120101421TEST000123"
for ((index, sample) in listOf("001234567890" to "LABEL-TYPE-CODE128", "000-ECHO-QR" to "QRCODE", gs1Payload to "EAN128").withIndex()) {
waitFor("Scanner ready. Press the handheld trigger.") waitFor("Scanner ready. Press the handheld trigger.")
context.sendBroadcast(Intent("io.qrun.mobile.scanner.SCAN").setPackage(context.packageName) context.sendBroadcast(Intent("io.qrun.mobile.scanner.SCAN").setPackage(context.packageName)
.addCategory(Intent.CATEGORY_DEFAULT).putExtra("com.symbol.datawedge.data_string", sample.first) .addCategory(Intent.CATEGORY_DEFAULT).putExtra("com.symbol.datawedge.data_string", sample.first)
.putExtra("com.symbol.datawedge.label_type", sample.second)) .putExtra("com.symbol.datawedge.label_type", sample.second).apply {
if (sample.second == "EAN128") {
putExtra("io.qrun.mobile.scanner.e2e.raw_data", sample.first.toByteArray(Charsets.US_ASCII))
putExtra("io.qrun.mobile.scanner.e2e.aim_identifier", "]C1")
}
})
var accepted: JSONObject? = null var accepted: JSONObject? = null
waitForServer { waitForServer {
accepted = rows("echoScan").singleOrNull { it.optString("runId") == runId && it.optString("barcode") == sample.first && it.optString("state") == "QUEUED" } accepted = rows("echoScan").singleOrNull { it.optString("runId") == runId && it.optString("barcode") == sample.first && it.optString("state") == "QUEUED" }
@@ -278,24 +325,29 @@ class ScannerEndToEndTest {
waitFor("Print queued $receipt", substring = true) waitFor("Print queued $receipt", substring = true)
assertScanDisplay(sample.first, sample.second, "Confirmed by server") assertScanDisplay(sample.first, sample.second, "Confirmed by server")
assertTrue("Accepted Echo scans must preserve exact type and approved-device attribution", 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("symbology") == sample.second && row.getString("rendererSymbology") == listOf("code128", "qr", "gs1-128")[index] &&
row.getString("scanSource") == "e2e-fixture" && row.getString("deviceId") == device.deviceId && row.getString("actorId") == "scanner") 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", 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()) 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) 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 }) assertSetupPinned(rows("echoFixtureRun").single { it.getString("id") == runId })
stage(if (index == 0) "echo-first-queued" else "echo-second-queued") if (index == 2) {
assertEquals("]C1", row.getString("aimIdentifier"))
assertEquals("[01]09501101530003[11]201014[21]TEST000123", row.getString("rendererValue"))
assertEquals("(01)09501101530003(11)201014(21)TEST000123", row.getString("humanReadable"))
}
stage(listOf("echo-first-queued", "echo-second-queued", "echo-gs1-queued")[index])
} }
ui.activityRule.scenario.onActivity { OrientationPolicy.LANDSCAPE.select(it) } ui.activityRule.scenario.onActivity { OrientationPolicy.LANDSCAPE.select(it) }
waitFor("Print queued $receipt", substring = true) waitFor("Print queued $receipt", substring = true)
ui.activityRule.scenario.recreate() ui.activityRule.scenario.recreate()
waitFor("Print queued $receipt", substring = true) waitFor("Print queued $receipt", substring = true)
assertScanDisplay("000-ECHO-QR", "QRCODE", "Confirmed by server") assertScanDisplay(gs1Payload, "EAN128", "Confirmed by server")
assertTrue("Recreation must retain scan phase without showing printer selection", ui.onAllNodesWithText("Choose a value").fetchSemanticsNodes().isEmpty()) 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 } 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 && assertTrue("Rotation/recreation must not accept another event", acceptedRows.size == 3 && acceptedRows.map { it.getString("eventId") }.toSet().size == 3 &&
acceptedRows.map { it.getString("printJobId") }.toSet().size == 2) acceptedRows.map { it.getString("printJobId") }.toSet().size == 3)
assertSetupPinned(rows("echoFixtureRun").single { it.getString("id") == runId }) assertSetupPinned(rows("echoFixtureRun").single { it.getString("id") == runId })
stage("echo-copies-persisted") stage("echo-copies-persisted")
stage("echo-recreation-stable") stage("echo-recreation-stable")
@@ -305,9 +357,17 @@ class ScannerEndToEndTest {
ui.onNodeWithText("Leave process", substring = false).performClick() ui.onNodeWithText("Leave process", substring = false).performClick()
waitForTag("appHome.rowForApp:echo") waitForTag("appHome.rowForApp:echo")
waitForServer { rows("echoFixtureRun").single { it.getString("id") == runId }.getString("state") == "CLOSED" } 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) assertTrue("Leaving must close this run without accepting extra print work", rows("echoScan").count { it.optString("runId") == runId } == 3)
assertCopiesPinned(rows("echoFixtureRun").single { it.getString("id") == runId }) assertCopiesPinned(rows("echoFixtureRun").single { it.getString("id") == runId })
stage("echo-run-closed") stage("echo-run-closed")
ui.onNodeWithTag("appHome.rowForApp:echo").performClick()
waitForTag("appHome.rowForTable:echoScan")
ui.onNodeWithTag("appHome.rowForTable:echoScan").performClick()
waitForTag("records.row:$receipt")
ui.onNodeWithTag("records.row:$receipt").performClick()
waitForTag("record.details")
ui.onNodeWithText("(01)09501101530003(11)201014(21)TEST000123").performScrollTo().assertIsDisplayed()
stage("echo-record-browsed")
} }
/******************************************************************************* /*******************************************************************************
@@ -315,6 +375,25 @@ class ScannerEndToEndTest {
*******************************************************************************/ *******************************************************************************/
@Test @Test
fun cameraAutoUploadsAndPendingGallerySurvivesRotationThenCleansOnRemoteLogout() { fun cameraAutoUploadsAndPendingGallerySurvivesRotationThenCleansOnRemoteLogout() {
exercisePhotoCapture(false)
}
/*******************************************************************************
** Record browsing selects the parent, and the same upload/lifecycle contract still applies.
*******************************************************************************/
@Test
fun recordDetailsCameraAndGalleryAttachToSelectedRecord() {
exercisePhotoCapture(true)
}
@Test
fun richRecordsPageGroupNavigateAndAttachPhotos() {
exercisePhotoCapture(fromRecord = true, rich = true)
}
private fun exercisePhotoCapture(fromRecord: Boolean, rich: Boolean = false) {
val parentTable = if (rich) "testOrder" else "document"
val parentId = if (rich) "26" else "1"
val instrumentation = InstrumentationRegistry.getInstrumentation() val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext val context = instrumentation.targetContext
check(context.packageName == "io.qrun.mobile.scanner.e2e") check(context.packageName == "io.qrun.mobile.scanner.e2e")
@@ -322,6 +401,14 @@ class ScannerEndToEndTest {
val config = JSONObject(configFile.readText()) val config = JSONObject(configFile.readText())
configFile.delete() configFile.delete()
origin = config.getString("serverUrl").trimEnd('/') origin = config.getString("serverUrl").trimEnd('/')
// Explicit fixture expectations, not production host classification: the rich-records scenario (rich=true) runs
// against the configured EchoDemo host, which admits an approved device's captures immediately
// (Record Files quarantineCapturedFiles=false) and so expects AVAILABLE. The generic document/process photo
// fixtures (rich=false) expect QUARANTINED.
val echoHost = rich
val savedMessage = if (echoHost) "Saved photo." else "Saved photo. Awaiting server review."
val expectedLifecycle = if (echoHost) "AVAILABLE" else "QUARANTINED"
val expectedBadge = if (echoHost) "Available" else "Awaiting review"
val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) } val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
val bytes = ByteArrayOutputStream().use { output -> bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output); output.toByteArray() } val bytes = ByteArrayOutputStream().use { output -> bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output); output.toByteArray() }
bitmap.recycle() bitmap.recycle()
@@ -364,34 +451,139 @@ class ScannerEndToEndTest {
adminCookie = loginAdmin(config.getString("adminPassword")) adminCookie = loginAdmin(config.getString("adminPassword"))
runAdmin("mobileDeviceApprove", device.deviceId) runAdmin("mobileDeviceApprove", device.deviceId)
ui.onNodeWithText("Check approval").performScrollTo().performClick() ui.onNodeWithText("Check approval").performScrollTo().performClick()
waitForTag("appHome.rowForApp:mobileScanApp") val app = if (rich) "mobileTestData" else "mobileScanApp"
ui.onNodeWithTag("appHome.rowForApp:mobileScanApp").performClick() waitForTag("appHome.rowForApp:$app")
waitForTag("appHome.rowForProcess:mobileRecordPhotos") // Accessible activation avoids a system floating control covering a tile on physical devices.
ui.onNodeWithTag("appHome.rowForProcess:mobileRecordPhotos").performClick() ui.onNodeWithTag("appHome.rowForApp:$app").performScrollTo().assertIsDisplayed()
.performSemanticsAction(SemanticsActions.OnClick) { it() }
if (fromRecord) {
waitForTag("appHome.rowForTable:$parentTable")
ui.onNodeWithTag("appHome.rowForTable:$parentTable").performScrollTo().assertIsDisplayed()
.performSemanticsAction(SemanticsActions.OnClick) { it() }
waitForTag("records.row:1")
if (rich) {
ui.onNodeWithText("Next").performClick()
waitForTag("records.row:26")
ui.onNodeWithTag("records.row:1").assertDoesNotExist()
}
ui.onNodeWithTag("records.row:$parentId").performClick()
waitForTag("record.details")
if (rich) {
ui.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
waitForTag("records.row:26")
ui.onNodeWithText("26–50 of 1000").assertIsDisplayed()
ui.onNodeWithTag("records.row:1").assertDoesNotExist()
ui.onNodeWithTag("records.search").performTextReplacement("TEST-000026")
ui.waitForIdle()
waitFor("1–1 of 1")
ui.onNodeWithTag("records.row:26").performClick()
waitForTag("record.details")
ui.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
waitFor("1–1 of 1")
ui.onNodeWithTag("records.search").assertTextContains("TEST-000026")
ui.onNodeWithText("Clear", substring = false).performClick()
waitFor("1–25 of 1000")
ui.onNodeWithText("Filter", substring = false).performClick()
waitFor("Filter records")
ui.onNodeWithTag("records.filter.value").performTextReplacement("TEST-000026")
ui.onNodeWithText("Apply filter").performClick()
waitFor("1–1 of 1")
ui.onNodeWithTag("records.row:26").performClick()
waitForTag("record.details")
listOf("Order overview", "Customer", "Shipping", "Amounts", "Warehouse", "Notes and audit").forEach {
ui.onNodeWithText("Sections (6)").performClick()
ui.onNodeWithText(it, substring = false).performClick()
ui.onNodeWithTag("record.section:$it").performScrollTo().assertIsDisplayed()
}
ui.onNodeWithText("Items", substring = false).performClick()
waitForTag("record.related.row:testOrderItem:301")
ui.onNodeWithText("1–10 of 12").assertIsDisplayed()
ui.onNodeWithTag("record.related.row:testOrderItem:1").assertDoesNotExist()
ui.onNodeWithText("Next", substring = false).performClick()
waitForTag("record.related.row:testOrderItem:311")
ui.onNodeWithTag("record.related.row:testOrderItem:311").performClick()
waitForTag("record.section:Pricing and handling")
ui.onNodeWithText("Back to parent record").performClick()
waitForTag("record.related.row:testOrderItem:311")
ui.onNodeWithText("History", substring = false).performClick()
waitForTag("record.related.row:testOrderEvent:76")
ui.onNodeWithText("1–3 of 3").assertIsDisplayed()
stage("rich-records-grouped-related-paged")
stage("rich-records-search-filter-back-preserved")
}
ui.onNodeWithText("Take photo").assertDoesNotExist()
ui.onNodeWithText("Actions", substring = false).performClick()
waitFor("Add photo")
ui.onNodeWithText("Add photo", substring = false).performClick()
} else {
waitForTag("appHome.rowForProcess:mobileRecordPhotos")
ui.onNodeWithTag("appHome.rowForProcess:mobileRecordPhotos").performClick()
}
waitFor("Take photo") waitFor("Take photo")
ui.onNodeWithText("Take photo").performScrollTo().performClick() ui.onNodeWithText("Take photo").performScrollTo().performClick()
waitFor("Saved photo. Awaiting server review.") waitFor(savedMessage)
if (echoHost) ui.onNodeWithText("Saved photo. Awaiting server review.").assertDoesNotExist()
assertEquals("Camera result must automatically upload without a second tap", 1, cameraCalls.get()) assertEquals("Camera result must automatically upload without a second tap", 1, cameraCalls.get())
assertTrue(directory.listFiles().orEmpty().none { it.name.startsWith("photo-") }) assertTrue(directory.listFiles().orEmpty().none { it.name.startsWith("photo-") })
val savedRows = jsonRequest("/qqq/v1/table/record_files_file/query", JSONObject()).getJSONArray("records") val savedRows = jsonRequest("/qqq/v1/table/record_files_file/query", JSONObject()).getJSONArray("records")
val savedFile = (0 until savedRows.length()).map { savedRows.getJSONObject(it).getJSONObject("values") } val savedFile = (0 until savedRows.length()).map { savedRows.getJSONObject(it).getJSONObject("values") }
.single { it.optString("deviceId") == device.deviceId && it.optString("sha256") == RecordFilesApi.sha256(bytes) } .single { it.optString("deviceId") == device.deviceId && it.optString("sha256") == RecordFilesApi.sha256(bytes) }
assertEquals("document", savedFile.getString("tableName")) assertEquals(parentTable, savedFile.getString("tableName"))
assertEquals("1", savedFile.getString("recordId")) assertEquals(parentId, savedFile.getString("recordId"))
assertEquals("CAPTURE", savedFile.getString("origin")) assertEquals("CAPTURE", savedFile.getString("origin"))
assertTrue(savedFile.getString("originDetail").endsWith("source CAMERA")) assertTrue(savedFile.getString("originDetail").endsWith("source CAMERA"))
assertEquals("QUARANTINED", savedFile.getString("lifecycle")) assertEquals(expectedLifecycle, savedFile.getString("lifecycle"))
assertEquals(bytes.size.toLong(), savedFile.getLong("sizeBytes")) assertEquals(bytes.size.toLong(), savedFile.getLong("sizeBytes"))
assertTrue(savedFile.getLong("attachmentId") > 0) assertTrue(savedFile.getLong("attachmentId") > 0)
val associations = jsonRequest("/qqq/v1/table/record_files_collection_item/query", JSONObject()).getJSONArray("records") val associations = jsonRequest("/qqq/v1/table/record_files_collection_item/query", JSONObject()).getJSONArray("records")
// Collection storage is one-based; the upload manifest/receipt positions are zero-based. // Collection storage is one-based; the upload manifest/receipt positions are zero-based.
assertTrue((0 until associations.length()).map { associations.getJSONObject(it).getJSONObject("values") } assertTrue((0 until associations.length()).map { associations.getJSONObject(it).getJSONObject("values") }
.any { it.optString("fileId") == savedFile.getString("id") && it.optString("tableName") == "document" && it.optString("recordId") == "1" && it.optInt("position") == 1 }) .any { it.optString("fileId") == savedFile.getString("id") && it.optString("tableName") == parentTable && it.optString("recordId") == parentId && it.optInt("position") == 1 })
stage("native-photo-camera-saved") stage("native-photo-camera-saved")
ui.onNodeWithText("Choose photo").performScrollTo().performClick() ui.onNodeWithText("Choose photo").performScrollTo().performClick()
waitFor("Photo ready to upload.") waitFor("Photo ready to upload.")
assertEquals(1, galleryCalls.get()) assertEquals(1, galleryCalls.get())
ui.onNodeWithText("Upload photo").performScrollTo().performClick()
waitFor(savedMessage)
val galleryRows = jsonRequest("/qqq/v1/table/record_files_file/query", JSONObject()).getJSONArray("records")
val gallerySaved = (0 until galleryRows.length()).map { galleryRows.getJSONObject(it).getJSONObject("values") }
.single { it.optString("deviceId") == device.deviceId && it.optString("originDetail").endsWith("source GALLERY") }
assertEquals(parentTable, gallerySaved.getString("tableName"))
assertEquals(parentId, gallerySaved.getString("recordId"))
assertEquals(RecordFilesApi.sha256(bytes), gallerySaved.getString("sha256"))
assertEquals(expectedLifecycle, gallerySaved.getString("lifecycle"))
assertTrue(gallerySaved.getLong("attachmentId") > 0)
stage("native-photo-gallery-saved")
if (fromRecord) {
ui.onNodeWithText("Done", substring = false).performClick()
ui.onNodeWithText("Photos", substring = false).performClick()
waitFor(if (rich) "3 photos" else "2 photos")
if (rich) {
ui.onNodeWithText("Preview example 27.jpg").assertDoesNotExist()
// Seeded, camera and gallery photos are all AVAILABLE, so scope thumbnail and Preview to the newly captured camera row.
val cameraRow = "record.photo:${savedFile.getString("id")}"
ui.onNodeWithTag("record.photos.list").performScrollToNode(hasTestTag(cameraRow))
val cameraThumbnail = hasTestTag("photo.image:256") and hasAnyAncestor(hasTestTag(cameraRow))
ui.waitUntil(timeoutMillis = 60_000) { runCatching { ui.onNode(cameraThumbnail).fetchSemanticsNode() }.isSuccess }
ui.onNode(hasText("Preview photo") and hasAnyAncestor(hasTestTag(cameraRow))).performClick()
waitFor("Photo preview")
waitForTag("photo.image:1600")
ui.onNodeWithText("Close preview").performClick()
stage("native-photo-approved-preview")
} else ui.onNodeWithText("Preview photo").assertDoesNotExist()
listOf(savedFile, gallerySaved).forEach { file ->
val row = "record.photo:${file.getString("id")}"
ui.onNodeWithTag("record.photos.list").performScrollToNode(hasTestTag(row))
ui.onNode(hasText(expectedBadge) and hasAnyAncestor(hasTestTag(row))).assertIsDisplayed()
}
stage("native-photo-gallery-parent-scoped")
ui.onNodeWithText("Actions", substring = false).performClick()
ui.onNodeWithText("Add photo", substring = false).performClick()
}
ui.onNodeWithText("Choose photo").performScrollTo().performClick()
waitFor("Photo ready to upload.")
assertEquals(2, galleryCalls.get())
val pending = directory.listFiles().orEmpty().single { it.name.startsWith("photo-") } val pending = directory.listFiles().orEmpty().single { it.name.startsWith("photo-") }
assertTrue(bytes.contentEquals(pending.readBytes())) assertTrue(bytes.contentEquals(pending.readBytes()))
ui.activityRule.scenario.onActivity { OrientationPolicy.LANDSCAPE.select(it) } ui.activityRule.scenario.onActivity { OrientationPolicy.LANDSCAPE.select(it) }
@@ -399,10 +591,18 @@ class ScannerEndToEndTest {
ui.activityRule.scenario.recreate() ui.activityRule.scenario.recreate()
waitFor("Photo ready to upload.") waitFor("Photo ready to upload.")
assertTrue("Rotation retains the same private original", pending.exists()) assertTrue("Rotation retains the same private original", pending.exists())
ui.onNodeWithTag("topBar.menuIcon").performClick() if (fromRecord) {
ui.onNodeWithTag("drawer.home").performClick() ui.onNodeWithText("Done", substring = false).assertIsNotEnabled()
ui.onNodeWithText("Leave process", substring = false).assertIsNotEnabled() ui.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
ui.onNodeWithText("Stay").performClick() waitFor("Photo in progress")
ui.onNodeWithText("Return to photo").performClick()
waitFor("Photo ready to upload.")
} else {
ui.onNodeWithTag("topBar.menuIcon").performClick()
ui.onNodeWithTag("drawer.home").performClick()
ui.onNodeWithText("Leave process", substring = false).assertIsNotEnabled()
ui.onNodeWithText("Stay").performClick()
}
stage("native-photo-rotation-preserved") stage("native-photo-rotation-preserved")
var sessionId = "" var sessionId = ""
@@ -24,7 +24,7 @@ internal class SessionAcceptanceClient(
*******************************************************************************/ *******************************************************************************/
fun json(path: String, body: JSONObject, expectedStatus: Int = 200): JSONObject = execute( fun json(path: String, body: JSONObject, expectedStatus: Int = 200): JSONObject = execute(
Request.Builder().url("$origin$path") Request.Builder().url("$origin$path")
.header("Cookie", cookie).post(body.toString().toRequestBody("application/json".toMediaType())).build(), expectedStatus .header("Cookie", cookie).header("Origin", origin).post(body.toString().toRequestBody("application/json".toMediaType())).build(), expectedStatus
) )
/******************************************************************************* /*******************************************************************************
@@ -32,7 +32,7 @@ internal class SessionAcceptanceClient(
*******************************************************************************/ *******************************************************************************/
fun process(name: String, suffix: String, values: JSONObject): JSONObject { fun process(name: String, suffix: String, values: JSONObject): JSONObject {
var result = execute( var result = execute(
Request.Builder().url("$origin/qqq/v1/processes/$name$suffix").header("Cookie", cookie) Request.Builder().url("$origin/qqq/v1/processes/$name$suffix").header("Cookie", cookie).header("Origin", origin)
.post( .post(
MultipartBody.Builder().setType(MultipartBody.FORM).addFormDataPart("values", values.toString()) MultipartBody.Builder().setType(MultipartBody.FORM).addFormDataPart("values", values.toString())
.addFormDataPart("stepTimeoutMillis", "10000").build() .addFormDataPart("stepTimeoutMillis", "10000").build()
@@ -77,7 +77,7 @@ internal class SessionAcceptanceClient(
*******************************************************************************/ *******************************************************************************/
fun logout() { fun logout() {
client.newCall( client.newCall(
Request.Builder().url("$origin/qqq/v1/logout").header("Cookie", cookie) Request.Builder().url("$origin/qqq/v1/logout").header("Cookie", cookie).header("Origin", origin)
.post("{}".toRequestBody("application/json".toMediaType())).build() .post("{}".toRequestBody("application/json".toMediaType())).build()
).execute().use { ).execute().use {
assertTrue("Fixture admin logout must succeed", it.isSuccessful) assertTrue("Fixture admin logout must succeed", it.isSuccessful)
@@ -29,7 +29,7 @@ internal object AimOutputProfile {
// AIM symbology character each known EMDK label must report; the modifier character is not checked here. // AIM symbology character each known EMDK label must report; the modifier character is not checked here.
private val FAMILY = mapOf( private val FAMILY = mapOf(
"CODE128" to 'C', "CODE39" to 'A', "CODE93" to 'G', "I2OF5" to 'I', "QRCODE" to 'Q', "DATAMATRIX" to 'd', "CODE128" to 'C', "EAN128" to 'C', "GS1_128" to 'C', "GS1-128" 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' "PDF417" to 'L', "AZTEC" to 'z', "EAN13" to 'E', "EAN8" to 'E', "UPCA" to 'E', "UPCE0" to 'E', "UPCE1" to 'E'
) )
@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -45,8 +44,6 @@ import androidx.lifecycle.repeatOnLifecycle
import coil3.ImageLoader import coil3.ImageLoader
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.svg.SvgDecoder import coil3.svg.SvgDecoder
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -63,7 +60,6 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
var brand by remember(server) { mutableStateOf(HostBrand()) } var brand by remember(server) { mutableStateOf(HostBrand()) }
var logo by remember(server) { mutableStateOf<ByteArray?>(null) } var logo by remember(server) { mutableStateOf<ByteArray?>(null) }
var connection by remember(server) { mutableStateOf("Checking") } var connection by remember(server) { mutableStateOf("Checking") }
var checkedAt by remember(server) { mutableStateOf("not checked") }
var showDiagnostics by remember(server) { mutableStateOf(false) } var showDiagnostics by remember(server) { mutableStateOf(false) }
val parentInputAllowed = LocalProcessInputAllowed.current val parentInputAllowed = LocalProcessInputAllowed.current
val context = LocalContext.current val context = LocalContext.current
@@ -114,7 +110,6 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
connection = "Checking" connection = "Checking"
while (isActive) { while (isActive) {
connection = if (runtime.connected()) "Connected" else "Disconnected" connection = if (runtime.connected()) "Connected" else "Disconnected"
checkedAt = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
withTimeoutOrNull(5000) { changed.receive() } withTimeoutOrNull(5000) { changed.receive() }
} }
} finally { } finally {
@@ -139,6 +134,7 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
{ {
logo?.let { AsyncImage(model = it, imageLoader = imageLoader, contentDescription = "${brand.displayName} logo", modifier = Modifier.size(40.dp)) } logo?.let { AsyncImage(model = it, imageLoader = imageLoader, contentDescription = "${brand.displayName} logo", modifier = Modifier.size(40.dp)) }
Text(brand.displayName, Modifier.weight(1f), style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(brand.displayName, Modifier.weight(1f), style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (compatible) TextButton(onClick = { showDiagnostics = true }) { Text(connection, style = MaterialTheme.typography.labelMedium) }
if (!compatible) TextButton(onClick = onChangeServer) { Text("Change server") } if (!compatible) TextButton(onClick = onChangeServer) { Text("Change server") }
} }
Box(Modifier.weight(1f)) Box(Modifier.weight(1f))
@@ -153,11 +149,6 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
if (compatibilityError != null) Button(onClick = { attempt++ }) { Text("Retry compatibility check") } if (compatibilityError != null) Button(onClick = { attempt++ }) { Text("Retry compatibility check") }
} }
} }
HorizontalDivider()
TextButton(onClick = { showDiagnostics = true }, modifier = Modifier.fillMaxWidth())
{
Text("$connection · $checkedAt · Diagnostics", color = MaterialTheme.colorScheme.onSurface)
}
} }
} }
if (showDiagnostics) DiagnosticScreen(runtime.diagnostics, onBack = { showDiagnostics = false }) if (showDiagnostics) DiagnosticScreen(runtime.diagnostics, onBack = { showDiagnostics = false })
@@ -246,7 +246,7 @@ internal class RecordFilesApi(
/******************************************************************************* /*******************************************************************************
** Cancellation closes transport; bounded JSON parsing cannot allocate an arbitrary server body. ** Cancellation closes transport; bounded JSON parsing cannot allocate an arbitrary server body.
*******************************************************************************/ *******************************************************************************/
private suspend fun execute(session: String?, builder: Request.Builder): JSONObject = withContext(Dispatchers.IO) private suspend fun send(session: String?, builder: Request.Builder): Response = withContext(Dispatchers.IO)
{ {
require(session?.isNotBlank() != false && compatibility.server == server) require(session?.isNotBlank() != false && compatibility.server == server)
val extension = compatibility.verify().extensions.singleOrNull { it.name == "record-files" } val extension = compatibility.verify().extensions.singleOrNull { it.name == "record-files" }
@@ -276,7 +276,11 @@ internal class RecordFilesApi(
} }
}) })
} }
response.use { response
}
private suspend fun execute(session: String?, builder: Request.Builder): JSONObject = withContext(Dispatchers.IO) {
send(session, builder).use {
if (!it.isSuccessful) throw UiApiException.from(it) if (!it.isSuccessful) throw UiApiException.from(it)
require(it.code == 200) { "Unexpected upload response status." } require(it.code == 200) { "Unexpected upload response status." }
val source = it.body?.source() ?: error("Missing upload response.") val source = it.body?.source() ?: error("Missing upload response.")
@@ -286,6 +290,24 @@ internal class RecordFilesApi(
} }
} }
/** Tracked owner delivery rechecks the parent and lifecycle. Never follow presigned or external URLs. */
suspend fun image(session: String, file: SavedRecordPhoto): ByteArray = withContext(Dispatchers.IO) {
require(file.lifecycle == "AVAILABLE" && file.mediaType in setOf("image/jpeg", "image/png", "image/webp"))
require(file.id.toLong() > 0 && file.size in 1..20L * 1024 * 1024)
val builder = Request.Builder().url(endpoint("/files/" + file.id + "/downloads/" + UUID.randomUUID()))
.header("X-Record-Files-Download", "1").get()
send(session, builder).use { response ->
if (!response.isSuccessful) throw UiApiException.from(response)
require(response.code == 200)
val source = response.body?.source() ?: error("Missing photo")
source.request(file.size + 1)
require(source.buffer.size == file.size) { "Invalid photo length" }
val bytes = source.readByteArray()
require(sha256(bytes) == file.sha256) { "Invalid photo checksum" }
bytes
}
}
/******************************************************************************* /*******************************************************************************
** Keep record-file boundary validation and manifest utilities alongside their transport data contracts. ** Keep record-file boundary validation and manifest utilities alongside their transport data contracts.
*******************************************************************************/ *******************************************************************************/
@@ -7,6 +7,10 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import com.kingsrook.qqq.frontend.android.mobileapp.ui.records.RecordAttachmentUi
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -42,16 +46,29 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil
Text("This photo control has unsupported metadata or no active process.", modifier.padding(16.dp)) Text("This photo control has unsupported metadata or no active process.", modifier.padding(16.dp))
return return
} }
RecordPhotoCapture(host, target, api, modifier, disabled, parameters.widgetBlock.blockId, process)
}
/*******************************************************************************
** Record details and process widgets share the same capability-gated upload implementation.
*******************************************************************************/
@Composable
internal fun RecordPhotoCapture(
host: com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel,
target: RecordPhotoTarget, api: RecordFilesApi, modifier: Modifier = Modifier, disabled: Boolean = false,
blockId: String = "record-photos",
process: com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel? = null,
recordUi: RecordAttachmentUi? = null,
) {
val context = LocalContext.current val context = LocalContext.current
val application = context.applicationContext val application = context.applicationContext
val session = host.sessionUUID val session = host.sessionUUID
val processId = process.processUUID ?: return val processId = if (process == null) "record" else process.processUUID ?: return
val processOwner = LocalViewModelStoreOwner.current as? NavBackStackEntry val processOwner = LocalViewModelStoreOwner.current as? NavBackStackEntry
if (processOwner == null) { if (processOwner == null) {
Text("Photo capture requires a process navigation scope.", modifier.padding(16.dp)) Text("Photo capture requires a record or process navigation scope.", modifier.padding(16.dp))
return return
} }
val blockId = parameters.widgetBlock.blockId
val key = "photo:$session:$processId:$blockId:${target.tableName.length}:${target.tableName}:${target.recordId}" val key = "photo:$session:$processId:$blockId:${target.tableName.length}:${target.tableName}:${target.recordId}"
val factory = remember(key, api) val factory = remember(key, api)
{ {
@@ -63,14 +80,18 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return PhotoUploadViewModel(api, session, target, File(application.cacheDir, "record-photos")) { return PhotoUploadViewModel(api, session, target, File(application.cacheDir, "record-photos")) {
host.sessionUUID == session && host.isAuthenticated && !host.isLogoutRequested && host.activeProcessViewModel === process host.sessionUUID == session && host.isAuthenticated && !host.isLogoutRequested && host.activeProcessViewModel === process
&& processOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)
} as T } as T
} }
} }
} }
val upload: PhotoUploadViewModel = viewModel(viewModelStoreOwner = processOwner, key = key, factory = factory) val upload: PhotoUploadViewModel = viewModel(viewModelStoreOwner = processOwner, key = key, factory = factory)
val owner = LocalLifecycleOwner.current val owner = LocalLifecycleOwner.current
fun setWork(message: String?) {
if (process != null) process.setClientWork(key, message) else host.qNavigator.recordWorkBlock = message
}
SideEffect { SideEffect {
process.setClientWork(key, if (upload.pending || upload.busy) "Finish, retry/check, or discard the pending photo before leaving or continuing." else null) setWork(if (upload.pending || upload.busy) "Finish, retry/check, or discard the pending photo before leaving or continuing." else null)
} }
DisposableEffect(owner, upload) DisposableEffect(owner, upload)
{ {
@@ -79,7 +100,9 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil
onDispose { onDispose {
owner.lifecycle.removeObserver(observer) owner.lifecycle.removeObserver(observer)
upload.pause() upload.pause()
if (!upload.pending) process.setClientWork(key, null) // A disposed configuration must not clear the retained upload's navigation guard.
// Authentication teardown clears record work separately in the host.
if (!upload.pending) setWork(null)
} }
} }
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> upload.cameraResult(success) } val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> upload.cameraResult(success) }
@@ -89,9 +112,13 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil
upload.galleryResult(media) { application.contentResolver.openInputStream(uri) } upload.galleryResult(media) { application.contentResolver.openInputStream(uri) }
} }
} }
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) SideEffect { recordUi?.available?.invoke(upload.limits != null) }
if (recordUi?.photosVisible == true) SavedRecordPhotos(host, target, api, upload.saved)
val controls: @Composable () -> Unit = {
Column((if (recordUi == null) modifier else modifier.verticalScroll(rememberScrollState())).padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp))
{ {
Text(target.title ?: "Record photos", style = MaterialTheme.typography.titleMedium) if (recordUi == null) Text(target.title ?: "Record photos", style = MaterialTheme.typography.titleMedium)
if (recordUi != null) Text(recordUi.recordLabel, style = MaterialTheme.typography.labelLarge)
target.instructions?.let { Text(it) } target.instructions?.let { Text(it) }
when { when {
upload.checking -> Text("Checking photo permissions…") upload.checking -> Text("Checking photo permissions…")
@@ -105,7 +132,7 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil
if ("image/jpeg" in upload.limits!!.mediaTypes) if ("image/jpeg" in upload.limits!!.mediaTypes)
Button(enabled = available, onClick = { Button(enabled = available, onClick = {
val file = upload.prepareCamera() val file = upload.prepareCamera()
process.setClientWork(key, "Return from the camera and finish or discard the photo before leaving.") setWork("Return from the camera and finish or discard the photo before leaving.")
val uri = FileProvider.getUriForFile(context, context.packageName + ".photos", file) val uri = FileProvider.getUriForFile(context, context.packageName + ".photos", file)
try { try {
camera.launch(uri) camera.launch(uri)
@@ -123,6 +150,13 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil
} }
if (upload.busy) Text("Working on photo…") if (upload.busy) Text("Working on photo…")
if (upload.message.isNotBlank()) Text(upload.message) if (upload.message.isNotBlank()) Text(upload.message)
if (upload.pending) Text("This app uploads while open and connected. Keep this process open until the server confirms the photo.") if (upload.pending) Text("This app uploads while open and connected. Keep this screen open until the server confirms the photo.")
} }
}
if (recordUi == null) controls()
else if (recordUi.captureVisible) AlertDialog(
onDismissRequest = { if (!upload.pending && !upload.busy) recordUi.closeCapture() },
title = { Text("Add photo") }, text = controls,
confirmButton = { TextButton(enabled = !upload.pending && !upload.busy, onClick = recordUi.closeCapture) { Text("Done") } }
)
} }
@@ -0,0 +1,138 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.kingsrook.qqq.frontend.android.core.model.RecordData
import com.kingsrook.qqq.frontend.android.core.model.RecordSearch
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonPrimitive
/** Only retained file identity and public display metadata reach the gallery. */
internal data class SavedRecordPhoto(val id: String, val name: String, val mediaType: String, val size: Long, val sha256: String, val lifecycle: String) {
companion object {
fun parse(row: RecordData, target: RecordPhotoTarget): SavedRecordPhoto {
require(row.id("tableName") == target.tableName && row.id("recordId") == target.recordId)
return SavedRecordPhoto(requireNotNull(row.id("id")), row.display("fileName"), row.id("mediaType").orEmpty(),
requireNotNull(row.id("sizeBytes")).toLong(), row.id("sha256").orEmpty(), row.id("lifecycle").orEmpty())
}
}
}
/** Parent-scoped metadata is visible even while the original awaits review. */
@Composable
internal fun SavedRecordPhotos(host: QViewModel, target: RecordPhotoTarget, api: RecordFilesApi, saved: Boolean) {
var offset by rememberSaveable(target.tableName, target.recordId, host.sessionUUID) { mutableStateOf(0) }
var refresh by remember { mutableStateOf(0) }
var rows by remember { mutableStateOf<List<SavedRecordPhoto>>(emptyList()) }
var hasNext by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(true) }
var failed by remember { mutableStateOf(false) }
var preview by remember { mutableStateOf<SavedRecordPhoto?>(null) }
val admission = remember { Semaphore(1) }
LaunchedEffect(target, host.sessionUUID, offset, refresh, saved) {
loading = true; failed = false
try {
val predicate = RecordSearch("image/", listOf("mediaType"), mapOf("tableName" to JsonPrimitive(target.tableName), "recordId" to JsonPrimitive(target.recordId)))
// Record Files intentionally denies aggregate counts. One look-ahead row keeps paging bounded.
val page = host.qqqRepository.searchRecords("record_files_file", "id", predicate, offset, 11).map { SavedRecordPhoto.parse(it, target) }
hasNext = page.size > 10
rows = page.take(10)
} catch (cancelled: CancellationException) { throw cancelled }
catch (exception: Exception) { failed = true; rows = emptyList() }
finally { loading = false }
}
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp).testTag("record.photos")) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(if (loading) "Loading photos…" else if (failed) "Photos unavailable" else "${offset + rows.size}${if (hasNext) "+" else ""} photos", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium)
TextButton(onClick = { refresh++ }) { Text("Refresh photos") }
}
if (failed) Text("Photos could not be loaded. Check your connection and access, then refresh.")
if (!failed && !loading && rows.isEmpty()) Text("No photos attached. Use Actions → Add photo.")
LazyColumn(Modifier.weight(1f).testTag("record.photos.list"), verticalArrangement = Arrangement.spacedBy(12.dp)) {
items(rows, key = { it.id }) { file ->
Row(Modifier.testTag("record.photo:${file.id}"), verticalAlignment = Alignment.CenterVertically) {
if (file.lifecycle == "AVAILABLE") PhotoImage(api, host.sessionUUID, file, admission, 256, Modifier.size(72.dp))
else Text("Photo", Modifier.size(72.dp).padding(8.dp), style = MaterialTheme.typography.labelMedium)
Column(Modifier.weight(1f).padding(start = 8.dp)) {
Text(file.name, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
Text(if (file.lifecycle == "QUARANTINED") "Awaiting review" else if (file.lifecycle == "AVAILABLE") "Available" else "Unavailable", style = MaterialTheme.typography.labelMedium)
if (file.lifecycle == "AVAILABLE") TextButton(onClick = { preview = file }) { Text("Preview photo") }
}
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
TextButton(enabled = !loading && offset > 0, onClick = { offset = (offset - 10).coerceAtLeast(0) }) { Text("Previous photos") }
TextButton(enabled = !loading && !failed && hasNext, onClick = { offset += 10 }) { Text("Next photos") }
}
}
preview?.let { file ->
AlertDialog(onDismissRequest = { preview = null }, title = { Text("Photo preview") }, text = {
Column { Text(file.name); PhotoImage(api, host.sessionUUID, file, admission, 1600, Modifier.fillMaxWidth()) }
}, confirmButton = { TextButton(onClick = { preview = null }) { Text("Close preview") } })
}
}
/** Decode a bounded bitmap in memory, without an image-loader disk cache or external URL. */
@Composable
private fun PhotoImage(api: RecordFilesApi, session: String, file: SavedRecordPhoto, admission: Semaphore, edge: Int, modifier: Modifier) {
var bitmap by remember(session, file, edge) { mutableStateOf<Bitmap?>(null) }
var failed by remember(session, file) { mutableStateOf(false) }
var retry by remember { mutableStateOf(0) }
LaunchedEffect(session, file, edge, retry) {
failed = false
try {
bitmap = admission.withPermit {
val bytes = api.image(session, file)
withContext(Dispatchers.Default) {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
require(options.outWidth in 1..100000 && options.outHeight in 1..100000)
options.inSampleSize = 1
while (maxOf(options.outWidth, options.outHeight) / options.inSampleSize > edge) options.inSampleSize *= 2
options.inJustDecodeBounds = false
requireNotNull(BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options))
}
}
} catch (cancelled: CancellationException) { throw cancelled }
catch (exception: Exception) { failed = true }
}
val image = bitmap
if (image != null) Image(image.asImageBitmap(), "Attached photo", modifier.testTag("photo.image:$edge"), contentScale = ContentScale.Fit)
else if (failed) TextButton(onClick = { retry++ }, modifier = modifier) { Text("Retry image") }
else Text("Loading image…", modifier, style = MaterialTheme.typography.labelSmall)
}
@@ -338,6 +338,10 @@ class ScannerActivity : ComponentActivity() {
} }
} }
}, },
com.kingsrook.qqq.frontend.android.mobileapp.ui.records.LocalRecordAttachments provides { model, tableName, recordId, recordUi ->
RecordPhotoCapture(model, RecordPhotoTarget(tableName, recordId, "Record photos", null), recordFiles, recordUi = recordUi,
disabled = !com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed.current())
},
LocalCustomWidgetRenderer provides { parameters, modifier, disabled -> LocalCustomWidgetRenderer provides { parameters, modifier, disabled ->
if (parameters.widgetBlock.blockType == "SCANNER") { if (parameters.widgetBlock.blockType == "SCANNER") {
ScannerWidget( ScannerWidget(
@@ -60,6 +60,54 @@ class PhotoUploadViewModelTest {
} }
} }
/*******************************************************************************
** A host that does not opt in (generic servers) commits QUARANTINED: the saved photo
** is reported as awaiting server review, never as available.
*******************************************************************************/
@Test
fun quarantinedReceiptOnOtherHostsReportsAwaitingReview() = runTest()
{
val dispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(dispatcher)
try {
val transport = FakeTransport().apply { lifecycle = "QUARANTINED" }
val model = PhotoUploadViewModel(transport, "session", target, files.newFolder(), dispatcher) { true }
advanceUntilIdle()
model.prepareCamera().writeBytes(original)
model.cameraResult(true)
advanceUntilIdle()
assertTrue(model.saved)
assertEquals("Saved photo. Awaiting server review.", model.message)
} finally {
Dispatchers.resetMain()
}
}
/*******************************************************************************
** An Echo host that admits approved-device captures commits AVAILABLE: the same unchanged
** receipt flow reports a plain saved photo, with the exact original still verified.
*******************************************************************************/
@Test
fun availableReceiptOnEchoHostReportsSavedWithoutReviewWait() = runTest()
{
val dispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(dispatcher)
try {
val transport = FakeTransport().apply { lifecycle = "AVAILABLE" }
val model = PhotoUploadViewModel(transport, "session", target, files.newFolder(), dispatcher) { true }
advanceUntilIdle()
model.prepareCamera().writeBytes(original)
model.cameraResult(true)
advanceUntilIdle()
assertTrue(model.saved)
assertEquals("Saved photo.", model.message)
assertArrayEquals(original, transport.uploaded)
assertEquals("CAMERA", model.intent!!.origin)
} finally {
Dispatchers.resetMain()
}
}
/******************************************************************************* /*******************************************************************************
** Clearing the process cancels in-flight transport before removing only the app-owned temporary original. ** Clearing the process cancels in-flight transport before removing only the app-owned temporary original.
*******************************************************************************/ *******************************************************************************/
@@ -215,6 +263,7 @@ class PhotoUploadViewModelTest {
var finalizes = 0 var finalizes = 0
var cancels = 0 var cancels = 0
var statusCalls = 0 var statusCalls = 0
var lifecycle = "QUARANTINED"
var loseCreate = false var loseCreate = false
var loseFinalize = false var loseFinalize = false
var chunkGate: CompletableDeferred<Unit>? = null var chunkGate: CompletableDeferred<Unit>? = null
@@ -282,7 +331,7 @@ class PhotoUploadViewModelTest {
"items", JSONArray().put( "items", JSONArray().put(
JSONObject() JSONObject()
.put("itemId", intent.itemId).put("position", 0).put("fileId", "file").put("versionId", 2).put("attachmentId", 3) .put("itemId", intent.itemId).put("position", 0).put("fileId", "file").put("versionId", 2).put("attachmentId", 3)
.put("sha256", intent.sha256).put("sizeBytes", intent.size).put("lifecycleAtCommit", "QUARANTINED") .put("sha256", intent.sha256).put("sizeBytes", intent.size).put("lifecycleAtCommit", lifecycle)
) )
) else JSONObject.NULL ) else JSONObject.NULL
return JSONObject().put("uploadId", id).put("state", state).put("receipt", receipt) return JSONObject().put("uploadId", id).put("state", state).put("receipt", receipt)
@@ -20,6 +20,33 @@ import org.junit.Test
** Verify discovery, immutable request replay and exact persistence receipts without a device. ** Verify discovery, immutable request replay and exact persistence receipts without a device.
*******************************************************************************/ *******************************************************************************/
class RecordFilesApiTest { class RecordFilesApiTest {
@Test
fun previewsUseTrackedSameOriginDeliveryAndVerifyBytes() = runBlocking() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(version(true)))
server.enqueue(MockResponse().setBody("""{"version":"1.0.0","blockType":"RECORD_FILES_CAPTURE","widgetType":"composite"}"""))
server.enqueue(MockResponse().setBody("""{"canUpload":false}"""))
val origin = server.url("/").toString()
val api = RecordFilesApi(origin, { mapOf("X-QQQ-Device-Token" to "synthetic") }, UiApiCompatibility(origin))
api.capabilities("synthetic-session", target)
repeat(3) { server.takeRequest() }
val file = SavedRecordPhoto("12", "test.jpg", "image/jpeg", bytes.size.toLong(), RecordFilesApi.sha256(bytes), "AVAILABLE")
server.enqueue(MockResponse().setBody(okio.Buffer().write(bytes)))
assertArrayEquals(bytes, api.image("synthetic-session", file))
val request = server.takeRequest()
assertTrue(request.path!!.startsWith("/qqq/v1/qbit/record-files/files/12/downloads/"))
assertEquals("1", request.getHeader("X-Record-Files-Download"))
assertEquals("android/qqq-mobile", request.getHeader("X-QQQ-Client"))
assertEquals("synthetic", request.getHeader("X-QQQ-Device-Token"))
assertTrue(runCatching { api.image("synthetic-session", file.copy(lifecycle = "QUARANTINED")) }.isFailure)
server.enqueue(MockResponse().setBody(okio.Buffer().write(byteArrayOf(4, 5, 6))))
assertTrue(runCatching { api.image("synthetic-session", file) }.isFailure)
server.takeRequest()
server.enqueue(MockResponse().setBody(okio.Buffer().write(byteArrayOf(1, 2, 3, 4))))
assertTrue(runCatching { api.image("synthetic-session", file) }.isFailure)
}
}
private val target = RecordPhotoTarget("document", "opaque/key", "Photos", null) private val target = RecordPhotoTarget("document", "opaque/key", "Photos", null)
private val bytes = byteArrayOf(1, 2, 3) private val bytes = byteArrayOf(1, 2, 3)
@@ -0,0 +1,8 @@
// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0
// Record details and synthetic camera/gallery results against an isolated owner fixture.
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-record-photo-e2e.mjs --serial DEVICE_SERIAL --fixture PRIVATE_RECORD_FILES_FIXTURE.json');
}
await runScannerE2e({ physical: true, serial: args[1], scenario: 'record-photos', fixture: args[3] });
+15 -6
View File
@@ -72,14 +72,15 @@ export async function runScannerE2e(options = {}) {
if (fixture) { if (fixture) {
const url = echo ? validateEchoFixtureDescriptor(fixture) : 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) : const purposeValid = deviceScreen ? fixture.purpose === 'device-screen-qa' && !['57832', '51631'].includes(url.port) :
echo || selectedScenario === 'photos' && fixture.processName === 'mobileRecordPhotos' && fixture.tableName === 'document' && String(fixture.recordId) === '1'; echo || selectedScenario === 'rich-records' && fixture.purpose === 'rich-records-e2e' && !['57832', '51631'].includes(url.port) ||
['photos', 'record-photos'].includes(selectedScenario) && 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 !== '/' || 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' || fixture.username !== 'scanner' || fixture.adminUsername !== 'admin' ||
typeof fixture.password !== 'string' || fixture.password.length < 16 || typeof fixture.adminPassword !== 'string' || fixture.adminPassword.length < 16) { typeof fixture.password !== 'string' || fixture.password.length < 16 || typeof fixture.adminPassword !== 'string' || fixture.adminPassword.length < 16) {
throw new Error('Physical acceptance requires its purpose-matched isolated loopback fixture and private credential file.'); throw new Error('Physical acceptance requires its purpose-matched isolated loopback fixture and private credential file.');
} }
} }
if (selectedScenario === 'photos' && !fixture) throw new Error('The photos scenario requires an explicit isolated Record Files fixture.'); if (['photos', 'record-photos', 'rich-records'].includes(selectedScenario) && !fixture) throw new Error('The photos scenario requires an explicit isolated Record Files fixture.');
const adbPrefix = serial ? ['-s', serial] : []; const adbPrefix = serial ? ['-s', serial] : [];
const physicalSession = physical && selectedScenario === 'session-monitor'; const physicalSession = physical && selectedScenario === 'session-monitor';
const packageName = deviceScreen || physicalSession ? 'io.qrun.mobile.scanner.qa' : 'io.qrun.mobile.scanner.e2e'; const packageName = deviceScreen || physicalSession ? 'io.qrun.mobile.scanner.qa' : 'io.qrun.mobile.scanner.e2e';
@@ -138,7 +139,7 @@ export async function runScannerE2e(options = {}) {
} }
const manufacturer = (await adbRun('shell', 'getprop', 'ro.product.manufacturer')).trim(); const manufacturer = (await adbRun('shell', 'getprop', 'ro.product.manufacturer')).trim();
const model = (await adbRun('shell', 'getprop', 'ro.product.model')).trim(); const model = (await adbRun('shell', 'getprop', 'ro.product.model')).trim();
if (!/^zebra/i.test(manufacturer) || !/^TC22(?:$|[ _-])/i.test(model)) throw new Error('This physical acceptance entry point is limited to the approved Zebra TC22.'); if (!/^zebra/i.test(manufacturer) || !/^(?:TC22|MC9400)(?:$|[ _-])/i.test(model)) throw new Error('This physical acceptance entry point is limited to Zebra TC22 and MC9400.');
const installed = (await adbRun('shell', 'pm', 'list', 'packages', '-u', packageName)).split(/\r?\n/).map(line => line.trim()); const installed = (await adbRun('shell', 'pm', 'list', 'packages', '-u', packageName)).split(/\r?\n/).map(line => line.trim());
if (installed.includes('package:' + packageName) || installed.includes('package:' + packageName + '.test')) { if (installed.includes('package:' + packageName) || installed.includes('package:' + packageName + '.test')) {
throw new Error('Disposable test packages already exist. Refusing to replace or clear an existing installation.'); throw new Error('Disposable test packages already exist. Refusing to replace or clear an existing installation.');
@@ -164,7 +165,7 @@ export async function runScannerE2e(options = {}) {
} }
console.log(`Native ${scenario} acceptance passed (local UI, no server).`); console.log(`Native ${scenario} acceptance passed (local UI, no server).`);
}; };
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']; const allowedScenarios = [...(physical ? ['navigation', 'photos', 'record-photos', 'device-screen', 'echo', 'rich-records'] : []), ...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 (selectedScenario && !allowedScenarios.includes(selectedScenario)) throw new Error('Unknown native E2E scenario.');
if (Object.hasOwn(localScenarios, selectedScenario)) { if (Object.hasOwn(localScenarios, selectedScenario)) {
const temporary = []; const temporary = [];
@@ -324,6 +325,8 @@ export async function runScannerE2e(options = {}) {
{ mode: 'compatible', name: 'admin-forms', test: 'adminFormsApproveRevokeAndCancel', requireCancel: true }, { mode: 'compatible', name: 'admin-forms', test: 'adminFormsApproveRevokeAndCancel', requireCancel: true },
{ mode: 'compatible', name: 'session-monitor', test: 'sessionActivityMirrorsActualScreenAndAdminEndForcesFreshLogin', requireSession: true }, { mode: 'compatible', name: 'session-monitor', test: 'sessionActivityMirrorsActualScreenAndAdminEndForcesFreshLogin', requireSession: true },
...(fixture ? [{ mode: 'compatible', name: 'photos', test: 'cameraAutoUploadsAndPendingGallerySurvivesRotationThenCleansOnRemoteLogout', requirePhotos: true }] : []), ...(fixture ? [{ mode: 'compatible', name: 'photos', test: 'cameraAutoUploadsAndPendingGallerySurvivesRotationThenCleansOnRemoteLogout', requirePhotos: true }] : []),
...(fixture ? [{ mode: 'compatible', name: 'record-photos', test: 'recordDetailsCameraAndGalleryAttachToSelectedRecord', requirePhotos: true }] : []),
...(fixture ? [{ mode: 'compatible', name: 'rich-records', test: 'richRecordsPageGroupNavigateAndAttachPhotos', requirePhotos: true }] : []),
...(echo ? [{ mode: 'compatible', name: 'echo', test: 'echoSelectsOnceQueuesTwoScansAndClosesOnAppsLeave', requireCancel: true }] : []), ...(echo ? [{ mode: 'compatible', name: 'echo', test: 'echoSelectsOnceQueuesTwoScansAndClosesOnAppsLeave', requireCancel: true }] : []),
]) { ]) {
if (selectedScenario && selectedScenario !== (phase.name ?? phase.mode)) continue; if (selectedScenario && selectedScenario !== (phase.name ?? phase.mode)) continue;
@@ -361,16 +364,22 @@ export async function runScannerE2e(options = {}) {
} }
reports.push({ scenario: phase.name ?? phase.mode, ...proxy.verify(!phase.expectedError, Boolean(phase.requireCancel), Boolean(phase.requireSession), Boolean(phase.requirePhotos)) }); reports.push({ scenario: phase.name ?? phase.mode, ...proxy.verify(!phase.expectedError, Boolean(phase.requireCancel), Boolean(phase.requireSession), Boolean(phase.requirePhotos)) });
if (echo) { 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']; 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-gs1-queued', 'echo-record-browsed', '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.'); 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, 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)); artifacts: echoArtifacts, completedStages: expectedStages, acceptedScans: 3, ...reports.at(-1), physicalScannerQualified: false, physicalPrintingQualified: false }, null, 2));
} }
console.log(`Native semantic E2E passed: ${phase.name ?? phase.mode}.`); console.log(`Native semantic E2E passed: ${phase.name ?? phase.mode}.`);
} }
await writeFile(join(evidence, 'semantic-contract.json'), `${JSON.stringify(reports, null, 2)}\n`); 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.'); console.log('All native requests in the selected scenarios matched the published semantic API.');
} catch (error) { } catch (error) {
if (physical && selectedScenario === 'rich-records' && installedByRun.has(packageName)) {
const screenshot = '/data/local/tmp/qqq-rich-records-failure.png';
await adbRun('shell', 'screencap', '-p', screenshot)
.then(() => adbRun('pull', screenshot, join(repository, 'scanner-host/build/native-device-e2e/rich-records-failure.png')))
.finally(() => adbRun('shell', 'rm', '-f', screenshot)).catch(() => {});
}
if (echo && echoEvidence) await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({ if (echo && echoEvidence) await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({
status: 'FAILED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts, completedStages: [...stages], status: 'FAILED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts, completedStages: [...stages],
}, null, 2)); }, null, 2));