Compare commits

...
Author SHA1 Message Date
james.maesandClaude Sonnet 5.5 089f57f759 Revert "ci: first run on ci-workflows v1 (native workflows)"
build / android (push) Failing after 3m29s
android / android (push) Failing after 3m29s
Commit 1d3847d deleted the entire tree (2584 files) and added nothing, so main was empty. This restores main to dc43a6a (the PR 22 merge) exactly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 06:18:45 -06:00
James MaesandClaude Opus 5.5 1d3847d070 ci: first run on ci-workflows v1 (native workflows)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:19 -06:00
james.maes dc43a6a747 Merge pull request 'ci: build on the shared ci-workflows Android workflow' (#22) from ci/shared-android into main 2026-11-08 13:52:39 +00:00
James MaesandClaude Opus 5.5 21c952ea81 ci: pin the shared workflow to v1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:52:19 -06:00
James MaesandClaude Opus 5.5 3bfafa5bac ci: call the shared workflow from ci-workflows main
build / android (push) Successful in 7m2s
android / android (push) Successful in 7m2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:45:02 -06:00
James MaesandClaude Opus 5.5 8e64d9a83c style: add the missing final newline (K22-CODE-07)
build / android (push) Successful in 6m38s
android / android (push) Successful in 6m38s
The source-standards check flags scanner-device-record-photo-e2e.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:28:48 -06:00
James MaesandClaude Opus 5.5 af38def2d2 ci: build on the shared ci-workflows Android workflow
build / android (push) Failing after 16s
android / android (push) Failing after 16s
Source checks, JVM unit tests, scanner lint, debug and QA APK assembly and
connected emulator tests run on every push via ci-workflows android.yaml.
vX.Y.Z tags publish the SDK AARs to the QRun-IO Gitea Maven registry.
Removes the retired CircleCI/qqq-orb configuration and the legacy GitHub
workflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:28:03 -06:00
james.maes f739a96f7a Merge pull request 'ci: remove workflows that call the retired CI system' (#21) from retire/old-ci into main 2026-11-07 13:29:37 +00:00
james.maesandClaude Opus 5.5 a2f8f2b5fd ci: remove workflows that call the retired CI system
Owner decision 2026-11-07: only KofTwentyTwo/ci-workflows remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:29:36 +00:00
james.maes 5ddd5a3317 Merge pull request 'release(scanner): 1.0.2 (versionCode 4) notes and build instructions' (#20) from release/scanner-1.0.2-notes into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: Shared Android CI / android-test (push) Successful in 9m48s
QRun-IO/ci-policy: Shared Android CI / secrets (push) Successful in 20s
QRun-IO/ci-policy: Shared Android CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 5s
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
Shared Android CI / unit-test (push) Successful in 4m7s
Shared Android CI / android-test (push) Successful in 9m44s
Shared Android CI / secrets (push) Successful in 19s
Shared Android CI / package (push) Successful in 3m49s
QRun-IO/ci-policy: Shared Android CI / package (push) Successful in 3m40s
Shared Android CI / gate (push) Successful in 14s
QRun Android lifecycle / release (push) Successful in 36m17s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (push) Successful in 4m10s
QRun-IO/ci-policy: ci / android-0 (push) Successful in 22m19s
2026-11-07 01:32:24 +00:00
james.maesandClaude Fable 5.1 cf9d53d786 release(scanner): prepare 1.0.2 (versionCode 4) notes and build instructions
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / android-test (push) Successful in 9m58s
QRun-IO/ci-policy: Shared Android CI / secrets (push) Successful in 21s
QRun-IO/ci-policy: Shared Android CI / android-test (pull_request) Successful in 10m0s
QRun-IO/ci-policy: Shared Android CI / package (pull_request) Successful in 15s
QRun-IO/ci-policy: Shared Android CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 9s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 8s
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / package (push) Successful in 15s
QRun-IO/ci-policy: ci / android-0 (pull_request) Successful in 27m25s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (push) Successful in 4m6s
QRun-IO/ci-policy: Shared Android CI / unit-test (pull_request) Successful in 4m6s
QRun-IO/ci-policy: Shared Android CI / secrets (pull_request) Successful in 20s
QRun-IO/ci-policy: ci / android-0 (push) Successful in 41m35s
QRun-IO/ci-policy: Shared Android CI / gate (pull_request) Successful in 14s
Supersedes the unpublished signed 1.0.1 (versionCode 3) candidate. The
version is passed at build time (-PscannerVersionName=1.0.2
-PscannerVersionCode=4); the docs now name that pair everywhere 1.0.1 / 3
was the next build. Release notes in CHANGELOG.md: company sign-out now
completes (device-session end ran before provider logout), user name shown
after browser sign-in, app/OS version reported on heartbeat (effective with
mobile-scanners server 1.6.0).

Nothing is built, signed, tagged or published. This branch carries the
local merges of PRs 17 and 18 and must be rebased after they merge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 17:25:35 -06:00
james.maes eeb9f63322 Merge pull request 'fix(scanner): report osVersion and appVersion on every heartbeat' (#18) from fix/refresh-device-versions into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: Shared Android CI / android-test (push) Successful in 9m50s
QRun-IO/ci-policy: Shared Android CI / secrets (push) Successful in 21s
QRun-IO/ci-policy: Shared Android CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 12s
Shared Android CI / unit-test (push) Successful in 4m28s
Shared Android CI / android-test (push) Successful in 10m14s
Shared Android CI / secrets (push) Successful in 21s
Shared Android CI / package (push) Successful in 3m38s
QRun-IO/ci-policy: Shared Android CI / package (push) Successful in 3m45s
Shared Android CI / gate (push) Successful in 16s
QRun Android lifecycle / release (push) Successful in 1h10m27s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (push) Successful in 4m1s
QRun-IO/ci-policy: ci / android-0 (push) Successful in 55m37s
2026-11-06 23:23:22 +00:00
james.maes f39f7a37d3 Merge pull request 'fix(auth): run provider logout before host session end; show OAuth user name' (#17) from fix/company-signout-before-session-end into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Canceled after 0s
QRun-IO/ci-policy: Shared Android CI / android-test (push) Canceled after 0s
QRun-IO/ci-policy: Shared Android CI / secrets (push) Canceled after 0s
QRun-IO/ci-policy: Shared Android CI / gate (push) Canceled after 0s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 13s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 11s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: Shared Android CI / package (push) Canceled after 0s
Shared Android CI / unit-test (push) Successful in 7m46s
Shared Android CI / android-test (push) Successful in 12m5s
Shared Android CI / secrets (push) Successful in 21s
Shared Android CI / package (push) Successful in 3m48s
Shared Android CI / gate (push) Successful in 14s
QRun Android lifecycle / release (push) Successful in 2h7m8s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Canceled after 0s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (push) Canceled after 0s
2026-11-06 23:22:49 +00:00
james.maesandClaude Fable 5.1 f733a9906c fix(scanner): report osVersion and appVersion on every heartbeat
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / android-test (pull_request) Successful in 10m21s
QRun-IO/ci-policy: Shared Android CI / package (pull_request) Successful in 20s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Successful in 1h27m9s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (pull_request) Successful in 4m35s
QRun-IO/ci-policy: Shared Android CI / secrets (pull_request) Successful in 22s
QRun-IO/ci-policy: Shared Android CI / gate (pull_request) Successful in 18s
After the MC9400 in-place 1.0.0 to 1.0.1 update, scan receipts still showed
appVersion 1.0.0: the host sent osVersion/appVersion only at enrollment and
the server never refreshed them. MobileSessionApi now carries both values
(Build.VERSION.RELEASE and BuildConfig.VERSION_NAME, blank values omitted,
200-character cap) on each heartbeat so a mobile-scanners 1.6 server
refreshes the device record; older servers ignore the optional keys.

MobileSessionTest verifies the heartbeat payload carries only the two
version keys and no other device metadata. UI-API.md and SESSION-STATE.md
record the change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 10:40:03 -06:00
james.maesandClaude Fable 5.1 cf82dd5d78 fix(auth): run provider logout before host session end; show OAuth user name
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / android-test (pull_request) Successful in 10m13s
QRun-IO/ci-policy: Shared Android CI / package (pull_request) Successful in 23s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Successful in 1h42m13s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (pull_request) Successful in 4m51s
QRun-IO/ci-policy: Shared Android CI / secrets (pull_request) Successful in 20s
QRun-IO/ci-policy: Shared Android CI / gate (pull_request) Successful in 19s
Company sign-out on the MC9400 (production APK, real Authentik) never opened
the browser: QViewModel.performLogout ran the host beforeLogout hook (scanner
device-session end, which runs the core logout server-side) before the
provider logout request, so POST /qqq/v1/logout found no session and returned
no nativeLogoutData. Company sign-out now requests the provider material
first, then ends the host session (401 tolerated), then clears local state.
Ordinary sign-out keeps the host-hook-first order.

Browser login now adopts the server-reported values.user.name so Account no
longer shows a blank current user.

Adds QViewModelLogoutTest (ordering, failing host end, ordinary order, user
name adoption, ManageSessionResponse.userName parsing) and documents the
finding in AUTHENTICATION.md and SESSION-STATE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 10:40:01 -06:00
james.maes 3325bd6061 Merge pull request 'test(scanner): verify immediate photo availability and preview' (#16) from test/available-scanner-photo-policy into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: Shared Android CI / android-test (push) Successful in 9m49s
QRun-IO/ci-policy: Shared Android CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: Shared Android CI / gate (push) Successful in 11s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (push) Successful in 5s
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
Shared Android CI / unit-test (push) Successful in 4m0s
Shared Android CI / android-test (push) Successful in 10m1s
Shared Android CI / secrets (push) Successful in 18s
Shared Android CI / package (push) Successful in 3m36s
QRun-IO/ci-policy: Shared Android CI / package (push) Successful in 3m41s
Shared Android CI / gate (push) Successful in 12s
QRun Android lifecycle / release (push) Successful in 35m58s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (push) Successful in 4m1s
QRun-IO/ci-policy: ci / android-0 (push) Successful in 21m49s
2026-11-05 18:16:17 +00:00
james.maesandClaude Sonnet 5.5 9e06e1f3e9 test(scanner): verify immediate photo availability and preview
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / android-test (pull_request) Successful in 9m49s
QRun-IO/ci-policy: Shared Android CI / package (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Successful in 14m34s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (pull_request) Successful in 4m1s
QRun-IO/ci-policy: Shared Android CI / secrets (pull_request) Successful in 18s
QRun-IO/ci-policy: Shared Android CI / gate (pull_request) Successful in 12s
Assert the Echo fixture's available captures and generic hosts' quarantine
behavior. Scope thumbnail and preview checks to the newly uploaded photo.

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

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

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

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

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

No files matched your search

-14
View File
@@ -1,14 +0,0 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /.*/
+19
View File
@@ -0,0 +1,19 @@
name: build
on:
push:
branches: ['**']
tags: ['v*']
jobs:
android:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/android.yaml@v1
secrets: inherit
with:
node-version: '24.11.1'
commands: |
node scripts/ci/check-source-standards.mjs
node --test scripts/ci/scanner-echo-guards.test.mjs
tasks: |
testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleDebug
-PscannerTestBuildType=qa :scanner-host:assembleQa :scanner-host:assembleQaAndroidTest
emulator-tasks: connectedDebugAndroidTest
publish-tasks: :qqq:android:core:publishAllPublicationsToGiteaRepository :qqq:android:mobileapp:publishAllPublicationsToGiteaRepository
@@ -1,13 +0,0 @@
name: QRun Android lifecycle
'on':
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
release:
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/android.yaml@qrun-release-v1
with:
runner: android-build
-138
View File
@@ -1,138 +0,0 @@
name: Release scanner APK
on:
push:
tags: ['scanner-v*']
permissions:
contents: read
concurrency:
group: scanner-release
cancel-in-progress: false
jobs:
verify:
uses: ./.github/workflows/scanner.yml
package:
needs: verify
runs-on: ubuntu-24.04
timeout-minutes: 30
environment: scanner-release
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
fetch-depth: 0
- name: Require main ancestry and a strictly newer Android version
id: release-version
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SCANNER_VERSION_CODE: ${{ vars.SCANNER_VERSION_CODE }}
shell: bash
run: |
set -euo pipefail
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main
[[ "$SCANNER_VERSION_CODE" =~ ^[1-9][0-9]{0,9}$ ]] || exit 1
(( SCANNER_VERSION_CODE <= 2100000000 )) || exit 1
gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
--jq '.[] | select(.draft == false) | select(.tag_name | startswith("scanner-v")) | .tag_name' > "$RUNNER_TEMP/scanner-prior-tags.txt"
while IFS= read -r tag; do
[[ "$tag" =~ ^scanner-v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]] || exit 1
prior_directory=$(mktemp -d "$RUNNER_TEMP/scanner-prior.XXXXXX")
gh release download "$tag" --pattern BUILD.txt --dir "$prior_directory"
previous=$(sed -n 's/^Version code: //p' "$prior_directory/BUILD.txt")
[[ "$previous" =~ ^[1-9][0-9]{0,9}$ ]] || exit 1
(( SCANNER_VERSION_CODE > previous )) || { echo 'Version code must exceed every published scanner release'; exit 1; }
done < "$RUNNER_TEMP/scanner-prior-tags.txt"
printf 'code=%s\n' "$SCANNER_VERSION_CODE" >> "$GITHUB_OUTPUT"
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
- name: Install Android SDK packages
run: |
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;34.0.0" "build-tools;35.0.0" "platform-tools"
- name: Build and verify signed release
env:
SCANNER_KEYSTORE_BASE64: ${{ secrets.SCANNER_KEYSTORE_BASE64 }}
SCANNER_STORE_PASSWORD: ${{ secrets.SCANNER_STORE_PASSWORD }}
SCANNER_KEY_ALIAS: ${{ secrets.SCANNER_KEY_ALIAS }}
SCANNER_KEY_PASSWORD: ${{ secrets.SCANNER_KEY_PASSWORD }}
SCANNER_CERTIFICATE_SHA256: ${{ vars.SCANNER_CERTIFICATE_SHA256 }}
SCANNER_VERSION_CODE: ${{ steps.release-version.outputs.code }}
shell: bash
run: |
set -euo pipefail
umask 077
version="${GITHUB_REF_NAME#scanner-v}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]] || exit 1
[[ "$SCANNER_VERSION_CODE" =~ ^[1-9][0-9]{0,9}$ ]] || exit 1
(( SCANNER_VERSION_CODE <= 2100000000 )) || exit 1
[[ "$SCANNER_CERTIFICATE_SHA256" =~ ^[A-Fa-f0-9]{64}$ ]] || exit 1
for name in SCANNER_KEYSTORE_BASE64 SCANNER_STORE_PASSWORD SCANNER_KEY_ALIAS SCANNER_KEY_PASSWORD; do
[[ -n "${!name}" ]] || { echo "Missing release signing configuration: $name"; exit 1; }
done
export SCANNER_KEYSTORE="$RUNNER_TEMP/scanner-release.jks"
trap 'rm -f -- "$SCANNER_KEYSTORE"' EXIT
printf '%s' "$SCANNER_KEYSTORE_BASE64" | base64 --decode > "$SCANNER_KEYSTORE"
unset SCANNER_KEYSTORE_BASE64
./gradlew --no-daemon --console=plain :scanner-host:lintRelease :scanner-host:assembleRelease \
"-PscannerVersionName=$version" "-PscannerVersionCode=$SCANNER_VERSION_CODE"
mkdir -p release
cp scanner-host/build/outputs/apk/release/scanner-host-release.apk "release/qqq-mobile-$version.apk"
cp scanner-host/build/reports/third-party/release/{aboutlibraries.json,LICENSE,NOTICE} release/
"$ANDROID_HOME/build-tools/35.0.0/apksigner" verify --verbose --print-certs "release/qqq-mobile-$version.apk" > release/signature.txt
certificate=$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' release/signature.txt)
[[ "${certificate,,}" == "${SCANNER_CERTIFICATE_SHA256,,}" ]] || { echo 'Unexpected signing certificate'; exit 1; }
"$ANDROID_HOME/build-tools/35.0.0/aapt" dump badging "release/qqq-mobile-$version.apk" > "$RUNNER_TEMP/scanner-manifest.txt"
grep -F "package: name='io.qrun.mobile.scanner' versionCode='$SCANNER_VERSION_CODE' versionName='$version'" "$RUNNER_TEMP/scanner-manifest.txt"
if grep -q 'application-debuggable' "$RUNNER_TEMP/scanner-manifest.txt"; then
echo 'Refusing a debuggable release'; exit 1
fi
printf 'Source: %s\nVersion: %s\nVersion code: %s\n' "$GITHUB_SHA" "$version" "$SCANNER_VERSION_CODE" > release/BUILD.txt
(cd release && sha256sum -- *.apk signature.txt BUILD.txt aboutlibraries.json LICENSE NOTICE > SHA256SUMS)
- name: Retain signed deliverable
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: scanner-release-${{ github.sha }}
path: release/
if-no-files-found: error
retention-days: 30
publish:
needs: package
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: scanner-release-${{ github.sha }}
path: release
- name: Publish immutable-tag APK assets
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
(cd release && sha256sum --check SHA256SUMS)
prerelease=()
version="${GITHUB_REF_NAME#scanner-v}"
tag_object=$(gh api "repos/$GH_REPO/git/ref/tags/$GITHUB_REF_NAME" --jq '.object.sha')
tag_type=$(gh api "repos/$GH_REPO/git/ref/tags/$GITHUB_REF_NAME" --jq '.object.type')
for ((attempt=0; attempt<5; attempt++)); do
if [[ "$tag_type" == commit ]]; then break; fi
[[ "$tag_type" == tag ]] || exit 1
tag_json=$(gh api "repos/$GH_REPO/git/tags/$tag_object")
tag_object=$(jq -r '.object.sha' <<< "$tag_json")
tag_type=$(jq -r '.object.type' <<< "$tag_json")
done
[[ "$tag_type" == commit && "$tag_object" == "$GITHUB_SHA" ]] || { echo 'Release tag no longer identifies the verified source'; exit 1; }
if [[ "$version" == *-* ]]; then prerelease=(--prerelease); fi
gh release create "$GITHUB_REF_NAME" --verify-tag --title "QQQ Mobile $version" \
--generate-notes "${prerelease[@]}" release/*
-163
View File
@@ -1,163 +0,0 @@
name: Scanner Android
on:
push:
branches: [main, 'feature/**', 'fix/**', 'release/**', 'hotfix/**']
pull_request:
workflow_call:
workflow_dispatch:
inputs:
emulator:
description: Run native UI and real-server scanner end-to-end tests
type: boolean
default: true
permissions:
contents: read
concurrency:
group: scanner-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
verify:
name: Tests, lint, and debug APK
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
with:
cache-read-only: ${{ github.event_name == 'pull_request' }}
- name: Verify source headers, whitespace, imports and JavaScript syntax
run: node scripts/ci/check-source-standards.mjs
- name: Run no-device Echo fixture guard tests
run: node --test scripts/ci/scanner-echo-guards.test.mjs
- name: Install required Android SDK packages
run: |
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;34.0.0" "build-tools;35.0.0" "platform-tools"
- name: Verify shared libraries and scanner host
run: >-
./gradlew --no-daemon --console=plain
:qqq:android:core:testDebugUnitTest
:qqq:android:mobileapp:testDebugUnitTest
:scanner-host:testDebugUnitTest
:scanner-host:lintDebug
:scanner-host:assembleDebug
- name: Compile opt-in physical QA acceptance
run: >-
./gradlew --no-daemon --console=plain -PscannerTestBuildType=qa
:scanner-host:assembleQa :scanner-host:assembleQaAndroidTest
- name: Hash debug APK
run: |
mkdir -p scanner-host/build/outputs/apk/debug/notices
cp scanner-host/build/reports/third-party/debug/{aboutlibraries.json,LICENSE,NOTICE} scanner-host/build/outputs/apk/debug/notices/
(cd scanner-host/build/outputs/apk/debug && sha256sum scanner-host-debug.apk notices/* > SHA256SUMS)
- name: Upload installable development APK
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: scanner-debug-${{ github.sha }}
path: scanner-host/build/outputs/apk/debug/
if-no-files-found: error
retention-days: 14
- name: Upload test and lint evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: scanner-checks-${{ github.sha }}
path: |
qqq/android/core/build/test-results/
qqq/android/mobileapp/build/test-results/
scanner-host/build/test-results/
scanner-host/build/reports/
retention-days: 14
emulator:
name: Native UI and real-server scanner E2E
if: github.event_name != 'workflow_dispatch' || inputs.emulator
needs: verify
runs-on: ubuntu-24.04
timeout-minutes: 40
env:
QQQ_CORE_SHA: ecf0f550a0357cec6387c2b54efca128a405a629
SCANNER_SERVER_SHA: 10416938724c9663d66f7848ff2b1a63fe635794
MAVEN_OPTS: -XX:ActiveProcessorCount=2 -Xmx768m
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
- name: Check out the pinned QQQ core source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: QRun-IO/qqq
ref: ${{ env.QQQ_CORE_SHA }}
path: .e2e-core
persist-credentials: false
- name: Check out the pinned real QQQ demo
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: QRun-IO/qbit-mobile-scanners
ref: ${{ env.SCANNER_SERVER_SHA }}
path: .e2e-server
persist-credentials: false
- name: Verify and build the isolated source-pinned server fixture
env:
JAVA_TOOL_OPTIONS: -XX:ActiveProcessorCount=2 -Xmx768m
run: |
test "$(git -C .e2e-core rev-parse HEAD)" = "$QQQ_CORE_SHA"
test "$(git -C .e2e-server rev-parse HEAD)" = "$SCANNER_SERVER_SHA"
fixture_m2=$(mktemp -d "$RUNNER_TEMP/scanner-fixture-m2.XXXXXX")
core_revision="4.1.0-ci.$QQQ_CORE_SHA"
(cd .e2e-core && ./mvnw --batch-mode --no-transfer-progress \
-Dmaven.repo.local="$fixture_m2" -Drevision="$core_revision" \
-pl qqq-bom,qqq-middleware-api -am clean install)
(cd .e2e-server && ./mvnw --batch-mode --no-transfer-progress \
-Dmaven.repo.local="$fixture_m2" -Dqqq.version="$core_revision" clean install)
(cd .e2e-server && ./mvnw --batch-mode --no-transfer-progress -f demo/pom.xml \
-Dmaven.repo.local="$fixture_m2" -Dqqq.version="$core_revision" clean verify \
dependency:build-classpath -DincludeScope=runtime -Dmdep.outputFile=target/e2e-classpath.txt \
dependency:tree -Dscope=runtime -DoutputFile=target/fixture-dependencies.txt \
help:effective-pom -Doutput=target/fixture-effective-pom.xml)
node scripts/ci/verify-fixture-provenance.mjs .e2e-server "$fixture_m2" "$QQQ_CORE_SHA" "$SCANNER_SERVER_SHA"
- name: Enable hosted-runner hardware acceleration
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run emulator checks
uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: 35
target: google_apis
arch: x86_64
disable-animations: true
script: |
./gradlew --no-daemon --console=plain :qqqsampleandroidmobileapp:connectedDebugAndroidTest :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest
node scripts/ci/scanner-e2e.mjs .e2e-server
- name: Upload emulator evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: scanner-emulator-${{ github.sha }}
include-hidden-files: true
path: |
qqqsampleandroidmobileapp/build/reports/androidTests/
qqqsampleandroidmobileapp/build/outputs/androidTest-results/
scanner-host/build/native-e2e/
.e2e-server/demo/target/fixture-provenance.json
.e2e-server/demo/target/fixture-dependencies.txt
.e2e-server/demo/target/fixture-effective-pom.xml
.e2e-core/**/target/surefire-reports/TEST-*.xml
.e2e-server/**/target/surefire-reports/TEST-*.xml
retention-days: 14
-3
View File
@@ -1,3 +0,0 @@
profile: android
orb_version: '0.8'
default_branch: main
+1 -1
View File
@@ -24,4 +24,4 @@ Never log or place passwords, session cookies, or device tokens in process value
## Repository automation
The primary forge is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive, so make no ordinary GitHub writes. Protected `main` on Gitea has `.gitea/workflows/qrun-android-release.yaml` calling the shared `KofTwentyTwo/platform-ci` `android.yaml@qrun-release-v1`; pull-request checks come from central Gitea policy, not from that release workflow. It is not a scanner-APK signing pipeline, and no automatic signing CI is configured. CircleCI is managed by `kingsrook/qqq-orb` using `.qqq-ci.yml`; preserve that lane and its main-only library publication. The legacy `.github` `Scanner Android` workflow builds a debug APK artifact and never publishes libraries or production releases; it and the scanner release workflow are not a selected release path. The owner has authorized production signing with the QRun-owned key `qqq-mobile-release` (details and fingerprint in `docs/RELEASING.md`) for signed-APK/MDM distribution. Do not create replacement or additional signing keys, put key material, passwords or vault item details in the repository, or use the legacy GitHub release workflows; create no tags or releases without current authorization. Do not replace managed CI with a stale local template.
The primary forge is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive, so make no ordinary GitHub writes. `.gitea/workflows/build.yaml` calls the shared `KofTwentyTwo/ci-workflows` Android workflow (`android.yaml@v1`) on every push: source checks, JVM unit tests, scanner lint, debug and QA APK assembly, and connected tests on an emulator (android-build runners). A `vX.Y.Z` tag also publishes the SDK AARs (`qqq-android-core`, `qqq-android-mobileapp`) to the QRun-IO Gitea Maven registry. It is not a scanner-APK signing pipeline, and no automatic signing CI is configured. The owner has authorized production signing with the QRun-owned key `qqq-mobile-release` (details and fingerprint in `docs/RELEASING.md`) for signed-APK/MDM distribution. Do not create replacement or additional signing keys, or put key material, passwords or vault item details in the repository; create no tags or releases without current authorization.
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 1.0.2 (versionCode 4), candidate
Not yet built, signed, tagged or published (prepared 2026-11-06). Supersedes the unpublished signed 1.0.1 (versionCode 3) candidate and updates it in place with the same permanent signer.
- Company sign-out now completes: the provider logout request runs before the scanner host ends its device session, so the browser end-session page opens. Previously the device-session end ran first, removed the QQQ session server-side, and `nativeLogoutData` was never returned; observed on the MC9400 with the production APK against the real Authentik server. (PR 17)
- The signed-in user's name is shown after browser (OAuth) sign-in instead of a blank current user. (PR 17)
- The scanner host reports the installed app and OS version on every session heartbeat, so scan receipts show the new version after an in-place update. Effective with mobile-scanners server 1.6.0; older servers ignore the keys. (PR 18)
## Unreleased
- Add the QQQ Mobile scanner host for metadata-defined native apps and processes.
+2 -2
View File
@@ -23,9 +23,9 @@ The smoke script clears application data only after verifying the target is an e
## CI and delivery
Managed CircleCI remains the shared-library test and Maven publication lane. Its Android profile publishes libraries only from `main`, after its checks. Feature branches do not publish.
`.gitea/workflows/build.yaml` calls the shared `KofTwentyTwo/ci-workflows` Android workflow (`android.yaml@v1`) on every push: source checks, JVM unit tests, scanner lint, debug and QA APK assembly, and connected tests on an emulator (android-build runners). A `vX.Y.Z` tag also publishes the SDK AARs (`qqq-android-core`, `qqq-android-mobileapp`) to the QRun-IO Gitea Maven registry. Feature branches never publish.
The GitHub **Scanner Android** workflow runs shared-library/scanner JVM tests, scanner lint, and debug APK assembly for pull requests and supported branches. It uploads the development APK with SHA256 and verification reports. **Run workflow** optionally adds native emulator tests and a scanner launch screenshot. This workflow never publishes Maven artifacts, signs a production APK, or deploys to a device/store.
The emulator scanner end-to-end suite against a real QQQ server (`scripts/ci/scanner-e2e.mjs`) is not part of CI; run it locally as described in [emulator testing](docs/EMULATOR.md).
Production signing and distribution require a separately configured release procedure. Do not treat the development APK as a production release.
+2 -2
View File
@@ -59,7 +59,7 @@ See [emulator testing](docs/EMULATOR.md) for an emulator demo, synthetic scans a
### Signed APK (direct install / MDM)
For real devices. Requires an **HTTPS** server URL and the production-signed APK built with the QRun-owned key. Follow the [installation guide](docs/INSTALLATION.md) and [release readiness](docs/RELEASING.md). A signed 1.0.0 (versionCode 2) APK exists and was installed and launched on one MC9400, but it has not been qualified end to end, distributed or published, and no release exists. The development-signed and production-signed builds cannot update each other in place.
Requires an **HTTPS** server URL and the production-signed APK built with the QRun-owned key. Follow the [installation guide](docs/INSTALLATION.md) and [release readiness](docs/RELEASING.md). The published download remains the [signed 1.0.0 preview](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease, forge sign-in required). The signed 1.0.1 candidate has passed trusted-HTTPS enrollment, physical scanning, navigation and an in-place update on the MC9400, preserving login and enrollment. Its publication, native Authentik qualification and final dependency-cohort checks remain open; a 1.0.2 (versionCode 4) candidate with company sign-out, user-name and heartbeat-version fixes is prepared but not yet built (see [CHANGELOG](CHANGELOG.md)). Development-signed installations require an explicit migration to the production signer.
## Build and test
@@ -73,7 +73,7 @@ Limits: JVM, emulator and synthetic-scan tests do not prove physical trigger, li
## CI and automation
The primary forge is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive and receives no ordinary writes. Protected `main` runs [`.gitea/workflows/qrun-android-release.yaml`](.gitea/workflows/qrun-android-release.yaml) on push and manual dispatch; it calls the shared `KofTwentyTwo/platform-ci` Android workflow (`android.yaml@qrun-release-v1`). That workflow builds, verifies and publishes the SDK libraries; it is not a scanner-APK signing or release pipeline. Pull-request checks come from central Gitea policy. The managed CircleCI profile (`.qqq-ci.yml`) and its main-only library publication are retained. The `.github` workflows are legacy and not a release path. Shared CI design is out of scope for this repository's local use: the demo above needs none of it.
The primary forge is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive and receives no ordinary writes. `.gitea/workflows/build.yaml` calls the shared `KofTwentyTwo/ci-workflows` Android workflow (`android.yaml@v1`) on every push: source checks, JVM unit tests, scanner lint, debug and QA APK assembly, and connected tests on an emulator (android-build runners). A `vX.Y.Z` tag also publishes the SDK AARs (`qqq-android-core`, `qqq-android-mobileapp`) to the QRun-IO Gitea Maven registry. It is not a scanner-APK signing or release pipeline. Shared CI design is out of scope for this repository's local use: the demo above needs none of it.
## Extension points
+2
View File
@@ -67,6 +67,8 @@ Ordinary **Sign out** continues to end only the QQQ session. The scanner also ex
Register the installed package's exact callback separately as a provider **post-logout** redirect, even when it matches the existing login callback. The client sends `{"nativeLogout":{"postLogoutRedirectUri":"<registered callback>"}}` to the existing `POST /qqq/v1/logout`. Its optional `nativeLogoutData` contains `issuer`, `endSessionEndpoint`, `postLogoutRedirectUri` and sensitive `idTokenHint`. Old sessions or unsupported servers may legitimately omit this data. The ordinary logout request remains unchanged.
**Ordering (fix prepared 2026-11-05, not released):** for company sign-out the client sends the provider logout request *before* running the host's `beforeLogout` hook, then clears local state, then opens the browser. The scanner host's hook ends the device session (`POST /qbit/mobile-scanners/sessions/current/end`), which on the server runs the core logout and removes the QQQ session; running it first left no session for `POST /qqq/v1/logout` to describe, so `nativeLogoutData` was never returned and the browser never opened (observed on an MC9400 against a real Authentik fixture). The device-session end may now answer 401 because the login already ended; the client tolerates that and still clears local authentication. Ordinary sign-out is unchanged: host hook first, then the core logout.
The client clears QQQ session/private process state before opening AppAuth's end-session browser. It independently fetches discovery from the original login binding and checks the issuer, HTTPS end-session endpoint (without user information, query or fragment), and exact callback. A distinct in-memory, single-use state attempt binds completion to server, provider and endpoint. Login cannot start during company logout; cancellation, wrong state, reset or a late callback cannot restore a QQQ session. Browser cancellation or unavailable data explicitly reports incomplete company sign-out. Returning from the provider is protocol completion, not an independent claim about every upstream SSO session.
The hint is deliberately held in transient ordinary classes with no generated value `toString`, never application preferences, ViewModel state, diagnostics, process values or application saved state. Parsing failures and HTTP diagnostics do not expose response bodies. AppAuth0.11.1 necessarily serializes its end-session request into Android Intents/saved Activity state, and the browser receives the hint in a URL query. This is not a zero-persistence guarantee. Do not enable URI/body logging, browser traces or proxy access logs containing query strings. AppAuth's warning on a state mismatch can contain state values; do not treat SDK debug output as a safe diagnostics feed. Native synthetic tests include a hint logcat/preferences canary; real-provider and hardware qualification are separate gates.
+3 -3
View File
@@ -1,6 +1,6 @@
# Finish ledger for 1.0
As of 2026-11-04. This is not a release and no 1.0 is published. Older ledgers are in [docs/history](history/README.md); the checklist is [TODO](TODO.md).
As of 2026-11-04. A signed 1.0.0 preview is published (prerelease `scanner-v1.0.0`); there is no stable or fully qualified 1.0. Older ledgers are in [docs/history](history/README.md); the checklist is [TODO](TODO.md).
| Area | State | Evidence / limit |
| --- | --- | --- |
@@ -8,8 +8,8 @@ As of 2026-11-04. This is not a release and no 1.0 is published. Older ledgers a
| Local demo | Accepted | Server on port 57832; TC22 development APK; operator-reported manual acceptance ALL PASS 2026-11-04 incl. label count/fidelity, camera, GPS, screen viewing (not instrumented, no per-case artifacts) |
| Echo | Local semantic flow qualified | Printing and inputs simulated in disposable runs; physical label count/fidelity operator-reported pass (not instrumented) |
| MC9400 | Demo-configured and approved | Physical trigger scan to backend not yet verified |
| Signed APK | Built and verified locally, not distributed | 1.0.0 / versionCode 2; installed and launched once on an MC9400 before the demo switch; no end-to-end qualification |
| Release | Not done | Needs HTTPS server, device qualification and distribution; no tag, no publication, no signing CI |
| Signed APK | Published as signed preview | [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), 1.0.0 / versionCode 2; APK and `SHA256SUMS` only, download-verified; installed and launched once on an MC9400 before the demo switch; no end-to-end qualification |
| Release | Preview only, not stable | Needs HTTPS server and device qualification (enrollment, scanning, in-place update) before stable; no signing CI |
| Authentik | Desktop passes; on-device callback unverified | No separate native provider/callback evidence |
| Dependencies | Qualified inputs, not a public cohort | Generation, Record Files, Scripting owner inputs behind the local server |
| MDM / company profile | Prepared, unverified | Nothing applied on a device |
+19 -7
View File
@@ -4,26 +4,38 @@ Use this for the **production-signed** APK on a small number of devices. Move to
This guide is not the local demo. The demo uses the development-signed debug APK, plain HTTP and `adb reverse` port forwarding to a workstation server; see [README](../README.md#installing-on-a-device). The two builds have different signers and cannot update each other.
**Current release status (November 5, 2026):** the only published release is the signed preview 1.0.0 below. A signed **1.0.1 (versionCode 3) candidate** exists but is **not published and has no download link**; this guide does not point to one. Use the table below for what is actually hosted. It is superseded by the planned **1.0.2 (versionCode 4)** candidate (not yet built; see [RELEASING](RELEASING.md#next-candidate-102-versioncode-4)). When a newer version is published, use the file, checksum and link given by the release owner, and verify the same signer.
| Candidate (not published) | Value |
| --- | --- |
| Version | 1.0.1, versionCode 3 |
| APK SHA-256 | `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F` (15015419 bytes) |
| Signing certificate SHA-256 | Same as below (`BDDB2D2F...5B17A9FF`) |
Details and qualification status: [RELEASING](RELEASING.md).
| Item | Value |
| --- | --- |
| File | `QQQ-Mobile-1.0.0.apk` (locally signed) |
| Source | [Signed preview release scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease; forge sign-in required) |
| File | `QQQ-Mobile-1.0.0.apk`, with `SHA256SUMS` on the same release page |
| Application ID | `io.qrun.mobile.scanner` |
| Version | 1.0.0, versionCode 2 |
| APK SHA-256 | `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51` |
| Signing certificate SHA-256 | `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF` |
The same APK is used for every customer; branding comes from the server at runtime. Compare the SHA-256 before installing. The checksum shows the file is intact, not who signed it.
The same APK is used for every customer; branding comes from the server at runtime. Download the APK and `SHA256SUMS` from the release page and compare the SHA-256 before installing, then verify the signing certificate matches the value above. The checksum shows the file is intact, not who signed it. This is a preview, not a stable release. Only the APK and `SHA256SUMS` are hosted; there is no hosted install kit.
This APK was installed and launched once on a fresh Zebra MC9400 (Android 13): install succeeded, the Connect screen appeared and the installed bytes matched the hash above. It has **not** been enrolled, scanned or navigated, because no HTTPS server was available, so these steps are not hardware-qualified until the first install is confirmed (below). That MC9400 now runs the development demo build instead.
**Device evidence.** *Historical (November 4):* this APK was installed and launched once on a fresh Zebra MC9400 (Android 13) and showed the Connect screen; it was not yet enrolled. *Current (November 5):* with the user's explicit authorization, the MC9400 was migrated: 1.0.0 enrollment over trusted HTTPS passed, then an in-place 1.0.1 update preserved login and enrollment. Apps/ScanCapture navigation and EMDK Scanner-ready were observed, and the operator confirmed a physical Saved scan that the backend verified. These checks are done and need not be repeated for this device. The 1.0.1 candidate is still unpublished, native Authentik login/logout/account switching is not yet qualified with the production APK, and the TC22 keeps its original development-signed installation.
## Before you start
- The device is connected to Wi-Fi.
- You have an **HTTPS** QQQ server URL the device can reach. Release builds reject HTTP. The server is not chosen yet: use the address you are given, and do not guess one.
- You have a customer **HTTPS** QQQ server URL with a trusted certificate that the device can reach. Release builds reject HTTP, and certificate checks must not be weakened. Use the address you are given (`https://<your-server>`); do not guess one. Local-network qualification used a temporary private network and is not a customer deployment.
- You have the QQQ credentials for the sign-in the server offers. None are in this guide.
## Install: copy the file (recommended)
1. Copy `QQQ-Mobile-1.0.0.apk` to the device's **Downloads** folder.
1. Copy the verified `QQQ-Mobile-1.0.0.apk` to the device's **Downloads** folder.
2. Open **Files**, go to Downloads, and tap the APK.
3. If Android asks, allow that Files app to install apps, then continue. You can turn the permission off again afterward.
@@ -50,7 +62,7 @@ Grant location when Android asks; scanning works without it. Zebra devices with
## Confirm the first new install
On the first device only, check each of these:
On the first device of a new customer deployment, check each of these (already done for the MC9400 qualification above):
- The footer shows **Connected**.
- One scan is captured and the server keeps it (**Saved scan**).
@@ -65,7 +77,7 @@ For a later build signed by the same key with a higher versionCode, run the same
## Devices running the development-signed build
Devices running the development-signed demo build (the TC22, and the MC9400 in demo mode) cannot be updated by this APK, because the signer differs. Android is expected to refuse the install. This is not a fault: do not uninstall the app or reset the device to get around it. Moving that device needs a planned one-time migration that includes re-enrollment.
Devices running the development-signed demo build (the TC22) cannot be updated by this APK, because the signer differs. Android is expected to refuse the install. This is not a fault: do not uninstall the app or reset the device to get around it. Moving that device needs an explicitly authorized, planned one-time migration that includes re-enrollment.
## References
+46
View File
@@ -0,0 +1,46 @@
# Record browsing and photo UX — November 5
The existing scanner application now supports permitted tables, grouped record details, related records and record-linked photo capture through standard semantic APIs. The companion Echo demo supplies 1,000 synthetic orders, 12,000 items and 3,000 history entries.
Source review: [Android PR 15](https://git.kof22.com/QRun-IO/qqq-android/pulls/15), paired with [scanner/server PR 19](https://git.kof22.com/QRun-IO/qbit-mobile-scanners/pulls/19).
## User flow
Open **Test Data → Test Orders**, search or filter, and select a record. Back preserves page/search/filter context. A fixed identity heading sits above Details, Items, History and Photos. Groups collapse and can be selected from Sections. Related items show concise reference/quantity/SKU labels; timestamps include timezone context and server currency/units are preserved.
Use **Actions → Add photo → Take photo / Choose photo**. The selected record remains visible in the chooser. Saved attachments appear under Photos with review status. Available images have thumbnails and previews; quarantined originals remain unavailable. The client follows the server's receipt: a host that admits approved-device captures immediately returns `AVAILABLE`, so the app shows "Saved photo."; hosts that retain quarantine return `QUARANTINED` ("Awaiting server review."). The maintained Echo host now uses Record Files' scoped `quarantineCapturedFiles` setting. The app has no admission policy of its own and no client change was needed. Photos already uploaded stay quarantined until the server releases them; the three earlier demo captures were released through the normal audited admin process. Photo transfer remains online-only, idempotent and protected by parent permissions and device/session authorization.
The signed MC9400 app's gallery upload returned "Saved photo." for a new synthetic PNG; server readback verified `AVAILABLE`, the intended parent, media type, byte count and SHA256. Updated native acceptance on the TC22 also passed synthetic camera/gallery uploads, the newly captured photo's thumbnail and full preview, parent scoping, rotation and forced-logout cleanup. All requests passed semantic API checks; the disposable app was removed afterward. This is not a camera-hardware qualification. The captured-photo policy still needs its public dependency release.
## Verification
- 232 unit tests: core 52, mobileapp 57, host 123; zero failures/errors/skips. Scanner lint: zero errors/warnings. Debug, E2E and instrumentation APK builds passed; eight runner guard tests passed.
- Isolated MC9400 rich-record native acceptance passed paging, groups, related queries/navigation, search/filter/Back preservation, camera/gallery persistence, approved preview, parent-scoped gallery, rotation, pending-photo navigation protection and forced-logout cleanup. All HTTP requests matched the semantic API. Existing process-photo acceptance also passed.
- The final date fallback handles default `%s` metadata and differing raw/display JSON values; its unit/lint/debug-build gate passed. Actual TC22 inspection confirmed localized dates/timezones, currency, Actions/capture chooser, direct tabs and order 26 → Android Back → page two. The full native suite preceded this date-only follow-up.
- Updated the ordinary TC22 app in place with matching signer and installed bytes verified: SHA-256 `F71033C835AC3EB361A04D78C842947C5A04C85CE1AB1B999AA8CEFBFC9D7A95`. No app-data reset, replacement login or separate manual QA app was needed.
Native camera/gallery inputs in automated acceptance were synthetic. This visual pass did not capture an operator photo, print a label or qualify physical GS1 reproduction. The November 4 operator report remains accepted separately.
## Actual TC22 screenshots
All displayed records are synthetic. These are screenshots of the installed app, not mockups.
| Record and actions | Capture chooser |
| --- | --- |
| ![Record Actions menu](images/record-ux-2026-11-05/tc22-ux-actions.png) | ![Take or choose a record photo](images/record-ux-2026-11-05/tc22-ux-add-photo.png) |
| Related items | Related history |
| --- | --- |
| ![Items with quantity, SKU and currency](images/record-ux-2026-11-05/tc22-ux-items.png) | ![History with formatted timestamps](images/record-ux-2026-11-05/tc22-ux-history.png) |
| Saved-photo empty state | Table after Android Back |
| --- | --- |
| ![Photos tab with Add photo guidance](images/record-ux-2026-11-05/tc22-ux-photos.png) | ![Page two retained after returning from a record](images/record-ux-2026-11-05/tc22-ux-back-preserved.png) |
## Implementation boundaries
Record Files intentionally denies aggregate counts; photo pages use bounded queries with one lookahead row. Downloads use the owner's authenticated tracked-delivery endpoint, exact size/hash verification, same-origin credentials, no redirects, bounded bitmap decoding and no image disk cache. Hidden fields stay out of native summaries/search.
Pending-upload Back handling is registered inside the record destination, after NavHost's callback. Configuration disposal does not clear retained upload guards; authentication teardown does. Tests use a disposable application ID, fresh temporary database and isolated accounts, preserving normal device/demo data.
No new business routes, generic screen framework, offline queue, raw record editing, production signing or artifact publication is introduced. Remaining qualification is tracked in [TODO](TODO.md).
+54 -11
View File
@@ -1,6 +1,33 @@
# Scanner APK release readiness
Status (November 4, 2026): **a QRun-owned production signing key exists with a verified backup, and a locally signed, verified 1.0.0 (versionCode 2) APK exists. It was installed and launched once on a fresh MC9400, but it has not been qualified end to end, distributed or published, and no release exists.** The owner selected direct signed-APK / MDM distribution. Current local demo devices (TC22 and, by the user's later choice, the MC9400) run the development-signed debug APK (0.1.0, versionCode 1) against the local HTTP server; that is not a production release. Manual TC22 acceptance was reported all-pass by the operator on November 4 (operator report, no per-case artifacts) for the development-signed build; it does not qualify the production signer and is not requested again for that build.
## Current status (November 5, 2026)
- **Published:** the only published release is still [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), a **signed preview** (Gitea prerelease, not a stable release). It is unchanged.
- **Candidate, not published:** QQQ Mobile **1.0.1 (versionCode 3)** is a signed candidate. Its release qualification is incomplete, and no tag or download link is available yet.
- **Signer:** the candidate is signed with the existing permanent QRun key (certificate SHA-256 `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`). No new or replacement key was created; the existing recovery backup was verified earlier.
- **Candidate APK:** SHA-256 `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F`, 15015419 bytes.
- **Source:** Android PR 15 `ab6e44c`, merged as `c782aaaaba3f62422232436a9000af9d4b38fc82` with the same app-source tree. Installed APK bytes, signature, non-debuggable flag and 16 KiB alignment were verified. `lintRelease` reported 0 errors, 0 warnings and 5 informational findings; the release build passed.
- **Distribution:** maintainers use the existing manual signing path for direct APK/MDM distribution. Automated signing CI is optional.
- **Next candidate:** **1.0.2 (versionCode 4)** is prepared but not built; see [Next candidate](#next-candidate-102-versioncode-4).
- **Open:** see [Qualification status](#qualification-status) and [TODO](TODO.md).
## Next candidate: 1.0.2 (versionCode 4)
Prepared on 2026-11-06 on branch `release/scanner-1.0.2` (Android PRs 17 and 18 merged locally plus these notes). **Nothing is built, signed, tagged, installed or published for 1.0.2.** The signed 1.0.1 candidate stays unpublished and is superseded; 1.0.2 updates it in place with the same signer and a higher `versionCode`.
Changes since 1.0.1 (details in [CHANGELOG](../CHANGELOG.md#102-versioncode-4-candidate)): company sign-out now completes (the device-session end ran before the provider logout, so no `nativeLogoutData` came back and the browser never opened on the MC9400); the user name is shown after browser sign-in; app/OS version are reported on every heartbeat (effective with mobile-scanners server 1.6.0).
Build only after PRs 17 and 18 are merged to `main` and this branch has been rebased onto that `main`, from the exact merged commit, with no `SCANNER_*` signing variables set:
```
./gradlew --no-daemon --console=plain :scanner-host:lintRelease :scanner-host:assembleRelease -PscannerVersionName=1.0.2 -PscannerVersionCode=4
```
Then sign `scanner-host/build/outputs/apk/release/scanner-host-release-unsigned.apk` in isolation with `apksigner` using the existing permanent key (certificate SHA-256 `BDDB2D2F...5B17A9FF`), verify v2+v3 with exactly one signer, 16 KiB alignment, `versionCode='4' versionName='1.0.2'` and non-debuggable, and write `QQQ-Mobile-1.0.2.apk`, `SHA256SUMS`, `signature-verification.txt`, `apk-metadata.txt` and `release-receipt.json` next to each other as for 1.0.1. Record the resulting hashes here, in [INSTALLATION](INSTALLATION.md) and in [SESSION-STATE](SESSION-STATE.md) before any tag or release.
## Historical status (November 4, 2026, superseded where noted)
Dated evidence below is preserved. As of November 4: a QRun-owned production signing key existed with a verified backup, and the signed 1.0.0 (versionCode 2) APK was published as a signed preview: [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), release ID 1361, tag on protected `main` `3eef6431ca39b6450cf764424d921a02b2afb9ab`. It had been installed and launched once on a fresh MC9400; at that time enrollment, scanning and update were unqualified. *(Superseded November 5: the MC9400 was migrated to the signed build; see below. The statement that the MC9400 ran the development-signed debug APK is no longer current. The TC22 still runs its original development-signed installation.)* Manual TC22 acceptance was reported all-pass by the operator on November 4 (operator report, no per-case artifacts) for the development-signed build; it does not qualify the production signer and is not rerun.
## Decisions (made)
@@ -14,7 +41,7 @@ Status (November 4, 2026): **a QRun-owned production signing key exists with a v
## What exists today
- **Gitea CI (actual).** `.gitea/workflows/qrun-android-release.yaml` is in the repository, runs on push to `main` (and manual dispatch) and calls the central `KofTwentyTwo/platform-ci` Android workflow at `qrun-release-v1`. A central main run (3634) passed and published the SDK AARs. It is **not** a scanner-APK signing or release pipeline: it uses no scanner signing secrets and produces no production-signed scanner APK.
- **Gitea CI (actual).** `.gitea/workflows/build.yaml` calls the shared `KofTwentyTwo/ci-workflows` Android workflow (`android.yaml@v1`) on every push: source checks, JVM unit tests, scanner lint, debug and QA APK assembly, and connected tests on an emulator (android-build runners). A `vX.Y.Z` tag also publishes the SDK AARs (`qqq-android-core`, `qqq-android-mobileapp`) to the QRun-IO Gitea Maven registry. It is **not** a scanner-APK signing or release pipeline: it uses no scanner signing secrets and produces no production-signed scanner APK.
- **Gradle signing (supported, not selected).** `scanner-host/build.gradle.kts` reads four environment variables: `SCANNER_KEYSTORE`, `SCANNER_STORE_PASSWORD`, `SCANNER_KEY_ALIAS`, `SCANNER_KEY_PASSWORD`.
- All four set: the `release` build type is signed with them.
- None set: `assembleRelease` produces an **unsigned** APK, which is what the isolated signing step consumes.
@@ -22,22 +49,38 @@ Status (November 4, 2026): **a QRun-owned production signing key exists with a v
- Debug, `e2e` and `qa` builds keep the development debug signing.
- Version comes from `-PscannerVersionName` (semantic version, default `0.1.0`) and `-PscannerVersionCode` (1 to 2100000000, default `1`).
- **Identity.** One APK serves all customers; branding is applied at runtime from the selected server's metadata, so no per-customer key or build is needed.
- **Legacy GitHub workflows (historical only).** `.github/workflows/release-scanner.yml` and `scanner.yml` predate Gitea as primary forge. They are not a release path; do not run or follow them. The [November 1 review](RELEASE-AUTOMATION-REVIEW.md) covered those files only.
- **Legacy GitHub workflows (removed).** `.github/workflows/release-scanner.yml` and `scanner.yml` predated Gitea as primary forge and were removed with the CircleCI configuration. The [November 1 review](RELEASE-AUTOMATION-REVIEW.md) covered those files only.
## First release: 1.0.0 (versionCode 2)
Version `1.0.0`, `versionCode` 2 was built unsigned (`lintRelease` and `assembleRelease`: 0 errors, 0 warnings, 2 information) from source matching protected `main` `0ae2101f082c241ca6a70462dc9ec3057aa6c906`; only documentation differs. It was then signed in isolation with `apksigner` on 2026-11-04 (16:22:05 UTC).
**Signed artifact (local, complete):** `QQQ-Mobile-1.0.0.apk`, application ID `io.qrun.mobile.scanner`, SHA-256 `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51`. Verification passed: signature schemes v2 and v3, exactly one signer whose certificate SHA-256 is `BDDB2D2F...5B17A9FF` (the fingerprint above), 16 KiB alignment, non-debuggable. The checksum shows integrity only, not signer correctness. The APK is not published or distributed and is kept outside this repository.
**Signed artifact (local, complete):** `QQQ-Mobile-1.0.0.apk`, application ID `io.qrun.mobile.scanner`, SHA-256 `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51`. Verification passed: signature schemes v2 and v3, exactly one signer whose certificate SHA-256 is `BDDB2D2F...5B17A9FF` (the fingerprint above), 16 KiB alignment, non-debuggable. The checksum shows integrity only, not signer correctness.
**Published preview (2026-11-04):** release [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (Gitea ID 1361, prerelease, signed Git tag at `3eef6431`; the tag adds documentation only over app source `0ae2101f`) hosts exactly two assets: `QQQ-Mobile-1.0.0.apk` (14933499 bytes) and `SHA256SUMS` (88 bytes). Both were downloaded back and matched the same SHA-256. The forge requires sign-in to download. The local installation kit ZIP was **not** published and no bundle is hosted. Link the release page, not an individual asset URL. The APK is kept outside this repository.
**Device evidence (MC9400, November 4):** the signed APK was installed on a fresh MC9400 (Android 13, EMDK present), launched without a startup crash, showed the Connect screen, and its installed bytes read back with the exact hash above. It was not enrolled: release builds require HTTPS and the only server available was the local HTTP demo. The same key and signer are unchanged. The user then chose to run the MC9400 in demo mode like the TC22, so that fresh, unenrolled production install was replaced with the development debug APK and port forwarding to the local server. No signed-build enrollment, scan or navigation was tested. The TC22 and its data were untouched throughout.
Remaining steps:
*(The November 4 MC9400 paragraph above is historical: the fresh install was later replaced by the migration recorded below.)*
1. Qualify the **actual production-signed APK** on a device: install, enrollment, scanner path ([Zebra scanning](ZEBRA-SCANNING.md)), Back/Apps navigation, and update over a prior production build. Use a planned device migration or a spare device; do not use a demo device implicitly, and note that moving a demo device to the signed build removes its development install and enrollment.
2. Provide an **HTTPS** server. Source release builds disallow cleartext; HTTP is limited to debug builds. The local demo does not need HTTPS.
3. Use the first direct installation to confirm the device flow, then distribute the same APK to the remaining devices. MDM can be introduced later.
4. Automated scanner-APK signing/release workflow is not configured and is separate from manual distribution; create no tags or releases without current authorization.
## Qualification status
Done (not to be repeated as unchecked work):
- **MC9400 migration:** the user explicitly authorized it. Release 1.0.0 enrollment over trusted HTTPS passed, then an in-place 1.0.1 update preserved login and enrollment. Apps/ScanCapture navigation and EMDK Scanner-ready state were observed.
- **Physical scan:** the operator confirmed a physical Saved scan. The backend verified CODE128, AIM identifier, raw bytes, user and device. The actual barcode differed from the requested literal.
- **TC22:** the original development-signed installation and its data are retained. The accepted November 4 manual all-pass is not rerun.
- **Server discovery:** against the real shared Authentik-backed server, UI API 1.10 discovery with strict nonce passed for both scanner and printing channels, extensions and callbacks.
- **Photo policy (live):** a signed MC9400 gallery upload returned Saved photo with `AVAILABLE` status and exact parent, type, size and hash; the previous three photos were released normally. Updated native acceptance on the TC22 passed synthetic camera/gallery uploads, the captured photo's thumbnail and full preview, parent scoping, rotation and forced-logout cleanup. All requests passed the semantic API checks. The disposable app was removed and the normal app reopened. This does not establish camera-hardware or native-Authentik acceptance.
- **Local network:** the local QQQ server remains on a PC and was reached over trusted HTTPS temporarily through a user-approved private Tailscale network. This is local qualification, not a customer production deployment. Customer instructions still require a customer HTTPS URL with a trusted certificate. TLS must not be weakened, and workstation hostnames, IPs, ports, device IDs, vault IDs, private file paths and passwords are not published.
Open:
1. **Native Authentik** login, logout and account switching with the actual production APK remain open, awaiting the MC wireless-debug endpoint. No native-Authentik PASS is claimed.
2. **Printing:** the print nonce fix (PR 11, merged `abc3d0d`, 532 hosted tests passed) postdates the published print 1.1.1; the next installer remains with the active printing owner.
3. Public compatible Core/Collab/document/files/scripting/bridges, the final public cohort gate, GS1 physical print/rescan, and full release-asset publication and distribution.
4. Company profiles and wallpaper are tracked separately.
5. Build, sign and publish 1.0.2 (versionCode 4) in place of the unpublished 1.0.1 candidate after PRs 17 and 18 merge (see [Next candidate](#next-candidate-102-versioncode-4)), with its checksum and installation instructions after remaining qualification. The published download remains the 1.0.0 signed preview; automated signing CI is optional.
## Release requirements
@@ -49,8 +92,8 @@ Remaining steps:
Android updates in place only when the signer matches and `versionCode` does not decrease. Losing this key, or its backup, means installed copies can no longer be updated in place; they would have to be uninstalled and reinstalled, which removes enrollment and session state. Keep the vault backup recoverable and re-verify it (hash, open, certificate comparison) after any change to it.
Development-signed demo installations (TC22, and the MC9400 in demo mode) cannot be upgraded by the production key. Moving one requires an explicit migration: preserve its test evidence, plan re-enrollment, and never uninstall or reset a device or application implicitly. No update or key-rotation policy beyond keeping this one permanent signer is defined here.
Development-signed demo installations (the TC22) cannot be upgraded by the production key. Moving one requires an explicit migration: preserve its test evidence, plan re-enrollment, and never uninstall or reset a device or application implicitly. The MC9400 was migrated under explicit user authorization (see above). No update or key-rotation policy beyond keeping this one permanent signer is defined here.
## Not yet established
Production-signed-APK device qualification, first distribution, an HTTPS production server, an automated scanner release workflow and its secret handling, and key-rotation planning. SDK publication by the central workflow is done and its completed audits are not reopened here. Other pending items are in [TODO](TODO.md).
Publication of the 1.0.1 candidate, native Authentik qualification with the production APK, a customer production HTTPS deployment, stable-release promotion, an automated scanner release workflow (optional) and its secret handling, and key-rotation planning. SDK publication by the central workflow is done and its completed audits are not reopened here. Other pending items are in [TODO](TODO.md).
+38 -24
View File
@@ -1,36 +1,50 @@
# Current status
As of 2026-11-04. Scope: the scanner host and the local Echo workflow only. This is the authoritative current view; earlier rolling notes are historical and kept in [docs/history](history/README.md). Nothing here claims a finished or released 1.0. Checklist: [TODO](TODO.md). Ledger: [FINISH-1.0](FINISH-1.0.md).
As of **2026-11-06**. Scope: scanner host and maintained local Echo workflow. [TODO](TODO.md) tracks remaining work; [history](history/README.md) preserves earlier evidence. This is not a final-release or full-qualification claim. See [Release facts](#release-facts-2026-11-05) below.
## Source
## Release candidate 1.0.2 (versionCode 4), 2026-11-06
- 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.
- Branch `release/scanner-1.0.2` carries Android PR 17 (company sign-out ordering, OAuth user name) and PR 18 (heartbeat app/OS version) plus the [1.0.2 changelog](../CHANGELOG.md#102-versioncode-4-candidate) and build instructions in [RELEASING](RELEASING.md#next-candidate-102-versioncode-4). It depends on both PRs merging first and must be rebased onto the resulting `main`. **Nothing is built, signed, tagged, installed or published for 1.0.2**; the signed 1.0.1 candidate stays unpublished.
## Local demo: accepted
## Heartbeat version fix (2026-11-06, Android PR 18)
- 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).
- After the MC9400 in-place 1.0.0 to 1.0.1 update, scan receipts still reported `appVersion` 1.0.0: the host only sent `osVersion`/`appVersion` at enrollment and the server never refreshed them. `MobileSessionApi` now sends both on every heartbeat (`MobileSessionTest.networkPayloadUsesSemanticRoutesAndCarriesOnlyScreenMetadata` covers the payload); the matching server change is mobile-scanners 1.6.0 on the same-named server branch. Older servers ignore the keys; the installed 1.0.1 APK does not include this change (planned for 1.0.2).
## MC9400 (second device)
## Release facts (2026-11-05)
- 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.
- **Published:** still `scanner-v1.0.0`, a signed preview. Signed candidate **1.0.1 (versionCode 3)** is **not published**, has no tag and no download link, and is not production-complete. Signer: existing permanent QRun key (certificate SHA-256 `BDDB2D2F...5B17A9FF`); no new key. APK SHA-256 `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F`, 15015419 bytes. Details: [RELEASING](RELEASING.md).
- **Source:** Android PR 15 `ab6e44c`, merged `c782aaaaba3f62422232436a9000af9d4b38fc82`, same app-source tree. Root verified installed APK bytes/signature/non-debuggable/16 KiB alignment; `lintRelease` 0 errors, 0 warnings, 5 informational; release build passed.
- **Done:** MC9400 migration (user-authorized): 1.0.0 enrollment over trusted HTTPS, then in-place 1.0.1 update preserving login/enrollment; Apps/ScanCapture and EMDK Scanner-ready observed; operator-confirmed physical Saved scan verified in the backend (CODE128/AIM/raw bytes/user/device; actual barcode differed from the requested literal). Not repeat work. TC22 keeps its original development-signed installation and data; the accepted November 4 manual all-pass is not rerun.
- **Network:** the local QQQ server stays on a PC, reached over trusted HTTPS temporarily through a user-approved private Tailscale network. This is not a customer production deployment; customer instructions require a customer HTTPS URL with a trusted certificate. TLS is not weakened; no hostnames, IPs, ports, device/vault IDs, private paths or passwords are published.
- **Server/Authentik:** real shared Authentik server discovery verified (UI API 1.10, strict nonce, scanner and printing channels/extensions/callbacks). Production-APK native login/logout/account switching is **open**, awaiting the MC wireless-debug endpoint; no native-Authentik PASS is claimed. Print nonce fix PR 11 (merged `abc3d0d`, 532 hosted tests passed) postdates published print 1.1.1; the next installer remains with the active printing owner.
- **Photo policy:** live. A signed MC9400 gallery synthetic PNG became a Saved photo, `AVAILABLE`, with exact parent/type/size/hash; the previous three photos were released normally. The app has no admission policy; the server receipt controls the message. The root is running the updated disposable TC22 E2E; no full new-preview or native PASS is claimed until it completes.
- **Company sign-out bug (2026-11-05):** on the physical MC9400 with the production APK against the real Authentik fixture, **Sign out of company** never opened the browser and reported "Company sign-out did not complete". Cause: `QViewModel.performLogout` ran the host `beforeLogout` hook (scanner device-session end, which runs the core logout server-side) before the provider logout request, so `nativeLogoutData` was never returned. A fix reordering provider logout before the host hook, plus adoption of the server-reported user name after browser login (Account showed a blank "Current User"), is Android PR 17 (`cf82dd5`) with unit tests; it is **not released** and no new APK is built (planned for 1.0.2, versionCode 4). See [AUTHENTICATION](AUTHENTICATION.md#optional-company-sign-out-implementation-awaiting-qualification).
- **Open:** public compatible Core/Collab/document/files/scripting/bridges, final public cohort gate, GS1 physical print/rescan, full release-asset publication/distribution. Company profiles/wallpaper are tracked separately; signing CI is optional and not blocking a direct APK release. Release authorizations already exist.
## Signed release
Statements below were written before these facts and are historical where they conflict (for example "debug update", MC9400 trigger check "outstanding", signed-build HTTPS qualification "open").
- 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 **not** published, tagged or distributed, and no production release exists. No automatic signing CI is configured. The signed artifact is kept outside the repository.
- Production qualification needs an HTTPS server and a full device pass of the signed build ([RELEASING](RELEASING.md), [INSTALLATION](INSTALLATION.md)). The local demo does not need HTTPS; release builds reject HTTP.
## Record browsing and photos
## Open (not claimed)
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.
- 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)).
**Record → Actions → Add photo** opens Take photo / Choose photo. Saved photos show parent-scoped lifecycle status and approved thumbnails/previews. Quarantined originals remain unavailable. Availability is decided only by the server receipt/lifecycle; focused tests cover both `AVAILABLE` and `QUARANTINED`, and the native E2E expects `AVAILABLE` for the configured Echo fixture while retaining quarantine assertions for the generic fixture. Thirteen focused photo/API tests passed. A fresh synthetic gallery upload through the signed MC9400 app returned "Saved photo."; server readback verified availability, parent, type, byte count and SHA256. The three earlier demo captures were released through the normal audited admin process. Updated TC22 native photo acceptance passed synthetic camera/gallery upload, the captured photo thumbnail/full preview, parent scoping, rotation and forced-logout cleanup, with all requests checked against the semantic API. The disposable test packages were removed and the normal app reopened. Public captured-photo dependency publication and native Authentik qualification remain open. Existing upload identity, retry handling, server authorization, pending-navigation guards and logout cleanup are preserved.
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).
## Verification and installation
- **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.
## Source and runtime
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.
The QRun-owned signing key and [signed preview `scanner-v1.0.0`](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) remain unchanged. That preview predates the record UX update; candidate 1.0.1 is unpublished. The preview is not a stable release or an automatic signing/publication pipeline; see [RELEASING](RELEASING.md).
## Accepted and outstanding hardware work
The November 4 TC22 operator group pass remains accepted; do not request it again. MC9400's physical scan/backend check and signed-build trusted-HTTPS enrollment/update are done (see Release facts). Physical GS1 print/rescan, native provider callback, public owner-dependency adoption and fleet/MDM rollout remain open. Historical hashes, signed-preview provenance and older tests are preserved in [the prior checkpoint](history/SESSION-STATE-before-record-ux-2026-11-05.md).
+16 -26
View File
@@ -1,35 +1,25 @@
# Checklist
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.
- [ ] Verify actual production-APK native Authentik login, logout and account switching; awaiting the MC wireless-debug endpoint. Real-server discovery (UI API 1.10, strict nonce, scanner and printing channels) is already verified; no native-Authentik PASS is claimed.
- [ ] After Android PRs 17 and 18 merge, rebase `release/scanner-1.0.2`, then build, sign (same permanent signer) and publish 1.0.2 (versionCode 4) with checksum and installation instructions after remaining qualification; see [RELEASING](RELEASING.md#next-candidate-102-versioncode-4). The signed 1.0.1 (versionCode 3) candidate is superseded and stays unpublished. The published release is still the 1.0.0 signed preview.
- [ ] Publish and distribute full release assets to remaining devices; validate MDM/company profile and wallpaper separately. A customer deployment needs a customer HTTPS URL with a trusted certificate.
- [ ] Printing: the next installer remains with the active printing owner (print nonce fix PR 11 postdates published print 1.1.1).
- [ ] Optional, non-blocking: automatic APK signing CI. Direct APK release does not depend on it.
## MC9400 remaining check
Done (November 5, do not repeat): MC9400 migration with release 1.0.0 trusted-HTTPS enrollment then in-place 1.0.1 update preserving login/enrollment; Apps/ScanCapture navigation and EMDK Scanner-ready observed; operator-confirmed physical Saved scan verified in the backend (CODE128, AIM, raw bytes, user, device; actual barcode differed from the requested literal). Live photo policy verified with a signed MC9400 synthetic gallery photo (`AVAILABLE`, exact parent/type/size/hash).
- [x] Production-signed APK installed and launched on a fresh MC9400; hash read back.
- [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).
Updated TC22 native photo acceptance passed: synthetic camera/gallery uploads, the newly captured photo's thumbnail/full preview, parent scoping, rotation and forced-logout cleanup, with semantic API checks. The disposable test packages were removed and the normal app reopened. Native Authentik remains a separate open check.
## Release qualification
## Dependencies and broader coverage
- [x] Signed-APK/MDM channel chosen; QRun-owned key created with verified backup; signed 1.0.0 (versionCode 2) built and verified locally.
- [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).
- [ ] First distribution to the remaining devices; MDM later. No tag or release exists.
- [ ] 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).
- [ ] Adopt public compatible Core/Collab/document/files/scripting/bridges inputs through a reviewed companion-server change, and pass the final public cohort 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.
## 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.
+1 -1
View File
@@ -63,7 +63,7 @@ The scanner event preserves the barcode string, event UUID and capture timestamp
## Session activity and screen viewing
The approved, authenticated host sends foreground activity through the registered extension. Heartbeats contain app/process/step names and availability, never form values, barcodes, passwords or credentials. The server derives identity from the verified QQQ session and device. Ending a session in QQQ causes the host to clear its local login when the next protected operation or heartbeat returns an authentication failure. Signing out or changing servers stops capture immediately and attempts to end the current server session before standard logout.
The approved, authenticated host sends foreground activity through the registered extension. Heartbeats contain app/process/step names and availability, never form values, barcodes, passwords or credentials. Each heartbeat also carries the installed `osVersion` and `appVersion` (the values enrollment reported); a mobile-scanners 1.6 server refreshes the device record when they change so scan receipts after an in-place update show the new version, and older servers ignore the extra optional keys. The server derives identity from the verified QQQ session and device. Ending a session in QQQ causes the host to clear its local login when the next protected operation or heartbeat returns an authentication failure. Signing out or changing servers stops capture immediately and attempts to end the current server session before standard logout.
An administrator viewing the ordinary QQQ session record renews a short screen-viewing lease through its standard widget. The host uses PixelCopy on its own app-content rectangle only, at most once per second, bounded to 640×1280 pixels and 256 KiB JPEG. No OS screen, other application or remote control is exposed. Capture requires the resumed, focused, approved authenticated screen, completed rendering and a live lease. Password screens, loading transitions, backgrounding and logout block capture and clear the server's displayed frame. Screen/session state is checked after capture and immediately before upload. Lease timing uses server-relative duration and Android's monotonic clock, not the handheld wall clock. Frames are not saved locally.
+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

+4 -4
View File
@@ -30,10 +30,10 @@ plugins {
publishing {
repositories {
maven {
name = "githubQqqMavenRegistry"
url = uri("https://maven.pkg.github.com/QRun-IO/qqq-maven-registry")
// username and password (a personal Github access token) should be specified as
// `githubPackagesUsername` and `githubPackagesPassword` Gradle properties
// Gitea Maven registry of the repository owner. CI sets giteaMavenUrl, giteaUsername
// and giteaPassword (ci-workflows android.yaml, publish-tasks on vX.Y.Z tags).
name = "gitea"
url = uri(providers.gradleProperty("giteaMavenUrl").getOrElse("https://git.kof22.com/api/packages/QRun-IO/maven"))
credentials(PasswordCredentials::class)
}
}
@@ -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)
@@ -31,4 +31,9 @@ data class ManageSessionResponse(
val uuid: String? = null,
val values: MapOfStringAny = emptyMap(),
) {
/*******************************************************************************
** Display name from the optional values.user object; absent, non-object or blank names yield null.
*******************************************************************************/
val userName: String?
get() = ((values["user"] as? Map<*, *>)?.get("name") as? String)?.takeIf { it.isNotBlank() }
}
@@ -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))
}
}
+7 -4
View File
@@ -30,10 +30,10 @@ plugins {
publishing {
repositories {
maven {
name = "githubQqqMavenRegistry"
url = uri("https://maven.pkg.github.com/QRun-IO/qqq-maven-registry")
// username and password (a personal Github access token) should be specified as
// `githubPackagesUsername` and `githubPackagesPassword` Gradle properties
// Gitea Maven registry of the repository owner. CI sets giteaMavenUrl, giteaUsername
// and giteaPassword (ci-workflows android.yaml, publish-tasks on vX.Y.Z tags).
name = "gitea"
url = uri(providers.gradleProperty("giteaMavenUrl").getOrElse("https://git.kof22.com/api/packages/QRun-IO/maven"))
credentials(PasswordCredentials::class)
}
}
@@ -72,6 +72,9 @@ android {
kotlinOptions {
jvmTarget = "1.8"
}
testOptions {
unitTests.isReturnDefaultValues = true
}
}
dependencies {
@@ -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 })
}
}
@@ -582,8 +582,23 @@ open class QViewModel(
*******************************************************************************/
fun logOut(): Job = performLogout()
/*******************************************************************************
** Host session resources end in a bounded, best-effort step; core logout and local clearing never depend on it.
*******************************************************************************/
private suspend fun endHostSession() {
try {
kotlinx.coroutines.withTimeoutOrNull(3000) { beforeLogout() }
} catch (exception: kotlinx.coroutines.CancellationException) {
throw exception
} catch (exception: Exception) { /* Core logout and local clearing still run. */
}
}
/*******************************************************************************
** Optional provider material is captured only by the initiating coroutine, never by ViewModel state.
** Ordinary logout ends host resources first. Provider logout runs first instead: it both returns the
** transient hint and ends the QQQ session, and a host session end (such as the scanner device-session end,
** which itself runs the core logout server-side) would otherwise remove that session before the hint is read.
*******************************************************************************/
private fun performLogout(providerLogout: (suspend () -> Unit)? = null): Job {
logoutJob?.takeIf {
@@ -606,16 +621,15 @@ open class QViewModel(
var serverConfirmed = false
try {
settingsMutex.withLock { settingsRepository.setSessionUUID(""); settingsRepository.setSessionUserFullName("") }
if (providerLogout == null) endHostSession()
try {
kotlinx.coroutines.withTimeoutOrNull(3000) { beforeLogout() }
} catch (exception: kotlinx.coroutines.CancellationException) {
throw exception
} catch (exception: Exception) { /* Core logout and local clearing still run. */
serverConfirmed = kotlinx.coroutines.withTimeoutOrNull(3000) {
if (providerLogout == null) qqqRepository.logout() else providerLogout()
true
} ?: false
} finally {
if (providerLogout != null) endHostSession()
}
serverConfirmed = kotlinx.coroutines.withTimeoutOrNull(3000) {
if (providerLogout == null) qqqRepository.logout() else providerLogout()
true
} ?: false
} catch (exception: kotlinx.coroutines.CancellationException) {
throw exception
} catch (exception: Exception) { /* Local sign-out still completes when the network is unavailable. */
@@ -672,7 +686,8 @@ open class QViewModel(
binding.login.server == environment.baseUrl && isLogoutRequested
/*******************************************************************************
** Local invalidation finishes before the browser sees the optional transient hint.
** Local invalidation finishes before the browser sees the optional transient hint: the provider logout
** request runs inside performLogout, whose completion clears local state before this returns.
*******************************************************************************/
suspend fun logOutWithProvider(generation: Long, binding: OAuth2LogoutBinding): NativeLogoutData? {
if (!isCurrentCompanyLogout(generation, binding) || companyLogoutPhase != OAuth2LogoutPhase.PREPARING) return null
@@ -846,6 +861,7 @@ open class QViewModel(
if (!isCurrentOAuth2Login(generation, binding)) return@launch
sessionUUID = requireNotNull(newSession)
qqqRepository.setSessionUUID(sessionUUID)
response.userName?.let { doSetSessionUserFullName(it) }
manageSessionLoadState = LoadState.Success(response)
logInSuccessful()
adopted = true
@@ -0,0 +1,224 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.mobileapp.viewmodel
import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository
import com.kingsrook.qqq.frontend.android.core.model.Environment
import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance
import com.kingsrook.qqq.frontend.android.core.model.metadata.QProcessMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionRequest
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionResponse
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.NativeLogoutData
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData
import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult
import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer
import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LoginBinding
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
private const val SERVER = "https://example.invalid"
private const val ISSUER = "https://idp.example.invalid"
private const val REDIRECT = "io.qrun.test.oauth://callback/oauth2redirect"
private const val STATE = "state-0123456789abcdef"
private const val NONCE = "nonce-0123456789abcdef"
private const val VERIFIER = "verifier-0123456789abcdefghijklmnopqrstuvwxyz0123456789"
/*******************************************************************************
** Ordering of host session end, provider logout and local clearing; identity adoption after browser login.
*******************************************************************************/
@OptIn(ExperimentalCoroutinesApi::class)
class QViewModelLogoutTest {
/*******************************************************************************
** (a) Provider logout must read the hint before the host ends its device session (which ends the QQQ session).
*******************************************************************************/
@Test
fun companyLogoutRequestsProviderMaterialBeforeHostSessionEnd() = runTest()
{
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
val repository = LogoutRepository()
val model = signedInModel(repository)
model.beforeLogout = { repository.calls += "beforeLogout" }
val material = companyLogout(model)
assertNotNull(material)
assertEquals(listOf("logoutWithProvider", "beforeLogout"), repository.calls)
assertEquals("Signed out.", model.logoutMessage)
assertFalse(model.isAuthenticated)
assertEquals("", model.sessionUUID)
assertEquals("", model.sessionUserFullName)
} finally {
Dispatchers.resetMain()
}
}
/*******************************************************************************
** (b) A failing host session end after the provider logout neither loses the hint nor leaves local state.
*******************************************************************************/
@Test
fun failingHostSessionEndAfterProviderLogoutKeepsMaterialAndClearsLocally() = runTest()
{
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
val repository = LogoutRepository()
val model = signedInModel(repository)
model.beforeLogout = { repository.calls += "beforeLogout"; throw IllegalStateException("401 Unauthorized") }
val material = companyLogout(model)
assertNotNull(material)
assertEquals(listOf("logoutWithProvider", "beforeLogout"), repository.calls)
assertEquals("Signed out.", model.logoutMessage)
assertFalse(model.isAuthenticated)
assertFalse(model.logoutInProgress)
assertEquals("", model.sessionUUID)
assertEquals("", repository.activeSession)
} finally {
Dispatchers.resetMain()
}
}
/*******************************************************************************
** (c) Ordinary sign-out keeps ending host resources before the core logout.
*******************************************************************************/
@Test
fun ordinaryLogoutEndsHostSessionBeforeCoreLogout() = runTest()
{
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
val repository = LogoutRepository()
val model = signedInModel(repository)
model.beforeLogout = { repository.calls += "beforeLogout" }
model.requestLogout()
model.logOut().join()
assertEquals(listOf("beforeLogout", "logout"), repository.calls)
assertEquals("Signed out.", model.logoutMessage)
assertFalse(model.isAuthenticated)
assertEquals("", model.sessionUUID)
} finally {
Dispatchers.resetMain()
}
}
/*******************************************************************************
** Browser login adopts the server-reported display name, as password login already does.
*******************************************************************************/
@Test
fun browserLoginAdoptsServerReportedUserName() = runTest()
{
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
val repository = LogoutRepository()
val model = signedInModel(repository)
assertTrue(model.isAuthenticated)
assertEquals("scanner-b", model.sessionUserFullName)
} finally {
Dispatchers.resetMain()
}
}
/*******************************************************************************
** The user object is optional and unknown keys stay tolerated; blank or non-object users yield no name.
*******************************************************************************/
@Test
fun manageSessionResponseExposesOptionalUserName() {
val json = Json { ignoreUnknownKeys = true }
val full = json.decodeFromString<ManageSessionResponse>(
"""{"uuid":"s","values":{"user":{"name":"scanner-b","email":"scanner-b@example.invalid","extra":1},"other":true},"unknown":"x"}"""
)
assertEquals("scanner-b", full.userName)
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s"}""").userName)
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s","values":{"user":"scanner-b"}}""").userName)
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s","values":{"user":{"name":" "}}}""").userName)
}
/*******************************************************************************
** Complete a native browser login against the fake repository so sign-out tests start authenticated.
*******************************************************************************/
private fun TestScope.signedInModel(repository: LogoutRepository): QViewModel {
QAppContainer.availableEnvironments = listOf(Environment("Test", SERVER))
val model = QViewModel(repository, MockPreferencesDataStore())
advanceUntilIdle()
val generation = requireNotNull(model.prepareOAuth2Login(loginBinding))
assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER))
model.completeOAuth2Login(loginBinding, STATE, STATE, NONCE, "client", REDIRECT, "login", "code")
advanceUntilIdle()
assertTrue(model.isAuthenticated)
assertEquals("session-1", model.sessionUUID)
repository.calls.clear()
return model
}
/*******************************************************************************
** Run the company sign-out preparation exactly as QOAuth2Logout does before opening the browser.
*******************************************************************************/
private suspend fun companyLogout(model: QViewModel): NativeLogoutData? {
val binding = OAuth2LogoutBinding(loginBinding, REDIRECT)
model.requestCompanyLogout(binding)
assertEquals(OAuth2LogoutPhase.PREPARING, model.companyLogoutPhase)
return model.logOutWithProvider(model.companyLogoutGeneration, binding)
}
private val loginBinding = OAuth2LoginBinding(
SERVER, "oauth", ISSUER, "$ISSUER/.well-known/openid-configuration", "client", "openid", REDIRECT, "login", "REQUIRED"
)
/*******************************************************************************
** Records the order of logout-related calls; any call after the session ended returns no provider material.
*******************************************************************************/
private class LogoutRepository : QQQRepository {
val calls = mutableListOf<String>()
var activeSession = ""
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = OAuth2AuthenticationMetaData(
"oauth", values = OAuth2AuthenticationMetaData.Values(
clientId = "client", scopes = "openid",
nativeSessionLogin = OAuth2AuthenticationMetaData.NativeSessionLogin(
OAuth2AuthenticationMetaData.SESSION_FLOW, "S256", ISSUER, "$ISSUER/.well-known/openid-configuration", listOf(REDIRECT), "login", "REQUIRED"
),
nativeSessionLogout = OAuth2AuthenticationMetaData.NativeSessionLogout(OAuth2AuthenticationMetaData.LOGOUT_FLOW, listOf(REDIRECT))
)
)
override suspend fun exchangeNativeSession(body: ManageSessionRequest): ManageSessionResponse =
ManageSessionResponse("session-1", mapOf("user" to mapOf("name" to "scanner-b", "email" to "scanner-b@example.invalid")))
override suspend fun logout() {
calls += "logout"
activeSession = ""
}
override suspend fun logoutWithProvider(postLogoutRedirectUri: String): NativeLogoutData? {
calls += "logoutWithProvider"
val material = if (activeSession.isBlank()) null else NativeLogoutData(ISSUER, "$ISSUER/end-session", postLogoutRedirectUri, "hint")
activeSession = ""
return material
}
override fun setSessionUUID(sessionUUID: String) {
activeSession = sessionUUID
}
override fun setEnvironment(environment: Environment) {}
override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse = error("Not used")
override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance = QInstance()
override suspend fun getProcessMetaData(processName: String): QProcessMetaData = error("Not used")
override suspend fun processInit(processName: String, values: Map<String, Any?>, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map<String, Any?>, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult = error("Not used")
override fun getURI(path: String): String = error("Not used")
}
}
@@ -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")
@@ -322,6 +401,14 @@ class ScannerEndToEndTest {
val config = JSONObject(configFile.readText())
configFile.delete()
origin = config.getString("serverUrl").trimEnd('/')
// Explicit fixture expectations, not production host classification: the rich-records scenario (rich=true) runs
// against the configured EchoDemo host, which admits an approved device's captures immediately
// (Record Files quarantineCapturedFiles=false) and so expects AVAILABLE. The generic document/process photo
// fixtures (rich=false) expect QUARANTINED.
val echoHost = rich
val savedMessage = if (echoHost) "Saved photo." else "Saved photo. Awaiting server review."
val expectedLifecycle = if (echoHost) "AVAILABLE" else "QUARANTINED"
val expectedBadge = if (echoHost) "Available" else "Awaiting review"
val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
val bytes = ByteArrayOutputStream().use { output -> bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output); output.toByteArray() }
bitmap.recycle()
@@ -364,34 +451,139 @@ 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.")
waitFor(savedMessage)
if (echoHost) ui.onNodeWithText("Saved photo. Awaiting server review.").assertDoesNotExist()
assertEquals("Camera result must automatically upload without a second tap", 1, cameraCalls.get())
assertTrue(directory.listFiles().orEmpty().none { it.name.startsWith("photo-") })
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"))
assertEquals(expectedLifecycle, savedFile.getString("lifecycle"))
assertEquals(bytes.size.toLong(), savedFile.getLong("sizeBytes"))
assertTrue(savedFile.getLong("attachmentId") > 0)
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(savedMessage)
val galleryRows = jsonRequest("/qqq/v1/table/record_files_file/query", JSONObject()).getJSONArray("records")
val gallerySaved = (0 until galleryRows.length()).map { galleryRows.getJSONObject(it).getJSONObject("values") }
.single { it.optString("deviceId") == device.deviceId && it.optString("originDetail").endsWith("source GALLERY") }
assertEquals(parentTable, gallerySaved.getString("tableName"))
assertEquals(parentId, gallerySaved.getString("recordId"))
assertEquals(RecordFilesApi.sha256(bytes), gallerySaved.getString("sha256"))
assertEquals(expectedLifecycle, gallerySaved.getString("lifecycle"))
assertTrue(gallerySaved.getLong("attachmentId") > 0)
stage("native-photo-gallery-saved")
if (fromRecord) {
ui.onNodeWithText("Done", substring = false).performClick()
ui.onNodeWithText("Photos", substring = false).performClick()
waitFor(if (rich) "3 photos" else "2 photos")
if (rich) {
ui.onNodeWithText("Preview example 27.jpg").assertDoesNotExist()
// Seeded, camera and gallery photos are all AVAILABLE, so scope thumbnail and Preview to the newly captured camera row.
val cameraRow = "record.photo:${savedFile.getString("id")}"
ui.onNodeWithTag("record.photos.list").performScrollToNode(hasTestTag(cameraRow))
val cameraThumbnail = hasTestTag("photo.image:256") and hasAnyAncestor(hasTestTag(cameraRow))
ui.waitUntil(timeoutMillis = 60_000) { runCatching { ui.onNode(cameraThumbnail).fetchSemanticsNode() }.isSuccess }
ui.onNode(hasText("Preview photo") and hasAnyAncestor(hasTestTag(cameraRow))).performClick()
waitFor("Photo preview")
waitForTag("photo.image:1600")
ui.onNodeWithText("Close preview").performClick()
stage("native-photo-approved-preview")
} else ui.onNodeWithText("Preview photo").assertDoesNotExist()
listOf(savedFile, gallerySaved).forEach { file ->
val row = "record.photo:${file.getString("id")}"
ui.onNodeWithTag("record.photos.list").performScrollToNode(hasTestTag(row))
ui.onNode(hasText(expectedBadge) and hasAnyAncestor(hasTestTag(row))).assertIsDisplayed()
}
stage("native-photo-gallery-parent-scoped")
ui.onNodeWithText("Actions", substring = false).performClick()
ui.onNodeWithText("Add photo", substring = false).performClick()
}
ui.onNodeWithText("Choose photo").performScrollTo().performClick()
waitFor("Photo ready to upload.")
assertEquals(2, galleryCalls.get())
val pending = directory.listFiles().orEmpty().single { it.name.startsWith("photo-") }
assertTrue(bytes.contentEquals(pending.readBytes()))
ui.activityRule.scenario.onActivity { OrientationPolicy.LANDSCAPE.select(it) }
@@ -399,10 +591,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 })
@@ -8,6 +8,7 @@ import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterce
import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException
import android.os.Build
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
@@ -52,7 +53,15 @@ internal class MobileSessionApi(
private val headers: () -> Map<String, String>,
private val compatibility: UiApiCompatibility,
onOutcome: (RequestOutcome) -> Unit = {},
private val deviceInfo: Map<String, String?> = installedVersions(),
) : MobileSessionTransport {
companion object {
/*******************************************************************************
** The same software versions enrollment reported, so an in-place update refreshes the device record and later scan receipts.
*******************************************************************************/
fun installedVersions(): Map<String, String?> = mapOf("osVersion" to Build.VERSION.RELEASE, "appVersion" to BuildConfig.VERSION_NAME)
}
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build()
@@ -65,6 +74,8 @@ internal class MobileSessionApi(
val json = JSONObject().put("foreground", state.foreground).put("screenAvailable", state.available)
.put("locationStatus", locationStatus)
if (state.captureMode != CaptureMode.APP_CONTENT) json.put("captureMode", state.captureMode.name)
// Omitted versions leave the enrolled values unchanged on the server (mobile-scanners 1.6); blank ones are never sent.
deviceInfo.forEach { (name, value) -> if (!value.isNullOrBlank()) json.put(name, value.take(200)) }
state.location.fix?.takeIf { locationAvailable }?.let { fix ->
json.put(
"location", JSONObject().put("latitude", fix.latitude).put("longitude", fix.longitude)
@@ -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(
@@ -311,7 +311,10 @@ class MobileSessionTest {
server.enqueue(MockResponse().setResponseCode(204))
server.enqueue(MockResponse().setBody("{}"))
val origin = server.url("/").toString()
val api = MobileSessionApi(origin, { mapOf("X-QQQ-Device-Token" to "synthetic-token") }, UiApiCompatibility(origin, true))
val api = MobileSessionApi(
origin, { mapOf("X-QQQ-Device-Token" to "synthetic-token") }, UiApiCompatibility(origin, true),
deviceInfo = mapOf("osVersion" to "14", "appVersion" to "1.0.1", "manufacturer" to null, "model" to " ")
)
api.heartbeat("synthetic-session", ready())
api.screen("synthetic-session", "lease-one", 4, frame)
api.end("synthetic-session", "SIGN_OUT")
@@ -324,7 +327,10 @@ class MobileSessionTest {
val body = JSONObject(heartbeat.body.readUtf8())
assertTrue(body.getBoolean("screenAvailable"))
assertEquals("count", body.getString("processName"))
listOf("user", "deviceId", "barcode", "sessionUUID", "password", "values").forEach { assertFalse(body.has(it)) }
// Installed versions ride on every heartbeat so an in-place update refreshes the device record; blank/null ones are omitted.
assertEquals("14", body.getString("osVersion"))
assertEquals("1.0.1", body.getString("appVersion"))
listOf("user", "deviceId", "barcode", "sessionUUID", "password", "values", "manufacturer", "model").forEach { assertFalse(body.has(it)) }
val screen = server.takeRequest()
assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/screen", screen.path)
assertEquals(4, JSONObject(screen.body.readUtf8()).getLong("sequence"))
@@ -60,6 +60,54 @@ class PhotoUploadViewModelTest {
}
}
/*******************************************************************************
** A host that does not opt in (generic servers) commits QUARANTINED: the saved photo
** is reported as awaiting server review, never as available.
*******************************************************************************/
@Test
fun quarantinedReceiptOnOtherHostsReportsAwaitingReview() = runTest()
{
val dispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(dispatcher)
try {
val transport = FakeTransport().apply { lifecycle = "QUARANTINED" }
val model = PhotoUploadViewModel(transport, "session", target, files.newFolder(), dispatcher) { true }
advanceUntilIdle()
model.prepareCamera().writeBytes(original)
model.cameraResult(true)
advanceUntilIdle()
assertTrue(model.saved)
assertEquals("Saved photo. Awaiting server review.", model.message)
} finally {
Dispatchers.resetMain()
}
}
/*******************************************************************************
** An Echo host that admits approved-device captures commits AVAILABLE: the same unchanged
** receipt flow reports a plain saved photo, with the exact original still verified.
*******************************************************************************/
@Test
fun availableReceiptOnEchoHostReportsSavedWithoutReviewWait() = runTest()
{
val dispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(dispatcher)
try {
val transport = FakeTransport().apply { lifecycle = "AVAILABLE" }
val model = PhotoUploadViewModel(transport, "session", target, files.newFolder(), dispatcher) { true }
advanceUntilIdle()
model.prepareCamera().writeBytes(original)
model.cameraResult(true)
advanceUntilIdle()
assertTrue(model.saved)
assertEquals("Saved photo.", model.message)
assertArrayEquals(original, transport.uploaded)
assertEquals("CAMERA", model.intent!!.origin)
} finally {
Dispatchers.resetMain()
}
}
/*******************************************************************************
** Clearing the process cancels in-flight transport before removing only the app-owned temporary original.
*******************************************************************************/
@@ -215,6 +263,7 @@ class PhotoUploadViewModelTest {
var finalizes = 0
var cancels = 0
var statusCalls = 0
var lifecycle = "QUARANTINED"
var loseCreate = false
var loseFinalize = false
var chunkGate: CompletableDeferred<Unit>? = null
@@ -282,7 +331,7 @@ class PhotoUploadViewModelTest {
"items", JSONArray().put(
JSONObject()
.put("itemId", intent.itemId).put("position", 0).put("fileId", "file").put("versionId", 2).put("attachmentId", 3)
.put("sha256", intent.sha256).put("sizeBytes", intent.size).put("lifecycleAtCommit", "QUARANTINED")
.put("sha256", intent.sha256).put("sizeBytes", intent.size).put("lifecycleAtCommit", lifecycle)
)
) else JSONObject.NULL
return JSONObject().put("uploadId", id).put("state", state).put("receipt", receipt)
@@ -20,6 +20,33 @@ import org.junit.Test
** Verify discovery, immutable request replay and exact persistence receipts without a device.
*******************************************************************************/
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));