Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61618f3345 | ||
|
|
835c5add68
|
||
|
|
a9fa2bc932
|
||
|
|
d4b2b626ca
|
||
|
|
4f96071157
|
||
|
|
ad0c5da85a | ||
|
|
631c86b244
|
||
|
|
52c34a54e7
|
||
|
|
df8b615c09 | ||
|
|
6cb8e34c5d
|
||
|
|
4be0bfe7ba
|
||
|
|
0d4f6df850
|
||
|
|
8e7fba0d65 | ||
|
|
01b9705428
|
||
|
|
63f026dfc2
|
||
|
|
78ae9e0722
|
||
|
|
e7774e4dd0
|
||
|
|
1f7c3e63ba
|
||
|
|
514c42b3f8 | ||
|
|
1f8e3196fe
|
||
|
|
408686ea23
|
||
|
|
ceb45dbf53 | ||
|
|
507bca7729
|
||
|
|
59959247c7
|
||
|
|
7dc1121c3a
|
||
|
|
ae3fd7be06
|
||
|
|
ef0af0e5f2 | ||
|
|
9e8685f6e0
|
||
|
|
61b30cddc3
|
||
|
|
c10edf4046
|
||
|
|
323b8d9866
|
||
|
|
75f661490d
|
||
|
|
31f4823269
|
||
|
|
a9eae04d58
|
||
|
|
e4233b315c
|
||
|
|
0b0875f40a
|
||
|
|
f9b0eb25c7
|
||
|
|
1874c64b0f
|
||
|
|
8b3f9aeb9a
|
||
|
|
df0ff73dae
|
||
|
|
1345197b51
|
||
|
|
0936c1ba58
|
||
|
|
286e2e0f3d
|
||
|
|
8c18f218a8
|
||
|
|
795a0a2a74
|
||
|
|
090e3c1135
|
||
|
|
c0e27b36fd
|
||
|
|
45f9cbddb1
|
||
|
|
e2ebc21ab2
|
||
|
|
01b02175b7
|
||
|
|
e3b775ec3f
|
||
|
|
9bf4619453
|
||
|
|
341b058725
|
||
|
|
b337d01eac
|
||
|
|
f0f4731cc0
|
||
|
|
ca6e4dd20c
|
||
|
|
15c663be46
|
||
|
|
f553eced25
|
||
|
|
bd3739d79b
|
||
|
|
16755f68cb
|
||
|
|
8da274e52b
|
||
|
|
101efc846b
|
||
|
|
a1e1cd47f1
|
||
|
|
912040dfbc
|
||
|
|
4bc88a404a
|
||
|
|
707e1ab8ba
|
||
|
|
bbc1fbf080
|
||
|
|
de8ba6315c
|
||
|
|
a5706dac6d
|
||
|
|
040329e110
|
||
|
|
e1e6a21c89
|
||
|
|
b156ab7c50
|
||
|
|
0b53f342e5
|
||
|
|
6e792eea63
|
||
|
|
1a3e33ba28
|
||
|
|
2de8470499
|
||
|
|
1041693872
|
||
|
|
bf174a6fe7
|
||
|
|
b14d925b74
|
||
|
|
0ef7a28354
|
||
|
|
a8ed10865e
|
||
|
|
bf98f84fe3
|
||
|
|
1df7bbd180
|
||
|
|
092655051d
|
||
|
|
cf835faf60
|
||
|
|
84ffd7c58e
|
||
|
|
5dd3cd78c4
|
||
|
|
a49724384f
|
||
|
|
7dd02b8874
|
||
|
|
69ada88621
|
||
|
|
21cc0e31f2
|
||
|
|
6c81ae5f14
|
||
|
|
9614d176a8
|
No files matched your search
@@ -5,3 +5,7 @@
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.pdf binary
|
||||
|
||||
# Retained upstream-derived artifact evidence must preserve the exact hashed bytes.
|
||||
# Its original whitespace is evidence, not locally maintained source formatting.
|
||||
integrations/record-files/evidence/**/qbit-collaboration-resolved.pom.xml -text !eol whitespace=-blank-at-eol,-blank-at-eof
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -1,5 +1,11 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /e2e
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
- package-ecosystem: maven
|
||||
directories: ['/', '/demo']
|
||||
target-branch: develop
|
||||
|
||||
@@ -22,7 +22,14 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force
|
||||
Invoke-ScriptAnalyzer -Path scripts -Recurse -Settings scripts/PSScriptAnalyzerSettings.psd1 -EnableExit
|
||||
$scriptPaths = @(Get-ChildItem -LiteralPath scripts, integrations/record-files -File -Filter '*.ps1')
|
||||
# An analyzer crash is not ignored: the failing file is named and the step still fails.
|
||||
$findings = @($scriptPaths | ForEach-Object {
|
||||
Write-Output "Analyzing $($_.FullName)" | Out-Host
|
||||
Invoke-ScriptAnalyzer -Path $_.FullName -Settings scripts/PSScriptAnalyzerSettings.psd1
|
||||
})
|
||||
$findings | Format-Table -AutoSize
|
||||
if($findings.Count -gt 0) { throw "PowerShell analysis reported $($findings.Count) findings." }
|
||||
verify:
|
||||
name: Verify demo HTTP contract
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -39,6 +46,20 @@ jobs:
|
||||
run: ./mvnw --batch-mode --no-transfer-progress verify install
|
||||
- name: Verify native login, enrollment and scan flow
|
||||
run: ./mvnw --batch-mode --no-transfer-progress -f demo/pom.xml verify
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: e2e/package-lock.json
|
||||
- name: Prepare isolated browser acceptance
|
||||
run: |
|
||||
./mvnw --batch-mode --no-transfer-progress -f demo/pom.xml dependency:build-classpath -Dmdep.outputFile=target/e2e-classpath.txt
|
||||
npm ci --prefix e2e
|
||||
cd e2e
|
||||
npx playwright install --with-deps chromium
|
||||
- name: Verify record and bulk process screens
|
||||
working-directory: e2e
|
||||
run: npm test
|
||||
- name: Retain test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
@@ -49,5 +70,7 @@ jobs:
|
||||
demo/target/surefire-reports/
|
||||
target/checkstyle-result.xml
|
||||
demo/target/checkstyle-result.xml
|
||||
e2e/playwright-report/
|
||||
e2e/test-results/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
@@ -5,3 +5,6 @@ data/
|
||||
*.log
|
||||
.idea/
|
||||
.flattened-pom.xml
|
||||
e2e/node_modules/
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
@@ -0,0 +1,13 @@
|
||||
# Exact Gitleaks fingerprints (commit:file:rule:line) for historical commits only.
|
||||
# Each is an inert, patterned placeholder used as a test-only administrator token
|
||||
# fixture in the Echo demo tests. It is not a credential for any system. Current
|
||||
# sources generate the token at runtime, so nothing here covers new findings.
|
||||
|
||||
# EchoDemoAdoptionTest
|
||||
ca6e4dd20c8d111f641f35faad5cc64828fe600d:examples/echo/demo/src/test/java/com/kingsrook/examples/echo/demo/EchoDemoAdoptionTest.java:generic-api-key:59
|
||||
|
||||
# EchoDemoAcceptanceTest, EchoDemoSettingsTest, TrustedLocalRendererSettingsTest, EchoDemoStartupTest
|
||||
4bc88a404a0a39b82fcf36f7c9d038984be0d060:examples/echo/demo/src/test/java/com/kingsrook/examples/echo/demo/EchoDemoAcceptanceTest.java:generic-api-key:65
|
||||
4bc88a404a0a39b82fcf36f7c9d038984be0d060:examples/echo/demo/src/test/java/com/kingsrook/examples/echo/demo/EchoDemoSettingsTest.java:generic-api-key:26
|
||||
4bc88a404a0a39b82fcf36f7c9d038984be0d060:examples/echo/demo/src/test/java/com/kingsrook/examples/echo/demo/TrustedLocalRendererSettingsTest.java:generic-api-key:31
|
||||
4bc88a404a0a39b82fcf36f7c9d038984be0d060:examples/echo/demo/src/test/java/com/kingsrook/examples/echo/demo/EchoDemoStartupTest.java:generic-api-key:47
|
||||
+1
-1
@@ -9,7 +9,7 @@ release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: all
|
||||
publish:
|
||||
snapshot: false
|
||||
snapshot: true
|
||||
release_candidate: false
|
||||
release: false
|
||||
feature: false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# QQQ Mobile Scanners
|
||||
|
||||
This is an application QBit and a separate local demo, using Java 21 and QQQ 4.1.0-RC.20.
|
||||
The Android host is maintained in [QRun-IO/qqq-android](https://github.com/QRun-IO/qqq-android).
|
||||
This is an application QBit and a separate local demo, using Java 21 and QQQ 4.1.0-RC.21. Optional `examples/echo` (scan-to-print) uses released Printing 1.0.0; its Generation, Record Files and Scripting owner inputs are not yet public. Contract versions: UI API 1.10, `mobile-scanners` 1.6 (1.5 on the maintained local server), `printing` 2.3, `mobile-record-files` 1.0.
|
||||
The Android host is maintained in [QRun-IO/qqq-android on Gitea](https://git.kof22.com/QRun-IO/qqq-android) (reviews pulls/8 and pulls/10 are merged into `main`). Present status is in `docs/SESSION-STATE.md`; older logs are in `docs/history/`.
|
||||
|
||||
## Build and verify
|
||||
|
||||
@@ -9,10 +9,13 @@ The Android host is maintained in [QRun-IO/qqq-android](https://github.com/QRun-
|
||||
- From this directory: `./mvnw verify install`, then `./mvnw -f demo/pom.xml verify`.
|
||||
- Windows equivalents use `mvnw.cmd`. Checkstyle runs during `validate`; do not disable it or skip tests for a completed change.
|
||||
- Root tests cover H2 persistence, permissions, concurrent retries and async identity. Demo tests cover the real native HTTP protocol. Hardware acceptance is separate: `docs/TC22-ACCEPTANCE.md`.
|
||||
- Every table-based process must work from a single record's Actions menu and from bulk selection. Cover explicit row selections and select-all/first-N filters with browser E2E tests, including confirmation, persisted changes, visible results, and unaffected unselected rows. Backend-only process tests do not replace these UI tests.
|
||||
- Every mobile process needs real native UI-to-server E2E coverage in the Android repository as well as the HTTP contract. Use isolated test accounts, databases and Android application IDs; never reset the user's demo or scanner data to run tests.
|
||||
|
||||
## Implementation conventions
|
||||
|
||||
- Keep ordinary QQQ app/process/widget metadata and existing native API paths. Do not create a second generic mobile-screen framework.
|
||||
- All mobile endpoints must be registered in the versioned semantic UI API extension, with discovery, stable operation IDs, typed OpenAPI schemas and compatibility regressions. Keep the Android client channel registered and test real HTTP requests with that channel. Do not add parallel unversioned/custom business routes.
|
||||
- Kingsrook Java formatting: three spaces, Allman braces, banner documentation, explicit imports, Apache SPDX headers. `checkstyle/checkstyle.xml` is the enforced authority.
|
||||
- Preserve online-only semantics, idempotent event retries and server-derived user/device attribution.
|
||||
- Device credentials belong only in scoped HTTP headers and private hashed storage, never process inputs, responses beyond enrollment, fixtures, logs or notes.
|
||||
@@ -22,6 +25,9 @@ The Android host is maintained in [QRun-IO/qqq-android](https://github.com/QRun-
|
||||
## Repository workflow
|
||||
|
||||
- `develop` is the development default; make feature/fix branches and pull requests into it. `main` is reserved for reviewed releases.
|
||||
- CircleCI uses the central qbit manifest profile on the managed `0.8` track. Keep the three version declarations identical. Extra demo verification belongs in its dedicated GitHub workflow, not the canonical setup caller.
|
||||
- The primary forge for source, reviews and checks is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive, so make no ordinary GitHub writes.
|
||||
- The CircleCI manifest (central qbit profile, managed `0.8` track; keep the three version declarations identical) and the GitHub demo workflow are retained legacy definitions: preserve the existing managed files, but primary CI changes follow current Gitea policy and should not be directed into GitHub.
|
||||
- All publication switches start disabled. Creating a repo or pushing a branch is not permission to publish Maven artifacts or cut a release.
|
||||
- Preserve unrelated work. Update `docs/SESSION-STATE.md` and `docs/TODO.md` when project state changes. Commit, push, release, deployment and external communication require authorization in the active task.
|
||||
- The local demo must keep working independent of any shared CI redesign. `docs/LOCAL-DEMO.md` distinguishes the basic Scan Capture demo (`scripts/Start-Demo.ps1`) from the full Echo server (`examples/echo/demo`); do not invent port defaults (the Echo launcher has none). Never print or paste demo passwords: use `scripts/Copy-DemoPassword.ps1`. Keep device identifiers, addresses, credentials, workstation paths and vault IDs out of the repository.
|
||||
- Do not claim a manual or hardware test beyond what the operator actually reported or what an artifact shows; do not request the already-reported TC22 manual tests again, and do not claim barcode fidelity beyond the documented Echo profiles.
|
||||
- Preserve unrelated work. Keep `docs/SESSION-STATE.md` (present status, short), `docs/TODO.md` (open items only) and `docs/PRODUCTION-READINESS.md` current when project state changes, and move superseded detail to `docs/history/` rather than deleting it. Commit, push, release, deployment and external communication require authorization in the active task.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Contributing
|
||||
|
||||
Use Java 21 and the checked-in Maven wrapper. Clone this repository and the companion [qqq-android](https://github.com/QRun-IO/qqq-android) when changing the mobile/server contract. No Android SDK is needed for server-only changes.
|
||||
Use Java 21 and the checked-in Maven wrapper. Clone this repository and the companion [qqq-android](https://git.kof22.com/QRun-IO/qqq-android) on Gitea (the primary forge; GitHub is a shadow/archive) when changing the mobile/server contract. No Android SDK is needed for server-only changes.
|
||||
|
||||
Create `feature/`, `fix/`, `docs/` or `chore/` branches from `develop` and open pull requests into `develop`. Use Conventional Commit subjects and describe the resulting behavior, relevant verification and compatibility changes. Request review from the code owner; do not merge with failing required checks.
|
||||
|
||||
|
||||
@@ -1,49 +1,91 @@
|
||||
# QQQ Mobile Scanners
|
||||
|
||||
Server support for approved Android handhelds running native QQQ apps. The first app, **Scan Capture**, stores barcode events in a QQQ table. The companion Android application is [QQQ Android's scanner host](https://github.com/QRun-IO/qqq-android/tree/develop/scanner-host).
|
||||
Server support for approved Android handhelds running native QQQ apps. The first app, **Scan Capture**, stores barcode events in a QQQ table. An optional example, **Echo**, scans a barcode and prints a copy of it ([Echo](examples/echo/README.md)).
|
||||
|
||||
Version one is online only. Devices require an authenticated user and administrator approval. Android renders existing QQQ app, process, frontend-step and widget metadata; this qbit adds the scanner widget and enrollment lifecycle.
|
||||
Version one is online only. Devices require an authenticated user and administrator approval. Android renders existing QQQ app, process, frontend-step and widget metadata; this qbit adds the scanner widget and the device enrollment lifecycle. Not released as production 1.0.
|
||||
|
||||
## Project layout
|
||||
## Where things live
|
||||
|
||||
- `src/main`: reusable qbit, device enrollment routes, device approval/revocation, scan process and table metadata.
|
||||
- `src/test`: server behavior and permission checks.
|
||||
- `demo`: local QQQ server with persistent H2 storage, native administration UI and password login.
|
||||
- `docs/TC22-ACCEPTANCE.md`: build, install and hardware verification.
|
||||
Gitea is the primary forge for source, reviews and checks. GitHub is a shadow/archive that receives no ordinary writes; any GitHub link in an older document is history only.
|
||||
|
||||
## Build
|
||||
- Server (this repository): [git.kof22.com/QRun-IO/qbit-mobile-scanners](https://git.kof22.com/QRun-IO/qbit-mobile-scanners), development branch `develop`.
|
||||
- Android host: [git.kof22.com/QRun-IO/qqq-android](https://git.kof22.com/QRun-IO/qqq-android) (`scanner-host` module). Its reviews 8 and 10 are merged into `main`.
|
||||
- Current status: [session state](docs/SESSION-STATE.md), [TODO](docs/TODO.md), [production readiness](docs/PRODUCTION-READINESS.md). Older logs are in [history](docs/history/README.md).
|
||||
|
||||
Use Java 21 and Maven 3.9+. The implementation targets QQQ `4.1.0-RC.20`; QQQ `4.0.0` does not provide the password session exchange needed by this demo.
|
||||
## Current versions
|
||||
|
||||
| Component | Version |
|
||||
| --- | --- |
|
||||
| Java / Maven | 21 / wrapper-pinned 3.9.9 |
|
||||
| QQQ | `4.1.0-RC.21` (public) |
|
||||
| QQQ semantic UI/UX API | 1.10 |
|
||||
| `mobile-scanners` extension | 1.5 |
|
||||
| `printing` extension (Echo) | 2.3, released Printing `1.0.0` |
|
||||
| `mobile-record-files` vocabulary | 1.0 |
|
||||
| Development snapshot | `0.1.0-20261103.233448-3645` (Central; development only) |
|
||||
|
||||
The 198-test cohort for this set passes with Checkstyle (scanner 53, demo 8, mobile Record Files 18, Printing bridge 16, Echo 68, Echo demo 35). The document, Record Files and Scripting owner artifacts used by Echo are not yet public; see [session state](docs/SESSION-STATE.md).
|
||||
|
||||
## Quick start
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd verify install
|
||||
.\mvnw.cmd -f demo/pom.xml verify
|
||||
pwsh -File scripts/Start-Demo.ps1
|
||||
```
|
||||
|
||||
The wrapper pins Maven 3.9.9. On Unix, use `./mvnw` in place of `mvnw.cmd`. This is an initial implementation, with no published Maven artifact. Consult [session state](docs/SESSION-STATE.md) for actual verification status.
|
||||
On Unix use `./mvnw`. The last command starts the basic Scan Capture demo on loopback. [Local demo](docs/LOCAL-DEMO.md) explains the two launchers (basic Scan Capture versus the full Echo server), signing in without displaying passwords, connecting a device or emulator, and the enroll, approve, scan and revoke walkthrough. For a physical handheld follow [TC22 acceptance](docs/TC22-ACCEPTANCE.md); for test runners see [testing](docs/TESTING.md). Checkstyle runs during `validate`; do not disable it or skip tests.
|
||||
|
||||
For the Windows demo, follow [local startup and credentials](docs/LOCAL-DEMO.md). For the scanner, follow [TC22 installation and acceptance](docs/TC22-ACCEPTANCE.md).
|
||||
## Architecture
|
||||
|
||||
## Contributing and CI
|
||||
The server uses QQQ's existing, versioned semantic UI/UX API; nothing parallel is added.
|
||||
|
||||
Development pull requests target `develop`. CircleCI uses the shared QRun-IO QBit pipeline; GitHub Actions runs the demo HTTP contract and CodeQL. All Maven publication switches start disabled. See [contributing](CONTRIBUTING.md), [CI and release operation](docs/CI-CD.md), [security reporting](SECURITY.md) and the [code of conduct](CODE_OF_CONDUCT.md). This project is licensed under [Apache 2.0](LICENSE).
|
||||
- App discovery and process execution go through `/qqq/v1`.
|
||||
- Enrollment, branding and status are registered operations of the independently versioned `mobile-scanners` extension at `/qqq/v1/qbit/mobile-scanners`, with discovery, stable operation IDs and typed OpenAPI schemas. The Android host checks core and extension compatibility before login. See the [semantic API contract](docs/UI-API.md).
|
||||
- The qbit contributes `mobileDevice`, private device credential storage, `mobileScan`, `mobileScanCapture`, the scanner widget and the device approve/revoke processes. Device sessions, live activity and consented screen viewing are in [device sessions](docs/DEVICE-SESSIONS.md) and [screen viewing](docs/DEVICE-SCREEN-VIEWING.md).
|
||||
- Metadata changes that use supported native components reach devices from the server; a new required component type needs a shared Android update. This does not imply full web-client feature coverage.
|
||||
|
||||
```text
|
||||
Android host --(/qqq/v1 + mobile-scanners extension)--> QQQ server + this qbit --> H2/RDBMS
|
||||
|-- optional: Echo, Record Files, Document Generation, Printing
|
||||
```
|
||||
|
||||
## Project layout
|
||||
|
||||
- `pom.xml`: the `qbit-mobile-scanners-reactor` aggregator (packaging pom, single `<revision>` source) of the two published modules, `base` and `integrations/record-files`; `demo` and `examples/echo` are not part of it. One root `mvn deploy` publishes the reactor POM, `qbit-mobile-scanners` and `qbit-mobile-scanners-record-files` at the same version.
|
||||
- `base/src/main`, `base/src/test`: the reusable qbit (enrollment routes, approval/revocation, scan process, table metadata) and its H2 behavior and permission tests.
|
||||
- `demo`: basic local QQQ server with persistent H2 storage, native administration UI and password login. QQQ `4.0.0` lacks the password session exchange it needs.
|
||||
- `e2e`: browser acceptance tests against the packaged frontend and an isolated real demo server.
|
||||
- `examples/echo` and `examples/echo/demo`: the optional Echo library and its runnable composed server.
|
||||
- `integrations/record-files`: optional camera/gallery upload bridge.
|
||||
- `scripts`: Windows launcher and the password-copy helper.
|
||||
|
||||
## Supported features
|
||||
|
||||
- Single-barcode capture (Zebra EMDK service on the Android host, no DataWedge profile) and manual entry for Scan Capture, with QQQ table-based login.
|
||||
- Device enrollment, approval and revocation from a record or from bulk selection (checked rows, select-all, first N up to 100), with confirmation and visible results.
|
||||
- Idempotent scan events, device/session records, online activity, administrator session termination, own-app screen viewing, optional foreground location, and customer branding with connection diagnostics ([branding](docs/BRANDING.md)).
|
||||
- Optional operator-consented device-screen viewing (extension 1.2), camera/gallery record uploads through the optional [Record Files bridge](integrations/record-files/README.md), and the Echo scan-to-print example.
|
||||
- Additional apps are ordinary QQQ app/process/widget metadata; their server-side permission rules must explicitly protect their actions.
|
||||
|
||||
## Embed in a QQQ application
|
||||
|
||||
Enable `instance.setEnforcePermissionsInCoreActions(true)`, register `MobileScannersQBitProducer` with a host-owned persistent backend, and attach `MobileScannersRouteProvider` to the QQQ Javalin server. The qbit requires core permission enforcement so direct table actions cannot bypass its protections. Grant `mobile.scan` to operators and `mobile.admin` to administrators through the host's identity provider/permission integration. The demo's fixed account roles are confined to the demo application.
|
||||
Enable `instance.setEnforcePermissionsInCoreActions(true)`, register `MobileScannersQBitProducer` with a host-owned persistent backend, and attach `MobileScannersRouteProvider` to the QQQ Javalin server. The qbit requires core permission enforcement so direct table actions cannot bypass its protections. Grant `mobile.scan` to operators and `mobile.admin` to administrators through the host's identity provider. The demo's fixed account roles stay inside the demo application.
|
||||
|
||||
The qbit contributes `mobileDevice`, private device credential storage, `mobileScan`, `mobileScanCapture`, and device approve/revoke processes. Generic table mutations are blocked; device lifecycle and capture go through guarded operations. An installation identifier is descriptive, not an authentication credential.
|
||||
|
||||
The API uses the normal QQQ user session plus `X-QQQ-Device-Id` and `X-QQQ-Device-Token`. Device credentials are not process input fields. Enrollment creates a pending device; approval enables app access, and revocation prevents subsequent captures. Lost local app data requires fresh enrollment and administrator review.
|
||||
Generic table mutations are blocked; device lifecycle and capture go through guarded operations. The API uses the normal QQQ user session plus `X-QQQ-Device-Id` and `X-QQQ-Device-Token`; device credentials appear only in those scoped headers and in private hashed storage, never in process inputs, responses beyond enrollment, fixtures or logs. Enrollment creates a pending device, approval enables app access and revocation prevents later captures. An installation identifier is descriptive, not a credential. Lost app data requires fresh enrollment and administrator review.
|
||||
|
||||
## Scan semantics
|
||||
|
||||
Each physical scan receives a new event UUID. A retry of that event uses the same UUID and returns the original receipt rather than inserting a second record. Scanning the same barcode twice intentionally produces two events.
|
||||
Each physical scan gets a new event UUID. A retry reuses that UUID and returns the original receipt; scanning the same barcode twice deliberately creates two events. Stored data includes barcode text (leading zeroes preserved), symbology, source, capture and receipt times, authenticated user, approved device, app and process, and reported hardware/app versions; extension 1.5 can add the scanner's AIM identifier and exact raw bytes. Client-reported timestamps and hardware details are provenance, not identity. The app shows success only after server acknowledgement, a timeout is an uncertain outcome to retry, and there is no offline queue.
|
||||
|
||||
Stored data includes barcode text, symbology, source, device capture time, server receipt time, authenticated user, approved device, app/process and reported hardware/app versions. Barcode text is preserved, including leading zeroes. Client-reported timestamps and hardware information are provenance, not trusted identity.
|
||||
## Limits and release status
|
||||
|
||||
The app must show server acknowledgement before reporting a save. A network timeout has an uncertain outcome; retry the retained event. There is no persistent offline queue in this version.
|
||||
- **Not production-ready.** The local demo works and the TC22 manual tests are operator-reported as passing; a QRun-signed Android 1.0 APK and its direct/MDM channel exist locally, but signed-build qualification on a device against an HTTPS server, distribution, HTTPS deployment and the other [readiness gates](docs/PRODUCTION-READINESS.md) are open. The HTTP loopback demo and an HTTPS production deployment are separate things.
|
||||
- **Barcode fidelity.** Echo supports a documented set of symbologies and refuses GS1, ECI, structured and binary payloads ([limits](examples/echo/README.md#fidelity-and-limits)); no universal reproduction or all-symbology claim is made. Hardware AIM output has no separate evidence beyond the operator's group report.
|
||||
- **Dependencies.** The development snapshot is published, but the document/files/scripting owner artifacts are not; Echo and the Record Files integration need a coherent local cohort until they are.
|
||||
- **Outside this version:** offline workflows, camera-based decoding, a visual app designer and applied MDM rollout. The Android host implements managed configuration, a profile generator and a live wallpaper, but applied rollout is not qualified. Native OAuth2 login exists only for servers that advertise the semantic UI API 1.10 native-session and nonce-validation features; no native handheld test against a configured provider (such as Authentik) is claimed, and that acceptance plus production ingress hardening remain gates.
|
||||
- **Database.** The supplied schema targets H2; migrations for other databases belong to the host application.
|
||||
|
||||
## Scope
|
||||
## Contributing and CI
|
||||
|
||||
Initial support is single-barcode Zebra DataWedge intent delivery and QQQ table-based username/password login. Other identity providers need Android host configuration before use. Additional apps can use QQQ's existing native process and widget metadata; their server-side permission rules must explicitly protect their actions. A visual mobile app designer, offline workflows, camera scanning, MDM rollout and remote fleet management are separate future work. The supplied SQL schema targets H2; other database migrations belong to the host application.
|
||||
Development pull requests target `develop`; `main` is reserved for reviewed releases. All Maven publication switches other than development snapshots stay disabled, and creating a repository or pushing a branch is not permission to publish. The CircleCI and GitHub workflow files describe the retained legacy shared QBit pipeline; [CI and release operation](docs/CI-CD.md) explains how to run the same checks locally, and the local demo does not depend on that pipeline. See [contributing](CONTRIBUTING.md), [security reporting](SECURITY.md) and the [code of conduct](CODE_OF_CONDUCT.md). Licensed under [Apache 2.0](LICENSE).
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-mobile-scanners-reactor</artifactId>
|
||||
<version>${revision}</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-mobile-scanners</artifactId>
|
||||
<version>${revision}</version>
|
||||
<name>QQQ Mobile Scanners</name>
|
||||
<description>Approved mobile devices and native QQQ barcode capture processes.</description>
|
||||
<url>https://git.kof22.com/QRun-IO/qbit-mobile-scanners</url>
|
||||
<scm>
|
||||
<connection>scm:git:https://git.kof22.com/QRun-IO/qbit-mobile-scanners.git</connection>
|
||||
<developerConnection>scm:git:ssh://git@git.kof22.com:22/QRun-IO/qbit-mobile-scanners.git</developerConnection>
|
||||
<url>https://git.kof22.com/QRun-IO/qbit-mobile-scanners</url>
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
<issueManagement>
|
||||
<system>Gitea</system>
|
||||
<url>https://git.kof22.com/QRun-IO/qbit-mobile-scanners/issues</url>
|
||||
</issueManagement>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.2.224</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-postgres</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.14</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.11.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>14.3.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<configLocation>${maven.multiModuleProjectDirectory}/checkstyle/checkstyle.xml</configLocation>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<includeTestSourceDirectory>true</includeTestSourceDirectory>
|
||||
<failOnViolation>true</failOnViolation>
|
||||
<violationSeverity>warning</violationSeverity>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>checkstyle</id>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessMetaDataAdjustment;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Freeze native single/bulk record selections for guarded admin processes.
|
||||
*******************************************************************************/
|
||||
final class MobileAdminSelection
|
||||
{
|
||||
/***************************************************************************
|
||||
** Standalone launches retain manual entry; selections never apply early.
|
||||
***************************************************************************/
|
||||
static void prepare(RunBackendStepInput input, RunBackendStepOutput output, String selectedTable, String idField, String noun) throws QException
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.ADMIN);
|
||||
String selection = input.getValueString("recordsParam");
|
||||
if(selection == null || selection.isBlank())
|
||||
{
|
||||
output.setRecords(new ArrayList<>());
|
||||
output.addValue("selectionSummary", "Enter the record IDs to process, separated by commas.");
|
||||
return;
|
||||
}
|
||||
String table = input.getValueString("tableName");
|
||||
if(table != null && !selectedTable.equals(table))
|
||||
{
|
||||
throw new QException("Select records from the process table.");
|
||||
}
|
||||
List<String> selected;
|
||||
if("recordIds".equals(selection))
|
||||
{
|
||||
selected = resolve(MobileService.required(input.getValues(), "recordIds", 3700), selectedTable);
|
||||
}
|
||||
else if("filterJSON".equals(selection))
|
||||
{
|
||||
selected = resolveFilter(input, selectedTable);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new QException("Select mobile records to process.");
|
||||
}
|
||||
List<String> labels = new ArrayList<>();
|
||||
List<QRecord> snapshot = new ArrayList<>();
|
||||
for(String id : selected)
|
||||
{
|
||||
var device = MobileService.get(selectedTable, id);
|
||||
labels.add((device.getValueString("displayName") == null ? device.getValueString("userId") : device.getValueString("displayName")) + " (" + id + ")");
|
||||
snapshot.add(new QRecord().withValue("id", id));
|
||||
}
|
||||
output.setRecords(snapshot);
|
||||
output.addValue(idField, String.join(",", selected));
|
||||
output.addValue("selectionSummary", selected.size() + " " + noun + " selected: " + String.join("; ", labels));
|
||||
output.setProcessMetaDataAdjustment(new ProcessMetaDataAdjustment().withUpdatedField(
|
||||
new QFieldMetaData(idField, QFieldType.STRING).withLabel("Record IDs").withMaxLength(3700).withIsHidden(true).withIsRequired(false)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Native select-all/first-N filters become a fixed, bounded record snapshot.
|
||||
***************************************************************************/
|
||||
private static List<String> resolveFilter(RunBackendStepInput input, String selectedTable) throws QException
|
||||
{
|
||||
QQueryFilter filter;
|
||||
try
|
||||
{
|
||||
filter = JsonUtils.toObject(MobileService.required(input.getValues(), "filterJSON", 100000), QQueryFilter.class);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw new QException("The selected device filter is invalid.", e);
|
||||
}
|
||||
if(filter == null || (filter.getLimit() != null && filter.getLimit() < 1))
|
||||
{
|
||||
throw new QException("Select between 1 and 100 records.");
|
||||
}
|
||||
filter.setLimit(filter.getLimit() == null ? 101 : Math.min(filter.getLimit(), 101));
|
||||
var records = new QueryAction().execute(new QueryInput().withTableName(selectedTable).withFilter(filter)).getRecords();
|
||||
if(records.isEmpty() || records.size() > 100)
|
||||
{
|
||||
throw new QException("Select between 1 and 100 records.");
|
||||
}
|
||||
return resolve(String.join(",", records.stream().map(record -> record.getValueString("id")).toList()), selectedTable);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Validate the entire bounded selection before any record is changed.
|
||||
***************************************************************************/
|
||||
static List<String> resolve(String value, String selectedTable) throws QException
|
||||
{
|
||||
if(value == null || value.isBlank() || value.length() > 3700)
|
||||
{
|
||||
throw new QException("Enter between 1 and 100 record IDs.");
|
||||
}
|
||||
String[] parts = value.split(",", -1);
|
||||
if(parts.length > 100)
|
||||
{
|
||||
throw new QException("Select at most 100 records at a time.");
|
||||
}
|
||||
Set<String> ids = new LinkedHashSet<>();
|
||||
for(String part : parts)
|
||||
{
|
||||
String id = part.trim();
|
||||
try
|
||||
{
|
||||
if(!UUID.fromString(id).toString().equals(id))
|
||||
{
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw new QException("Each record ID must be a valid UUID.");
|
||||
}
|
||||
if(MobileService.get(selectedTable, id) == null)
|
||||
{
|
||||
throw new QException("A selected mobile record no longer exists.");
|
||||
}
|
||||
ids.add(id);
|
||||
}
|
||||
return new ArrayList<>(ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Public appearance data available before authentication.
|
||||
*******************************************************************************/
|
||||
public class MobileBrandingSpec extends MobileJsonSpec
|
||||
{
|
||||
/***************************************************************************
|
||||
** Public, stable semantic operation.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return new BasicOperation().withHttpMethod(HttpMethod.GET).withPath(MobileScannersUiApi.PREFIX + "/branding")
|
||||
.withOperationId("mobileScannersBranding").withShortSummary("Read customer branding")
|
||||
.withLongDescription("Public appearance data; ignores stale user and device credentials. Never includes private configuration.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Login screens also need appearance data.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public boolean isSecured()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Typed public response.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return "MobileScannersBrandingV1";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Publish the complete extension vocabulary once.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
return MobileScannersSchemas.components();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only explicitly allowlisted branding properties leave the server.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input)
|
||||
{
|
||||
MobileScannersQBitConfig config = QContext.getQInstance().getQBits().values().stream().map(bit -> bit.getConfig())
|
||||
.filter(MobileScannersQBitConfig.class::isInstance).map(MobileScannersQBitConfig.class::cast)
|
||||
.findFirst().orElseGet(MobileScannersQBitConfig::new);
|
||||
Map<String, String> response = new LinkedHashMap<>();
|
||||
response.put("displayName", config.getDisplayName());
|
||||
response.put("primaryColor", config.getPrimaryColor());
|
||||
if(config.getLogoUrl() != null)
|
||||
{
|
||||
response.put("logoUrl", config.getLogoUrl());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit scope of consented screen pixels; omitted legacy fields are app-only.
|
||||
*******************************************************************************/
|
||||
enum MobileCaptureMode
|
||||
{
|
||||
APP_CONTENT, DEVICE_SCREEN;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Unknown modes cannot broaden a client's capture permission.
|
||||
***************************************************************************/
|
||||
static MobileCaptureMode parse(Object value) throws QBadRequestException
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return APP_CONTENT;
|
||||
}
|
||||
if(value instanceof String text)
|
||||
{
|
||||
for(MobileCaptureMode mode : values())
|
||||
{
|
||||
if(mode.name().equals(text))
|
||||
{
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new QBadRequestException("Unsupported screen capture mode.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current device state is visible with authenticated user and device headers.
|
||||
*******************************************************************************/
|
||||
public class MobileCurrentDeviceSpec extends MobileDeviceJsonSpec
|
||||
{
|
||||
/***************************************************************************
|
||||
** Stable device-status operation.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return new BasicOperation().withHttpMethod(HttpMethod.GET).withPath(MobileScannersUiApi.PREFIX + "/devices/current")
|
||||
.withOperationId("mobileScannersCurrentDevice").withShortSummary("Read the authenticated device state")
|
||||
.withLongDescription("Requires QQQ user session with mobile.scan and both device headers. PENDING and REVOKED devices may read their state but cannot run scanner processes. Headers also bind device identity on core metadata/widget/process requests; never send them to another origin or in process values.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Versioned safe device response.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return "MobileScannersDeviceV1";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Request-scoped identity is established by the credential binding filter.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.SCAN);
|
||||
String id = MobilePermissions.REQUEST_DEVICE.get();
|
||||
QRecord device = id == null ? null : MobileService.get(MobileScannersQBitProducer.DEVICE, id);
|
||||
if(device == null)
|
||||
{
|
||||
throw new QPermissionDeniedException("Invalid device credential.");
|
||||
}
|
||||
return Map.of("deviceId", id, "status", device.getValueString("status"), "shortName", device.getValueString("shortName"),
|
||||
"description", java.util.Objects.toString(device.getValueString("description"), ""),
|
||||
"displayName", device.getValueString("displayName") == null ? "" : device.getValueString("displayName"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queryable device connectivity and optional foreground location observations.
|
||||
*******************************************************************************/
|
||||
final class MobileDeviceActivity
|
||||
{
|
||||
/***************************************************************************
|
||||
** No independent timers or background location tracking.
|
||||
***************************************************************************/
|
||||
private MobileDeviceActivity()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Ordinary QQQ fields support native filtering and coordinate inspection.
|
||||
***************************************************************************/
|
||||
static void addFields(QTableMetaData table)
|
||||
{
|
||||
table.withVirtualField(new QVirtualFieldMetaData("online", QFieldType.BOOLEAN).withLabel("Online").withIsEditable(false)
|
||||
.withIsQuerySelectable(true).withCriteriaExpander(new QCodeReference(MobileDeviceOnline.class)));
|
||||
table.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(MobileDeviceOnline.class));
|
||||
table.withField(new QFieldMetaData("locationStatus", QFieldType.STRING).withMaxLength(30));
|
||||
for(String name : List.of("lastSeenAt", "locationCapturedAt", "locationReceivedAt"))
|
||||
{
|
||||
table.withField(new QFieldMetaData(name, QFieldType.DATE_TIME));
|
||||
}
|
||||
for(String name : List.of("latitude", "longitude", "accuracyMeters"))
|
||||
{
|
||||
table.withField(new QFieldMetaData(name, QFieldType.DECIMAL));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Presence reflects authenticated traffic independently of approval status.
|
||||
***************************************************************************/
|
||||
static void seen(String id) throws QException
|
||||
{
|
||||
update(new QRecord().withValue("id", id).withValue("lastSeenAt", Instant.now()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Preserve last known coordinates with explicit availability and timestamps,
|
||||
** and refresh reported software versions in the same presence write.
|
||||
***************************************************************************/
|
||||
static void heartbeat(QRecord device, Map<String, Object> input, boolean foreground) throws QException
|
||||
{
|
||||
QRecord changes = new QRecord().withValue("id", device.getValueString("id")).withValue("lastSeenAt", Instant.now());
|
||||
MobileService.refreshDeviceInfo(device, input, changes);
|
||||
String status = MobileService.optional(input, "locationStatus", 30);
|
||||
if(status != null && !List.of("AVAILABLE", "PERMISSION_DENIED", "DISABLED", "UNAVAILABLE").contains(status))
|
||||
{
|
||||
throw new QBadRequestException("Unknown location status.");
|
||||
}
|
||||
if("AVAILABLE".equals(status) && input.get("location") == null)
|
||||
{
|
||||
throw new QBadRequestException("Available location requires coordinates and a capture timestamp.");
|
||||
}
|
||||
changes.setValue("locationStatus", status == null ? "UNAVAILABLE" : status);
|
||||
if(input.get("location") != null)
|
||||
{
|
||||
if(!foreground || !"AVAILABLE".equals(status) || !(input.get("location") instanceof Map<?, ?> location))
|
||||
{
|
||||
throw new QBadRequestException("Location requires an available foreground observation.");
|
||||
}
|
||||
changes.setValue("latitude", decimal(location, "latitude", -90, 90));
|
||||
changes.setValue("longitude", decimal(location, "longitude", -180, 180));
|
||||
changes.setValue("accuracyMeters", decimal(location, "accuracyMeters", 0, 10000000));
|
||||
try
|
||||
{
|
||||
Object captured = location.get("capturedAt");
|
||||
if(!(captured instanceof String text) || text.length() > 40)
|
||||
{
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
changes.setValue("locationCapturedAt", Instant.parse(text));
|
||||
}
|
||||
catch(Exception failure)
|
||||
{
|
||||
throw new QBadRequestException("Location capturedAt must be an ISO-8601 timestamp.");
|
||||
}
|
||||
changes.setValue("locationReceivedAt", Instant.now());
|
||||
}
|
||||
update(changes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Reject nonnumeric, nonfinite and out-of-range coordinates before storage.
|
||||
***************************************************************************/
|
||||
private static BigDecimal decimal(Map<?, ?> input, String key, int minimum, int maximum) throws QException
|
||||
{
|
||||
if(!(input.get(key) instanceof Number number) || !Double.isFinite(number.doubleValue())
|
||||
|| number.doubleValue() < minimum || number.doubleValue() > maximum)
|
||||
{
|
||||
throw new QBadRequestException("Invalid location " + key + ".");
|
||||
}
|
||||
return new BigDecimal(number.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only trusted telemetry writes may update presence and location fields.
|
||||
***************************************************************************/
|
||||
private static void update(QRecord record) throws QException
|
||||
{
|
||||
MobileService.internally(() ->
|
||||
{
|
||||
var saved = new UpdateAction().execute(new UpdateInput().withTableName(MobileScannersQBitProducer.DEVICE)
|
||||
.withTransaction(MobileService.TRANSACTION.get()).withRecord(record)).getRecords();
|
||||
if(saved == null || saved.size() != 1 || (saved.getFirst().getErrors() != null && !saved.getFirst().getErrors().isEmpty()))
|
||||
{
|
||||
throw new QException("Device activity could not be updated.");
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve selected devices before the administrator confirms the action.
|
||||
*******************************************************************************/
|
||||
public class MobileDeviceAdminPrepareStep implements BackendStep
|
||||
{
|
||||
/***************************************************************************
|
||||
** Share the native bounded record/filter selection implementation.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
MobileAdminSelection.prepare(input, output, MobileScannersQBitProducer.DEVICE, "deviceId", "device(s)");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Validate every target before device mutation.
|
||||
***************************************************************************/
|
||||
static List<String> resolve(String value) throws QException
|
||||
{
|
||||
return MobileAdminSelection.resolve(value, MobileScannersQBitProducer.DEVICE);
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -28,10 +28,16 @@ public class MobileDeviceAdminStep implements BackendStep
|
||||
{
|
||||
throw new QException("Unknown mobile administration process.");
|
||||
}
|
||||
String id = MobileService.required(input.getValues(), "deviceId", 36);
|
||||
MobilePermissions.require(MobilePermissions.ADMIN);
|
||||
String id = input.getRecords() != null && !input.getRecords().isEmpty()
|
||||
? String.join(",", input.getRecords().stream().map(record -> record.getValueString("id")).toList())
|
||||
: MobileService.required(input.getValues(), "deviceId", 3700);
|
||||
var ids = MobileDeviceAdminPrepareStep.resolve(id);
|
||||
boolean approved = MobileScannersQBitProducer.APPROVE.equals(process);
|
||||
MobileService.setApproval(id, approved);
|
||||
output.addValue("deviceId", id);
|
||||
MobileService.setApproval(ids, approved);
|
||||
output.addValue("deviceId", String.join(",", ids));
|
||||
output.addValue("status", approved ? "APPROVED" : "REVOKED");
|
||||
output.addValue("processedDeviceCount", ids.size());
|
||||
output.addValue("result", ids.size() + (ids.size() == 1 ? " device " : " devices ") + (approved ? "approved." : "revoked."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional integrations consume public attribution without accessing credentials.
|
||||
** Identity is a value, never proof: every use must revalidate current authority.
|
||||
*******************************************************************************/
|
||||
public final class MobileDeviceAuthorization
|
||||
{
|
||||
/***************************************************************************
|
||||
** Static authorization boundary.
|
||||
***************************************************************************/
|
||||
private MobileDeviceAuthorization()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Public identifiers only; no authentication UUID or device secret escapes.
|
||||
***************************************************************************/
|
||||
public record Identity(String actorId, String deviceId, String publicSessionId)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Capture only a verified request or its server-created asynchronous session.
|
||||
** An already bound mobile sign-in cannot fall back to desktop attribution.
|
||||
***************************************************************************/
|
||||
public static Optional<Identity> captureCurrent() throws QException
|
||||
{
|
||||
return MobileSessionService.withActiveSession(() ->
|
||||
{
|
||||
QSession authentication = QContext.getQSession();
|
||||
if(authentication == null || authentication.getUser() == null || authentication.getUser().getIdReference() == null
|
||||
|| authentication.getUuid() == null || authentication.getUuid().isBlank())
|
||||
{
|
||||
throw new QAuthenticationException("Sign in to continue.");
|
||||
}
|
||||
Serializable expected = authentication instanceof VerifiedDeviceSession checked ? checked.checkedAuthority() : VerifiedDeviceSession.authorityOf(authentication);
|
||||
requireCurrent(authentication, expected);
|
||||
QRecord binding = MobileService.get(MobileScannersQBitProducer.SESSION_BINDING, MobileService.hash(authentication.getUuid()));
|
||||
String device = MobilePermissions.REQUEST_DEVICE.get();
|
||||
if(authentication instanceof VerifiedDeviceSession verified)
|
||||
{
|
||||
if(device != null && !device.equals(verified.deviceId()))
|
||||
{
|
||||
throw new QPermissionDeniedException("Device attribution does not match this sign-in.");
|
||||
}
|
||||
device = verified.deviceId();
|
||||
}
|
||||
if(device == null)
|
||||
{
|
||||
String channel = authentication.getClientChannelName();
|
||||
if(binding != null || (channel != null && !"web".equals(channel)))
|
||||
{
|
||||
throw new QPermissionDeniedException("A verified device credential is required.");
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
MobilePermissions.require(MobilePermissions.SCAN);
|
||||
MobileService.requireApproved(device);
|
||||
QRecord session = MobileSessionService.observe(device);
|
||||
binding = MobileService.get(MobileScannersQBitProducer.SESSION_BINDING, MobileService.hash(authentication.getUuid()));
|
||||
if(binding == null || !authentication.getUuid().equals(binding.getValueString("coreSessionUuid"))
|
||||
|| !session.getValueString("id").equals(binding.getValueString("sessionId")))
|
||||
{
|
||||
throw new QPermissionDeniedException("Device session binding could not be verified.");
|
||||
}
|
||||
requireCurrent(authentication, expected);
|
||||
if(!(authentication instanceof VerifiedDeviceSession))
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.setQSession(new VerifiedDeviceSession(authentication, device));
|
||||
}
|
||||
catch(CloneNotSupportedException failure)
|
||||
{
|
||||
throw new QException("Device context could not be retained.", failure);
|
||||
}
|
||||
}
|
||||
return Optional.of(new Identity(MobileService.userId(), device, session.getValueString("id")));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Approval, observation and host hooks may refresh or replace the session;
|
||||
** only the same session with identical authority may be retained or used.
|
||||
***************************************************************************/
|
||||
private static void requireCurrent(QSession authentication, Serializable expected) throws QPermissionDeniedException
|
||||
{
|
||||
if(QContext.getQSession() != authentication || !expected.equals(VerifiedDeviceSession.authorityOf(authentication)))
|
||||
{
|
||||
throw new QPermissionDeniedException("Device authorization changed; sign in again.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A persisted/public DTO must still match this authenticated private binding.
|
||||
***************************************************************************/
|
||||
public static void revalidate(Identity identity) throws QException
|
||||
{
|
||||
executeAuthorized(identity, () -> null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Keep local end/revoke and a protected operation under the same instance lock.
|
||||
** Cross-node atomicity requires a host transactional authorization strategy.
|
||||
***************************************************************************/
|
||||
public static <T> T executeAuthorized(Identity identity, Operation<T> operation) throws QException
|
||||
{
|
||||
if(identity == null)
|
||||
{
|
||||
throw new QPermissionDeniedException("Device attribution no longer matches this sign-in.");
|
||||
}
|
||||
return executeAuthorized(Optional.of(identity), operation);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Empty attribution is allowed only while the authenticated session is unbound.
|
||||
** Retain the same exclusion through the callback's synchronous commits.
|
||||
***************************************************************************/
|
||||
public static <T> T executeAuthorized(Optional<Identity> identity, Operation<T> operation) throws QException
|
||||
{
|
||||
return MobileSessionService.withActiveSession(() ->
|
||||
{
|
||||
Optional<Identity> current = captureCurrent();
|
||||
if(identity == null || !identity.equals(current))
|
||||
{
|
||||
throw new QPermissionDeniedException("Device attribution no longer matches this sign-in.");
|
||||
}
|
||||
return operation.run();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Callback runs while local mobile authorization cannot be changed.
|
||||
***************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface Operation<T>
|
||||
{
|
||||
/***********************************************************************
|
||||
** Perform the authorized operation.
|
||||
***********************************************************************/
|
||||
T run() throws QException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared device-header contract for authenticated mobile operations.
|
||||
*******************************************************************************/
|
||||
abstract class MobileDeviceJsonSpec extends MobileJsonSpec
|
||||
{
|
||||
/***************************************************************************
|
||||
** Document both credential headers without examples containing secrets.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return List.of(new Parameter().withName(MobileScannersRouteProvider.DEVICE_HEADER).withIn(In.HEADER).withRequired(true)
|
||||
.withDescription("Enrolled installation UUID").withSchema(new Schema().withType(Type.STRING).withFormat("uuid").withMaxLength(36)),
|
||||
new Parameter().withName(MobileScannersRouteProvider.TOKEN_HEADER).withIn(In.HEADER).withRequired(true)
|
||||
.withDescription("Private one-time issued device credential; never log").withSchema(new Schema().withType(Type.STRING).withMaxLength(128)));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.query.CriteriaExpansionContext;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.query.SearchTermKind;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.query.VirtualFieldCriteriaExpanderInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Native virtual online field: no stale stored boolean or reconciliation race.
|
||||
*******************************************************************************/
|
||||
public class MobileDeviceOnline extends AbstractPostQueryCustomizer implements VirtualFieldCriteriaExpanderInterface
|
||||
{
|
||||
/***************************************************************************
|
||||
** RC.20 has no virtual dependency hook; include the safe source field before SQL.
|
||||
***************************************************************************/
|
||||
static void includeProjectionDependency(QueryInput query)
|
||||
{
|
||||
if(query.getFieldNamesToInclude() != null && query.getFieldNamesToInclude().contains("online"))
|
||||
{
|
||||
var fields = new HashSet<>(query.getFieldNamesToInclude());
|
||||
fields.add("lastSeenAt");
|
||||
query.setFieldNamesToInclude(fields);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Every read derives presence from the last authenticated server receipt.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records)
|
||||
{
|
||||
Instant cutoff = Instant.now().minusSeconds(15);
|
||||
for(QRecord record : records)
|
||||
{
|
||||
Instant seen = record.getValueInstant("lastSeenAt");
|
||||
record.setValue("online", seen != null && !seen.isBefore(cutoff));
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Database query/count selection uses exactly the same last-seen boundary.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QQueryFilter expand(QTableMetaData table, QFilterCriteria criteria, CriteriaExpansionContext context) throws QException
|
||||
{
|
||||
boolean online;
|
||||
if(criteria.getOperator() == QCriteriaOperator.TRUE || criteria.getOperator() == QCriteriaOperator.FALSE)
|
||||
{
|
||||
online = criteria.getOperator() == QCriteriaOperator.TRUE;
|
||||
}
|
||||
else if((criteria.getOperator() == QCriteriaOperator.EQUALS || criteria.getOperator() == QCriteriaOperator.NOT_EQUALS)
|
||||
&& criteria.getValues() != null && criteria.getValues().size() == 1
|
||||
&& List.of("true", "false").contains(String.valueOf(criteria.getValues().getFirst())))
|
||||
{
|
||||
online = Boolean.parseBoolean(String.valueOf(criteria.getValues().getFirst()));
|
||||
if(criteria.getOperator() == QCriteriaOperator.NOT_EQUALS)
|
||||
{
|
||||
online = !online;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new QBadRequestException("Online supports true/false and boolean equals/not-equals filters.");
|
||||
}
|
||||
Instant cutoff = Instant.now().minusSeconds(15);
|
||||
if(online)
|
||||
{
|
||||
return new QQueryFilter().withCriteria("lastSeenAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, cutoff);
|
||||
}
|
||||
return new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria("lastSeenAt", QCriteriaOperator.LESS_THAN, cutoff).withCriteria("lastSeenAt", QCriteriaOperator.IS_BLANK);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Free-text search does not infer a device connectivity filter.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QQueryFilter expandSearchTerm(QTableMetaData table, String term, SearchTermKind kind, CriteriaExpansionContext context)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restrict ordinary record editing to the three administrator profile fields.
|
||||
*******************************************************************************/
|
||||
public class MobileDeviceProfileCustomizer extends AbstractPreUpdateCustomizer
|
||||
{
|
||||
private static final Set<String> EDITABLE = Set.of("shortName", "displayName", "description");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Both permission entry and persistence lifecycle apply this same allowlist.
|
||||
***************************************************************************/
|
||||
static void sanitize(List<QRecord> records) throws QException
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.ADMIN);
|
||||
MobileSessionService.requireNotEnded();
|
||||
if(records == null)
|
||||
{
|
||||
throw new QBadRequestException("Device records are required.");
|
||||
}
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord stored = MobileService.get(MobileScannersQBitProducer.DEVICE, record.getValueString("id"));
|
||||
if(stored == null)
|
||||
{
|
||||
throw new QBadRequestException("Device not found.");
|
||||
}
|
||||
for(String key : new ArrayList<>(record.getValues().keySet()))
|
||||
{
|
||||
if(!"id".equals(key) && !EDITABLE.contains(key))
|
||||
{
|
||||
if(!Objects.equals(record.getValue(key), stored.getValue(key)))
|
||||
{
|
||||
throw new QPermissionDeniedException("Only device short name, display name and description can be edited.");
|
||||
}
|
||||
record.getValues().remove(key);
|
||||
}
|
||||
}
|
||||
if(record.getValues().containsKey("shortName"))
|
||||
{
|
||||
record.setValue("shortName", canonical(MobileService.required(record.getValues(), "shortName", 32)));
|
||||
}
|
||||
for(String key : List.of("displayName", "description"))
|
||||
{
|
||||
MobileService.optional(record.getValues(), key, "description".equals(key) ? 2000 : 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Canonical values have a database unique index, including concurrent edits.
|
||||
***************************************************************************/
|
||||
static String canonical(String value) throws QBadRequestException
|
||||
{
|
||||
String canonical = value.trim().toLowerCase(Locale.ROOT);
|
||||
if(!canonical.matches("[a-z0-9][a-z0-9-]{2,31}"))
|
||||
{
|
||||
throw new QBadRequestException("Short name must be 3 to 32 lowercase letters, digits or hyphens, starting with a letter or digit.");
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Internal approval and heartbeat writes remain distinct from generic edits.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records) throws QException
|
||||
{
|
||||
if(!Boolean.TRUE.equals(MobilePermissions.INTERNAL.get()))
|
||||
{
|
||||
sanitize(records);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.CompositeWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextBlockData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextValues;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Native refreshing status distinguishes approval, connectivity and location age.
|
||||
*******************************************************************************/
|
||||
public class MobileDeviceStatusWidget extends AbstractWidgetRenderer
|
||||
{
|
||||
/***************************************************************************
|
||||
** Only administrators can view live device observations.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.ADMIN);
|
||||
String id = MobileService.required(input.getQueryParams(), "id", 36);
|
||||
var device = MobileService.get(MobileScannersQBitProducer.DEVICE, id);
|
||||
if(device == null)
|
||||
{
|
||||
throw new QBadRequestException("Device not found.");
|
||||
}
|
||||
Instant now = Instant.now();
|
||||
Instant seen = device.getValueInstant("lastSeenAt");
|
||||
CompositeWidgetData data = new CompositeWidgetData().withLayout(CompositeWidgetData.Layout.FLEX_COLUMN);
|
||||
add(data, "Approval Status: " + device.getValueString("status"));
|
||||
add(data, "Connection: " + (seen != null && !seen.plusSeconds(15).isBefore(now) ? "Online" : "Offline")
|
||||
+ " | Last seen: " + age(seen, now));
|
||||
add(data, "Location status: " + Objects.toString(device.getValueString("locationStatus"), "UNAVAILABLE"));
|
||||
Instant received = device.getValueInstant("locationReceivedAt");
|
||||
if(received == null)
|
||||
{
|
||||
add(data, "No location has been reported.");
|
||||
}
|
||||
else
|
||||
{
|
||||
add(data, "Last known location: " + device.getValueString("latitude") + ", " + device.getValueString("longitude")
|
||||
+ " | Accuracy: " + device.getValueString("accuracyMeters") + " meters");
|
||||
add(data, "Location received: " + age(received, now) + " | Captured at: " + device.getValueString("locationCapturedAt"));
|
||||
}
|
||||
return new RenderWidgetOutput(data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Server receipt age remains meaningful despite device clock skew.
|
||||
***************************************************************************/
|
||||
private static String age(Instant timestamp, Instant now)
|
||||
{
|
||||
return timestamp == null ? "Never" : Math.max(0, Duration.between(timestamp, now).getSeconds()) + " seconds ago";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Standard typed TEXT blocks avoid generated HTML and custom wire objects.
|
||||
***************************************************************************/
|
||||
private static void add(CompositeWidgetData data, String text)
|
||||
{
|
||||
data.withBlock(new TextBlockData().withBlockId("status" + data.getBlocks().size()).withValues(new TextValues(text)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticated enrollment issues one private device credential exactly once.
|
||||
*******************************************************************************/
|
||||
public class MobileEnrollSpec extends MobileJsonSpec
|
||||
{
|
||||
/***************************************************************************
|
||||
** Stable enrollment operation.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return new BasicOperation().withHttpMethod(HttpMethod.POST).withPath(MobileScannersUiApi.PREFIX + "/devices/enroll")
|
||||
.withOperationId("mobileScannersEnrollDevice").withShortSummary("Enroll a handheld")
|
||||
.withLongDescription("Requires a QQQ user session with mobile.scan permission. Returns a secret once; store privately and never log. Device remains PENDING until an administrator approves it. Existing installation IDs return 409 without rotating credentials.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Versioned enrollment response.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return "MobileScannersEnrollmentV1";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Versioned bounded enrollment input.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return new Schema().withRefToSchema("MobileScannersEnrollRequestV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** New enrollment creates a device.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public BasicResponse defineBasicSuccessResponse()
|
||||
{
|
||||
return new BasicResponse(HttpStatus.CREATED, "Pending device and one-time credential", getResponseSchemaName());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Preserve the 1.0 character limit for international device descriptions.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected int maximumBodyBytes()
|
||||
{
|
||||
return 16384;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A bounded UTF-8 request must also meet the existing character contract.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public JsonInput buildInput(Context context) throws Exception
|
||||
{
|
||||
JsonInput input = super.buildInput(context);
|
||||
if(context.body().length() > 4096)
|
||||
{
|
||||
throw new QBadRequestException("The enrollment request exceeds 4096 characters.");
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Runtime status matches the published contract.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void handleOutput(Context context, JsonOutput output) throws Exception
|
||||
{
|
||||
context.status(HttpStatus.CREATED);
|
||||
super.handleOutput(context, output);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Use the existing permission-checked QQQ service and tables.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return MobileService.enroll(input.getBody());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard semantic JSON transport and safe structured errors.
|
||||
*******************************************************************************/
|
||||
abstract class MobileJsonSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// QLogger adds user identity automatically. Keep boundary diagnostics //
|
||||
// entirely static without changing authentication context or log settings. //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
private static final Logger LOG = LogManager.getLogger(MobileJsonSpec.class);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bound enrollment input before parsing and reject non-JSON payloads.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public JsonInput buildInput(Context context) throws Exception
|
||||
{
|
||||
context.header("Cache-Control", "no-store");
|
||||
if(defineRequestBodySchema() != null)
|
||||
{
|
||||
if(context.bodyAsBytes().length > maximumBodyBytes())
|
||||
{
|
||||
throw new QBadRequestException("The request exceeds the supported size.");
|
||||
}
|
||||
if(context.contentType() == null || !context.contentType().toLowerCase(Locale.ROOT).startsWith("application/json"))
|
||||
{
|
||||
throw new QBadRequestException("The request body must be JSON.");
|
||||
}
|
||||
}
|
||||
return super.buildInput(context);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Frame upload alone overrides the normal small JSON payload limit.
|
||||
***************************************************************************/
|
||||
protected int maximumBodyBytes()
|
||||
{
|
||||
return 4096;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Authentication errors must never expose credential or storage details.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected void handleException(Context context, Exception exception)
|
||||
{
|
||||
context.header("Cache-Control", "no-store").contentType("application/json");
|
||||
if(exception instanceof QAuthenticationException)
|
||||
{
|
||||
QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.Code.UNAUTHORIZED, "Sign in to continue.");
|
||||
}
|
||||
else if(exception instanceof QPermissionDeniedException)
|
||||
{
|
||||
QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.Code.FORBIDDEN, "User or device is not authorized.");
|
||||
}
|
||||
else if(exception instanceof MobileService.EnrollmentConflictException || exception instanceof MobileScreenService.FrameConflictException)
|
||||
{
|
||||
QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.Code.CONFLICT, exception.getMessage());
|
||||
}
|
||||
else if(exception instanceof QBadRequestException)
|
||||
{
|
||||
QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.Code.BAD_REQUEST, exception.getMessage());
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG.warn("{\"message\":\"Mobile request failed\",\"category\":\"unexpected-failure\",\"operation\":\"mobile-json\"}");
|
||||
QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.Code.INTERNAL_SERVER_ERROR, "The mobile request could not be completed.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** All routes use the core machine-readable error envelope.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "User sign-in required", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission or device credential denied", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.CONFLICT, "Installation already enrolled", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Request failed", BasicErrorResponseV1.class.getSimpleName()));
|
||||
}
|
||||
}
|
||||
+31
-1
@@ -13,6 +13,7 @@ import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
@@ -29,12 +30,18 @@ public class MobilePermissions implements CustomPermissionChecker
|
||||
static final ThreadLocal<String> REQUEST_DEVICE = new ThreadLocal<>();
|
||||
static final ThreadLocal<Boolean> INTERNAL = new ThreadLocal<>();
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Clone before attaching request identity: stored sessions may be shared.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput action, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(metadata instanceof QTableMetaData && MobileScannersQBitProducer.DEVICE.equals(metadata.getName()) && action instanceof QueryInput query)
|
||||
{
|
||||
MobileDeviceOnline.includeProjectionDependency(query);
|
||||
}
|
||||
if(Boolean.TRUE.equals(INTERNAL.get()))
|
||||
{
|
||||
return;
|
||||
@@ -56,6 +63,7 @@ public class MobilePermissions implements CustomPermissionChecker
|
||||
throw new QPermissionDeniedException("An approved device credential is required.");
|
||||
}
|
||||
MobileService.requireApproved(deviceId);
|
||||
MobileSessionService.observe(deviceId);
|
||||
if(action instanceof RunProcessInput)
|
||||
{
|
||||
QContext.setQSession(new VerifiedDeviceSession(QContext.getQSession(), deviceId));
|
||||
@@ -70,13 +78,35 @@ public class MobilePermissions implements CustomPermissionChecker
|
||||
require(ADMIN);
|
||||
if(metadata instanceof QTableMetaData table)
|
||||
{
|
||||
if(MobileScannersQBitProducer.CREDENTIAL.equals(table.getName()) || action instanceof InsertInput || action instanceof UpdateInput || action instanceof DeleteInput)
|
||||
if(MobileScannersQBitProducer.DEVICE.equals(table.getName()))
|
||||
{
|
||||
try
|
||||
{
|
||||
if(action instanceof UpdateInput update)
|
||||
{
|
||||
MobileSessionService.requireNotEnded();
|
||||
if(update.getRecords() != null)
|
||||
{
|
||||
MobileDeviceProfileCustomizer.sanitize(update.getRecords());
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(Exception failure)
|
||||
{
|
||||
throw new QPermissionDeniedException("Device profile or online status could not be verified.");
|
||||
}
|
||||
}
|
||||
if(MobileScannersQBitProducer.CREDENTIAL.equals(table.getName()) || MobileScannersQBitProducer.SESSION_BINDING.equals(table.getName())
|
||||
|| action instanceof InsertInput || action instanceof UpdateInput || action instanceof DeleteInput)
|
||||
{
|
||||
throw new QPermissionDeniedException("Use the mobile device processes; direct writes and credential reads are forbidden.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit authenticated user and permission, even for direct backend calls.
|
||||
***************************************************************************/
|
||||
File renamed without changes.
+11
-5
@@ -6,11 +6,13 @@
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -24,11 +26,15 @@ public class MobileScanStep implements BackendStep
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
QRecord saved = MobileService.capture(input.getValues());
|
||||
output.addValue("acknowledgedEventId", saved.getValueString("eventId"));
|
||||
output.addValue("scanId", saved.getValueString("id"));
|
||||
Map<String, Serializable> context = MobileScannerActions.capture(input);
|
||||
output.addValue("scanContext", new HashMap<>(context));
|
||||
output.addValue("acknowledgedEventId", context.get("eventId"));
|
||||
output.addValue("scanId", context.get("scanId"));
|
||||
output.addValue("scanSaved", true);
|
||||
output.addValue(MobileScannersQBitProducer.WIDGET, new MobileScannerWidget.ScannerWidgetData());
|
||||
output.getProcessState().setNextStepName("scan");
|
||||
if(input.getProcess().getStepList().getLast().getName().equals(input.getStepName()))
|
||||
{
|
||||
output.getProcessState().setNextStepName("scan");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist one scan from a registered backend process using verified authority.
|
||||
*******************************************************************************/
|
||||
public final class MobileScannerActions
|
||||
{
|
||||
/***************************************************************************
|
||||
** Static process action; no independent identity or event framework.
|
||||
***************************************************************************/
|
||||
private MobileScannerActions()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Return immutable accepted context; consume transient inputs before reuse.
|
||||
** Host permission checkers bind with captureCurrent before async dispatch.
|
||||
***************************************************************************/
|
||||
public static Map<String, Serializable> capture(RunBackendStepInput input) throws QException
|
||||
{
|
||||
return capture(input, MobileScannersQBitProducer.APP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Attribute host-owned actions to a configured app, never caller values.
|
||||
***************************************************************************/
|
||||
public static Map<String, Serializable> capture(RunBackendStepInput input, String appName) throws QException
|
||||
{
|
||||
if(input.getProcess() == null || appName == null || appName.isBlank() || QContext.getQInstance().getApp(appName) == null)
|
||||
{
|
||||
throw new QException("A registered scanner process and application are required.");
|
||||
}
|
||||
if((Boolean.TRUE.equals(input.getValue("scanSaved")) || input.getValue("acknowledgedEventId") != null)
|
||||
&& !(input.getValue("scanContext") instanceof Map<?, ?>))
|
||||
{
|
||||
throw new QException("Restart the scanner process to use typed scan context. Leave or cancel this process, then reopen it.");
|
||||
}
|
||||
Map<String, Serializable> submitted = new HashMap<>(input.getValues());
|
||||
input.getValues().keySet().removeAll(List.of("barcode", "symbology", "scanSource", "eventId", "capturedAt", "receivedAt",
|
||||
"aimIdentifier", "rawDataBase64",
|
||||
"scanContext", "scanSaved", "scanId", "acknowledgedEventId", "userId", "deviceId", "appId", "processName"));
|
||||
if(MobileDeviceAuthorization.captureCurrent().isEmpty())
|
||||
{
|
||||
throw new QPermissionDeniedException("An approved device credential is required.");
|
||||
}
|
||||
QRecord saved = MobileService.capture(submitted, input.getProcess().getName(), appName);
|
||||
Map<String, Serializable> context = new HashMap<>();
|
||||
for(String field : List.of("barcode", "symbology", "scanSource", "eventId", "deviceId", "userId", "appId", "processName"))
|
||||
{
|
||||
context.put(field, saved.getValueString(field));
|
||||
}
|
||||
for(String field : List.of("aimIdentifier", "rawDataBase64"))
|
||||
{
|
||||
// Optional metadata: omit the key entirely for older clients and manual capture.
|
||||
String value = saved.getValueString(field);
|
||||
if(value != null)
|
||||
{
|
||||
context.put(field, value);
|
||||
}
|
||||
}
|
||||
context.put("capturedAt", saved.getValueInstant("capturedAt").toString());
|
||||
context.put("receivedAt", saved.getValueInstant("receivedAt").toString());
|
||||
context.put("scanId", saved.getValueString("id"));
|
||||
return Map.copyOf(context);
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -30,6 +30,8 @@ public class MobileScannerWidget extends AbstractWidgetRenderer
|
||||
return new RenderWidgetOutput(new ScannerWidgetData());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Native widget wire shape. RC20's closed WidgetBlock enum predates SCANNER,
|
||||
** so this extension supplies its wire fields directly as QWidgetData.
|
||||
@@ -45,6 +47,8 @@ public class MobileScannerWidget extends AbstractWidgetRenderer
|
||||
return "composite";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Android's native DataWedge-enabled renderer.
|
||||
***********************************************************************/
|
||||
@@ -53,13 +57,16 @@ public class MobileScannerWidget extends AbstractWidgetRenderer
|
||||
return "COMPOSITE";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** One scanner component; the surrounding process owns its actions.
|
||||
***********************************************************************/
|
||||
public List<Map<String, Serializable>> getSubBlocks()
|
||||
{
|
||||
return List.of(Map.of("blockId", "barcodeScanner", "blockType", "SCANNER", "values",
|
||||
new java.util.HashMap<>(Map.of("title", "Scan a barcode", "instructions", "Scan with the trigger or enter a barcode. Saved scans appear in Mobile Scanner Administration."))));
|
||||
new java.util.HashMap<>(Map.of("title", "Scan a barcode", "instructions", "Scan with the trigger or enter a barcode. Saved scans appear in Mobile Scanner Administration.",
|
||||
"manualLabel", "Manual barcode", "submitLabel", "Submit barcode", "maxLength", 4096))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Storage backend supplied by the host application.
|
||||
*******************************************************************************/
|
||||
public class MobileScannersQBitConfig implements QBitConfig
|
||||
{
|
||||
private String backendName;
|
||||
private String displayName = "QQQ Mobile Scanners";
|
||||
private String primaryColor = "#1565C0";
|
||||
private String logoUrl;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Public customer name shown by the shared mobile client.
|
||||
***************************************************************************/
|
||||
public String getDisplayName()
|
||||
{
|
||||
return displayName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Plain display text only, never markup or control characters.
|
||||
***************************************************************************/
|
||||
public MobileScannersQBitConfig withDisplayName(String displayName)
|
||||
{
|
||||
if(displayName == null || displayName.isBlank() || displayName.length() > 80 || !displayName.equals(displayName.strip())
|
||||
|| displayName.chars().anyMatch(value -> Character.isISOControl(value) || value == '<' || value == '>'))
|
||||
{
|
||||
throw new IllegalArgumentException("Mobile displayName must be 1-80 trimmed plain-text characters.");
|
||||
}
|
||||
this.displayName = displayName;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Opaque RGB theme color; clients choose readable foreground colors.
|
||||
***************************************************************************/
|
||||
public String getPrimaryColor()
|
||||
{
|
||||
return primaryColor;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Restrict branding to a six-digit hex color, not arbitrary CSS.
|
||||
***************************************************************************/
|
||||
public MobileScannersQBitConfig withPrimaryColor(String primaryColor)
|
||||
{
|
||||
if(primaryColor == null || !primaryColor.matches("#[0-9a-fA-F]{6}"))
|
||||
{
|
||||
throw new IllegalArgumentException("Mobile primaryColor must use #RRGGBB format.");
|
||||
}
|
||||
this.primaryColor = primaryColor.toUpperCase(Locale.ROOT);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Optional public logo; no authenticated assets or embedded data.
|
||||
***************************************************************************/
|
||||
public String getLogoUrl()
|
||||
{
|
||||
return logoUrl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The client loads an HTTPS image without forwarding QQQ credentials.
|
||||
***************************************************************************/
|
||||
public MobileScannersQBitConfig withLogoUrl(String logoUrl)
|
||||
{
|
||||
if(logoUrl != null)
|
||||
{
|
||||
URI uri;
|
||||
try
|
||||
{
|
||||
uri = URI.create(logoUrl);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw new IllegalArgumentException("Mobile logoUrl must be a public HTTPS URL.");
|
||||
}
|
||||
if(logoUrl.length() > 2048 || !"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null
|
||||
|| uri.getUserInfo() != null || uri.getFragment() != null || uri.getPort() > 65535)
|
||||
{
|
||||
throw new IllegalArgumentException("Mobile logoUrl must be a public HTTPS URL without credentials or a fragment.");
|
||||
}
|
||||
}
|
||||
this.logoUrl = logoUrl;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read the configured backend.
|
||||
***************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
return backendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Configure the host-owned durable backend.
|
||||
***************************************************************************/
|
||||
public MobileScannersQBitConfig withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fail startup when the backend is absent.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance instance, List<String> errors)
|
||||
{
|
||||
if(backendName == null || instance.getBackend(backendName) == null)
|
||||
{
|
||||
errors.add("Mobile scanners requires an existing backendName.");
|
||||
}
|
||||
if(!Boolean.TRUE.equals(instance.getEnforcePermissionsInCoreActions()))
|
||||
{
|
||||
errors.add("Mobile scanners requires enforcePermissionsInCoreActions=true to protect device credentials and scan records.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-5
@@ -7,6 +7,7 @@ package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
@@ -26,6 +27,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -42,8 +44,14 @@ public class MobileScannersQBitProducer implements QBitProducer
|
||||
public static final String WIDGET = "mobileScanner";
|
||||
public static final String APP = "mobileScanApp";
|
||||
public static final String ADMIN_APP = "mobileScannerAdmin";
|
||||
public static final String SESSION = "mobileSession";
|
||||
public static final String SESSION_BINDING = "mobileSessionBinding";
|
||||
public static final String SESSION_END = "mobileSessionEnd";
|
||||
public static final String SESSION_SCREEN = "mobileSessionScreen";
|
||||
private MobileScannersQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Configure the storage backend.
|
||||
***************************************************************************/
|
||||
@@ -53,6 +61,8 @@ public class MobileScannersQBitProducer implements QBitProducer
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Install once per QInstance; names are intentionally stable for clients.
|
||||
***************************************************************************/
|
||||
@@ -66,21 +76,33 @@ public class MobileScannersQBitProducer implements QBitProducer
|
||||
config.validate(instance);
|
||||
instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-mobile-scanners")
|
||||
.withVersion("0.1.0-SNAPSHOT").withNamespace(namespace).withConfig(config));
|
||||
MobileScannersUiApi.register(instance);
|
||||
QTableMetaData device = table(DEVICE, "Mobile Devices", 36);
|
||||
for(String field : List.of("installationId", "displayName", "manufacturer", "model", "osVersion", "appVersion", "status", "enrolledBy", "approvedBy"))
|
||||
{
|
||||
device.withField(text(field, 200));
|
||||
}
|
||||
device.withField(time("enrolledAt")).withField(time("approvedAt"));
|
||||
device.withField(text("shortName", 32).withIsRequired(true)).withField(text("description", 2000))
|
||||
.withUniqueKey(new UniqueKey("shortName"))
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(MobileDeviceProfileCustomizer.class));
|
||||
MobileDeviceActivity.addFields(device);
|
||||
for(var field : device.getFields().values())
|
||||
{
|
||||
field.setIsEditable(List.of("shortName", "displayName", "description").contains(field.getName()));
|
||||
}
|
||||
device.getField("status").setLabel("Approval Status");
|
||||
instance.addTable(device);
|
||||
instance.addTable(table(CREDENTIAL, "Device Credentials", 36).withField(text("tokenHash", 64).withIsHidden(true)));
|
||||
instance.addTable(table(CREDENTIAL, "Device Credentials", 36).withField(text("tokenHash", 64).withIsHidden(true).withIsSensitive(true)));
|
||||
QTableMetaData scan = table(SCAN, "Barcode Scans", 64);
|
||||
for(String field : List.of("deviceId", "eventId", "userId", "appId", "processName", "symbology", "scanSource", "manufacturer", "model", "osVersion", "appVersion"))
|
||||
{
|
||||
scan.withField(text(field, 200));
|
||||
}
|
||||
scan.withField(text("barcode", 4096)).withField(time("capturedAt")).withField(time("receivedAt"));
|
||||
scan.withField(text("aimIdentifier", MobileService.AIM_IDENTIFIER_LENGTH)).withField(text("rawDataBase64", MobileService.RAW_DATA_MAXIMUM_CHARACTERS));
|
||||
instance.addTable(scan);
|
||||
MobileSessionMetadata.register(instance, config.getBackendName());
|
||||
instance.addWidget(new QWidgetMetaData().withName(WIDGET).withLabel("Scan a barcode").withType("composite")
|
||||
.withCodeReference(new QCodeReference(MobileScannerWidget.class)).withPermissionRules(rules()));
|
||||
instance.addProcess(new QProcessMetaData().withName(CAPTURE).withLabel("Scan Capture").withPermissionRules(rules())
|
||||
@@ -91,19 +113,27 @@ public class MobileScannersQBitProducer implements QBitProducer
|
||||
for(String process : List.of(APPROVE, REVOKE))
|
||||
{
|
||||
instance.addProcess(new QProcessMetaData().withName(process).withLabel(process.equals(APPROVE) ? "Approve Device" : "Revoke Device")
|
||||
.withTableName(DEVICE).withPermissionRules(rules())
|
||||
.withTableName(DEVICE).withMaxInputRecords(100).withPermissionRules(rules())
|
||||
.withStep(new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(MobileDeviceAdminPrepareStep.class)))
|
||||
.withStep(new QFrontendStepMetaData().withName("input").withLabel("Device")
|
||||
.withFormField(text("deviceId", 36).withLabel("Device ID").withIsRequired(true))
|
||||
.withViewField(text("selectionSummary", 30000).withLabel("Devices to process"))
|
||||
.withFormField(text("deviceId", 3700).withLabel("Device IDs").withIsRequired(true))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)))
|
||||
.withStep(new QBackendStepMetaData().withName("apply").withCode(new QCodeReference(MobileDeviceAdminStep.class))));
|
||||
.withStep(new QBackendStepMetaData().withName("apply").withCode(new QCodeReference(MobileDeviceAdminStep.class)))
|
||||
.withStep(new QFrontendStepMetaData().withName("result").withLabel("Result")
|
||||
.withViewField(text("result", 200).withLabel("Result"))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))));
|
||||
}
|
||||
instance.addApp(new QAppMetaData().withName(APP).withLabel("Scan Capture").withIcon(new QIcon("qr_code_scanner"))
|
||||
.withPermissionRules(rules()).withSection(new QAppSection().withName("scan").withLabel("Scanning").withProcesses(List.of(CAPTURE))));
|
||||
instance.addApp(new QAppMetaData().withName(ADMIN_APP).withLabel("Mobile Scanner Administration").withIcon(new QIcon("devices"))
|
||||
.withPermissionRules(rules()).withSection(new QAppSection().withName("devices").withLabel("Devices and Scans")
|
||||
.withTables(List.of(DEVICE, SCAN)).withProcesses(List.of(APPROVE, REVOKE))));
|
||||
.withTables(List.of(DEVICE, SESSION, SCAN)).withProcesses(List.of(APPROVE, REVOKE, SESSION_END))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Every table action is guarded, including generic UI API mutations.
|
||||
***************************************************************************/
|
||||
@@ -113,6 +143,8 @@ public class MobileScannersQBitProducer implements QBitProducer
|
||||
.withPrimaryKeyField("id").withField(text("id", idLength).withIsRequired(true)).withPermissionRules(rules());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Common guarded metadata rules.
|
||||
***************************************************************************/
|
||||
@@ -122,6 +154,8 @@ public class MobileScannersQBitProducer implements QBitProducer
|
||||
.withCustomPermissionChecker(new QCodeReference(MobilePermissions.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bound all text before it reaches persistence.
|
||||
***************************************************************************/
|
||||
@@ -130,6 +164,8 @@ public class MobileScannersQBitProducer implements QBitProducer
|
||||
return new QFieldMetaData(name, QFieldType.STRING).withMaxLength(length);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Native timestamp metadata.
|
||||
***************************************************************************/
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils;
|
||||
import io.javalin.apibuilder.ApiBuilder;
|
||||
import io.javalin.apibuilder.EndpointGroup;
|
||||
import io.javalin.http.Context;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Request-scoped device credentials for registered native QQQ UI APIs.
|
||||
*******************************************************************************/
|
||||
public class MobileScannersRouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// QLogger adds user identity automatically. Keep boundary diagnostics //
|
||||
// entirely static without changing authentication context or log settings. //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
private static final Logger LOG = LogManager.getLogger(MobileScannersRouteProvider.class);
|
||||
|
||||
public static final String DEVICE_HEADER = "X-QQQ-Device-Id";
|
||||
public static final String TOKEN_HEADER = "X-QQQ-Device-Token";
|
||||
private QInstance instance;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Install on the same QJavalinServer that serves native QQQ routes.
|
||||
***************************************************************************/
|
||||
public MobileScannersRouteProvider(QInstance instance)
|
||||
{
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Support host metadata refresh.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void setQInstance(QInstance instance)
|
||||
{
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Native metadata, widget and process calls carry the same device headers.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public EndpointGroup getJavalinEndpointGroup()
|
||||
{
|
||||
return () ->
|
||||
{
|
||||
ApiBuilder.before("/qqq/*", this::bindDevice);
|
||||
ApiBuilder.after("/qqq/*", context -> MobilePermissions.REQUEST_DEVICE.remove());
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The shared device headers also belong to other extensions' own device
|
||||
** registries. Only a request for another extension's route (not scanner
|
||||
** routes, and not generic metadata/process/widget calls) from a distinct
|
||||
** registered client channel is left to that extension's authenticator. The
|
||||
** channel is attribution that QQQ already validated against its registry;
|
||||
** it grants nothing here: no scanner device is bound, so scanner capture
|
||||
** still requires its own verified credential, and Android stays verified.
|
||||
***************************************************************************/
|
||||
private boolean isForeignExtensionClient(Context context)
|
||||
{
|
||||
String path = context.path();
|
||||
if(!path.startsWith("/qqq/v1/qbit/") || path.startsWith("/qqq/v1" + MobileScannersUiApi.PREFIX + "/"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var session = QContext.getQSession();
|
||||
String channel = session == null ? null : session.getClientChannelName();
|
||||
return channel != null && session.getCredentialScope() == null && !"android".equals(channel) && !"web".equals(channel)
|
||||
&& ClientChannelsMetaData.of(instance).isAllowedChannel(channel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Verify credentials before native handlers; authentication remains QQQ's.
|
||||
***************************************************************************/
|
||||
private void bindDevice(Context context) throws Exception
|
||||
{
|
||||
MobilePermissions.REQUEST_DEVICE.remove();
|
||||
if(context.path().endsWith("/widget/mobileSessionScreen") || context.path().endsWith("/widget/mobileDeviceStatus"))
|
||||
{
|
||||
context.header("Cache-Control", "no-store");
|
||||
}
|
||||
String id = context.header(DEVICE_HEADER);
|
||||
String token = context.header(TOKEN_HEADER);
|
||||
if("/qqq/v1/logout".equals(context.path()))
|
||||
{
|
||||
try
|
||||
{
|
||||
ExecutorSessionUtils.setupSession(context, instance);
|
||||
MobileSessionService.recordLogout();
|
||||
}
|
||||
catch(Exception ignored)
|
||||
{
|
||||
// Core logout must still clear an expired session if history cannot be updated.
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(Set.of("/qqq/v1/version", "/qqq/v1/openapi.json", "/qqq/v1/openapi.yaml", "/qqq/v1/metaData/authentication",
|
||||
"/qqq/v1/manageSession", "/qqq/v1/logout", "/qqq/v1" + MobileScannersUiApi.PREFIX + "/branding",
|
||||
"/qqq/v1" + MobileScannersUiApi.PREFIX + "/status", "/qqq/v1" + MobileScannersUiApi.PREFIX + "/devices/enroll").contains(context.path()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(id == null && token == null && context.cookie("sessionUUID") == null && context.cookie("sessionId") == null && context.header("Authorization") == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
ExecutorSessionUtils.setupSession(context, instance);
|
||||
MobileSessionService.requireNotEnded();
|
||||
if(id == null && token == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(isForeignExtensionClient(context))
|
||||
{
|
||||
return;
|
||||
}
|
||||
QRecord device = MobileService.authenticateDevice(id, token);
|
||||
MobilePermissions.REQUEST_DEVICE.set(device.getValueString("id"));
|
||||
if(!("/qqq/v1" + MobileScannersUiApi.PREFIX + "/sessions/heartbeat").equals(context.path().replaceFirst("/+$", "")))
|
||||
{
|
||||
// Heartbeats authorize approval before creating or advancing activity.
|
||||
MobileSessionService.observe(device.getValueString("id"));
|
||||
}
|
||||
}
|
||||
catch(QAuthenticationException e)
|
||||
{
|
||||
context.contentType("application/json");
|
||||
QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.Code.UNAUTHORIZED, "Sign in to continue.");
|
||||
context.skipRemainingHandlers();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
if(!(e instanceof QPermissionDeniedException) && !(e instanceof QBadRequestException))
|
||||
{
|
||||
LOG.warn("{\"message\":\"Device authorization failed\",\"category\":\"unexpected-failure\",\"operation\":\"bind-device\"}");
|
||||
}
|
||||
context.contentType("application/json");
|
||||
QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.Code.FORBIDDEN, "Invalid device credential.");
|
||||
context.skipRemainingHandlers();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned JSON shapes for transport and native SCANNER widget semantics.
|
||||
*******************************************************************************/
|
||||
public final class MobileScannersSchemas
|
||||
{
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private MobileScannersSchemas()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Shared components are also merged into the core UI API OpenAPI document.
|
||||
***************************************************************************/
|
||||
public static Map<String, Schema> components()
|
||||
{
|
||||
Map<String, Schema> schemas = new TreeMap<>();
|
||||
schemas.put("MobileScannersBrandingV1", object("Customer appearance; displayName and primaryColor are always present; logoUrl is optional.", Map.of(
|
||||
"displayName", text(80), "primaryColor", text(7).withDescription("Six-digit CSS hex color, including #."),
|
||||
"logoUrl", text(2048).withFormat("uri").withDescription("Optional HTTPS image URL, no credentials or fragment.")), "displayName", "primaryColor"));
|
||||
schemas.put("MobileScannersStatusV1", object("Connectivity only; status is always present.", Map.of(
|
||||
"status", text(2).withEnumValues(List.of("ok"))), "status"));
|
||||
schemas.put("MobileScannersEnrollRequestV1", object("installationId is required. Other strings are optional. JSON body maximum is 4096 characters.", Map.of(
|
||||
"installationId", uuid(), "displayName", text(200), "manufacturer", text(200), "model", text(200),
|
||||
"osVersion", text(200), "appVersion", text(200), "shortName", text(32), "description", text(2000)), "installationId"));
|
||||
schemas.put("MobileScannersEnrollmentV1", object("All properties present. deviceToken is a secret returned only once; never log or include it in metadata/process values.", Map.of(
|
||||
"deviceId", uuid(), "deviceToken", text(128), "status", status(), "shortName", text(32), "displayName", text(200), "description", text(2000)), "deviceId", "deviceToken", "status"));
|
||||
schemas.put("MobileScannersDeviceV1", object("All properties present. No credential material is returned.", Map.of(
|
||||
"deviceId", uuid(), "displayName", text(200), "status", status(), "shortName", text(32), "description", text(2000)), "deviceId", "displayName", "status"));
|
||||
schemas.put("MobileScannersScannerValuesV1", object("SCANNER presentation values. Optional manualLabel defaults to Manual barcode; submitLabel defaults to Submit barcode; maxLength defaults to 4096. Extension 1.4 adds acknowledgmentLabel (default Saved scan) and manualEntryEnabled (default true). Presentation never changes acknowledgment requirements. Unknown additive properties may be ignored.", Map.of(
|
||||
"title", text(200), "instructions", text(2000), "manualLabel", text(200), "submitLabel", text(200),
|
||||
"acknowledgmentLabel", text(80).withDescription("Trimmed nonempty single-line plain text, without controls, formatting characters or markup. For example Print queued; this does not mean physical output completed."),
|
||||
"manualEntryEnabled", new Schema().withType(Type.BOOLEAN).withDescription("Whether to show manual entry controls. False does not disable hardware scanning or relax backend validation."),
|
||||
"maxLength", new ScannerMaximumSchema().withType(Type.INTEGER).withDescription("Inclusive supported range 1..4096; reject invalid server values.")), "title", "instructions"));
|
||||
schemas.put("MobileScannersScannerBlockV1", object("SCANNER child block of a core COMPOSITE widget, returned by core process/widget operations. Requires mobile-scanners extension major 1. All properties present; do not infer this contract from process or widget names.", Map.of(
|
||||
"blockId", text(200), "blockType", text(7).withEnumValues(List.of("SCANNER")),
|
||||
"values", ref("MobileScannersScannerValuesV1")), "blockId", "blockType", "values"));
|
||||
schemas.put("MobileScannersScanEventV1", object("Values submitted to the current core process frontend step. barcode, eventId and capturedAt required. eventId remains unchanged on retry; identity comes only from verified user/device context, never these values. Barcode is a nonempty string, preserving leading zeroes.", Map.of(
|
||||
"barcode", text(4096), "eventId", uuid(), "capturedAt", text(40).withFormat("date-time"),
|
||||
"symbology", text(200).withDescription("Exact decoder identifier, never inferred from barcode text. If absent or blank, the server uses MANUAL for scanSource manual, otherwise UNKNOWN."),
|
||||
"scanSource", text(200).withDescription("Capture method, for example datawedge, camera or manual. Absent or blank becomes UNKNOWN."),
|
||||
"aimIdentifier", aimIdentifier(), "rawDataBase64", rawDataBase64()), "barcode", "eventId", "capturedAt"));
|
||||
Map<String, Schema> scanContext = new TreeMap<>();
|
||||
scanContext.putAll(Map.of("scanId", text(64), "barcode", text(4096), "symbology", text(200), "scanSource", text(200), "eventId", uuid(),
|
||||
"capturedAt", text(40).withFormat("date-time"), "receivedAt", text(40).withFormat("date-time")));
|
||||
scanContext.putAll(Map.of("userId", text(200), "deviceId", uuid(), "appId", text(200), "processName", text(200)));
|
||||
List<String> requiredContext = List.copyOf(scanContext.keySet());
|
||||
scanContext.put("aimIdentifier", aimIdentifier());
|
||||
scanContext.put("rawDataBase64", rawDataBase64());
|
||||
schemas.put("MobileScannersScanContextV1", object("Persisted accepted scan for subsequent backend actions. Decoder identifiers are preserved; absent legacy type is UNKNOWN, or MANUAL for explicit manual source. Identity is server-derived. appId identifies the server-configured application; processName is the registered executing process. Extension 1.5 adds optional aimIdentifier and rawDataBase64, present only when the accepted scan has them and otherwise omitted. Raw event inputs are consumed so the next event cannot inherit them.", scanContext, requiredContext.toArray(String[]::new)));
|
||||
schemas.put("MobileScannersScanAcknowledgmentV1", object("Properties in core process response values after persistence. scanSaved=true, acknowledgedEventId matching the submitted eventId, and a nonempty scanId acknowledge success after async polling completes. Extension 1.3 always supplies scanContext for subsequent backend steps. It remains optional in this schema for older 1.x servers. A retry returns its original scanId and context; changed type/source/process is rejected.", Map.of(
|
||||
"acknowledgedEventId", uuid(), "scanId", text(64), "scanSaved", new Schema().withType(Type.BOOLEAN),
|
||||
"scanContext", ref("MobileScannersScanContextV1")), "acknowledgedEventId", "scanId", "scanSaved"));
|
||||
schemas.putAll(MobileSessionSchemas.components());
|
||||
return schemas;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit object properties permit future additive fields.
|
||||
***************************************************************************/
|
||||
static Schema object(String description, Map<String, Schema> properties, String... required)
|
||||
{
|
||||
return new RequiredObjectSchema(List.of(required)).withType(Type.OBJECT).withDescription(description).withProperties(new TreeMap<>(properties));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bounded strings retain their native wire type.
|
||||
***************************************************************************/
|
||||
static Schema text(int maximum)
|
||||
{
|
||||
return new Schema().withType(Type.STRING).withMaxLength(maximum);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Extension 1.5 original AIM identifier; optional and never inferred.
|
||||
***************************************************************************/
|
||||
private static Schema aimIdentifier()
|
||||
{
|
||||
return text(3).withDescription("Extension 1.5, optional. The original AIM identifier reported by the scanner SDK, exactly three characters matching ^\\][A-Za-z][0-9A-Za-z]$. Never guessed from symbology. Omit when unavailable. Present blank, malformed or wrong-typed values are rejected. Participates in exact event retry binding.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Extension 1.5 exact SDK raw bytes; no decoding or trust is implied.
|
||||
***************************************************************************/
|
||||
private static Schema rawDataBase64()
|
||||
{
|
||||
return text(10924).withDescription("Extension 1.5, optional. Canonical standard padded Base64 of 1 to 8192 bytes, byte-for-byte the scanner SDK getRawData result. The server does not decode it, strip prefixes or assume it equals the UTF-8 of barcode. It may include SDK code-ID framing, so it is exact SDK output, not symbol codewords or evidence of attested scanner configuration. Omit when unavailable. Present blank, non-canonical, malformed, oversize or wrong-typed values are rejected. Participates in exact event retry binding.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Canonical installation and event identifiers.
|
||||
***************************************************************************/
|
||||
static Schema uuid()
|
||||
{
|
||||
return text(36).withFormat("uuid");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Unknown future device states must be treated as unapproved by clients.
|
||||
***************************************************************************/
|
||||
private static Schema status()
|
||||
{
|
||||
return text(200).withEnumValues(List.of("PENDING", "APPROVED", "REVOKED"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Link reusable versioned components.
|
||||
***************************************************************************/
|
||||
static Schema ref(String name)
|
||||
{
|
||||
return new Schema().withRefToSchema(name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** RC20's Schema lacks required; expose the standard OpenAPI keyword.
|
||||
***************************************************************************/
|
||||
public static class RequiredObjectSchema extends Schema
|
||||
{
|
||||
private final List<String> required;
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Bind the required properties of this versioned object.
|
||||
***********************************************************************/
|
||||
public RequiredObjectSchema(List<String> required)
|
||||
{
|
||||
this.required = required;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Serialized by the same Jackson mapper as the core schema fields.
|
||||
***********************************************************************/
|
||||
public List<String> getRequired()
|
||||
{
|
||||
return required;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Standard numeric bounds missing from RC20's base Schema model.
|
||||
***************************************************************************/
|
||||
public static class ScannerMaximumSchema extends Schema
|
||||
{
|
||||
/***********************************************************************
|
||||
** Scanner input accepts at least one character.
|
||||
***********************************************************************/
|
||||
public int getMinimum()
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Never exceed the persistence and process input limit.
|
||||
***********************************************************************/
|
||||
public int getMaximum()
|
||||
{
|
||||
return 4096;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Discoverable mobile transport and SCANNER vocabulary within the QQQ UI API.
|
||||
*******************************************************************************/
|
||||
public final class MobileScannersUiApi
|
||||
{
|
||||
public static final String PREFIX = "/qbit/mobile-scanners";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private MobileScannersUiApi()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Register the independently versioned contract with the host UI API.
|
||||
***************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
ClientChannelsMetaData.ofOrWithNew(instance).withChannel("android", "Native Android client");
|
||||
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The same specs produce runtime routes, discovery and merged OpenAPI.
|
||||
***************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return new MiddlewareSpecExtension().withName("mobile-scanners").withSemanticVersion("1.6.0")
|
||||
.withDescription("Native scanner devices, customer branding and SCANNER process widget contract.")
|
||||
.withSpec(MobileBrandingSpec.class).withSpec(MobileStatusSpec.class)
|
||||
.withSpec(MobileEnrollSpec.class).withSpec(MobileCurrentDeviceSpec.class)
|
||||
.withSpec(MobileSessionHeartbeatSpec.class).withSpec(MobileSessionEndSpec.class).withSpec(MobileSessionScreenSpec.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.MemoryCacheImageInputStream;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded, ephemeral, demand-driven frames. No images are written to disk.
|
||||
*******************************************************************************/
|
||||
final class MobileScreenService
|
||||
{
|
||||
static final int MAX_BYTES = 262144;
|
||||
private static final int MAX_BASE64 = 349528;
|
||||
private static final int MAX_LEASES = 64;
|
||||
private final Map<String, Lease> leases = new HashMap<>();
|
||||
private final Clock clock;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Production clock.
|
||||
***************************************************************************/
|
||||
MobileScreenService()
|
||||
{
|
||||
this(Clock.systemUTC());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit clock makes expiry behavior independently testable.
|
||||
***************************************************************************/
|
||||
MobileScreenService(Clock clock)
|
||||
{
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Refreshing an authorized visible admin widget renews capture demand.
|
||||
***************************************************************************/
|
||||
synchronized View view(String sessionId) throws QException
|
||||
{
|
||||
return view(sessionId, MobileCaptureMode.APP_CONTENT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A change in capture scope invalidates every old pixel and lease identity.
|
||||
***************************************************************************/
|
||||
synchronized View view(String sessionId, MobileCaptureMode mode) throws QException
|
||||
{
|
||||
prune();
|
||||
Lease lease = leases.get(sessionId);
|
||||
if(lease != null && lease.mode != mode)
|
||||
{
|
||||
leases.remove(sessionId);
|
||||
lease = null;
|
||||
}
|
||||
if(lease == null)
|
||||
{
|
||||
if(leases.size() >= MAX_LEASES)
|
||||
{
|
||||
throw new QBadRequestException("Too many active screen viewers. Close another view and retry.");
|
||||
}
|
||||
lease = new Lease(mode);
|
||||
leases.put(sessionId, lease);
|
||||
}
|
||||
lease.expiresAt = clock.instant().plusSeconds(30);
|
||||
if(lease.receivedAt == null || lease.receivedAt.plusSeconds(10).isBefore(clock.instant()))
|
||||
{
|
||||
lease.jpeg = null;
|
||||
return new View(mode == MobileCaptureMode.DEVICE_SCREEN ? "Waiting for a consented device screen" : "Waiting for a current app screen", null, null);
|
||||
}
|
||||
return new View(mode == MobileCaptureMode.DEVICE_SCREEN ? "Live device screen — operator consent" : "Live app screen", "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(lease.jpeg), lease.receivedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Return only current demand; the server clock defines lease lifetime.
|
||||
***************************************************************************/
|
||||
synchronized Map<String, Object> activeLease(String sessionId)
|
||||
{
|
||||
prune();
|
||||
Lease lease = leases.get(sessionId);
|
||||
return lease == null ? null : Map.of("leaseId", lease.id, "expiresAt", lease.expiresAt.toString(), "frameIntervalMillis", 1000,
|
||||
"maxWidth", 640, "maxHeight", 1280, "maxBytes", MAX_BYTES, "nextSequence", lease.sequence + 1, "captureMode", lease.mode.name());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Reject oversize encoding before decoding and dimensions before pixels.
|
||||
***************************************************************************/
|
||||
synchronized void upload(String sessionId, Map<String, Object> input) throws QException
|
||||
{
|
||||
prune();
|
||||
Lease lease = leases.get(sessionId);
|
||||
if(lease == null || !lease.id.equals(MobileService.required(input, "leaseId", 36)))
|
||||
{
|
||||
throw new QPermissionDeniedException("No active screen viewing lease.");
|
||||
}
|
||||
if(MobileCaptureMode.parse(input.get("captureMode")) != lease.mode)
|
||||
{
|
||||
throw new QPermissionDeniedException("Frame capture mode does not match the viewing lease.");
|
||||
}
|
||||
long sequence = integer(input, "sequence", 0, 9007199254740990L);
|
||||
if(sequence <= lease.sequence || (lease.receivedAt != null && lease.receivedAt.plusMillis(750).isAfter(clock.instant())))
|
||||
{
|
||||
throw new FrameConflictException();
|
||||
}
|
||||
int width = (int) integer(input, "width", 1, 640);
|
||||
int height = (int) integer(input, "height", 1, 1280);
|
||||
if(!"image/jpeg".equals(input.get("mimeType")))
|
||||
{
|
||||
throw new QBadRequestException("Only JPEG screen frames are supported.");
|
||||
}
|
||||
try
|
||||
{
|
||||
Instant.parse(MobileService.required(input, "capturedAt", 40));
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
throw new QBadRequestException("capturedAt must be an ISO-8601 timestamp.");
|
||||
}
|
||||
String encoded = MobileService.required(input, "imageBase64", MAX_BASE64);
|
||||
byte[] jpeg;
|
||||
try
|
||||
{
|
||||
jpeg = Base64.getDecoder().decode(encoded);
|
||||
}
|
||||
catch(IllegalArgumentException exception)
|
||||
{
|
||||
throw new QBadRequestException("Invalid image encoding.");
|
||||
}
|
||||
if(jpeg.length > MAX_BYTES || jpeg.length < 4 || jpeg[0] != (byte) 0xff || jpeg[1] != (byte) 0xd8)
|
||||
{
|
||||
throw new QBadRequestException("Invalid or oversized JPEG frame.");
|
||||
}
|
||||
validateJpeg(jpeg, width, height);
|
||||
lease.sequence = sequence;
|
||||
lease.jpeg = jpeg;
|
||||
lease.receivedAt = clock.instant();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Image metadata is checked before allocating a bounded decoded raster.
|
||||
***************************************************************************/
|
||||
private static void validateJpeg(byte[] jpeg, int width, int height) throws QBadRequestException
|
||||
{
|
||||
try(MemoryCacheImageInputStream stream = new MemoryCacheImageInputStream(new ByteArrayInputStream(jpeg)))
|
||||
{
|
||||
var readers = ImageIO.getImageReaders(stream);
|
||||
if(!readers.hasNext())
|
||||
{
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
ImageReader reader = readers.next();
|
||||
try
|
||||
{
|
||||
reader.setInput(stream, true, true);
|
||||
if(!"JPEG".equalsIgnoreCase(reader.getFormatName()) || reader.getWidth(0) != width || reader.getHeight(0) != height)
|
||||
{
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
if(reader.read(0) == null)
|
||||
{
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
reader.dispose();
|
||||
}
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
throw new QBadRequestException("Invalid JPEG frame or dimensions.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Ending/revoking viewing drops all retained pixels and capture demand.
|
||||
***************************************************************************/
|
||||
synchronized void remove(String id)
|
||||
{
|
||||
leases.remove(id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Expiry is enforced on reads and writes, even without a cleanup scheduler.
|
||||
***************************************************************************/
|
||||
private void prune()
|
||||
{
|
||||
leases.values().removeIf(lease -> lease.expiresAt == null || !lease.expiresAt.isAfter(clock.instant()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Preserve integer semantics and reject overflow/floating-point coercion.
|
||||
***************************************************************************/
|
||||
private static long integer(Map<String, Object> input, String field, long minimum, long maximum) throws QBadRequestException
|
||||
{
|
||||
Object value = input.get(field);
|
||||
if(!(value instanceof Number number) || number.doubleValue() != number.longValue() || number.longValue() < minimum || number.longValue() > maximum)
|
||||
{
|
||||
throw new QBadRequestException(field + " is outside the supported integer range.");
|
||||
}
|
||||
return number.longValue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A safe widget payload containing only the most recent eligible image.
|
||||
***************************************************************************/
|
||||
record View(String status, String dataUri, Instant receivedAt)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** At most one frame per active session, at most 64 sessions per instance.
|
||||
***************************************************************************/
|
||||
private static final class Lease
|
||||
{
|
||||
private final String id = UUID.randomUUID().toString();
|
||||
private final MobileCaptureMode mode;
|
||||
private Instant expiresAt;
|
||||
private Instant receivedAt;
|
||||
private long sequence = -1;
|
||||
private byte[] jpeg;
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Scope is immutable for this lease.
|
||||
***********************************************************************/
|
||||
private Lease(MobileCaptureMode mode)
|
||||
{
|
||||
this.mode = mode;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Retry after the next heartbeat sequence/rate synchronization.
|
||||
***************************************************************************/
|
||||
static class FrameConflictException extends QException
|
||||
{
|
||||
/***********************************************************************
|
||||
** No frame or credential content is included in errors.
|
||||
***********************************************************************/
|
||||
FrameConflictException()
|
||||
{
|
||||
super("Frame sequence or upload rate is out of date. Refresh the heartbeat.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+306
-19
@@ -13,17 +13,26 @@ import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
@@ -33,6 +42,16 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
*******************************************************************************/
|
||||
public final class MobileService
|
||||
{
|
||||
static final ThreadLocal<QBackendTransaction> TRANSACTION = new ThreadLocal<>();
|
||||
|
||||
static final List<String> DEVICE_INFO_FIELDS = List.of("manufacturer", "model", "osVersion", "appVersion");
|
||||
static final int AIM_IDENTIFIER_LENGTH = 3;
|
||||
static final int RAW_DATA_MAXIMUM_BYTES = 8192;
|
||||
static final int RAW_DATA_MAXIMUM_CHARACTERS = 10924;
|
||||
private static final Pattern AIM_IDENTIFIER = Pattern.compile("^\\][A-Za-z][0-9A-Za-z]$");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** No service instances or global mutable storage.
|
||||
***************************************************************************/
|
||||
@@ -40,12 +59,15 @@ public final class MobileService
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Register once; a guessed installation ID never rotates an approved token.
|
||||
***************************************************************************/
|
||||
public static Map<String, Object> enroll(Map<String, Object> input) throws QException
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.SCAN);
|
||||
MobileSessionService.requireNotEnded();
|
||||
String id = uuid(required(input, "installationId", 36));
|
||||
if(get(MobileScannersQBitProducer.DEVICE, id) != null || get(MobileScannersQBitProducer.CREDENTIAL, id) != null)
|
||||
{
|
||||
@@ -57,16 +79,55 @@ public final class MobileService
|
||||
QRecord credential = new QRecord().withValue("id", id).withValue("tokenHash", hash(token));
|
||||
QRecord device = new QRecord().withValue("id", id).withValue("installationId", id).withValue("status", "PENDING")
|
||||
.withValue("enrolledBy", userId()).withValue("enrolledAt", Instant.now());
|
||||
for(String name : new String[] { "displayName", "manufacturer", "model", "osVersion", "appVersion" })
|
||||
device.setValue("displayName", optional(input, "displayName", 200));
|
||||
for(String name : DEVICE_INFO_FIELDS)
|
||||
{
|
||||
device.setValue(name, optional(input, name, 200));
|
||||
}
|
||||
// Credential first: a failed second insert leaves no usable approved device.
|
||||
insert(MobileScannersQBitProducer.CREDENTIAL, credential);
|
||||
insert(MobileScannersQBitProducer.DEVICE, device);
|
||||
return Map.of("deviceId", id, "deviceToken", token, "status", "PENDING");
|
||||
String shortName = optional(input, "shortName", 32);
|
||||
device.setValue("shortName", shortName == null ? "scanner-" + id.replace("-", "").substring(0, 12) : MobileDeviceProfileCustomizer.canonical(shortName));
|
||||
device.setValue("description", optional(input, "description", 2000));
|
||||
for(int attempt = 0; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
transaction(() ->
|
||||
{
|
||||
insert(MobileScannersQBitProducer.CREDENTIAL, credential);
|
||||
insert(MobileScannersQBitProducer.DEVICE, device);
|
||||
return null;
|
||||
});
|
||||
break;
|
||||
}
|
||||
catch(QException failure)
|
||||
{
|
||||
if(shortName != null || attempt >= 3 || !shortNameExists(device.getValueString("shortName")))
|
||||
{
|
||||
throw failure;
|
||||
}
|
||||
credential.getErrors().clear();
|
||||
device.getErrors().clear();
|
||||
device.setValue("shortName", "scanner-" + UUID.randomUUID().toString().replace("-", "").substring(0, 24));
|
||||
}
|
||||
}
|
||||
return Map.of("deviceId", id, "deviceToken", token, "status", "PENDING", "shortName", device.getValueString("shortName"),
|
||||
"displayName", Objects.toString(device.getValueString("displayName"), ""), "description", Objects.toString(device.getValueString("description"), ""));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Retry only generated-name collisions after the failed transaction rolls back.
|
||||
***************************************************************************/
|
||||
private static boolean shortNameExists(String name) throws QException
|
||||
{
|
||||
return internally(() -> !new QueryAction().execute(new QueryInput().withTableName(MobileScannersQBitProducer.DEVICE)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("shortName", QCriteriaOperator.EQUALS, name)).withLimit(1)))
|
||||
.getRecords().isEmpty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Compare a token digest in constant time; return no credential material.
|
||||
***************************************************************************/
|
||||
@@ -90,6 +151,8 @@ public final class MobileService
|
||||
return device;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Re-read status for every operation, including resumed asynchronous steps.
|
||||
***************************************************************************/
|
||||
@@ -103,10 +166,26 @@ public final class MobileService
|
||||
return device;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only admin processes may change device approval.
|
||||
***************************************************************************/
|
||||
public static void setApproval(String deviceId, boolean approved) throws QException
|
||||
{
|
||||
MobileSessionService.withActiveSession(() ->
|
||||
{
|
||||
applyApproval(deviceId, approved);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Approval writes execute inside the active-session authorization boundary.
|
||||
***************************************************************************/
|
||||
private static void applyApproval(String deviceId, boolean approved) throws QException
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.ADMIN);
|
||||
if(get(MobileScannersQBitProducer.DEVICE, deviceId) == null)
|
||||
@@ -117,16 +196,101 @@ public final class MobileService
|
||||
.withValue("approvedBy", userId()).withValue("approvedAt", Instant.now());
|
||||
internally(() ->
|
||||
{
|
||||
var output = new UpdateAction().execute(new UpdateInput().withTableName(MobileScannersQBitProducer.DEVICE).withRecord(record));
|
||||
var output = new UpdateAction().execute(new UpdateInput().withTableName(MobileScannersQBitProducer.DEVICE).withTransaction(TRANSACTION.get()).withRecord(record));
|
||||
check(output.getRecords().getFirst());
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A failed bulk approval rolls back the entire confirmed selection.
|
||||
***************************************************************************/
|
||||
static void setApproval(List<String> ids, boolean approved) throws QException
|
||||
{
|
||||
MobileSessionService.withActiveSession(() -> transaction(() ->
|
||||
{
|
||||
for(String id : ids)
|
||||
{
|
||||
applyApproval(id, approved);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Related writes require a real backend transaction, never the no-op base.
|
||||
***************************************************************************/
|
||||
static <T> T transaction(Operation<T> operation) throws QException
|
||||
{
|
||||
if(TRANSACTION.get() != null)
|
||||
{
|
||||
return operation.run();
|
||||
}
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new GetInput().withTableName(MobileScannersQBitProducer.DEVICE)))
|
||||
{
|
||||
if(transaction.getClass().equals(QBackendTransaction.class))
|
||||
{
|
||||
throw new QException("Mobile scanners requires a transactional storage backend.");
|
||||
}
|
||||
TRANSACTION.set(transaction);
|
||||
try
|
||||
{
|
||||
T result = operation.run();
|
||||
transaction.commit();
|
||||
return result;
|
||||
}
|
||||
catch(Exception failure)
|
||||
{
|
||||
transaction.rollback();
|
||||
throw failure;
|
||||
}
|
||||
finally
|
||||
{
|
||||
TRANSACTION.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A deterministic primary key deduplicates request retries across servers.
|
||||
***************************************************************************/
|
||||
static QRecord capture(Map<String, Serializable> input) throws QException
|
||||
{
|
||||
return capture(input, MobileScannersQBitProducer.CAPTURE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Process identity comes from the executing backend step, never its values.
|
||||
***************************************************************************/
|
||||
static QRecord capture(Map<String, Serializable> input, String processName) throws QException
|
||||
{
|
||||
return capture(input, processName, MobileScannersQBitProducer.APP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Host code supplies application metadata identity, never request values.
|
||||
***************************************************************************/
|
||||
static QRecord capture(Map<String, Serializable> input, String processName, String appId) throws QException
|
||||
{
|
||||
return MobileSessionService.withActiveSession(() -> captureApproved(input, processName, appId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Serialize the local authorization check and write with session ending.
|
||||
***************************************************************************/
|
||||
private static QRecord captureApproved(Map<String, Serializable> input, String processName, String appId) throws QException
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.SCAN);
|
||||
String deviceId = QContext.getQSession() instanceof VerifiedDeviceSession verified ? verified.deviceId() : null;
|
||||
@@ -144,10 +308,15 @@ public final class MobileService
|
||||
throw new QException("capturedAt must be an ISO-8601 timestamp.");
|
||||
}
|
||||
String id = hash(deviceId + ":" + eventId);
|
||||
String scanSource = normalizedScanSource(optional(input, "scanSource", 200));
|
||||
String symbology = normalizedSymbology(optional(input, "symbology", 200), scanSource);
|
||||
String aimIdentifier = aimIdentifier(input);
|
||||
String rawDataBase64 = rawDataBase64(input);
|
||||
QRecord record = new QRecord().withValue("id", id).withValue("deviceId", deviceId).withValue("eventId", eventId)
|
||||
.withValue("barcode", barcode).withValue("symbology", optional(input, "symbology", 200)).withValue("scanSource", optional(input, "scanSource", 200))
|
||||
.withValue("barcode", barcode).withValue("symbology", symbology).withValue("scanSource", scanSource)
|
||||
.withValue("aimIdentifier", aimIdentifier).withValue("rawDataBase64", rawDataBase64)
|
||||
.withValue("capturedAt", captured).withValue("receivedAt", Instant.now()).withValue("userId", userId())
|
||||
.withValue("appId", MobileScannersQBitProducer.APP).withValue("processName", MobileScannersQBitProducer.CAPTURE);
|
||||
.withValue("appId", appId).withValue("processName", processName);
|
||||
for(String field : new String[] { "manufacturer", "model", "osVersion", "appVersion" })
|
||||
{
|
||||
record.setValue(field, device.getValue(field));
|
||||
@@ -173,21 +342,99 @@ public final class MobileService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Reusing a request ID for another scan is an error, not a silent overwrite.
|
||||
***************************************************************************/
|
||||
private static QRecord sameEvent(QRecord existing, QRecord requested) throws QException
|
||||
{
|
||||
for(String field : new String[] { "barcode", "symbology", "scanSource", "userId" })
|
||||
String originalSource = normalizedScanSource(existing.getValueString("scanSource"));
|
||||
String originalSymbology = normalizedSymbology(existing.getValueString("symbology"), originalSource);
|
||||
if(!originalSource.equals(requested.getValueString("scanSource")) || !originalSymbology.equals(requested.getValueString("symbology")))
|
||||
{
|
||||
throw new QException("This eventId was already used for a different scan.");
|
||||
}
|
||||
for(String field : new String[] { "barcode", "userId", "processName", "appId", "aimIdentifier", "rawDataBase64" })
|
||||
{
|
||||
// Absent (NULL) and present metadata are different events, including historical rows.
|
||||
if(!Objects.equals(existing.getValueString(field), requested.getValueString(field)))
|
||||
{
|
||||
throw new QException("This eventId was already used for a different scan.");
|
||||
}
|
||||
}
|
||||
existing.setValue("scanSource", originalSource);
|
||||
existing.setValue("symbology", originalSymbology);
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Optional original AIM identifier, for example ]C1; absent stays absent and
|
||||
** is never guessed from the symbology.
|
||||
***************************************************************************/
|
||||
static String aimIdentifier(Map<String, ?> input) throws QException
|
||||
{
|
||||
String value = optional(input, "aimIdentifier", AIM_IDENTIFIER_LENGTH);
|
||||
if(value != null && !AIM_IDENTIFIER.matcher(value).matches())
|
||||
{
|
||||
throw new QBadRequestException("aimIdentifier must be a three character AIM identifier such as ]C1.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Optional exact SDK raw bytes as canonical padded Base64. Bytes are never
|
||||
** decoded into text, trimmed or compared with the barcode string.
|
||||
***************************************************************************/
|
||||
static String rawDataBase64(Map<String, ?> input) throws QException
|
||||
{
|
||||
String value = optional(input, "rawDataBase64", RAW_DATA_MAXIMUM_CHARACTERS);
|
||||
if(value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
byte[] decoded;
|
||||
try
|
||||
{
|
||||
decoded = Base64.getDecoder().decode(value);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw new QBadRequestException("rawDataBase64 must be canonical padded Base64.");
|
||||
}
|
||||
if(decoded.length < 1 || decoded.length > RAW_DATA_MAXIMUM_BYTES || !Base64.getEncoder().encodeToString(decoded).equals(value))
|
||||
{
|
||||
throw new QBadRequestException("rawDataBase64 must be canonical padded Base64 of 1 to " + RAW_DATA_MAXIMUM_BYTES + " bytes.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Legacy clients may omit source; absence must not invent a capture method.
|
||||
***************************************************************************/
|
||||
private static String normalizedScanSource(String scanSource)
|
||||
{
|
||||
return scanSource == null || scanSource.isBlank() ? "UNKNOWN" : scanSource;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Preserve decoder identifiers exactly; barcode text never implies a type.
|
||||
***************************************************************************/
|
||||
private static String normalizedSymbology(String symbology, String scanSource)
|
||||
{
|
||||
return symbology == null || symbology.isBlank() ? ("manual".equalsIgnoreCase(scanSource) ? "MANUAL" : "UNKNOWN") : symbology;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read only through QQQ and the narrowly scoped internal permission grant.
|
||||
***************************************************************************/
|
||||
@@ -199,18 +446,21 @@ public final class MobileService
|
||||
input.setTableName(table);
|
||||
input.setPrimaryKey(id);
|
||||
input.setShouldOmitHiddenFields(false);
|
||||
input.setTransaction(TRANSACTION.get());
|
||||
return new GetAction().execute(input).getRecord();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Row-level errors must be checked before acknowledging a scan.
|
||||
***************************************************************************/
|
||||
private static QRecord insert(String table, QRecord record) throws QException
|
||||
static QRecord insert(String table, QRecord record) throws QException
|
||||
{
|
||||
return internally(() ->
|
||||
{
|
||||
var output = new InsertAction().execute(new InsertInput().withTableName(table).withRecord(record));
|
||||
var output = new InsertAction().execute(new InsertInput().withTableName(table).withTransaction(TRANSACTION.get()).withRecord(record));
|
||||
if(output.getRecords() == null || output.getRecords().size() != 1)
|
||||
{
|
||||
throw new QException("The record could not be saved.");
|
||||
@@ -221,6 +471,8 @@ public final class MobileService
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Never expose persistence details or credentials in a client error.
|
||||
***************************************************************************/
|
||||
@@ -232,10 +484,12 @@ public final class MobileService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Restore the permission scope even when a backend throws.
|
||||
***************************************************************************/
|
||||
private static <T> T internally(Operation<T> operation) throws QException
|
||||
static <T> T internally(Operation<T> operation) throws QException
|
||||
{
|
||||
Boolean previous = MobilePermissions.INTERNAL.get();
|
||||
MobilePermissions.INTERNAL.set(true);
|
||||
@@ -256,10 +510,12 @@ public final class MobileService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** QQQ operation with checked failure.
|
||||
***************************************************************************/
|
||||
private interface Operation<T>
|
||||
interface Operation<T>
|
||||
{
|
||||
/***********************************************************************
|
||||
** Execute one internal persistence operation.
|
||||
@@ -267,14 +523,18 @@ public final class MobileService
|
||||
T run() throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Server-authenticated identity only.
|
||||
***************************************************************************/
|
||||
private static String userId()
|
||||
static String userId()
|
||||
{
|
||||
return QContext.getQSession().getUser().getIdReference();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Required bounded strings preserve leading zeroes in barcodes.
|
||||
***************************************************************************/
|
||||
@@ -283,15 +543,17 @@ public final class MobileService
|
||||
String value = optional(input, name, maximum);
|
||||
if(value == null || value.isEmpty())
|
||||
{
|
||||
throw new QException(name + " is required.");
|
||||
throw new QBadRequestException(name + " is required.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Reject non-string and oversized metadata.
|
||||
***************************************************************************/
|
||||
private static String optional(Map<String, ?> input, String name, int maximum) throws QException
|
||||
static String optional(Map<String, ?> input, String name, int maximum) throws QException
|
||||
{
|
||||
Object value = input.get(name);
|
||||
if(value == null)
|
||||
@@ -300,11 +562,32 @@ public final class MobileService
|
||||
}
|
||||
if(!(value instanceof String text) || text.length() > maximum)
|
||||
{
|
||||
throw new QException(name + " must be a string of at most " + maximum + " characters.");
|
||||
throw new QBadRequestException(name + " must be a string of at most " + maximum + " characters.");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An updated app or OS reports new versions after enrollment; copy only
|
||||
** changed, bounded values so scans record the software that produced them.
|
||||
** Absent, null or blank values never erase what enrollment recorded.
|
||||
***************************************************************************/
|
||||
static void refreshDeviceInfo(QRecord device, Map<String, ?> input, QRecord changes) throws QException
|
||||
{
|
||||
for(String name : DEVICE_INFO_FIELDS)
|
||||
{
|
||||
String value = optional(input, name, 200);
|
||||
if(value != null && !value.isBlank() && !value.equals(device.getValueString(name)))
|
||||
{
|
||||
changes.setValue(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Enforce canonical UUIDs before using them as identifiers.
|
||||
***************************************************************************/
|
||||
@@ -321,14 +604,16 @@ public final class MobileService
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw new QException("A canonical UUID is required.");
|
||||
throw new QBadRequestException("A canonical UUID is required.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** SHA-256 of high-entropy tokens, never plaintext storage.
|
||||
***************************************************************************/
|
||||
private static String hash(String value)
|
||||
static String hash(String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -340,6 +625,8 @@ public final class MobileService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit conflict so callers can restart enrollment with a new identity.
|
||||
***************************************************************************/
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Freeze single/bulk session selections before confirmation.
|
||||
*******************************************************************************/
|
||||
public class MobileSessionAdminPrepareStep implements BackendStep
|
||||
{
|
||||
/***************************************************************************
|
||||
** Reuse the same selection semantics as device approval and revocation.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
MobileAdminSelection.prepare(input, output, MobileScannersQBitProducer.SESSION, "sessionId", "session(s)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Administrator termination with durable history and QQQ auth invalidation.
|
||||
*******************************************************************************/
|
||||
public class MobileSessionAdminStep implements BackendStep
|
||||
{
|
||||
/***************************************************************************
|
||||
** Recheck permission and the full stable selection before changing anything.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.ADMIN);
|
||||
if(!MobileScannersQBitProducer.SESSION_END.equals(input.getProcessName()))
|
||||
{
|
||||
throw new QException("Unknown session administration process.");
|
||||
}
|
||||
String selected = input.getRecords() != null && !input.getRecords().isEmpty()
|
||||
? String.join(",", input.getRecords().stream().map(record -> record.getValueString("id")).toList())
|
||||
: MobileService.required(input.getValues(), "sessionId", 3700);
|
||||
var ids = MobileAdminSelection.resolve(selected, MobileScannersQBitProducer.SESSION);
|
||||
String reason = MobileService.optional(input.getValues(), "reason", 200);
|
||||
MobileSessionService.endAll(ids, reason);
|
||||
output.addValue("processedSessionCount", ids.size());
|
||||
output.addValue("result", ids.size() + (ids.size() == 1 ? " session ended." : " sessions ended."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Operator logout history; admin termination uses an ordinary QQQ process.
|
||||
*******************************************************************************/
|
||||
public class MobileSessionEndSpec extends MobileDeviceJsonSpec
|
||||
{
|
||||
/***************************************************************************
|
||||
** End only the caller's currently verified binding.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return new BasicOperation().withHttpMethod(HttpMethod.POST).withPath(MobileScannersUiApi.PREFIX + "/sessions/current/end")
|
||||
.withOperationId("mobileScannersEndCurrentSession").withShortSummary("End this handheld sign-in")
|
||||
.withLongDescription("Ends the authenticated device session and invalidates its QQQ login. No target session ID is accepted. An already ended/expired login may return 401; the client must still clear local authentication. Device approval is unchanged.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Safe public session result.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return "MobileScannersSessionEndedV1";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit operator reason, not a free-form secret-bearing message.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return new Schema().withRefToSchema("MobileScannersEndSessionRequestV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Durable tombstone plus existing QQQ authentication logout.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return MobileSessionService.endCurrent(input.getBody());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current sign-in activity and server-controlled screen capture demand.
|
||||
*******************************************************************************/
|
||||
public class MobileSessionHeartbeatSpec extends MobileDeviceJsonSpec
|
||||
{
|
||||
/***************************************************************************
|
||||
** Bind identity from QQQ authentication and verified device headers only.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return new BasicOperation().withHttpMethod(HttpMethod.POST).withPath(MobileScannersUiApi.PREFIX + "/sessions/heartbeat")
|
||||
.withOperationId("mobileScannersSessionHeartbeat").withShortSummary("Report current handheld session activity")
|
||||
.withLongDescription("Binds the current QQQ sign-in to the verified device. Nullable display metadata never grants permissions. Ended sign-ins return 401 and cannot start another mobile session. screenAvailable=false immediately removes the retained frame. Lease duration is expiresAt minus serverTime, not the device clock. Optional manufacturer, model, osVersion and appVersion refresh the enrolled device record when they differ (1.6); scans recorded afterwards carry the refreshed values.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Additive 1.1 response.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return "MobileScannersHeartbeatV1";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Small, bounded activity document.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return new Schema().withRefToSchema("MobileScannersHeartbeatRequestV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Server owns session and user/device identity.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return MobileSessionService.heartbeat(input.getBody());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordinary QQQ record, child-list, widget and process metadata for sessions.
|
||||
*******************************************************************************/
|
||||
final class MobileSessionMetadata
|
||||
{
|
||||
/***************************************************************************
|
||||
** Metadata utility.
|
||||
***************************************************************************/
|
||||
private MobileSessionMetadata()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Register public history separately from private authentication references.
|
||||
***************************************************************************/
|
||||
static void register(QInstance instance, String backend)
|
||||
{
|
||||
QTableMetaData sessions = table(MobileScannersQBitProducer.SESSION, "Mobile Sessions", backend, 36);
|
||||
for(String field : List.of("deviceId", "userId", "status", "endedBy", "endReason", "appName", "processName", "processInstanceId", "stepName", "screenLabel"))
|
||||
{
|
||||
sessions.withField(text(field, 200));
|
||||
}
|
||||
for(String field : List.of("lastAction", "errorCode", "captureBlockedReason", "authCleanupStatus"))
|
||||
{
|
||||
sessions.withField(text(field, 80));
|
||||
}
|
||||
sessions.withField(text("traceId", 100));
|
||||
sessions.withField(text("captureMode", 20));
|
||||
for(String field : List.of("startedAt", "lastSeenAt", "endedAt", "authCleanupAt"))
|
||||
{
|
||||
sessions.withField(new QFieldMetaData(field, QFieldType.DATE_TIME));
|
||||
}
|
||||
sessions.withField(new QFieldMetaData("foreground", QFieldType.BOOLEAN));
|
||||
sessions.withField(new QFieldMetaData("screenAvailable", QFieldType.BOOLEAN));
|
||||
sessions.withSection(new QFieldSection("activity", "Session activity", new QIcon("devices"), Tier.T2,
|
||||
List.of("id", "deviceId", "userId", "status", "startedAt", "lastSeenAt", "foreground", "appName", "processName", "processInstanceId", "stepName", "screenLabel", "lastAction", "errorCode", "traceId", "endedAt", "endedBy", "endReason", "screenAvailable", "captureMode", "captureBlockedReason", "authCleanupStatus", "authCleanupAt")));
|
||||
sessions.withSection(new QFieldSection("screen", new QIcon("screen_share"), Tier.T2).withLabel("Live screen")
|
||||
.withWidgetName(MobileScannersQBitProducer.SESSION_SCREEN));
|
||||
instance.addTable(sessions);
|
||||
instance.addTable(table(MobileScannersQBitProducer.SESSION_BINDING, "Private Session Bindings", backend, 64)
|
||||
.withField(text("sessionId", 36).withIsHidden(true)).withField(text("coreSessionUuid", 200).withIsHidden(true).withIsSensitive(true)));
|
||||
instance.addWidget(new QWidgetMetaData().withName(MobileScannersQBitProducer.SESSION_SCREEN).withLabel("Live screen")
|
||||
.withType("block").withCodeReference(new QCodeReference(MobileSessionScreenWidget.class))
|
||||
.withDefaultValue("refreshSeconds", 2).withPermissionRules(MobileScannersQBitProducer.rules()));
|
||||
QJoinMetaData join = new QJoinMetaData().withName("mobileDeviceSessions").withLeftTable(MobileScannersQBitProducer.DEVICE)
|
||||
.withRightTable(MobileScannersQBitProducer.SESSION).withType(JoinType.ONE_TO_MANY).withJoinOn(new JoinOn("id", "deviceId"));
|
||||
instance.addJoin(join);
|
||||
instance.addWidget(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName("mobileDeviceSessions")
|
||||
.withLabel("Device Sessions").withCanAddChildRecord(false).getWidgetMetaData()
|
||||
.withDefaultValue("orderBy", new ArrayList<>(List.of(new QFilterOrderBy("startedAt", false))))
|
||||
.withDefaultValue("refreshSeconds", 2).withPermissionRules(MobileScannersQBitProducer.rules()));
|
||||
var device = instance.getTable(MobileScannersQBitProducer.DEVICE);
|
||||
List<String> deviceFields = new ArrayList<>(device.getFields().keySet());
|
||||
device.withSection(new QFieldSection("device", "Device", new QIcon("devices"), Tier.T2, deviceFields));
|
||||
instance.addWidget(new QWidgetMetaData().withName("mobileDeviceStatus").withLabel("Device status").withType("block")
|
||||
.withCodeReference(new QCodeReference(MobileDeviceStatusWidget.class)).withDefaultValue("refreshSeconds", 2)
|
||||
.withPermissionRules(MobileScannersQBitProducer.rules()));
|
||||
device.withSection(new QFieldSection("status", new QIcon("sensors"), Tier.T2).withLabel("Live status").withWidgetName("mobileDeviceStatus"));
|
||||
device.withSection(new QFieldSection("sessions", new QIcon("history"), Tier.T2).withLabel("Sessions").withWidgetName("mobileDeviceSessions"));
|
||||
instance.addProcess(new QProcessMetaData().withName(MobileScannersQBitProducer.SESSION_END).withLabel("End Session")
|
||||
.withTableName(MobileScannersQBitProducer.SESSION).withMaxInputRecords(100).withPermissionRules(MobileScannersQBitProducer.rules())
|
||||
.withStep(new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(MobileSessionAdminPrepareStep.class)))
|
||||
.withStep(new QFrontendStepMetaData().withName("input").withLabel("Sessions")
|
||||
.withViewField(text("selectionSummary", 30000).withLabel("Sessions to end"))
|
||||
.withFormField(text("sessionId", 3700).withLabel("Session IDs").withIsRequired(true))
|
||||
.withFormField(text("reason", 200).withLabel("Reason"))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)))
|
||||
.withStep(new QBackendStepMetaData().withName("apply").withCode(new QCodeReference(MobileSessionAdminStep.class)))
|
||||
.withStep(new QFrontendStepMetaData().withName("result").withLabel("Result")
|
||||
.withViewField(text("result", 200).withLabel("Result"))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Guard native table reads and deny generic mutations.
|
||||
***************************************************************************/
|
||||
private static QTableMetaData table(String name, String label, String backend, int idLength)
|
||||
{
|
||||
return new QTableMetaData().withName(name).withLabel(label).withBackendName(backend).withPrimaryKeyField("id")
|
||||
.withField(text("id", idLength).withIsRequired(true)).withPermissionRules(MobileScannersQBitProducer.rules());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bounded semantic text fields.
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData text(String name, int maximum)
|
||||
{
|
||||
return new QFieldMetaData(name, QFieldType.STRING).withMaxLength(maximum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
import static com.kingsrook.qbits.mobilescanners.MobileScannersSchemas.object;
|
||||
import static com.kingsrook.qbits.mobilescanners.MobileScannersSchemas.ref;
|
||||
import static com.kingsrook.qbits.mobilescanners.MobileScannersSchemas.text;
|
||||
import static com.kingsrook.qbits.mobilescanners.MobileScannersSchemas.uuid;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Additive extension 1.1 session/activity/screen vocabulary.
|
||||
*******************************************************************************/
|
||||
final class MobileSessionSchemas
|
||||
{
|
||||
/***************************************************************************
|
||||
** Static schema producer.
|
||||
***************************************************************************/
|
||||
private MobileSessionSchemas()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Core auth identifiers and device tokens never appear in these shapes.
|
||||
***************************************************************************/
|
||||
static Map<String, Schema> components()
|
||||
{
|
||||
Map<String, Schema> schemas = new TreeMap<>();
|
||||
Map<String, Schema> activity = new TreeMap<>();
|
||||
activity.put("foreground", new Schema().withType(Type.BOOLEAN));
|
||||
activity.put("screenAvailable", new Schema().withType(Type.BOOLEAN));
|
||||
activity.put("captureMode", captureMode());
|
||||
for(String field : List.of("appName", "processName", "processInstanceId", "stepName", "screenLabel"))
|
||||
{
|
||||
activity.put(field, nullableText(200));
|
||||
}
|
||||
for(String field : List.of("lastAction", "errorCode", "captureBlockedReason"))
|
||||
{
|
||||
activity.put(field, nullableText(80));
|
||||
}
|
||||
activity.put("traceId", nullableText(100));
|
||||
activity.put("locationStatus", text(30).withEnumValues(List.of("AVAILABLE", "PERMISSION_DENIED", "DISABLED", "UNAVAILABLE")));
|
||||
activity.put("location", ref("MobileScannersLocationV1").withNullable(true));
|
||||
for(String field : MobileService.DEVICE_INFO_FIELDS)
|
||||
{
|
||||
activity.put(field, text(200));
|
||||
}
|
||||
schemas.put("MobileScannersLocationV1", object("Optional foreground observation with runtime permission. Coordinates are last known, not a guarantee of current position; the server separately records receipt time.", Map.of(
|
||||
"latitude", new BoundedIntegerSchema(-90, 90).withType(Type.NUMBER), "longitude", new BoundedIntegerSchema(-180, 180).withType(Type.NUMBER),
|
||||
"accuracyMeters", new BoundedIntegerSchema(0, 10000000).withType(Type.NUMBER), "capturedAt", timestamp()), "latitude", "longitude", "accuracyMeters", "capturedAt"));
|
||||
schemas.put("MobileScannersHeartbeatRequestV1", object("Display activity only. Never include form values, barcodes, passwords or raw error messages. JSON maximum 4096 bytes. Omitted captureMode means APP_CONTENT, requiring foreground eligible app pixels. DEVICE_SCREEN requires explicit per-session Android operator consent and may remain available in background. False screenAvailable clears pixels and lease; changing mode invalidates old demand. Optional manufacturer, model, osVersion and appVersion (1.6) refresh the enrolled device record when they differ, so later scans carry the installed software; omitted or blank values leave stored values unchanged.", activity, "foreground", "screenAvailable"));
|
||||
schemas.put("MobileScannersScreenLeaseV1", object("Server-authorized capture demand. Compute remaining duration from expiresAt minus heartbeat serverTime. Sequence is scoped to this lease and resumes at nextSequence after client restart.", Map.of(
|
||||
"leaseId", uuid(), "expiresAt", timestamp(), "frameIntervalMillis", integer(1000, 1000), "maxWidth", integer(640, 640),
|
||||
"maxHeight", integer(1280, 1280), "maxBytes", integer(262144, 262144), "nextSequence", integer(0, 9007199254740990L), "captureMode", captureMode()),
|
||||
"leaseId", "expiresAt", "frameIntervalMillis", "maxWidth", "maxHeight", "maxBytes", "nextSequence"));
|
||||
schemas.put("MobileScannersHeartbeatV1", object("Current authenticated binding. screenMirror is omitted unless capture is eligible and an administrator is actively viewing. An ended or expired sign-in returns 401; the client must clear authentication.", Map.of(
|
||||
"sessionId", uuid(), "status", text(20).withEnumValues(List.of("ACTIVE")), "serverTime", timestamp(), "heartbeatIntervalMillis", integer(5000, 5000),
|
||||
"screenMirror", ref("MobileScannersScreenLeaseV1"), "supportedCaptureModes", new Schema().withType(Type.ARRAY).withItems(captureMode())), "sessionId", "status", "serverTime", "heartbeatIntervalMillis"));
|
||||
schemas.put("MobileScannersEndSessionRequestV1", object("Operator intent for their own authenticated session; no target ID is accepted.", Map.of(
|
||||
"reason", text(30).withEnumValues(List.of("SIGN_OUT", "SERVER_CHANGE"))), "reason"));
|
||||
schemas.put("MobileScannersSessionEndedV1", object("Public tracking identity only. Device approval is unchanged.", Map.of(
|
||||
"sessionId", uuid(), "status", text(20).withEnumValues(List.of("ENDED")), "endedAt", timestamp()), "sessionId", "status", "endedAt"));
|
||||
schemas.put("MobileScannersScreenRequestV1", object("Mode must match the current session and viewing lease. Omitted mode is APP_CONTENT: never login/password/other-app/OS pixels. DEVICE_SCREEN is optional1.2 and requires per-session operator system consent. Total JSON maximum368640 bytes; decoded JPEG maximum262144 bytes, width<=640, height<=1280. Frames are ephemeral, latest-only and cleared on blocked capture, mode change, expired demand or session end.", Map.of(
|
||||
"leaseId", uuid(), "sequence", integer(0, 9007199254740990L), "capturedAt", timestamp(), "width", integer(1, 640), "height", integer(1, 1280),
|
||||
"mimeType", text(10).withEnumValues(List.of("image/jpeg")), "imageBase64", text(349528).withFormat("byte"), "captureMode", captureMode()),
|
||||
"leaseId", "sequence", "capturedAt", "width", "height", "mimeType", "imageBase64"));
|
||||
return schemas;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Additive 1.2 scope; absent legacy values always mean app content.
|
||||
***************************************************************************/
|
||||
private static Schema captureMode()
|
||||
{
|
||||
return text(20).withEnumValues(List.of("APP_CONTENT", "DEVICE_SCREEN"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Nullable activity fields explicitly clear older display state.
|
||||
***************************************************************************/
|
||||
private static Schema nullableText(int maximum)
|
||||
{
|
||||
return text(maximum).withNullable(true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** UTC instant representation.
|
||||
***************************************************************************/
|
||||
private static Schema timestamp()
|
||||
{
|
||||
return text(40).withFormat("date-time");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** RC20 extensions expose numeric schema limits through normal serialization.
|
||||
***************************************************************************/
|
||||
private static Schema integer(long minimum, long maximum)
|
||||
{
|
||||
return new BoundedIntegerSchema(minimum, maximum).withType(Type.INTEGER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Standard JSON integer bounds.
|
||||
***************************************************************************/
|
||||
public static class BoundedIntegerSchema extends Schema
|
||||
{
|
||||
private final long minimum;
|
||||
private final long maximum;
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Capture declared limits once.
|
||||
***********************************************************************/
|
||||
BoundedIntegerSchema(long minimum, long maximum)
|
||||
{
|
||||
this.minimum = minimum;
|
||||
this.maximum = maximum;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Inclusive minimum.
|
||||
***********************************************************************/
|
||||
public long getMinimum()
|
||||
{
|
||||
return minimum;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Inclusive maximum.
|
||||
***********************************************************************/
|
||||
public long getMaximum()
|
||||
{
|
||||
return maximum;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded app-only screen frame upload for an active administrator view lease.
|
||||
*******************************************************************************/
|
||||
public class MobileSessionScreenSpec extends MobileDeviceJsonSpec
|
||||
{
|
||||
/***************************************************************************
|
||||
** Upload never accepts a target session or external URL.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return new BasicOperation().withHttpMethod(HttpMethod.POST).withPath(MobileScannersUiApi.PREFIX + "/sessions/current/screen")
|
||||
.withOperationId("mobileScannersUploadSessionScreen").withShortSummary("Upload an app screen for a live viewer")
|
||||
.withLongDescription("Requires the current approved device session, screenAvailable=true and a mode-matched active viewer lease. Default APP_CONTENT excludes login/password/OS/other-app pixels. Optional DEVICE_SCREEN requires operator system consent for this session. JPEG decoded size at most 262144 bytes, dimensions at most 640x1280, JSON at most 368640 bytes. Sequence is scoped to leaseId. Frames are ephemeral and cleared on unavailable capture, mode change, expiry or session end. A 409 asks the client to refresh heartbeat sequence/rate state.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** No payload is returned on success.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Typed bounded JPEG document.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return new Schema().withRefToSchema("MobileScannersScreenRequestV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The sole larger JSON operation has a strict total byte limit.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected int maximumBodyBytes()
|
||||
{
|
||||
return 368640;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Match runtime's empty success response in the generated contract.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public BasicResponse defineBasicSuccessResponse()
|
||||
{
|
||||
return new BasicResponse(HttpStatus.NO_CONTENT, "Current frame accepted", null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Avoid serializing image data or a fabricated JSON success body.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void handleOutput(Context context, JsonOutput output)
|
||||
{
|
||||
context.status(HttpStatus.NO_CONTENT).result("");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Permission, bounds and sequence are validated before retaining a frame.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
MobileSessionService.upload(input.getBody());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.CompositeWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageBlockData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageStyles;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageValues;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextBlockData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextValues;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Native QQQ IMAGE/TEXT blocks refreshed by the standard widget API.
|
||||
*******************************************************************************/
|
||||
public class MobileSessionScreenWidget extends AbstractWidgetRenderer
|
||||
{
|
||||
/***************************************************************************
|
||||
** Every refresh rechecks administrator permission and the target session.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
String id = MobileService.required(input.getQueryParams(), "id", 36);
|
||||
var view = MobileSessionService.view(id);
|
||||
String status = view.status() + (view.receivedAt() == null ? "" : " — received " + view.receivedAt());
|
||||
CompositeWidgetData data = new CompositeWidgetData().withLayout(CompositeWidgetData.Layout.FLEX_COLUMN)
|
||||
.withBlock(new TextBlockData().withBlockId("screenStatus").withValues(new TextValues(status)));
|
||||
if(view.dataUri() != null)
|
||||
{
|
||||
data.withBlock(new ImageBlockData().withBlockId("screenFrame")
|
||||
.withValues(new ImageValues().withPath(view.dataUri()).withAlt(view.status()))
|
||||
.withStyles(new ImageStyles().withWidth("320px").withHeight("auto")));
|
||||
}
|
||||
return new RenderWidgetOutput(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.WeakHashMap;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreHelper;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.LogoutExecutor;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.io.LogoutInput;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.LogoutResponseV1;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Device session history bound only to verified QQQ authentication context.
|
||||
*******************************************************************************/
|
||||
final class MobileSessionService
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// QLogger adds user identity automatically. Keep boundary diagnostics //
|
||||
// entirely static without changing authentication context or log settings. //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
private static final Logger LOG = LogManager.getLogger(MobileSessionService.class);
|
||||
|
||||
private static final Map<QInstance, MobileSessionService> INSTANCES = new WeakHashMap<>();
|
||||
private final MobileScreenService screens = new MobileScreenService();
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One lock and bounded transient screen cache per server instance.
|
||||
***************************************************************************/
|
||||
static synchronized MobileSessionService current()
|
||||
{
|
||||
return INSTANCES.computeIfAbsent(QContext.getQInstance(), key -> new MobileSessionService());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A session ended on this server cannot race a local scan persistence write.
|
||||
***************************************************************************/
|
||||
static <T> T withActiveSession(MobileService.Operation<T> operation) throws QException
|
||||
{
|
||||
synchronized(current())
|
||||
{
|
||||
requireNotEnded();
|
||||
return operation.run();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Apply the durable tombstone even when device headers are omitted.
|
||||
***************************************************************************/
|
||||
static void requireNotEnded() throws QException
|
||||
{
|
||||
String uuid = authenticatedUuid();
|
||||
QRecord binding = MobileService.get(MobileScannersQBitProducer.SESSION_BINDING, MobileService.hash(uuid));
|
||||
if(binding != null)
|
||||
{
|
||||
QRecord session = MobileService.get(MobileScannersQBitProducer.SESSION, binding.getValueString("sessionId"));
|
||||
if(session == null || !"ACTIVE".equals(session.getValueString("status")))
|
||||
{
|
||||
throw new QAuthenticationException("This mobile session has ended. Sign in again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Observe old clients too; binding never accepts a client-supplied user/UUID.
|
||||
***************************************************************************/
|
||||
static QRecord observe(String deviceId) throws QException
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.SCAN);
|
||||
synchronized(current())
|
||||
{
|
||||
requireNotEnded();
|
||||
String uuid = authenticatedUuid();
|
||||
String key = MobileService.hash(uuid);
|
||||
QRecord binding = MobileService.get(MobileScannersQBitProducer.SESSION_BINDING, key);
|
||||
QRecord session;
|
||||
if(binding == null)
|
||||
{
|
||||
session = MobileService.transaction(() ->
|
||||
{
|
||||
QRecord created = MobileService.insert(MobileScannersQBitProducer.SESSION, new QRecord()
|
||||
.withValue("id", UUID.randomUUID().toString()).withValue("deviceId", deviceId).withValue("userId", MobileService.userId())
|
||||
.withValue("status", "ACTIVE").withValue("startedAt", Instant.now()).withValue("lastSeenAt", Instant.now())
|
||||
.withValue("screenAvailable", false).withValue("captureBlockedReason", "CLIENT_NOT_REPORTING"));
|
||||
MobileService.insert(MobileScannersQBitProducer.SESSION_BINDING, new QRecord().withValue("id", key)
|
||||
.withValue("sessionId", created.getValueString("id")).withValue("coreSessionUuid", uuid));
|
||||
MobileDeviceActivity.seen(deviceId);
|
||||
return created;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
session = MobileService.get(MobileScannersQBitProducer.SESSION, binding.getValueString("sessionId"));
|
||||
if(!deviceId.equals(session.getValueString("deviceId")) || !MobileService.userId().equals(session.getValueString("userId")))
|
||||
{
|
||||
throw new QPermissionDeniedException("This sign-in belongs to another device.");
|
||||
}
|
||||
}
|
||||
// Coalesce request activity; explicit heartbeat updates are always persisted.
|
||||
Instant seen = session.getValueInstant("lastSeenAt");
|
||||
if(seen == null || seen.plusSeconds(5).isBefore(Instant.now()))
|
||||
{
|
||||
update(new QRecord().withValue("id", session.getValueString("id")).withValue("lastSeenAt", Instant.now()));
|
||||
MobileDeviceActivity.seen(deviceId);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Safe activity is display metadata, never authorization or process input.
|
||||
***************************************************************************/
|
||||
static Map<String, Object> heartbeat(Map<String, Object> input) throws QException
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.SCAN);
|
||||
String deviceId = currentDevice();
|
||||
synchronized(current())
|
||||
{
|
||||
QRecord device = MobileService.requireApproved(deviceId);
|
||||
QRecord session = observe(deviceId);
|
||||
boolean foreground = bool(input, "foreground");
|
||||
MobileCaptureMode mode = MobileCaptureMode.parse(input.get("captureMode"));
|
||||
boolean available = bool(input, "screenAvailable") && (foreground || mode == MobileCaptureMode.DEVICE_SCREEN);
|
||||
if(mode != MobileCaptureMode.parse(session.getValueString("captureMode")))
|
||||
{
|
||||
current().screens.remove(session.getValueString("id"));
|
||||
}
|
||||
QRecord changes = new QRecord().withValue("id", session.getValueString("id")).withValue("lastSeenAt", Instant.now())
|
||||
.withValue("foreground", foreground).withValue("screenAvailable", available).withValue("captureMode", mode.name());
|
||||
for(String name : List.of("appName", "processName", "processInstanceId", "stepName", "screenLabel"))
|
||||
{
|
||||
changes.setValue(name, MobileService.optional(input, name, 200));
|
||||
}
|
||||
for(String name : List.of("lastAction", "errorCode", "captureBlockedReason"))
|
||||
{
|
||||
changes.setValue(name, MobileService.optional(input, name, 80));
|
||||
}
|
||||
changes.setValue("traceId", MobileService.optional(input, "traceId", 100));
|
||||
MobileDeviceActivity.heartbeat(device, input, foreground);
|
||||
update(changes);
|
||||
String id = session.getValueString("id");
|
||||
if(!available)
|
||||
{
|
||||
current().screens.remove(id);
|
||||
}
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("sessionId", id);
|
||||
response.put("status", "ACTIVE");
|
||||
response.put("serverTime", Instant.now().toString());
|
||||
response.put("heartbeatIntervalMillis", 5000);
|
||||
response.put("supportedCaptureModes", List.of("APP_CONTENT", "DEVICE_SCREEN"));
|
||||
if(available && "APPROVED".equals(MobileService.get(MobileScannersQBitProducer.DEVICE, deviceId).getValueString("status")))
|
||||
{
|
||||
Map<String, Object> lease = current().screens.activeLease(id);
|
||||
if(lease != null)
|
||||
{
|
||||
response.put("screenMirror", lease);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An operator can end only the session bound to their current credentials.
|
||||
***************************************************************************/
|
||||
static Map<String, Object> endCurrent(Map<String, Object> input) throws QException
|
||||
{
|
||||
String reason = MobileService.required(input, "reason", 30);
|
||||
if(!List.of("SIGN_OUT", "SERVER_CHANGE").contains(reason))
|
||||
{
|
||||
throw new QBadRequestException("Unknown session end reason.");
|
||||
}
|
||||
QRecord session = observe(currentDevice());
|
||||
Instant endedAt = end(session.getValueString("id"), reason, false);
|
||||
return Map.of("sessionId", session.getValueString("id"), "status", "ENDED", "endedAt", endedAt.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Standard logout remains usable by clients that predate session telemetry.
|
||||
***************************************************************************/
|
||||
static void recordLogout() throws QException
|
||||
{
|
||||
QRecord binding = MobileService.get(MobileScannersQBitProducer.SESSION_BINDING, MobileService.hash(authenticatedUuid()));
|
||||
if(binding != null)
|
||||
{
|
||||
end(binding.getValueString("sessionId"), "SIGN_OUT", false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Persist the tombstone before calling provider cleanup; never trust a no-op.
|
||||
***************************************************************************/
|
||||
static Instant end(String id, String reason, boolean administrator) throws QException
|
||||
{
|
||||
synchronized(current())
|
||||
{
|
||||
if(administrator)
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.ADMIN);
|
||||
}
|
||||
QRecord session = MobileService.get(MobileScannersQBitProducer.SESSION, id);
|
||||
if(session == null)
|
||||
{
|
||||
throw new QBadRequestException("Session not found.");
|
||||
}
|
||||
QRecord binding = bindingFor(id);
|
||||
if(!administrator && (binding == null || !authenticatedUuid().equals(binding.getValueString("coreSessionUuid"))))
|
||||
{
|
||||
throw new QPermissionDeniedException("This session does not belong to this sign-in.");
|
||||
}
|
||||
Instant endedAt = session.getValueInstant("endedAt");
|
||||
if(!"ENDED".equals(session.getValueString("status")))
|
||||
{
|
||||
endedAt = Instant.now();
|
||||
update(new QRecord().withValue("id", id).withValue("status", "ENDED").withValue("endedAt", endedAt)
|
||||
.withValue("endedBy", MobileService.userId()).withValue("endReason", reason == null ? "ADMINISTRATOR" : reason)
|
||||
.withValue("screenAvailable", false).withValue("foreground", false).withValue("authCleanupStatus", "PENDING"));
|
||||
}
|
||||
current().screens.remove(id);
|
||||
if(binding != null)
|
||||
{
|
||||
String coreUuid = binding.getValueString("coreSessionUuid");
|
||||
if(MobileService.TRANSACTION.get() == null)
|
||||
{
|
||||
cleanupAuthentication(id, coreUuid);
|
||||
}
|
||||
}
|
||||
return endedAt;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Provider cleanup is retried by an idempotent end; the tombstone is final.
|
||||
***************************************************************************/
|
||||
private static void cleanupAuthentication(String id, String coreUuid) throws QException
|
||||
{
|
||||
update(new QRecord().withValue("id", id).withValue("authCleanupStatus", "REQUESTED").withValue("authCleanupAt", Instant.now()));
|
||||
try
|
||||
{
|
||||
LogoutInput logout = new LogoutInput();
|
||||
logout.setSessionUUID(coreUuid);
|
||||
new LogoutExecutor().execute(logout, new LogoutResponseV1());
|
||||
QSessionStoreHelper.removeSession(coreUuid);
|
||||
}
|
||||
catch(QException failure)
|
||||
{
|
||||
LOG.warn("{\"message\":\"Mobile authentication cleanup requires retry\",\"category\":\"provider-cleanup-failure\",\"operation\":\"cleanup-authentication\"}");
|
||||
update(new QRecord().withValue("id", id).withValue("authCleanupStatus", "RETRY_NEEDED"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Commit every selected tombstone before any external authentication cleanup.
|
||||
***************************************************************************/
|
||||
static void endAll(List<String> ids, String reason) throws QException
|
||||
{
|
||||
synchronized(current())
|
||||
{
|
||||
requireNotEnded();
|
||||
String caller = authenticatedUuid();
|
||||
List<CleanupTarget> targets = new ArrayList<>();
|
||||
MobileService.transaction(() ->
|
||||
{
|
||||
for(String id : ids)
|
||||
{
|
||||
end(id, reason, true);
|
||||
QRecord binding = bindingFor(id);
|
||||
if(binding != null)
|
||||
{
|
||||
targets.add(new CleanupTarget(id, binding.getValueString("coreSessionUuid")));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
targets.sort(java.util.Comparator.comparing(target -> caller.equals(target.coreUuid())));
|
||||
for(CleanupTarget target : targets)
|
||||
{
|
||||
cleanupAuthentication(target.id(), target.coreUuid());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Server-only cleanup snapshot, never serialized into process/API values.
|
||||
***************************************************************************/
|
||||
private record CleanupTarget(String id, String coreUuid)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Frame validation and storage occur only for the authenticated bound owner.
|
||||
***************************************************************************/
|
||||
static void upload(Map<String, Object> input) throws QException
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.SCAN);
|
||||
synchronized(current())
|
||||
{
|
||||
String device = currentDevice();
|
||||
MobileService.requireApproved(device);
|
||||
QRecord session = observe(device);
|
||||
MobileCaptureMode mode = MobileCaptureMode.parse(session.getValueString("captureMode"));
|
||||
if(!Boolean.TRUE.equals(session.getValueBoolean("screenAvailable"))
|
||||
|| (mode == MobileCaptureMode.APP_CONTENT && !Boolean.TRUE.equals(session.getValueBoolean("foreground")))
|
||||
|| mode != MobileCaptureMode.parse(input.get("captureMode")))
|
||||
{
|
||||
throw new QPermissionDeniedException("Screen capture is unavailable.");
|
||||
}
|
||||
current().screens.upload(session.getValueString("id"), input);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Admin view refresh grants short-lived demand, not persistent recording.
|
||||
***************************************************************************/
|
||||
static MobileScreenService.View view(String id) throws QException
|
||||
{
|
||||
MobilePermissions.require(MobilePermissions.ADMIN);
|
||||
synchronized(current())
|
||||
{
|
||||
QRecord session = MobileService.get(MobileScannersQBitProducer.SESSION, id);
|
||||
if(session == null)
|
||||
{
|
||||
throw new QBadRequestException("Session not found.");
|
||||
}
|
||||
String state = "Live";
|
||||
if(!"ACTIVE".equals(session.getValueString("status")))
|
||||
{
|
||||
state = "Session ended";
|
||||
}
|
||||
else if(session.getValueInstant("lastSeenAt").plusSeconds(15).isBefore(Instant.now()))
|
||||
{
|
||||
state = "Offline: no recent heartbeat";
|
||||
}
|
||||
else if(!"APPROVED".equals(MobileService.get(MobileScannersQBitProducer.DEVICE, session.getValueString("deviceId")).getValueString("status")))
|
||||
{
|
||||
state = "Device is not approved";
|
||||
}
|
||||
else if(!Boolean.TRUE.equals(session.getValueBoolean("screenAvailable")))
|
||||
{
|
||||
state = "Screen unavailable: " + java.util.Objects.toString(session.getValueString("captureBlockedReason"), "Capture is unavailable");
|
||||
}
|
||||
if(!"Live".equals(state))
|
||||
{
|
||||
current().screens.remove(id);
|
||||
return new MobileScreenService.View(state, null, null);
|
||||
}
|
||||
return current().screens.view(id, MobileCaptureMode.parse(session.getValueString("captureMode")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Private core session IDs are never queried through the public table API.
|
||||
***************************************************************************/
|
||||
private static QRecord bindingFor(String id) throws QException
|
||||
{
|
||||
return MobileService.internally(() -> new QueryAction().execute(new QueryInput().withTableName(MobileScannersQBitProducer.SESSION_BINDING)
|
||||
.withTransaction(MobileService.TRANSACTION.get()).withShouldOmitHiddenFields(false).withFilter(new QQueryFilter(new QFilterCriteria("sessionId", QCriteriaOperator.EQUALS, id)).withLimit(1)))
|
||||
.getRecords().stream().findFirst().orElse(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Changes use QQQ actions and publish safe invalidation hints only.
|
||||
***************************************************************************/
|
||||
private static void update(QRecord record) throws QException
|
||||
{
|
||||
MobileService.internally(() ->
|
||||
{
|
||||
var saved = new UpdateAction().execute(new UpdateInput().withTableName(MobileScannersQBitProducer.SESSION).withTransaction(MobileService.TRANSACTION.get()).withRecord(record)).getRecords();
|
||||
if(saved == null || saved.size() != 1 || (saved.getFirst().getErrors() != null && !saved.getFirst().getErrors().isEmpty()))
|
||||
{
|
||||
throw new QException("The mobile session could not be updated.");
|
||||
}
|
||||
return null;
|
||||
});
|
||||
RealtimeBus.get(QContext.getQInstance()).publish(new RealtimeMessage().withChannel("record:mobileSession:" + record.getValueString("id"))
|
||||
.withEvent("record.changed").withData(Map.of("table", MobileScannersQBitProducer.SESSION, "pk", record.getValueString("id"))));
|
||||
RealtimeBus.get(QContext.getQInstance()).publish(new RealtimeMessage().withChannel("table:mobileSession")
|
||||
.withEvent("table.changed").withData(Map.of("table", MobileScannersQBitProducer.SESSION)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** No request body can manufacture a trusted device identity.
|
||||
***************************************************************************/
|
||||
private static String currentDevice() throws QException
|
||||
{
|
||||
String id = MobilePermissions.REQUEST_DEVICE.get();
|
||||
if(id == null)
|
||||
{
|
||||
throw new QPermissionDeniedException("A verified device is required.");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only QQQ's verified session supplies the private authentication UUID.
|
||||
***************************************************************************/
|
||||
private static String authenticatedUuid() throws QAuthenticationException
|
||||
{
|
||||
var session = QContext.getQSession();
|
||||
if(session == null || session.getUser() == null || session.getUuid() == null || session.getUuid().isBlank())
|
||||
{
|
||||
throw new QAuthenticationException("Sign in to continue.");
|
||||
}
|
||||
return session.getUuid();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Do not coerce text into safety-critical capture state.
|
||||
***************************************************************************/
|
||||
private static boolean bool(Map<String, Object> input, String name) throws QBadRequestException
|
||||
{
|
||||
if(!(input.get(name) instanceof Boolean value))
|
||||
{
|
||||
throw new QBadRequestException(name + " must be a boolean.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A public transport heartbeat; it does not imply user/device authorization.
|
||||
*******************************************************************************/
|
||||
public class MobileStatusSpec extends MobileJsonSpec
|
||||
{
|
||||
/***************************************************************************
|
||||
** Stable heartbeat operation.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return new BasicOperation().withHttpMethod(HttpMethod.GET).withPath(MobileScannersUiApi.PREFIX + "/status")
|
||||
.withOperationId("mobileScannersStatus").withShortSummary("Check mobile server connectivity")
|
||||
.withLongDescription("Public connectivity only. Client must separately show authentication and device approval status.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Connectivity remains visible before login and after credentials expire.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public boolean isSecured()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Typed heartbeat response.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return "MobileScannersStatusV1";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A successful response confirms this server can answer requests.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input)
|
||||
{
|
||||
return Map.of("status", "ok");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.temporal.Temporal;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QCredentialScope;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Server-created per-request session; survives QQQ asynchronous context capture.
|
||||
** No client process value or shared stored session can manufacture this type.
|
||||
*******************************************************************************/
|
||||
final class VerifiedDeviceSession extends QSession
|
||||
{
|
||||
private final String deviceId;
|
||||
private final Authority checked;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Immutable semantic copy of every session field that can grant or narrow
|
||||
** access. QCredentialScope has no value equality, so its fields are copied.
|
||||
***************************************************************************/
|
||||
private record Authority(String userId, String uuid, String channel, Set<String> permissions, Map<String, Set<Serializable>> securityKeys,
|
||||
Map<String, Serializable> variants, Scope scope, String unverifiable) implements Serializable
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Credential ceilings and auth selectors (credential id, readOnly, channel).
|
||||
***************************************************************************/
|
||||
private record Scope(String credentialId, boolean readOnly, Set<String> apps, Set<String> tables, Set<String> processes,
|
||||
Set<String> reports, Set<String> widgets, Set<String> operationIds) implements Serializable
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Preserve all user restrictions while isolating mutable session state.
|
||||
***************************************************************************/
|
||||
VerifiedDeviceSession(QSession original, String deviceId) throws CloneNotSupportedException
|
||||
{
|
||||
QSession copy = original.clone();
|
||||
this.deviceId = deviceId;
|
||||
setIdReference(copy.getIdReference());
|
||||
setUuid(copy.getUuid());
|
||||
setUser(copy.getUser());
|
||||
setPermissions(copy.getPermissions());
|
||||
setSecurityKeyValues(copy.getSecurityKeyValues());
|
||||
setBackendVariants(copy.getBackendVariants());
|
||||
setValues(copy.getValues());
|
||||
setValuesForFrontend(copy.getValuesForFrontend());
|
||||
setClientChannel(copy.getClientChannel());
|
||||
setCredentialScope(copy.getCredentialScope());
|
||||
this.checked = authority(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Identity verified from the HTTP credential, never process input.
|
||||
***************************************************************************/
|
||||
String deviceId()
|
||||
{
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The authority verified when this wrapper was created.
|
||||
***************************************************************************/
|
||||
Serializable checkedAuthority()
|
||||
{
|
||||
return checked;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Current semantic authority of any session, for comparison with a prior one.
|
||||
***************************************************************************/
|
||||
static Serializable authorityOf(QSession session)
|
||||
{
|
||||
return authority(session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Copies the collections; QSession.clone and QCredentialScope.clone are the
|
||||
** native isolation and copy elements shallowly. Element values that are not
|
||||
** known immutable types could change snapshot and session together, so such
|
||||
** a snapshot is made unequal to every other and the comparison fails closed.
|
||||
***************************************************************************/
|
||||
private static Authority authority(QSession session)
|
||||
{
|
||||
String unverifiable = null;
|
||||
Map<String, Set<Serializable>> keys = null;
|
||||
if(session.getSecurityKeyValues() != null)
|
||||
{
|
||||
keys = new HashMap<>();
|
||||
for(Map.Entry<String, List<Serializable>> entry : session.getSecurityKeyValues().entrySet())
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// core row security tests List.contains: membership is authority, so order and //
|
||||
// repeated identical appends are not changes. A null list stays distinct. //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
Set<Serializable> values = entry.getValue() == null ? null : new HashSet<>(entry.getValue());
|
||||
if(values != null && !values.stream().allMatch(VerifiedDeviceSession::isImmutable))
|
||||
{
|
||||
unverifiable = UUID.randomUUID().toString();
|
||||
}
|
||||
keys.put(entry.getKey(), values);
|
||||
}
|
||||
}
|
||||
Map<String, Serializable> variants = session.getBackendVariants() == null ? null : new HashMap<>(session.getBackendVariants());
|
||||
if(variants != null && !variants.values().stream().allMatch(VerifiedDeviceSession::isImmutable))
|
||||
{
|
||||
unverifiable = UUID.randomUUID().toString();
|
||||
}
|
||||
QCredentialScope scope = session.getCredentialScope();
|
||||
return new Authority(session.getUser() == null ? null : session.getUser().getIdReference(), session.getUuid(), session.getClientChannel(),
|
||||
copy(session.getPermissions()), keys, variants,
|
||||
scope == null ? null : new Scope(scope.getCredentialId(), scope.getReadOnly(), copy(scope.getApps()), copy(scope.getTables()),
|
||||
copy(scope.getProcesses()), copy(scope.getReports()), copy(scope.getWidgets()), copy(scope.getOperationIds())), unverifiable);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only value types that cannot be mutated in place.
|
||||
***************************************************************************/
|
||||
private static boolean isImmutable(Serializable value)
|
||||
{
|
||||
return value == null || value instanceof String || value instanceof Integer || value instanceof Long || value instanceof Short
|
||||
|| value instanceof Byte || value instanceof Double || value instanceof Float || value instanceof BigInteger
|
||||
|| value instanceof BigDecimal || value instanceof Boolean || value instanceof Character || value instanceof Enum<?>
|
||||
|| value instanceof UUID || value instanceof Temporal;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Null means unrestricted, which must stay distinct from an empty set.
|
||||
***************************************************************************/
|
||||
private static Set<String> copy(Set<String> set)
|
||||
{
|
||||
return set == null ? null : new HashSet<>(set);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
-- Copyright (c) 2026 Kingsrook, LLC
|
||||
-- SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
SET TIME ZONE 'UTC';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mobileDevice (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
installationId VARCHAR(200) NOT NULL, displayName VARCHAR(200), manufacturer VARCHAR(200),
|
||||
model VARCHAR(200), osVersion VARCHAR(200), appVersion VARCHAR(200), status VARCHAR(200) NOT NULL CHECK (status IN ('PENDING', 'APPROVED', 'REVOKED')),
|
||||
enrolledBy VARCHAR(200) NOT NULL, approvedBy VARCHAR(200), enrolledAt TIMESTAMP NOT NULL, approvedAt TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mobileDeviceCredential (
|
||||
id VARCHAR(36) PRIMARY KEY, tokenHash VARCHAR(64) NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mobileScan (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
deviceId VARCHAR(200) NOT NULL REFERENCES mobileDevice(id), eventId VARCHAR(200) NOT NULL, userId VARCHAR(200) NOT NULL,
|
||||
appId VARCHAR(200) NOT NULL, processName VARCHAR(200) NOT NULL,
|
||||
barcode VARCHAR(4096) NOT NULL, symbology VARCHAR(200), scanSource VARCHAR(200), manufacturer VARCHAR(200), model VARCHAR(200),
|
||||
osVersion VARCHAR(200), appVersion VARCHAR(200), capturedAt TIMESTAMP NOT NULL, receivedAt TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mobileSession (
|
||||
id VARCHAR(36) PRIMARY KEY, deviceId VARCHAR(36) NOT NULL REFERENCES mobileDevice(id), userId VARCHAR(200) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('ACTIVE', 'ENDED')), startedAt TIMESTAMP NOT NULL,
|
||||
lastSeenAt TIMESTAMP NOT NULL, endedAt TIMESTAMP, endedBy VARCHAR(200), endReason VARCHAR(200),
|
||||
foreground BOOLEAN, screenAvailable BOOLEAN, captureBlockedReason VARCHAR(80), appName VARCHAR(200), processName VARCHAR(200),
|
||||
processInstanceId VARCHAR(200), stepName VARCHAR(200), screenLabel VARCHAR(200), lastAction VARCHAR(80), errorCode VARCHAR(80), traceId VARCHAR(100)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mobileSession_device ON mobileSession(deviceId, startedAt);
|
||||
CREATE TABLE IF NOT EXISTS mobileSessionBinding (
|
||||
id VARCHAR(64) PRIMARY KEY, sessionId VARCHAR(36) NOT NULL UNIQUE REFERENCES mobileSession(id), coreSessionUuid VARCHAR(200) NOT NULL
|
||||
);
|
||||
ALTER TABLE mobileDevice ADD COLUMN IF NOT EXISTS shortName VARCHAR(32);
|
||||
ALTER TABLE mobileDevice ADD COLUMN IF NOT EXISTS description VARCHAR(2000);
|
||||
ALTER TABLE mobileDevice ALTER COLUMN shortName DROP NOT NULL;
|
||||
UPDATE mobileDevice d SET shortName = NULL
|
||||
WHERE shortName = 'scanner-' || SUBSTRING(REPLACE(id, '-', ''), 1, 24)
|
||||
AND EXISTS (SELECT 1 FROM mobileDevice earlier WHERE earlier.shortName = d.shortName AND earlier.id < d.id);
|
||||
MERGE INTO mobileDevice d USING (
|
||||
WITH missing AS (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS ordinal FROM mobileDevice WHERE shortName IS NULL
|
||||
), candidates AS (
|
||||
SELECT X, 'scanner-' || CAST(X AS VARCHAR) AS candidate FROM SYSTEM_RANGE(1, (SELECT COUNT(*) FROM mobileDevice))
|
||||
), available AS (
|
||||
SELECT candidate, ROW_NUMBER() OVER (ORDER BY X) AS ordinal FROM candidates
|
||||
WHERE NOT EXISTS (SELECT 1 FROM mobileDevice existing WHERE existing.shortName = candidates.candidate)
|
||||
)
|
||||
SELECT missing.id, available.candidate FROM missing JOIN available ON missing.ordinal = available.ordinal
|
||||
) allocated ON d.id = allocated.id
|
||||
WHEN MATCHED THEN UPDATE SET shortName = allocated.candidate;
|
||||
ALTER TABLE mobileDevice ALTER COLUMN shortName SET NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mobileDevice_shortName ON mobileDevice(shortName);
|
||||
ALTER TABLE mobileDevice ADD COLUMN IF NOT EXISTS lastSeenAt TIMESTAMP;
|
||||
ALTER TABLE mobileDevice ADD COLUMN IF NOT EXISTS locationStatus VARCHAR(30);
|
||||
ALTER TABLE mobileDevice ADD COLUMN IF NOT EXISTS latitude NUMERIC(12,8);
|
||||
ALTER TABLE mobileDevice ADD COLUMN IF NOT EXISTS longitude NUMERIC(12,8);
|
||||
ALTER TABLE mobileDevice ADD COLUMN IF NOT EXISTS accuracyMeters NUMERIC(18,4);
|
||||
ALTER TABLE mobileDevice ADD COLUMN IF NOT EXISTS locationCapturedAt TIMESTAMP;
|
||||
ALTER TABLE mobileDevice ADD COLUMN IF NOT EXISTS locationReceivedAt TIMESTAMP;
|
||||
CREATE INDEX IF NOT EXISTS mobileDevice_lastSeenAt ON mobileDevice(lastSeenAt);
|
||||
ALTER TABLE mobileSession ADD COLUMN IF NOT EXISTS authCleanupStatus VARCHAR(80);
|
||||
ALTER TABLE mobileSession ADD COLUMN IF NOT EXISTS authCleanupAt TIMESTAMP;
|
||||
ALTER TABLE mobileSession ADD COLUMN IF NOT EXISTS captureMode VARCHAR(20) DEFAULT 'APP_CONTENT';
|
||||
DELETE FROM mobileDeviceCredential WHERE NOT EXISTS (SELECT 1 FROM mobileDevice WHERE mobileDevice.id = mobileDeviceCredential.id);
|
||||
|
||||
ALTER TABLE mobileSession ALTER COLUMN startedAt TIMESTAMP;
|
||||
ALTER TABLE mobileSession ALTER COLUMN lastSeenAt TIMESTAMP;
|
||||
ALTER TABLE mobileSession ALTER COLUMN endedAt TIMESTAMP;
|
||||
ALTER TABLE mobileSession ALTER COLUMN authCleanupAt TIMESTAMP;
|
||||
ALTER TABLE mobileDevice ALTER COLUMN enrolledAt TIMESTAMP;
|
||||
ALTER TABLE mobileDevice ALTER COLUMN approvedAt TIMESTAMP;
|
||||
ALTER TABLE mobileDevice ALTER COLUMN lastSeenAt TIMESTAMP;
|
||||
ALTER TABLE mobileDevice ALTER COLUMN locationCapturedAt TIMESTAMP;
|
||||
ALTER TABLE mobileDevice ALTER COLUMN locationReceivedAt TIMESTAMP;
|
||||
ALTER TABLE mobileScan ALTER COLUMN capturedAt TIMESTAMP;
|
||||
ALTER TABLE mobileScan ALTER COLUMN receivedAt TIMESTAMP;
|
||||
-- Extension 1.5 optional exact capture metadata. NULL means absent for historical rows, older clients and manual entry.
|
||||
ALTER TABLE mobileScan ADD COLUMN IF NOT EXISTS aimIdentifier VARCHAR(3);
|
||||
ALTER TABLE mobileScan ADD COLUMN IF NOT EXISTS rawDataBase64 VARCHAR(10924);
|
||||
@@ -0,0 +1,92 @@
|
||||
-- Copyright (c) 2026 Kingsrook, LLC
|
||||
-- SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
-- PostgreSQL 17 fresh-install schema for Mobile Scanners: the final state of db/mobile-scanners-h2.sql (the H2 file carries
|
||||
-- the upgrade history of older installs, written with H2-only syntax). Physical names are the metadata names, quoted,
|
||||
-- and quoted, because QQQ quotes every identifier it issues (case preserved). Idempotent, run by the schema owner
|
||||
-- (never the runtime role), which then needs SELECT, INSERT, UPDATE and DELETE on these tables.
|
||||
-- mobileScan.aimIdentifier and rawDataBase64 are the Extension 1.5 optional exact capture metadata (NULL = absent).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "mobileDevice" (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
"installationId" VARCHAR(200) NOT NULL,
|
||||
"displayName" VARCHAR(200),
|
||||
manufacturer VARCHAR(200),
|
||||
model VARCHAR(200),
|
||||
"osVersion" VARCHAR(200),
|
||||
"appVersion" VARCHAR(200),
|
||||
status VARCHAR(200) NOT NULL CHECK (status IN ('PENDING', 'APPROVED', 'REVOKED')),
|
||||
"enrolledBy" VARCHAR(200) NOT NULL,
|
||||
"approvedBy" VARCHAR(200),
|
||||
"enrolledAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
"approvedAt" TIMESTAMP WITH TIME ZONE,
|
||||
"shortName" VARCHAR(32) NOT NULL,
|
||||
description VARCHAR(2000),
|
||||
"lastSeenAt" TIMESTAMP WITH TIME ZONE,
|
||||
"locationStatus" VARCHAR(30),
|
||||
latitude NUMERIC(12,8),
|
||||
longitude NUMERIC(12,8),
|
||||
"accuracyMeters" NUMERIC(18,4),
|
||||
"locationCapturedAt" TIMESTAMP WITH TIME ZONE,
|
||||
"locationReceivedAt" TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mobileDevice_shortName ON "mobileDevice"("shortName");
|
||||
CREATE INDEX IF NOT EXISTS mobileDevice_lastSeenAt ON "mobileDevice"("lastSeenAt");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "mobileDeviceCredential" (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
"tokenHash" VARCHAR(64) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "mobileScan" (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
"deviceId" VARCHAR(200) NOT NULL REFERENCES "mobileDevice"(id),
|
||||
"eventId" VARCHAR(200) NOT NULL,
|
||||
"userId" VARCHAR(200) NOT NULL,
|
||||
"appId" VARCHAR(200) NOT NULL,
|
||||
"processName" VARCHAR(200) NOT NULL,
|
||||
barcode VARCHAR(4096) NOT NULL,
|
||||
symbology VARCHAR(200),
|
||||
"scanSource" VARCHAR(200),
|
||||
manufacturer VARCHAR(200),
|
||||
model VARCHAR(200),
|
||||
"osVersion" VARCHAR(200),
|
||||
"appVersion" VARCHAR(200),
|
||||
"capturedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
"receivedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
"aimIdentifier" VARCHAR(3),
|
||||
"rawDataBase64" VARCHAR(10924)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "mobileSession" (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
"deviceId" VARCHAR(36) NOT NULL REFERENCES "mobileDevice"(id),
|
||||
"userId" VARCHAR(200) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('ACTIVE', 'ENDED')),
|
||||
"startedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
"lastSeenAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
"endedAt" TIMESTAMP WITH TIME ZONE,
|
||||
"endedBy" VARCHAR(200),
|
||||
"endReason" VARCHAR(200),
|
||||
foreground BOOLEAN,
|
||||
"screenAvailable" BOOLEAN,
|
||||
"captureBlockedReason" VARCHAR(80),
|
||||
"appName" VARCHAR(200),
|
||||
"processName" VARCHAR(200),
|
||||
"processInstanceId" VARCHAR(200),
|
||||
"stepName" VARCHAR(200),
|
||||
"screenLabel" VARCHAR(200),
|
||||
"lastAction" VARCHAR(80),
|
||||
"errorCode" VARCHAR(80),
|
||||
"traceId" VARCHAR(100),
|
||||
"authCleanupStatus" VARCHAR(80),
|
||||
"authCleanupAt" TIMESTAMP WITH TIME ZONE,
|
||||
"captureMode" VARCHAR(20) DEFAULT 'APP_CONTENT'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mobileSession_device ON "mobileSession"("deviceId", "startedAt");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "mobileSessionBinding" (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
"sessionId" VARCHAR(36) NOT NULL UNIQUE REFERENCES "mobileSession"(id),
|
||||
"coreSessionUuid" VARCHAR(200) NOT NULL
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import io.javalin.Javalin;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Branding is public appearance data, never an authentication requirement.
|
||||
*******************************************************************************/
|
||||
class MobileBrandingTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** Reject markup, controls, CSS and credential-bearing or non-HTTPS logos.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void rejectsInvalidConfiguration()
|
||||
{
|
||||
MobileScannersQBitConfig config = new MobileScannersQBitConfig();
|
||||
for(String name : List.of("", " ", " name", "name ", "x\ny", "<b>name</b>", "x".repeat(81)))
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class, () -> config.withDisplayName(name));
|
||||
}
|
||||
for(String color : List.of("red", "#FFF", "#11223344", "#gg2233", " #112233"))
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class, () -> config.withPrimaryColor(color));
|
||||
}
|
||||
for(String url : List.of("", "/logo.png", "http://example.com/logo.png", "data:image/png;base64,x", "file:///logo.png",
|
||||
"https://user:password@example.com/logo.png", "https://example.com/logo.png#fragment", "https://example.com:99999/logo.png",
|
||||
"https://example.com/" + "a".repeat(2048)))
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class, () -> config.withLogoUrl(url));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> config.withDisplayName(null));
|
||||
assertThrows(IllegalArgumentException.class, () -> config.withPrimaryColor(null));
|
||||
assertEquals("#AABBCC", config.withPrimaryColor("#aabbcc").getPrimaryColor());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Defaults remain available with no session and stale device credentials.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void publicDefaultsIgnoreStaleDeviceCredentials() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-mobile-scanners")
|
||||
.withVersion("0.1.0-SNAPSHOT").withNamespace("mobile").withConfig(new MobileScannersQBitConfig().withBackendName("privateBackend")));
|
||||
MobileScannersRouteProvider provider = new MobileScannersRouteProvider(instance);
|
||||
Javalin server = Javalin.create(config ->
|
||||
{
|
||||
config.jetty.host = "127.0.0.1";
|
||||
config.routes.apiBuilder(provider.getJavalinEndpointGroup());
|
||||
config.routes.apiBuilder(() ->
|
||||
{
|
||||
MobileBrandingSpec brandingSpec = new MobileBrandingSpec();
|
||||
brandingSpec.setQInstance(instance);
|
||||
brandingSpec.defineRoute(new MiddlewareVersionV1(), "v1");
|
||||
MobileStatusSpec statusSpec = new MobileStatusSpec();
|
||||
statusSpec.setQInstance(instance);
|
||||
statusSpec.defineRoute(new MiddlewareVersionV1(), "v1");
|
||||
});
|
||||
}).start(0);
|
||||
try
|
||||
{
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + server.port() + "/qqq/v1/qbit/mobile-scanners/branding"))
|
||||
.timeout(Duration.ofSeconds(5)).header(MobileScannersRouteProvider.DEVICE_HEADER, "stale")
|
||||
.header(MobileScannersRouteProvider.TOKEN_HEADER, "invalid").GET().build();
|
||||
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("no-store", response.headers().firstValue("Cache-Control").orElseThrow());
|
||||
JSONObject branding = new JSONObject(response.body());
|
||||
assertEquals(2, branding.length());
|
||||
assertEquals("QQQ Mobile Scanners", branding.getString("displayName"));
|
||||
assertEquals("#1565C0", branding.getString("primaryColor"));
|
||||
HttpRequest heartbeat = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + server.port() + "/qqq/v1/qbit/mobile-scanners/status"))
|
||||
.timeout(Duration.ofSeconds(5)).header(MobileScannersRouteProvider.DEVICE_HEADER, "stale")
|
||||
.header(MobileScannersRouteProvider.TOKEN_HEADER, "invalid").GET().build();
|
||||
var status = HttpClient.newHttpClient().send(heartbeat, HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, status.statusCode());
|
||||
assertEquals("no-store", status.headers().firstValue("Cache-Control").orElseThrow());
|
||||
JSONObject health = new JSONObject(status.body());
|
||||
assertEquals(1, health.length());
|
||||
assertEquals("ok", health.getString("status"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deterministic published contract, compatibility baseline and registration.
|
||||
*******************************************************************************/
|
||||
class MobileScannersApiContractTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** Regeneration is explicit; ordinary builds detect drift and breaking edits.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void committedContractMatchesRegisteredSpecs() throws Exception
|
||||
{
|
||||
var extension = MobileScannersUiApi.extension();
|
||||
var version = new MiddlewareVersionV1();
|
||||
String generated = UiApiContract.generateExtensionYaml(version, extension);
|
||||
Path contract = Path.of("src/main/resources/openapi/qqq-v1/mobile-scanners.yaml");
|
||||
Path baseline = Path.of("src/test/resources/openapi/mobile-scanners-1.0.0.yaml");
|
||||
if(Boolean.getBoolean("mobile.openapi.update"))
|
||||
{
|
||||
Files.createDirectories(contract.getParent());
|
||||
Files.writeString(contract, generated);
|
||||
}
|
||||
if(Boolean.getBoolean("mobile.openapi.initializeBaseline") && !Files.exists(baseline))
|
||||
{
|
||||
Files.createDirectories(baseline.getParent());
|
||||
Files.writeString(baseline, generated);
|
||||
}
|
||||
assertEquals(java.util.List.of(), UiApiContract.check(generated, Files.readString(contract), Files.readString(baseline), 1,
|
||||
"mvnw.cmd -Dtest=MobileScannersApiContractTest -Dmobile.openapi.update=true test"));
|
||||
Path sessionBaseline = Path.of("src/test/resources/openapi/mobile-scanners-1.1.0.yaml");
|
||||
if(Boolean.getBoolean("mobile.openapi.initializeSessionBaseline") && !Files.exists(sessionBaseline))
|
||||
{
|
||||
Files.writeString(sessionBaseline, generated);
|
||||
}
|
||||
assertEquals(java.util.List.of(), UiApiContract.check(generated, Files.readString(contract), Files.readString(sessionBaseline), 1, "Regenerate the current extension contract."));
|
||||
var model = version.generateExtensionOpenAPIModel("qqq", extension);
|
||||
assertEquals(Set.of("/qqq/v1/qbit/mobile-scanners/branding", "/qqq/v1/qbit/mobile-scanners/status",
|
||||
"/qqq/v1/qbit/mobile-scanners/devices/enroll", "/qqq/v1/qbit/mobile-scanners/devices/current",
|
||||
"/qqq/v1/qbit/mobile-scanners/sessions/heartbeat", "/qqq/v1/qbit/mobile-scanners/sessions/current/end",
|
||||
"/qqq/v1/qbit/mobile-scanners/sessions/current/screen"), model.getPaths().keySet());
|
||||
assertTrue(model.getComponents().getSchemas().keySet().stream().allMatch(name -> name.startsWith("MobileScanners") || name.equals("BasicErrorResponseV1")));
|
||||
for(String schema : MobileScannersSchemas.components().keySet())
|
||||
{
|
||||
assertTrue(model.getComponents().getSchemas().containsKey(schema), schema);
|
||||
}
|
||||
assertEquals(7, extension.getSpecs().size());
|
||||
assertEquals("1.6.0", extension.getSemanticVersion());
|
||||
for(String previous : java.util.List.of("1.4.0", "1.5.0", "1.6.0"))
|
||||
{
|
||||
assertEquals(java.util.List.of(), UiApiContract.check(generated, Files.readString(contract), Files.readString(Path.of("src/test/resources/openapi/mobile-scanners-" + previous + ".yaml")), 1,
|
||||
"Regenerate the current extension contract."));
|
||||
}
|
||||
var heartbeatRequest = model.getComponents().getSchemas().get("MobileScannersHeartbeatRequestV1");
|
||||
for(String optional : MobileService.DEVICE_INFO_FIELDS)
|
||||
{
|
||||
assertTrue(heartbeatRequest.getProperties().containsKey(optional), optional);
|
||||
assertEquals(200, heartbeatRequest.getProperties().get(optional).getMaxLength(), optional);
|
||||
assertFalse(generated.contains("- \"" + optional + "\""), optional + " must stay optional, never a required key.");
|
||||
}
|
||||
var scanEvent = model.getComponents().getSchemas().get("MobileScannersScanEventV1");
|
||||
var scanContext = model.getComponents().getSchemas().get("MobileScannersScanContextV1");
|
||||
for(String optional : java.util.List.of("aimIdentifier", "rawDataBase64"))
|
||||
{
|
||||
assertTrue(scanEvent.getProperties().containsKey(optional), optional);
|
||||
assertTrue(scanContext.getProperties().containsKey(optional), optional);
|
||||
assertFalse(generated.contains("- \"" + optional + "\""), optional + " must stay optional, never a required key.");
|
||||
}
|
||||
assertEquals(3, scanEvent.getProperties().get("aimIdentifier").getMaxLength());
|
||||
assertEquals(10924, scanEvent.getProperties().get("rawDataBase64").getMaxLength());
|
||||
assertTrue(generated.contains("rawDataBase64"));
|
||||
assertTrue(generated.contains("aimIdentifier"));
|
||||
assertTrue(generated.contains("mobileScannersEnrollDevice"));
|
||||
assertTrue(generated.contains("X-QQQ-Device-Token"));
|
||||
assertTrue(generated.contains("acknowledgedEventId"));
|
||||
assertTrue(generated.contains("acknowledgmentLabel"));
|
||||
assertTrue(generated.contains("manualEntryEnabled"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Re-registering replaces the same extension, never adds duplicate routes.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void registrationIsStable()
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
MobileScannersUiApi.register(instance);
|
||||
MobileScannersUiApi.register(instance);
|
||||
assertEquals(Set.of("mobile-scanners"), MiddlewareSpecExtensionsMetaData.of(instance).getExtensions().keySet());
|
||||
assertEquals("/qbit/mobile-scanners", MobileScannersUiApi.extension().getEffectivePathPrefix());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Behavioral limits, sequence, demand expiry and immediate pixel removal.
|
||||
*******************************************************************************/
|
||||
class MobileScreenServiceTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** Full-device pixels require an explicit matching lease, never legacy scope.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void captureModeIsBoundToLeaseAndChangingModeClearsPixels() throws Exception
|
||||
{
|
||||
MobileScreenService screens = new MobileScreenService(new MutableClock());
|
||||
screens.view("session");
|
||||
String appLease = (String) screens.activeLease("session").get("leaseId");
|
||||
screens.upload("session", frame(appLease, 0));
|
||||
assertNotNull(screens.view("session").dataUri());
|
||||
assertNull(screens.view("session", MobileCaptureMode.DEVICE_SCREEN).dataUri());
|
||||
String deviceLease = (String) screens.activeLease("session").get("leaseId");
|
||||
assertEquals("DEVICE_SCREEN", screens.activeLease("session").get("captureMode"));
|
||||
assertThrows(QException.class, () -> screens.upload("session", frame(appLease, 1)));
|
||||
assertThrows(QException.class, () -> screens.upload("session", frame(deviceLease, 0)));
|
||||
Map<String, Object> deviceFrame = frame(deviceLease, 0);
|
||||
deviceFrame.put("captureMode", "DEVICE_SCREEN");
|
||||
screens.upload("session", deviceFrame);
|
||||
assertNotNull(screens.view("session", MobileCaptureMode.DEVICE_SCREEN).dataUri());
|
||||
assertNull(screens.view("session").dataUri());
|
||||
assertThrows(QException.class, () -> screens.upload("session", deviceFrame));
|
||||
assertThrows(QException.class, () -> MobileCaptureMode.parse("UNKNOWN"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A viewer lease is required, and closing it eventually ends capture demand.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void leaseExpiryAndBlockedCaptureDiscardFrames() throws Exception
|
||||
{
|
||||
MutableClock clock = new MutableClock();
|
||||
MobileScreenService screens = new MobileScreenService(clock);
|
||||
assertNull(screens.activeLease("session"));
|
||||
assertThrows(QException.class, () -> screens.upload("session", frame(UUID.randomUUID().toString(), 0)));
|
||||
screens.view("session");
|
||||
var lease = screens.activeLease("session");
|
||||
assertEquals(0L, lease.get("nextSequence"));
|
||||
screens.upload("session", frame((String) lease.get("leaseId"), 0));
|
||||
assertNotNull(screens.view("session").dataUri());
|
||||
screens.remove("session");
|
||||
assertNull(screens.view("session").dataUri());
|
||||
assertEquals(0L, screens.activeLease("session").get("nextSequence"));
|
||||
clock.advance(31);
|
||||
assertNull(screens.activeLease("session"));
|
||||
assertThrows(QException.class, () -> screens.upload("session", frame((String) lease.get("leaseId"), 1)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Oversize encodings and misleading image dimensions fail before retention.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void rejectsOversizeMalformedAndMismatchedFrames() throws Exception
|
||||
{
|
||||
MobileScreenService screens = new MobileScreenService();
|
||||
screens.view("session");
|
||||
String lease = (String) screens.activeLease("session").get("leaseId");
|
||||
Map<String, Object> frame = frame(lease, 0);
|
||||
frame.put("imageBase64", "A".repeat(349529));
|
||||
assertThrows(QException.class, () -> screens.upload("session", frame));
|
||||
frame.put("imageBase64", "not base64");
|
||||
assertThrows(QException.class, () -> screens.upload("session", frame));
|
||||
frame.put("imageBase64", frame(lease, 0).get("imageBase64"));
|
||||
frame.put("width", 640);
|
||||
assertThrows(QException.class, () -> screens.upload("session", frame));
|
||||
frame.put("width", 2);
|
||||
frame.put("mimeType", "image/svg+xml");
|
||||
assertThrows(QException.class, () -> screens.upload("session", frame));
|
||||
assertNull(screens.view("session").dataUri());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Sequence is scoped to the lease and server limits prevent upload flooding.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void rejectsOldSequenceAndRateAndDropsEndedSession() throws Exception
|
||||
{
|
||||
MutableClock clock = new MutableClock();
|
||||
MobileScreenService screens = new MobileScreenService(clock);
|
||||
screens.view("session");
|
||||
String lease = (String) screens.activeLease("session").get("leaseId");
|
||||
screens.upload("session", frame(lease, 5));
|
||||
assertThrows(MobileScreenService.FrameConflictException.class, () -> screens.upload("session", frame(lease, 6)));
|
||||
clock.advance(1);
|
||||
assertThrows(MobileScreenService.FrameConflictException.class, () -> screens.upload("session", frame(lease, 5)));
|
||||
screens.upload("session", frame(lease, 6));
|
||||
assertEquals(7L, screens.activeLease("session").get("nextSequence"));
|
||||
screens.remove("session");
|
||||
assertNull(screens.activeLease("session"));
|
||||
screens.view("session");
|
||||
assertEquals(0L, screens.activeLease("session").get("nextSequence"));
|
||||
assertNull(screens.view("session").dataUri());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Generated non-user pixels keep test artifacts free of screen data.
|
||||
***************************************************************************/
|
||||
private static Map<String, Object> frame(String lease, long sequence) throws Exception
|
||||
{
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
ImageIO.write(new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB), "jpeg", output);
|
||||
return new HashMap<>(Map.of("leaseId", lease, "sequence", sequence, "capturedAt", Instant.now().toString(),
|
||||
"width", 2, "height", 2, "mimeType", "image/jpeg", "imageBase64", Base64.getEncoder().encodeToString(output.toByteArray())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Deterministic expiry tests without sleeping.
|
||||
***************************************************************************/
|
||||
private static class MutableClock extends Clock
|
||||
{
|
||||
private Instant now = Instant.parse("2026-10-30T12:00:00Z");
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Advance only this test's clock.
|
||||
***********************************************************************/
|
||||
void advance(long seconds)
|
||||
{
|
||||
now = now.plusSeconds(seconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Server time is always UTC.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public ZoneId getZone()
|
||||
{
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Zone has no effect on instant comparisons.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Return the explicitly controlled instant.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public Instant instant()
|
||||
{
|
||||
return now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
components:
|
||||
schemas:
|
||||
BasicErrorResponseV1:
|
||||
description: "The error body of every UI API operation (core and extensions)."
|
||||
properties:
|
||||
error:
|
||||
description: "The error message (the same as message; kept for clients of\
|
||||
\ UI API 1.0 to 1.5)."
|
||||
type: "string"
|
||||
code:
|
||||
description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\
|
||||
\ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\
|
||||
\ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\
|
||||
\ Extensions may add codes; treat an unknown code by the HTTP status.\
|
||||
\ Since 1.6.0."
|
||||
type: "string"
|
||||
message:
|
||||
description: "A message to show the user. Since 1.6.0."
|
||||
type: "string"
|
||||
fieldErrors:
|
||||
description: "Errors about particular request fields, when known. Since\
|
||||
\ 1.6.0."
|
||||
items:
|
||||
properties:
|
||||
field:
|
||||
description: "The field (or parameter) name."
|
||||
type: "string"
|
||||
message:
|
||||
description: "The error about that field."
|
||||
type: "string"
|
||||
code:
|
||||
description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\
|
||||
\ PATTERN."
|
||||
type: "string"
|
||||
type: "object"
|
||||
type: "array"
|
||||
traceId:
|
||||
description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\
|
||||
\ response header); quote it when reporting a problem. Since 1.6.0."
|
||||
type: "string"
|
||||
type: "object"
|
||||
MobileScannersBrandingV1:
|
||||
description: "Customer appearance; displayName and primaryColor are always present;\
|
||||
\ logoUrl is optional."
|
||||
properties:
|
||||
displayName:
|
||||
maxLength: 80
|
||||
type: "string"
|
||||
logoUrl:
|
||||
description: "Optional HTTPS image URL, no credentials or fragment."
|
||||
format: "uri"
|
||||
maxLength: 2048
|
||||
type: "string"
|
||||
primaryColor:
|
||||
description: "Six-digit CSS hex color, including #."
|
||||
maxLength: 7
|
||||
type: "string"
|
||||
required:
|
||||
- "displayName"
|
||||
- "primaryColor"
|
||||
type: "object"
|
||||
MobileScannersDeviceV1:
|
||||
description: "All properties present. No credential material is returned."
|
||||
properties:
|
||||
deviceId:
|
||||
format: "uuid"
|
||||
maxLength: 36
|
||||
type: "string"
|
||||
displayName:
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
status:
|
||||
enum:
|
||||
- "PENDING"
|
||||
- "APPROVED"
|
||||
- "REVOKED"
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
required:
|
||||
- "deviceId"
|
||||
- "displayName"
|
||||
- "status"
|
||||
type: "object"
|
||||
MobileScannersEnrollRequestV1:
|
||||
description: "installationId is required. Other strings are optional. JSON body\
|
||||
\ maximum is 4096 characters."
|
||||
properties:
|
||||
appVersion:
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
displayName:
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
installationId:
|
||||
format: "uuid"
|
||||
maxLength: 36
|
||||
type: "string"
|
||||
manufacturer:
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
model:
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
osVersion:
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
required:
|
||||
- "installationId"
|
||||
type: "object"
|
||||
MobileScannersEnrollmentV1:
|
||||
description: "All properties present. deviceToken is a secret returned only\
|
||||
\ once; never log or include it in metadata/process values."
|
||||
properties:
|
||||
deviceId:
|
||||
format: "uuid"
|
||||
maxLength: 36
|
||||
type: "string"
|
||||
deviceToken:
|
||||
maxLength: 128
|
||||
type: "string"
|
||||
status:
|
||||
enum:
|
||||
- "PENDING"
|
||||
- "APPROVED"
|
||||
- "REVOKED"
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
required:
|
||||
- "deviceId"
|
||||
- "deviceToken"
|
||||
- "status"
|
||||
type: "object"
|
||||
MobileScannersScanAcknowledgmentV1:
|
||||
description: "Properties in core process response values after persistence.\
|
||||
\ All are required to acknowledge success: scanSaved=true, acknowledgedEventId\
|
||||
\ matches submitted eventId and scanId is nonempty. Async polling must complete\
|
||||
\ first. A repeated event returns its original scanId."
|
||||
properties:
|
||||
acknowledgedEventId:
|
||||
format: "uuid"
|
||||
maxLength: 36
|
||||
type: "string"
|
||||
scanId:
|
||||
maxLength: 64
|
||||
type: "string"
|
||||
scanSaved:
|
||||
type: "boolean"
|
||||
required:
|
||||
- "acknowledgedEventId"
|
||||
- "scanId"
|
||||
- "scanSaved"
|
||||
type: "object"
|
||||
MobileScannersScanEventV1:
|
||||
description: "Values submitted to the current core process frontend step. barcode,\
|
||||
\ eventId and capturedAt required. eventId remains unchanged on retry; identity\
|
||||
\ comes only from verified user/device context, never these values. Barcode\
|
||||
\ is a nonempty string, preserving leading zeroes."
|
||||
properties:
|
||||
barcode:
|
||||
maxLength: 4096
|
||||
type: "string"
|
||||
capturedAt:
|
||||
format: "date-time"
|
||||
maxLength: 40
|
||||
type: "string"
|
||||
eventId:
|
||||
format: "uuid"
|
||||
maxLength: 36
|
||||
type: "string"
|
||||
scanSource:
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
symbology:
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
required:
|
||||
- "barcode"
|
||||
- "eventId"
|
||||
- "capturedAt"
|
||||
type: "object"
|
||||
MobileScannersScannerBlockV1:
|
||||
description: "SCANNER child block of a core COMPOSITE widget, returned by core\
|
||||
\ process/widget operations. Requires mobile-scanners extension major 1. All\
|
||||
\ properties present; do not infer this contract from process or widget names."
|
||||
properties:
|
||||
blockId:
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
blockType:
|
||||
enum:
|
||||
- "SCANNER"
|
||||
maxLength: 7
|
||||
type: "string"
|
||||
values:
|
||||
$ref: "#/components/schemas/MobileScannersScannerValuesV1"
|
||||
required:
|
||||
- "blockId"
|
||||
- "blockType"
|
||||
- "values"
|
||||
type: "object"
|
||||
MobileScannersScannerValuesV1:
|
||||
description: "SCANNER presentation values. Optional manualLabel defaults to\
|
||||
\ Manual barcode; submitLabel defaults to Submit barcode; maxLength defaults\
|
||||
\ to 4096. Unknown additive properties may be ignored."
|
||||
properties:
|
||||
instructions:
|
||||
maxLength: 2000
|
||||
type: "string"
|
||||
manualLabel:
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
maxLength:
|
||||
description: "Inclusive supported range 1..4096; reject invalid server values."
|
||||
maximum: 4096
|
||||
minimum: 1
|
||||
type: "integer"
|
||||
submitLabel:
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
title:
|
||||
maxLength: 200
|
||||
type: "string"
|
||||
required:
|
||||
- "title"
|
||||
- "instructions"
|
||||
type: "object"
|
||||
MobileScannersStatusV1:
|
||||
description: "Connectivity only; status is always present."
|
||||
properties:
|
||||
status:
|
||||
enum:
|
||||
- "ok"
|
||||
maxLength: 2
|
||||
type: "string"
|
||||
required:
|
||||
- "status"
|
||||
type: "object"
|
||||
securitySchemes:
|
||||
sessionUuidCookie:
|
||||
description: "The session cookie set by POST /qqq/v1/manageSession (web clients)."
|
||||
in: "cookie"
|
||||
name: "sessionUUID"
|
||||
type: "apiKey"
|
||||
bearerAuth:
|
||||
bearerFormat: "JWT or session UUID"
|
||||
description: "`Authorization: Bearer <token>`, where the token is either\n-\
|
||||
\ an OAuth2 access token (a signed JWT) issued by the app's identity provider\
|
||||
\ to the app's client,\n or to its native client (`values.nativeClientId`\
|
||||
\ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\
|
||||
\ grant or auth-code + PKCE. It is verified against the\n provider's keys\
|
||||
\ (issuer, audience, expiry, signature) and gives the same user and permissions\n\
|
||||
\ as signing in on the web. When it expires (401), refresh it at the provider\
|
||||
\ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\
|
||||
\ which the server keeps refreshed."
|
||||
scheme: "bearer"
|
||||
type: "http"
|
||||
oauth2:
|
||||
description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\
|
||||
\ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\
|
||||
\ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\
|
||||
\ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\
|
||||
\ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\
|
||||
\ grant (`device_authorization_endpoint`, RFC 8628), then send the access\
|
||||
\ token\nas `Authorization: Bearer`."
|
||||
flows:
|
||||
authorizationCode:
|
||||
authorizationUrl: "/authorize"
|
||||
scopes:
|
||||
email: "Email address"
|
||||
offline_access: "A refresh token, to renew the access token without signing\
|
||||
\ in again"
|
||||
openid: "OpenID Connect sign-in"
|
||||
profile: "Name, username and groups (permissions come from the groups)"
|
||||
tokenUrl: "/token"
|
||||
type: "oauth2"
|
||||
info:
|
||||
contact:
|
||||
email: "contact@kingsrook.com"
|
||||
description: "Native scanner devices, customer branding and SCANNER process widget\
|
||||
\ contract."
|
||||
title: "QQQ Middleware API extension: mobile-scanners"
|
||||
version: "1.0.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/qbit/mobile-scanners/branding:
|
||||
get:
|
||||
description: "Public appearance data; ignores stale user and device credentials.\
|
||||
\ Never includes private configuration."
|
||||
operationId: "mobileScannersBranding"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "User sign-in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission or device credential denied"
|
||||
500:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Request failed"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MobileScannersBrandingV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Installation already enrolled"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found, or not visible to this user (code NOT_FOUND)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
summary: "Read customer branding"
|
||||
tags:
|
||||
- "mobile-scanners"
|
||||
/qqq/v1/qbit/mobile-scanners/status:
|
||||
get:
|
||||
description: "Public connectivity only. Client must separately show authentication\
|
||||
\ and device approval status."
|
||||
operationId: "mobileScannersStatus"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "User sign-in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission or device credential denied"
|
||||
500:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Request failed"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MobileScannersStatusV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Installation already enrolled"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found, or not visible to this user (code NOT_FOUND)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
summary: "Check mobile server connectivity"
|
||||
tags:
|
||||
- "mobile-scanners"
|
||||
/qqq/v1/qbit/mobile-scanners/devices/enroll:
|
||||
post:
|
||||
description: "Requires a QQQ user session with mobile.scan permission. Returns\
|
||||
\ a secret once; store privately and never log. Device remains PENDING until\
|
||||
\ an administrator approves it. Existing installation IDs return 409 without\
|
||||
\ rotating credentials."
|
||||
operationId: "mobileScannersEnrollDevice"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MobileScannersEnrollRequestV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "User sign-in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission or device credential denied"
|
||||
500:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Request failed"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Installation already enrolled"
|
||||
201:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MobileScannersEnrollmentV1"
|
||||
description: "Pending device and one-time credential"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found, or not visible to this user (code NOT_FOUND)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Enroll a handheld"
|
||||
tags:
|
||||
- "mobile-scanners"
|
||||
/qqq/v1/qbit/mobile-scanners/devices/current:
|
||||
get:
|
||||
description: "Requires QQQ user session with mobile.scan and both device headers.\
|
||||
\ PENDING and REVOKED devices may read their state but cannot run scanner\
|
||||
\ processes. Headers also bind device identity on core metadata/widget/process\
|
||||
\ requests; never send them to another origin or in process values."
|
||||
operationId: "mobileScannersCurrentDevice"
|
||||
parameters:
|
||||
- description: "Enrolled installation UUID"
|
||||
in: "header"
|
||||
name: "X-QQQ-Device-Id"
|
||||
required: true
|
||||
schema:
|
||||
format: "uuid"
|
||||
maxLength: 36
|
||||
type: "string"
|
||||
- description: "Private one-time issued device credential; never log"
|
||||
in: "header"
|
||||
name: "X-QQQ-Device-Token"
|
||||
required: true
|
||||
schema:
|
||||
maxLength: 128
|
||||
type: "string"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "User sign-in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission or device credential denied"
|
||||
500:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Request failed"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MobileScannersDeviceV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Installation already enrolled"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found, or not visible to this user (code NOT_FOUND)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Read the authenticated device state"
|
||||
tags:
|
||||
- "mobile-scanners"
|
||||
servers:
|
||||
- description: "The QQQ server that serves this document (paths include /qqq/v1)."
|
||||
url: "/"
|
||||
tags:
|
||||
- description: "Operations of the mobile-scanners extension."
|
||||
name: "mobile-scanners"
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+15
-5
@@ -7,8 +7,19 @@
|
||||
<properties>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<qqq.version>4.1.0-RC.20</qqq.version>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -23,7 +34,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.35</version>
|
||||
<version>1.0.0-RC.38</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
@@ -32,8 +43,7 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.11.4</version>
|
||||
<artifactId>junit-jupiter-engine</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
@@ -68,7 +78,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
<version>3.5.6</version>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<qqq.logger.logSessionId.disabled>true</qqq.logger.logSessionId.disabled>
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
public class MobileScannersDemo extends AbstractQQQApplication
|
||||
{
|
||||
private final String jdbcUrl;
|
||||
private MobileScannersQBitConfig mobileConfig = new MobileScannersQBitConfig().withBackendName("mobileDb");
|
||||
private QInstance instance;
|
||||
|
||||
|
||||
@@ -51,6 +52,41 @@ public class MobileScannersDemo extends AbstractQQQApplication
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep H2 open between QQQ connections; normal JVM shutdown flushes the file.
|
||||
*******************************************************************************/
|
||||
static String fileDatabaseUrl(Path database)
|
||||
{
|
||||
return "jdbc:h2:file:" + database.toAbsolutePath().normalize().toString().replace('\\', '/') + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure the same public appearance for every client of this demo server.
|
||||
*******************************************************************************/
|
||||
public MobileScannersDemo withBranding(String displayName, String primaryColor, String logoUrl)
|
||||
{
|
||||
if(instance != null)
|
||||
{
|
||||
throw new IllegalStateException("Configure branding before defining the demo instance.");
|
||||
}
|
||||
MobileScannersQBitConfig configured = new MobileScannersQBitConfig().withBackendName("mobileDb");
|
||||
if(displayName != null)
|
||||
{
|
||||
configured.withDisplayName(displayName);
|
||||
}
|
||||
if(primaryColor != null)
|
||||
{
|
||||
configured.withPrimaryColor(primaryColor);
|
||||
}
|
||||
configured.withLogoUrl(logoUrl);
|
||||
mobileConfig = configured;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The same instance backs native UI routes and the enrollment route provider.
|
||||
*******************************************************************************/
|
||||
@@ -81,8 +117,9 @@ public class MobileScannersDemo extends AbstractQQQApplication
|
||||
instance.addTable(table);
|
||||
}
|
||||
instance.getTable("demoUser").getField("passwordHash").setIsHidden(true);
|
||||
new MobileScannersQBitProducer().withConfig(new MobileScannersQBitConfig().withBackendName("mobileDb")).produce(instance, "mobile");
|
||||
for(String name : List.of(MobileScannersQBitProducer.DEVICE, MobileScannersQBitProducer.CREDENTIAL, MobileScannersQBitProducer.SCAN))
|
||||
new MobileScannersQBitProducer().withConfig(mobileConfig).produce(instance, "mobile");
|
||||
for(String name : List.of(MobileScannersQBitProducer.DEVICE, MobileScannersQBitProducer.CREDENTIAL, MobileScannersQBitProducer.SCAN,
|
||||
MobileScannersQBitProducer.SESSION, MobileScannersQBitProducer.SESSION_BINDING))
|
||||
{
|
||||
QTableMetaData table = instance.getTable(name);
|
||||
table.withBackendDetails(new RDBMSTableBackendDetails().withTableName(name));
|
||||
@@ -183,7 +220,8 @@ public class MobileScannersDemo extends AbstractQQQApplication
|
||||
Integer port = args.length == 0 ? 8080 : Integer.valueOf(args[0]);
|
||||
Path database = Path.of("data", "mobile").toAbsolutePath().normalize();
|
||||
Files.createDirectories(database.getParent());
|
||||
MobileScannersDemo application = new MobileScannersDemo("jdbc:h2:file:" + database.toString().replace('\\', '/') + ";DATABASE_TO_UPPER=FALSE");
|
||||
MobileScannersDemo application = new MobileScannersDemo(fileDatabaseUrl(database));
|
||||
application.withBranding(System.getenv("MOBILE_BRAND_DISPLAY_NAME"), System.getenv("MOBILE_BRAND_PRIMARY_COLOR"), System.getenv("MOBILE_BRAND_LOGO_URL"));
|
||||
application.initializeDatabase(System.getenv("MOBILE_DEMO_SCANNER_PASSWORD"), System.getenv("MOBILE_DEMO_ADMIN_PASSWORD"));
|
||||
application.start(port);
|
||||
}
|
||||
|
||||
+969
-15
File diff suppressed because it is too large.
Load diff
+234
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners.demo;
|
||||
|
||||
|
||||
import java.io.File;
|
||||
import java.net.HttpCookie;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the same standalone file-database entry point as native acceptance.
|
||||
*******************************************************************************/
|
||||
class MobileStandalonePresenceTest
|
||||
{
|
||||
@TempDir
|
||||
private Path directory;
|
||||
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
|
||||
private String origin;
|
||||
private String deviceId;
|
||||
private String deviceToken;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A non-UTC child JVM must keep receiving viewing leases after fifteen seconds.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
@Timeout(90)
|
||||
void standaloneFileServerRetainsFreshPresenceAndViewingDemand() throws Exception
|
||||
{
|
||||
int port;
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
origin = "http://127.0.0.1:" + port;
|
||||
String scannerPassword = UUID.randomUUID().toString();
|
||||
String adminPassword = UUID.randomUUID().toString();
|
||||
String classpath = Arrays.stream(System.getProperty("java.class.path").split(File.pathSeparator))
|
||||
.map(value -> Path.of(value).toAbsolutePath().normalize().toString()).collect(Collectors.joining(File.pathSeparator));
|
||||
String executable = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java";
|
||||
ProcessBuilder builder = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", executable).toString(),
|
||||
"-XX:ActiveProcessorCount=2", "-Xmx512m", "-Duser.timezone=America/Chicago", "-cp", classpath,
|
||||
MobileScannersDemo.class.getName(), String.valueOf(port));
|
||||
builder.directory(directory.toFile());
|
||||
builder.environment().put("MOBILE_DEMO_SCANNER_PASSWORD", scannerPassword);
|
||||
builder.environment().put("MOBILE_DEMO_ADMIN_PASSWORD", adminPassword);
|
||||
builder.redirectOutput(ProcessBuilder.Redirect.DISCARD).redirectError(ProcessBuilder.Redirect.DISCARD);
|
||||
Process server = builder.start();
|
||||
try
|
||||
{
|
||||
awaitServer(server);
|
||||
String scanner = login("scanner", scannerPassword);
|
||||
String admin = login("admin", adminPassword);
|
||||
JSONObject enrolled = json("POST", "/qqq/v1/qbit/mobile-scanners/devices/enroll", scanner,
|
||||
new JSONObject().put("installationId", UUID.randomUUID().toString()).put("displayName", "Standalone presence regression"), true);
|
||||
deviceId = enrolled.getString("deviceId");
|
||||
deviceToken = enrolled.getString("deviceToken");
|
||||
String process = "/qqq/v1/processes/mobileDeviceApprove";
|
||||
JSONObject launched = form(process + "/init", admin, new JSONObject());
|
||||
String processId = launched.getString("processUUID");
|
||||
JSONObject applied = form(process + "/" + processId + "/step/input", admin, new JSONObject().put("deviceId", deviceId));
|
||||
assertEquals("result", applied.getString("nextStep"));
|
||||
form(process + "/" + processId + "/step/result", admin, new JSONObject());
|
||||
JSONObject activity = new JSONObject().put("foreground", true).put("screenAvailable", true);
|
||||
Instant began = Instant.now();
|
||||
for(int iteration = 0; iteration < 4; iteration++)
|
||||
{
|
||||
if(iteration > 0)
|
||||
{
|
||||
Thread.sleep(6000);
|
||||
}
|
||||
JSONObject heartbeat = json("POST", "/qqq/v1/qbit/mobile-scanners/sessions/heartbeat", scanner, activity, true);
|
||||
String sessionId = heartbeat.getString("sessionId");
|
||||
JSONObject row = json("POST", "/qqq/v1/table/mobileSession/query", admin, new JSONObject(), false)
|
||||
.getJSONArray("records").getJSONObject(0).getJSONObject("values");
|
||||
assertEquals(sessionId, row.getString("id"));
|
||||
Instant seen = Instant.parse(row.getString("lastSeenAt"));
|
||||
Instant serverTime = Instant.parse(heartbeat.getString("serverTime"));
|
||||
long ageMillis = Duration.between(seen, serverTime).toMillis();
|
||||
assertTrue(Math.abs(ageMillis) < 3000,
|
||||
"Heartbeat age must be current: ageMillis=" + ageMillis + ", seen=" + seen + ", serverTime=" + serverTime);
|
||||
JSONObject widget = json("POST", "/qqq/v1/widget/mobileSessionScreen", admin, new JSONObject().put("id", sessionId), false);
|
||||
String status = widget.getJSONArray("blocks").getJSONObject(0).getJSONObject("values").getString("text");
|
||||
assertEquals("Waiting for a current app screen", status);
|
||||
JSONObject next = json("POST", "/qqq/v1/qbit/mobile-scanners/sessions/heartbeat", scanner, activity, true);
|
||||
assertTrue(next.has("screenMirror"), "Fresh native heartbeat must receive the browser viewer's lease");
|
||||
}
|
||||
assertTrue(Duration.between(began, Instant.now()).getSeconds() > 15);
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.destroy();
|
||||
if(!server.waitFor(5, TimeUnit.SECONDS))
|
||||
{
|
||||
server.destroyForcibly();
|
||||
assertTrue(server.waitFor(5, TimeUnit.SECONDS), "Isolated standalone server must stop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bound startup without printing child environment variables or responses.
|
||||
***************************************************************************/
|
||||
private void awaitServer(Process server) throws Exception
|
||||
{
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(25).toNanos();
|
||||
while(System.nanoTime() < deadline)
|
||||
{
|
||||
assertTrue(server.isAlive(), "Standalone demo exited during startup");
|
||||
try
|
||||
{
|
||||
if(client.send(HttpRequest.newBuilder(URI.create(origin + "/qqq/v1/version")).timeout(Duration.ofSeconds(2)).GET().build(),
|
||||
HttpResponse.BodyHandlers.discarding()).statusCode() == 200)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(java.io.IOException ignored)
|
||||
{
|
||||
// The child has not bound its loopback socket yet.
|
||||
}
|
||||
Thread.sleep(100);
|
||||
}
|
||||
throw new AssertionError("Standalone demo startup timed out");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Passwords remain exclusively in the request header and child environment.
|
||||
***************************************************************************/
|
||||
private String login(String username, String password) throws Exception
|
||||
{
|
||||
String basic = Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8));
|
||||
var response = client.send(headers("/qqq/v1/manageSession", null, true).header("Authorization", "Basic " + basic)
|
||||
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, response.statusCode(), "Standalone login failed");
|
||||
return response.headers().allValues("Set-Cookie").stream().flatMap(value -> HttpCookie.parse(value).stream())
|
||||
.filter(cookie -> "sessionUUID".equals(cookie.getName())).findFirst().orElseThrow().getValue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Browser requests omit the native channel and device headers deliberately.
|
||||
***************************************************************************/
|
||||
private HttpRequest.Builder headers(String path, String session, boolean nativeClient)
|
||||
{
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(origin + path)).timeout(Duration.ofSeconds(10));
|
||||
if(session != null)
|
||||
{
|
||||
builder.header("Cookie", "sessionUUID=" + session);
|
||||
}
|
||||
if(nativeClient)
|
||||
{
|
||||
builder.header("X-QQQ-Client", "android/qqq-mobile");
|
||||
if(deviceId != null)
|
||||
{
|
||||
builder.header("X-QQQ-Device-Id", deviceId).header("X-QQQ-Device-Token", deviceToken);
|
||||
}
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Use the ordinary versioned API; never print credential-bearing bodies.
|
||||
***************************************************************************/
|
||||
private JSONObject json(String method, String path, String session, JSONObject body, boolean nativeClient) throws Exception
|
||||
{
|
||||
var response = client.send(headers(path, session, nativeClient).header("Content-Type", "application/json")
|
||||
.method(method, HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertTrue(response.statusCode() == 200 || response.statusCode() == 201, "Standalone JSON operation failed: " + path + " status=" + response.statusCode());
|
||||
return new JSONObject(response.body());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Drive the real approval process, including a bounded async job handoff.
|
||||
***************************************************************************/
|
||||
private JSONObject form(String path, String session, JSONObject values) throws Exception
|
||||
{
|
||||
String boundary = "qqq-presence-" + UUID.randomUUID();
|
||||
String body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"values\"\r\n\r\n" + values
|
||||
+ "\r\n--" + boundary + "\r\nContent-Disposition: form-data; name=\"stepTimeoutMillis\"\r\n\r\n5000\r\n--" + boundary + "--\r\n";
|
||||
var response = client.send(headers(path, session, false).header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, response.statusCode(), "Standalone approval process failed");
|
||||
JSONObject result = new JSONObject(response.body());
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos();
|
||||
String processId = result.optString("processUUID");
|
||||
String jobId = result.optString("jobUUID");
|
||||
while("JOB_STARTED".equals(result.optString("type")) || "RUNNING".equals(result.optString("type")))
|
||||
{
|
||||
assertTrue(System.nanoTime() < deadline, "Standalone approval job timed out");
|
||||
Thread.sleep(25);
|
||||
var poll = client.send(headers("/qqq/v1/processes/mobileDeviceApprove/" + processId + "/status/" + jobId, session, false).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, poll.statusCode());
|
||||
result = new JSONObject(poll.body());
|
||||
}
|
||||
assertFalse(result.has("error"), "Standalone approval returned a process error");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.mobilescanners.demo;
|
||||
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve historical instants when replacing H2 zoned timestamp columns.
|
||||
*******************************************************************************/
|
||||
class MobileTimestampMigrationTest
|
||||
{
|
||||
@TempDir
|
||||
private Path directory;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A separate non-UTC JVM avoids changing the timezone of concurrent tests.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
@Timeout(40)
|
||||
void zonedFileDatabaseMigratesWithoutChangingInstantsOrExistingRecords() throws Exception
|
||||
{
|
||||
String classpath = Arrays.stream(System.getProperty("java.class.path").split(File.pathSeparator))
|
||||
.map(value -> Path.of(value).toAbsolutePath().normalize().toString()).collect(Collectors.joining(File.pathSeparator));
|
||||
String executable = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java";
|
||||
Path output = directory.resolve("migration.log");
|
||||
Process child = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", executable).toString(),
|
||||
"-XX:ActiveProcessorCount=2", "-Xmx256m", "-Duser.timezone=America/Chicago", "-cp", classpath,
|
||||
MigrationProbe.class.getName(), directory.resolve("legacy").toString())
|
||||
.redirectErrorStream(true).redirectOutput(output.toFile()).start();
|
||||
try
|
||||
{
|
||||
assertTrue(child.waitFor(30, TimeUnit.SECONDS), "Isolated migration probe must finish");
|
||||
assertEquals(0, child.exitValue(), () -> "Migration probe failed: " + readOutput(output));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(child.isAlive())
|
||||
{
|
||||
child.destroyForcibly();
|
||||
assertTrue(child.waitFor(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only synthetic fixture data can appear in child assertions.
|
||||
***************************************************************************/
|
||||
private static String readOutput(Path output)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Files.readString(output);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
return exception.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Run the actual initializer twice against a closed/reopened file database.
|
||||
***************************************************************************/
|
||||
public static class MigrationProbe
|
||||
{
|
||||
private static final Map<String, List<String>> TIMESTAMPS = Map.of(
|
||||
"mobileDevice", List.of("enrolledAt", "approvedAt", "lastSeenAt", "locationCapturedAt", "locationReceivedAt"),
|
||||
"mobileScan", List.of("capturedAt", "receivedAt"),
|
||||
"mobileSession", List.of("startedAt", "lastSeenAt", "endedAt", "authCleanupAt"));
|
||||
private static final List<OffsetDateTime> INSTANTS = List.of(
|
||||
OffsetDateTime.parse("2024-03-10T01:59:59.123456-06:00"),
|
||||
OffsetDateTime.parse("2024-11-03T12:30:00.654321+05:45"));
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Use only an isolated fixture; no HTTP listener or operator credentials.
|
||||
************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
assertEquals(ZoneId.of("America/Chicago"), ZoneId.systemDefault());
|
||||
String url = "jdbc:h2:file:" + args[0].replace('\\', '/') + ";DATABASE_TO_UPPER=FALSE";
|
||||
MobileScannersDemo demo = new MobileScannersDemo(url);
|
||||
demo.initializeDatabase("synthetic-scanner-password", "synthetic-admin-password");
|
||||
Map<String, String> passwords;
|
||||
try(Connection connection = DriverManager.getConnection(url, "sa", ""))
|
||||
{
|
||||
passwords = passwordHashes(connection);
|
||||
seedLegacyZonedRows(connection);
|
||||
}
|
||||
for(int run = 0; run < 2; run++)
|
||||
{
|
||||
demo.initializeDatabase(null, null);
|
||||
try(Connection connection = DriverManager.getConnection(url, "sa", ""))
|
||||
{
|
||||
assertEquals(passwords, passwordHashes(connection));
|
||||
for(var entry : TIMESTAMPS.entrySet())
|
||||
{
|
||||
try(var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT * FROM " + entry.getKey() + " ORDER BY id"))
|
||||
{
|
||||
int index = 0;
|
||||
while(rows.next())
|
||||
{
|
||||
for(String field : entry.getValue())
|
||||
{
|
||||
assertEquals(INSTANTS.get(index).toInstant(), rows.getObject(field, LocalDateTime.class).toInstant(ZoneOffset.UTC), entry.getKey() + "." + field);
|
||||
}
|
||||
if(entry.getKey().equals("mobileDevice"))
|
||||
{
|
||||
assertEquals("APPROVED", rows.getString("status"));
|
||||
assertEquals("Original name " + index, rows.getString("displayName"));
|
||||
}
|
||||
if(entry.getKey().equals("mobileScan"))
|
||||
{
|
||||
assertEquals("000123-raw-" + index, rows.getString("barcode"));
|
||||
assertEquals("scanner", rows.getString("userId"));
|
||||
}
|
||||
index++;
|
||||
}
|
||||
assertEquals(INSTANTS.size(), index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Existing passwords must remain byte-for-byte identical across startup.
|
||||
************************************************************************/
|
||||
private static Map<String, String> passwordHashes(Connection connection) throws Exception
|
||||
{
|
||||
Map<String, String> hashes = new LinkedHashMap<>();
|
||||
try(var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT username, passwordHash FROM demoUser ORDER BY username"))
|
||||
{
|
||||
while(rows.next())
|
||||
{
|
||||
hashes.put(rows.getString(1), rows.getString(2));
|
||||
}
|
||||
}
|
||||
assertEquals(2, hashes.size());
|
||||
return hashes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Recreate legacy column types before writing explicit offset timestamps.
|
||||
************************************************************************/
|
||||
private static void seedLegacyZonedRows(Connection connection) throws Exception
|
||||
{
|
||||
try(var statement = connection.createStatement())
|
||||
{
|
||||
for(var entry : TIMESTAMPS.entrySet())
|
||||
{
|
||||
for(String field : entry.getValue())
|
||||
{
|
||||
statement.execute("ALTER TABLE " + entry.getKey() + " ALTER COLUMN " + field + " TIMESTAMP WITH TIME ZONE");
|
||||
}
|
||||
}
|
||||
for(int index = 0; index < INSTANTS.size(); index++)
|
||||
{
|
||||
String id = "fixture-" + index;
|
||||
statement.execute("INSERT INTO mobileDevice (id, installationId, shortName, displayName, status, enrolledBy, enrolledAt) VALUES ('" + id + "', '" + id + "', 'scanner-" + index + "', 'Original name " + index + "', 'APPROVED', 'scanner', CURRENT_TIMESTAMP)");
|
||||
statement.execute("INSERT INTO mobileScan (id, deviceId, eventId, userId, appId, processName, barcode, capturedAt, receivedAt) VALUES ('" + id + "', '" + id + "', 'event-" + index + "', 'scanner', 'scan', 'capture', '000123-raw-" + index + "', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)");
|
||||
statement.execute("INSERT INTO mobileSession (id, deviceId, userId, status, startedAt, lastSeenAt) VALUES ('" + id + "', '" + id + "', 'scanner', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)");
|
||||
for(var entry : TIMESTAMPS.entrySet())
|
||||
{
|
||||
for(String field : entry.getValue())
|
||||
{
|
||||
try(var update = connection.prepareStatement("UPDATE " + entry.getKey() + " SET " + field + " = ? WHERE id = ?"))
|
||||
{
|
||||
update.setObject(1, INSTANTS.get(index));
|
||||
update.setString(2, id);
|
||||
assertEquals(1, update.executeUpdate());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# API 1.9 consumer qualification
|
||||
|
||||
Verified October 31, 2026 using exact-source core `ecf0f550a0357cec6387c2b54efca128a405a629` and scanner server `b14d925b741e815d1cfa4f62301f19207f25b03f`. These are draft source revisions, not published Maven releases or an operator-runtime upgrade.
|
||||
|
||||
## Dependency coherence
|
||||
|
||||
The demo imports the selected QQQ BOM. Its `qqq.version` therefore governs transitive core, middleware, OpenAPI and RDBMS dependencies, rather than only direct dependencies. The demo test engine also follows that BOM; an independently pinned Jupiter aggregate mixed incompatible test API/engine versions.
|
||||
|
||||
The strict runtime verifier previously rejected the mixed RC.20/new-core fixture. After correction it verifies **195 runtime JAR hashes, 2,440 unique QQQ classes and zero duplicate QQQ classes**, all resolved from the private qualification repository. Every backend QQQ module has the exact CI revision. Guava resolves to **33.7.2-jre** and H2 to **2.2.224**. Qualified core JAR hashes remain unchanged through consumer verification.
|
||||
|
||||
## Executed checks
|
||||
|
||||
- Exact-source core selected reactor closure: **4,507 tests discovered, zero failures/errors, 16 existing skips**, plus configured coverage/static gates. Windows omits only the cosmetic shell coverage summary; hosted Ubuntu retains it.
|
||||
- Fresh scanner server: **37 tests**, zero failures/errors.
|
||||
- Fresh demo using the selected core: **five tests**, zero failures/errors.
|
||||
- Actual public discovery: UI API **1.9.0**, mobile-scanners **1.1.0**, TABLE_BASED authentication. The OAuth-only `nativeSessionLogin` feature is correctly absent for this configuration.
|
||||
- Real-browser process suite: **15 passed, zero skipped/flaky/unexpected**, in 60.3 seconds. Coverage includes single-record, checked-bulk, first-N and whole-query administration; cancellation; session access/replay; leased screen viewing; device names/descriptions; location attribution and independent presence expiry.
|
||||
|
||||
The fixture used fresh disposable databases and random credentials. Normal scanner data, services and USB forwarding were preserved. No emulator was started. Physical EMDK, actual configured Authentik, camera/GPS and production deployment acceptance remain separate gates.
|
||||
|
||||
The Android repository's `scripts/ci/verify-fixture-provenance.mjs` and source-pinned hosted workflow retain the classpath hashes, dependency tree, effective POM and test reports. These checks reject mixed runtime versions; they do not install replacement artifacts into a shared Maven repository.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Customer branding and connection status
|
||||
|
||||
The shared Android APK checks the selected server's semantic API compatibility, then loads appearance before login. A customer can configure the in-app display name, primary color and optional logo without compiling another APK. The installed application's Android launcher name, icon and package identity remain properties of the APK. Branding and connectivity are documented operations in the [mobile-scanners semantic UI API extension](UI-API.md).
|
||||
|
||||
## Host configuration
|
||||
|
||||
Configure the existing qbit before producing its metadata:
|
||||
|
||||
```java
|
||||
new MobileScannersQBitConfig()
|
||||
.withBackendName("mobileDb")
|
||||
.withDisplayName("Warehouse Mobile")
|
||||
.withPrimaryColor("#007A55")
|
||||
.withLogoUrl("https://assets.example.com/mobile-logo.png");
|
||||
```
|
||||
|
||||
`displayName` must be 1–80 trimmed plain-text characters with no control characters or angle brackets. `primaryColor` must be `#RRGGBB`; the server normalizes hex letters to uppercase. `logoUrl` is optional (`null` clears it), at most 2048 characters, and must be an absolute HTTPS URL without user credentials or a fragment. Invalid settings fail configuration. Use a public static PNG or JPEG image; the server does not download or proxy it. The client must load it without forwarding QQQ session cookies, device credentials or authorization headers, and use its default visual treatment if loading fails. Branding is presentation, not verified customer identity or an authorization policy.
|
||||
|
||||
For the local demo, set any of these before launching `scripts/Start-Demo.ps1`:
|
||||
|
||||
```powershell
|
||||
$env:MOBILE_BRAND_DISPLAY_NAME = 'Warehouse Mobile'
|
||||
$env:MOBILE_BRAND_PRIMARY_COLOR = '#007A55'
|
||||
$env:MOBILE_BRAND_LOGO_URL = 'https://assets.example.com/mobile-logo.png'
|
||||
```
|
||||
|
||||
Unset variables use defaults: `QQQ Mobile Scanners`, `#1565C0`, and no logo. Restart the server after configuration changes, then reconnect the client. The existing Windows launcher passes these environment variables through to Java. Use only public logo assets. Branding is public; keep private credentials and sensitive tenant details out.
|
||||
|
||||
## Public client contract
|
||||
|
||||
`GET /qqq/v1/qbit/mobile-scanners/branding` returns HTTP 200 with `Cache-Control: no-store` and JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"displayName": "Warehouse Mobile",
|
||||
"primaryColor": "#007A55",
|
||||
"logoUrl": "https://assets.example.com/mobile-logo.png"
|
||||
}
|
||||
```
|
||||
|
||||
The `logoUrl` property is omitted when unset. The endpoint requires no session or approved device and ignores stale device headers. It exposes only those allowlisted appearance fields, never qbit configuration, backend names, secrets or enrollment records. A compatible server's unavailable or malformed branding uses the client's default theme. A server missing the required extension is blocked before login with an upgrade/configuration message.
|
||||
|
||||
`GET /qqq/v1/qbit/mobile-scanners/status` likewise returns HTTP 200, `Cache-Control: no-store`, and `{"status":"ok"}` without authentication. This means the HTTP service is reachable; it does not test database readiness, authenticate a user, or approve a device. Clients must continue using authenticated device status and normal process responses for those decisions. No diagnostics, database details or credentials are returned.
|
||||
+16
-6
@@ -1,13 +1,15 @@
|
||||
# CI and release operation
|
||||
|
||||
This repository follows the current QRun-IO QBit pipeline structure: a canonical CircleCI setup caller and a small `.qqq-ci.yml` manifest. The default development branch is `develop`; `main` is reserved for reviewed releases.
|
||||
The primary forge for source, reviews and checks is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive and receives no ordinary writes. The CircleCI and GitHub Actions material below describes the legacy shared QBit pipeline files kept in this repository and is not a statement of which hosted checks currently gate Gitea changes; confirm the checks a pull request actually runs on Gitea. Run the commands in Verification locally either way.
|
||||
|
||||
This repository follows the QRun-IO QBit pipeline structure: a canonical CircleCI setup caller and a small `.qqq-ci.yml` manifest. The default development branch is `develop`; `main` is reserved for reviewed releases.
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | What runs |
|
||||
| --- | --- |
|
||||
| CircleCI `setup` and generated `verify` | Shared `kingsrook/qqq-orb@0.8` QBit verification, including Maven tests and reports |
|
||||
| GitHub `Demo acceptance` / `Verify demo HTTP contract` | Wrapper-pinned root verification/install, then the real demo HTTP acceptance suite |
|
||||
| GitHub `Demo acceptance` / `Verify demo HTTP contract` | Wrapper-pinned root verification/install, real demo HTTP acceptance, then browser E2E against the packaged frontend |
|
||||
| GitHub `Demo acceptance` / `Check Windows demo helpers` | PSScriptAnalyzer checks for the local Windows launcher and credential helpers |
|
||||
| GitHub `CodeQL` | QRun-IO's shared QBit source analysis worker, pinned to an immutable commit |
|
||||
| Dependabot | Weekly grouped Maven and GitHub Actions update pull requests targeting `develop` |
|
||||
@@ -16,19 +18,27 @@ The three orb version declarations must match exactly. The `0.8` minor track rec
|
||||
|
||||
Run the same project checks locally with `./mvnw verify install` and `./mvnw -f demo/pom.xml verify`. The wrapper pins Maven 3.9.9 and validates its distribution checksum. Java 21 is required. The demo uses no external database or user credentials in CI.
|
||||
|
||||
[Process acceptance tests](TESTING.md) documents the browser and native test runners. Browser E2E runs inside the existing required demo job, so failures block that check. The Android repository runs the isolated scanner-host E2E against a pinned server revision on pull requests and pushes, in addition to its shared native UI suite.
|
||||
|
||||
For workflow syntax, run `actionlint .github/workflows/codeql.yml .github/workflows/demo-acceptance.yml`. Validate the setup caller with `circleci config validate --org gh/QRun-IO .circleci/config.yml`, using your existing CircleCI authentication. The shared orb is private; without organization scope and authorization, the compiler can report that it cannot find the orb. Do not place tokens in config files or command arguments.
|
||||
|
||||
## Initial publishing policy
|
||||
## Development snapshot publication (enabled, November 3)
|
||||
|
||||
Every `publish` switch and `release_completion` is explicitly false. Branch pushes, including initial `main`/`develop` pushes, and `vX.Y.Z` tags perform verification without deploying Maven artifacts. There is no automatic RC-cut workflow in this bootstrap configuration. GitHub test-report artifacts are diagnostic outputs, not releases.
|
||||
PR 7 merged as `795a0a2`; its post-merge run 3607 on `develop` passed but captured nothing because `publish.snapshot` was false. PR 12 set only `publish.snapshot: true` in `.qqq-ci.yml` and merged as `286e2e0`. Run 3645 succeeded and the snapshot `0.1.0-20261103.233448-3645` was read back anonymously from Central (JAR, POM, sources and Javadocs; the 37 published Java source files match the reviewed source). PR 13 (`1345197`, Echo defaults to released Printing `1.0.0`) then passed CI runs 4157 and 4158.
|
||||
|
||||
To enable publishing later, submit a reviewed change to the manifest after TC22 acceptance and production readiness work are complete. Choose the intended snapshot/RC/GA policy; verify the organization registry context, signing configuration and Maven coordinates. Add the then-current shared `qbit-release-rc.yml` caller pinned to an immutable commit before enabling `release_completion`. Update protections and documentation using observed checks from successful hosted runs. Creating a repository does not itself authorize artifact publication.
|
||||
`develop` builds therefore publish development snapshots. Every other publication value is unchanged: `publish.release_candidate`, `release`, `feature`, `hotfix` and `asciidoc`, `release_on_main` and `release_completion` stay false, and the orb stays on `0.8`. A snapshot is not a stable release or a promotion to `main`, and source publication does not equal production 1.0.
|
||||
|
||||
## Initial publishing policy (historical bootstrap)
|
||||
|
||||
At bootstrap, every `publish` switch and `release_completion` was explicitly false; only the snapshot switch has since changed (above). Branch pushes, including initial `main`/`develop` pushes, and `vX.Y.Z` tags perform verification without deploying Maven artifacts. There is no automatic RC-cut workflow in this bootstrap configuration. GitHub test-report artifacts are diagnostic outputs, not releases.
|
||||
|
||||
RC and stable publication still require a reviewed manifest change after TC22 acceptance and production readiness work are complete. Verify the organization registry context, signing configuration and Maven coordinates. Add the then-current shared `qbit-release-rc.yml` caller pinned to an immutable commit before enabling `release_completion`. Update protections and documentation using observed checks from successful hosted runs. The development-snapshot change above does not enable these release paths. The local demo does not depend on any of this pipeline.
|
||||
|
||||
## Repository settings
|
||||
|
||||
These are external settings; files alone cannot enable them:
|
||||
|
||||
1. Set the default branch to `develop`; protect `develop`, `main`, `release/*` and release tags. Require pull requests, code-owner review, resolved conversations and successful applicable checks; block force pushes/deletion.
|
||||
1. Set the default branch to `develop`; protect `develop`, `main` and `release/*`. Require pull requests, signed commits, resolved conversations and successful applicable checks; block force pushes/deletion. Bootstrap allows a pull request with zero mandatory human approvals, matching a maintainer-operated project. CODEOWNERS identifies the responsible maintainer; add mandatory reviewers when the organization assigns a review team. Protect release tags when releases are enabled.
|
||||
2. Connect the repository to the QRun-IO CircleCI organization and enable dynamic configuration. The shared QBit job expects its established `qqq-maven-registry-credentials` context even for test-only verification; grant only the intended trusted repositories and actors access. Do not put its values in this repository.
|
||||
3. Require CircleCI `setup` and `verify` plus demo acceptance and CodeQL using the actual check names/apps emitted by the first successful hosted run. Do not guess status contexts and leave every pull request permanently blocked.
|
||||
4. Enable Actions with read-only default workflow permissions, the dependency graph, Dependabot security updates, secret scanning/push protection and private vulnerability reporting when supported.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# CI helper crash handoff (Windows helper job)
|
||||
|
||||
Scope: PSScriptAnalyzer 1.25.0 throws NullReferenceException on
|
||||
`integrations/record-files/Verify-Isolated.ps1` only (root reproduced; the other
|
||||
five analyzed scripts report zero findings). Run 41337798368 was the hosted failure.
|
||||
|
||||
## Changes
|
||||
|
||||
- `integrations/record-files/Verify-Isolated.ps1`: the final two statements built
|
||||
one-line hashtables containing a `$(if ... else ...)` subexpression and several
|
||||
nested `(Get-FileHash ...)` calls, piped directly to `ConvertTo-Json`. That is
|
||||
the only construct in the repository's scripts combining those features, and
|
||||
the alignment/whitespace rules inspect hashtable key/value extents. The
|
||||
values are now computed into plain variables first and the two hashtables
|
||||
are multi-line with aligned `=`. Output keys, values and file names are
|
||||
unchanged. No rule, setting, version pin or test was relaxed.
|
||||
- `.github/workflows/demo-acceptance.yml`: each file is announced before it is
|
||||
analyzed. A crash still terminates the step (no try/catch), and the last
|
||||
`Analyzing ...` line names the file.
|
||||
|
||||
## Confidence
|
||||
|
||||
The exact crashing AST node was not reproduced by me (no shell). The two
|
||||
rewritten statements are the strongest candidate, not a proven cause. If the
|
||||
analyzer still crashes, the next suspects in the same file are the one-line
|
||||
hashtables at the `$manifest` and `$compiled` loops and the
|
||||
`foreach($x in $a | Where-Object ...)` headers; extract them into variables the
|
||||
same way. Pin a different analyzer version only if a minimal reproduction
|
||||
shows a bug that source changes cannot avoid.
|
||||
|
||||
## Verification for the coordinator (read-only analysis)
|
||||
|
||||
```powershell
|
||||
# from R:\Git.Local\QRunIO\qbit-mobile-scanners
|
||||
pwsh -NoProfile -Command "Get-ChildItem -LiteralPath scripts,integrations/record-files -File -Filter *.ps1 | ForEach-Object { try { $r=@(Invoke-ScriptAnalyzer -Path $_.FullName -Settings scripts/PSScriptAnalyzerSettings.psd1); '{0}: {1} findings' -f $_.Name,$r.Count; $r | Format-Table RuleName,Line,Message -AutoSize } catch { '{0}: CRASH {1}' -f $_.Name,$_.Exception.GetType().Name } }"
|
||||
|
||||
# parser check of the edited script
|
||||
pwsh -NoProfile -Command "$e=$null;$t=$null;[void][Management.Automation.Language.Parser]::ParseFile((Resolve-Path integrations/record-files/Verify-Isolated.ps1),[ref]$t,[ref]$e);$e.Count"
|
||||
|
||||
# existing path regression
|
||||
pwsh -NoProfile -File integrations/record-files/Test-IsolatedBuildPaths.ps1
|
||||
```
|
||||
|
||||
Expected: every file reports 0 findings with no CRASH, the parser prints 0, and
|
||||
the path regression passes. Any new rule findings in the rewritten block must be
|
||||
fixed in source, not suppressed.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Claude scanner-auth handoff (unbuilt, uncommitted)
|
||||
|
||||
## Source facts (core qqq-wt-native-provider-logout, read-only)
|
||||
- QSession is fully mutable; `QContext.setQSession` aside, any code holding the session can change permissions, user, uuid, channel, security keys, variants, credentialScope in place. Nothing in core guarantees a retained wrapper still has the authority that was checked.
|
||||
- QCredentialScope has no equals; its getters return unmodifiable views; `null` set = unrestricted (distinct from empty).
|
||||
- `QSession.hasPermission` ignores credential scope, so `MobilePermissions.require(SCAN)` does not evaluate ceilings.
|
||||
|
||||
## Change
|
||||
- (first round; see follow-up below for the current API) `VerifiedDeviceSession`: at construction stores an immutable `Authority` snapshot (userId, uuid, clientChannel, permissions, security keys, backend variants, and a `Scope` copy: credentialId, readOnly, apps/tables/processes/reports/widgets/operationIds, null preserved). `authorityUnchanged()` re-derives it and compares by value.
|
||||
- `MobileDeviceAuthorization.captureCurrent`: an existing wrapper whose authority differs from its snapshot throws `QPermissionDeniedException` (fail closed) before any other use; otherwise all existing checks run and the same wrapper is retained (no `setQSession`). First capture still wraps. Values/valuesForFrontend are deliberately excluded (non-grant data).
|
||||
- Tests (`MobileScannersTest`): `repeatedCaptureRetainsSameVerifiedSession`, `retainedSessionRejectsChangedAuthority` (device header, user, uuid/binding, permissions, approval revoke), `retainedSessionRejectsCredentialScopeAndSelectorChanges` (each ceiling set widened/nulled/narrowed, readOnly, credentialId, scope removed, channel, security key, backend variant, extra permission; each rejected with wrapper kept, then passes after restore).
|
||||
|
||||
## Follow-up round (post-review; unbuilt, so the 35-pass result predates it)
|
||||
- `captureCurrent` now computes the expected authority once at the start (wrapper: its checked snapshot; plain session: a fresh snapshot) and calls `requireCurrent` twice: at the start, and again after ALL checks (approval, observe, binding re-read) immediately before retaining or wrapping. `requireCurrent` fails closed unless `QContext.getQSession()` is still the very same reference AND its authority equals the expected one. Core replacing the session or a host hook changing grants/ceilings during validation is rejected; first capture is not wrapped in that case.
|
||||
- Immutability assessment (core source): `QSession.clone` copies security-key lists and backend variants one level (elements shared); `QCredentialScope.clone` copies its sets (String elements). Core offers nothing deeper. So `authority()` copies collections and treats element values as safe only if they are known immutable types (String, boxed numerics, BigInteger/BigDecimal, Boolean, Character, enum, UUID, java.time Temporal). Any other element makes the snapshot carry a random marker so it never equals anything: fail closed, no custom serialization.
|
||||
- New tests: `authorityChangedDuringChecksIsNotRetained` (scoped first capture copies exact ceilings/grants/channel; mid-check ceiling widening, grant addition, session replacement on a retained wrapper; mid-check widening on first capture leaves the plain session unwrapped; a test-only POST_QUERY_RECORD hook on the device table fires during the approval read and each case asserts the hook was consumed) and `mutableSessionValuesFailClosed`. If the hook never fires, the `assertNull(hook)` lines fail loudly rather than pass vacuously.
|
||||
- Serialized-session compatibility: `VerifiedDeviceSession` declares no `serialVersionUID`, so its computed UID already changed with any prior class edit and changes again now (new fields, nested records). Evidence searched: no ObjectOutputStream/serialVersionUID use in this repo; core `StateSerialization` (Java serialization with an ObjectInputFilter allowing `com.kingsrook.*`) has no QSession reference in the `state` package. I found no source showing a VerifiedDeviceSession is persisted across deployments, so no migration is claimed or added; the wrapper is created per request. If a host persists sessions through a state provider, in-flight old wrappers would fail to deserialize after upgrade and re-authenticate. This is unverified for hosts outside this repo.
|
||||
|
||||
## Verification fix
|
||||
- `authorityChangedDuringChecksIsNotRetained` failed with "second customizer with role postQueryRecord" because the device table already registers `MobileDeviceOnline`. The test now asserts that production customizer is registered, then replaces the map entry with `SessionMutator` (a subclass that runs the one-shot hook and then `super.apply`, i.e. the real behavior). Production code is unchanged. The `assertNull(SessionMutator.hook)` lines after each case still prove the hook ran during the checks (a hook that never fired would leave it non-null and fail).
|
||||
- Unverified: not rebuilt after this edit (no shell). `MobileBrandingTest` loopback error is environmental per root.
|
||||
|
||||
## Security-key set semantics (RF interop fix; unbuilt)
|
||||
- `VerifiedDeviceSession.Authority.securityKeys` is now `Map<String, Set<Serializable>>`: core row security uses `List.contains`, so membership is the authority; order and repeated identical appends no longer count as change. A null list stays distinct from empty; a new value, a new key, a removed key, or a different user/scope/permission still rejects. Credential-ceiling null-vs-empty handling is unchanged. Immutable-element fail-closed rule unchanged.
|
||||
- Test `securityKeysCompareAsSets`: repeated same-value append and reorder accepted with the same wrapper; new tenant, different tenant set, new key, and a different actor rejected.
|
||||
- DONE (later, unbuilt): RF fixture `Authentication.grant(instance, session)` now ends with `CollaborationSecurity.applyToSession(instance, session)` (import `com.kingsrook.qbits.collaboration.CollaborationSecurity`), called from both `createSession` and `isSessionValid`; `integrations/record-files/README.md` documents the call order. The entry below is superseded.
|
||||
- (superseded) the RF fixture `Authentication.grant` (`integrations/record-files/.../MobileRecordFilesIntegrationTest.java` ~line 705) was not changed to call `CollaborationSecurity.applyToSession(instance, session)`. I could not find that class's package in any readable source and will not guess an import. Edit for the owner/root: in `grant`, after the existing `withSecurityKeyValue("tenant","a")`/admin permission lines and before returning, add `CollaborationSecurity.applyToSession(instance, session)` with the real import (this static method needs the `instance`, so pass it into `grant(instance, session)` from both `createSession` and `isSessionValid`). It must run in authentication, before the first Mobile capture, so no late wrapper mutation or exemption is needed. README note for the optional bridge: authentication applies `CollaborationSecurity.applyToSession` during createSession/revalidation before capture.
|
||||
|
||||
## Limits / blockers
|
||||
- Not compiled or run (no shell). Scope test assumes a scope listing all instance tables/apps lets enroll/bind/capture work; if core denies for another reason, adjust the baseline.
|
||||
- Behavior: a legitimate in-place refresh that changes permissions now rejects until re-verified rather than being silently adopted (owner-required fail closed).
|
||||
- Credential ceilings are still not independently enforced by `captureCurrent` (SCAN uses hasPermission); they are only preserved/unchanged. Needs owner decision.
|
||||
- Out of scope, same bug: `MobilePermissions.checkPermissionsThrowing` (~line 69) still replaces the wrapper on RunProcessInput for scanner processes. Needs root coordination.
|
||||
|
||||
## Gate (private frozen caches, run by coordinator)
|
||||
1. `./mvnw.cmd -Dtest=MobileScannersTest verify`
|
||||
2. `./mvnw.cmd verify install`
|
||||
3. `./mvnw.cmd -f demo/pom.xml verify`
|
||||
|
||||
Coordinator verification: fullscanner49tests and realdemoHTTP7tests pass, zeroCheckstyleviolations, privatePU/f708 core. ExactRFowner18 remains pending. No productioncache/runtimeadoption.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Mobile scanner server standards review
|
||||
|
||||
Scope: all 52 maintained Java sources and associated first-party scripts/configuration. Archived acceptance evidence and generated/vendor files retain their separate provenance.
|
||||
|
||||
Corrections add missing copyright/SPDX notices, required member spacing and script help/style, and enforce the existing PowerShell analysis policy in CI. Unexpected route/binding/provider-cleanup failures now emit fixed structured warning categories through the existing logging backend. Messages contain no exception text, credentials, user identifiers, scan values or location. Direct backend logging deliberately avoids automatic user enrichment at this sensitive boundary.
|
||||
|
||||
The optional test attachment fixture now reads file metadata before opening its stream, preventing a resource leak if metadata lookup fails. Six PowerShell scripts pass analysis with zero findings, and the isolated-path regression passes. Fresh enabled Checkstyle, clean compilation, 39 server tests and six real HTTP demo tests pass against the qualified private API 1.10 dependencies. All 18 optional Record Files consumer cases also pass, for 63 tests with no failures, errors or skips. Evidence is retained in the private qqq-server-standards-b183343407ca426b9655fe44303c97f6 snapshot. These checks do not qualify the files owner's entire release.
|
||||
|
||||
The review does not replace real Android projection, camera/gallery, GPS-fix, provider/logout, lifecycle/retry acceptance or deployment configuration checks. Release 1.0 remains incomplete until those gates are satisfied; production signing/distribution remains the owner's final step.
|
||||
|
||||
Typed scan-context follow-up: extension1.3 exposes persisted scanContext to subsequent backend actions and a narrow public capture facade, using the existing verified-device authorization. Raw inputs are consumed to prevent inherited symbology; pre1.3 saved process state fails closed with a restart instruction. Configured app/process attribution and event type/source/app binding are covered. Fresh Checkstyle plus44root/7HTTPdemo/18optionalfiles cases (69total) and2canonical-contract generation cases pass; released schema baselines are unchanged. Echo example and its owner integrations remain separate work.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Optional device-screen viewing: extension 1.2
|
||||
|
||||
App-content viewing remains the default. The heartbeat response now advertises `supportedCaptureModes: [APP_CONTENT, DEVICE_SCREEN]`. A missing capability list on a 1.1 server permits app-content mode only. The Android operator must explicitly choose device-screen sharing and accept Android's system consent for each session. An administrator opening the existing screen widget cannot grant consent or start system capture remotely.
|
||||
|
||||
Heartbeat requests may include `captureMode`. Omitted means `APP_CONTENT`, whose existing foreground requirement remains. `DEVICE_SCREEN` allows background availability while the Android projection grant, device approval, authenticated session and admin viewing lease remain valid. The client retains foreground-only GPS reporting. `mobileSession.captureMode` is an additive nullable text column, with the supplied H2 migration defaulting existing rows to `APP_CONTENT`; hosts using another database must add the equivalent field before adopting the metadata.
|
||||
|
||||
Every lease reports its mode. Every device-screen frame explicitly supplies `captureMode: DEVICE_SCREEN`; omitted frame mode remains app-content. Upload must match both the session's current mode and its exact lease. Switching modes discards retained pixels and demand, creating a new lease identity only after the authorized admin widget refreshes. Existing sequence, JPEG size/rate, ephemeral retention, approval and per-record admin permission checks remain in force. The server trusts the approved client's statement of OS consent, not an unverified browser administrator request; it cannot independently inspect an Android grant.
|
||||
|
||||
No new route, audio capture, recording, remote input or external media service is introduced. Android uses a nonexported foreground service with system consent and a Stop notification, refuses stored/replayed grants and halts on expiry, Stop, lock, logout/revocation or loss of session ownership. It waits at most 30 seconds for admin demand before stopping. Actual MediaProjection behavior needs explicit physical-device acceptance; Java/HTTP/synthetic lifecycle tests alone do not establish it.
|
||||
|
||||
Regenerate the current OpenAPI fragment using the existing `mobile.openapi.update` gate. Preserve the 1.0 and 1.1 baselines and run their compatibility checks. Migration and tests are source changes until the coordinated verification reports pass; no existing operator server was changed by this work.
|
||||
|
||||
The initial coordinated reactor gate passes39 server tests and six real-HTTP demo tests, with zero failures/errors/skips and normal Checkstyle. It uses the qualified `ecf0f550` core revision without installing artifacts; core JAR hashes and historical1.0/1.1 baselines remain unchanged. Independent source review covered every changed production Java file, schema/migration and changed tests, with no concrete introduced security finding. It does not establish host-specific multitenant policy or Android permission behavior. The server cannot attest the client's OS consent, and expired frame access is rejected even though physical heap pruning occurs lazily on subsequent reads/writes. Real consent, fresh-frame, lock, background, rotation and forced-session-end acceptance remain required on the final Android candidate.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Device sessions and live administration
|
||||
|
||||
Status: implementation in progress. This document records the agreed contract, not completed acceptance.
|
||||
|
||||
## Requirements
|
||||
|
||||
Administrators can list all enrolled devices and their current and past sessions, inspect live app/process/screen activity, view the actual QQQ application screen, and end one or several sessions. Device approval remains separate from user-session termination. Every administration process must work from a record and from selected bulk records, with real browser and native E2E coverage.
|
||||
|
||||
## Design
|
||||
|
||||
Use ordinary QQQ tables, record widgets and processes. A public `mobileSession` record stores device/user attribution, server timestamps, bounded activity names and termination audit. A private binding maps the authenticated core QQQ session to its public record; authentication identifiers never enter public records or telemetry. The server derives identity from QQQ authentication and verified device credentials.
|
||||
|
||||
`mobileSessionEnd` uses the standard prepare/confirm/apply/result flow. It preserves the reviewed selection, validates every target before changing any, records who ended it, revokes the core session through QQQ's existing logout executor, and clears cached authentication. An independent ended-session check also protects requests and asynchronous scan writes, including cookie replay without device headers.
|
||||
|
||||
The registered `mobile-scanners` semantic extension advances additively to 1.1.0. Existing 1.0 contracts and their baseline remain intact. Older scanner clients are observed through their existing authenticated device calls; no new mandatory credential header is introduced. New clients negotiate 1.1 before login.
|
||||
|
||||
| Operation | Purpose |
|
||||
| --- | --- |
|
||||
| `POST /qqq/v1/qbit/mobile-scanners/sessions/heartbeat` | Start or refresh the current authenticated device session and return capture demand. |
|
||||
| `POST /qqq/v1/qbit/mobile-scanners/sessions/current/end` | Record normal sign-out/server change before core logout. |
|
||||
| `POST /qqq/v1/qbit/mobile-scanners/sessions/current/screen` | Upload one bounded JPEG for the current session and active viewer lease. |
|
||||
| Standard QQQ widget/process APIs | View live session records/screens and run single/bulk termination. |
|
||||
|
||||
Heartbeats run about every five seconds and contain app/process/step identifiers, a bounded screen label, foreground/capture availability, and safe action/error identifiers. They exclude passwords, form values, barcode contents and raw logs. Server time determines presence and lease expiry. The client derives a monotonic lease deadline from the server's remaining duration so device clock skew cannot extend viewing.
|
||||
|
||||
The session record's ordinary composite IMAGE widget refreshes every two seconds while visible and renews a 30-second viewer lease. The Android host uses PixelCopy on its own application content only while authenticated, approved, foregrounded, focused and leased. It excludes authentication/password screens, checks the same screen/session generation after asynchronous capture, and shows a visible viewing indicator. Backgrounding, logout, termination and unavailable capture clear the current frame.
|
||||
|
||||
Frames are limited to 640 by 1280 pixels and 256 KiB, with a 360 KiB JSON body limit. The server validates encoded length before decoding and checks JPEG dimensions before pixel allocation. Lease-scoped ordering rejects stale uploads; heartbeat returns `nextSequence` for client restart recovery. Only the latest frame is held transiently in bounded memory. No screen history or remote input control is included.
|
||||
|
||||
## Device identity, presence and location
|
||||
|
||||
Devices have an administrator-editable display name and description, plus a unique short name. Enrollment assigns a default short name; setup/approval can assign a meaningful name. Short names are canonical lowercase, 3–32 characters, using letters, digits and hyphens, with a database uniqueness constraint. Existing devices require a preserving migration before the constraint is enabled. Profile edits cannot change approval or credentials.
|
||||
|
||||
Approval and online presence are separate. Presence is based on server-observed heartbeat age and must expire even when a device stops sending requests. Administrators need an accurate online-only query. Location reports include coordinates, accuracy, capture time and server receipt time; unavailable or denied location never blocks scanning. The Android client requests foreground permission explicitly. Last-known location must show its age and must not be presented as a current GPS fix. No coordinates are sent to third-party maps by default.
|
||||
|
||||
## Record photos
|
||||
|
||||
The separate Record Files qbit owns retained bytes, associations, audit and upload finalization. The scanner owns native camera/gallery handling and bounded private temporary files, stable retry identity and a durable-save receipt. The optional `integrations/record-files` module adopts frozen bundle `retained-contract-20261031-0352` in an isolated dependency cache. Its operation guard protects authorization through the tested single-JVM commits and rollbacks against concurrent revocation/session termination; 18 integration/contract tests pass. Native capture uses explicit `RECORD_FILES_CAPTURE` metadata with `values.parent.tableName` and `values.parent.recordId`, checks discovery/capabilities and never infers the parent from a process name. Synthetic TC22 camera/gallery, rotation and logout cleanup pass. Production storage/content processing, immutable dependency CI and actual lens/operator acceptance remain open. Shared coordination is recorded in the mobile and Record Files owner mailboxes under `R:\agent-comms`.
|
||||
|
||||
## Deployment boundary
|
||||
|
||||
The initial live-view frame/lease store belongs to one server instance. Clustered deployments need shared ephemeral storage or explicit instance affinity. Durable device/session records use the configured QQQ backend. Schema changes must preserve the existing demo database; release documentation must state supported database migrations and deployment assumptions.
|
||||
|
||||
## Verification plan
|
||||
|
||||
- Server: authenticated identity binding, private fields, permission denial, ended cookie replay, asynchronous write races, expiry, image limits/order and existing 1.0 compatibility.
|
||||
- Browser: device/session navigation, refreshed image widget, single/bulk termination, confirmation/cancellation, persisted audit and unaffected sessions.
|
||||
- Android: truthful activity state, capture lifecycle/password gating, delayed-capture races, remote logout and a fresh session after login.
|
||||
- TC22: actual PixelCopy screen visible in admin, no capture outside the eligible app state, remote termination, reconnect/retry and persistence.
|
||||
|
||||
Production readiness is tracked in [PRODUCTION-READINESS.md](PRODUCTION-READINESS.md).
|
||||
@@ -0,0 +1,13 @@
|
||||
# Scoped Echo release review — November 1, 2026
|
||||
|
||||
An independent Claude agent reviewed the Echo implementation at4bc88a4 and the Android semantic process-choice changes retained in a2f0080. This was a read-only code review, not a new exhaustive repository or deployment security certification. It reported no confirmed high or critical finding in the reviewed happy path.
|
||||
|
||||
The review identified one medium availability issue and two concrete low issues, corrected in this checkpoint:
|
||||
|
||||
- Failures before a durable print intent was pinned left the same event unable to recover. An unpinned event can now retry through a single atomic pin; a missing or competing pin prevents generation. Pinned/uncertain attempts remain read-only reconciliation, never a replacement print.
|
||||
- The pin update now requires exactly one affected row. A concurrent loser cannot call the generator or mark the winning attempt as failed.
|
||||
- Accepted owner receipts are recorded before the final authority check. A late revocation still denies acknowledgment and new work, while preserving the already accepted job in the audit state. The pre-accept and pre-acknowledgment authority checks remain.
|
||||
|
||||
Five new regressions cover pre-pin recovery, missing/competing intent, simultaneous retries, and authority loss after acceptance and during lookup. All38 example tests and all22 composed-demo tests pass with Checkstyle0 against the matched private dependency cohort. These tests use controlled output and a simulated printer; they do not prove physical output.
|
||||
|
||||
The reviewer also noted H2-only qualification and a hypothesis about extended Code39 behavior. Other database vendors and the broader AIM/GS1/EAN/raw/decoder-mode fidelity contract are not qualified by this review. Supported-profile boundaries remain explicit; no unsupported payload is silently converted. Source publication, a qualified public Maven cohort, current primary-forge CI, physical/provider/lifecycle checks and organization signing/distribution are separate release gates.
|
||||
@@ -0,0 +1,51 @@
|
||||
# GS1 duplication and mobile records
|
||||
|
||||
Active work requested November 4, 2026. Implementation and isolated acceptance are underway; deployment and physical qualification remain separate.
|
||||
|
||||
## First delivery: GS1-128 duplication
|
||||
|
||||
- Preserve the original scanned payload, AIM identifier and available raw evidence.
|
||||
- Convert supported GS1 element strings to the renderer's canonical GS1 input without guessing missing separators.
|
||||
- Print a GS1-128 symbol and its human-readable interpretation underneath, preserving leading zeroes and application identifiers.
|
||||
- Verify independent decoding of the rendered output, retry behavior, permissions and refusal of ambiguous inputs.
|
||||
- Qualify the actual label/media/printer and handheld path before claiming tomorrow's workflow ready.
|
||||
|
||||
## Native records and photos
|
||||
|
||||
- Discover permitted tables through existing semantic metadata.
|
||||
- Browse records and open record details through existing versioned query/read operations.
|
||||
- Offer camera/gallery attachment only when Record Files authorizes that exact record.
|
||||
- Reuse the native upload implementation and committed receipt checks; cover denied records, cancellation and retries with isolated native/server E2E tests.
|
||||
|
||||
## Remaining readiness work
|
||||
|
||||
1. Establish the intended HTTPS host and standalone connectivity.
|
||||
2. Qualify the existing signed APK and an enrollment-preserving update path.
|
||||
3. Verify an actual MC9400 trigger and persisted record (operator action required).
|
||||
4. Adopt published, coherent owner dependencies when available; do not invent versions.
|
||||
5. Qualify native provider login, logout and account switching against the configured provider.
|
||||
6. Document and exercise database migration, backup/recovery and retention for the selected deployment.
|
||||
7. Run final server/browser/native gates on the resulting revision and reconcile release documentation.
|
||||
8. Deliver the GS1 profile above before considering broader barcode profiles.
|
||||
|
||||
## Constraints
|
||||
|
||||
Preserve the user's database, accounts, enrollment and printer setup. Use isolated test databases and application IDs. No offline queue or replacement screen framework. Source checks cannot establish physical scanning/printing. Do not publish artifacts or promote a stable release as part of a test run.
|
||||
|
||||
## Verified November 4
|
||||
|
||||
- Scanner/base-demo/Echo reactor: 135 tests passed, including GS1 raw evidence, retry identity, derived HRI, metadata discovery and read-only permission regressions. Checkstyle passed.
|
||||
- Final composed cohort: 188 tests passed (scanner 53, base demo 8, Record Files 18, Echo 74, Echo demo 35), with Checkstyle, using the previously qualified owner dependency versions in an isolated copy. No POM publication coordinates were changed.
|
||||
- Android core/mobileapp/host unit tests, lint and debug/E2E APK builds passed; eight test-runner guard tests passed.
|
||||
- Disposable MC9400 native Echo acceptance passed with synthetic Code128, QR and GS1-128 events, one job per event, rotation/recreation, run cancellation and saved-record browsing. All requests matched the published semantic API. Printing was simulated; this does not qualify the physical trigger.
|
||||
- Disposable MC9400 record-details acceptance saved synthetic camera and gallery photos to the selected record through the real Record Files APIs. Pending-photo rotation, navigation blocking and remote-logout cleanup passed. The existing process-based photo flow also passed the same camera/gallery and lifecycle regression.
|
||||
- Actual renderer output independently decoded for synthetic GTIN/date/serial and separator-bearing batch/serial examples on 4-by-1-inch, 300-dpi media; HRI matched and did not overlap the symbol.
|
||||
|
||||
## Deployment and migration gate
|
||||
|
||||
Before updating the maintained demo, stop its owning process cleanly and back up the closed database, attachment files and configuration together. Preserve accounts, device enrollment, printer settings and template revisions. Apply the additive nullable `rendererValue` and `humanReadable` columns in `echo-h2.sql`; existing scans keep their original values and are not backfilled or reprinted. Verify the backup can be restored into an isolated directory and start only that restored copy for the recovery check.
|
||||
|
||||
Publish a separately named GS1 document using `EchoTemplatePublisher.publishGs1` for the printer's reported media profile. Existing plain documents remain valid for their original profiles. Use the matching GS1 document for the sample label, print one copy, then scan that output and compare exact GS1 data and visible HRI. Do not declare physical acceptance from a synthetic broadcast or renderer-only result. Printer/media confirmation, deployment authorization, HTTPS configuration and the remaining readiness gates above are outstanding.
|
||||
## Forge source publication
|
||||
|
||||
Server source commit `1874c64` merged through PR 15 as `f9b0eb2` after Gitea checks 4318 and 4319 succeeded. Signed tag `echo-gs1-source-20261104` pins that exact merged tree. The Gitea prerelease includes a source archive, checksum and verification receipt. Forge must include `examples/echo` explicitly; publishing the root scanner JAR alone does not include GS1 duplication. This is a source integration release, not an Echo Maven or APK release. No Forge or handheld deployment was performed.
|
||||
+49
-15
@@ -1,38 +1,72 @@
|
||||
# Run the local demo on Windows
|
||||
|
||||
Install Java 21, PowerShell 7, and the Android SDK. Start the server from this repository:
|
||||
The local demo runs on your workstation, binds only to loopback and is independent of the shared CI pipeline. There are two launchers; pick by what you want to try.
|
||||
|
||||
| | Basic Scan Capture demo | Full Echo demo (all qbits) |
|
||||
| --- | --- | --- |
|
||||
| Starts | `scripts/Start-Demo.ps1` | `EchoDemo` or `TrustedLocalEchoDemo` from [`examples/echo/demo`](../examples/echo/demo/README.md) |
|
||||
| Contains | Mobile Scanners only: enrollment, approval, the **Scan Capture** app, device sessions, barcode scan table | Mobile Scanners plus Echo, Collaboration/Record Files, Document Generation and Printing in one server |
|
||||
| Try it for | Login, device approval, scanning and storing barcodes | Scan, render and print a label (the Echo app), scan photos |
|
||||
| Port | `-Port` parameter, script default `8080` | No default: pass a port argument or set `ECHO_DEMO_PORT` |
|
||||
| Setup | Java 21 and PowerShell 7 | Also a Printing tenant, a printing agent and printer, Docker for the renderer and published Echo templates; see its README |
|
||||
| Accounts | Generated on first start, kept in a per-user protected file | Reuses the basic demo's accounts when started on an existing database |
|
||||
|
||||
The maintained local server in this project uses the full Echo launcher in its existing-database mode. That mode is the only one that accepts host port `57832`; fresh demos refuse it. See [adopting the existing local server](../examples/echo/demo/docs/LOCAL-SERVER-ADOPTION.md).
|
||||
|
||||
## Basic Scan Capture demo
|
||||
|
||||
Install Java 21, PowerShell 7 and the Android SDK, then from this repository:
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/Start-Demo.ps1
|
||||
```
|
||||
|
||||
The launcher verifies both Maven projects, then keeps the server running at <http://127.0.0.1:8080/>. Stop it with Ctrl+C. After a successful build, `-SkipBuild` starts it directly. If port 8080 is unavailable, use `-Port 18080` and open <http://127.0.0.1:18080/>. The database persists under `demo/data`; the server binds only to loopback.
|
||||
The launcher verifies both Maven projects, then keeps the server running at <http://127.0.0.1:8080/>. Stop it with Ctrl+C. After a successful build, `-SkipBuild` starts it directly. If port 8080 is unavailable, use `-Port 18080` (or another port) and open that address. The database persists under `demo/data`.
|
||||
|
||||
First startup creates random passwords for the `admin` and `scanner` accounts. The launcher stores them using Windows DPAPI under `%LOCALAPPDATA%/QRunIO/MobileScannersDemo/credentials.clixml`, readable only through the same Windows user/machine protection. They are never written to the repository or printed. Keep that credential file with this local database; the launcher never changes existing account passwords.
|
||||
First startup creates random passwords for the `admin` and `scanner` accounts. They are protected with Windows DPAPI under `%LOCALAPPDATA%/QRunIO/MobileScannersDemo/credentials.clixml`, readable only by the same Windows user, and are never written to the repository or printed. The launcher never changes existing passwords. If a database exists without its credential file, it refuses to start rather than reset accounts.
|
||||
|
||||
In another PowerShell window, copy the admin password when ready to log into the browser:
|
||||
To sign in without ever displaying a password, use the helper from another PowerShell window:
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/Copy-DemoPassword.ps1 -Account admin
|
||||
```
|
||||
|
||||
Sign in with username `admin`, paste the password, then clear the clipboard with `Set-Clipboard -Value ''`. Use `-Account scanner` to copy the scanner password for the Android login. These accounts belong only to this local demo. For another operating system, supply your own password environment variables as described in [TC22 acceptance](TC22-ACCEPTANCE.md).
|
||||
Paste into the browser login for user `admin`, then clear the clipboard with `Set-Clipboard -Value ''`. Use `-Account scanner` for the handheld login. Never paste passwords into an issue, chat or log. These accounts belong only to the local demo; on another operating system, supply your own password environment variables as described in [TC22 acceptance](TC22-ACCEPTANCE.md).
|
||||
|
||||
## Emulator walkthrough
|
||||
## Connect a device or emulator
|
||||
|
||||
Follow the Android repository's [emulator guide](https://github.com/QRun-IO/qqq-android/blob/main/docs/EMULATOR.md) to start a virtual device and install the debug APK. Forward its port to the host:
|
||||
Install the matching debug build of the [Android scanner host](https://git.kof22.com/QRun-IO/qqq-android) (the `scanner-host` module; its `docs/EMULATOR.md` covers the emulator). Always pick the target explicitly when more than one device is attached. USB or paired wireless ADB both work; do not put device serials or addresses in notes or commits.
|
||||
|
||||
```powershell
|
||||
adb reverse tcp:8080 tcp:8080
|
||||
adb devices -l
|
||||
$serial = '<serial or address from adb devices>'
|
||||
adb -s $serial install -r <path to scanner-host-debug.apk>
|
||||
adb -s $serial reverse tcp:8080 tcp:<host port>
|
||||
```
|
||||
|
||||
If the host server uses `-Port 18080`, use `adb reverse tcp:8080 tcp:18080` instead; the Android server URL still uses port 8080.
|
||||
`install -r` updates the app without clearing its login or enrollment. Use the host port the server actually listens on (8080, the `-Port` value, or the Echo launcher's port). The app URL on the device stays `http://127.0.0.1:8080` because the forward maps device port 8080 to the host port. The debug build permits HTTP only for this local use; deployed builds require HTTPS.
|
||||
|
||||
1. Open **QQQ Mobile** on the emulator and use server `http://127.0.0.1:8080`.
|
||||
2. Sign in as `scanner` and enroll the device. It starts pending approval.
|
||||
3. In the browser as `admin`, open **Mobile Scanner Administration**, locate the device, copy its ID, and run **Approve Device** with that ID.
|
||||
4. Refresh approval on Android, open **Scan Capture**, type a barcode such as `000123456789`, and submit it.
|
||||
## Walkthrough: enroll, approve, scan, revoke
|
||||
|
||||
1. Open **QQQ Mobile** on the device and use server `http://127.0.0.1:8080`.
|
||||
2. Sign in as `scanner` and enroll. The device starts pending approval.
|
||||
3. In the browser as `admin`, open **Mobile Scanner Administration → Mobile Devices**, open the device record and choose **Actions → Approve Device**. Review the selected device, continue and check the success result. For several devices, select their rows and run the same action from the bulk menu; the standalone sidebar process also accepts device IDs.
|
||||
4. Refresh approval on the device, open **Scan Capture**, scan or type a barcode such as `000123456789` and submit it.
|
||||
5. Inspect **Barcode Scans** in the browser and verify the exact text, user, device and timestamps.
|
||||
6. Revoke the device and confirm subsequent capture is refused.
|
||||
6. Choose **Revoke Device** from the record or a bulk selection, confirm, and verify that later capture is refused.
|
||||
|
||||
The emulator exercises native screens, login, approval, manual capture and persistence. Verify the physical trigger, DataWedge delivery and barcode decoding on the Zebra TC22 using [the hardware checklist](TC22-ACCEPTANCE.md).
|
||||
Approval and revocation accept checked rows, select-all matching rows and first-N selections, up to 100 devices per run. The confirmation fixes the selected IDs for that run, so a device that enrolls afterwards is not included; larger selections fail explicitly, and nothing changes until you continue past confirmation.
|
||||
|
||||
An emulator exercises native screens, login, approval, manual capture and persistence. The physical trigger, Zebra scanner delivery and decoding need real hardware: use the [hardware checklist](TC22-ACCEPTANCE.md).
|
||||
|
||||
## Full Echo demo
|
||||
|
||||
Follow the [Echo demo README](../examples/echo/demo/README.md) for build, environment variables (set in the environment, never on the command line), accounts and permissions, and operator order. Use the trusted-local launcher for a developer machine and the production `EchoDemo` only with the owner's qualified renderer sandbox. The Android flow is the same as above, then open **Echo**, choose the printer, paper size, document and optional copies, and scan.
|
||||
|
||||
## Upgrading an existing demo database
|
||||
|
||||
Stop its JVM gracefully and back up the closed `demo/data` directory together with its protected credential file. The shared H2 schema begins with `SET TIME ZONE 'UTC'` before converting legacy `TIMESTAMP WITH TIME ZONE` columns to the current UTC `TIMESTAMP`, preserving original instants regardless of the workstation time zone. Re-running initialization preserves accounts, passwords, device approval and scans. Do not copy over an open H2 database, and do not use an account reset as an upgrade step. The schema leaves its migration connection in UTC, so hosts should use a dedicated migration connection or restore their own zone afterwards.
|
||||
|
||||
Use the matching Android build with the [mobile-scanners semantic API extension](UI-API.md). Public discovery is at `/qqq/v1/version`; the connectivity endpoint is `/qqq/v1/qbit/mobile-scanners/status`. Upgrade the server and the app together: the former standalone mobile routes are gone.
|
||||
|
||||
See [branding configuration](BRANDING.md) to try customer names, colors and logos with the same APK. Tap the Android connection footer to view or copy diagnostic events.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Mobile browsing test data
|
||||
|
||||
The composed Echo demo includes **Test Data → Test Orders** for testing the existing mobile app. These are synthetic records, separate from scanner captures and printer jobs.
|
||||
|
||||
| Table | Rows | Contents |
|
||||
| --- | ---: | --- |
|
||||
| Test Orders | 1,000 | 39 fields across Order overview, Customer, Shipping, Amounts, Warehouse, and Notes and audit |
|
||||
| Order Items | 12,000 | 12 related items per order, grouped item and handling fields |
|
||||
| Order History | 3,000 | Three related history entries per order |
|
||||
|
||||
Values exercise dates, timestamps, numbers, decimals, booleans, leading zeros, Unicode, multiline notes, long descriptions and empty optional fields. All customer information is fictional.
|
||||
|
||||
Search or filter the table, open an order, and use Details, Items, History or Photos. Details groups collapse and can be selected directly. Table pages contain 25 records; related tabs contain 10 records per page. Back preserves the table page and search/filter state.
|
||||
|
||||
Use **Actions → Add photo → Take photo / Choose photo** to attach an image to the displayed record. Photos lists saved attachments, including those awaiting server review; available images have thumbnails and previews. Normal Record Files permissions, quarantine and upload lifecycle still apply.
|
||||
|
||||
The native preview uses standard QQQ metadata sections and direct association joins through the existing semantic query/get API. It supports read-only browsing; arbitrary widgets, nested editing and unsupported relationship definitions are not added by this fixture.
|
||||
|
||||
`MobileTestData` belongs only to `examples/echo/demo`. Startup adds missing H2 tables and missing synthetic rows without replacing existing rows or attachments. The scanner account can read these tables and attach photos; raw table edits remain refused. The basic Scan Capture demo does not install this dataset.
|
||||
|
||||
Verification uses separate databases, accounts and disposable application IDs. Updating the maintained demo must preserve its database, accounts, printer configuration and existing device enrollment.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Mobile record UX improvements
|
||||
|
||||
User direction: Add photo belongs in the record's Actions menu, alongside other actions. Address the full TC22 visual review in the existing app and maintained demo.
|
||||
|
||||
1. Preserve table page, scroll, search and filter state across record navigation and recreation.
|
||||
2. Keep record identity visible; add Details, related-record and Photos navigation, collapsible field groups, compact summaries and formatted values.
|
||||
3. Put capture/selection behind Actions → Add photo; retain pending-upload guards. Browse saved attachments with lifecycle status and authorized previews.
|
||||
4. Use standard semantic metadata, query/count/process and owner attachment/download APIs. Preserve existing permissions and quarantine.
|
||||
5. Verify unit/build/lint, isolated real native flows, and actual TC22 layout; update the same app/server with preserved data.
|
||||
|
||||
The user subsequently authorized committing, pushing and merging the completed scanner and Android work through Gitea. No new artifact release is requested.
|
||||
|
||||
## Progress
|
||||
|
||||
All five steps are complete. The maintained server and existing TC22 app were updated with preserved data and enrollment. Native search/filter/Back, related-record and photo acceptance passed. Actual TC22 inspection confirmed the Actions menu, capture chooser, tabs, formatting and page-two restoration. Evidence and remaining hardware/release limits are in [the completion record](history/MOBILE-UX-2026-11-05.md).
|
||||
@@ -0,0 +1,56 @@
|
||||
# Single-instance deployment and recovery
|
||||
|
||||
Written 2026-11-05 from the static final review of source base `a9eae04` (see [review reconciliation](REVIEW-RECONCILIATION-2026-11-05.md)). The procedure below is for the host/operator in a real incident. The only executed evidence is the automated [isolated local-demo recovery acceptance](#isolated-local-demo-recovery-acceptance-automated-passed), which covers the demo's stopped H2 database plus object snapshot and restore. No production storage, enterprise object-store or high-availability deployment is supported or qualified, and those are not tasks of this scanner release. The supported model is one server process per database.
|
||||
|
||||
## Topology
|
||||
|
||||
- Run exactly **one server JVM per database**, with no overlap during an upgrade (stop the old process before starting the new one). Revocation and capture-commit exclusion use a per-JVM monitor, screen-mirror frames are held in memory only, and Echo job admission relies on a row lock in that one database.
|
||||
- H2 must be used single-process. Do not point two servers at one database.
|
||||
- The host owns HTTPS and the identity provider; the loopback HTTP demo is not a deployment ([production readiness](PRODUCTION-READINESS.md)).
|
||||
|
||||
## Backup and restore
|
||||
|
||||
- Take one **consistent snapshot of the database and any file storage together** (same moment, or with the server stopped). A database restored without its matching files, or the reverse, leaves dangling references.
|
||||
- Treat bindings and backups as **sensitive**. `mobileSessionBinding.coreSessionUuid` is stored in plaintext at rest (reads are blocked by the application, not by the backup). Anyone holding a backup holds live session identifiers until they expire or end. Backups also contain barcode payloads and last-known device location.
|
||||
- After any restore, before reopening to users:
|
||||
1. Stop all server processes, then restore the database and files from the same snapshot. Nothing can be done through the application while every server is stopped, so steps 2 and 3 need a running server.
|
||||
2. **Restart in controlled, admin-only access** (for example, reachable only by administrators, with scanner clients and other users kept off the network path) and keep it that way through steps 3 to 6. Do not reopen to users first: a device revoked after the snapshot may be approved again and a session ended after it may be active again.
|
||||
3. **Re-audit device approvals.** A restore reverts revocations, approvals and enrollments made after the snapshot, so a device revoked later may be approved again. Review every device and re-revoke as needed.
|
||||
4. **End or reconcile active sessions.** Tombstones written after the snapshot are lost. With the server running in that controlled state, apply the End Session process to the restored sessions so cleanup runs against the restored state, and check the cleanup status below.
|
||||
5. Devices enrolled after the snapshot no longer exist and must re-enroll.
|
||||
6. **Echo:** jobs accepted after the snapshot may be absent from the database although a print was already queued. Manually reconcile the physical or Printing queue against the restored jobs **before authorizing any retry**, and retry only what that comparison shows was not printed. This is a manual check; there is **no exactly-once physical print guarantee**, and a retry can still produce a duplicate or a missing print if the queue or printer state is unknown.
|
||||
7. Reopen to users only after the above are done.
|
||||
|
||||
### Isolated local-demo recovery acceptance (automated, passed)
|
||||
|
||||
`EchoDemoAcceptanceTest.restoredSnapshotKeepsScanAndRetainedFileAndRevertedApprovalStateIsReaudited` (Echo composed demo) exercises this procedure on private temporary directories only.
|
||||
|
||||
**Verification (2026-11-05):** the combined five-module `verify` on the merged source (`develop` `9e8685f` plus the storage fixes) passed with qualified private owner dependencies: 196 tests (root 53, demo 8, Record Files 22, Echo 74, Echo demo 39), 0 failures, 0 errors, 0 skips, and zero Checkstyle violations in every module. The qualified owner inputs are private, so this is not public-dependency qualification.
|
||||
|
||||
The case restores H2 scan rows, the exact leading-zero value and symbology, and the Record Files owner's own `RecordFilesTables.FILE` row. It asserts that row's exact table, record and attachment relationship and the SHA-256 of the object bytes. It shows that a revocation and session end made after the snapshot are reverted by the restore, then reapplies the normal Revoke and End Session processes and shows that a valid in-flight run and a new initialization are denied. No physical printer, service or demo data was changed.
|
||||
|
||||
Flow: it enrolls and approves a synthetic device, scans over real HTTP, and lets the real Record Files service retain the generated original in the demo local-file adapter. With the servers stopped and H2 shut down, it copies the database and object directory together, makes later changes (a second scan and file, a revocation and an ended session), restores only those two temporary directories and reads them without the application. It then restarts on a new ephemeral loopback port and applies the existing Revoke and End Session processes.
|
||||
|
||||
What this is **not**: it covers the local demo's H2 database and local files only, using private coherent fixture inputs. It is not published-cohort qualification (that is repeated when the final public dependencies are adopted), and enterprise object-store and high-availability recovery are unsupported and unqualified. In a real incident the full procedure above still requires an operator-owned audit of approvals, sessions and the print queue before a restored service returns to users.
|
||||
|
||||
Storage regressions in `PrivateObjectFilesTest` (part of the Echo demo's 39 tests): `concurrentSameKeyWritersLeaveOneWinnersBytesAndMetadata` (concurrent same-key writers leave one winner's bytes and metadata), `dotBucketsAreRefusedAndValidBucketsStillWork` (`.` and `..` buckets refused), plus `existingObjectIsNeverReplaced` and `keysCannotTraverseOrAlias`.
|
||||
|
||||
Limits: the file store is the single-host demo adapter and the database is the demo's H2 file, so this does not qualify production storage, an object store, encryption or high availability. Other database backends are not supported or tested here. The renderer is controlled and the printer is a database fixture; no job is physically printed and no normal demo data is changed. The file check reads the Record Files rows and object bytes directly, not through a product service.
|
||||
|
||||
## Session cleanup status
|
||||
|
||||
Ending a session (`MobileSessionService`) first persists the ended state with `authCleanupStatus = PENDING`. Cleanup then sets `REQUESTED` (with `authCleanupAt`) **before** calling the logout executor and removing the stored core session. If that call throws a `QException`, the status becomes `RETRY_NEEDED`. So `REQUESTED` means cleanup was attempted and no failure was reported; it is not independent proof that the provider session is gone. Cleanup is skipped when a binding does not exist, and for a single end inside an open transaction; a status left at `PENDING` also needs a retry. The bulk End Session process commits all tombstones first and runs cleanup afterwards.
|
||||
|
||||
To retry: find sessions with `authCleanupStatus` of `RETRY_NEEDED` or `PENDING` (admin view of the session table), then run End Session again for them. Ending an already ended session does not change its end time and reruns cleanup when a binding exists. Confirm the status reaches `REQUESTED`. There is no automatic monitor in this repository; the operator must check for these statuses.
|
||||
|
||||
## Retention
|
||||
|
||||
Scans, sessions, bindings and Echo scans are **kept indefinitely** by default. Table deletes are blocked by permissions and foreign keys tie history to devices. Barcode payloads and last-known location are retained, so a different retention period is a host-owned policy, not a feature of this release: any purge is a host DBA procedure, taken with a snapshot first and respecting the foreign keys. Audit is current-state only; revoking overwrites the previous approver and time.
|
||||
|
||||
## Database migration beyond H2
|
||||
|
||||
The supplied, tested scripts are H2: `base/src/main/resources/db/mobile-scanners-h2.sql` and the Echo `echo-h2.sql`. Other backends are unsupported and untested here; a host that uses one **owns the equivalent migration**; do not assume `ADD COLUMN IF NOT EXISTS` or the column types. Additive columns that older databases need before running current servers: on `mobileScan`, nullable `aimIdentifier` (`VARCHAR(3)`) and `rawDataBase64`; on Echo, nullable `echo_scan.aimIdentifier`, `rendererValue` and `humanReadable`, and **`echo_run.copies`, which is `INT NOT NULL DEFAULT 1`** (existing runs keep one copy), so it is not nullable and the equivalent migration must supply the default. Echo also has nullable template columns (`templateId`, `templateRevisionId`) in `echo-h2.sql`. Test the migration on a copy, and snapshot first.
|
||||
|
||||
## Demo-only file adapters
|
||||
|
||||
`LocalFilesystemAttachmentStorage` and `LocalStagingChunkStorage` (Echo demo) are single-host local-file adapters for the demo. They are not production storage; photo and record-file storage for a deployment is not qualified here. `ECHO_RELEASE_GENERATED_FILES=true` turns off generated-file quarantine and exists for the demo only; leave it off unless every producer permitted to keep generated output is trusted ([Echo demo README](../examples/echo/demo/README.md)).
|
||||
@@ -0,0 +1,37 @@
|
||||
# Production readiness
|
||||
|
||||
**Status (2026-11-05): not released as production 1.0.** The local demo works and is accepted by the operator; the gates below decide whether the project can be called production-ready. This is a checklist, not a score. The detailed evidence ledger, with per-gate tables and the architecture review findings A1–A11, is archived in [history](history/PRODUCTION-READINESS-through-2026-11-04.md); present status is in [session state](SESSION-STATE.md).
|
||||
|
||||
## Delivered and verified
|
||||
|
||||
| Area | Evidence (see session state for revisions and counts) |
|
||||
| --- | --- |
|
||||
| Server source and development publication | PR 19 merged as `9e8685f`, including record/photo UX support and earlier PR 17/18 changes; the storage/recovery patch is based on that source. Earlier PR 12 snapshot `0.1.0-20261103.233448-3645` was read back anonymously and matched its reviewed source. |
|
||||
| Android host libraries | Published development libraries from source `0ae2101`: SDK run 3634 passed and artifacts verified. The scanner application's newer source and signed candidate are recorded below. |
|
||||
| Current cohort tests | Combined five-module `verify` (2026-11-05): 196 server tests (53 / 8 / 22 / 74 / 39), zero failures, errors and skips, zero Checkstyle violations, with qualified private owner dependencies. Android 232 unit tests and lint pass. Inherited report-only guardrail findings remain visible. |
|
||||
| Storage integrity and recovery | `PrivateObjectFilesTest` concurrent-writer and dot-bucket regressions; `EchoDemoAcceptanceTest.restoredSnapshotKeepsScanAndRetainedFileAndRevertedApprovalStateIsReaudited` is the isolated local-demo recovery acceptance (stopped H2 plus object snapshot/restore, `RecordFilesTables.FILE` relationship and byte hashes). See [operations](OPERATIONS.md). |
|
||||
| Local runtime | One server using the tested development aggregate: public QQQ SNAPSHOT, current Collaboration, scoped local Record Files and qualified document inputs, plus Printing 1.0.0. API 1.10, mobile 1.5, printing 2.3, mobile-record-files 1.0; existing data preserved. Final immutable public-cohort qualification remains open. |
|
||||
| Device and admin processes | Enrollment, approval and revocation work from a single record and from bulk selection (checked rows, select-all, first N), with confirmation and visible results, covered by HTTP and browser E2E tests. Earlier browser run: 15 cases. |
|
||||
| Approved-scanner photos | Host-only `quarantineCapturedFiles=false` makes an approved device's captured JPEG/PNG `AVAILABLE`; other intake stays `QUARANTINED`. `record-files.release` is admin-only. Root-reported: 69 owner and 197 scanner/Echo tests passed, 13 focused Android tests passed, Checkstyle passed. A synthetic gallery PNG from the signed MC9400 app was `AVAILABLE` with exact parent/type/size/SHA-256. No camera-hardware or new full-preview proof. Depends on the unmerged Record Files owner PR 1. |
|
||||
| Hardware | TC22 manual tests: operator-reported pass on November 4 (no per-case artifacts). MC9400 physical trigger done: Saved scan with backend EMDK CODE128/AIM/raw bytes and attribution confirmed (the barcode was not the requested literal). |
|
||||
|
||||
## Open gates
|
||||
|
||||
The maintained server and existing TC22 app now run the GS1, rich test-data and record UX changes. Actions → Add photo, grouped/related records, search/filter/Back state and the saved-photo gallery are implemented and verified by isolated native acceptance plus an actual TC22 visual pass. Existing data and enrollment were preserved. See [current evidence](history/MOBILE-UX-2026-11-05.md). This supersedes the earlier undeployed status; physical GS1 print/rescan and signed-build production deployment remain open. The user authorized the final release, signing and publication for this bounded goal; Android PR 15 (`c782aaaaba3f62422232436a9000af9d4b38fc82`) merged after all CI.
|
||||
|
||||
| Gate | What is still required |
|
||||
| --- | --- |
|
||||
| Public dependency cohort | Public, coherent Generation/bridges/Record Files/Scripting artifacts from their owner; adoption of the Core/Collaboration/Next UI foundations when released. Today these are qualified owner inputs, and the current local aggregate uses public Core SNAPSHOT, current Collaboration and a local Record Files change, not a final public cohort. |
|
||||
| Release | Retest the full gate when the final public dependency cohort is adopted, with a reviewed release manifest; Maven publication stays disabled until then. Redesign of the shared CI pipeline is outside this scope. |
|
||||
| Signing and distribution | Done: QRun key with backup, signed Android 1.0.1 (code 3) built from the merged PR 15 source tree and installed in place on an MC9400 (installed bytes match; login/enrollment preserved; Scan Capture reopened; HTTPS connected). Open: public dependencies, signing-APK download publication and final distribution. No signing CI. |
|
||||
| Deployment | HTTPS and the host's identity provider. Single-instance procedure, recovery and default-indefinite, host-owned retention guidance is in [operations](OPERATIONS.md); isolated local-demo recovery acceptance has passed. Enterprise object-store/HA and non-H2 databases are unsupported and unqualified options, not release tasks. The HTTP loopback demo is not a production deployment. |
|
||||
| Provider authentication | Native OAuth2 requires UI API 1.10 native-session and nonce-validation support on the server (verified in the Android source). Shared Authenticator server configuration is verified (trusted HTTPS, API 1.10, scanner and printing extensions and channels, exact callbacks, REQUIRED nonce). Still open: native Authentik login, logout and account switching on a handheld (needs the MC9400 wireless-debugging endpoint). No native-auth pass is claimed. The Printing client nonce fix (PR 11, `443f642`; 352 tests and build passed) awaits hosted Core. |
|
||||
| Further device qualification | MDM-applied managed configuration remain unverified. Company profile/wallpaper is separate tracked scope, not a blocker for this release goal. The TC22 camera, GPS and screen-viewing checks are covered by the accepted operator group report; no separate per-case evidence is claimed or required again for that demo. |
|
||||
| Barcode fidelity | Echo's supported profiles are documented ([limits](../examples/echo/README.md#fidelity-and-limits)); The committed source supports bounded GS1-128; physical print/rescan remains open. Other GS1, ECI, structured and binary profiles remain refused. No universal barcode-reproduction claim. |
|
||||
| Final reviews | Reconcile the dated security, architecture and standards reports to the final revision; they are scoped historical reviews, not a fresh exhaustive scan. A dated static review of base `a9eae04`, its supplement and its scope and limits are in [review reconciliation](REVIEW-RECONCILIATION-2026-11-05.md); it is not an audit rating. |
|
||||
|
||||
## Rules that stay in force
|
||||
|
||||
- Do not describe a passing build, an emulator run or a demo as production acceptance.
|
||||
- Never reset or overwrite the user's demo or scanner data to run a test; use isolated accounts, databases and application IDs.
|
||||
- Keep device credentials, passwords, device identifiers and workstation paths out of the repository.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Final review reconciliation, 2026-11-05
|
||||
|
||||
**Source base:** `a9eae04` (merge of PR 17, record-files publication metadata), branch `docs/release-readiness-reconciliation`. Present status is in [session state](SESSION-STATE.md).
|
||||
|
||||
This records what one static review covered. It is not an audit rating and not a release or production claim.
|
||||
|
||||
## Scope and limits
|
||||
|
||||
- Method: a source and test **read** of the server and Echo at the base above. Nothing was built, run or deployed for the review itself.
|
||||
- Validation of the resulting patch: on the working tree, root `./mvnw verify install` passed 53 tests with zero errors, failures or skips and 0 Checkstyle violations, and the frozen 1.5.0 YAML's SHA-256 equals the runtime YAML. Base demo verification also passed all 8 HTTP/presence/migration tests, zero failures/errors/skips and 0 Checkstyle violations (61 tests total). Earlier cohort counts in [session state](SESSION-STATE.md) remain historical and were not rerun on final public inputs; no whole-cohort or public-release verification is claimed.
|
||||
- Found no actionable source blocker among the traced paths: admin single/bulk processes, device approval and revocation, session end, scan idempotency, session mirroring, Echo copies and retry, and the optional file-integration guard.
|
||||
- Not verifiable here: Android-source items (A3, A5, A6, A8, A10, A11), which rely on the Android repository's own closure report.
|
||||
- Not read in that pass: the GS1 renderer-side classes (`EchoGs1`, `EchoTemplates`, `EchoTemplatePublisher`, `EchoOwnerAdapters`), `EchoDemoAuthentication`, `LocalStagingChunkStorage`, `PrivateObjectFiles`, and the Record Files and Echo test sources.
|
||||
- Not covered: physical output (copies, GS1 print and rescan), the owner Generation/Printing behavior, browser E2E re-run, MC9400, HTTPS and the identity provider. These remain pending as listed in [production readiness](PRODUCTION-READINESS.md). The three earlier dated reviews predate GS1, copies, photo capture and the 1.5 raw metadata.
|
||||
|
||||
## Actions taken from the review
|
||||
|
||||
- Status documents reconciled to the committed GS1 source and PR 17.
|
||||
- [Operations and recovery](OPERATIONS.md) added; it is a procedure, not an executed drill.
|
||||
- A frozen 1.5.0 OpenAPI baseline added to the contract test; the existing baselines and assertions remain. No runtime or contract change.
|
||||
- `UI-API.md` operation table now lists all seven operations; root `pom.xml` url, scm and issue tracker point at Gitea.
|
||||
- Deferred: the screen-upload 409 and every other operation's 409 share the generic text "Installation already enrolled" in `MobileJsonSpec`. Correcting it is a contract text change needing a patch version bump and regenerated YAML, so it is not done here.
|
||||
|
||||
## Supplement: findings checked after the review
|
||||
|
||||
Scope: the four findings raised after the static review. This is not an audit rating or a score.
|
||||
|
||||
- **F1, concurrent metadata overwrite:** fixed in `PrivateObjectFiles` and regression-tested by `PrivateObjectFilesTest.lateSecondWriterIsRefusedAndFirstWritersBytesAndMetadataRemain` (also `existingObjectIsNeverReplaced`). The controlled interleaving reproduced mismatched metadata on the old implementation; all eight storage tests then passed with the fix.
|
||||
- **F2, dot bucket traversal:** fixed and regression-tested by `PrivateObjectFilesTest.dotBucketsAreRefusedAndValidBucketsStillWork` (also `keysCannotTraverseOrAlias`).
|
||||
- **F3, ZPL command injection:** disproven at source. In the owner Generation renderer, `ZplRenderer.java` lines 326-331 encode every UTF-8 byte under `^FH`. `RendererTest.typedZplEscapesCommandsAndEmitsRasterBarcodes` covers `^XA`, `~JA` and underscore input. That source is owner-held; the exact published artifacts are still pending qualification.
|
||||
- **F4, label clipping:** the assumption is guarded by renderer placement bounds, which refuse overwide text. No universal media-fidelity claim is made.
|
||||
|
||||
The fixes were verified by the 2026-11-05 combined five-module `verify` (196 tests, zero failures, errors and skips, zero Checkstyle violations) on qualified private owner dependencies, not on published artifacts. Pending: owner artifact qualification, physical output and any enterprise object-store or high-availability recovery (unsupported, not tasks of this release).
|
||||
|
||||
## Inherited guardrail heuristics (visible, not disabled)
|
||||
|
||||
Two generic guardrail reports are heuristic mismatches, not missing behavior. They stay visible as existing signals; no check was disabled and no test marker was added to satisfy them.
|
||||
|
||||
- **Cross-QBit references.** Root `src` imports no other QBit's package and its POM depends only on core, Javalin and test libraries. The references are in the separate Echo and Record Files modules, where they are the purpose. Any allowlist belongs at the guardrail, not here.
|
||||
- **OpenAPI and test markers.** The contract is covered by `MobileScannersApiContractTest` (generated against committed YAML and the 1.0.0, 1.1.0, 1.4.0 and 1.5.0 baselines, exact path set and schemas) and real discovery in `MobileHttpAcceptanceTest`. A generic string-marker scan cannot see this.
|
||||
+39
-39
@@ -1,51 +1,51 @@
|
||||
# QQQ Mobile Scanners session state
|
||||
# Session state
|
||||
|
||||
Updated: 2026-10-29
|
||||
Current as of **2026-11-05**. Open work is in [TODO](TODO.md), release gates in [production readiness](PRODUCTION-READINESS.md), and prior evidence in [history](history/README.md).
|
||||
|
||||
## Confirmed scope
|
||||
## Source and publication
|
||||
|
||||
- Native Android host for Zebra TC22 and other handheld scanners, extending `qqq-android`.
|
||||
- Server-defined native screens and QQQ process actions, using existing QQQ metadata.
|
||||
- Online only for version one; no offline scan queue.
|
||||
- Local demo server with real QQQ login and required administrator device approval.
|
||||
- First app: barcode capture into a QQQ table with scan, user, device and timestamp metadata.
|
||||
- User authorized Codex agents for implementation and subsequently requested GitHub repositories and standard project files. Repository creation and pushes are authorized; artifact publication remains disabled.
|
||||
GS1-128 duplication, including barcode and printed HRI, is merged through [PR 15](https://git.kof22.com/QRun-IO/qbit-mobile-scanners/pulls/15), commit `f9b0eb2`, and included in the [Forge source prerelease](https://git.kof22.com/QRun-IO/qbit-mobile-scanners/releases/tag/echo-gs1-source-20261104). This is source publication, not a published Echo Maven dependency. PRs 17 and 18 add publication metadata, the frozen contract baseline and recovery guidance; those changes are retained in this feature branch.
|
||||
|
||||
## Completed plan
|
||||
The [rich demo fixture](MOBILE-TEST-DATA.md) and [record UX improvements](history/MOBILE-UX-2026-11-05.md) are implemented. Native browsing, record Actions and photo presentation belong to the companion Android repository. The user authorized the final release, signing and publication for this bounded goal. Android [PR 15](https://git.kof22.com/QRun-IO/qqq-android/pulls/15) merged as `c782aaaaba3f62422232436a9000af9d4b38fc82` after all CI. Maven publication switches stay disabled until the public dependency cohort is adopted.
|
||||
|
||||
1. Established enrollment and scan contract using existing QQQ APIs.
|
||||
2. Implemented qbit tables, native metadata/processes, approval/revocation and guarded routes.
|
||||
3. Added Android scanner host, table-based login/enrollment gate and Zebra DataWedge input.
|
||||
4. Added a file-backed H2 demo and verified native login, approval, scan, retry and revocation over HTTP.
|
||||
5. Built the Android debug APK and documented TC22 hardware acceptance.
|
||||
6. Added standard repository/community files, current shared CircleCI QBit configuration, CodeQL, Dependabot, demo HTTP and Windows-script CI, and a documented publishing policy.
|
||||
| Item | State |
|
||||
| --- | --- |
|
||||
| Primary forge | Gitea `QRun-IO/qbit-mobile-scanners`, default `develop`; Android uses protected `main`. GitHub is a shadow/archive. |
|
||||
| Scanner development snapshot | PR 12 merged; run 3645 published `0.1.0-20261103.233448-3645`, anonymously read back and compared to its reviewed source. |
|
||||
| Printing | PR 13 merged; Echo defaults to released Printing `1.0.0`. |
|
||||
| Android libraries and signed preview | PRs 8/10 merged at `0ae2101`, documentation through PR 14 (`464814f`); SDK run 3634 verified. `scanner-v1.0.0` is a signed preview, not production qualification. |
|
||||
| Production 1.0 | Not released. Publication policy and switches are unchanged. |
|
||||
|
||||
## Baseline and ownership
|
||||
## Local runtime
|
||||
|
||||
- QQQ baseline: 4.1.0-RC.20. The local 4.0.0 middleware does not support Basic sign-in through `manageSession`; RC.20 does.
|
||||
- Backend agent: qbit main source, unit/integration tests and root POM.
|
||||
- Android agent: `qqq-android` source and scanner application module.
|
||||
- Toolchain agent: Android SDK installed after explicit user approval of required Google terms.
|
||||
- Parent: local demo, cross-component verification and documentation.
|
||||
The maintained composed Echo server and **existing TC22 app** were updated in place on November 5. Database and attachment objects were backed up first; accounts, enrollment, app data and printer configuration were preserved. Live reads verified 1,000 test orders, 12,000 items and 3,000 history entries; orders have 39 fields, six groups and two associations.
|
||||
|
||||
Use **Test Data → Test Orders → a record → Actions → Add photo**. Details, Items, History and Photos tabs provide direct access. Search/filter/count and Back preserve table context. Approved photos have thumbnails/previews; quarantined uploads remain listed as awaiting review. Ordinary read/attach permissions still apply.
|
||||
|
||||
**Approved-scanner photos.** The Echo host sets Record Files `quarantineCapturedFiles=false`, so a JPEG/PNG captured by a currently approved device is `AVAILABLE` at once. Uploads, imports and attachment-panel files stay `QUARANTINED`. Parent tables are Echo scans plus the test orders, items and events; the scanner's existing `.attach` grants are retained. Only `admin` gains `record-files.release`; scanner grants are unchanged. Three earlier quarantined photos were released through the audited admin Release process. The server's current aggregate was built from public Core SNAPSHOT, current Collaboration, the scoped local Record Files change and qualified private document inputs, not a final public cohort. The scoped Record Files change is [owner PR 1](https://git.kof22.com/QRun-IO/qbit-record-files/pulls/1) (policy commit `3f08c584`), open and unmerged; its public CI currently fails an enrollment check that the coordinator owns, so neither the check nor its merge gates are changed here. No version or POM was guessed.
|
||||
|
||||
The [local demo](LOCAL-DEMO.md) source defaults remain QQQ `4.1.0-RC.21` and Printing `1.0.0` pending the public-cohort update; the maintained runtime uses the verified development aggregate described above. Host port is `57832`. The TC22 development app retains its loopback forwarding; the signed MC9400 app connects through trusted HTTPS. Contract versions remain UI API 1.10, mobile-scanners 1.5, printing 2.3 and mobile-record-files 1.0. This is local development qualification, not a customer production deployment.
|
||||
|
||||
## Verification
|
||||
|
||||
- Qbit: `./mvnw.cmd -q verify install` passed 11 tests (7 H2 behavioral/security tests and 4 inherited guardrails), with Checkstyle passing.
|
||||
- Demo: `../mvnw.cmd -B verify` passed Checkstyle and one real HTTP acceptance scenario. It verifies QQQ login, anonymous and pending-device denial, operator/admin separation, approval, async native process capture, same-event deduplication, SQL provenance, bad/missing device credentials, revocation while a process is open, logout and session replay rejection.
|
||||
- HTTP fixtures verify the actual native `composite`/`SCANNER` widget payload. The Android tests deserialize these real metadata/process/init responses.
|
||||
- Android: core/mobileapp/scanner JVM suites passed 19 tests, no failures or skips. `lintDebug` and `assembleDebug` passed. Lint has 0 errors and 5 warnings: 3 inherited Navigation custom checks skipped due to API incompatibility and 2 dependency/version notices.
|
||||
- Debug APK signature verified. Path: `../../qqq-android/scanner-host/build/outputs/apk/debug/scanner-host-debug.apk`.
|
||||
- APK SHA256: `B3E160CE3279C369CEDB425AC6E777A2729CE8A2E430E7D6F46D35C6376F8214`.
|
||||
- Android SDK location: `C:/Users/james/AppData/Local/Android/Sdk`; SDK 35, build-tools 34/35, Gradle 8.7.
|
||||
- `adb devices -l` returned no attached devices. No physical TC22, camera, or emulator acceptance performed.
|
||||
- Repository bootstrap verification: CircleCI setup validated against the private QRun-IO orb with explicit organization scope; Actionlint passed for both workflows. Current shared generator accepted the manifest and selected verification only for seven branch/tag cases. All publication switches are false. Root 11 tests and demo one scenario were rerun successfully after repository metadata changes.
|
||||
- Photo/admin change set (root-reported, not rerun in this review): 69 owner tests and 197 scanner/Echo tests passed (runs of 31, 158 and 8), 13 focused Android tests passed, and the relevant Checkstyle passed. These groupings differ from the module split below, so no per-module counts are claimed. Public immutable dependency qualification is still pending.
|
||||
- Live evidence: a fresh synthetic gallery PNG uploaded from the production-signed MC9400 app returned "Saved photo", and the server showed `AVAILABLE` with the exact parent, type, size and SHA-256. An older photo's Available state, thumbnail and Preview control were observed. The new photo's full preview was not clicked because ADB disconnected. No camera-hardware or full-preview proof is claimed.
|
||||
- The earlier combined five-module `verify` (2026-11-05, base `9e8685f` plus the storage fixes) passed **196 server tests** with zero failures, errors and skips and zero Checkstyle violations: scanner 53, base demo 8, mobile Record Files 22, Echo 74, Echo demo 39. It used qualified private owner dependencies; there is no public-dependency or physical-output qualification, and the gate must be repeated when the public cohort is adopted. The new build parent's report-only schema-location/contract-test findings remain visible.
|
||||
- Storage/recovery PR 21 is merged (`59959247`), with bounded cold-restore and race proof complete. Website 0.1.17 is complete. Evidence: `PrivateObjectFilesTest.concurrentSameKeyWritersLeaveOneWinnersBytesAndMetadata` and `dotBucketsAreRefusedAndValidBucketsStillWork`; recovery acceptance `EchoDemoAcceptanceTest.restoredSnapshotKeepsScanAndRetainedFileAndRevertedApprovalStateIsReaudited` (details in [OPERATIONS](OPERATIONS.md#isolated-local-demo-recovery-acceptance-automated-passed)).
|
||||
- Android passed **232 unit tests**, lint with **0 errors/0 warnings**, and the debug APK build. E2E APK/instrumentation builds and eight runner guard tests also passed.
|
||||
- Isolated MC9400 native acceptance passed search/filter/paging/Back, grouped/related records, camera/gallery upload, parent-scoped saved-photo list, approved preview, rotation, pending-upload navigation protection and forced-logout cleanup. All requests matched the semantic API. Process-photo regression also passed.
|
||||
- The final date-only correction received unit/lint/build checks and actual TC22 verification. Actual-device screenshots confirm Actions → Add photo, dates/timezones, currency, related tabs, photo empty state and return to page two. Synthetic camera/gallery inputs in automated acceptance are not a new operator camera report.
|
||||
- Earlier 135/188/198/377-test cohorts and 15 browser E2E cases are historical evidence, not a fresh run against future public dependencies. The snapshot source comparison applies to its original revision only.
|
||||
- 2026-11-06, branch `fix/refresh-device-versions`: the MC9400 in-place 1.0.0 to 1.0.1 update left the device record and every later scan reporting `appVersion` 1.0.0, because only enrollment ever wrote `manufacturer`/`model`/`osVersion`/`appVersion` and no later client request carried them. Extension 1.6 adds the same optional fields to `MobileScannersHeartbeatRequestV1`; the heartbeat refreshes the device record when a value differs and scans copy the refreshed record (see [UI-API](UI-API.md)). The Android host change (send `osVersion`/`appVersion` on heartbeat) is on the same-named branch in the Android repository; devices need that client build before their versions refresh. Root module: 56 tests, zero Checkstyle violations, new baseline `mobile-scanners-1.6.0.yaml`.
|
||||
|
||||
## Handoff and remaining scope
|
||||
## Handhelds and remaining limits
|
||||
|
||||
- Follow `TC22-ACCEPTANCE.md` for local credentials, loopback demo, USB forwarding, DataWedge profile and device tests.
|
||||
- Runtime demo server is not left running. No user credentials were created outside temporary test databases.
|
||||
- First host supports QQQ table-based authentication; other providers show an explicit unsupported message pending configuration.
|
||||
- Server enforces approved device plus authenticated user, including async execution. Enable QQQ core permission enforcement when embedding the qbit.
|
||||
- Only a current unacknowledged scan is retained in memory for explicit retry. Closing/resetting the app is not an offline queue.
|
||||
- The H2 schema is supplied; migrations for other databases and deployment authentication remain host-owned.
|
||||
- Pending physical acceptance is tracked in TODO. GitHub bootstrap and hosted checks are separate from artifact release or production deployment; use `CI-CD.md` for the repository settings and release enablement policy.
|
||||
Previous TC22 APK SHA-256: `F71033C835AC3EB361A04D78C842947C5A04C85CE1AB1B999AA8CEFBFC9D7A95`; prior preview hashes are in [history](history/RICH-RECORDS-2026-11-05.md).
|
||||
|
||||
Signed Android `1.0.1` (code 3) was built from the identical merged source tree (PR 15) and installed in place on the MC9400. The installed bytes match the built APK, login and enrollment were preserved, the authorized Scan Capture screen reopened, and the HTTPS connection was established. The MC9400 physical trigger is **done**: an actual operator trigger produced a Saved scan, and the backend confirmed EMDK CODE128, AIM and raw bytes with server-derived attribution. The barcode scanned was not the literal string requested, so no exact-test-string claim is made.
|
||||
|
||||
Shared Authenticator server configuration is verified: real trusted HTTPS, UI API 1.10, both scanner and printing extensions and channels, exact callbacks and a REQUIRED nonce. Native client login, logout and account switching are **not yet tested** (this needs the MC9400 wireless-debugging endpoint), so no native-auth pass is claimed. The Printing client required-nonce fix (PR 11, `443f642`; 352 Windows/CLI tests and build passed) awaits hosted Core. Pending: native Authentik login/logout/account switching, the final public Core/Collaboration/document/files/scripting/bridge artifacts, a public-cohort rerun, GS1 physical print/rescan, signing-APK download publication and owned cleanup.
|
||||
|
||||
The November 4 TC22 operator group pass remains accepted and is not requested again. Physical GS1 print/rescan, provider callback acceptance and fleet rollout remain open. Echo supports only its documented [barcode profiles](../examples/echo/README.md#fidelity-and-limits). Company profile/wallpaper is separate tracked scope, not a blocker for this release goal.
|
||||
|
||||
Operations and recovery guidance is in [OPERATIONS](OPERATIONS.md). The automated stopped-H2 plus object snapshot/restore case passed as isolated local-demo recovery acceptance (no physical printing, no normal demo data changed). Enterprise object-store/HA and non-H2 databases are unsupported and unqualified, not release tasks. The [dated review](REVIEW-RECONCILIATION-2026-11-05.md) records its scope and supplement. Public owner dependencies require a separate reviewed adoption and a new gate.
|
||||
+34
-7
@@ -2,6 +2,20 @@
|
||||
|
||||
This checklist records steps to run on physical hardware. A passing JVM test or APK build does not establish that a TC22 scan succeeded.
|
||||
|
||||
## Operator acceptance — November 4, 2026
|
||||
|
||||
**PASS — operator reported.** The user confirmed in the project conversation: "i have ran the manual tests - it all works". This closes the outstanding manual acceptance group for the installed local demo. The user did not supply separate per-case logs or output samples; this entry records their acceptance without inventing those artifacts. No repeat of the same manual checks is requested.
|
||||
|
||||
The last recorded installed configuration was the normal TC22 APK with SHA-256 `38A11155BF6A9C089A1EE6D9E44401E8FF39497B7150B51A85F1F916148BE60D`, connected to the local composed server on port 57832 with public QQQ RC.21 and Printing 1.0.0. This identifies our last deployment record, not a new device inspection at the time of the user's tests.
|
||||
|
||||
Release packaging/publication and the documented unsupported barcode profiles remain separate from this manual acceptance. Signed-build HTTPS device qualification and distribution remain deferred by the user. The procedures below are retained for future deployments and regressions.
|
||||
|
||||
The development APK used for this acceptance is version 0.1.0 (code 1). The server it connected to is the local composed server described in [session state](SESSION-STATE.md).
|
||||
|
||||
## Second handheld: Zebra MC9400 (Android 13)
|
||||
|
||||
The MC9400 was installed and launched with a signed 1.0 release build. At the user's request it was then configured like the TC22 for now: the same development APK as the TC22, device port 8080 forwarded to the host server over paired wireless ADB, signed in as the `scanner` account, and approved through the ordinary **Approve Device** process. The original TC22 is unchanged. **Pending:** one physical trigger scan and the matching backend scan record. Run the acceptance sequence below on it when convenient; the November 4 operator pass above covered the TC22 only.
|
||||
|
||||
## Start the server
|
||||
|
||||
From `qbit-mobile-scanners`, build and install the qbit, then build the demo:
|
||||
@@ -28,30 +42,43 @@ Install Android SDK platform 35 and its build tools, then from `qqq-android`:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat :scanner-host:testDebugUnitTest :scanner-host:assembleDebug
|
||||
adb devices
|
||||
adb install -r scanner-host/build/outputs/apk/debug/scanner-host-debug.apk
|
||||
adb reverse tcp:8080 tcp:8080
|
||||
adb devices -l
|
||||
$tc22Serial = '<TC22 serial from adb devices>'
|
||||
adb -s $tc22Serial install -r scanner-host/build/outputs/apk/debug/scanner-host-debug.apk
|
||||
adb -s $tc22Serial reverse tcp:8080 tcp:8080
|
||||
```
|
||||
|
||||
Enable USB debugging on the TC22 and approve the workstation on the handheld when prompted. In QQQ Mobile, use `http://127.0.0.1:8080` as the local server. HTTP is permitted only for local testing by the debug build; deployed builds require HTTPS.
|
||||
|
||||
Always select the TC22 serial explicitly when an emulator or another Android device is attached. The current workstation demo runs on port 57832; use `adb -s $tc22Serial reverse tcp:8080 tcp:57832` for that server while keeping the app URL unchanged. `install -r` updates the APK without clearing its existing login or enrollment.
|
||||
|
||||
## Configure Zebra DataWedge
|
||||
|
||||
Create a dedicated profile associated with package `io.qrun.mobile.scanner` and activity `*`. Enable barcode input and Intent Output, disable Keystroke Output to avoid double delivery, and choose Broadcast Intent delivery. Set Intent action to `io.qrun.mobile.scanner.SCAN` and category to `android.intent.category.DEFAULT`.
|
||||
|
||||
In Intent Output's Component Information, restrict delivery to `io.qrun.mobile.scanner` and enable its application signature check. Use single-decode mode. Zebra documents barcode text, symbology and source in its [Intent Output guide](https://techdocs.zebra.com/datawedge/14-0/guide/output/intent/).
|
||||
In Intent Output's Component Information, restrict delivery to `io.qrun.mobile.scanner` and enable its application signature check. Use single-decode mode. Zebra documents barcode text, symbology and source in its [DataWedge 13 Intent Output guide](https://techdocs.zebra.com/datawedge/13-0/guide/output/intent/).
|
||||
|
||||
## Observed hardware results, 2026-10-30 (historical record)
|
||||
|
||||
The attached TC22 runs Android 14, DataWedge 13.0.325 and QQQ Mobile 0.1.0. Installation, sign-in, pending enrollment, approval from the browser record Actions menu, native app discovery and opening Scan Capture passed. Updating the APK preserved login. The profile uses the package/activity, input/output and explicit package/signature settings above.
|
||||
|
||||
The user confirmed two deliberate physical trigger presses; two scan records were visible in the backend. One stored row matched the handheld receipt for `QQQ-TEST-123456`, CODE128, source `scanner`, user `scanner`, TC22/Android 14/app 0.1.0, app `mobileScanApp`, process `mobileScanCapture`, and capture/receipt timestamps. This establishes basic physical Code128 decoding, delivery and persistence. It does not establish duplicate delivery suppression or retained-event retry behavior.
|
||||
|
||||
The user then confirmed a saved physical QR scan; the backend record preserved `001234567890` exactly, with `LABEL-TYPE-QRCODE`, source `scanner`, and matching TC22/user/app/process and capture/receipt metadata. Three physical records are now verified: two deliberate Code128 captures and one QR capture. Physical QR delivery and leading-zero preservation passed.
|
||||
|
||||
The connection-loss/retry and revocation-denial cases were still open when this was written; the operator's November 4 report above covers them as part of the outstanding manual group, without separate per-case artifacts.
|
||||
|
||||
## Acceptance sequence
|
||||
|
||||
1. Record TC22 Android version, DataWedge version and installed APK version.
|
||||
2. Sign in as `scanner`. Enroll with a recognizable device name. Confirm the pending device cannot run Scan Capture.
|
||||
3. In the browser, sign in as `admin`, open Mobile Scanner Administration and locate that device. Copy its device ID, run Approve Device, and enter the ID in the form. Refresh status on the handheld.
|
||||
3. In the browser, sign in as `admin`, open Mobile Scanner Administration and locate that device. From the device record choose Actions → Approve Device, review the selected device and confirm. Check the success result, then refresh status on the handheld. Also test approval of selected rows from the device list when multiple handhelds are available.
|
||||
4. Select Scan Capture. Scan a Code128 barcode and a QR code. Confirm one success receipt per scan.
|
||||
5. Inspect Barcode Scans in the admin UI. Verify exact barcode text, symbology, source, user, device, capture/receipt timestamps and app/process identity. Include a barcode with leading zeroes.
|
||||
6. Scan the same barcode twice deliberately. Confirm two events. Retry one retained event after a transport interruption and confirm one stored event for its UUID.
|
||||
7. Stop the server. Attempt a scan and confirm no false success. Restart and explicitly retry the retained event. Verify the acknowledged row exists exactly once.
|
||||
8. Run Revoke Device with the same device ID while its scan screen remains open. Confirm the next capture is refused and no row is added.
|
||||
8. Run Revoke Device from that record while its scan screen remains open. Confirm the next capture is refused and no row is added. Verify bulk revocation affects the selected handhelds and leaves an unselected handheld unchanged.
|
||||
9. Sign out. Confirm the scanner cannot save under the previous user and the previous user session is ended.
|
||||
10. Restart the server and application. Confirm saved scans and device approval persist. Reinstallation/data reset must not silently inherit approval.
|
||||
|
||||
Record pass/fail and observations in `SESSION-STATE.md`. Hardware acceptance remains pending until these steps are actually performed on the TC22.
|
||||
For a new device or a regression run, record pass/fail and observations in [session state](SESSION-STATE.md) without device serials, addresses or credentials. A device is not accepted until its own steps have actually been performed or reported.
|
||||
@@ -0,0 +1,30 @@
|
||||
# TC22 visual and interaction review
|
||||
|
||||
Follow-up: all eight findings were addressed in the existing app and maintained demo. See [implementation and verification](history/MOBILE-UX-2026-11-05.md). The review below preserves the original observations.
|
||||
|
||||
Reviewed November 5 on the actual TC22 running the existing app and maintained demo, using synthetic Test Orders. Directly inspected the first and second table pages, all six order groups, photo controls, related items, an item detail, history links and Back navigation. This is a visual/interaction review, not a new camera, printer or hardware acceptance claim. No records or photos were changed.
|
||||
|
||||
## Assessment
|
||||
|
||||
Readable and functional, but the current presentation is inefficient for repeated handheld work. Large text and buttons help readability; hierarchy, navigation and access to frequent actions need improvement. The data volume exposes limitations that smaller records concealed.
|
||||
|
||||
## Prioritized changes
|
||||
|
||||
| Priority | Observed issue | Recommendation |
|
||||
| --- | --- | --- |
|
||||
| P1 | Page two → order 26 → Android Back returns to page one. | Preserve table page, scroll position and filters per navigation entry. Verify Back after multiple record visits. |
|
||||
| P1 | Reaching photo buttons required five long swipes from the top of order 1. Items/history follow all 39 fields. | Add direct Details, Items, Photos and History navigation, with a prominent Add photo action. Make detail groups collapsible and provide a section selector. |
|
||||
| P1 | Toolbar says only “Record”; order identity disappears while scrolling. Child records use the same title. | Keep a compact record reference and record type visible. Show a parent-order link on item details; keep record labels out of telemetry. |
|
||||
| P1 | A 1,000-row list has Previous/Next/Refresh but no visible search, filters, count or page indication. | Add server-backed search, relevant filters and “26–50 of 1,000.” Consider scan-to-find where declared metadata supports it. |
|
||||
| P2 | Table entries are centered blue links with uneven wrapping; child descriptions occupy three lines. History shows only event identifiers. | Use left-aligned rows with a strong reference, concise secondary text, status and a clear navigation affordance. Show item quantity/SKU and history timestamp/status through metadata-defined summaries. |
|
||||
| P2 | Item heading repeats the full description in very large type, then repeats it as a field. Persistent branding/header and diagnostic footer consume substantial space. | Use a short reference heading and secondary description. Consolidate the header and reduce connected status to a compact indicator; retain accessible text and touch targets. |
|
||||
| P2 | Dates/timestamps and money read like raw values; timestamp example includes `T` and `Z`. | Apply metadata-aware currency, units and localized date/time formatting with timezone context. Preserve leading zeros. Show an explicit empty-value indicator. |
|
||||
| P2 | Photo controls offer capture/select but this screen provides no visible saved-photo gallery or attachment count. | Add thumbnails/count, preview, upload progress and clear pending-review/retry states. Keep the destination record visible near photo actions. |
|
||||
|
||||
## What works
|
||||
|
||||
Text is legible, field labels are distinguishable, sections have clear headings, and the blue photo buttons are easy to identify. Leading zeros, Unicode and multiline values displayed correctly. Parent-to-item navigation worked. Table pagination is available without scrolling the whole page.
|
||||
|
||||
## Suggested order
|
||||
|
||||
Fix navigation state first. Then add persistent record identity and direct photo/related-record access. Next improve list summaries/search and reduce repeated content. Finish with formatting and attachment presentation. Implement generic metadata-driven controls rather than order-specific layouts. No UI fixes were made during this review.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Process acceptance tests
|
||||
|
||||
Every table-based process must be exercised through both the record screen and the bulk-selection UI. HTTP-only tests cannot establish that the menus, confirmation forms, results, or return navigation work. Mobile processes also require native UI tests against the real server.
|
||||
|
||||
| Process | Entry points | Required observable result |
|
||||
| --- | --- | --- |
|
||||
| Approve Device | Record Actions, checked rows, select-all/first-N, standalone IDs | Review selected devices; no early mutation; selected devices become approved; unselected devices stay unchanged; visible success result |
|
||||
| Revoke Device | Record Actions, checked rows, select-all/first-N, standalone IDs | Selected devices become revoked; visible result; further capture is denied |
|
||||
| Scan Capture | Android app picker and native process screen | Login, enrollment and approval gate; exact barcode stored with device/user metadata; acknowledgment; capture denied after revocation |
|
||||
|
||||
Scan Capture creates new scan records. It is not a process over existing table rows, so its entry point is the native scanner UI.
|
||||
|
||||
## Server and HTTP checks
|
||||
|
||||
With Java 21:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd verify install
|
||||
.\mvnw.cmd -f demo/pom.xml verify
|
||||
```
|
||||
|
||||
The optional Echo modules sit outside the root lifecycle and have their own gates (68 library and 35 composed-demo tests in the current cohort, plus 18 mobile Record Files and 16 Printing-bridge tests):
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd -f examples/echo/pom.xml verify
|
||||
.\mvnw.cmd -f examples/echo/demo/pom.xml verify
|
||||
```
|
||||
|
||||
They resolve the owner Generation/bridges/Record Files artifacts from a coherent local Maven repository; see the [Echo README](../examples/echo/README.md#build-and-acceptance).
|
||||
|
||||
The HTTP suite uses an isolated H2 database and generated accounts. It sends the Android client-channel header on every request and exercises real login and process endpoints, asynchronous polling, confirmation/result steps, scan retry idempotency and authorization. Discovery must advertise the Android channel and the registered mobile-scanners extension. SQL assertions independently check persisted values and unaffected devices. Root process tests additionally cover malformed/oversized selections, first-N limits, and stable selection snapshots despite later enrollment or changed submitted values.
|
||||
|
||||
## Browser E2E
|
||||
|
||||
Build the server above, then generate the demo runtime classpath and install the pinned browser-test dependency:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd -f demo/pom.xml dependency:build-classpath '-Dmdep.outputFile=target/e2e-classpath.txt'
|
||||
cd e2e
|
||||
npm ci
|
||||
npx playwright install chromium
|
||||
npm test
|
||||
```
|
||||
|
||||
The suite starts the packaged QQQ frontend and backend against a fresh temporary database. It signs in through the real session API and uses that session in a dedicated test browser, not your open browser or persistent demo. Native E2E separately covers the login UI. Port 57933 is the default; set `MOBILE_E2E_PORT` to choose another unused port. An already running server is never reused. Traces are disabled to avoid recording authentication headers. Test reports are ignored by Git.
|
||||
|
||||
## Native Android E2E
|
||||
|
||||
The companion Android repository provides `scripts/ci/scanner-e2e.mjs`. After building the server runtime classpath above, use Java 21, Android SDK 35, Node.js and a running emulator:
|
||||
|
||||
```powershell
|
||||
cd ..\qqq-android
|
||||
.\gradlew.bat :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest
|
||||
$env:ANDROID_SERIAL = 'emulator-5554'
|
||||
node scripts/ci/scanner-e2e.mjs ..\qbit-mobile-scanners
|
||||
```
|
||||
|
||||
The runner uses a separate `io.qrun.mobile.scanner.e2e` package, a temporary server database and generated credentials. It preserves the normal scanner app's data and refuses physical devices. The test drives the actual login, pending approval, app picker and Scan Capture screens, then independently checks the saved row and revocation denial.
|
||||
|
||||
The semantic migration expands this to six scenarios: incompatible core version, missing extension, missing Android channel, baseline scan flow, additive future metadata, and native approval/revocation/cancellation forms. A test-only forwarding proxy matches requests to the real server's published OpenAPI operations and checks that rejected servers receive no credentials. Current verification results and remaining hardware checks are recorded in [session state](SESSION-STATE.md). Isolated test accounts, databases and application IDs are required; never reset the demo or scanner data to run tests.
|
||||
|
||||
Hardware trigger, decoding and DataWedge delivery still require [TC22 acceptance](TC22-ACCEPTANCE.md). Emulator success does not establish physical scanner acceptance.
|
||||
+36
-14
@@ -1,16 +1,38 @@
|
||||
# QQQ Mobile Scanners TODO
|
||||
# TODO
|
||||
|
||||
- [x] Native qbit device/scan tables and permission boundaries.
|
||||
- [x] Authenticated enrollment, approval and revocation.
|
||||
- [x] Native Scan Capture process and idempotent scan storage.
|
||||
- [x] Android login, device gate, app picker and DataWedge scan handling.
|
||||
- [x] Persistent local demo with separate scanner/admin accounts.
|
||||
- [x] Automated HTTP acceptance: pending denial, approval, capture, duplicate retry, revoke denial.
|
||||
- [x] Android build and meaningful unit checks.
|
||||
- [x] Standard repository files, current shared CircleCI profile, demo/Windows CI, CodeQL and Dependabot.
|
||||
- [x] Validate CircleCI, workflow syntax and verification-only publication policy locally.
|
||||
- [ ] Connect hosted CI and confirm actual required status checks and repository protections.
|
||||
- [ ] TC22 physical scanner acceptance.
|
||||
- [x] TC22 install and DataWedge acceptance guide written; physical execution remains pending.
|
||||
Open work only, as of **2026-11-05**. Present status is in [session state](SESSION-STATE.md); release gates are in [production readiness](PRODUCTION-READINESS.md). The earlier ledger, including every completed item, is archived in [history](history/TODO-through-2026-11-04.md).
|
||||
|
||||
Future scope (not version one): offline queue, camera fallback, kiosk/MDM provisioning, broader native screen coverage, fleet rollout.
|
||||
## Needs an owner outside this repository
|
||||
|
||||
- [ ] **Public document/files/scripting artifacts.** The document owner publishes a coherent set (Generation, its Printing and Record Files bridges including copies support, Record Files, Scripting) as public Maven artifacts. Until then the Echo modules and the optional Record Files integration build against qualified owner inputs and only `qqq.version` and `printing.version` resolve publicly. The owner's packaging is in progress; no completed version is known, so do not invent coordinates or change POMs ahead of it.
|
||||
- [ ] **Core, Collaboration and Next UI foundations.** Adopt the public versions when the owner releases them, as a normal reviewed dependency change with a fresh full gate.
|
||||
|
||||
Note: redesign of the shared CI pipeline is outside this scanner/local Echo scope and is not tracked here; the local demo does not depend on it.
|
||||
|
||||
- [ ] **Record Files `quarantineCapturedFiles`.** The Echo host (`EchoDemo`) and the optional integration test already call/require `RecordFilesQBitConfig.withQuarantineCapturedFiles(Boolean)` (nullable, null inherits `quarantineNewFiles`; applies only to CAPTURE intake with a provider-verified device identity). The owner's scoped change (policy commit `3f08c584`, PR 1) is open and unmerged, and its CI currently fails a not-enrolled check owned by the coordinator; do not change CI or weaken merge gates. Adopt the released version as a reviewed pin once public; no coordinate is known, so none was guessed and this source builds only against the local owner input until then.
|
||||
|
||||
## This repository
|
||||
|
||||
- [ ] Qualify GS1 template/media setup and **physical print/rescan** of the requested label profile. Software duplication, native acceptance and maintained-demo adoption are complete.
|
||||
Retention stays indefinite by default and any change is a host-owned policy ([operations](OPERATIONS.md#retention)). Enterprise object-store and high-availability recovery, and database backends other than H2, are unsupported/unqualified deployment options, not tasks of this release. The 2026-11-05 gate (196 tests, zero failures/errors/skips, zero Checkstyle violations) used qualified private owner dependencies; the required public-cohort rerun remains listed below.
|
||||
- [ ] Optionally correct the generic 409 text in `MobileJsonSpec` (screen upload is a stale-frame conflict) with a patch version bump and regenerated contract; deferred.
|
||||
- [ ] Complete the authorized readiness work in [the active plan](GS1-RECORD-PHOTOS-PLAN.md), retaining explicit external/hardware gates.
|
||||
|
||||
- [ ] Re-run the full gate (root, demo, Echo, Echo demo, Record Files, browser E2E) on whatever dependency set is adopted next, and record the actual counts.
|
||||
- [ ] Qualify the bounded GS1-128 profile and keep explicit refusals for other GS1, ECI, structured and binary scanner profiles in Echo ([limits](../examples/echo/README.md#fidelity-and-limits)).
|
||||
|
||||
## Hardware and device follow-up
|
||||
|
||||
- [ ] Native Authentik login, logout and account switching on a handheld (not yet tested; needs the MC9400 wireless-debugging endpoint). The shared Authenticator server configuration is verified; the Printing client nonce fix (PR 11) awaits hosted Core. The MC9400 physical trigger check is done ([session state](SESSION-STATE.md#handhelds-and-remaining-limits)).
|
||||
- [ ] Optional: click the full preview of the newly uploaded gallery photo on a handheld (not observed because ADB disconnected). No camera-hardware proof is claimed.
|
||||
- [ ] Optional: capture per-case evidence (photos, logs, a scanned-back label) on a future run. The November 4 operator pass is accepted as is and is not to be requested again.
|
||||
|
||||
## Release (deliberately last)
|
||||
|
||||
- [ ] Final distribution of the signed 1.0.1 build and the public dependencies it waits on. Signing is done: the build was installed in place on an MC9400 through ADB, preserved enrollment, and reopened Scan Capture against the HTTPS server. Remaining device checks are listed above; no automatic APK-signing CI is qualified. Also open: signing-APK download publication, owned cleanup, and a rerun of the public cohort with the final public Core/Collaboration/document/files/scripting/bridge artifacts.
|
||||
- [ ] Company profile/wallpaper is a separate tracked scope and not a blocker for this bounded release goal.
|
||||
- [ ] HTTPS deployment by the host (any non-H2 migration is host-owned and unsupported here) and the remaining gates in [production readiness](PRODUCTION-READINESS.md).
|
||||
|
||||
## Not in version one
|
||||
|
||||
Offline queue, camera-based barcode decoding, kiosk/MDM provisioning beyond the implemented managed configuration, broader native screen coverage and fleet rollout.
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# Mobile Scanners semantic UI API
|
||||
|
||||
Mobile Scanners is an independently versioned QQQ UI API extension. `MobileScannersQBitProducer` registers the `mobile-scanners` contract, currently **1.6.0** (core UI API 1.10 on the maintained local server), through `MiddlewareSpecExtensionsMetaData`. The framework generates its routes, discovery advertisement and merged OpenAPI from the same endpoint specs. Optional operator-consented device-screen viewing is described in [DEVICE-SCREEN-VIEWING.md](DEVICE-SCREEN-VIEWING.md); app-content remains the default. There is no separate `/mobile/v1` API or legacy alias.
|
||||
|
||||
The host must also install `MobileScannersRouteProvider` on the same server. This provider binds verified device headers to the current request; it does not register additional business routes. Keep the versioned QQQ routes enabled. The qbit registers the `android` client channel while preserving the host's existing channel policies. The demo disables legacy unversioned middleware routes.
|
||||
|
||||
## Discover before sending credentials
|
||||
|
||||
1. Fetch `GET /qqq/v1/version` without user or device credentials.
|
||||
2. The current Android client requires core semantic version `>=1.8.0 <2.0.0`, with `version`, `metaData`, `processes`, `processCancel`, `extensions` and `widgets` features, and `android` in `clientChannels`.
|
||||
3. Find `extensions[].name == "mobile-scanners"`, require extension version `>=1.0.0 <2.0.0`, and verify `pathPrefix == "/qbit/mobile-scanners"`.
|
||||
4. Resolve the extension prefix under the advertised core base path `/qqq/v1`. Fetch public branding, discover authentication through the core API, and use the existing QQQ session flow.
|
||||
5. Discover apps, processes, frontend steps, fields and widgets through the core metadata/process API. Treat process/widget names as metadata, not protocol selectors.
|
||||
|
||||
An incompatible or missing contract blocks the client with an upgrade/configuration explanation. Do not silently fall back to undocumented routes. Additive unknown JSON properties are safe to ignore. Unknown device states never mean approved; unsupported required UI components must not be silently skipped.
|
||||
|
||||
The discovery document advertises the relative extension `pathPrefix`; merged OpenAPI at `/qqq/v1/openapi.json` and `/qqq/v1/openapi.yaml` contains full paths.
|
||||
|
||||
## Operations
|
||||
|
||||
Every path below is relative to `/qqq/v1/qbit/mobile-scanners`.
|
||||
|
||||
| Method and path | OpenAPI operationId | Authentication | Success |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /branding` | `mobileScannersBranding` | Public | 200, `MobileScannersBrandingV1` |
|
||||
| `GET /status` | `mobileScannersStatus` | Public | 200, `MobileScannersStatusV1` |
|
||||
| `POST /devices/enroll` | `mobileScannersEnrollDevice` | QQQ user session, `mobile.scan` | 201, `MobileScannersEnrollmentV1` |
|
||||
| `GET /devices/current` | `mobileScannersCurrentDevice` | QQQ user session, `mobile.scan`, both device headers | 200, `MobileScannersDeviceV1` |
|
||||
| `POST /sessions/heartbeat` | `mobileScannersSessionHeartbeat` | QQQ user session (cookie, bearer or OAuth2), both device headers | 200, `MobileScannersHeartbeatV1` |
|
||||
| `POST /sessions/current/end` | `mobileScannersEndCurrentSession` | QQQ user session (cookie, bearer or OAuth2), both device headers | 200, `MobileScannersSessionEndedV1` |
|
||||
| `POST /sessions/current/screen` | `mobileScannersUploadSessionScreen` | QQQ user session (cookie, bearer or OAuth2), both device headers | 204, no body; its 409 means a stale frame or sequence, not an enrollment conflict |
|
||||
|
||||
All seven operations also declare 400, 401, 403, 404, 429 and 500 error responses (`BasicErrorResponseV1`). The published YAML labels every operation's 409 "Installation already enrolled"; that text is accurate only for enrollment. Session behavior is in [device sessions](DEVICE-SESSIONS.md) and [screen viewing](DEVICE-SCREEN-VIEWING.md).
|
||||
|
||||
Public discovery, OpenAPI, branding and connectivity ignore stale device headers. Authentication discovery/session/logout also remain available when a previously enrolled credential is no longer valid. Responses from extension handlers use `Cache-Control: no-store`.
|
||||
|
||||
Enrollment takes `MobileScannersEnrollRequestV1`: required canonical UUID `installationId`, optional `displayName`, `manufacturer`, `model`, `osVersion` and `appVersion` strings, each at most 200 characters. The JSON request body is limited to 4096 characters. A duplicate installation returns HTTP 409 and never rotates or discloses its existing credential. A new installation starts `PENDING` and receives its private `deviceToken` once.
|
||||
|
||||
Since extension 1.6, `MobileScannersHeartbeatRequestV1` also accepts the same optional `manufacturer`, `model`, `osVersion` and `appVersion` strings (at most 200 characters, 400 otherwise). An approved device's record is updated only when a sent value differs; omitted, null or blank values leave the enrolled values unchanged. Scan events copy the device record at save time, so an in-place app or OS update is reflected on the next heartbeat and every later scan, while earlier scans keep the versions that produced them. The Android host sends `osVersion` and `appVersion` on every heartbeat.
|
||||
|
||||
Send `X-QQQ-Device-Id` and `X-QQQ-Device-Token` on authenticated device/core metadata, widget and process requests to this same origin. The device ID is a UUID; the token is private and at most 128 characters. These headers supplement the authenticated user session. They never replace user permission checks. Never put a token in metadata, URLs, process values, screenshots, logs or fixtures; never forward credentials to a branding asset origin. Store the issued credential privately. Only its digest is stored server-side.
|
||||
|
||||
`devices/current` returns `deviceId`, `displayName` and `status`, never a token. `PENDING` and `REVOKED` devices may read their status. Scanner access still requires `APPROVED`, checked when launching and saving, including asynchronous/resumed execution. Device approval/revocation uses the ordinary QQQ processes, including single-record and bulk selections.
|
||||
|
||||
Errors use core `BasicErrorResponseV1`, including `error`, `message`, `code` and `traceId`; `X-QQQ-Trace-Id` matches the body. Invalid enrollment input is 400/`BAD_REQUEST`, missing login 401/`UNAUTHENTICATED`, denied permission or credential 403/`PERMISSION_DENIED`, duplicate installation 409/`CONFLICT`, unexpected server failure 500/`INTERNAL_ERROR`. Error payloads contain no credential or backend details.
|
||||
|
||||
## SCANNER widget vocabulary
|
||||
|
||||
Extension 1.x defines `MobileScannersScannerBlockV1`, a `SCANNER` child of the core `COMPOSITE` widget. The standard core process/widget operations transport it. There is no scanner-specific run endpoint. QQQ RC20's closed backend block enum predates `SCANNER`, so `MobileScannerWidget` emits its documented JSON through ordinary `QWidgetData`.
|
||||
|
||||
The block contains required `blockId`, `blockType: "SCANNER"` and `values`. `MobileScannersScannerValuesV1` defines required `title` and `instructions`. Optional `manualLabel` and `submitLabel` default to `Manual barcode` and `Submit barcode`. Optional integer `maxLength` defaults to 4096 and must be within 1–4096. The current server supplies all five values. Native barcode input and manual entry use the same semantics.
|
||||
|
||||
`MobileScannersScanEventV1` describes values submitted to the current core frontend step: required nonempty string `barcode` (maximum 4096), canonical UUID `eventId` and ISO-8601 `capturedAt`; optional bounded strings `symbology` and `scanSource`. Leading zeroes and nonblank decoder identifiers are retained exactly, including vendor names such as `LABEL-TYPE-CODE128`; barcode text never implies a symbology. Missing or blank type becomes `UNKNOWN`, or `MANUAL` for explicit manual source. Missing or blank source becomes `UNKNOWN`. A retry keeps the same event ID; changing its barcode, type, source, actor, application or executing process is rejected. User/device identity is derived from verified context, not from supplied values.
|
||||
|
||||
After core asynchronous polling completes, `MobileScannersScanAcknowledgmentV1` describes the response values: `scanSaved` must be true, `acknowledgedEventId` must match the submitted event and `scanId` must be nonempty. A successful HTTP response alone is not a persistence acknowledgment. A retry returns the original scan ID. The process returns its next screen and widget through the same core response.
|
||||
|
||||
### Accepted scan context and backend actions
|
||||
|
||||
Extension 1.3 additionally supplies `values.scanContext`, described by `MobileScannersScanContextV1`. It contains `scanId`, `barcode`, `symbology`, `scanSource`, `eventId`, `capturedAt`, `receivedAt`, `userId`, `deviceId`, `appId` and `processName`. Dates are ISO-8601 strings. Context comes from the accepted persisted event, rather than a submitted context map. The application comes from server configuration and the process from registered executing metadata; neither comes from request values. This additive property stays optional in the acknowledgment schema for compatibility with older 1.x servers.
|
||||
|
||||
Hosts can append ordinary backend steps after the default process's `save` step. They read `input.getValue("scanContext")` as a map and branch on its `symbology` without querying the scan table. The same text may therefore take separate QR and Code 128 actions. The final host step explicitly returns to `scan`; the unextended default process still loops automatically. Scan-row deduplication does not make arbitrary host actions execute exactly once: those actions must use the stable `scanId` or `eventId` for their own idempotency.
|
||||
|
||||
Host-owned processes use `MobileScannerActions.capture(input, appName)` with their static registered application name, and publish the returned immutable map as `scanContext`. The default overload uses the scanner application. Its permission checker must call the existing `MobileDeviceAuthorization.captureCurrent()` before async dispatch, reject empty device attribution and retain `mobile.scan` permission enforcement. Capture rechecks current authority and approval; a client-provided context never authorizes work.
|
||||
|
||||
Raw event fields and old receipts are consumed before capture, including rejected attempts, so the next submission cannot inherit a preceding type. Following steps use the nested accepted context. Historical NULL type/source values normalize for comparison and returned context without rewriting those rows or accepting a newly claimed known type on retry. Pre-1.3 process state containing a receipt without context fails closed: leave/cancel and reopen the scanner process. Logout and device re-enrollment are unnecessary.
|
||||
|
||||
### Exact capture metadata in 1.5
|
||||
|
||||
Extension 1.5.0 adds two optional flat string values to `MobileScannersScanEventV1` and, when present, to `MobileScannersScanContextV1`. Neither is a required key and old required fields are unchanged. Missing values are valid for older clients and manual capture.
|
||||
|
||||
| Value | Rule |
|
||||
| --- | --- |
|
||||
| `aimIdentifier` | Exactly 3 characters matching `^\][A-Za-z][0-9A-Za-z]$`: the original AIM identifier from the scanner SDK, never guessed from `symbology`. |
|
||||
| `rawDataBase64` | Canonical standard padded Base64 of 1 to 8192 decoded bytes (at most 10924 characters): the byte-for-byte SDK `getRawData` result. The server does not decode it, strip prefixes or assume it equals the UTF-8 of `barcode`. It may include SDK code-ID framing, so it is exact SDK output, not symbol codewords and not evidence of attested scanner configuration. |
|
||||
|
||||
A present value that is blank, not a string, malformed, non-canonical (missing or extra padding, whitespace, URL alphabet, non-zero trailing bits) or oversize is rejected by the server with no scan saved. A JSON `null` is treated as absent. Stored values are nullable columns on `mobileScan`; historical rows keep `NULL`, which means absent.
|
||||
|
||||
Exact equality of both values, including absent versus present, participates in event retry binding: an identical retry returns the original row and scan ID, while changed bytes, a changed AIM identifier, or adding or dropping either value for an existing `eventId` is rejected like any other changed scan. The context contains these keys only when the accepted scan has them. Both input keys are consumed before capture (including rejected attempts) and any submitted `scanContext` is replaced, so the next event cannot inherit them and a client cannot forge them. They carry no identity and no trust elevation, and there is no decoder profile or trust field in this version.
|
||||
|
||||
A client sends these fields only when the current server's `mobile-scanners` version is `>=1.5.0 <2.0.0`, the registered path is advertised and the server's process metadata permits them. Older servers receive exactly the legacy five keys (`barcode`, `symbology`, `scanSource`, `eventId`, `capturedAt`). Hardware qualification of AIM and raw-byte fidelity is separate follow-up work; this transport does not by itself establish exact barcode reproduction.
|
||||
|
||||
Schema migration (H2, idempotent, nullable): `ALTER TABLE mobileScan ADD COLUMN IF NOT EXISTS aimIdentifier VARCHAR(3)` and `... rawDataBase64 VARCHAR(10924)` in `mobile-scanners-h2.sql`. Other backends must add equivalent nullable columns before deploying 1.5.0 servers; do not assume `ADD COLUMN IF NOT EXISTS` or the type sizes exist elsewhere.
|
||||
|
||||
## Contract checks and compatibility
|
||||
|
||||
The published fragment is [mobile-scanners.yaml](../base/src/main/resources/openapi/qqq-v1/mobile-scanners.yaml); the initial release baseline is [mobile-scanners-1.0.0.yaml](../base/src/test/resources/openapi/mobile-scanners-1.0.0.yaml), with additional frozen baselines such as [mobile-scanners-1.4.0.yaml](../base/src/test/resources/openapi/mobile-scanners-1.4.0.yaml) (the contract immediately before the 1.5.0 capture metadata) and [mobile-scanners-1.5.0.yaml](../base/src/test/resources/openapi/mobile-scanners-1.5.0.yaml), the frozen 1.5.0 release snapshot that later changes are checked against. `MobileScannersApiContractTest` rejects generated-document drift, unresolved contract structure, incompatible baseline changes and missing version bumps using QQQ's shared contract checker. Object required fields and scanner numeric bounds are standard OpenAPI keywords, added by small Schema subclasses because RC20's base model does not expose those setters.
|
||||
|
||||
For an intentional contract edit, update specs/schemas and the extension semantic version, then regenerate only the current fragment:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd '-Dtest=MobileScannersApiContractTest' '-Dmobile.openapi.update=true' test
|
||||
```
|
||||
|
||||
Do not refresh the release baseline to hide a compatibility failure. Additive compatible changes require a minor version increment; fixes require a patch increment. Breaking contracts require the corresponding new major contract and an explicit client migration. A server can change supported metadata/screens without rebuilding customer APKs; introducing a new required native component needs a shared client update and compatible contract negotiation.
|
||||
|
||||
Root `verify` checks the committed fragment/baseline and public stale-credential behavior. Demo `verify` runs the real HTTP/authentication/process/H2 lifecycle, checks discovery and merged operation IDs/security/schema references, validates actual enrollment/device/SCANNER/event/ack payloads against served schemas, and checks structured errors and rejection of legacy routes. It writes sanitized core and extension discovery fixtures under `demo/target/android-contract` for Android contract tests. Browser/native end-to-end suites provide the final rendered UI and persistence checks.
|
||||
|
||||
## Scanner presentation hints in 1.4
|
||||
|
||||
SCANNER values may include acknowledgmentLabel (default Saved scan) and manualEntryEnabled (default true). Labels are bounded trimmed single-line plain text of 1–80 characters; explicitly invalid labels or nonboolean manual flags are rejected by the native renderer. Echo uses Print queued and disables manual entry because a manually typed value has no authoritative symbology. These hints do not change scanSaved, scanId or matching-event acceptance requirements; Print queued is not physical output confirmation. Older 1.x clients may ignore the optional hints.
|
||||
@@ -0,0 +1,25 @@
|
||||
> Historical record, superseded by [current status](../SESSION-STATE.md). Do not treat as current.
|
||||
|
||||
# Echo session state
|
||||
|
||||
November 1, 2026: optional example source is being implemented under this directory only. Echo registers ordinary setup and scanner-loop process metadata, uses current approved-device attribution and canonical persisted scan context, and delegates generation/retention/printing to existing QBits. Its state store admits one event, preserves uncertain identities and refuses silent reprint. Core protected process identity, exact media/template revision and owner read-only acceptance seams are under coordinated implementation; no physical printer or peer service has been touched.
|
||||
|
||||
Initial private offline build stopped on an unavailable aggregate JUnit artifact; dependency declarations now use the already available split API/engine. Current compilation and focused H2/process tests remain pending. There is no runnable fully wired demo or production-release claim. See [TODO](../../examples/echo/docs/TODO.md) for remaining acceptance.
|
||||
|
||||
Verification now passes14 real QQQ USER process/mobile-approval/H2 tests, zero failures/errors/skips, and zero Checkstyle violations, against the qualified protected-process SDK4.1.0-pu.507b0d21e09d7eebb76782d14660483c45c77b11 and scanner facade JAR9A5728B7…. Tests cover setup once, two-type exact-value loop, canonical receipt, changed-type retry refusal, uncertain/crashed read-only receipt adoption without re-enqueue, deterministic in-flight concurrency, post-setup cancellation, same-user run substitution, media drift/too-small plan and enforced native source reads including forged reserved keys. Destination/layout/generation fixtures are synthetic; no real renderer, owner adapter, Android or physical-print acceptance is implied. Private evidence is under Ccache/qqq-echo-final-verify-e3929445f6a144e889ba12d90e4c7efb. The unqualified generation API dependency is compile-only provenance, not release adoption.
|
||||
|
||||
Final packaging follow-up: module-directory wrapper verification passes15 tests (including canonical cross-run replay refusal), zero failures/errors/skips, zero Checkstyle violations and deprecation/unchecked warnings-as-errors. Apache license metadata is present in the POM; the JAR contains byte-identical existing repository LICENSE/NOTICE under META-INF/qqq-echo and unchanged db/echo-h2.sql. No duplicate license source files were introduced. Real adapters, hosted integration and physical printing remain pending.
|
||||
|
||||
## Semantic scanner presentation companion
|
||||
|
||||
Echo now supplies acknowledgmentLabel=Print queued and manualEntryEnabled=false through the registered scanner extension 1.4. The companion Android implementation passes 167 JVM cases, strict compilation and lint with zero errors/warnings. The server passes 69 consumer cases plus two contract-generation cases. Echo's 15 strict process/state tests also pass against the qualified 1.4 scanner artifact. This remains synthetic owner-port qualification; actual pinned rendering, media/profile propagation, native Echo UI and physical output still require owner handoffs and acceptance.
|
||||
|
||||
2026-11-01: Added test-scope EchoHttpFixture + actual semantic HTTP smoke, reusing the qualified demo artifact, TABLE auth and mobile routes. No production row lock or service changed. UI fixture has random in-memory state and unmistakable simulated printing branding. Real HTTP retry exposed database precision changes in server receivedAt; removed that observation timestamp from print intent comparison while preserving the first persisted observation. Canonical scan identity, payload/type/source/capture/run bindings remain enforced. Final fixture verification pending; old qualified Echo15 proof is retained.
|
||||
|
||||
Fixture verification: private qqq-echo-http-fixture-e66c4b1bc35a434a8e668fa7cff49dd8, strict16/0/0/0 +Checkstyle0 PASS. Snapshot includes compiled test/main classes, fixture-java.args and source provenance. HTTP test stops its listener cleanly; no device or physical print run. Native execution is the next independent gate.
|
||||
|
||||
2026-11-01 13:15 local: Split only EchoScan reads from approved-device capture. EchoSourceReadPermissions admits current nonblank actor + echo.use for native Get/Query/Count/Aggregate and metadata discovery; native actor RecordSecurityLock remains unchanged and direct writes remain denied. Echo app/widget/process/capture and test-only run inspection retain the approved-device checker. Original-actor desktop read after run close succeeds without mobile.scan/device scope; revoked Echo grant, anonymous actor, different actor with spoofed reserved security key, direct update/delete and desktop capture/process attempts fail. EchoTest17/0fail/0error/0skip, Checkstyle0 and strict Java21 package PASS against core pu.507b0d21 + mobile1.4 FDDB118A; evidence source-read-split.log in the private Echo HTTP fixture cache. Frozen native fixture classes/args were not replaced.
|
||||
|
||||
Critical path: qualify this split's HTTP companion, finish frozen native TC22 UI gate, then compose a production adapter only after reviewed owner media-capability/adoption + exact-pinned template/stock/media + read-only acceptance lookup handoffs. Printing must qualify current original-actor/source reauthorization before pending delivery/retry bytes release. Actual rendering/physical label fidelity and rescan are still unqualified; simulated fixture receipts do not satisfy them.
|
||||
|
||||
Companion semantic HTTP smoke1/0/0/0 +Checkstyle0 PASS after the split (source-read-http.log). Combined bounded cohort18 tests passes; original frozen UI fixture bundle is unchanged.
|
||||
@@ -0,0 +1,46 @@
|
||||
> Historical record, superseded by [current status](../SESSION-STATE.md). Do not treat as current.
|
||||
|
||||
# Echo remaining work
|
||||
|
||||
## Current checkpoint — November 2, copies live
|
||||
|
||||
- [x] Printer, paper/label size and Document selection, real installed Printing client connection and 4x1 document publication are complete on the normal local server57832.
|
||||
- [x] Copies per scan is optional, defaults to 1 and accepts 1–100; pinned for the run and carried as one job's copy quantity. Null/blank/default, selected 3, mutation refusal, retry identity and additive migration tests pass.
|
||||
- [x] Root gates pass: 68 Echo and 35 composed demo tests; 377 unique tests including the Generation/Printing/scanner/files dependencies across verified module runs.
|
||||
- [x] Real TC22 semantic acceptance passes ten stages/116 requests using simulated scans and printing. The normal connected TC22 is left on setup showing Copies per scan = 1; normal server advertises mobile1.5.
|
||||
- [ ] Physical quantity, trigger/print/rescan and AIM/EAN/UPC fidelity checks remain unqualified. The operator is remote.
|
||||
- [ ] Canonical Generation copies adoption/publication and coherent public CI remain pending; qualified local source/handoff lives in C:/Users/james/.cache/qqq-generation-copies-20261102 and qqq-generation-copies-handoff.md.
|
||||
|
||||
The older ledger below is historical; its unrun-test, setup and mobile1.4 notes are superseded by this checkpoint.
|
||||
|
||||
- [ ] Root gate for the required Document choice (printer, paper or label size, then Document at run start; see DOCUMENT-CHOICE-HANDOFF.md): module and demo verify, then on-device check that the generic form shows the dependent Document field.
|
||||
|
||||
Verified by root (2026-11-02): module `verify` 33 tests, Checkstyle 0, against the complete private cohort `0.1.0-echo.gen64c9pu507`. Runnable demo `verify` 22 tests, Checkstyle 0, including the composed acceptance below, with the real new Echo read checker and the renderer owner's TL1 factory (52 owner tests pass, no copied transport). A local demo run was observed live on an ephemeral loopback port (scanner and admin native login and metadata 200). No printer or template on it, and no physical printing.
|
||||
|
||||
## Done
|
||||
|
||||
- [x] Process, state, mobile-approval and H2 tests against the protected-process-identity core API; concurrency, crash recovery, cancellation and run-substitution regressions.
|
||||
- [x] Test-only isolated semantic HTTP fixture (simulated printing) and split read-only `echoScan` permission with actor-lock, revoked, anonymous and write negatives; the read rule also accepts Collaboration's and Record Files' plain capability probe for sessions with `echo.use`.
|
||||
- [x] Owner adapters from actual public APIs: authorized printer/media from Printing, exact pinned template/revision/stock/media generation, read-only accepted-print lookup, `QUEUED` only for a durable available receipt.
|
||||
- [x] Media choices filtered by the selected printer through the semantic dependency value.
|
||||
- [x] Host composition `EchoHost.install` using `PrintingDispatch.forOwnerMediaEnforcement(source)`.
|
||||
- [x] Publisher scene reviewed against the worker source and owner fixture shape; EAN13/EAN8/UPCA/UPCE0 refused before admission; six types admitted.
|
||||
- [x] Runnable demo module and the owner's trusted-local renderer factory integration.
|
||||
- [x] **Clean composed acceptance** (demo `EchoDemoAcceptanceTest`): authenticated approved scanner, real `echoPrint`, real Generation, Record Files retention and Printing job acceptance; two scans give two retained originals and two accepted jobs on one printer, a retry gives the same job, and leaving closes the run. Controlled synthetic renderer and simulated Printing queue; not physical.
|
||||
|
||||
## Open
|
||||
|
||||
- [ ] Actual native Printing client (Windows agent) connection to the composed server and the current stock/media handoff.
|
||||
- [ ] Publish the ZPL template through the real renderer, then preview and independently decode Code 128 and a leading-zero QR (`001234567890`) at 4 x 6 in / 300 dpi.
|
||||
- [ ] Real-server native TC22 run: setup once, scan twice, rotate/recreate, leave; one stable print identity after transport loss.
|
||||
- [ ] Physical print and TC22 rescan with exact value, symbology, quiet zones and stock dimensions; native Printing acceptance. A queue acknowledgment is not paper.
|
||||
- [x] Source: AIM persistence (`echo_scan.aimIdentifier`), EAN13/EAN8/UPCA/UPCE0 validation, and a scanner-fidelity document through the renderer's scanner mode; candidate Android AIM output profile (`AimOutputProfile`, driver config/readback/restore). Not yet run (root builds) and not physically qualified.
|
||||
- [x] Source: Copies per scan (setup field, server-pinned `echo_run.copies`, scan-stage forgery refusal, widget text). Library tests added, not run.
|
||||
- [x] Source: copies reach Generation's request and the accepted-lookup binding (needs the Generation copies cohort from `C:/Users/james/.cache/qqq-generation-copies-handoff.md`, unbuilt). Optional field with 1..100 core limits and real HTTP tests for default 1, chosen 3 and mutation rejection; not run.
|
||||
- [ ] Root gate with the Generation copies cohort, the `20261102-print-copies-h2.sql` migration in the demo's `GENERATION_MIGRATIONS`, a Printing owner that honors `options.copies`, and a physical count check on the printer.
|
||||
- [ ] TC22 pulls proving what `getData()` and `getRawData()` contain under `codeIdType=AIM`, then a physical EAN/UPC scan, print and rescan on the scanner-fidelity document. Normal server 1.5 adoption (the live server is 1.4 and drops AIM and raw bytes).
|
||||
- [ ] GS1, ECI, structured, binary and non-ASCII payloads stay refused: the existing public renderer has no scanner-mode profile for them (see `C:/Users/james/.cache/qqq-finish-barcode-handoff.md`).
|
||||
- [ ] Original-actor source reauthorization policy confirmed for delayed byte release (Printing retained source authority).
|
||||
- [ ] Deployment: Windows agent exposing `printer-media-v1`, ZPL printer, tenant, operator permissions without `printing_admin` (the scanner never receives it).
|
||||
- [ ] Public immutable SDK publication, Gitea CI and distribution, then signing, last. The private cohort is unpublished; PR 7 is on Gitea and earlier GitHub references are historical.
|
||||
- [ ] Release and production readiness: not done and not claimed.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Mobile record UX completion — November 5
|
||||
|
||||
The [TC22 review](../TC22-UIUX-REVIEW-2026-11-05.md) identified eight usability issues. All are addressed in the companion Android application and this demo's ordinary metadata:
|
||||
|
||||
- Table page/scroll/search/filter state survives record navigation and recreation; search, exact text filters and counts use the semantic query/count APIs.
|
||||
- Record identity stays visible above Details, related-record and Photos tabs. Groups collapse; a section selector and child-to-parent navigation shorten large records.
|
||||
- Left-aligned summaries, short item labels, localized dates/timezones, currency/units and explicit empty values improve readability. Diagnostic detail is behind Connected.
|
||||
- **Actions → Add photo** opens the existing camera/gallery upload flow. Saved photos are scoped to the parent, show lifecycle status and use the owner's authorized tracked download for available-image previews. Quarantined originals remain unavailable.
|
||||
|
||||
Record Files deliberately refuses aggregate counts: the gallery uses bounded pages with one lookahead row, without weakening permissions. Images have bounded byte/bitmap memory, exact length/hash checks, same-origin authenticated delivery and no image disk cache. Navigation guards remain active during pending uploads, including rotation; logout removes owned temporary staging.
|
||||
|
||||
## Verification and adoption
|
||||
|
||||
The composed feature cohort passed 189 tests (scanner 53, base demo 8, mobile Record Files 18, Echo 74, Echo demo 36) with Checkstyle. Android passed 232 unit tests and lint (0 errors/0 warnings), plus debug/E2E/instrumentation builds and eight native runner guard tests. Isolated MC9400 rich-record acceptance passed grouping, related paging, search/filter/Back preservation, camera/gallery persistence, parent-scoped gallery, approved preview, rotation, blocked navigation and forced-logout cleanup. Process-photo regression passed separately. All native requests matched the published semantic API.
|
||||
|
||||
The date fallback needed to recognize the server's default `%s` metadata and differing raw/display JSON representations. Regression tests and a final unit/lint/APK gate cover that correction; actual TC22 screenshots show formatted dates and timezone context. The final date-only change did not repeat the full native suite.
|
||||
|
||||
The maintained server was updated with a database/object backup and no reset. The ordinary TC22 application was replaced in place after matching its signer; the installed bytes match SHA-256 `F71033C835AC3EB361A04D78C842947C5A04C85CE1AB1B999AA8CEFBFC9D7A95`. Login, enrollment, server address and existing data survived. Actual TC22 inspection confirmed Actions, Take photo/Choose photo, Details/Items/History/Photos, formatted values and order 26 → Android Back → 26–50 of 1,000. No real camera capture or print was submitted during this visual check.
|
||||
|
||||
Earlier UX-build hashes `23BA59F0EEE34D5B9ABBE5D45158A57C98416A5D7D69337090129AB6C853EABC` and `25DAB25D92FF709E4DA5DAA9949A501113E71DC050FF12E07C17C06EE06514E5` were superseded by the date correction. The preceding rich-record preview is preserved [separately](RICH-RECORDS-2026-11-05.md).
|
||||
|
||||
## Source and remaining limits
|
||||
|
||||
The user authorized commit, push and merge after the local update. Latest upstream scanner PRs 17/18 are retained. Reviews: [scanner/server PR 19](https://git.kof22.com/QRun-IO/qbit-mobile-scanners/pulls/19) and [Android PR 15](https://git.kof22.com/QRun-IO/qqq-android/pulls/15), which includes actual TC22 screenshots. Publication switches and repository dependency defaults are unchanged.
|
||||
|
||||
The combined source passed a fresh **193-test** server gate: 53 scanner, 8 base demo, 22 mobile Record Files, 74 Echo and 36 Echo demo, zero failures/errors/skips and passing Checkstyle. The inherited build parent adds four guardrail cases; report-only extension schema-location/contract-test findings remain visible. Every source/resource file in the isolated build was matched to the merged working tree by count and SHA-256. Owner-coordinate and Checkstyle-path overrides existed only in that verification copy. A direct build against the old default Record Files snapshot failed because its authorization API is missing; the qualified running-demo cohort passes. Public owner-dependency adoption remains separate work.
|
||||
|
||||
The November 4 TC22 operator pass remains accepted. Physical GS1 print/rescan, MC9400 trigger acceptance, HTTPS/signed-build qualification and public owner-dependency adoption remain distinct open gates. No production release or new Maven artifact publication is claimed.
|
||||
@@ -0,0 +1,107 @@
|
||||
> Historical record, superseded by [current status](../SESSION-STATE.md). Do not treat as current.
|
||||
|
||||
# Mobile scanners 1.0 release gates
|
||||
|
||||
Status: not release-ready. The user requested a full client/server audit and production-ready 1.0. This is an evidence ledger; a numerical quality score does not replace the gates below.
|
||||
|
||||
## Current checkpoint — November 2
|
||||
|
||||
Server `e3b775e` (Gitea PR 7) fixes the Generation/renderer output-limit mismatch and passes 45 Echo library plus 29 composed demo tests with zero Checkstyle violations. Android implementation `fb3cc5d`, published with documentation at `ffc5b49` (Gitea PR 10), passes 174 JVM tests, lint with zero errors and warnings, and the debug APK build. One local server on host port 57832 runs Echo, Record Files, Document Generation and Printing together (API 1.10.0, mobile 1.4.0, printing 2.3.0). The normal TC22 and the installed print client work against it, and the real 4x1 stock, media revision and Echo document are published and selectable. The earlier native acceptance (7 stages, 112 requests) used simulated printing and inputs.
|
||||
|
||||
The synthetic HTTP event `ECHO-HTTP-TEST-001` has one matching scan and one actual ZPL print job in `SPOOLED`, with a Windows spooler job assigned and its process closed. A read-only recheck at 16:31 UTC confirmed that receipt, server HTTP 200, the running print service, and the TC22 showing Echo / Scanner ready / Connected. No Windows administrator prompt or printer pairing is pending. Paper output and physical TC22 print/rescan remain unconfirmed; see the [delivery receipt](../../examples/echo/demo/docs/echo-delivery-acceptance.json).
|
||||
|
||||
Still not release-ready. Open: physical Code128 and leading-zero QR scan, print and rescan; extended barcode fidelity beyond the currently admitted types; full provider, camera, GPS, projection and device qualification; public coherent artifacts; primary Gitea CI; and final signing and distribution, which is deliberately last. Core PR 1337 was reviewed with its 57 focused tests verified, but normal CI and publication are not established and default dependencies are not repinned. Where dated checkpoints below describe the same-origin fix, printer connection, media, document or server composition as pending, this section supersedes them.
|
||||
|
||||
## Superseded checkpoint — November 1, 17:34 UTC
|
||||
|
||||
Server standards checkpoint0b53f34 is signed/pushed and all eight hosted checks pass; a fresh private API1.10 cohort passes39 server, six demo HTTP and18 Record Files consumer cases with Checkstyle. Client strict compilation and163 JVM tests pass; final TC22 native acceptance is running and the Query artwork density warning is being corrected before installation. About includes bundled Query artwork and the exact public Dogfooding/Query links.
|
||||
|
||||
Actual Authentik desktop acceptance now passes real negative nonce rejection, A login, explicit company logout, old-cookie rejection, fresh B credentials and B principal in the same retained browser context, followed by B local logout/rejection. HTTPS verification remained enabled with profile-only fixture trust. The owned provider required its documented company invalidation flow; no global flow or default client sign-out policy changed. This closes the earlier desktop A-to-B handoff failure for the qualified API1.10 fixture, not the actual Android external-browser callback gate. The normal server remains API1.9/mobile1.1 and operator data is preserved.
|
||||
|
||||
CorePR1369's new hosted run exposed a missing explicit architecture allow-list entry for extracted private-session logout plumbing; a narrow policy-conforming correction is under verification. Do not call final core CI green. Remaining physical GPS/camera/projection/lifecycle/retry, immutable files-owner handoff, deployment and owner-deferred signing/distribution gates remain open. Dated matrix rows below retain their earlier source/runtime scope and are superseded by this checkpoint where stated.
|
||||
## Current release ledger — November 1
|
||||
|
||||
This is the current acceptance matrix; dated checkpoints below remain historical evidence. Android portrait-default source `7de2b5088fa376cb161ed5ac7e5358d2a2145d92` passes all eleven hosted checks. Documentation/QR fixture checkpoint `2e0fd962a7ece0f81328e5f9f05e4c1e2d77a853` does not establish physical QR acceptance. Server implementation `10416938724c9663d66f7848ff2b1a63fe635794` has qualified API1.9/mobile1.2 source evidence. Source publication, private consumer qualification and the installed operator runtime are distinct; no production APK or immutable dependency release is claimed.
|
||||
|
||||
| Requirement | Accepted evidence and scope | Remaining release gate |
|
||||
| --- | --- | --- |
|
||||
| Current Android and CI | All eleven hosted checks pass at published Android8d8 (portrait implementation7de), including native navigation/input, PixelCopy and semantic E2E. The subsequent optional provider-logout working patch passes148 JVM tests, lint and debug build; its native tests compile but have not run. The completed-logout/enabled-action guard retains fresh-login/new-session assertions. Portrait default preserves explicit saved choices. | Physical operator navigation, lifecycle, ownership, outage/retry and leave/cancel walkthrough. Any later executable source needs its own verification. |
|
||||
| Trusted scanner ingress and hardware | Ordinary-UID/distinct-signer rejection of eight forged scans plus manual positive control; actual EMDK Code128 source/model/event proof: eight deliberate events on prior F3AF APK and one on the installed portrait-default C891 APK. Historical DataWedge evidence is separately retained. | Current leading-zero QR, repeated-intent/lifecycle/ownership matrix, connection failure and uncertain receipt/retry/leave acceptance. Code128 success does not qualify QR or every lifecycle path. |
|
||||
| Running operator deployment | Preserving coreecf/serverb14 UI API1.9/mobile1.1 runtime on127.0.0.1:57832; normal TC22 uses development APK C89161C522AA7DFFE09C009ABDE769C1BADB7E6C64BFCE699857062A1CEC6340 with signer/data backup preserved and portrait requested. | Supported production deployment, HTTPS/authentication, migration/recovery and release upgrade acceptance. No normal1.2/RF installation is implied. |
|
||||
| Source-pinned server/admin APIs | Server104 source:39 server/six demo/all15 browser cases,195 hashed JARs/2,441 unique QQQ classes/zero duplicates; record/bulk administration, profiles, presence, sessions and screen controls covered. Coreecf selected closure:4,507 discovered/16 existing skips/zero failures/errors. | Coherent immutable dependency publication and final supported deployment configuration. Default Maven dependencies still select RC.20; passing source-pinned consumers do not change that default. |
|
||||
| Authentik | Real Authentik2026.8.5 browser PKCE login established operator A; QQQ local logout invalidated its old cookie. With retained browser SSO, the next prompt=login silently reused A, so operator handoff failed. Optional semantic API1.10 RP logout and Android AppAuth handling are implemented but unqualified; the first core build found an API mismatch and review found an alternate-authentication-source gap under correction. | Correct and qualify the optional logout implementation, then prove real A→provider logout→B identity isolation. Native browser callback/lifecycle and user-selected sign-out policy remain pending. Existing failed retained-SSO evidence must remain failed. |
|
||||
| Optional whole-device viewing | Additive mobile1.2 server and consent-based Android source built/reviewed; mode/session-bound leases and bounded image ownership verified automatically. Normal server remains mobile1.1 and does not expose device-screen mode. | Actual OS consent/notification, Home/other-app pixels, Stop/lock/rotation, lease expiry and remote logout on the refined candidate. Separately preserve normal data before any coordinated1.2 upgrade. |
|
||||
| Camera/gallery and Record Files | Filesystem portability142 discovered/138 passed/four existing skips; metadata symlink-parent regression fixed and independently closed. Final coherent core32/scanner1a3 qualification passes39 scanner, six demo,18 Record Files and15 browser cases (78 total), with214 JARs/3,163 unique QQQ classes/zero duplicates and no separate filesystem override. Synthetic native camera/gallery/receipt cases remain separate evidence. | Coherent immutable supported RF/attachment-owner/core/RDBMS/filesystem tuple and default adoption, then actual TC22 lens/gallery permissions and durable authorized record association. The isolated coherent qualification does not establish the files owner full-suite/default publication gate or change the normal runtime. |
|
||||
| GPS and customer appearance | Foreground-only location/state/age controls and automated permission-denial/schema coverage; current basic operator display/navigation observed. | Real GPS permission/fix/disabled-provider behavior and customer-specific logo/physical appearance. |
|
||||
| Security, architecture and standards | Historical scoped security reports/remediations retained; independent architecture closure maps A1–A11 controls/tests. Whole-device changed-file review and follow-up found no concrete introduced issue; filesystem finding closed against corrected source/test evidence. | Final revision/scope reconciliation and unresolved external-provider/deployment gates. These reports do not become a new exhaustive scan or blanket production clearance. |
|
||||
| Release and operations | Release automation/source packaging independently reviewed; normal preserving migration/login checks passed. | Reviewed GitHub environment/tag protections, organization signing identity/recovery, actual signed APK upgrade/physical qualification and distribution decision. Signing/distribution remains owner-deferred until the final step. |
|
||||
|
||||
The local emulator remains off because of workstation stalls. Disposable QA and private fixtures must not replace the normal package/runtime or clear operator data. No numerical quality score replaces the remaining gates.
|
||||
|
||||
Historical November1 display observation retained: the handheld's black desktop mirror was not proof of a crash. Direct inspection found the normal process alive/resumed, no crash-buffer entries and connected sign-in; login reached Scan Capture without clearing data or running disposable instrumentation over the operator.
|
||||
|
||||
## Historical checkpoints
|
||||
|
||||
Latest correction: hosted Android testing found revoked heartbeats remained successful. Approval now precedes all heartbeat activity observation, including accepted trailing-slash aliases. Independent red reproductions and final 37-root/four-demo green tests are retained; new hosted native verification remains pending. Scanner authentication must follow configured Authentik through explicit native semantic authentication metadata; that implementation and real-provider acceptance are now required release gates.
|
||||
|
||||
The optional consumer has subsequently qualified frozen0515 with37 fresh base and18 integration tests, including the matched core logging correction and runtime integrity checks. This remains an isolated private candidate; immutable dependency publication and optional hosted CI are required before production adoption. Separate OAuth2 log-redaction work in the native-auth core branch is not included in0515.
|
||||
|
||||
## Scope and checkpoints
|
||||
|
||||
Latest verified checkpoint: server `0926550` includes the standalone file-backed presence regression and the passing browser-to-native
|
||||
viewing regression after guarded-upload commit `84ffd7c`. Base 37, demo HTTP two,
|
||||
browser 15 and optional guarded integration 18 cases pass. Android's local reports
|
||||
show 91 JVM tests; TC22 navigation (12 cases), orientation, offline licenses,
|
||||
synthetic camera/gallery/rotation/logout, and the complete real-screen/session-end
|
||||
scenario pass. Native photo/session requests match published semantic operations.
|
||||
The full server deep static security review of `0926550` is sealed with no reportable findings. It does not inspect external dependencies or production configuration and does not replace runtime acceptance. Android's full scan and the later single-hamburger UX correction remain pending.
|
||||
These are checkpoint results; final Android audits, fresh Android hosted CI,
|
||||
production provider/immutable dependency qualification and actual lens acceptance
|
||||
remain open. Signing/distribution is owner-deferred until the final step.
|
||||
|
||||
- Server semantic baseline: `7dd02b8874882ca2edcdcd89109b3dee6b4b0c88`, with all hosted checks passing. Documentation checkpoint: `a49724384f569a4b4b66ce2cd4aa69332ee1e0e8`.
|
||||
- Android semantic baseline: `6574ec4991dbb859b60c3a0be462af96918aee32`; hosted JVM/lint/APK, CodeQL and CircleCI checks passed, but both hosted native E2E jobs failed at compatible login before any credential request reached the server. Login/form reachability fixes are under verification; this baseline is not fully green.
|
||||
- Active implementation: [device/session history, live activity, actual app screen viewing, session termination, identity/presence/location and Record Files integration](../DEVICE-SESSIONS.md).
|
||||
- Final audit scope: both complete repositories, including shared Android core/mobileapp libraries, scanner host, qbit, demo, build/release configuration, tests and documentation. Exclude local secrets and operator data from published evidence.
|
||||
|
||||
## Required gates
|
||||
|
||||
| Gate | Status / required evidence |
|
||||
| --- | --- |
|
||||
| Feature completion | Session administration, screen viewing and device naming/presence/location have passing server/browser coverage. Native navigation/session and Record Files photo integration are in progress. Actual ingestion routes exist; atomic revocation/commit protection and compatible runtime qualification remain open. |
|
||||
| Security | Independent multi-pass full scans of the frozen client and server; validate findings, fix release blockers, verify fixes and retain coverage/limitations. |
|
||||
| Architecture | Review boundaries, coupling/cohesion, session ownership, semantic API evolution, concurrency, resource limits and deployment assumptions. |
|
||||
| Coding standards | Repository conventions, public API compatibility, lifecycle/error handling, duplication, dead code and maintainability review. |
|
||||
| Automated tests | Unit, HTTP contract, schema/version compatibility, browser record/bulk processes and native UI-to-real-server tests on the final revision. |
|
||||
| Physical acceptance | TC22 capture, mirroring, remote logout, failure/retry, restart, data preservation, location permission/age, camera capture and gallery upload to an authorized record. |
|
||||
| Delivery | Release build, signing/update approach, reproducible artifacts, dependencies/licenses, secrets checks and final hosted CI. |
|
||||
| Operations | Supported deployment/database migration, HTTPS/authentication, retention/resource limits, diagnostics and recovery documentation. |
|
||||
| Final review | No unresolved release-blocking findings; evidence identifies exact revisions and remaining supported-use boundaries. |
|
||||
|
||||
## Verified baseline evidence
|
||||
|
||||
October 31 working-source evidence: 37 root tests, two real HTTP acceptance tests and all 15 browser E2E cases pass. Browser cases include single/selected-bulk/full-query session end, cancellation, live image/clear, device profile edits, location timestamps and online expiry/filtering. The full browser run completed in 59.4 seconds after correcting file-H2 connection lifetime. A subsequent virtual-online projection fix passes the 37-test suite; its combined native acceptance remains pending. Android's intermediate 76 JVM tests and zero-error/zero-warning lint pass; shared native and real-server session tests remain in progress. These are local intermediate results, not a frozen release or fresh hosted qualification.
|
||||
|
||||
Server: 23 root tests plus the real HTTP lifecycle scenario and six packaged-frontend browser E2E scenarios. Android: 53 JVM tests, six shared native UI tests, lint with zero errors/six warnings, debug/E2E APK builds, and six semantic native scenarios across focused runs. The final admin scenario passed in 255.917 seconds; 31 of 31 requests matched 13 published operations, including cancellation. These results are baseline evidence and must not be represented as verification of later session/mirroring changes.
|
||||
|
||||
Physical TC22 (Android 14, DataWedge 13.0.325, app 0.1.0): installed with existing state preserved, signed in and approved through the actual device-record process. A dedicated DataWedge profile uses barcode input, explicit broadcast delivery and package/signature verification, with keyboard output disabled. Two deliberate Code128 trigger scans produced two records. One QR trigger scan preserved `001234567890`; backend records confirmed symbology, source, user/device/app/process and timestamps. Outage/retry and remote-session controls remain to be tested on the completed feature.
|
||||
|
||||
## Review findings
|
||||
|
||||
The initial full-source architecture/maintainability review covered Android `6574ec4` and server `7dd02b8`; it does not cover the later session/device features. Findings are being remediated, with final independent review still required:
|
||||
|
||||
| Finding | Remediation owner / evidence pending |
|
||||
| --- | --- |
|
||||
| A1: scan retry cleared on an incomplete receipt | Android host: require matching acknowledgement ID, `scanSaved=true` and nonblank scan ID. |
|
||||
| A2: invalid scanner length metadata silently defaulted/truncated | Android host: reject invalid or out-of-range metadata. |
|
||||
| A3: legacy INPUT_FIELD bypassed typed field semantics and interpreted input as action code | Shared client: typed field handling and explicit action capability. |
|
||||
| A4: ordinary asynchronous status failures lacked a safe retry | Shared client: retry status GET without replaying submission. |
|
||||
| A5: cancellation during initialization could leave a late process orphaned | Shared client: retain cancellation intent and cancel a returned process ID. |
|
||||
| A6: login/forms could place actions outside the viewport or behind the keyboard | Shared client: scrolling/IME handling and constrained-viewport native tests. Hosted failure cause remains unproven until the regression evidence is complete. |
|
||||
| A7: explicit null displayed a default while submitting null | Shared client: distinguish absent keys from explicit null consistently. |
|
||||
| A8: business JSON collided with class-based serialization envelopes | Shared client: ordinary JSON values without class loading. |
|
||||
| A9: multi-record mutations and enrollment could persist partially | Server: transactions for credential/device and session/binding pairs and bulk state changes; provider logout after durable tombstones with explicit cleanup outcome. |
|
||||
| A10: media ownership/disposal could leak resources | Shared client: sound/image lifecycle ownership. |
|
||||
| A11: navigation title/state and native back stack could drift | Android host: derive navigation state from the active NavController. |
|
||||
|
||||
The completed TC22 session scenario delivered and decoded real application JPEG pixels, cleared background frames, renewed viewing on resume, honored operator Stop viewing, ended the session remotely and accepted a fresh login. Android packaging now verifies 130 notice entries, nine license texts and eight native binary hashes in each host variant. The full server static scan is complete; Android review, external core logging remediation/adoption, production signing/update path, supported database migration and final hosted checks remain required before 1.0.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Documentation history
|
||||
|
||||
Latest checkpoint: [record UX completion, November 5](MOBILE-UX-2026-11-05.md). Earlier preview: [rich record fixture](RICH-RECORDS-2026-11-05.md).
|
||||
|
||||
Superseded rolling logs, preserved verbatim as evidence. They are **not** current status: many entries describe work as pending that was later completed. Read the current entry points instead:
|
||||
|
||||
- [Project README](../../README.md), [session state](../SESSION-STATE.md), [TODO](../TODO.md), [production readiness](../PRODUCTION-READINESS.md)
|
||||
- [Echo README](../../examples/echo/README.md), [Echo demo README](../../examples/echo/demo/README.md)
|
||||
|
||||
| File | What it is | Covers |
|
||||
| --- | --- | --- |
|
||||
| [SESSION-STATE-through-2026-11-04.md](SESSION-STATE-through-2026-11-04.md) | The former `docs/SESSION-STATE.md` rolling log, newest checkpoint first | 2026-10-29 to 2026-11-04 |
|
||||
| [TODO-through-2026-11-04.md](TODO-through-2026-11-04.md) | The former `docs/TODO.md` ledger | 2026-10-29 to 2026-11-04 |
|
||||
| [PRODUCTION-READINESS-through-2026-11-04.md](PRODUCTION-READINESS-through-2026-11-04.md) | The former release-gate ledger, including per-gate evidence tables | 2026-10-29 to 2026-11-04 |
|
||||
| [ECHO-SESSION-STATE-through-2026-11-04.md](ECHO-SESSION-STATE-through-2026-11-04.md) | The former `examples/echo/docs/SESSION-STATE.md` | 2026-11-01 |
|
||||
| [ECHO-TODO-through-2026-11-04.md](ECHO-TODO-through-2026-11-04.md) | The former `examples/echo/docs/TODO.md` | 2026-11-01 to 2026-11-02 |
|
||||
|
||||
## Reading notes
|
||||
|
||||
- Entries are dated and were true when written. Where an entry says an item is pending, check the current documents before relying on it.
|
||||
- Gitea (`git.kof22.com/QRun-IO`) is the primary forge. Pull request and repository links to GitHub in these files are historical shadow references.
|
||||
- The content was moved without rewording; only a banner and relative links were adjusted. It contains workstation-local evidence locations and earlier version coordinates that do not apply to a fresh checkout.
|
||||
- The dated hand-off and review documents under `docs/` and `examples/echo/` (for example `CLAUDE-CI-HANDOFF.md`, `CLAUDE-SCANNER-AUTH-HANDOFF.md`, `ECHO-RELEASE-REVIEW-2026-11-01.md`, `ADAPTERS-HANDOFF.md`, `DOCUMENT-CHOICE-HANDOFF.md`, `examples/echo/demo/CLAUDE-HANDOFF.md`) stay in place with their original dates. Treat them as historical unless a current document links to them.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Rich record preview — November 5
|
||||
|
||||
Before this update, the maintained demo ran the earlier 198-test public RC.21/Printing 1.0 cohort. The first TC22 records/photo preview was installed in place with SHA-256 `79E6CD0559419E059EC253DD0364C13BDDB3C27F566F0DA9A478FBB70D99A038`; the user then clarified that the existing app and existing server should be used, without a separate manual QA application.
|
||||
|
||||
The rich-record update added demo-only synthetic tables, ordinary metadata sections/associations, read-only permissions and explicit attachment grants. Native grouping and child queries use the versioned semantic API. Verification caught missing section tiers in metadata serialization and missing Origin headers in the composed-server test helper; both were corrected without weakening server guards. The final gate passed 189 tests and Android unit/lint/build checks. Isolated MC9400 native acceptance passed before adoption.
|
||||
|
||||
The maintained database and objects were backed up; the server restarted with the new application JARs and the same owner dependency cohort. Scanner-account reads verified all fixture counts and layout metadata. The existing TC22 app was updated with matching signer and installed bytes verified; its data and connection remained intact. Current details are in [session state](../SESSION-STATE.md).
|
||||
@@ -0,0 +1,275 @@
|
||||
> Historical record, superseded by [current status](../SESSION-STATE.md). Do not treat as current.
|
||||
|
||||
## November 4 — current checkpoint
|
||||
|
||||
This section supersedes the historical checkpoints below. The operator reports that the manual tests all passed. Release packaging and publication remain incomplete.
|
||||
|
||||
- **Scanner publication complete:** PR 12 merged normally as `286e2e0`; run 3645 succeeded. Anonymous Central readback verified the JAR, POM, sources and Javadocs for `com.kingsrook.qbits:qbit-mobile-scanners:0.1.0-20261103.233448-3645`. All 37 published Java source files match the reviewed source.
|
||||
- **Android publication complete:** PRs 10 and 8 merged into `main` at `0ae2101`. Main lifecycle 3634 succeeded; the shared CI owner's independent registry readback matched all ten primary files, including both AARs, at timestamp `0.1.1-20261103.232145-3634`. This publishes development libraries, not a signed production APK.
|
||||
- **Local runtime updated:** the server on port 57832 now uses public QQQ `4.1.0-RC.21` and released Printing `1.0.0`. The existing database and settings were retained. A fresh identity and health check confirms API `1.10.0`, mobile extension `1.5.0` and printing extension `2.3.0`. The normal TC22 remains signed in, shows Echo setup with Copies 1 and Connected; the existing Print Agent shows Connected and ready against this server. No physical print was submitted during this update.
|
||||
- **Exact updated cohort qualified:** 198 tests passed with no failures, errors or skips and Checkstyle passed. All 94 scanner Java inputs match reviewed source; all five QQQ backend dependencies resolve to public RC.21. Released Printing removes the three private QQQ transitive dependencies from the prior gate. Other document/files components still use qualified local owner artifacts.
|
||||
- **Echo Printing default implemented and reviewed:** both Echo POMs now select released Printing `1.0.0`; the dependency comment and READMEs match. Both modules pass Maven validation and Checkstyle. The version pin is the only semantic POM change, and all 94 Java files match the prior 198-test qualification. Remaining owner package versions are unchanged; normal source review/publication is next.
|
||||
- **Remaining source/publication work:** commit, review and publish the two Echo POM defaults for Printing `1.0.0` (prepared above), and obtain the document owner's coherent standalone Generation/bridges/Record Files/Scripting artifacts, including copies. The owner's website image pipeline does not publish these Maven artifacts. The owner verified that the local standalone directories are not Git repositories and have no release workflows; reviewed code lives in host candidate `b6fb238`, using private coordinates. The remaining work is source extraction with provenance, public dependency pins and standard standalone release wiring. No new domain feature is required for this publication step. Claude access is restored, and the dependency owner's actual Claude standalone packaging is active as of November 4; no claim of completion is made.
|
||||
- **Manual acceptance passed, operator reported:** on November 4 the user stated, "i have ran the manual tests - it all works". Accept this as completion of the outstanding manual acceptance group and do not request the same checks again. See [TC22 acceptance](../TC22-ACCEPTANCE.md). This is the operator's report, not a newly instrumented run or separate per-case evidence.
|
||||
- **Remaining scope/distribution decisions:** Echo's documented scanner profile still explicitly refuses unsupported GS1/ECI/binary variants; the manual pass does not add encoder support. Production Android signing and distribution remain deferred by the user.
|
||||
|
||||
Evidence: local `qqq-scanner-published-3645/anonymous-readback.json`, `qrun-android-3634-readback/registry-readback-proof.json`, `qqq-printing100-echo-gate-20261103/verified-summary.json`, and the runtime pointer `qqq-public-core-printing-runtime-current.txt` under the user's cache directory.
|
||||
|
||||
## November 3 — historical checkpoint
|
||||
|
||||
- **Server source:** `45f9cbddb14999d28add02dd9a37e4ce018e282e` is signed and pushed on Gitea PR 7 (earlier "uncommitted" statements below are historical). The five `qqq.version` POM pins to public **QQQ 4.1.0-RC.21** (root, demo, Echo, Echo demo, record-files) and the record-files POM test fix below are included in this verified dependency update.
|
||||
- **Final RC.21 gate: PASS** (`C:/Users/james/.cache/qqq-rc21-copies-gate-076d059d8d0641d9a85874ec64008def/verified-summary.json`). 377 unique tests with zero failures, errors or skips and Checkstyle PASS: Generation 179 and Printing bridge 16 (`verify-recovery.log`); scanner 53, demo 8, mobile Record Files 18, Echo 68 and Echo demo 35 (`verify-consumers-junit-fixed.log`). Plus 15 real admin-browser E2E cases on public RC.21 (single, checked and query selection, first-N approve, revoke, end session, display name/description, location/online, screenshot widget). The three earlier MySQL network timeouts did not recur in the focused 31 or the full 179 runs; the original failure reports are retained.
|
||||
- **Owned source fix:** `integrations/record-files/pom.xml` drops the aggregate Jupiter 5.11.4 and uses the BOM-managed engine and params; the XML test classpath shows API, engine, params and platform all at 6.0.2.
|
||||
- **Dependency honesty:** the five owned scanner consumer modules resolve only public QQQ artifacts (core, rdbms, middleware, filesystem, openapi RC.21; frontend-next RC.35), and Generation's core is RC.21 too, so all 182 scanner-consumer tests use public QQQ modules. The 16 Printing-bridge owner tests still resolve three private `pu507` QQQ libraries (filesystem, Javalin, OpenAPI) transitively through the existing owner Printing POM, and the other owner qbits remain qualified private inputs. So: **377 pass with public core RC.21 plus qualified owner inputs; this is not a fully public or released cohort.** No normal-runtime core upgrade was performed.
|
||||
- **Generation copies:** canonical Git source is now `Website-Backend/third-party-src/qbit-document-generation`. Signed `aac68907e67906aeef4e0cd687d28bc98b738f8c` is pushed on `feature/echo-generation-copies`, based on the actual PR 343 head `8f473fa`. The isolated clone `C:/Users/james/.cache/qqq-generation-copies-git-20261103` has 13 paths (including the PostgreSQL migration and provenance); the 21/21 module manifest matches. Qualified: 179 Generation + 16 bridge tests (prior cohort) and a fresh real PostgreSQL 17 migration pass. Owner integration and public artifact publication are not acknowledged and remain pending. No peer active checkout was edited.
|
||||
- **Android:** `f72b90d08cf29d2785cef1d5c5e7eeaf75f9afd2` is signed and pushed to the existing AGit PR 10 head (read back independently over SSH). Gitea CI 3480 **succeeded** on it (unit-test, device-test, package, secrets, gate; root read the UI); root also verified a clean git merge-tree of f72b90d with actual `main` b75fdac and merged nothing. Pull requests #8 and #10 remain open; nothing is released. The updated normal APK (SHA-256 `4AAB6951D3B0ABDDD4F5A8DC6790DCD84233EE08179B68DB108BF77909AB850E`) is installed with the same signer and no data clear; Connected, Apps, Settings, portrait, location on and the system-wallpaper brand-only preview were verified. No wallpaper or profile was applied. Fresh: 222 JVM tests, lint 0 errors/0 warnings (2 info), APK builds, 23 Node tests, 255-file standards pass. A fresh disposable TC22 Echo ten-stage run passed 109/109 semantic requests (simulated inputs and printing).
|
||||
- **Authentik:** a fresh real desktop acceptance passed against the same isolated TLS fixture (nonce, A login, logout with old-cookie replay rejected, fresh B without clearing cookies, logout). The opt-in native harness is integrated and compiled and its 8 launcher guards pass. Native TC22 execution still needs the temporary CA and PIN interaction (pending); no trust bypass. The normal server on 57832 is unchanged and ready.
|
||||
- **Still open (not claimed):** Generation canonical owner integration and public reproducible inputs for the owner qbits; primary source CI, merge and release; the manual TC22 trigger, value/type, AIM, label count, rescan, photo, GPS, projection and profile checks (the operator is still remote); native Authentik CA, PIN and callback; and signing/distribution, deliberately last by user decision. The normal TC22 currently shows Echo setup, Copies 1, Connected.
|
||||
|
||||
## November 3 — publication preparation (historical; superseded by the current checkpoint above)
|
||||
|
||||
The scanner/Echo/demo sources for copies per scan, AIM/EAN/UPC fidelity and photo composition are in the working tree of `feature/echo-example`, ready for one signed commit and review (not yet committed). They declare no private artifact coordinates: Echo's POM states that copies need a Generation build with the print-copies change, and `integrations/record-files` now takes `qqq.version`, `mobile.version`, `recordfiles.version` and `collaboration.version` with the demo's defaults. The Generation copies change is ten hash-checked files; its eight existing files match the Kof22 release reactor's `third-party-src/qbit-document-generation` byte for byte (SHA-256 listed in `qqq-finish-server-ready-20261103.md`). No Git repository for Generation exists in these local directories: adoption belongs to the Kof22 release repository, so the change is a hash-verified file set, not a commit here. Nothing was built or pushed in this step.
|
||||
|
||||
## November 2 — Echo copies adopted in the normal local server
|
||||
|
||||
Optional **Copies per scan** defaults to 1 and accepts 1–100. Omitted/null/blank setup uses 1. The run pins the quantity; scan payloads cannot change it. Each scan creates one retained job with options.copies, with retries preserving quantity and job identity. Existing records migrate to 1. The normal server on port 57832 now runs this feature, mobile-scanners 1.5 and mobile-record-files 1.0 using the original database, approval, accounts and printer configuration. The unchanged normal TC22 APK is connected and shows the setup field with 1.
|
||||
|
||||
Verified gates: Generation 179, Printing bridge 16, scanner 53, base demo 8, mobile Record Files 18, Echo 68 and composed demo 35 tests (377 unique tests across module runs, no failures/errors/skips). Native TC22 acceptance passed ten stages and 116/116 semantic requests, including default 1, range rejection, chosen 3 across two scans, recreation and Leave. Scanner inputs/printing are simulated; physical count remains unconfirmed. Android source also passes 222 JVM tests, lint and APK builds; 15 Node tests pass.
|
||||
|
||||
Generation's ten-file copies contract is verified in C:/Users/james/.cache/qqq-generation-copies-20261102 with handoff qqq-generation-copies-handoff.md; canonical owner adoption/publication is still pending. Echo photo composition is running and its authorization tests pass; actual capture/upload is unqualified. Native Authentik and physical trigger/print/rescan, camera/GPS/projection checks are deferred while the operator is remote. No certificate installation is confirmed. Public coherent artifacts/CI and final signing/distribution remain open. Earlier checkpoints below are historical.
|
||||
|
||||
## November 2 — current checkpoint
|
||||
|
||||
Source candidate: mobile-scanners 1.5 adds optional `aimIdentifier` and exact SDK `rawDataBase64` to persisted scans and backend `scanContext`. Changed metadata on an existing event is rejected; identical retries retain the original row, and omitted metadata cannot leak from the preceding event. The generated semantic contract remains compatible with the unchanged 1.0, 1.1 and newly preserved 1.4 baselines. The isolated four-module reactor passes 135 tests (53 scanner, 8 native HTTP demo, 45 Echo, 29 composed demo), zero failures/errors/skips and Checkstyle clean. Independent Claude diff review found no actionable correctness, security or compatibility defect; a whitespace-only review correction followed verification. This candidate is not installed in the running demo; hardware AIM extraction and extended Echo fidelity remain open.
|
||||
|
||||
The first actual Echo native HTTP event exposed a composition defect: Generation requested its default 20 MiB allowance from a renderer capped at 1 MiB. Both demo launchers now propagate their configured cap into Generation. Restoring the old wiring produces three regression failures; the fixed library/demo reactor passes all 74 tests (45 + 29), with no failures, errors or skips and Checkstyle clean. The preserving local server restart uses these tested artifacts. A new, clearly synthetic `ECHO-HTTP-TEST-001` Code128 event completed real rendering, retention and printing: one job reached `SPOOLED` with a spooler job assigned, and native Cancel closed the process. The earlier failed event remains `ATTENTION` without a print job and was also cancellable. Neither event is a physical TC22 scan. Paper output and rescan confirmation remain pending; see [delivery receipt](../../examples/echo/demo/docs/echo-delivery-acceptance.json).
|
||||
|
||||
The real trusted-local renderer has produced Code128 `QQQ-TEST-123456` and QR `001234567890` from the actual published 4x1 revision. Independent ZXing pixel decoding and stock/margin/fit checks pass; a fresh native read confirms the same published revision and stock. See [rendering evidence and test images](../../examples/echo/demo/docs/ECHO-RENDERER-ACCEPTANCE.md). That earlier rendering-only check submitted no printer job; the subsequent synthetic delivery is recorded above. Physical acceptance remains open.
|
||||
|
||||
The primary CI secret-scan failure was reproduced locally as five findings in inert test-token literals. The fixtures now generate tokens at runtime; `.gitleaksignore` names only the five exact historical commit/file/rule/line fingerprints. There are no rule or path exclusions and no production validation changes. Historical and staged Gitleaks scans pass, and all 27 composed demo tests pass with zero failures, errors or skips and zero Checkstyle violations. Current hosted CI still needs verification after publication.
|
||||
|
||||
Completed and verified:
|
||||
|
||||
- Server source `e3b775e` is signed and published as Gitea PR 7. The Echo library and composed demo pass 74 tests with zero Checkstyle violations. Host forwarding accepts `localhost` and `127.0.0.1` with a valid explicit port, and foreign Host and Origin values are denied.
|
||||
- One local QQQ demo server on host port 57832 runs Mobile Scanners/Echo, Collaboration/Record Files, Document Generation and Printing (API 1.10.0, mobile 1.4.0, printing 2.3.0). On this workstation, device port 8080 forwards to host port 57832. The original accounts, enrollment and approval are retained.
|
||||
- Android `fb3cc5d` (Gitea PR 10) adds the native same-origin fix. The normal TC22 was updated without clearing data, and existing login and approval, heartbeat, real printer, media and document selection, and Cancel then Leave were verified. The emulator is off because it stalls this workstation.
|
||||
- The installed print client (RC4) is signed in, enrolled and connected to this server, and its native-approved ZPL printer is available. The Printing owner has aligned the loaded 4x1 stock (101600 x 25400 microns at 300 dpi) and updated the real media revision. No UAC prompt or printer setup is pending.
|
||||
- A real Echo 4x1 barcode document is published through native authoring, with a matching immutable revision 1. The TC22 selected the actual printer, the fresh 4x1 media and that document. Next succeeded and the device shows Scanner ready and Connected.
|
||||
|
||||
Not yet done:
|
||||
|
||||
- No physical Echo print or rescan has been proven. The synthetic event has exactly one matching scan and one actual ZPL job in `SPOOLED`, rechecked at 16:31 UTC. The same read-only check confirmed server HTTP 200, print service Running, and the normal TC22 showing Echo / Scanner ready / Connected. One physical scan of QQQ-TEST-123456 has been requested from the user; no UAC or setup action is pending.
|
||||
- The earlier native end-to-end run (7 stages, 112 semantic requests) used simulated printing and inputs. It is not physical evidence.
|
||||
- Still open for 1.0: physical Code128 and leading-zero QR scan, print and rescan; the full original provider, camera, GPS, projection and device qualification; public coherent artifacts; primary Gitea CI; and final signing and distribution. Signing is deliberately last, by the user's decision. A working first demo does not collapse these requirements, and nothing here claims release readiness.
|
||||
- Core native API merge continuation PR 1337 (a42f9e5) was reviewed with no actionable conflict regression, and its 57 focused tests were verified. Normal CI and publication are not established, and default dependencies are not repinned.
|
||||
|
||||
The entries below are historical. Where they describe same-origin login, printer connection, media, document publication or server composition as pending, this checkpoint supersedes them.
|
||||
|
||||
## November 2 — one local QQQ server and installed print client connected
|
||||
|
||||
The canonical local server on57832 now runs Mobile Scanners/Echo, Collaboration/Record Files, Document Generation and Printing together, using the existing development database. Both original accounts authenticated. Live discovery advertises API1.10/mobile1.4/printing2.3; authenticated administration metadata exposes the composed modules. This is greenfield local development: no backup, cutover or cross-server bridge is required.
|
||||
|
||||
The full demo gate passes27 tests with zero failures/errors/skips and zero Checkstyle violations. It includes file-backed existing-data acceptance and the USB-forwarded loopback Host regression. Actual running HTTP checks accept Host127.0.0.1:8080 through57832 and reject foreign hosts/origins. The TC22 retains its original app data, approved device and8080 USB connection; no emulator runs.
|
||||
|
||||
The existing RC4 print client is now natively signed in, enrolled and connected to57832. It reports five printers/two exposed. The sole available ZPL printer was approved through the ordinary printingEnablePrinter process; the persisted native query confirms enabled/available. No physical job was sent and printer defaults were unchanged.
|
||||
|
||||
(Superseded by the current checkpoint above: the same-origin header, 4x1 media report and document publication are done. Only the physical TC22 scan, print and rescan remains for the demo, and the full production release gates remain open. Earlier separate 53118 pairing and Windows-prompt status below are historical.)
|
||||
|
||||
## November 1 — corrected setup verified on TC22 and published
|
||||
|
||||
Server source8da274e and Android test-only source47a923a are signed/pushed; Android's actual Gitea PR10 review topic was advanced to47a923a via SSH AGit (the source feature branch alone does not advance an AGit review ref). Required printer/media/INTEGER Document selection, dependency clearing, two accepted synthetic scans with the same selections, recreation and Apps/Leave all passed on real TC22:7 stages,112/112 published semantic requests,15 operations. Normal app/data preserved; emulator off; printing/scanner qualification flags remain false. Server45/library24/demo and Android99focused/host tests+8Nodeguards/lint0errors0warnings pass. Live private server53118 is still101efc8/EchoRetry pending document-choice rollout, and actual printer pairing awaits the current Windows consent in existing session73437. No root physical print has occurred. Normal57832 stays unchanged.
|
||||
## November 1 — operator chooses the Document at setup (server verified)
|
||||
|
||||
User-confirmed flow: choose printer, Document template and paper/label size once, then repeat scan -> server render -> accepted print job until Leave. The Android client remains a generic semantic QQQ process client; it has no printer-device integration. `templateId` is required INTEGER with an authorized dependent PVS, not a hidden server default. The run pins selected template/revision; changed documents are refused rather than substituted. Migration preserves existing rows. Root:45 Echo library/24 full composed demo tests PASS, Checkstyle0. Tests include two document choices, forged retarget refusal, republish refusal with native Leave/CLOSED and repeated scan rendering. Expected errors use existing output.setException to retain process ownership after core step failure. Native Android tests99 + Node8 + lint0errors0warnings/testAPK compile pass; updated TC22 UI acceptance remains in progress. No physical print claim. Private live53118 remains filtered source101efc8/EchoRetry until this rollout, and printer retry awaits Windows consent; normal57832/TC22data are preserved.
|
||||
## November 1 — shared device headers scoped per extension registry (verified)
|
||||
|
||||
`MobileScannersRouteProvider` interpreted every `X-QQQ-Device-*` header as a scanner credential, so a native Printing agent's own proof got 403 on `/qqq/v1/qbit/printing/*`. It now leaves a request to another extension's own authenticator only when the path is another extension's `/qqq/v1/qbit/*` route (not scanner routes, metadata, process or widget calls) and QQQ already validated a registered, non-`android`, non-`web`, non-token client channel. Android and unmarked requests keep scanner verification everywhere, ended-session and approval checks are unchanged, and no scanner device is bound for the foreign route, so the marker grants nothing. New composed demo regression `printerDeviceProofIsAuthorizedByPrintingAndNeverByScannerAuthentication` passed against the new immutable private scanner artifact. Root verification:50 scanner tests,7 base-demo HTTP tests and23 composed Echo-demo tests, Checkstyle0. The corrected regression fails403 at printer binding on the old artifact and passes on the fixed artifact; wrong proof/actor and scanner-boundary denials remain enforced. Live update and physical printing are separate.
|
||||
|
||||
## November 1 — independent review corrections verified
|
||||
|
||||
The scoped independent review of Echo4bc88a4/Androida2f0080 found a pre-pin recovery gap, an unchecked atomic pin result and a late-revocation receipt-audit gap. The fixes preserve authorization checks and lookup-only recovery after a pin. Five meaningful regressions now pass: Echo38 and full composed demo22, Checkstyle0. See ECHO-RELEASE-REVIEW-2026-11-01.md. The running private backend remains on the earlier qualified Echo artifact until an owned rollout; no running SDK or normal operator database was overwritten. Physical print/rescan and public release gates remain open.
|
||||
## November 1 — Echo composed pipeline verified and private backend live
|
||||
|
||||
Echo's 33 tests and the runnable demo's full22 tests pass with zero failures/errors/skips and Checkstyle0. The clean native HTTP acceptance uses the real Generation, Record Files and Printing owners: two scans create two retained originals and two accepted jobs; retry preserves one job, and Leave closes the run. Renderer output is controlled and printer inventory simulated in this test, so physical printing remains unqualified. The Echo source checker now accepts core's exact bare READ capability probe while preserving user/permission checks, actor row locks and concrete write denial. The demo enriches complete QQQ metadata, establishes attachment capability, and derives the printing tenant idempotently. Host tests reset JVM-local RDBMS providers between fixtures.
|
||||
|
||||
The local launcher uses the renderer owner's explicit TrustedLocalDockerExchange public factory (unique private renderer0.1.0-echo.gen64c9pu507.tl1); no copied transport or production fallback remains. Owner52 tests and real Docker smoke qualify development use only. A fresh private instance is live with successful scanner/admin semantic login and metadata; no printer or template is configured yet. Normal operator data/runtime remain preserved.
|
||||
|
||||
Next required path: native Windows printing connection/current4x1stock handoff, publish and pin a real media-matched template, actual TC22 scan/print/rescan, and primary Gitea CI/public dependency publication. Full1.0 physical provider/GPS/photo/screen gates and broader AIM/EAN/UPC/GS1/raw barcode fidelity remain open. Production signing/distribution is deferred as requested. Passing private SDK/source gates is not a production release claim.
|
||||
## November 1 — coherent scanner and Record Files regression verified
|
||||
|
||||
Security-key snapshots compare effective membership as sets, allowing identical repeated grants and reordering while rejecting new or changed authority. Optional Record Files authentication applies the owner's CollaborationSecurity.applyToSession during login/revalidation before the first verified capture. Scanner50 and native Record Files18 (16 integration +2 contract) pass with zero failures/errors/skips and Checkstyle0 against the complete private gen64c9pu507/PU dependency cohort. This supersedes the prior pending/failed native18 checkpoint; it does not declare private dependencies published or production-qualified.
|
||||
|
||||
Echo32 and runnable demo12 also pass on that cohort. The demo startup test uses a refusing renderer; actual live cold renderer configuration and physical print/rescan remain pending. Android169 JVM and five-stage TC22 native semantic Echo acceptance passed with simulated printing. Gitea is primary for further source publication; previous GitHub PR links are historical shadow references. Preserve the normal scanner/server/data and printer installation. Signing/distribution remains deferred.
|
||||
## November 1 — verified capture identity retained without authority drift
|
||||
|
||||
Claude's scanner fix preserves the same already-verified session only after current approval/binding/authentication and unchanged session reference/authority checks. First capture still wraps. Tests cover repeated object identity, current permission/credential ceilings/selectors, device and user binding, revocation, and real post-query-hook mutations during validation. Mutable opaque authority values refuse retention. Full scanner49 and real demoHTTP7 cases pass with Checkstyle0 against the frozen privatePU/f708 core.
|
||||
|
||||
The files owner still must qualify its strict native upload/finalize18 against this exact patch; no per-instance enrichment policy or global registry is adopted. Normal server/data remain unchanged. Echo native TC22 now passes all5stages/86documentedrequests with client3bec46d; real renderer/printer output and final host integration remain open. Production signing is deferred.
|
||||
# QQQ Mobile Scanners session state
|
||||
|
||||
## November 1 typed scan context implementation
|
||||
|
||||
Extension 1.3 defines accepted `scanContext` for backend actions, preserving exact symbology and server-derived attribution. Legacy omissions are explicit UNKNOWN/MANUAL; event inputs are consumed, changed retry types/apps/processes are rejected, and pre-context state requires reopening the process. The default scanner loop remains while appended host steps can consume context. Qualified private API 1.10 verification passes 44 root, seven real-HTTP demo and 18 optional integration tests with normal Checkstyle; canonical contract generation passes two additional tests and preserves both release baselines. The source cohort is `qqq-server-typed-context-843fa0ee69b34535b1890cf7347492ee`; no normal runtime adoption is implied.
|
||||
|
||||
## Echo implementation scope
|
||||
|
||||
User requested example QQQ application Echo: first select printer and paper/label size, then continuously scan → render the same value/symbology → print until leaving. User chose one in-flight job and delegated first real printer/media choice to the printing master. Team implements the optional examples/echo host, ordinary semantic QQQ process metadata and server per-scan context; no new Android screen engine, barcode encoder, attachment framework or print queue.
|
||||
|
||||
Owner coordination requests are posted in our shared mailbox. Source discovery found missing authorized media discovery/job options, worker barcode fit-to-stock, least-privilege printing submission and an immutable retained-print adapter handoff. These are concrete integration gates, not permission to change the other masters' source or live printer/service. Implementation and isolated tests can proceed while owners respond; no physical print or final working Echo claim yet.
|
||||
|
||||
## November 1 standards implementation and API 1.10 qualification
|
||||
|
||||
Copyright/SPDX, member layout and optional PowerShell tooling fixes are applied. Unexpected server failures now produce fixed structured diagnostics without private values. The attachment test fixture closes its metadata-before-stream ownership gap. Fresh Checkstyle/compilation, 39 root tests and six real HTTP tests pass against the qualified private API1.10 cohort; optional integration verification is tracked in the standards review. These are source gates, not a normal-server deployment.
|
||||
|
||||
Core native API1.10 is signed and published as e1e45b29f in draft PR1369. Its real Authentik fixture is upgraded and actual retained-browser account switching is under test. Android1127 is published; the installed normal APK is the diagnostics/location3fee build, preserving sign-in and data. Prior installed1c Code128, leading-zero QR and menu/reopen were physically confirmed. Default-on location, existing fine/coarse grants and system location are verified; an actual GPS fix remains open. Historical entries below retain their original scope. See [standards review](../CODING-STANDARDS-REVIEW-2026-11-01.md).
|
||||
|
||||
|
||||
## November 1 provider logout implementation in progress
|
||||
|
||||
Core32 passes all ten hosted checks, including corrected1000-test sample acceptance; Android8d8 passes all eleven checks. The final coherent core32/scanner1a3 cohort passes39 scanner/six demo/18 Record Files/15 browser cases and is recorded in the published qualification document.
|
||||
|
||||
Real Authentik now passes a desktop-browser PKCE callback through the native-session exchange contract, principal A, QQQ logout and old-cookie rejection. This does not establish Android external-browser acceptance. A subsequent login with provider SSO retained silently reused A despite prompt=login. The pinned provider also ignores max_age=0. This is an open operator-handoff issue, not a passed two-operator test. Optional standard RP-initiated logout is being implemented in core branch feature/mobile-native-provider-logout (based on core32) and the Android client, with explicit API1.10 capability, encrypted session-bound hint storage, local logout independent of provider failures, and a separately correlated AppAuth end-session attempt. The user policy choice (automatic company sign-out versus retained SSO with account confirmation/Switch account) remains pending; default sign-out behavior has not changed.
|
||||
|
||||
The optional Android provider-logout working patch passes148 JVM tests, lint and debug build; native cases compile but have not run. The first isolated core package failed at an UpdateOutput API mismatch before tests. Independent review also found the optional hint gate must reject alternate in-process/code-state authentication sources; corrections and an HTTP regression are in progress. Initial failed candidate evidence is preserved.
|
||||
|
||||
The isolated localhost-CA E2E APK built from signed Android8d8 passes63 unit cases/zero lint errors and packaged CA/callback/signature inspection. It is not installed; no device trust changed. Normal TC22 remains portrait C891 and its API1.9/mobile1.1 server remains untouched. Current QR, camera/gallery, projection, GPS and failure/navigation operator acceptance remain open. Files-owner immutable handoff/default adoption and final signing/distribution remain separate.
|
||||
|
||||
Updated: 2026-11-01
|
||||
|
||||
## November 1 release reconciliation
|
||||
|
||||
Published server1041693 retains its qualified source-pinned API1.9/mobile1.2 server/demo/browser/provenance evidence. Android portrait-default7de passes all eleven hosted checks; QR documentation2e0fd962 does not establish physical QR acceptance. The normal runtime remains coreecf/serverb14 API1.9/mobile1.1 on57832, with normal portrait-default C891 development APK installed preserving signer/data. Trusted EMDK Code128 evidence comprises eight priorF3AF events and oneC891 event; QR, lifecycle/ownership and failure/retry/leave operator paths remain pending. Whole-device1.2 is qualified source, not installed normal-server behavior.
|
||||
|
||||
Filesystem portability passes142 discovered/138 passed/four existing skips, including the corrected invalid-reference exception contract; its metadata symlink-parent and NUL-reference findings are independently closed. The final coherent core32/scanner1a3 cohort passes78 consumer cases, including18 Record Files cases, with no separate filesystem override. Matching files-owner immutable/default adoption remains open. Actual Authentik flow remains pending; strict proxy TLS now works, while provider configuration returned a validation error under diagnosis; physical projection, camera/gallery and GPS acceptance remain open. Signing/distribution stays owner-deferred until the final step. [PRODUCTION-READINESS.md](../PRODUCTION-READINESS.md) is the current matrix; older entries below retain historical scope and are superseded where their then-open state has changed.
|
||||
|
||||
Cross-chat coordination uses `R:\agent-comms\README.md` and the mobile master's owned `R:\agent-comms\qqq-mobile-scanner.md`, explicitly requested by the user. Record Files and Printing acknowledged the directory. Read the current summary at the top of each owner's mailbox before changing shared contracts.
|
||||
|
||||
## November 1 Record Files consumer checkpoint
|
||||
|
||||
The isolated core32/scanner1a3 cohort passes all 78 consumer cases (39 scanner,
|
||||
six demo HTTP, 18 Record Files and 15 real-browser process tests), with coherent
|
||||
backend versions, 214 optional runtime JARs and zero duplicate QQQ/QBit classes.
|
||||
Core verification combines the prior full reactor gate, the corrected filesystem
|
||||
full gate and an exhaustive final artifact class comparison; see the precise
|
||||
[qualification scope](../../integrations/record-files/docs/CORE-32DF-CONSUMER-QUALIFICATION.md).
|
||||
The files-owner matching immutable handoff, optional default adoption and actual
|
||||
camera/gallery acceptance remain open. This cohort did not change the normal
|
||||
runtime, device or historical optional default.
|
||||
|
||||
## October 31 current checkpoint
|
||||
|
||||
The normal local demo is now running the qualified `ecf0f550`/`b14d925` API1.9/mobile1.1 TABLE runtime at its existing loopback address. This supersedes the unchanged-runtime statements in the historical acceptance entries below. Read-only inspection froze its actual old classpath; old-runtime and old-after-new HTTP/login checks passed on historical and fresh closed backups before startup. Fresh copied invariants preserved every original column, row count, password/device-credential hash, barcode and UTC timestamp. Both original accounts passed live login, authenticated metadata and logout; ordinary semantic queries confirmed two devices and three scans, including the leading-zero barcode. The normal Android installation and peer services were preserved. Private backup/runtime evidence is retained in the task cache; old PID-specific maintenance helpers must never target the replacement JVM.
|
||||
|
||||
Optional consent-based whole-device viewing is under implementation as an additive extension1.2 contract; it is not part of that running, qualified1.1 runtime. Android diagnostic checkpoint `6f25562` is published to its existing PR after instrumentation compilation; fresh hosted results remain pending. The actual trusted-scanner trigger attempt is unverified. Production signing/distribution remains the final owner decision.
|
||||
|
||||
The initial1.2 reactor now passes39 server and six real-HTTP demo tests with normal Checkstyle, no failures/errors/skips, unchanged qualified-core hashes and unchanged1.0/1.1 baselines. Independent changed-file source review found no concrete introduced security finding; Android consent and projection acceptance remain open. The diagnostic Android checkpoint subsequently passed CircleCI37 native tests and unit tests; its earlier intermittent focus-owner cause remains unproven.
|
||||
|
||||
Exact-source core `ecf0f550a0357cec6387c2b54efca128a405a629` with server `b14d925b741e815d1cfa4f62301f19207f25b03f` now passes fresh37 server/five demo tests, strict runtime provenance and all15 browser cases (zero skips/flaky/failures). The runtime has195 hashed JARs,2,440 unique QQQ classes, zero duplicate QQQ classes, patched Guava33.7.2-jre and H2 2.2.224. Core's full selected closure separately passes4,507 discovered/16 existing skips/zero failures/errors. See [qualification scope](../API19-CONSUMER-ACCEPTANCE.md). No operator runtime was upgraded by this acceptance.
|
||||
|
||||
The demo now imports the QQQ BOM using its own `qqq.version`, so an explicit version selection governs its complete transitive QQQ runtime. Private source qualification caught the prior mismatch: the scanner's installed POM retained its default RC.20 property, while the demo override selected a newer direct RDBMS dependency. The strict runtime verifier rejected the mixed core/Javalin/OpenAPI versions and older Guava despite passing tests. The first BOM-aligned run then exposed the demo's explicit JUnit 5.11.4 aggregate mixing an older API with the BOM's JUnit 6 engine; the demo now uses the BOM-managed test engine and its matching API/platform. The coherent candidate's fresh acceptance is recorded above; passing tests from the earlier mixed runtime did not establish that result. No operator runtime or shared Maven repository was changed.
|
||||
|
||||
The normal operator demo was upgraded in place to mobile extension1.1 with a closed owner-only backup: two accounts, two devices (one approved), three scans, credential hashes and timestamp instants were preserved. Both original passwords authenticated after startup. Its JVM currently uses UTC because the copied upgrade exposed H2 converting legacy zoned timestamps using the workstation zone. The canonical H2 schema now begins by setting its migration connection to UTC, protecting both direct schema consumers and the demo initializer. A no-install reactor verifies the freshly packaged base resource: 37 base tests and five demo tests pass, with zero Checkstyle violations, including a file-backed America/Chicago child-JVM regression over all eleven converted timestamp columns, multiple source offsets, preserved passwords/approval/barcodes and an idempotent second startup. The isolated old-schema resource without the UTC statement fails with the expected six-hour winter timestamp shift; the tested jar resource hash matches the canonical source. This source change does not restart the operator runtime; it remains safely mitigated with UTC.
|
||||
|
||||
The optional Record Files consumer now explicitly defaults to frozen0515 and its separate isolated-m2-0515 cache. Fresh normalized-heartbeat base37 and optional18 pass, with214 runtime jars/3180 classes/zero duplicates. Immutable evidence is retained under integrations/record-files/evidence/20261031-124632Z. Earlier0352 native acceptance remains historical;0515 is a new server consumer qualification, not an operator upgrade. Published dependency coordinates and hosted optional CI remain blocked on the upstream handoff recorded in HOSTED-CI-DEPENDENCIES.md. New API1.9 native Authentik metadata is being developed in an isolated core worktree and is not part of this server's adopted dependencies.
|
||||
|
||||
Hosted Android acceptance exposed a heartbeat revocation defect after the published checkpoint: pending/revoked devices could keep receiving successful heartbeats and remain signed in. The fix checks approval under the existing activity lock before observing activity, and the route filter avoids pre-observation for canonical and accepted trailing-slash heartbeat paths. Both defects were independently reproduced before correction. Final root verification/install passes 37 tests and demo verification passes four tests, including the real HTTP no-side-effects regression and standalone presence case. Native hosted confirmation on the new server revision is still required; earlier static zero-findings results do not prove absence of this defect.
|
||||
|
||||
Server checkpoint `0926550` includes 37 passing root tests, two HTTP acceptance cases, a passing standalone file-backed presence regression and 15 passing browser cases. The optional Record Files module adopts frozen bundle `retained-contract-20261031-0352`; 18 guarded integration/contract tests pass. TC22 navigation (12 cases), orientation, offline licenses, synthetic photos and the complete screen-viewing/session-end scenario pass on Android checkpoint `d5b5580`. These do not imply an operator-runtime upgrade or actual camera-lens acceptance.
|
||||
|
||||
The full server deep static security scan of `0926550` completed and its canonical report was sealed on October 31 with no reportable findings. Coverage excludes external dependency implementations and production configuration; no new runtime tests were executed by the scan. The known external core DEBUG session logging issue, immutable Record Files publication/CI and production provider qualification remain open. Android's final full scan is still pending. A user-requested single-hamburger navigation correction is now being implemented after the published Android checkpoint; its new tests are not yet run.
|
||||
|
||||
The normal operator demo and normal TC22 package remain unchanged. A separate temporary visual demo showed the new menu on the TC22; the user stopped computer control and retains the device. Do not restart, uninstall or drive that demo until coordinated. Signing/distribution remains deferred to the final step.
|
||||
|
||||
The local emulator is closed after the user confirmed it caused workstation-wide stutters. TC22 is connected; do not restart the emulator automatically. Builds are serialized with bounded workers. Existing operator demo and handheld state remain preserved. Android85 JVM tests and zero-error/zero-warning lint pass at the last compiled checkpoint; newer changes must be verified again.
|
||||
|
||||
The active goal authorizes completing and publishing the project, superseding historical artifact-publication deferrals below. However, on October31 the owner explicitly deferred signing and distribution until the final step because the channel is undecided. Complete implementation, final audits, tests, documentation and ordinary hosted CI first. No signing key, production release, merge or deployment is claimed.
|
||||
|
||||
## Active: device sessions, live screen viewing and 1.0 audit
|
||||
|
||||
Additional user scope: unique short device names assigned at setup/approval, editable names/descriptions, independent approval/presence, online fleet view and foreground GPS with age/accuracy. Camera/gallery photos use the separately developed Record Files qbit. The Build QQQ Documentation agent owns it and `qbit-record-files/docs/INGESTION-CONTRACT.md` documents the implemented record-files1.0 semantic capabilities/create/status/chunk/finalize/cancel operations and durable receipts. The October31 checkpoint above supersedes earlier proposed-only contract status. Native photo acceptance and the final authorization guard remain pending; unavailable capabilities stay hidden.
|
||||
|
||||
The user requires all device/session history, live activity and actual application-screen viewing in QQQ administration, plus session termination. Implementation follows [DEVICE-SESSIONS.md](../DEVICE-SESSIONS.md): ordinary QQQ tables/widgets/processes, server-owned authenticated bindings, an additive 1.1 semantic extension and demand-based bounded screen frames. Native and browser actions require full E2E coverage, including record and bulk entry points.
|
||||
|
||||
The user also requires independent in-depth security, architecture, coding-standards and coupling/cohesion reviews of both complete repositories before a production-ready 1.0. [PRODUCTION-READINESS.md](../PRODUCTION-READINESS.md) tracks the gates and exact baseline evidence. Plan: complete the session feature and tests; freeze final revisions for independent audits; remediate and verify findings; complete physical and release/operations acceptance. Deployment/distribution details have been requested. No 1.0 readiness or release is claimed.
|
||||
|
||||
## Current: semantic API migration
|
||||
|
||||
The qbit registers mobile-scanners 1.0.0 through QQQ's extension registry. Device enrollment/current status, branding and connectivity use `/qqq/v1/qbit/mobile-scanners`; discovery advertises the relative prefix `/qbit/mobile-scanners`. The generated OpenAPI includes typed device and scanner event/acknowledgment schemas, stable operation IDs and standard errors. Legacy standalone mobile routes are removed. [UI-API.md](../UI-API.md) describes the contract and versioning policy.
|
||||
|
||||
Root `verify install` passed 23 tests, including the extension contract baseline checks; demo `verify` passed one real HTTP discovery/schema/error/lifecycle scenario. All six migrated browser E2E scenarios passed in 2.9 minutes. Native integration exposed an unregistered Android client channel returning HTTP400; the qbit now registers `android` without replacing existing channel policies. The real HTTP regression sends `X-QQQ-Client: android/qqq-mobile` on all requests and passes. Android requires `android` in discovery's `clientChannels`; its combined JVM suites passed 53 tests (core 21/mobileapp 23/scanner 9), with six shared native UI tests also passing. Final lint passed with zero errors and six warnings; debug and E2E APK builds passed. Five native scanner scenarios passed with the OpenAPI request audit: incompatible core version, missing extension, missing Android channel, baseline scan flow and additive future metadata. The final native administration form scenario passed in 255.917 seconds after correcting completion handling for a COMPLETE response without `nextStep`: approval, revocation and cancellation completed, with all 31 requests matched to 13 published operations. All six native scenarios passed across the original and focused reruns; this is not a claim of one uninterrupted six-case run. All hosted checks passed for published server commit `7dd02b8874882ca2edcdcd89109b3dee6b4b0c88`: CircleCI setup/verify, CodeQL, Windows helpers and the demo HTTP/browser E2E job.
|
||||
|
||||
Active plan: publish the companion Android checkpoint pinned to verified server code `7dd02b8`, then add the requested device/session activity, session termination and on-demand app-screen viewing. Physical connection-loss/retry and revoke-denial checks are deferred to acceptance with those session controls; the persistent server and USB forwarding are restored. The local server and emulator restarted after the workstation crash with data preserved; the backend administration UI was visibly signed in. The physical TC22 is identified through authorized USB debugging, running Android 14 and DataWedge 13.0.325. The debug app is installed and signed in. Approval from the browser record's Actions menu visibly reported one device approved; the handheld then opened the native app catalog and Scan Capture process. Updating the APK preserved its login. DataWedge is configured for package `io.qrun.mobile.scanner`, activity `*`, barcode input enabled, keystroke output disabled and Intent Output enabled. Intent delivery uses Broadcast, action `io.qrun.mobile.scanner.SCAN`, category `android.intent.category.DEFAULT`, and an explicit target package with signature checking enabled. Two deliberate physical trigger presses were confirmed by the user and produced two persisted records visible in Barcode Scans. A stored row matched the TC22 receipt for `QQQ-TEST-123456`, CODE128, source `scanner`, user `scanner`, TC22/Android 14/app 0.1.0, app `mobileScanApp`, process `mobileScanCapture`, and capture/receipt timestamps. Basic physical Code128 delivery and persistence passed. A subsequent physical QR scan was acknowledged on the handheld and independently visible in the backend as `001234567890`, `LABEL-TYPE-QRCODE`, source `scanner`, with matching TC22/user/app/process and capture/receipt metadata. Three physical records are verified: two deliberate Code128 captures and one QR capture. Physical QR delivery and leading-zero preservation passed; physical connection-loss/retry and revoke-denial checks remain pending. The old semantic-gap note below is superseded by this implementation status and the current TODO.
|
||||
|
||||
## Verified: record and bulk device administration
|
||||
|
||||
- User reported Approve Device did not work from a device record and required proper E2E coverage for every process, including single-record and bulk entry points.
|
||||
- Root and real HTTP regressions reproduced the missing selected device ID. Approval/revocation now prepare a stable selection, show confirmation, apply only after confirmation, and display a result/count. Explicit selected IDs and native select-all/first-N filters are supported up to 100 devices; oversized selections fail explicitly.
|
||||
- The process stores selected IDs in native process records, ignores replacement IDs submitted at confirmation, rechecks administrator permission, and validates all targets before changing any. Standalone manual ID entry remains supported.
|
||||
- Root verification/install passed 21 tests and Checkstyle. Demo verification passed the real HTTP scenario covering single-record approval, both bulk selection modes for approve/revoke, unselected-device isolation, visible results, scanner permission denial, scan persistence/retry, and revoked-device denial.
|
||||
- Restarted the persistent local demo on port 57832; public status returned `ok`. Accounts, database and emulator forwarding were preserved. Dedicated scanner-host native E2E passed (one complete real-server scenario, 50.932 seconds); Android verification also passed 28 JVM tests, lint/build and workflow syntax. Test APKs were removed and the normal scanner restored.
|
||||
- Browser E2E passed all six scenarios in 2.7 minutes: record approve/revoke with results and return navigation, checked-row bulk with untouched-device assertions, standalone manual entry, cancellation, full-query selection, and first-N selection with independent persisted-state checks. Tests use the packaged frontend and a temporary real backend. The fixture authenticates through the real API and supplies the nonsecret `qqq.sessionHint` marker so the UI resumes and validates its HttpOnly cookie. Browser checks now run inside the existing required demo CI job; native E2E runs in the Android push/PR emulator lane. New-head hosted results remain separate from these local passes.
|
||||
- UI/UX API clarification: the Android app uses `/qqq/v1` metadata/process operations, but full semantic-client conformance is unfinished. Version/feature negotiation, broader native component handling and migration of separate `/mobile/v1` routes into a registered UI API extension are tracked in TODO; do not describe the current client as fully conformant.
|
||||
|
||||
2026-10-30 correction: native authentication metadata is served at `/qqq/v1/metaData/authentication`. The device-header guard now exempts that exact public path; the previous legacy-only exemption caused HTTP403 on app restart after enrollment when device headers were present before a user session was restored. A real HTTP regression failed before the fix and passed afterward with stale, pending, invalid-token and revoked device headers. Protected metadata/process/scan authorization still fails appropriately. Root13 tests, Checkstyle and demo HTTP acceptance passed; the local demo was restarted on57832 with its persistent database and accounts preserved.
|
||||
|
||||
## Current repository and demo state
|
||||
|
||||
- GitHub repository created at https://github.com/QRun-IO/qbit-mobile-scanners with `develop` as default. Initial signed commit `abbcc39` is published. Companion Android implementation is in https://github.com/QRun-IO/qqq-android/pull/8.
|
||||
- Hosted initial-commit checks passed: CircleCI `setup`/`verify`, CodeQL `analyze / Analyze branch source`, demo HTTP acceptance and Windows helper analysis. CircleCI dynamic configuration is enabled. Maven artifact publication remains disabled.
|
||||
- Runtime branding is configurable on the server before login; the shared Android APK uses the customer name, primary color and optional HTTPS logo. Public branding/status endpoints carry no credentials. Root tests now pass 13 cases; demo HTTP acceptance also passes.
|
||||
- Windows demo helpers store generated demo passwords using per-user Windows DPAPI outside the repository. An Android 15/API35 emulator is running with WHPX; six native UI instrumentation tests passed. Physical TC22 acceptance is still pending.
|
||||
- The current workstation demo uses loopback port 57832 because other services occupy the conventional ports. `adb reverse tcp:8080 tcp:57832` preserves the Android server URL `http://127.0.0.1:8080/`. Start it again with `pwsh -File scripts/Start-Demo.ps1 -SkipBuild -Port 57832` after stopping the existing instance. See `LOCAL-DEMO.md` for login helpers.
|
||||
- The earlier verification/handoff notes below describe the initial implementation before repository publication and emulator setup.
|
||||
|
||||
## Confirmed scope
|
||||
|
||||
- Native Android host for Zebra TC22 and other handheld scanners, extending `qqq-android`.
|
||||
- Server-defined native screens and QQQ process actions, using existing QQQ metadata.
|
||||
- Online only for version one; no offline scan queue.
|
||||
- Local demo server with real QQQ login and required administrator device approval.
|
||||
- First app: barcode capture into a QQQ table with scan, user, device and timestamp metadata.
|
||||
- User authorized Codex agents for implementation and subsequently requested GitHub repositories and standard project files. Repository creation and pushes are authorized; artifact publication remains disabled.
|
||||
|
||||
## Completed plan
|
||||
|
||||
1. Established enrollment and scan contract using existing QQQ APIs.
|
||||
2. Implemented qbit tables, native metadata/processes, approval/revocation and guarded routes.
|
||||
3. Added Android scanner host, table-based login/enrollment gate and Zebra DataWedge input.
|
||||
4. Added a file-backed H2 demo and verified native login, approval, scan, retry and revocation over HTTP.
|
||||
5. Built the Android debug APK and documented TC22 hardware acceptance.
|
||||
6. Added standard repository/community files, current shared CircleCI QBit configuration, CodeQL, Dependabot, demo HTTP and Windows-script CI, and a documented publishing policy.
|
||||
|
||||
## Baseline and ownership
|
||||
|
||||
- QQQ baseline: 4.1.0-RC.21 (public Maven Central; adopted 2026-11-03, gate pending). The local 4.0.0 middleware does not support Basic sign-in through `manageSession`; RC.20 and later do.
|
||||
- Backend agent: qbit main source, unit/integration tests and root POM.
|
||||
- Android agent: `qqq-android` source and scanner application module.
|
||||
- Toolchain agent: Android SDK installed after explicit user approval of required Google terms.
|
||||
- Parent: local demo, cross-component verification and documentation.
|
||||
|
||||
## Verification
|
||||
|
||||
- Qbit: `./mvnw.cmd -q verify install` passed 11 tests (7 H2 behavioral/security tests and 4 inherited guardrails), with Checkstyle passing.
|
||||
- Demo: `../mvnw.cmd -B verify` passed Checkstyle and one real HTTP acceptance scenario. It verifies QQQ login, anonymous and pending-device denial, operator/admin separation, approval, async native process capture, same-event deduplication, SQL provenance, bad/missing device credentials, revocation while a process is open, logout and session replay rejection.
|
||||
- HTTP fixtures verify the actual native `composite`/`SCANNER` widget payload. The Android tests deserialize these real metadata/process/init responses.
|
||||
- Android: core/mobileapp/scanner JVM suites passed 19 tests, no failures or skips. `lintDebug` and `assembleDebug` passed. Lint has 0 errors and 5 warnings: 3 inherited Navigation custom checks skipped due to API incompatibility and 2 dependency/version notices.
|
||||
- Debug APK signature verified. Path: `../../qqq-android/scanner-host/build/outputs/apk/debug/scanner-host-debug.apk`.
|
||||
- APK SHA256: `B3E160CE3279C369CEDB425AC6E777A2729CE8A2E430E7D6F46D35C6376F8214`.
|
||||
- Android SDK location: `C:/Users/james/AppData/Local/Android/Sdk`; SDK 35, build-tools 34/35, Gradle 8.7.
|
||||
- `adb devices -l` returned no attached devices. No physical TC22, camera, or emulator acceptance performed.
|
||||
- Repository bootstrap verification: CircleCI setup validated against the private QRun-IO orb with explicit organization scope; Actionlint passed for both workflows. Current shared generator accepted the manifest and selected verification only for seven branch/tag cases. All publication switches are false. Root 11 tests and demo one scenario were rerun successfully after repository metadata changes.
|
||||
|
||||
## Handoff and remaining scope
|
||||
|
||||
- Follow `TC22-ACCEPTANCE.md` for local credentials, loopback demo, USB forwarding, DataWedge profile and device tests.
|
||||
- Runtime demo server is not left running. No user credentials were created outside temporary test databases.
|
||||
- First host supports QQQ table-based authentication; other providers show an explicit unsupported message pending configuration.
|
||||
- Server enforces approved device plus authenticated user, including async execution. Enable QQQ core permission enforcement when embedding the qbit.
|
||||
- Only a current unacknowledged scan is retained in memory for explicit retry. Closing/resetting the app is not an offline queue.
|
||||
- The H2 schema is supplied; migrations for other databases and deployment authentication remain host-owned.
|
||||
- Pending physical acceptance is tracked in TODO. GitHub bootstrap and hosted checks are separate from artifact release or production deployment; use `CI-CD.md` for the repository settings and release enablement policy.
|
||||
|
||||
## Native authentication review follow-up
|
||||
The first optional provider-logout compile failure and alternate authentication-source hint gate are corrected in a new frozen source candidate; its isolated focused build is running. Independently, native login does not currently carry/validate the original request nonce against the provider ID token. PKCE code binding is present; no account-takeover exploit is claimed. Explicit provider-bound claim/nonce validation and real-provider acceptance remain required before production authentication closure.
|
||||
|
||||
Android nonce/logout working source now passes151 JVM tests, zero lint errors/warnings and APK/native-test compilation. Independent26-file diff review is completed/sealed as c358cde3-b51d-4451-b07d-8d1afe6c4485 with no findings; this is source-scoped and does not establish native/browser/provider execution. Core combined nonce/test corrections remain in progress; initial diagnostic failures stay failed.
|
||||
@@ -0,0 +1,128 @@
|
||||
> Historical record, superseded by [current status](../SESSION-STATE.md). Do not treat as current.
|
||||
|
||||
# QQQ Mobile Scanners TODO
|
||||
|
||||
Current checkpoint (November 4; supersedes historical notes below):
|
||||
|
||||
- [x] Scanner PR 12 merged `286e2e0`; full run 3645 succeeded. Central snapshot `0.1.0-20261103.233448-3645` verified anonymously, including all 37 published Java source files.
|
||||
- [x] Android PRs 10 and 8 merged into main `0ae2101`; full main lifecycle 3634 succeeded. Shared CI's independent registry readback matched all ten primary library files at `0.1.1-20261103.232145-3634`.
|
||||
- [x] Qualify released Printing `1.0.0` with public QQQ RC.21: 198 tests pass, Checkstyle passes, all 94 scanner Java files match reviewed source, and no private QQQ backend artifacts remain in that cohort.
|
||||
- [x] Adopt that cohort on local port 57832 while retaining the database/settings. Confirm API health, TC22 Echo setup/Copies 1/Connected and Print Agent Connected and ready after restart.
|
||||
- [x] Adopt and review Printing `1.0.0` in both Echo POMs and matching READMEs. Both Maven validate/Checkstyle checks pass; 94 Java inputs match the 198-test qualification.
|
||||
- [ ] Document owner: extract reviewed code from host candidate `b6fb238` with provenance, set public dependency pins and standard standalone release wiring, then publish coherent document/files/scripting Maven artifacts including Generation copies. The local standalone directories are not Git repositories and have no workflows; the website image pipeline does not publish these artifacts. Claude access is restored; the dependency owner's actual Claude standalone packaging is active as of November 4, not finished.
|
||||
- [x] Outstanding manual acceptance group: user reported on November 4, "i have ran the manual tests - it all works". Recorded as operator acceptance in [TC22 acceptance](../TC22-ACCEPTANCE.md); no duplicate manual checks requested. Separate per-case artifacts were not supplied.
|
||||
- [ ] Resolve or explicitly scope the unsupported GS1/ECI/binary scanner profiles before claiming universal barcode reproduction.
|
||||
- [ ] Production Android signing/distribution last, as requested. Development publication and demo availability are not production 1.0 acceptance.
|
||||
|
||||
Historical checkpoint (November 3):
|
||||
|
||||
- [x] Server `45f9cbd` is signed and pushed (Gitea PR 7). Android `f72b90d` is signed and pushed to the existing AGit PR 10 head and Gitea CI 3480 succeeded on it (pull requests #8 and #10 still open; not merged, not released). Updated normal APK installed without clearing data; fresh disposable TC22 Echo run passed 109/109 semantic requests (simulated inputs/printing).
|
||||
- [x] Final RC.21 gate PASS: 377 unique tests (Generation 179, Printing bridge 16, scanner 53, demo 8, mobile Record Files 18, Echo 68, Echo demo 35), zero failures/errors/skips, Checkstyle PASS, plus 15 real admin-browser E2E on public RC.21. The earlier MySQL timeouts did not recur (focused 31, full 179). Evidence: `C:/Users/james/.cache/qqq-rc21-copies-gate-076d059d8d0641d9a85874ec64008def/verified-summary.json`.
|
||||
- [x] Verified dependency update: five RC.21 POM pins and the record-files POM test fix (BOM-managed Jupiter engine/params, all 6.0.2).
|
||||
- [ ] This is public core RC.21 plus qualified owner inputs, not a fully public or released cohort: the 16 Printing-bridge tests still resolve three private `pu507` QQQ libraries through the owner Printing POM, and the other owner qbits remain qualified private inputs. Public reproducible owner inputs, primary source CI, merge and release remain open. No normal-runtime core upgrade was done.
|
||||
- [x] Generation copies: signed `aac68907e67906aeef4e0cd687d28bc98b738f8c` on `feature/echo-generation-copies` (base PR 343 head `8f473fa`) in `Website-Backend/third-party-src/qbit-document-generation`; 13 paths, 21/21 manifest match; real PostgreSQL 17 migration passes. [ ] Owner integration and public artifact publication not yet acknowledged.
|
||||
- [x] Fresh real Authentik desktop acceptance passes; native harness integrated (8 guards pass). [ ] Native TC22 run needs the temporary CA and PIN (pending); no trust bypass.
|
||||
- [ ] Manual physical checks: trigger, AIM, fidelity, label count, photo, GPS, projection. [ ] Signing and distribution last, by user decision.
|
||||
|
||||
Previous checkpoint (November 2; historical, superseded by the current checkpoint above):
|
||||
|
||||
- [x] Optional Copies per scan defaults to 1, accepts 1–100 and remains server-pinned for every scan/retry. Live on the original local server57832; normal TC22 setup shows 1.
|
||||
- [x] Verify 377 unique server/owner tests across the module runs, plus real TC22 ten-stage/116-request semantic acceptance with simulated scans/printing.
|
||||
- [x] Adopt mobile-scanners1.5 and actual mobile-record-files1.0/Echo photo composition locally, preserving the original database and printer settings. Photo authorization tests pass.
|
||||
- [ ] Confirm physical label count/fidelity and real camera/GPS/projection/provider behavior; operator currently remote. Temporary Authentik CA installation is unconfirmed.
|
||||
- [ ] Obtain canonical owner adoption/publication of the verified Generation copies handoff: ten hash-checked files for the Kof22 release repository's `third-party-src/qbit-document-generation` (base verified identical), then a published Generation coherent with Echo's `generation.version`; complete coherent artifacts/CI. Signing/distribution stays last.
|
||||
|
||||
Current demo path (November2; supersedes historical runtime/pairing notes below):
|
||||
|
||||
- [x] Implement and verify additive mobile1.5 raw-byte/AIM transport, exact retry binding, optional context and preserving H2 migration:135 scanner/demo/Echo tests pass. Android counterpart is implemented; native capture and installed-runtime adoption remain open.
|
||||
- [ ] Qualify actual TC22 raw-byte delivery, AIM extraction and decoder settings; finish renderer scanner-mode/extended-symbology integration. Transport support alone does not establish same-symbol reproduction.
|
||||
- [x] Fix and adopt the Generation/renderer byte-limit mismatch in both demo launchers; three failures with old wiring, 74 passing library/demo tests with the fix. One clearly synthetic native HTTP event now reaches the actual Windows printer spooler through Echo, with process cancellation verified after failure and acceptance. Paper output, TC22 trigger capture and rescan remain separate open checks.
|
||||
- [x] Render the actual published 4x1 document through the real renderer and independently decode its Code128 and leading-zero QR pixels; both preserve exact value/symbology and fit the configured stock. See [evidence and test images](../../examples/echo/demo/docs/ECHO-RENDERER-ACCEPTANCE.md). Physical printing remains unverified.
|
||||
- [x] Correct the five inert test-token secret-scan findings using generated fixtures and exact historical fingerprints; local historical/staged scans and all 27 demo tests pass. Current primary CI remains an open gate.
|
||||
- [x] Run all real scanner/Echo, files, document and printing qbits in the single local server57832; original accounts work; full demo27 and live forwarded-host checks pass.
|
||||
- [x] Connect the existing installed print client to57832 and approve its available ZPL printer through the native QQQ process; no job/default changes.
|
||||
- [x] Verify the Android same-origin header (Android `fb3cc5d`, 174 JVM tests) and TC22 sign-in, heartbeat, printer/media/document selection and Cancel/Leave against the strict local host, preserving data.
|
||||
- [x] Resolve the earlier 4x6 media report versus loaded 4x1 stock with the Printing owner (101600 x 25400 microns, 300 dpi) and publish the real Echo 4x1 barcode document with its matching immutable revision 1.
|
||||
- [ ] Complete one physical Code128 (QQQ-TEST-123456) scan, print and rescan on the TC22, then leading-zero QR. One synthetic HTTP test job reached `SPOOLED`; physical paper output and handheld acceptance are not yet proven.
|
||||
- [ ] Finish the existing production-readiness matrix and publication gates; local demo acceptance alone does not complete 1.0. Public coherent artifacts, primary Gitea CI and final signing remain open, with signing last by user decision.
|
||||
|
||||
- [x] Implement explicit required INTEGER Document choice with printer/media dependency, server-pinned revision and recoverable errors:45 library/24 composed tests and real TC22 seven-stage/112-request semantic acceptance pass (simulated printing).
|
||||
- [x] Roll out the document-selection feature and connect the installed print client to the canonical local server. Real4x1 publication and physical scan/print/rescan remain tracked above.
|
||||
|
||||
- [x] Verify the per-extension device-header scope fix (50 base,7 base-demo and composed HTTP regressions), then complete actual native printer enrollment/binding on the local server.
|
||||
|
||||
- [x] Publish reviewed Echo retry/receipt fixes as signed Gitea commit912040d;38 library/22 demo tests pass. (Historical: the then-pending Windows approval of printer pairing is superseded; the print client is now connected to the single local server.) Physical output and public CI/artifacts are still open.
|
||||
|
||||
- [x] Verify real composed Echo native HTTP pipeline:38example/22demo, retained files, accepted jobs, retry identity and Leave closure; simulated printer/controlled renderer only.
|
||||
- [x] Complete native printer connection on the current loaded stock (done on the single local server). Physical scan/print/rescan is tracked above; published dependencies and primary CI remain open.
|
||||
|
||||
- [x] Verify effective security-key set comparison and early Collaboration authentication initialization: scanner50/native Record Files18, Checkstyle0 on private coherent cohort.
|
||||
- [x] Compose the live Echo renderer, Printing and document publication in the single local server. Physical TC22 print/rescan is tracked above; startup smoke and simulated printing do not close that gate.
|
||||
|
||||
- [ ] Deliver Echo example: choose an authorized printer, media and Document (all three required) once, then one scan/render/accepted-print job at a time until leave; reproduce exact value and symbology with worker-owned integer-module fitting and quiet zones. Source, composition, Document choice and native setup are done. Still open: the physical print and rescan above, and full-fidelity work beyond the six admitted printable-ASCII types (EAN/UPC, GS1, AIM identifier and raw payloads), tracked in `examples/echo/README.md`.
|
||||
- [ ] Coordinate qualified renderer fit, authorized printer/media discovery, least-privilege submit and retained-print adapter contracts with their owners via shared communications.
|
||||
- [x] Verify scanContext 1.3 with root44/demo7/optional18 and Checkstyle: following-step QR/Code 128 branching, legacy omission isolation, trusted attribution and exact retry binding; physical scanner acceptance remains separate.
|
||||
|
||||
- [x] Apply the full scoped standards review corrections and verify server39/demo6 with Checkstyle; see [review](../CODING-STANDARDS-REVIEW-2026-11-01.md).
|
||||
- [ ] Finish final hosted checks and remaining physical/provider acceptance; source compliance is not production release approval.
|
||||
|
||||
- [x] Upgrade the normal local demo to qualified API1.9/mobile1.1 with fresh closed backup, original-data invariants, recovery qualification and live verification of both existing accounts and preserved devices/scans.
|
||||
- [x] Implement/review additive extension1.2 whole-device source with explicit Android consent, mode-bound leases and automated controls, preserving1.1 app-content compatibility.
|
||||
- [ ] Complete actual Android projection/notification/Home/Stop/lock/rotation/expiry/remote-logout acceptance; normal server remains mobile1.1.
|
||||
|
||||
- [x] Qualify the demo BOM import and managed JUnit engine with fresh37 server/five demo tests, exact runtime module/Guava/H2 provenance and all15 single-record/bulk browser cases; see [acceptance evidence](../API19-CONSUMER-ACCEPTANCE.md). The operator runtime remains separate.
|
||||
- [x] Preserve legacy timestamp instants in the canonical supplied H2 schema with an explicit UTC migration connection; verify non-UTC file-backed upgrade, unchanged passwords/approval/scans and repeated initialization. The operator upgrade is preserved with UTC; a no-install reactor passes 37 base tests and five demo tests using the freshly built schema.
|
||||
- [x] Implement unique short names, editable names/descriptions, additive H2 migration and protected profile editing; qualify the preserving upgrade of the operator database before deployment.
|
||||
- [x] Implement separate approval/presence states, online-only fleet query and foreground location with accuracy/age and permission-denied behavior; actual GPS-fix acceptance remains in the release gates.
|
||||
- [x] Implement the optional Record Files contract/native capture/association path and qualify the final coherent core32/scanner1a3 consumer cohort: 39 scanner, six demo HTTP, 18 Record Files and 15 browser cases. See [qualification scope](../../integrations/record-files/docs/CORE-32DF-CONSUMER-QUALIFICATION.md); this is not a files-owner full-suite or default-adoption claim.
|
||||
- [ ] Qualify coherent immutable/default RF dependencies and actual TC22 camera/gallery permissions and durable authorized association; no normal-runtime adoption is implied.
|
||||
|
||||
- [x] Device/session history, live activity and demand-based actual app-screen viewing through native QQQ administration and semantic extension 1.1.
|
||||
- [x] Single-record and bulk End Session process with server-enforced invalidation, audit and browser/native E2E coverage.
|
||||
- [x] Complete and seal the full server deep static security review of `0926550`; no reportable findings, with external implementation/deployment limitations retained.
|
||||
- [ ] Complete final revision/scope reconciliation of historical security reports and remaining external deployment/provider gates. Independent architecture A1–A11 source closure and the filesystem regression correction are recorded; do not relabel partial/historical reports as a new exhaustive scan.
|
||||
- [ ] Complete production 1.0 gates in [PRODUCTION-READINESS.md](../PRODUCTION-READINESS.md), including physical acceptance and deployment/release requirements.
|
||||
|
||||
- [x] Native qbit device/scan tables and permission boundaries.
|
||||
- [x] Authenticated enrollment, approval and revocation.
|
||||
- [x] Native Scan Capture process and idempotent scan storage.
|
||||
- [x] Android login, device gate, app picker and current trusted EMDK scanner path; prior DataWedge handling is historical.
|
||||
- [x] Persistent local demo with separate scanner/admin accounts.
|
||||
- [x] Automated HTTP acceptance: pending denial, approval, capture, duplicate retry, revoke denial.
|
||||
- [x] Android build and meaningful unit checks.
|
||||
- [x] Standard repository files, current shared CircleCI profile, demo/Windows CI, CodeQL and Dependabot.
|
||||
- [x] Validate CircleCI, workflow syntax and verification-only publication policy locally.
|
||||
- [x] Connect hosted CI and confirm actual successful status checks.
|
||||
- [x] Define branch protection policy from observed hosted checks; enforcement lives in GitHub settings.
|
||||
- [x] Server-configured customer branding for one shared APK.
|
||||
- [x] Public status endpoint for the Android connection footer and diagnostics.
|
||||
- [x] Android emulator installed; native UI tests passed.
|
||||
- [x] Single-record and bulk approval/revocation with confirmation, stable selection and visible results.
|
||||
- [x] HTTP acceptance for checked rows, filtered bulk selection, persisted results and unselected-device isolation.
|
||||
- [x] Browser E2E acceptance for all administration processes from record and bulk menus (six scenarios passed).
|
||||
- [x] Scanner-host native E2E acceptance against an isolated real demo server.
|
||||
- [x] Register enrollment, branding and status as the documented mobile-scanners 1.0.0 semantic UI API extension; generate OpenAPI and check the versioned contract baseline.
|
||||
- [x] Verify extension discovery, real HTTP payload schemas, standard errors, public stale-credential behavior and rejection of legacy routes (root 23 tests and demo HTTP verification passed).
|
||||
- [x] Coordinate Android core/extension compatibility checks before login and native semantic process/form updates.
|
||||
- [x] Register the Android client channel while preserving host channel policies; verify the real HTTP suite sends Android-channel requests successfully.
|
||||
- [x] Rerun all six browser E2E scenarios against the migrated server (passed in 2.9 minutes).
|
||||
- [x] Verify the migrated client/server pair's incompatible-version, missing-extension, missing-channel, baseline scan and additive-metadata native scenarios with the OpenAPI request audit.
|
||||
- [x] Verify Android's 53 JVM tests, six shared native UI tests, lint and debug/E2E APK builds.
|
||||
- [x] Complete native approval/revocation/cancellation forms after the completion-state fix (255.917 seconds; all 31 requests matched to 13 published operations).
|
||||
- [x] Publish server semantic API migration commit `7dd02b8` and verify its hosted CircleCI, CodeQL, Windows helper and demo HTTP/browser checks all pass.
|
||||
- [ ] Finish current EMDK physical acceptance: Code128 positive controls pass; current QR, lifecycle/ownership and failure/retry/leave matrix remain open.
|
||||
- [x] Identify the physical TC22, install the debug app, sign in and enroll through USB forwarding.
|
||||
- [x] Approve the TC22 through its browser record Actions menu and open its native app catalog and Scan Capture process; preserve login across APK update.
|
||||
- [x] Configure the TC22 DataWedge profile with explicit package/signature delivery; verify two deliberate Code128 hardware captures persist with matching metadata.
|
||||
- [x] Verify physical QR delivery and exact preservation of leading zeroes in `001234567890`, with matching persisted scan metadata.
|
||||
- [ ] Finish physical connection-loss/retry and revocation-denial acceptance alongside the requested device/session controls.
|
||||
- [x] TC22 install and DataWedge acceptance guide written and updated with observed results.
|
||||
|
||||
Future scope (not version one): offline queue, camera-based barcode decoding, kiosk/MDM provisioning, broader native screen coverage, fleet rollout. Optional record-photo capture is implemented; its remaining qualification is tracked above.
|
||||
|
||||
- [ ] Complete the optional semantic API1.10 provider-logout capability and client AppAuth end-session handling; keep local cleanup reliable on provider failure, qualify real A-to-B handoff, and apply the user's pending sign-out policy. Existing prompt-only retained-SSO handoff is not accepted.
|
||||
|
||||
- [ ] Validate native OIDC ID-token claims and original request nonce before creating the QQQ session. Review confirmed request continuity and S256 PKCE exist, but expected nonce is not currently carried to the server exchange. Add explicit semantic capability/wire compatibility and rejection tests; retain real-provider nonce acceptance as pending.
|
||||
|
||||
- [x] Retain unchanged verified capture session identity with explicit current authority/reference checks;49root+7demo tests andCheckstylepass.
|
||||
- [ ] Qualify exact ownerRF18 upload/finalize using the frozen identity patch before final coherent host adoption.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Independent architecture and maintainability closure review
|
||||
|
||||
Date: 2026-11-01. Reviewer: independent screen_security_review agent.
|
||||
|
||||
Android source: 229fce30a1b859a7c443a3ad68e5d6cd3a7cc6de, R:/Git.Local/QRunIO/qqq-android-wt-ci.
|
||||
Server source: 2de847049941f93c8dde37bd7696aa352bbbcbfa, R:/Git.Local/QRunIO/qbit-mobile-scanners; implementation checkpoint 10416938724c9663d66f7848ff2b1a63fe635794.
|
||||
Both HEADs matched and working trees were clean when inspected. This review does not modify or reopen any sealed security scan.
|
||||
|
||||
## Outcome
|
||||
|
||||
No additional concrete release-blocking source defect established by this architecture/maintainability pass. A1-A11 have identifiable source remediations and regression tests. That is source closure, not blanket production readiness: immutable dependency adoption, final hosted/native evidence, configured identity provider, actual EMDK/MediaProjection/GPS/camera acceptance, and production signing/deployment remain gates.
|
||||
|
||||
This is a cross-module architecture closure review, using the engineering architecture and code-review workflows. It is not a new exhaustive security scan, dependency audit, per-line review of every model/helper, or runtime acceptance. Tests cited below were inspected as source, not executed by this reviewer. Counts/results in PRODUCTION-READINESS.md remain the root's separate evidence; they are not re-certified here.
|
||||
|
||||
## Coverage and component responsibilities
|
||||
|
||||
Production inventory was enumerated for shared Android core/mobileapp libraries, scanner host main/production sources, historical sample app, base server, demo and optional Record Files adapter. Focused source tracing covered the following execution paths:
|
||||
|
||||
- Core owns semantic HTTP operations, credential-free compatibility discovery, structured errors and ordinary JSON value serialization. UiApiCompatibility.kt:29-83 checks origin-scoped discovery, required features/channel and major/minor ranges; NetworkQQQRepository.kt:73-183 supplies origin-bound transport/session handling and provisional login-cookie adoption. No scanner business operation is implemented in core.
|
||||
- Shared mobileapp owns authentication generation, process initialization/submission/status/cancellation, typed form semantics and navigation. Scanner/photo controls are host extensions; ProcessAcknowledgment.kt is an explicit receipt policy instead of interpreting any eventId as a scanner request. SemanticProcess.kt:39-109 blocks unsupported required controls at submission and rendering boundaries.
|
||||
- Scanner host owns server-scoped encrypted enrollment credentials, enrollment UI, hardware driver ownership, optional foreground location, app/display capture and optional photo transport. ScannerActivity wires these to the existing QViewModel/process renderer. DeviceCredentials.kt:22-88 uses per-server no-backup files, AES-GCM/Keystore and atomic persistence; corrupt storage fails closed.
|
||||
- ScannerInputSession/ScannerDriver isolates the vendor SDK. Production factory selects EMDK only after presence checks; ZebraScannerDriver marshals callbacks to Main, protects a single outstanding manager claim and releases late acquisitions. ScannerInputSession.kt:42-157 combines generation, active owner, accepting/armed state and a live surface predicate. Physical vendor behavior remains unproved by these fakes.
|
||||
- Base server owns approved device credentials, authenticated private session bindings, scan receipts/idempotence, administrative selections, presence and bounded live pixels. MobileScannersUiApi is the sole semantic extension registration, and specs retain stable operations. MobileScannersRouteProvider.java:103-115 verifies device headers; MobileSessionService.java:86-122 binds device/user to authenticated UUID; MobilePermissions.java:45-118 restricts scanner/admin actions and generic table access.
|
||||
- Demo is a local TABLE_BASED acceptance host, not a production authentication deployment: MobileScannersDemo.java:197-204 explicitly binds loopback; DemoAuthenticationModule.java grants roles only to its validated local accounts.
|
||||
- Optional Record Files adapter depends on both qbits in its own module. MobileCaptureIdentityProvider.java:28-66 exposes public attribution, validates provider identity and delegates a protected commit callback to MobileDeviceAuthorization. The base scanner qbit does not acquire a Record Files dependency. Contract discovery is public vocabulary, not upload authorization (MobileRecordFilesContract.java:43-89).
|
||||
|
||||
These boundaries provide sufficient cohesion for the present feature set. The larger ProcessViewModel/QViewModel coordinators have lifecycle responsibilities and regression seams; their size alone is not a release blocker. No speculative abstraction, HTTP-client unification, framework replacement or wholesale refactor is recommended by this review.
|
||||
|
||||
## Initial A1-A11 reconciliation
|
||||
|
||||
Paths in this table are relative to Android unless prefixed Server. Long package roots are identified after the table.
|
||||
|
||||
| ID | Current enforcing source | Regression evidence inspected | Closure and limit |
|
||||
| --- | --- | --- | --- |
|
||||
| A1 incomplete scan receipt | host ScannerWidget.kt:91-92 requests matching event, scanSaved=true and nonblank scanId; mobileapp ProcessAcknowledgment.kt:13-15 checks all requirements; ProcessViewModel.kt:554-570 retains pending request on mismatch | mobileapp SemanticProcessTest.kt:62; ProcessSubmissionTest.kt:240-256,312; host ScannerInputSessionTest.kt:100 | Source remediated; physical/real-server retry acceptance remains separate. |
|
||||
| A2 invalid length metadata | host ScannerMetaData.kt:11-19 defaults only when absent, rejects nonnumeric/fractional/out-of-range input; ScannerWidget preserves raw barcode and rejects overlength rather than truncating | ScannerMetaDataTest and ScanEventTest; ScannerInputSessionTest.kt:100 | Source remediated; no silent metadata coercion required for supported scanner vocabulary. |
|
||||
| A3 typed INPUT_FIELD/action confusion | InputFieldWidgetBlock.kt:62-107 uses typed metadata, explicit submitOnEnter and data callback; SemanticProcess.kt:76-109 collects supported widget fields; ProcessViewModel.kt:390-411 validates and converts at submission | SemanticProcessTest.kt:80,149,155; native SemanticInputTests.kt:118,132 | Source remediated; unsupported selectors/components fail explicitly. |
|
||||
| A4 async status retry | ProcessViewModel.kt:462-479 retries known job status without replaying step mutation; checkIn:610-640 preserves known job on network failure | ProcessSubmissionTest.kt:167 | Source remediated. Ordinary unknown mutation results are intentionally not automatically replayed. |
|
||||
| A5 late initialization cancellation | ProcessViewModel.kt:314-349 preserves returned process identity and cancels it when cancellation intent is set; cancel:646-678 does not discard the only pending identity | ProcessSubmissionTest.kt:192,214 | Source remediated for explicit process cancellation; transport/provider/server behavior remains a separate integration boundary. |
|
||||
| A6 offscreen/keyboard actions | QPasswordLogin.kt:41 scrolling+IME padding; QProcessHome.kt:173-176 weighted scroll area+IME padding; ProcessComponentEditForm clears focus/hides keyboard | native SemanticInputTests.kt:56,83; session E2E fresh-login test now waits for completed logout and enabled button | Source remediation present. Historical hosted failure claims must use final execution evidence; this review does not infer success from compilation or the timing change alone. |
|
||||
| A7 null/default mismatch | SemanticFields.kt:8-9 checks containsKey; EditForm initializes and submission uses same semanticInitialValue; widget writes default only when key absent | SemanticProcessTest.kt:72 | Source remediated. Explicit null remains null rather than visually showing an unsubmitted default. |
|
||||
| A8 serialization envelope collision | core GenericSerializing.kt:84-127 handles wire objects as ordinary maps/lists/primitives; no wire-selected class discovery | core GenericSerializingTest.kt:73 | Source remediated. Legacy API wrappers remain for source compatibility, not wire class selection. |
|
||||
| A9 partial multi-write operations | Server MobileService.java:82-87 enrolls both rows transactionally; :188-227 bulk approval transaction rejects no-op backend transactions; MobileSessionService.java:98-108 creates session/binding transactionally; :274-298 commits every selected tombstone before provider cleanup; :255-267 records cleanup status | Server MobileScannersTest.java:379,623; HTTP bulk lifecycle; optional MobileRecordFilesIntegrationTest.java:539 protected commit cases | Source remediated for local supported transaction model. Cross-node revocation/commit exclusion is explicitly outside the instance monitor; production topology must be constrained or supplied with transactional authorization. |
|
||||
| A10 media ownership | ProcessSound.kt:17-42 reuses/releases one optional player; QProcessHome.kt:224-255 binds release to stop/disposal; ImageWidgetBlock.kt:52-56 owns/shuts down loader; AudioWidgetBlock releases on stop/disposal; host PhotoUploadViewModel.kt:306-319 cancels I/O, closes source and removes owned files after completion | ProcessSoundTest.kt:10,40; PhotoUploadViewModelTest.kt:34,55,91,128,160; native image/camera source cases | Targeted ownership fixes present. Actual camera, content-provider blocking and native graphics/audio services are not proved by JVM tests. |
|
||||
| A11 navigation state drift | QNavigator.kt:117-153 installs/removes destination listener and reconciles actual controller state; :161-235 routes app/process/Back/Home through leave policy; QMobileAppMain owns shared drawer-first Back action | native NavigationTests.kt:118,211,277,296 and session rotation/recreation source test | Source remediated. Current hosted result must still be tied to exact revision; no device walkthrough claimed here. |
|
||||
|
||||
Package roots: host = scanner-host/src/main/java/io/qrun/mobile/scanner; mobileapp viewmodels = qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel; mobileapp UI = qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui; core = qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core. Corresponding JVM tests use src/test/java and native tests live in qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp. Server main package is src/main/java/com/kingsrook/qbits/mobilescanners; optional adapter package is integrations/record-files/src/main/java/com/kingsrook/qbits/mobilescanners/recordfiles.
|
||||
|
||||
## Current lifecycle, concurrency and resource conclusions
|
||||
|
||||
1. Scan retry identity is device+event based, with a deterministic database key; concurrent insert recovery checks barcode/symbology/source/user, not just ID (MobileService.java:243-299). Leading zeroes remain strings. Approval and session termination are enforced around local capture, not delegated to UI metadata.
|
||||
2. Administrative selection is reviewed before application and frozen into bounded server process records. MobileAdminSelection.java:37-145 caps selection at100 and validates every target. Server tests cover single/bulk/manual/filter/first-N paths. This is stronger than replaying a caller's replacement selection at apply time.
|
||||
3. Local session authorization and screen operations use a per-QInstance monitor (MobileSessionService.java:48-63). Optional attachment commits are held through MobileDeviceAuthorization.executeAuthorized, including empty desktop attribution. This is not a distributed lock or a tenant partition guarantee. No shared-QInstance multitenant or multi-JVM safety certification follows from it.
|
||||
4. Screen demand and mode are independent controls: changing mode removes old pixels/lease; upload must match both current session mode and exact lease mode (MobileSessionService.java:141-145,312-327; MobileScreenService.java:62-79,103-113). Client consent is enforced by Android, not cryptographically attested to the server. Server display wording is an approved-client assertion.
|
||||
5. Screen retention is bounded to64 leases and one compressed latest frame each;30second demand,10second visible freshness, bounded Base64/JPEG/dimensions and sequence/rate checks precede retention (MobileScreenService.java:29-31,73-88,103-182). Expiry is access enforcement with lazy pruning on subsequent reads/writes (:201), not scheduled heap erasure. No disk frame storage is introduced.
|
||||
6. Android projection keeps one latest native image, drains to prevent the old queued-image problem, copies pixels on Main before background JPEG encoding, and discards results after projection/owner change. Notification/lock/stop/lease cleanup is explicit; actual Android behavior remains a physical gate.
|
||||
7. Photo uploads are scoped to session+process+block+parent, held in a navigation-owned ViewModel (RecordPhotoWidget.kt:47-73). Status/create recovery retains immutable request/item identity, chunk bytes and exact-original SHA; only matching COMPLETE association receipts permit Saved/local deletion (PhotoUploadViewModel.kt:267-294; RecordFilesApi.kt:87-102). Background pause preserves pending original, while destruction releases only files allocated by that owner. This does not guarantee OS process-death recovery, and the UI explicitly requires staying open/connected.
|
||||
8. Location remains optional foreground-only telemetry, with accuracy/source timestamps and listener removal (ForegroundLocation.kt:41-75). Server schemas describe last-known observations, not current-position proof. Actual permission/fix/disabled-provider behavior is not established by source.
|
||||
9. Authentication remains origin/attempt generation bound; provisional responses are not automatically installed by the network layer, logout blocks re-login through completion, and stale metadata callbacks are generation checked. Configured Authentik/browser SSO acceptance and trust configuration are owned by the separate investigation, not closed here.
|
||||
|
||||
## Coding standards and operational closure
|
||||
|
||||
Server production classes inspected use the repository's three-space/Allman/import/SPDX conventions and normal QQQ actions/semantic specs. Android additions generally have small native-boundary helpers and explicit lifecycle ownership; older shared files retain historical comments/import formatting. No coding-style variation observed establishes a release-blocking behavioral defect. Actual Checkstyle/lint verdicts must remain tied to their existing build evidence; they were not rerun here.
|
||||
|
||||
Compatibility is additive in source: absent modes mean APP_CONTENT, optional RF vocabulary is separately discovered, unknown required controls stop submission, and server current OpenAPI is compared with preserved1.0/1.1 baselines. However, server pom.xml:37-40 still defaults qqq.version to4.1.0-RC.20, and integrations/record-files/pom.xml:10-24 retains frozen/snapshot tuple coordinates. A passing isolated source-pinned consumer is not the default dependency graph. Before supported production adoption, publish/select one coherent immutable tuple and prove its effective runtime contents. RF tuple qualification is being investigated separately; this artifact does not supersede it.
|
||||
|
||||
Release automation was separately reviewed read-only in this same reviewer thread: read-only verification token, exact companion source pins/provenance, environment-scoped signing, APK certificate/package/version/non-debuggable readback, and publish-time tag target/hash checks were present. External environment/tag protection, production key/recovery and actual signed upgrade remain unverified; no signing/distribution action is authorized by this artifact.
|
||||
|
||||
## Remaining release gates and exclusions
|
||||
|
||||
- Fresh final-revision hosted native/session login evidence; final UI source guard alone is not a result.
|
||||
- Actual current EMDK trigger formats, repeated events, pause/restart/ownership restoration and error/retry/leave walkthrough. Earlier DataWedge evidence does not qualify EMDK.
|
||||
- Operator-mediated MediaProjection consent, notification, Home/other-app pixels, stop, lock, rotation, expiry and remote-session termination on the refined candidate.
|
||||
- Actual GPS permission/fix/disabled behavior, camera/gallery permissions and durable authorized record association on a matched runtime.
|
||||
- Configured Authentik native/browser exchange, provider rotation, shared-operator SSO and trust setup; external provider behavior and dependencies are not audited here.
|
||||
- Coherent immutable core/server/RF/attachment-owner/RDBMS publication and deployment provenance. Root/other reviewers own tuple investigation.
|
||||
- Defined supported deployment/database migration and topology. Local monitor guarantees do not extend to clusters; no tenant-specific policy has been established by this qbit review.
|
||||
- Organization signing key/environment/tag safeguards, recovery and signed APK update acceptance, then distribution only at the owner's deferred final step.
|
||||
|
||||
No network, builds, tests, runtime processes, device actions, repository edits or commits were performed for this closure pass. Only this owned cache artifact was written. Prior sealed security reports keep their original revisions, scopes and exclusions; this report does not silently broaden them.
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright (c) 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import { startDemo } from './start-demo.mjs';
|
||||
|
||||
export default async function globalSetup() {
|
||||
return startDemo();
|
||||
}
|
||||
Generated
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "qqq-mobile-scanners-browser-acceptance",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "qqq-mobile-scanners-browser-acceptance",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.64.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "qqq-mobile-scanners-browser-acceptance",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.64.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
process.env.MOBILE_E2E_PORT ??= '57933';
|
||||
process.env.MOBILE_E2E_SCANNER_PASSWORD ??= randomBytes(24).toString('base64url');
|
||||
process.env.MOBILE_E2E_ADMIN_PASSWORD ??= randomBytes(24).toString('base64url');
|
||||
process.env.MOBILE_DEMO_SCANNER_PASSWORD = process.env.MOBILE_E2E_SCANNER_PASSWORD;
|
||||
process.env.MOBILE_DEMO_ADMIN_PASSWORD = process.env.MOBILE_E2E_ADMIN_PASSWORD;
|
||||
const baseURL = `http://127.0.0.1:${process.env.MOBILE_E2E_PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
globalSetup: './global-setup.mjs',
|
||||
use: {
|
||||
baseURL,
|
||||
browserName: 'chromium',
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
trace: 'off',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
});
|
||||
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user