diff --git a/docs/RECORD-UX-2026-11-05.md b/docs/RECORD-UX-2026-11-05.md new file mode 100644 index 0000000..8df637e --- /dev/null +++ b/docs/RECORD-UX-2026-11-05.md @@ -0,0 +1,44 @@ +# 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. Photo transfer remains online-only, idempotent and protected by parent permissions and device/session authorization. + +## 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). diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index f9beee1..d8d368e 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,36 +1,29 @@ # 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 stable or fully qualified 1.0; a signed preview is published. 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 a development preview, not production 1.0 qualification. -## Source +## Record browsing and photos -- 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. +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. -## Local demo: accepted +**Record → Actions → Add photo** opens Take photo / Choose photo. Saved photos show parent-scoped lifecycle status and approved thumbnails/previews. Quarantined originals remain unavailable. Existing upload identity, retry handling, server authorization, pending-navigation guards and logout cleanup are preserved. -- 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). +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). -## MC9400 (second device) +## Verification and installation -- 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. +- **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. -## Signed release +## Source and runtime -- 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. +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. -## Open (not claimed) +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 this debug update. It is not a stable release or an automatic signing/publication pipeline; see [RELEASING](RELEASING.md). -- 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)). +## Accepted and outstanding hardware work + +The November 4 TC22 operator group pass remains accepted; do not request it again. MC9400 remains installed, signed in and approved, with a physical trigger/backend check outstanding. Physical GS1 print/rescan, native provider callback, signed-build HTTPS qualification, 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). diff --git a/docs/TODO.md b/docs/TODO.md index cf681a3..26be251 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,36 +1,21 @@ # 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. -- [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. +- [ ] Qualify actual GS1 label media and physical printed-label rescan. Synthetic capture/render evidence does not close this check. +- [ ] Confirm one physical MC9400 trigger scan in the backend. The normal app is installed, signed in and approved. +- [ ] Qualify the production-signed build against an HTTPS server: enrollment, scanner path, navigation and update. +- [ ] Verify an actual native Authentik/provider callback, logout and account switching. +- [ ] Distribute the qualified signed build to remaining devices; validate MDM/company profile separately. The published signed preview predates the current record UX update. +- [ ] Automatic APK signing/publication remains owned by the shared CI design; no new pipeline is configured here. -## MC9400 remaining check +## Dependencies and broader coverage -- [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). +- [ ] Adopt public, coherent Generation/Record Files/Scripting owner inputs through a reviewed companion-server change and a fresh complete gate. +- [ ] Verify company profile / EHS / OEMConfig / MDM deployment ([DEVICE-PROVISIONING](DEVICE-PROVISIONING.md), [COMPANY-PROFILE](COMPANY-PROFILE.md)). +- [ ] Resolve applicable [legacy source follow-ups](LEGACY-SOURCE-FOLLOWUPS.md). +- [ ] 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. -## 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. +Offline queueing and background synchronization remain outside version one. The November 4 TC22 operator pass is accepted and should not be requested again. diff --git a/docs/history/README.md b/docs/history/README.md index 82cab68..fbf08e3 100644 --- a/docs/history/README.md +++ b/docs/history/README.md @@ -1,5 +1,7 @@ # 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). - [SESSION-STATE-through-2026-11-04.md](SESSION-STATE-through-2026-11-04.md): rolling session log, newest first. diff --git a/docs/history/SESSION-STATE-before-record-ux-2026-11-05.md b/docs/history/SESSION-STATE-before-record-ux-2026-11-05.md new file mode 100644 index 0000000..484ef04 --- /dev/null +++ b/docs/history/SESSION-STATE-before-record-ux-2026-11-05.md @@ -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)). diff --git a/docs/history/TODO-before-record-ux-2026-11-05.md b/docs/history/TODO-before-record-ux-2026-11-05.md new file mode 100644 index 0000000..ca6e209 --- /dev/null +++ b/docs/history/TODO-before-record-ux-2026-11-05.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. diff --git a/docs/images/record-ux-2026-11-05/tc22-ux-actions.png b/docs/images/record-ux-2026-11-05/tc22-ux-actions.png new file mode 100644 index 0000000..3a980e6 Binary files /dev/null and b/docs/images/record-ux-2026-11-05/tc22-ux-actions.png differ diff --git a/docs/images/record-ux-2026-11-05/tc22-ux-add-photo.png b/docs/images/record-ux-2026-11-05/tc22-ux-add-photo.png new file mode 100644 index 0000000..91e0c6a Binary files /dev/null and b/docs/images/record-ux-2026-11-05/tc22-ux-add-photo.png differ diff --git a/docs/images/record-ux-2026-11-05/tc22-ux-back-preserved.png b/docs/images/record-ux-2026-11-05/tc22-ux-back-preserved.png new file mode 100644 index 0000000..d0cc50f Binary files /dev/null and b/docs/images/record-ux-2026-11-05/tc22-ux-back-preserved.png differ diff --git a/docs/images/record-ux-2026-11-05/tc22-ux-display-record.png b/docs/images/record-ux-2026-11-05/tc22-ux-display-record.png new file mode 100644 index 0000000..79267d8 Binary files /dev/null and b/docs/images/record-ux-2026-11-05/tc22-ux-display-record.png differ diff --git a/docs/images/record-ux-2026-11-05/tc22-ux-history.png b/docs/images/record-ux-2026-11-05/tc22-ux-history.png new file mode 100644 index 0000000..e406c14 Binary files /dev/null and b/docs/images/record-ux-2026-11-05/tc22-ux-history.png differ diff --git a/docs/images/record-ux-2026-11-05/tc22-ux-items.png b/docs/images/record-ux-2026-11-05/tc22-ux-items.png new file mode 100644 index 0000000..6abc45f Binary files /dev/null and b/docs/images/record-ux-2026-11-05/tc22-ux-items.png differ diff --git a/docs/images/record-ux-2026-11-05/tc22-ux-photos.png b/docs/images/record-ux-2026-11-05/tc22-ux-photos.png new file mode 100644 index 0000000..8b31e51 Binary files /dev/null and b/docs/images/record-ux-2026-11-05/tc22-ux-photos.png differ diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt index 295201e..48e5087 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt @@ -120,6 +120,48 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs 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 { + 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 { + 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, offset: Int, limit: Int): List { + 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 { diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt index 4687db6..5061f95 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt @@ -134,6 +134,21 @@ interface QQQApiService { @GET(BASE_PATH + "metaData/process/{processName}") 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. *******************************************************************************/ diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt index ea95a5d..afd85c8 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt @@ -106,6 +106,27 @@ interface QQQRepository { *******************************************************************************/ 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 = + 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 = + 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, offset: Int, limit: Int = 10): List = + throw UnsupportedOperationException("Related records are unavailable") + /******************************************************************************* ** Start server-defined process execution with explicit values and record selection; return authoritative step state. *******************************************************************************/ diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/RecordData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/RecordData.kt new file mode 100644 index 0000000..82e33ab --- /dev/null +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/RecordData.kt @@ -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 = emptyMap(), + val displayValues: Map = 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 = emptyList()) + +@Serializable +data class RecordGetResponse(val record: RecordData) diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/RecordLayout.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/RecordLayout.kt new file mode 100644 index 0000000..ab7ca7a --- /dev/null +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/RecordLayout.kt @@ -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) + +/** Retain metadata order, suppress hidden sections, and show ungrouped fields once. */ +fun QTableMetaData.recordGroups(): List { + 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? { + 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() + 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 +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/RecordSearch.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/RecordSearch.kt new file mode 100644 index 0000000..01a5b64 --- /dev/null +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/RecordSearch.kt @@ -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 = emptyList(), + val matches: Map = 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 = recordGroups().flatMap { it.fields } + .filter { it.type == QFieldType.STRING || it.type == QFieldType.TEXT } + +fun QTableMetaData.recordSearchFields(): List { + 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)}" } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QTableMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QTableMetaData.kt index 628680b..aa1dfe1 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QTableMetaData.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QTableMetaData.kt @@ -32,5 +32,36 @@ data class QTableMetaData( val label: String, val icon: QIcon? = null, val isHidden: Boolean = false, + val primaryKeyField: String? = null, + val fields: Map = emptyMap(), + val sections: List? = null, + val associations: List? = null, + val searchFields: List? = null, ) { } + +@Serializable +data class QRecordSection( + val name: String, + val label: String? = null, + val fieldNames: List? = 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 = emptyList(), +) + +@Serializable +data class QRecordJoinOn(val leftField: String, val rightField: String) diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt index 49920e0..5a61098 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt @@ -15,6 +15,93 @@ import org.junit.Test ** Modern servers return HttpOnly session cookies; both credentials and redirects remain origin-scoped. *******************************************************************************/ 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) + } + } + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/RecordLayoutTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/RecordLayoutTest.kt new file mode 100644 index 0000000..9b349ba --- /dev/null +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/RecordLayoutTest.kt @@ -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("""{ + "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("""{ + "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)) + } +} diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/RecordPresentationTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/RecordPresentationTest.kt new file mode 100644 index 0000000..88a017c --- /dev/null +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/RecordPresentationTest.kt @@ -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)) + } +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/QMobileAppMain.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/QMobileAppMain.kt index 773ef4d..0edf00a 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/QMobileAppMain.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/QMobileAppMain.kt @@ -165,6 +165,10 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter? LaunchedEffect(qViewModel.isAuthenticated, qViewModel.isLogoutRequested, businessReady) { if (!businessReady) topLevelAppState.closeNavDrawer() + if (!qViewModel.isAuthenticated || qViewModel.isLogoutRequested) { + qViewModel.qNavigator.recordWorkBlock = null + qViewModel.qNavigator.blockedNavigationMessage = null + } if (qViewModel.isLogoutRequested) { settingsOpen.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}") }, confirmButton = { TextButton(onClick = { diagnosticsOpen.value = false }) { Text("Close") } }) 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") } }) + } } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/QNavigator.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/QNavigator.kt index fe55424..7f4b597 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/QNavigator.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/QNavigator.kt @@ -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.HomeRoute 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.ProcessViewModel 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) 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. *******************************************************************************/ @@ -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. *******************************************************************************/ fun requestLeave(title: String, confirmLabel: String = "Leave process", alwaysConfirm: Boolean = false, action: () -> Unit) { + recordWorkBlock?.let { blockedNavigationMessage = it; return } if (leaveRequest != null || qViewModel.logoutInProgress) return val process = qViewModel.activeProcessViewModel if (alwaysConfirm || process != null && !process.getNoMoreSteps()) @@ -156,7 +162,9 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod val instance = (qViewModel.qInstanceLoadState as? LoadState.Success)?.value val processName = if (entry.destination.hasRoute()) entry.toRoute().name else null val appName = if (entry.destination.hasRoute()) entry.toRoute().name else - if (entry.destination.hasRoute()) entry.toRoute().appName else null + if (entry.destination.hasRoute()) entry.toRoute().appName else + if (entry.destination.hasRoute()) entry.toRoute().appName else + if (entry.destination.hasRoute()) entry.toRoute().appName else null atHome = entry.destination.hasRoute() navDepth = if (atHome) 0 else 1 currentAppName = if (atHome) null else appName @@ -187,13 +195,25 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod /******************************************************************************* *******************************************************************************/ fun navigateToApp(name: String, label: String) { - if (currentAppName == name && qViewModel.activeProcessViewModel == null) return + if (currentAppName == name && navController?.currentDestination?.hasRoute() == true) return requestLeave("Switch app?") { homeNow() 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 ** (e.g., when screen orientation changes) - manage the lifecycle of the diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/apps/QAppHomeGridLayout.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/apps/QAppHomeGridLayout.kt index 0d2fdf8..efab0c1 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/apps/QAppHomeGridLayout.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/apps/QAppHomeGridLayout.kt @@ -137,9 +137,10 @@ fun QAppHomeGridLayout(qInstance: QInstance, modifier: Modifier = Modifier, app: } for (child in otherChildren) { 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) + else if (child.type == QAppNodeType.TABLE) qNavigator?.navigateToTable(child.name, child.label) else { notYetImplementedDialogTitle.value = child.label 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) { - notYetImplementedDialogTitle.value = table.label - notYetImplementedDialogBody.value = "Tables are not yet supported in this application frontend." - isNotYetImplementedDialogOpen.value = true + qNavigator?.navigateToTable(table.name, table.label) } } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QApplicationWrapper.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QApplicationWrapper.kt index eb951ed..38b56f2 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QApplicationWrapper.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QApplicationWrapper.kt @@ -99,6 +99,15 @@ fun QApplicationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier, q } } + composable() { entry -> + val route: TableRoute = entry.toRoute() + com.kingsrook.qqq.frontend.android.mobileapp.ui.records.TableScreen(qViewModel, route.name) + } + composable() { 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 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) + diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/records/RecordScreens.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/records/RecordScreens.kt new file mode 100644 index 0000000..4bf6004 --- /dev/null +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/records/RecordScreens.kt @@ -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(null) } + var rows by remember { mutableStateOf>(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(null) } + var table by remember(tableName, recordId, host.sessionUUID) { mutableStateOf(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>(emptyList()) } + var table by remember { mutableStateOf(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 }) + } +} diff --git a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/ScannerEndToEndTest.kt b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/ScannerEndToEndTest.kt index dd6ca47..ca8a269 100644 --- a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/ScannerEndToEndTest.kt +++ b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/ScannerEndToEndTest.kt @@ -16,6 +16,9 @@ import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertHasNoClickAction import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextContains @@ -74,6 +77,44 @@ class ScannerEndToEndTest { private val client = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build() private lateinit var origin: 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. @@ -263,11 +304,17 @@ class ScannerEndToEndTest { stage("echo-setup-selected") 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.") context.sendBroadcast(Intent("io.qrun.mobile.scanner.SCAN").setPackage(context.packageName) .addCategory(Intent.CATEGORY_DEFAULT).putExtra("com.symbol.datawedge.data_string", sample.first) - .putExtra("com.symbol.datawedge.label_type", sample.second)) + .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 waitForServer { 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) assertScanDisplay(sample.first, sample.second, "Confirmed by server") assertTrue("Accepted Echo scans must preserve exact type and approved-device attribution", - row.getString("symbology") == sample.second && row.getString("rendererSymbology") == (if (index == 0) "code128" else "qr") && + row.getString("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") assertTrue("Only a synthetic accepted job may acknowledge this fixture scan", row.getString("id") == receipt && row.getString("printJobId") == "fixture-job-$receipt" && row.getString("eventId").isNotBlank()) assertTrue("Each event must create exactly one accepted Echo scan", rows("echoScan").count { it.optString("runId") == runId } == index + 1) assertSetupPinned(rows("echoFixtureRun").single { it.getString("id") == runId }) - stage(if (index == 0) "echo-first-queued" else "echo-second-queued") + 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) } waitFor("Print queued $receipt", substring = true) ui.activityRule.scenario.recreate() 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()) val acceptedRows = rows("echoScan").filter { it.optString("runId") == runId } - assertTrue("Rotation/recreation must not accept another event", acceptedRows.size == 2 && acceptedRows.map { it.getString("eventId") }.toSet().size == 2 && - acceptedRows.map { it.getString("printJobId") }.toSet().size == 2) + 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 == 3) assertSetupPinned(rows("echoFixtureRun").single { it.getString("id") == runId }) stage("echo-copies-persisted") stage("echo-recreation-stable") @@ -305,9 +357,17 @@ class ScannerEndToEndTest { ui.onNodeWithText("Leave process", substring = false).performClick() waitForTag("appHome.rowForApp:echo") waitForServer { rows("echoFixtureRun").single { it.getString("id") == runId }.getString("state") == "CLOSED" } - assertTrue("Leaving must close this run without accepting extra print work", rows("echoScan").count { it.optString("runId") == runId } == 2) + 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 }) 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 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 context = instrumentation.targetContext check(context.packageName == "io.qrun.mobile.scanner.e2e") @@ -364,10 +443,74 @@ class ScannerEndToEndTest { 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.rowForProcess:mobileRecordPhotos") - ui.onNodeWithTag("appHome.rowForProcess:mobileRecordPhotos").performClick() + val app = if (rich) "mobileTestData" else "mobileScanApp" + waitForTag("appHome.rowForApp:$app") + // Accessible activation avoids a system floating control covering a tile on physical devices. + 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") ui.onNodeWithText("Take photo").performScrollTo().performClick() waitFor("Saved photo. Awaiting server review.") @@ -376,8 +519,8 @@ class ScannerEndToEndTest { 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") } .single { it.optString("deviceId") == device.deviceId && it.optString("sha256") == RecordFilesApi.sha256(bytes) } - assertEquals("document", savedFile.getString("tableName")) - assertEquals("1", savedFile.getString("recordId")) + assertEquals(parentTable, savedFile.getString("tableName")) + assertEquals(parentId, savedFile.getString("recordId")) assertEquals("CAPTURE", savedFile.getString("origin")) assertTrue(savedFile.getString("originDetail").endsWith("source CAMERA")) assertEquals("QUARANTINED", savedFile.getString("lifecycle")) @@ -386,12 +529,47 @@ class ScannerEndToEndTest { 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. 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") ui.onNodeWithText("Choose photo").performScrollTo().performClick() waitFor("Photo ready to upload.") assertEquals(1, galleryCalls.get()) + ui.onNodeWithText("Upload photo").performScrollTo().performClick() + waitFor("Saved photo. Awaiting server review.") + 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")) + 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() + waitForTag("photo.image:256") + ui.onNodeWithText("Preview photo").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("Awaiting review") 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-") } assertTrue(bytes.contentEquals(pending.readBytes())) ui.activityRule.scenario.onActivity { OrientationPolicy.LANDSCAPE.select(it) } @@ -399,10 +577,18 @@ class ScannerEndToEndTest { ui.activityRule.scenario.recreate() waitFor("Photo ready to upload.") assertTrue("Rotation retains the same private original", pending.exists()) - ui.onNodeWithTag("topBar.menuIcon").performClick() - ui.onNodeWithTag("drawer.home").performClick() - ui.onNodeWithText("Leave process", substring = false).assertIsNotEnabled() - ui.onNodeWithText("Stay").performClick() + if (fromRecord) { + ui.onNodeWithText("Done", substring = false).assertIsNotEnabled() + ui.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() } + 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") var sessionId = "" diff --git a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/SessionAcceptanceClient.kt b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/SessionAcceptanceClient.kt index 478a9ef..f4a2a09 100644 --- a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/SessionAcceptanceClient.kt +++ b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/SessionAcceptanceClient.kt @@ -24,7 +24,7 @@ internal class SessionAcceptanceClient( *******************************************************************************/ fun json(path: String, body: JSONObject, expectedStatus: Int = 200): JSONObject = execute( 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 { 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( MultipartBody.Builder().setType(MultipartBody.FORM).addFormDataPart("values", values.toString()) .addFormDataPart("stepTimeoutMillis", "10000").build() @@ -77,7 +77,7 @@ internal class SessionAcceptanceClient( *******************************************************************************/ fun logout() { 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() ).execute().use { assertTrue("Fixture admin logout must succeed", it.isSuccessful) diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/AimOutputProfile.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/AimOutputProfile.kt index b5e4476..3e105c7 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/AimOutputProfile.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/AimOutputProfile.kt @@ -29,7 +29,7 @@ internal object AimOutputProfile { // AIM symbology character each known EMDK label must report; the modifier character is not checked here. private val FAMILY = mapOf( - "CODE128" to 'C', "CODE39" to 'A', "CODE93" to 'G', "I2OF5" to 'I', "QRCODE" to 'Q', "DATAMATRIX" to 'd', + "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' ) diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/HostChrome.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/HostChrome.kt index f9d06db..32464ce 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/HostChrome.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/HostChrome.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -45,8 +44,6 @@ import androidx.lifecycle.repeatOnLifecycle import coil3.ImageLoader import coil3.compose.AsyncImage import coil3.svg.SvgDecoder -import java.time.LocalTime -import java.time.format.DateTimeFormatter import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.isActive 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 logo by remember(server) { mutableStateOf(null) } var connection by remember(server) { mutableStateOf("Checking") } - var checkedAt by remember(server) { mutableStateOf("not checked") } var showDiagnostics by remember(server) { mutableStateOf(false) } val parentInputAllowed = LocalProcessInputAllowed.current val context = LocalContext.current @@ -114,7 +110,6 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co connection = "Checking" while (isActive) { connection = if (runtime.connected()) "Connected" else "Disconnected" - checkedAt = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) withTimeoutOrNull(5000) { changed.receive() } } } 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)) } 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") } } 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") } } } - HorizontalDivider() - TextButton(onClick = { showDiagnostics = true }, modifier = Modifier.fillMaxWidth()) - { - Text("$connection · $checkedAt · Diagnostics", color = MaterialTheme.colorScheme.onSurface) - } } } if (showDiagnostics) DiagnosticScreen(runtime.diagnostics, onBack = { showDiagnostics = false }) diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordFilesApi.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordFilesApi.kt index 5eb1f04..7de29dc 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordFilesApi.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordFilesApi.kt @@ -246,7 +246,7 @@ internal class RecordFilesApi( /******************************************************************************* ** 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) 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) require(it.code == 200) { "Unexpected upload response status." } 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. *******************************************************************************/ diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordPhotoWidget.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordPhotoWidget.kt index 6a36800..46722a7 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordPhotoWidget.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordPhotoWidget.kt @@ -7,6 +7,10 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.MaterialTheme 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)) 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 application = context.applicationContext 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 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 } - val blockId = parameters.widgetBlock.blockId val key = "photo:$session:$processId:$blockId:${target.tableName.length}:${target.tableName}:${target.recordId}" val factory = remember(key, api) { @@ -63,14 +80,18 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil @Suppress("UNCHECKED_CAST") return PhotoUploadViewModel(api, session, target, File(application.cacheDir, "record-photos")) { host.sessionUUID == session && host.isAuthenticated && !host.isLogoutRequested && host.activeProcessViewModel === process + && processOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) } as T } } } val upload: PhotoUploadViewModel = viewModel(viewModelStoreOwner = processOwner, key = key, factory = factory) val owner = LocalLifecycleOwner.current + fun setWork(message: String?) { + if (process != null) process.setClientWork(key, message) else host.qNavigator.recordWorkBlock = message + } 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) { @@ -79,7 +100,9 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil onDispose { owner.lifecycle.removeObserver(observer) 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) } @@ -89,9 +112,13 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil 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) } when { upload.checking -> Text("Checking photo permissions…") @@ -105,7 +132,7 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil if ("image/jpeg" in upload.limits!!.mediaTypes) Button(enabled = available, onClick = { 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) try { camera.launch(uri) @@ -123,6 +150,13 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil } if (upload.busy) Text("Working on photo…") 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") } } + ) } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/SavedRecordPhotos.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/SavedRecordPhotos.kt new file mode 100644 index 0000000..7faa651 --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/SavedRecordPhotos.kt @@ -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>(emptyList()) } + var hasNext by remember { mutableStateOf(false) } + var loading by remember { mutableStateOf(true) } + var failed by remember { mutableStateOf(false) } + var preview by remember { mutableStateOf(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(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) +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt index 368890c..5b55917 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt @@ -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 -> if (parameters.widgetBlock.blockType == "SCANNER") { ScannerWidget( diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/RecordFilesApiTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/RecordFilesApiTest.kt index 5d6a59c..ba0ee68 100644 --- a/scanner-host/src/test/java/io/qrun/mobile/scanner/RecordFilesApiTest.kt +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/RecordFilesApiTest.kt @@ -20,6 +20,33 @@ import org.junit.Test ** Verify discovery, immutable request replay and exact persistence receipts without a device. *******************************************************************************/ 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 bytes = byteArrayOf(1, 2, 3) diff --git a/scripts/ci/scanner-device-record-photo-e2e.mjs b/scripts/ci/scanner-device-record-photo-e2e.mjs new file mode 100644 index 0000000..416f286 --- /dev/null +++ b/scripts/ci/scanner-device-record-photo-e2e.mjs @@ -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] }); \ No newline at end of file diff --git a/scripts/ci/scanner-e2e-runner.mjs b/scripts/ci/scanner-e2e-runner.mjs index e352ed2..8eb7b50 100644 --- a/scripts/ci/scanner-e2e-runner.mjs +++ b/scripts/ci/scanner-e2e-runner.mjs @@ -72,14 +72,15 @@ export async function runScannerE2e(options = {}) { if (fixture) { const url = echo ? validateEchoFixtureDescriptor(fixture) : new URL(fixture.baseUrl); 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 !== '/' || fixture.username !== 'scanner' || fixture.adminUsername !== 'admin' || typeof fixture.password !== 'string' || fixture.password.length < 16 || typeof fixture.adminPassword !== 'string' || fixture.adminPassword.length < 16) { throw new Error('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 physicalSession = physical && selectedScenario === 'session-monitor'; 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 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()); 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.'); @@ -164,7 +165,7 @@ export async function runScannerE2e(options = {}) { } 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 (Object.hasOwn(localScenarios, selectedScenario)) { const temporary = []; @@ -324,6 +325,8 @@ export async function runScannerE2e(options = {}) { { mode: 'compatible', name: 'admin-forms', test: 'adminFormsApproveRevokeAndCancel', requireCancel: true }, { mode: 'compatible', name: 'session-monitor', test: 'sessionActivityMirrorsActualScreenAndAdminEndForcesFreshLogin', requireSession: true }, ...(fixture ? [{ mode: 'compatible', name: 'photos', test: 'cameraAutoUploadsAndPendingGallerySurvivesRotationThenCleansOnRemoteLogout', requirePhotos: true }] : []), + ...(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 }] : []), ]) { 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)) }); 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.'); 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}.`); } await writeFile(join(evidence, 'semantic-contract.json'), `${JSON.stringify(reports, null, 2)}\n`); console.log('All native requests in the selected scenarios matched the published semantic API.'); } catch (error) { + if (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({ status: 'FAILED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts, completedStages: [...stages], }, null, 2));