feat: browse records and attach photos from record Actions #15

Merged
james.maes merged 2 commits from feature/gs1-record-photos into main 2026-11-05 12:55:49 +00:00
39 changed files with 1400 additions and 109 deletions

No files matched your search

+44
View File
@@ -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).
+17 -24
View File
@@ -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).
+14 -29
View File
@@ -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.
+2
View File
@@ -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.
@@ -0,0 +1,50 @@
Historical checkpoint before the November 5 record UX update. Current state is in [session state](../SESSION-STATE.md).
## November 5 rich-record preview
The same TC22 app now has grouped fields and direct related-record navigation through standard semantic metadata/query APIs. The maintained Echo demo supplies Test Data: 1,000 orders (39 fields, six sections), 12,000 items and 3,000 history entries. Android unit tests, lint and debug/E2E builds passed. Isolated MC9400 native acceptance passed paging, grouping, filtered child navigation, camera/gallery attachments to order 26, rotation and remote logout cleanup; all requests matched the semantic API. Installed TC22 APK SHA-256: `65FB631F0CFF9B68A0F3FC01D37D656FD69D690F1BA63F8A34FB314A2B03B97F`, matching signer and installed bytes verified. Existing app data, enrollment and server address were retained. These source changes remain local/uncommitted; no new release was published. This supersedes the earlier preview/server-selection status below.
## November 5 TC22 preview update
At the user's request, the existing TC22 app was updated in place to the local records/photo debug build, with matching signer verified before installation and installed APK SHA-256 `79E6CD0559419E059EC253DD0364C13BDDB3C27F566F0DA9A478FBB70D99A038` verified afterward. No uninstall, data clear, separate QA app or server change occurred. Server selection and hands-on record/photo acceptance are pending. The older acceptance evidence below applies to the prior development build.
# Current status
As of 2026-11-05. Scope: the scanner host and the local Echo workflow only. This is the authoritative current view; earlier rolling notes are historical and kept in [docs/history](README.md). Nothing here claims a stable or fully qualified 1.0; a signed preview is published. Checklist: [TODO](../TODO.md). Ledger: [FINISH-1.0](../FINISH-1.0.md).
## Local feature verification
Uncommitted GS1 and record-photo work adds permitted-table browsing, record details, and camera/gallery attachments using existing semantic APIs. Android unit tests, lint and debug/E2E builds passed. Disposable MC9400 tests passed synthetic GS1 capture with exact HRI and saved-record browsing, plus record-linked camera/gallery upload, rotation, navigation blocking and remote-logout cleanup. These tests used isolated apps/databases and simulated barcode input/printing. The companion composed server cohort passed 188 tests and Checkstyle; the existing photo-process regression and eight runner guard tests also passed. The ordinary scanner app is unchanged; physical GS1 print/rescan and deployment remain outstanding.
## Source
- Protected `main` contains all application work (PR 8 and PR 10 merged). Application source is unchanged by the documentation and signing work after that merge.
- The Gitea workflow `.gitea/workflows/qrun-android-release.yaml` exists on `main`; it calls the shared platform Android workflow and is not a scanner-APK release pipeline (see [RELEASING](../RELEASING.md)).
- Documentation is published separately (signing guide and this reconciliation). Shared CI/CD redesign is outside this repository's local use and does not gate the demo.
## Local demo: accepted
- Local server on the workstation (port 57832): public QQQ 4.1.0-RC.21 plus released Printing 1.0 and qualified document, files and scripting owner inputs. UI API 1.10, mobile-scanners 1.5, printing 2.3, mobile-record-files 1.0.
- TC22 runs the development-signed debug APK (version 0.1.0, versionCode 1, SHA-256 `38A11155BF6A9C089A1EE6D9E44401E8FF39497B7150B51A85F1F916148BE60D`), reaching the server through device port 8080 forwarded to the workstation.
- The operator reported the outstanding manual TC22 acceptance group ALL PASS on 2026-11-04, including label count/fidelity, camera and GPS, and screen viewing. This is operator-reported acceptance, not instrumented or per-case evidence; no per-case artifacts are recorded and the checks are not requested again.
- Desktop-browser Authentik acceptance passed with strict TLS (nonce rejection, login, company logout, fresh second operator).
## MC9400 (second device)
- A production-signed 1.0.0 (versionCode 2) APK was installed on a fresh MC9400 (Android 13, EMDK present) and launched; installed bytes matched the signed APK hash. No fatal crash at startup.
- At the user's request the MC9400 now runs the same development APK as the TC22 in demo mode, with device port 8080 forwarded to the workstation server over paired wireless ADB. It signed in and was approved through the ordinary mobile device approval process (status APPROVED).
- **Remaining check:** a physical MC9400 trigger scan confirmed in the backend. Not yet verified; no scan claim is made.
## Signed release
- Direct signed-APK / MDM distribution was chosen. A QRun-owned key exists (alias `qqq-mobile-release`, certificate SHA-256 `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`) with a verified backup. Key, passwords and vault details are not in this repository.
- Signed `QQQ-Mobile-1.0.0.apk` (versionCode 2, SHA-256 `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51`) exists locally, built from `main` application source: lint 0 errors/0 warnings, v2/v3 signature with the exact certificate, alignment and non-debuggable verified.
- It is published as a **signed preview**: Gitea prerelease [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (ID 1361, signed tag on protected `main` `3eef6431ca39b6450cf764424d921a02b2afb9ab`; app source matches `0ae2101f`, the tag adds documentation only). Hosted assets are the APK (14933499 bytes) and `SHA256SUMS` (88 bytes), both downloaded back and matching the hash above. The local installation kit ZIP was not published. Forge sign-in is required. This is not a stable release. No automatic signing CI is configured, and SDK CI publication is a separate path.
- Production qualification (still pending) needs an HTTPS server and a full device pass of the signed build ([RELEASING](../RELEASING.md), [INSTALLATION](../INSTALLATION.md)). The local demo does not need HTTPS; release builds reject HTTP.
## Open (not claimed)
- Release qualification of the signed APK end to end (HTTPS server, enrollment, scan, navigation, update).
- Actual on-device Authentik callback: unverified; no separate evidence, remains production qualification.
- Dependency publication: the Generation, Record Files and Scripting owner inputs behind the local server are qualified inputs, not a fully public, reproducible or released cohort.
- Company profile / MDM rollout: nothing applied or verified on a device ([DEVICE-PROVISIONING](../DEVICE-PROVISIONING.md)).
@@ -0,0 +1,44 @@
Historical checkpoint before the November 5 record UX update. Current state is in [session state](../SESSION-STATE.md).
# Checklist
As of 2026-11-04, scanner host and local Echo scope only. Narrative: [SESSION-STATE](../SESSION-STATE.md). Completed and superseded items are kept in [docs/history](README.md).
## Active feature delivery
- [ ] Complete final review and deployment preparation for GS1 duplication and record photos; composed-server and native checks passed.
- [ ] Qualify actual GS1 label media, physical trigger and printed-label rescan.
- [ ] Deploy the reviewed app/server changes only with authorization, preserving ordinary app data and enrollment.
## Local demo (accepted)
- [x] Application work merged to protected `main`; `.gitea/workflows/qrun-android-release.yaml` present.
- [x] Local server on port 57832 (QQQ 4.1.0-RC.21, Printing 1.0, qualified owner inputs) running UI API 1.10, mobile 1.5, printing 2.3, record-files 1.0.
- [x] TC22 on the development APK against that server; operator reported the outstanding manual acceptance group ALL PASS (2026-11-04), including label count/fidelity, camera, GPS and screen viewing; operator-reported, not instrumented or per-case evidence.
- [x] Echo setup (printer, label size, document, copies 1–100) and semantic native flow qualified, with simulated inputs/printing in disposable runs.
- [x] Real desktop Authentik acceptance with strict TLS.
## MC9400 remaining check
- [x] Production-signed APK installed and launched on a fresh MC9400; hash read back.
- [x] Switched to the TC22 development APK demo configuration at the user's request; signed in and approved (APPROVED).
- [ ] Physical MC9400 trigger scan reaches the backend (Saved scan and a matching server record).
## Release qualification
- [x] Signed-APK/MDM channel chosen; QRun-owned key created with verified backup; signed 1.0.0 (versionCode 2) built and verified locally.
- [x] Direct-install guide written ([INSTALLATION](../INSTALLATION.md)).
- [ ] Qualify the production-signed APK on a device against an HTTPS server (enrollment, scanner path, navigation, update).
- [x] Signed preview published: Gitea prerelease `scanner-v1.0.0` (APK and `SHA256SUMS` only; local install kit not hosted).
- [ ] Distribution to the remaining devices after qualification; MDM later. Promotion to a stable release needs the qualification above.
- [ ] Automatic signing/publication pipeline: not configured; owned by the shared CI design, not needed for manual distribution.
- [ ] Actual on-device Authentik callback (unverified; no separate evidence).
## Dependencies and provisioning
- [ ] Public, reproducible publication of the Generation, Record Files and Scripting owner inputs (currently qualified inputs).
- [ ] Company profile / EHS / OEMConfig / MDM deployment verified on a device ([DEVICE-PROVISIONING](../DEVICE-PROVISIONING.md), [COMPANY-PROFILE](../COMPANY-PROFILE.md)).
- [ ] Legacy source follow-ups ([LEGACY-SOURCE-FOLLOWUPS](../LEGACY-SOURCE-FOLLOWUPS.md)).
- [ ] Extend the [native component boundary](../UI-API.md) (possible values, record lists, specialized forms, reports) with conformance and E2E coverage as needed.
Offline queueing and background synchronization are outside version one.
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -120,6 +120,48 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
sessionUUIDs[baseUrl] = sessionUUID
}
/*******************************************************************************
** Bounded, stably ordered read-only pages retain normal session/device guards.
*******************************************************************************/
override suspend fun getTableMetaData(tableName: String) = qqqApiServiceMap[baseUrl]!!.getTableMetaData(tableName)
override suspend fun queryRecords(tableName: String, primaryKey: String, offset: Int, limit: Int): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> {
require(offset >= 0 && limit in 1..100 && primaryKey.isNotBlank())
val filter = JSONObject().put("skip", offset).put("limit", limit)
.put("orderBys", org.json.JSONArray().put(JSONObject().put("fieldName", primaryKey).put("isAscending", true)))
val body = JSONObject().put("filter", filter).toString().toRequestBody("application/json".toMediaType())
return qqqApiServiceMap[baseUrl]!!.queryRecords(tableName, body).records
}
override suspend fun getRecord(tableName: String, recordId: String) = qqqApiServiceMap[baseUrl]!!.getRecord(tableName, recordId).record
override suspend fun searchRecords(tableName: String, primaryKey: String, search: com.kingsrook.qqq.frontend.android.core.model.RecordSearch, offset: Int, limit: Int): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> {
require(offset >= 0 && limit in 1..100 && primaryKey.isNotBlank())
val filter = search.filter().put("skip", offset).put("limit", limit)
.put("orderBys", org.json.JSONArray().put(JSONObject().put("fieldName", primaryKey).put("isAscending", true)))
val body = JSONObject().put("filter", filter).toString().toRequestBody("application/json".toMediaType())
return qqqApiServiceMap[baseUrl]!!.queryRecords(tableName, body).records
}
override suspend fun countRecords(tableName: String, search: com.kingsrook.qqq.frontend.android.core.model.RecordSearch): Long {
val body = JSONObject().put("filter", search.filter()).toString().toRequestBody("application/json".toMediaType())
return qqqApiServiceMap[baseUrl]!!.countRecords(tableName, body).count.also { require(it >= 0) }
}
override suspend fun queryRelatedRecords(tableName: String, primaryKey: String, matches: Map<String, kotlinx.serialization.json.JsonElement>, offset: Int, limit: Int): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> {
require(offset >= 0 && limit in 1..100 && primaryKey.isNotBlank() && matches.isNotEmpty())
val criteria = org.json.JSONArray()
matches.forEach { (field, value) ->
require(field.isNotBlank() && value is kotlinx.serialization.json.JsonPrimitive && value != kotlinx.serialization.json.JsonNull)
criteria.put(JSONObject().put("fieldName", field).put("operator", "EQUALS")
.put("values", org.json.JSONArray("[$value]")))
}
val filter = JSONObject().put("skip", offset).put("limit", limit).put("booleanOperator", "AND").put("criteria", criteria)
.put("orderBys", org.json.JSONArray().put(JSONObject().put("fieldName", primaryKey).put("isAscending", true)))
val body = JSONObject().put("filter", filter).toString().toRequestBody("application/json".toMediaType())
return qqqApiServiceMap[baseUrl]!!.queryRecords(tableName, body).records
}
/*******************************************************************************
*******************************************************************************/
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData {
@@ -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.
*******************************************************************************/
@@ -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<com.kingsrook.qqq.frontend.android.core.model.RecordData> =
throw UnsupportedOperationException("Table browsing is unavailable")
suspend fun getRecord(tableName: String, recordId: String): com.kingsrook.qqq.frontend.android.core.model.RecordData =
throw UnsupportedOperationException("Record viewing is unavailable")
suspend fun searchRecords(tableName: String, primaryKey: String, search: com.kingsrook.qqq.frontend.android.core.model.RecordSearch, offset: Int, limit: Int = 25): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> =
throw UnsupportedOperationException("Record search is unavailable")
suspend fun countRecords(tableName: String, search: com.kingsrook.qqq.frontend.android.core.model.RecordSearch): Long =
throw UnsupportedOperationException("Record counts are unavailable")
suspend fun queryRelatedRecords(tableName: String, primaryKey: String, matches: Map<String, kotlinx.serialization.json.JsonElement>, offset: Int, limit: Int = 10): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> =
throw UnsupportedOperationException("Related records are unavailable")
/*******************************************************************************
** Start server-defined process execution with explicit values and record selection; return authoritative step state.
*******************************************************************************/
@@ -0,0 +1,30 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.core.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
/*******************************************************************************
** Versioned table responses preserve opaque primary keys and display values.
*******************************************************************************/
@Serializable
data class RecordData(
val tableName: String? = null,
val recordLabel: String? = null,
val values: Map<String, JsonElement> = emptyMap(),
val displayValues: Map<String, JsonElement> = emptyMap(),
) {
fun id(primaryKey: String): String? = (values[primaryKey] as? JsonPrimitive)?.contentOrNull
fun display(field: String): String = ((displayValues[field] ?: values[field]) as? JsonPrimitive)?.contentOrNull.orEmpty()
}
/*******************************************************************************
** Query records are unwrapped; a get response wraps exactly one record.
*******************************************************************************/
@Serializable
data class RecordQueryResponse(val records: List<RecordData> = emptyList())
@Serializable
data class RecordGetResponse(val record: RecordData)
@@ -0,0 +1,41 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.core.model
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.QRecordAssociation
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
data class RecordFieldGroup(val label: String, val fields: List<QFieldMetaData>)
/** Retain metadata order, suppress hidden sections, and show ungrouped fields once. */
fun QTableMetaData.recordGroups(): List<RecordFieldGroup> {
val sections = sections.orEmpty()
val hidden = sections.filter { it.isHidden || it.hidden }.flatMap { it.fieldNames.orEmpty() }.toSet()
val remaining = fields.filterValues { !it.isHidden && !it.isHeavy }.toMutableMap()
hidden.forEach(remaining::remove)
val groups = sections.filter { !it.isHidden && !it.hidden }.mapNotNull { section ->
val members = section.fieldNames.orEmpty().mapNotNull(remaining::remove)
if (members.isEmpty()) null else RecordFieldGroup(section.label ?: section.name, members)
}.toMutableList()
if (remaining.isNotEmpty()) groups.add(RecordFieldGroup(if (groups.isEmpty()) "Details" else "Other fields", remaining.values.toList()))
return groups
}
/** Resolve only declared, direct equality joins. Missing values must never become an unfiltered query. */
fun QRecordAssociation.parentMatches(parentTable: String, record: RecordData): Map<String, JsonElement>? {
val forward = join.leftTable == parentTable && join.rightTable == associatedTableName
val reverse = join.rightTable == parentTable && join.leftTable == associatedTableName
if ((!forward && !reverse) || join.joinOns.isEmpty()) return null
val matches = linkedMapOf<String, JsonElement>()
for (on in join.joinOns) {
val source = if (forward) on.leftField else on.rightField
val target = if (forward) on.rightField else on.leftField
val value = record.values[source]
if (target.isBlank() || source.isBlank() || value !is JsonPrimitive || value == JsonNull || target in matches) return null
matches[target] = value
}
return matches
}
@@ -0,0 +1,74 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.core.model
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldType
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import org.json.JSONArray
import org.json.JSONObject
import java.text.NumberFormat
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/** The same predicate is used by page and count requests. Parent matches remain mandatory. */
data class RecordSearch(
val text: String = "",
val searchFields: List<String> = emptyList(),
val matches: Map<String, JsonElement> = emptyMap(),
) {
fun filter(): JSONObject {
val criteria = JSONArray()
matches.forEach { (name, value) ->
require(name.isNotBlank() && value is JsonPrimitive && value != JsonNull)
criteria.put(JSONObject().put("fieldName", name).put("operator", "EQUALS").put("values", JSONArray("[$value]")))
}
val result = JSONObject().put("booleanOperator", "AND").put("criteria", criteria)
if (text.isNotBlank()) {
require(searchFields.isNotEmpty() && searchFields.all(String::isNotBlank))
val search = JSONArray()
searchFields.forEach { search.put(JSONObject().put("fieldName", it).put("operator", "CONTAINS").put("values", JSONArray().put(text))) }
result.put("subFilters", JSONArray().put(JSONObject().put("booleanOperator", "OR").put("criteria", search)))
}
return result
}
}
@Serializable
data class RecordCountResponse(val count: Long)
/** Search only visible scalar text fields; server metadata defines preferred fields. */
fun QTableMetaData.textFields(): List<QFieldMetaData> = recordGroups().flatMap { it.fields }
.filter { it.type == QFieldType.STRING || it.type == QFieldType.TEXT }
fun QTableMetaData.recordSearchFields(): List<String> {
val visible = textFields().map { it.name }
return searchFields.orEmpty().filter { it in visible }.ifEmpty { visible.take(3) }
}
/** Preserve server formatting and identifiers. Only typed dates and numbers receive a local fallback. */
fun RecordData.formatted(field: QFieldMetaData): String {
val value = display(field.name)
if (value.isBlank()) return "—"
if (!field.displayFormat.isNullOrBlank() && field.displayFormat != "%s") return value
return runCatching {
when (field.type) {
QFieldType.DATE -> LocalDate.parse(value).format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
QFieldType.DATE_TIME -> Instant.parse(value).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("MMM d, yyyy · h:mm a z"))
QFieldType.DECIMAL -> NumberFormat.getNumberInstance().apply { maximumFractionDigits = 8 }.format(value.toBigDecimal())
QFieldType.BOOLEAN -> when (value) { "true" -> "Yes"; "false" -> "No"; else -> value }
else -> value
}
}.getOrDefault(value)
}
/** Compact summaries follow metadata order, omit the primary key and never expose hidden groups. */
fun RecordData.summary(table: QTableMetaData): String = table.recordGroups().flatMap { it.fields }
.filter { it.name != table.primaryKeyField && display(it.name).isNotBlank() && display(it.name).length <= 80 && recordLabel?.contains(display(it.name)) != true }
.take(3).joinToString(" · ") { "${it.label}: ${formatted(it)}" }
@@ -32,5 +32,36 @@ data class QTableMetaData(
val label: String,
val icon: QIcon? = null,
val isHidden: Boolean = false,
val primaryKeyField: String? = null,
val fields: Map<String, QFieldMetaData> = emptyMap(),
val sections: List<QRecordSection>? = null,
val associations: List<QRecordAssociation>? = null,
val searchFields: List<String>? = null,
) {
}
@Serializable
data class QRecordSection(
val name: String,
val label: String? = null,
val fieldNames: List<String>? = null,
val isHidden: Boolean = false,
val hidden: Boolean = false,
)
@Serializable
data class QRecordAssociation(
val name: String,
val associatedTableName: String,
val join: QRecordJoin,
)
@Serializable
data class QRecordJoin(
val leftTable: String,
val rightTable: String,
val joinOns: List<QRecordJoinOn> = emptyList(),
)
@Serializable
data class QRecordJoinOn(val leftField: String, val rightField: String)
@@ -15,6 +15,93 @@ import org.junit.Test
** Modern servers return HttpOnly session cookies; both credentials and redirects remain origin-scoped.
*******************************************************************************/
class NetworkQQQRepositoryTest {
@Test
fun searchAndCountKeepIdenticalPredicatesAndOpaqueParentKeys() = runBlocking() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setBody("{\"count\":1}"))
server.enqueue(MockResponse().setBody("{\"records\":[]}"))
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin)
repository.setEnvironment(Environment("test", origin))
val search = com.kingsrook.qqq.frontend.android.core.model.RecordSearch("a\"b", listOf("reference", "name"), mapOf("parent" to kotlinx.serialization.json.JsonPrimitive("0007")))
assertEquals(1L, repository.countRecords("items", search))
repository.searchRecords("items", "id", search, 25)
server.takeRequest()
val counted = server.takeRequest()
assertEquals("/qqq/v1/table/items/count", counted.path)
val expected = org.json.JSONObject(counted.body.readUtf8()).getJSONObject("filter")
val query = org.json.JSONObject(server.takeRequest().body.readUtf8()).getJSONObject("filter")
assertEquals(25, query.remove("skip"))
assertEquals(25, query.remove("limit"))
query.remove("orderBys")
assertTrue(expected.similar(query))
assertEquals("0007", expected.getJSONArray("criteria").getJSONObject(0).getJSONArray("values").getString(0))
assertEquals("OR", expected.getJSONArray("subFilters").getJSONObject(0).getString("booleanOperator"))
}
}
@Test
fun relatedQueriesAreBoundedAndCannotDropTheParentFilter() = runBlocking() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setBody("""{"records":[]}"""))
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin)
repository.setEnvironment(Environment("test", origin))
repository.queryRelatedRecords("lines", "id", mapOf("parent" to kotlinx.serialization.json.JsonPrimitive("0007")), 10, 10)
server.takeRequest()
val request = server.takeRequest()
assertEquals("/qqq/v1/table/lines/query", request.path)
val filter = org.json.JSONObject(request.body.readUtf8()).getJSONObject("filter")
assertEquals(10, filter.getInt("skip"))
assertEquals(10, filter.getInt("limit"))
assertEquals("AND", filter.getString("booleanOperator"))
val criteria = filter.getJSONArray("criteria").getJSONObject(0)
assertEquals("parent", criteria.getString("fieldName"))
assertEquals("EQUALS", criteria.getString("operator"))
assertEquals("0007", criteria.getJSONArray("values").getString(0))
assertTrue(runCatching { repository.queryRelatedRecords("lines", "id", emptyMap(), 0) }.isFailure)
assertTrue(runCatching { repository.queryRelatedRecords("lines", "id", mapOf("parent" to kotlinx.serialization.json.JsonNull), 0) }.isFailure)
}
}
/*******************************************************************************
** Native browsing retains server identity, opaque ids, bounded paging and read-only routes.
*******************************************************************************/
@Test
fun recordBrowsingUsesSemanticRoutesAndPreservesIds() = runBlocking() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setBody("""{"name":"items","label":"Items","primaryKeyField":"key","fields":{"key":{"name":"key","label":"Key","type":"STRING"}}}"""))
server.enqueue(MockResponse().setBody("""{"records":[{"tableName":"items","recordLabel":"Item","values":{"key":"0007","quantity":0},"displayValues":{"quantity":"Zero"}}]}"""))
server.enqueue(MockResponse().setBody("""{"record":{"tableName":"items","values":{"key":"0007"}}}"""))
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin, additionalHeaders = { mapOf("X-QQQ-Device-Id" to "fixture-device") })
repository.setEnvironment(Environment("test", origin))
repository.setSessionUUID("fixture-session")
assertEquals("key", repository.getTableMetaData("items").primaryKeyField)
val records = repository.queryRecords("items", "key", 25, 25)
assertEquals("0007", records.single().id("key"))
assertEquals("Zero", records.single().display("quantity"))
assertEquals("0007", repository.getRecord("items", "0007").id("key"))
assertEquals("/qqq/v1/version", server.takeRequest().path)
assertEquals("/qqq/v1/metaData/table/items", server.takeRequest().path)
val query = server.takeRequest()
assertEquals("/qqq/v1/table/items/query", query.path)
assertEquals("fixture-device", query.getHeader("X-QQQ-Device-Id"))
assertEquals("sessionUUID=fixture-session", query.getHeader("Cookie"))
val filter = org.json.JSONObject(query.body.readUtf8()).getJSONObject("filter")
assertEquals(25, filter.getInt("skip"))
assertEquals(25, filter.getInt("limit"))
assertEquals("key", filter.getJSONArray("orderBys").getJSONObject(0).getString("fieldName"))
val get = server.takeRequest()
assertEquals("GET", get.method)
assertEquals("/qqq/v1/table/items/0007", get.path)
assertTrue(runCatching { repository.queryRecords("items", "key", 0, 1000) }.isFailure)
}
}
/*******************************************************************************
**
*******************************************************************************/
@@ -0,0 +1,41 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.core.model
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.QRecordAssociation
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class RecordLayoutTest {
@Test
fun sectionsPreserveOrderWithoutLeakingHiddenOrDuplicatingFields() {
val table = Json.decodeFromString<QTableMetaData>("""{
"name":"orders","label":"Orders","fields":{
"id":{"name":"id","label":"ID","type":"INTEGER"},
"secret":{"name":"secret","label":"Secret","type":"STRING"},
"other":{"name":"other","label":"Other","type":"STRING"}},
"sections":[{"name":"hidden","hidden":true,"fieldNames":["secret"]},
{"name":"overview","label":"Overview","fieldNames":["id","id","missing"]}]
}""")
assertEquals(listOf("Overview", "Other fields"), table.recordGroups().map { it.label })
assertEquals(listOf("id", "other"), table.recordGroups().flatMap { it.fields }.map { it.name })
}
@Test
fun relationshipsRespectDirectionTypesAndMissingParentKeys() {
val association = Json.decodeFromString<QRecordAssociation>("""{
"name":"Lines","associatedTableName":"lines",
"join":{"leftTable":"orders","rightTable":"lines","joinOns":[{"leftField":"key","rightField":"orderKey"}]}
}""")
val record = RecordData(values = mapOf("key" to JsonPrimitive("0007")))
assertEquals(mapOf("orderKey" to JsonPrimitive("0007")), association.parentMatches("orders", record))
assertNull(association.parentMatches("unrelated", record))
assertNull(association.parentMatches("orders", RecordData()))
val reversed = association.copy(join = association.join.copy(leftTable = "lines", rightTable = "orders",
joinOns = association.join.joinOns.map { it.copy(leftField = it.rightField, rightField = it.leftField) }))
assertEquals(association.parentMatches("orders", record), reversed.parentMatches("orders", record))
}
}
@@ -0,0 +1,46 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.core.model
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldType
import com.kingsrook.qqq.frontend.android.core.model.metadata.QRecordSection
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonArray
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RecordPresentationTest {
@Test
fun typedDatesFormatEvenWhenTheirRawJsonRepresentationDiffersFromDisplayText() {
val record = RecordData(values = mapOf("date" to JsonArray(listOf(JsonPrimitive(2026), JsonPrimitive(1), JsonPrimitive(2)))),
displayValues = mapOf("date" to JsonPrimitive("2026-01-02"), "time" to JsonPrimitive("2026-01-02T11:30:00Z")))
// The semantic server supplies %s for fields without a custom display format.
val date = record.formatted(QFieldMetaData("date", "Date", type = QFieldType.DATE, displayFormat = "%s"))
assertTrue(date.contains("2026"))
assertFalse(date.contains("2026-01-02"))
assertFalse(record.formatted(QFieldMetaData("time", "Time", type = QFieldType.DATE_TIME, displayFormat = "%s")).contains("T11:30"))
}
@Test
fun formattingPreservesIdentifiersAndServerCurrencyAndMakesEmptyValuesExplicit() {
val record = RecordData(values = mapOf("key" to JsonPrimitive("000026"), "amount" to JsonPrimitive(3.5), "enabled" to JsonPrimitive(true)), displayValues = mapOf("amount" to JsonPrimitive("$3.50")))
assertEquals("000026", record.formatted(QFieldMetaData("key", "Key", type = QFieldType.STRING)))
assertEquals("$3.50", record.formatted(QFieldMetaData("amount", "Amount", type = QFieldType.DECIMAL, displayFormat = "$%,.2f")))
assertEquals("Yes", record.formatted(QFieldMetaData("enabled", "Enabled", type = QFieldType.BOOLEAN)))
assertEquals("—", record.formatted(QFieldMetaData("missing", "Missing", type = QFieldType.STRING)))
}
@Test
fun searchAndSummaryNeverExposeHiddenSectionFields() {
val field = QFieldMetaData("visible", "Visible", type = QFieldType.STRING)
val hidden = field.copy(name = "secret", label = "Secret")
val table = QTableMetaData("test", "Test", fields = mapOf("visible" to field, "secret" to hidden),
searchFields = listOf("secret", "visible"), sections = listOf(QRecordSection("hidden", fieldNames = listOf("secret"), hidden = true)))
val record = RecordData(values = mapOf("visible" to JsonPrimitive("public"), "secret" to JsonPrimitive("private")))
assertEquals(listOf("visible"), table.recordSearchFields())
assertEquals("Visible: public", record.summary(table))
}
}
@@ -165,6 +165,10 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter?
LaunchedEffect(qViewModel.isAuthenticated, qViewModel.isLogoutRequested, businessReady)
{
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") } })
}
}
}
@@ -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<QInstance>)?.value
val processName = if (entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().name else null
val appName = if (entry.destination.hasRoute<AppRoute>()) entry.toRoute<AppRoute>().name else
if (entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().appName else null
if (entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().appName else
if (entry.destination.hasRoute<TableRoute>()) entry.toRoute<TableRoute>().appName else
if (entry.destination.hasRoute<RecordRoute>()) entry.toRoute<RecordRoute>().appName else null
atHome = entry.destination.hasRoute<HomeRoute>()
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<AppRoute>() == 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
@@ -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)
}
}
}
@@ -99,6 +99,15 @@ fun QApplicationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier, q
}
}
composable<TableRoute>() { entry ->
val route: TableRoute = entry.toRoute()
com.kingsrook.qqq.frontend.android.mobileapp.ui.records.TableScreen(qViewModel, route.name)
}
composable<RecordRoute>() { entry ->
val route: RecordRoute = entry.toRoute()
com.kingsrook.qqq.frontend.android.mobileapp.ui.records.RecordScreen(qViewModel, route.tableName, route.recordId, route.hasParent)
}
}
}
}
@@ -136,3 +145,9 @@ data class AppRoute(val name: String)
@Serializable
data class ProcessRoute(val name: String, val appName: String? = null)
@Serializable
data class TableRoute(val name: String, val appName: String? = null)
@Serializable
data class RecordRoute(val tableName: String, val recordId: String, val appName: String? = null, val hasParent: Boolean = false)
@@ -0,0 +1,283 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.mobileapp.ui.records
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.kingsrook.qqq.frontend.android.core.model.RecordData
import com.kingsrook.qqq.frontend.android.core.model.RecordSearch
import com.kingsrook.qqq.frontend.android.core.model.formatted
import com.kingsrook.qqq.frontend.android.core.model.parentMatches
import com.kingsrook.qqq.frontend.android.core.model.recordGroups
import com.kingsrook.qqq.frontend.android.core.model.recordSearchFields
import com.kingsrook.qqq.frontend.android.core.model.summary
import com.kingsrook.qqq.frontend.android.core.model.textFields
import com.kingsrook.qqq.frontend.android.core.model.metadata.QRecordAssociation
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalNavigationBackAction
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.json.JsonPrimitive
/** Attachment state stays mounted even when another tab is selected. */
data class RecordAttachmentUi(val photosVisible: Boolean, val captureVisible: Boolean, val closeCapture: () -> Unit, val available: (Boolean) -> Unit, val recordLabel: String)
val LocalRecordAttachments = compositionLocalOf<@Composable (QViewModel, String, String, RecordAttachmentUi) -> Unit> { { _, _, _, _ -> } }
/** Save page, search, filter and scroll state in this navigation entry. */
@Composable
fun TableScreen(host: QViewModel, tableName: String) {
var offset by rememberSaveable(tableName, host.sessionUUID) { mutableStateOf(0) }
var search by rememberSaveable(tableName, host.sessionUUID) { mutableStateOf("") }
var filterField by rememberSaveable(tableName, host.sessionUUID) { mutableStateOf("") }
var filterValue by rememberSaveable(tableName, host.sessionUUID) { mutableStateOf("") }
var filterOpen by remember { mutableStateOf(false) }
var reload by remember { mutableStateOf(0) }
var table by remember(tableName, host.sessionUUID) { mutableStateOf<QTableMetaData?>(null) }
var rows by remember { mutableStateOf<List<RecordData>>(emptyList()) }
var count by remember { mutableStateOf(0L) }
var loading by remember { mutableStateOf(true) }
var failed by remember { mutableStateOf(false) }
val scroll = rememberLazyListState()
LaunchedEffect(tableName, offset, search, filterField, filterValue, reload, host.sessionUUID) {
loading = true; failed = false
try {
if (search.isNotEmpty()) delay(300)
val metadata = table ?: host.qqqRepository.getTableMetaData(tableName).also { table = it }
val predicate = RecordSearch(search, metadata.recordSearchFields(), if (filterField.isNotEmpty() && filterValue.isNotEmpty()) mapOf(filterField to JsonPrimitive(filterValue)) else emptyMap())
count = host.qqqRepository.countRecords(tableName, predicate)
if (offset > 0 && offset >= count) { offset = 0; return@LaunchedEffect }
rows = host.qqqRepository.searchRecords(tableName, requireNotNull(metadata.primaryKeyField), predicate, offset)
} catch (cancelled: CancellationException) { throw cancelled }
catch (exception: Exception) { failed = true; rows = emptyList() }
finally { loading = false }
}
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
OutlinedTextField(search, { search = it; offset = 0 }, label = { Text("Search records") }, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("records.search"))
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = { filterOpen = true }, enabled = table?.textFields()?.isNotEmpty() == true) { Text(if (filterValue.isEmpty()) "Filter" else "Filter applied") }
if (search.isNotEmpty() || filterValue.isNotEmpty()) TextButton(onClick = { search = ""; filterField = ""; filterValue = ""; offset = 0 }) { Text("Clear") }
TextButton(onClick = { reload++ }) { Text("Refresh") }
}
if (loading) Text("Loading records…")
if (failed) Text("Records could not be loaded. Check your connection and access.")
if (!loading && !failed && rows.isEmpty()) Text("No matching records.")
if (loading || failed) Spacer(Modifier.weight(1f)) else LazyColumn(state = scroll, modifier = Modifier.weight(1f)) {
if (!loading && !failed) items(rows, key = { requireNotNull(table?.primaryKeyField?.let(it::id)) }) { row ->
val id = requireNotNull(table?.primaryKeyField?.let(row::id))
RecordRow(row, table!!, "records.row:$id") { host.qNavigator.navigateToRecord(tableName, id) }
}
}
if (!failed) Paging(offset, rows.size, count, 25, loading, { offset = it })
}
if (filterOpen && table != null) RecordFilterDialog(table!!, filterField, filterValue, { filterOpen = false }) { field, value ->
filterField = field; filterValue = value; offset = 0; filterOpen = false
}
}
@Composable
private fun RecordFilterDialog(table: QTableMetaData, initialField: String, initialValue: String, close: () -> Unit, apply: (String, String) -> Unit) {
val fields = table.textFields()
var field by remember { mutableStateOf(fields.firstOrNull { it.name == initialField } ?: fields.first()) }
var value by remember { mutableStateOf(initialValue) }
var choose by remember { mutableStateOf(false) }
AlertDialog(onDismissRequest = close, title = { Text("Filter records") }, text = {
Column {
Box {
TextButton(onClick = { choose = true }) { Text("Field: ${field.label}") }
DropdownMenu(choose, { choose = false }) { fields.forEach { item -> DropdownMenuItem(text = { Text(item.label) }, onClick = { field = item; choose = false }) } }
}
OutlinedTextField(value, { value = it }, label = { Text("Equals") }, singleLine = true, modifier = Modifier.testTag("records.filter.value"))
}
}, confirmButton = { TextButton(onClick = { apply(field.name, value) }) { Text("Apply filter") } }, dismissButton = { TextButton(onClick = close) { Text("Cancel") } })
}
@Composable
private fun Paging(offset: Int, size: Int, count: Long, pageSize: Int, loading: Boolean, change: (Int) -> Unit) {
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(if (loading) "Loading…" else if (count == 0L) "0 records" else "${offset + 1}–${offset + size} of $count", style = MaterialTheme.typography.labelLarge, modifier = Modifier.testTag("records.count"))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
TextButton(enabled = !loading && offset > 0, onClick = { change((offset - pageSize).coerceAtLeast(0)) }) { Text("Previous") }
TextButton(enabled = !loading && offset + size < count, onClick = { change(offset + pageSize) }) { Text("Next") }
}
}
}
@Composable
private fun RecordRow(record: RecordData, table: QTableMetaData, tag: String, open: () -> Unit) {
Row(Modifier.fillMaxWidth().testTag(tag).clickable(onClickLabel = "Open record", onClick = open).padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(record.recordLabel ?: table.primaryKeyField?.let(record::id).orEmpty(), style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(record.summary(table), style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
Text("›", Modifier.padding(start = 8.dp), style = MaterialTheme.typography.titleLarge)
}
HorizontalDivider()
}
/** Identity and Actions remain fixed above content. Record values never enter navigation telemetry. */
@Composable
fun RecordScreen(host: QViewModel, tableName: String, recordId: String, hasParent: Boolean = false) {
// Register inside the destination, after NavHost's own callback, as process screens do.
val hostBack = LocalNavigationBackAction.current
BackHandler { if (hostBack != null) hostBack() else host.qNavigator.popStack() }
var record by remember(tableName, recordId, host.sessionUUID) { mutableStateOf<RecordData?>(null) }
var table by remember(tableName, recordId, host.sessionUUID) { mutableStateOf<QTableMetaData?>(null) }
var failed by remember { mutableStateOf(false) }
var reload by remember { mutableStateOf(0) }
var tab by rememberSaveable(tableName, recordId, host.sessionUUID) { mutableStateOf(0) }
var group by rememberSaveable(tableName, recordId, host.sessionUUID) { mutableStateOf(0) }
var actions by remember { mutableStateOf(false) }
var sections by remember { mutableStateOf(false) }
var capture by rememberSaveable(tableName, recordId, host.sessionUUID) { mutableStateOf(false) }
var photoAvailable by remember(tableName, recordId, host.sessionUUID) { mutableStateOf(false) }
val attachments = LocalRecordAttachments.current
val tabState = rememberSaveableStateHolder()
LaunchedEffect(tableName, recordId, host.sessionUUID, reload) {
failed = false
try {
val metadata = host.qqqRepository.getTableMetaData(tableName)
val loaded = host.qqqRepository.getRecord(tableName, recordId)
check(metadata.primaryKeyField?.let(loaded::id) == recordId)
check(loaded.tableName == null || loaded.tableName == tableName)
table = metadata; record = loaded
} catch (cancelled: CancellationException) { throw cancelled }
catch (exception: Exception) { failed = true }
}
val current = record
if (failed) Column(Modifier.padding(16.dp)) {
Text("This record could not be loaded. It may be unavailable or access may have changed.")
Button(onClick = { reload++ }) { Text("Retry") }
} else if (current == null) Text("Loading record…", Modifier.padding(16.dp))
else {
val metadata = table!!
val related = metadata.associations.orEmpty()
val groups = metadata.recordGroups()
val photoTab = related.size + 1
Column(Modifier.fillMaxSize().testTag("record.details")) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(current.recordLabel ?: recordId, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(metadata.label, style = MaterialTheme.typography.labelMedium)
}
Box {
TextButton(onClick = { actions = true }) { Text("Actions") }
DropdownMenu(actions, { actions = false }) {
DropdownMenuItem(text = { Text("Add photo") }, enabled = photoAvailable, onClick = { actions = false; capture = true })
DropdownMenuItem(text = { Text("Refresh record") }, onClick = { actions = false; reload++ })
}
}
}
if (hasParent) TextButton(onClick = { host.qNavigator.requestLeave("Back to parent?") { host.qNavigator.navController?.popBackStack() } }) { Text("Back to parent record") }
ScrollableTabRow(tab.coerceAtMost(photoTab), edgePadding = 0.dp) {
(listOf("Details") + related.map { it.name } + "Photos").forEachIndexed { index, label ->
Tab(tab == index, onClick = { tab = index }, text = { Text(label) })
}
}
Box(Modifier.weight(1f)) {
if (tab == 0) Column(Modifier.fillMaxSize()) {
Box {
TextButton(onClick = { sections = true }) { Text("Sections (${groups.size})") }
DropdownMenu(sections, { sections = false }) { groups.forEachIndexed { index, value ->
DropdownMenuItem(text = { Text(value.label) }, onClick = { group = index; sections = false })
} }
}
Column(Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 16.dp)) {
groups.forEachIndexed { index, value ->
TextButton(onClick = { group = if (group == index) -1 else index }, modifier = Modifier.fillMaxWidth().testTag("record.section:${value.label}")) {
Text("${if (group == index) "−" else "+"} ${value.label} (${value.fields.size})", Modifier.fillMaxWidth(), style = MaterialTheme.typography.titleMedium)
}
if (group == index) value.fields.forEach { field ->
Column(Modifier.padding(vertical = 6.dp)) {
Text(field.label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(current.formatted(field))
}
}
HorizontalDivider()
}
}
}
if (tab in 1..related.size) tabState.SaveableStateProvider("related:$tab") { RelatedRecords(host, tableName, recordId, current, related[tab - 1]) }
attachments(host, tableName, recordId, RecordAttachmentUi(tab == photoTab, capture, { capture = false }, { photoAvailable = it }, current.recordLabel ?: recordId))
}
}
}
}
/** Child queries and counts always use the declared parent join. */
@Composable
private fun RelatedRecords(host: QViewModel, parentTable: String, parentId: String, parent: RecordData, association: QRecordAssociation) {
var offset by rememberSaveable(parentTable, parentId, association.name, host.sessionUUID) { mutableStateOf(0) }
var reload by remember { mutableStateOf(0) }
var rows by remember { mutableStateOf<List<RecordData>>(emptyList()) }
var table by remember { mutableStateOf<QTableMetaData?>(null) }
var count by remember { mutableStateOf(0L) }
var loading by remember { mutableStateOf(true) }
var failed by remember { mutableStateOf(false) }
val scroll = rememberLazyListState()
LaunchedEffect(parentTable, parentId, association, offset, reload, host.sessionUUID) {
loading = true; failed = false
try {
val matches = requireNotNull(association.parentMatches(parentTable, parent))
check(matches.isNotEmpty())
val child = host.qqqRepository.getTableMetaData(association.associatedTableName)
check(matches.keys.all { it in child.fields })
table = child
val predicate = RecordSearch(matches = matches)
count = host.qqqRepository.countRecords(child.name, predicate)
rows = host.qqqRepository.searchRecords(child.name, requireNotNull(child.primaryKeyField), predicate, offset, 10)
} catch (cancelled: CancellationException) { throw cancelled }
catch (exception: Exception) { failed = true; rows = emptyList() }
finally { loading = false }
}
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp).testTag("record.related:${association.associatedTableName}")) {
if (loading) Text("Loading related records…")
if (failed) { Text("Related records are unavailable."); TextButton(onClick = { reload++ }) { Text("Retry") } }
if (!loading && !failed && rows.isEmpty()) Text("No related records.")
LazyColumn(state = scroll, modifier = Modifier.weight(1f)) {
if (!loading && !failed) items(rows) { row ->
val id = table?.primaryKeyField?.let(row::id)
if (id != null) RecordRow(row, table!!, "record.related.row:${association.associatedTableName}:$id") { host.qNavigator.navigateToRecord(association.associatedTableName, id, true) }
}
}
if (!failed) Paging(offset, rows.size, count, 10, loading, { offset = it })
}
}
@@ -16,6 +16,9 @@ import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.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 = ""
@@ -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)
@@ -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'
)
@@ -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<ByteArray?>(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 })
@@ -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.
*******************************************************************************/
@@ -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") } }
)
}
@@ -0,0 +1,138 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.kingsrook.qqq.frontend.android.core.model.RecordData
import com.kingsrook.qqq.frontend.android.core.model.RecordSearch
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonPrimitive
/** Only retained file identity and public display metadata reach the gallery. */
internal data class SavedRecordPhoto(val id: String, val name: String, val mediaType: String, val size: Long, val sha256: String, val lifecycle: String) {
companion object {
fun parse(row: RecordData, target: RecordPhotoTarget): SavedRecordPhoto {
require(row.id("tableName") == target.tableName && row.id("recordId") == target.recordId)
return SavedRecordPhoto(requireNotNull(row.id("id")), row.display("fileName"), row.id("mediaType").orEmpty(),
requireNotNull(row.id("sizeBytes")).toLong(), row.id("sha256").orEmpty(), row.id("lifecycle").orEmpty())
}
}
}
/** Parent-scoped metadata is visible even while the original awaits review. */
@Composable
internal fun SavedRecordPhotos(host: QViewModel, target: RecordPhotoTarget, api: RecordFilesApi, saved: Boolean) {
var offset by rememberSaveable(target.tableName, target.recordId, host.sessionUUID) { mutableStateOf(0) }
var refresh by remember { mutableStateOf(0) }
var rows by remember { mutableStateOf<List<SavedRecordPhoto>>(emptyList()) }
var hasNext by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(true) }
var failed by remember { mutableStateOf(false) }
var preview by remember { mutableStateOf<SavedRecordPhoto?>(null) }
val admission = remember { Semaphore(1) }
LaunchedEffect(target, host.sessionUUID, offset, refresh, saved) {
loading = true; failed = false
try {
val predicate = RecordSearch("image/", listOf("mediaType"), mapOf("tableName" to JsonPrimitive(target.tableName), "recordId" to JsonPrimitive(target.recordId)))
// Record Files intentionally denies aggregate counts. One look-ahead row keeps paging bounded.
val page = host.qqqRepository.searchRecords("record_files_file", "id", predicate, offset, 11).map { SavedRecordPhoto.parse(it, target) }
hasNext = page.size > 10
rows = page.take(10)
} catch (cancelled: CancellationException) { throw cancelled }
catch (exception: Exception) { failed = true; rows = emptyList() }
finally { loading = false }
}
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp).testTag("record.photos")) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(if (loading) "Loading photos…" else if (failed) "Photos unavailable" else "${offset + rows.size}${if (hasNext) "+" else ""} photos", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium)
TextButton(onClick = { refresh++ }) { Text("Refresh photos") }
}
if (failed) Text("Photos could not be loaded. Check your connection and access, then refresh.")
if (!failed && !loading && rows.isEmpty()) Text("No photos attached. Use Actions → Add photo.")
LazyColumn(Modifier.weight(1f).testTag("record.photos.list"), verticalArrangement = Arrangement.spacedBy(12.dp)) {
items(rows, key = { it.id }) { file ->
Row(Modifier.testTag("record.photo:${file.id}"), verticalAlignment = Alignment.CenterVertically) {
if (file.lifecycle == "AVAILABLE") PhotoImage(api, host.sessionUUID, file, admission, 256, Modifier.size(72.dp))
else Text("Photo", Modifier.size(72.dp).padding(8.dp), style = MaterialTheme.typography.labelMedium)
Column(Modifier.weight(1f).padding(start = 8.dp)) {
Text(file.name, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
Text(if (file.lifecycle == "QUARANTINED") "Awaiting review" else if (file.lifecycle == "AVAILABLE") "Available" else "Unavailable", style = MaterialTheme.typography.labelMedium)
if (file.lifecycle == "AVAILABLE") TextButton(onClick = { preview = file }) { Text("Preview photo") }
}
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
TextButton(enabled = !loading && offset > 0, onClick = { offset = (offset - 10).coerceAtLeast(0) }) { Text("Previous photos") }
TextButton(enabled = !loading && !failed && hasNext, onClick = { offset += 10 }) { Text("Next photos") }
}
}
preview?.let { file ->
AlertDialog(onDismissRequest = { preview = null }, title = { Text("Photo preview") }, text = {
Column { Text(file.name); PhotoImage(api, host.sessionUUID, file, admission, 1600, Modifier.fillMaxWidth()) }
}, confirmButton = { TextButton(onClick = { preview = null }) { Text("Close preview") } })
}
}
/** Decode a bounded bitmap in memory, without an image-loader disk cache or external URL. */
@Composable
private fun PhotoImage(api: RecordFilesApi, session: String, file: SavedRecordPhoto, admission: Semaphore, edge: Int, modifier: Modifier) {
var bitmap by remember(session, file, edge) { mutableStateOf<Bitmap?>(null) }
var failed by remember(session, file) { mutableStateOf(false) }
var retry by remember { mutableStateOf(0) }
LaunchedEffect(session, file, edge, retry) {
failed = false
try {
bitmap = admission.withPermit {
val bytes = api.image(session, file)
withContext(Dispatchers.Default) {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
require(options.outWidth in 1..100000 && options.outHeight in 1..100000)
options.inSampleSize = 1
while (maxOf(options.outWidth, options.outHeight) / options.inSampleSize > edge) options.inSampleSize *= 2
options.inJustDecodeBounds = false
requireNotNull(BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options))
}
}
} catch (cancelled: CancellationException) { throw cancelled }
catch (exception: Exception) { failed = true }
}
val image = bitmap
if (image != null) Image(image.asImageBitmap(), "Attached photo", modifier.testTag("photo.image:$edge"), contentScale = ContentScale.Fit)
else if (failed) TextButton(onClick = { retry++ }, modifier = modifier) { Text("Retry image") }
else Text("Loading image…", modifier, style = MaterialTheme.typography.labelSmall)
}
@@ -338,6 +338,10 @@ class ScannerActivity : ComponentActivity() {
}
}
},
com.kingsrook.qqq.frontend.android.mobileapp.ui.records.LocalRecordAttachments provides { model, tableName, recordId, recordUi ->
RecordPhotoCapture(model, RecordPhotoTarget(tableName, recordId, "Record photos", null), recordFiles, recordUi = recordUi,
disabled = !com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed.current())
},
LocalCustomWidgetRenderer provides { parameters, modifier, disabled ->
if (parameters.widgetBlock.blockType == "SCANNER") {
ScannerWidget(
@@ -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)
@@ -0,0 +1,8 @@
// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0
// Record details and synthetic camera/gallery results against an isolated owner fixture.
import { runScannerE2e } from './scanner-e2e-runner.mjs';
const args = process.argv.slice(2);
if (args.length !== 4 || args[0] !== '--serial' || args[2] !== '--fixture' || !args[1] || !args[3]) {
throw new Error('Usage: node scripts/ci/scanner-device-record-photo-e2e.mjs --serial DEVICE_SERIAL --fixture PRIVATE_RECORD_FILES_FIXTURE.json');
}
await runScannerE2e({ physical: true, serial: args[1], scenario: 'record-photos', fixture: args[3] });
+15 -6
View File
@@ -72,14 +72,15 @@ export async function runScannerE2e(options = {}) {
if (fixture) {
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));