Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8478ae50f8
|
||
|
|
e255b7341d
|
||
|
|
ffc5b493fa
|
||
|
|
fb3cc5d2f8
|
||
|
|
47a923a712
|
||
|
|
a2f0080755
|
||
|
|
c55d3002b0
|
||
|
|
3bec46d35a
|
||
|
|
de3f6f6b03
|
||
|
|
cd8c9f2a8c
|
||
|
|
6293886f59
|
No files matched your search
@@ -0,0 +1,22 @@
|
||||
# STATIC QUALIFICATION TEMPLATE, NOT A PRODUCTION CALLER. Do not copy, commit or dispatch until every UNRESOLVED_* value is
|
||||
# replaced by an actual value and the result is frozen-reviewed. Final production stays the central policy / organisation
|
||||
# workflow: no per-repository bespoke Android workflow survives qualification.
|
||||
#
|
||||
# Owner branch rules for the one qualification run:
|
||||
# - derive the branch from the recorded current Android source head (never from the peer PR10 branch);
|
||||
# - the branch changes only this one workflow file (.gitea/workflows/android-qualification.yaml) and nothing else;
|
||||
# - the branch is created and the workflow dispatched by the owner after the runtime gates in rollout.md pass.
|
||||
# Manual only: no push or pull_request trigger, so nothing runs by accident and no fork event can reach the runner.
|
||||
name: android-qualification
|
||||
on:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: android-qualification-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
ci:
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/android.yaml@4a7f159b82727e93689799d93ee03288a36208ba
|
||||
with:
|
||||
runner: android-build
|
||||
@@ -37,6 +37,8 @@ jobs:
|
||||
cache-read-only: ${{ github.event_name == 'pull_request' }}
|
||||
- name: Verify source headers, whitespace, imports and JavaScript syntax
|
||||
run: node scripts/ci/check-source-standards.mjs
|
||||
- name: Run no-device Echo fixture guard tests
|
||||
run: node --test scripts/ci/scanner-echo-guards.test.mjs
|
||||
- name: Install required Android SDK packages
|
||||
run: |
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;34.0.0" "build-tools;35.0.0" "platform-tools"
|
||||
|
||||
@@ -38,7 +38,7 @@ The same APK runs on other Android devices with explicit manual entry. When the
|
||||
|
||||
The shared launcher name/icon remain QQQ Mobile. On connecting to a server, the host reads public `GET /qqq/v1/qbit/mobile-scanners/branding` (`displayName`, `primaryColor`, optional `logoUrl`). The name and Material theme apply to login, device approval, and native app screens. Names are limited to 80 printable characters and colors to `#RRGGBB`; malformed values fall back to QQQ defaults. Button foregrounds choose readable black/white contrast. Optional logos must use HTTPS, are fetched without session/device headers or cookies, and cannot prevent login if unavailable. Responses are size-limited, redirects are disabled, and branding is reset when changing servers. Reconnect to pick up server branding changes; no APK rebuild is needed.
|
||||
|
||||
The persistent footer shows **Checking**, **Connected**, or **Disconnected** with the last check time. It checks `GET /qqq/v1/qbit/mobile-scanners/status` every five seconds while foregrounded and on network changes; `Connected` means a valid `{ "status": "ok" }` response, not sign-in or device approval. Tap the footer for **Copy**, **Clear**, and **Close** diagnostics. The in-memory log holds the latest 100 fixed event codes with timestamps, HTTP status, and latency for branding, connection checks, native QQQ calls (including login/process requests), and device enrollment/status requests. A transport failure has no HTTP status; an HTTP response retains its status, including 401/403/500 errors. The log contains no credentials, scan values, request paths, queries, raw response text, or exception messages. Changing servers clears the displayed diagnostics. Monitoring never queues or retries a scan.
|
||||
The persistent footer shows **Checking**, **Connected**, or **Disconnected** with the last check time. It checks `GET /qqq/v1/qbit/mobile-scanners/status` every five seconds while foregrounded and on network changes; `Connected` means a valid `{ "status": "ok" }` response, not sign-in or device approval. Tap the footer, or use the Diagnostics entry in the menu, to open a full-screen diagnostics view with a server connection summary (reachability only), the recent activity newest first in local time, **All** and **Problems** filters, **Pause updates** and **Resume live updates**, **Copy**, **Share** and **Clear**, and **Back**. While it is open, process input stays blocked. The in-memory log holds the latest 100 fixed event codes with timestamps, HTTP status, and latency for branding, connection checks, native QQQ calls (including login/process requests), and device enrollment/status requests. A transport failure has no HTTP status; an HTTP response retains its status, including 401/403/500 errors. The log contains no credentials, scan values, request paths, queries, raw response text, or exception messages. Changing servers clears the displayed diagnostics. Monitoring never queues or retries a scan.
|
||||
|
||||
## Online-only behavior
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Claude native handoff: Echo setup named possible values
|
||||
|
||||
## Source-verified contract (core `qqq-wt-native-provider-logout`)
|
||||
- operationId `searchProcessPossibleValues`: `POST /qqq/v1/processes/{processName}/possibleValues/{fieldName}` (`UiApiOperationIds`, `ProcessPossibleValuesSpecV1`, openapi.yaml ~12440). Server advertises feature `possibleValues` on `GET /version`.
|
||||
- Request: `application/json` `{ "values": {other form values}, "processValues": {...}, "useCase": "form", "searchTerm"?, "ids"? }` (`PossibleValuesSpecUtils`). Not multipart. Same session cookie, `X-QQQ-Client` and device headers as other native calls (shared Retrofit client).
|
||||
- Response `PossibleValuesResponseV1`: `{ "options": [{ "id", "label", ... }], "hasMore"? }`; `options` is omitted when empty.
|
||||
- Field metadata (FieldMetaData v1): `possibleValueSourceName`, and for contingent fields `possibleValueSourceDependsOn: [names]` and `possibleValueSourceDependencyBehavior: DISABLE_UNTIL_SET | SHOW_ALL_UNTIL_SET` (docs/CONTINGENT-POSSIBLE-VALUES.md).
|
||||
- Frontend rules (same doc): re-query when a dependency changes, clear a selection that no longer fits (transitively), disable with a hint until dependencies are set (default), ignore stale responses.
|
||||
|
||||
A previous revision of this work invented the multipart shape from memory; it has been replaced entirely by the above. The route path happened to match; the body did not.
|
||||
|
||||
## Root cause (unchanged, source inference)
|
||||
Echo `setup` declares `printerId`/`mediaId` with named PVS (media `dependsOn printerId`); fixture uses ENUM PVS. The client rendered only inline choices, so Printer became a free-text "Printer *" field and submission was blocked as unsupported. That init is a synchronous COMPLETE (not ASYNC) is inferred from sources and the operations log; the native E2E remains the authority. The fixture is NOT changed to inline choices.
|
||||
|
||||
## Implementation
|
||||
- core: `QQQApiService.processPossibleValues`; `QQQRepository.processPossibleValues(process, field, values, processValues)` (default throws Unsupported so other fakes compile); `NetworkQQQRepository` builds the JSON body and decodes options (scalar ids kept); `QFieldMetaData` gains `possibleValueSourceDependsOn` / `possibleValueSourceDependencyBehavior`.
|
||||
- mobileapp `ProcessViewModel`: `loadRemoteOptions`, `remoteOptionsBlockedReason` (DISABLE_UNTIL_SET -> "Choose Printer first." and no request), `fieldChanged` (dependents drop selection immediately, transitively, and re-query), per-field generation token so only the newest request publishes (a late reply for an old parent cannot restore options or a selection), post-load prune of a choice no longer offered, `fieldResets` so the control follows a VM-cleared value. Load failures are shown with "Retry choices", cancel the superseded job, never set client-work, so Cancel/Leave stay available. `reset()` clears all of it.
|
||||
- `ProcessComponentEditForm`: select for named PVS (label text unchanged: "Printer", "Paper or label size", button "Choose a value"), disabled with a hint while blocked.
|
||||
- `SemanticProcess.unsupported` accepts named PVS in EDIT_FORM only (widget INPUT_FIELD remains unsupported).
|
||||
- E2E test: literal `waitFor("Printer")` unchanged (diagnostic text dump retained on timeout); added a readiness wait for the enabled media control before clicking it, since media is contingent. No assertion weakened.
|
||||
- docs/UI-API.md updated.
|
||||
|
||||
## Tests added/updated
|
||||
- `NetworkQQQRepositoryTest.processPossibleValuesUsesThePublishedJsonOperation`: method/path/JSON body/use case, response with options, and the omitted-`options` empty response.
|
||||
- `ProcessSubmissionTest.namedPossibleValueSourcesLoadWithDependentValuesAndReportFailures`: blocked dependent not searched; parent value sent; dependent selection cleared on parent change; late old-parent reply ignored; failure reported, no client-work block, retry succeeds.
|
||||
- `SemanticProcessTest`: named PVS in EDIT_FORM supported; UNKNOWN type and widget PVS still unsupported.
|
||||
Note: server response bodies in tests are minimal shapes taken from `PossibleValuesResponseV1`/`ProcessPossibleValuesSpecV1Test` (`options[].id/label`), not captured from a live server.
|
||||
|
||||
## Commands for root (Java 21, wrapper)
|
||||
```
|
||||
./gradlew :qqq:android:core:testDebugUnitTest :qqq:android:mobileapp:testDebugUnitTest :scanner-host:testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest
|
||||
node --test scripts/ci/scanner-echo-guards.test.mjs
|
||||
```
|
||||
Then re-approve rebuilt APK hashes and run the root native Echo helper.
|
||||
|
||||
## Known gaps / risks
|
||||
- Nothing compiled or run (no shell). Expect possible small compile fixes (Compose state/`mutableIntStateOf` delegate, imports).
|
||||
- `possibleValues` is not added to the client's REQUIRED_FEATURES (all existing fixtures advertise only six features); a server lacking it fails the search with a visible error + Retry rather than at startup.
|
||||
- Pre-existing and untested: decoding inline `inlinePossibleValueSource` ids typed `java.io.Serializable` via kotlinx.
|
||||
- Whether the real Echo metadata publishes `possibleValueSourceDependsOn` for media is inferred from `withPossibleValueSourceDependsOn(List.of("printerId"))` in the producer; unverified against a live `/metaData/process/echoPrint` response.
|
||||
- Untracked `native.md` at the repo root is an obsolete earlier handoff (a pointer to this file); delete it.
|
||||
|
||||
Coordinator verification:169JVMtests pass; lint0errors/0warnings; E2EAPKs assemble; all8guards/sourcegate236pass. RealTC22 Echo native scenario completes all5stages with86/86documented semanticrequests,2accepted synthetic scans andsuccessfulcleanup. Printing remains simulated.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Echo native UI acceptance fixture
|
||||
|
||||
The explicit Echo scenario exercises the real Android host, TABLE login, enrollment/approval, semantic printer/media/document choices, real Echo process and H2 persistence. Its test-only backend accepts **simulated** jobs. Passing it does not qualify EMDK, barcode rendering, printer adapters or physical output.
|
||||
|
||||
The backend owner supplies `com.kingsrook.examples.echo.fixture.EchoHttpFixture` from the isolated optional Echo test module, launched with a free loopback port, `ECHO_FIXTURE_TYPE=echo-ui-e2e` and private `ECHO_FIXTURE_SCANNER_PASSWORD`/`ECHO_FIXTURE_ADMIN_PASSWORD`. Readiness is `ECHO_UI_E2E_READY port=N simulatedPrinting=true`. It uses in-memory H2 and no real printing/generation adapter. Root owns launch, the exact private dependency tuple and approval of the concrete APK/fixture cohort.
|
||||
|
||||
The credential file must stay private and contain these fields with newly generated passwords of at least 16 characters:
|
||||
|
||||
```json
|
||||
{
|
||||
"fixtureType": "echo-ui-e2e",
|
||||
"simulatedPrinting": true,
|
||||
"baseUrl": "http://127.0.0.1:PRIVATE_FREE_PORT/",
|
||||
"username": "scanner",
|
||||
"password": "PRIVATE_GENERATED_PASSWORD",
|
||||
"adminUsername": "admin",
|
||||
"adminPassword": "PRIVATE_GENERATED_PASSWORD"
|
||||
}
|
||||
```
|
||||
|
||||
The runner refuses normal scanner ports 57832/51631 and printing ports 18081/18086, non-loopback/decorated URLs, the wrong purpose, unsimulated fixtures, incompatible core/mobile contracts and branding other than `Echo UI E2E — simulated printing`. It requires UI API1.10.0/mobile-scanners1.4.0 and checks APK package identities. Only a selected physical Zebra TC22 and absent disposable `io.qrun.mobile.scanner.e2e`/`.test` packages are accepted. Normal and QA packages/data remain untouched. Location reporting is disabled only in the new disposable package, and no screenshots are taken.
|
||||
|
||||
Preparation requires no device execution:
|
||||
|
||||
```text
|
||||
node --test scripts/ci/scanner-echo-guards.test.mjs
|
||||
./gradlew :scanner-host:testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest
|
||||
```
|
||||
|
||||
Only after root launches the private fixture and approves the exact cohort:
|
||||
|
||||
```text
|
||||
node scripts/ci/scanner-device-echo-e2e.mjs --serial TC22_SERIAL --fixture PRIVATE_ECHO_FIXTURE.json
|
||||
```
|
||||
|
||||
The native test signs in, enrolls and approves through existing helpers; opens Apps → Echo; selects the printer, media and an INTEGER Document through semantic possible-value controls, reselects the printer to verify stale dependent selections are cleared and Next is refused until reselection, then retains all three choices; injects two public synthetic values with CODE128/QR types through the E2E-only driver; checks `Print queued` and absent manual controls; rotates/recreates without another acceptance; then uses Apps/Leave and verifies the run closes. Independent reads use standard QQQ `echoScan` and read-only `echoFixtureRun` queries as the scanner with approved-device headers, retaining the real actor/device locks. No custom business verification endpoint or administrator bypass is used.
|
||||
|
||||
The existing OpenAPI proxy audits all observed requests and requires native cancellation. Fixed stage markers and operation IDs/counts, APK hashes and a simulation-labelled `echo-proof.json` are written under `scanner-host/build/native-device-e2e/`; credentials, serial and opaque identifiers are redacted from instrumentation output. No normal app data, global animation settings, DataWedge profile or existing forwarding is replaced. The runner removes only its own reverse and newly installed disposable packages; cleanup failures fail the run and its proof.
|
||||
|
||||
The user-authorized TC22 invocation completed successfully after the bounded compilation/guards. This fixture remains simulated printing, not physical printing evidence.
|
||||
|
||||
Current recorded acceptance: all7 stages passed on TC22, including Document selection and stale selection clearing. Root verified99 focused/host JVM tests,8 Node guards, zero lint errors/warnings, and112/112 documented native requests. Printing is simulated. The proof explicitly leaves physicalScannerQualified and physicalPrintingQualified false.
|
||||
@@ -1,5 +1,50 @@
|
||||
## November 2 — current checkpoint
|
||||
|
||||
Source candidate: immutable `ScannerCapture` retains SDK raw bytes as canonical Base64 before the callback returns and routes them through the ordinary semantic process payload. New optional fields are sent only after same-server discovery advertises mobile-scanners >=1.5,<2; older servers still receive the five legacy keys. Barcode text and symbology remain unchanged, scan/capture string representations are redacted, and a failed-submit regression proves the entire event is resent unchanged. Verification passes 185 JVM tests (44 core, 56 mobileapp, 85 host), lint with zero errors/warnings (two existing informational hints), debug/E2E APK builds and QA Android-test compilation. New native tests and operator installation have not run. The SDK configuration is unchanged and AIM extraction is not implemented by this transport slice.
|
||||
|
||||
Completed and verified:
|
||||
|
||||
- Android source `fb3cc5d` is signed and published as Gitea PR 10. It passes 174 JVM tests (44 core, 55 mobileapp, 75 host), lint with zero errors and zero warnings, and the debug APK build.
|
||||
- The native `SameOriginInterceptor` fixes the cookie-bearing heartbeat that returned 403. The server's same-origin guard is not weakened.
|
||||
- The normal TC22 installation was updated without clearing data. Existing login and device approval, heartbeat, the real print client's printer, fresh 4x1 media and published document selection, and Cancel then Leave all work against the one local server.
|
||||
- That server is one QQQ demo on host port 57832. It 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.
|
||||
- The Printing owner has aligned the loaded 4x1 stock (101600 x 25400 microns at 300 dpi) and updated the real media revision. A real Echo 4x1 barcode document is published. The TC22 selected the actual printer, the fresh 4x1 media and that document, and Next succeeded and shows Scanner ready and Connected.
|
||||
- The emulator is off because it stalls this workstation. Do not restart it automatically.
|
||||
|
||||
Not yet done:
|
||||
|
||||
- No physical Echo print or rescan has been proven. One separate synthetic HTTP Echo event reached the actual Windows spooler; physical paper and handheld print/rescan remain unconfirmed. One physical scan of QQQ-TEST-123456 has been requested from the user.
|
||||
- Still open for 1.0: physical Code128 and leading-zero QR scan, print and rescan; full 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. The first demo working 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 the printer media report, the 4x1 document or the same-origin fix as pending, this checkpoint supersedes them.
|
||||
|
||||
## November 2 — native cookie Origin compatibility verified on TC22
|
||||
|
||||
The combined Echo Demo on57832 (reached through device port8080) returned200 for login, then403 for the cookie-bearing session heartbeat POST, because Android's native QQQ requests omitted `Origin`. The server's same-origin guard remains enforced. Core now has a small `SameOriginInterceptor`: it sets `Origin` from the actual request URL's canonical scheme/host/port only (default ports omitted, IPv6 bracketed, never credentials/input/headers) and is installed last so additional headers cannot override it. It is used by `NetworkQQQRepository` and the `DeviceApi`, `MobileSessionApi` and `RecordFilesApi` clients. Version discovery, presentation/logo fetches, OIDC/browser/token requests and redirect refusal are unchanged.
|
||||
|
||||
All174 JVM tests passed (44core/55mobileapp/75host), with zero failures/errors/skips. Lint has zero errors/warnings and two existing informational hints; debug APK build passed. MockWebServer tests cover exact Origin plus cookie on real POSTs, canonicalization, override resistance and redirect non-forwarding. Session and record-file host tests assert the header on their actual requests.
|
||||
|
||||
The verified APK (SHA256 `0B872005B2FA84F693E73CC49133F5C1C02F732A034AA0635A2B50682A61A98E`) was installed on the normal TC22 without clearing its data. Existing scanner login and device approval work against the actual combined server; heartbeat remains connected. Echo opens its three required choices, the approved printer and its reported media load, and Cancel -> Leave returns to the app normally. This directly qualifies setup/session/cancel against the strict host, not a physical print. The earlier112-request/two-synthetic-scan acceptance remains separate. (Superseded: the printing owner has since reported the 4x1 media and the 4x1 document is published; see the current checkpoint above. Physical printing is still unproven.) Emulator stays off.
|
||||
|
||||
## November 1 — required Document choice verified on the real TC22
|
||||
|
||||
Echo asks for printer, paper/label size and Document once; all subsequent scans use those server-pinned choices. Android production code is unchanged: the generic semantic INTEGER/PVS/dependency controls handle it. The updated separate E2E app passed all7 stages on the actual TC22 (select Document, clear stale choices on parent change, validate required fields, queue two scans, recreate without duplication, Apps/Leave/CLOSED). All112 requests matched published semantic operations. Disposable app/reverse/fixture cleanup passed, normal app/data preserved, emulator off. Printing and scanner inputs are simulated; physicalScannerQualified/physicalPrintingQualified remain false.
|
||||
|
||||
Verification:99 focused/host JVM tests,8 Node guards, zero lint errors/warnings and both disposable APKs compiled; after a test-only fixed-footer click correction, the actual TC22 acceptance passed. Companion server source8da274e is signed/pushed with45 library and24 composed demo tests passing. Private live server53118 still has header fix101efc8 pending document-choice rollout; real printer pairing retry awaits the current Windows consent. Installed normal APK324227CC... remains sourcea2f0080, whose production code is identical to this test-only change. Full physical/provider/camera/GPS/projection/public artifact/signing gates remain open.
|
||||
## November 1 — Echo native semantic choices verified on TC22
|
||||
|
||||
Claude implemented named process possible values using the actual published `searchProcessPossibleValues` JSON contract, dependent-field metadata, cancellation/error/retry and stale-response rejection. The unchanged Echo fixture now completes login, enrollment/approval, printer/media selection once, two accepted synthetic scans, rotation/recreation without duplicates, and Apps/Leave closing the run on the real TC22. All86observed requests matched published semantic operations. Disposable-package cleanup passed; normal package/data and real printers were preserved.
|
||||
|
||||
Validation:169JVMtests,8Nodeguards,236-file source standards, zero lint errors/warnings and bothE2EAPKs pass. This is simulated printing, not EMDK or physical output qualification. The remaining immediate blocker is the real Generation/Printing adapter and template/host composition, followed by physical print/rescan. Normal installed APK remains625a275; normal server API1.9/mobile1.1. Production signing remains deferred.
|
||||
# Session state
|
||||
|
||||
## Echo semantic presentation source checkpoint
|
||||
|
||||
The optional extension 1.4 acknowledgmentLabel and manualEntryEnabled hints are published on feature/echo-scanner-presentation (draft PR9). Existing defaults remain Saved scan/manual enabled; Echo uses Print queued/manual disabled. Strict compilation, 167 JVM cases, lint zero errors/warnings and debug assembly pass. Hardware, lifecycle and receipt predicates are unchanged. The server companion passes 69 consumer cases plus two contract cases; the optional Echo module passes 15 strict QQQ/H2 tests.
|
||||
|
||||
The normal TC22 remains the separately installed 625a275 build (APK AF13778A…3E9216), preserving sign-in/data. Its About screen was opened and Query artwork, both public links and licenses verified through filtered accessibility data. New Echo-native UI/actual owner adapters/physical printing remain unqualified; printing and document/scripting owners have acknowledged their implementation lanes. The running normal server remains API1.9/mobile1.1. Production signing/distribution stays the owner's final step.
|
||||
|
||||
## November 1 standards fixes and About update
|
||||
|
||||
Applied the scoped standards review: copyright/SPDX, explicit imports, declaration headers, native official/three-space formatter profile, mechanical CI source gate and Kotlin warnings-as-errors. Strict compilation and163 JVM tests pass; all46 disposable sample native tests pass on TC22, and production QA instrumentation compiles. The native suite uses fixture providers; actual Android external-browser/provider acceptance remains separate. The existing formatter reports one unstable Gradle script and full formatter CI runner provisioning remains explicit in the review.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# TODO
|
||||
|
||||
- [x] Implement negotiated mobile1.5 raw-byte transport with immutable capture, same-server capability gating and byte-identical failed-request retry;185 JVM tests, lint0errors/0warnings, debug/E2E builds and QA test compilation pass.
|
||||
- [ ] Qualify new raw-byte delivery on TC22 and complete actual AIM extraction/decoder-profile checks. Current source leaves AIM absent and does not change hardware configuration; full Echo fidelity is still open.
|
||||
- [x] Verify native `Origin` compatibility:174 JVM tests, lint0errors0warnings and APK build pass; normal TC22 login/approval/heartbeat/Echo setup/printer-media choices/Cancel-Leave pass against the strict local Echo host through8080->57832, preserving data.
|
||||
|
||||
- [x] Verify generic INTEGER Document choice/dependency clearing on actual TC22:7 stages,112 semantic requests,99 focused/host tests and8 Node guards; simulated printing, normal data preserved.
|
||||
- [x] Connect the installed print client to the single local server and publish the actual Echo4x1 document through native QQQ processes.
|
||||
- [x] Resolve the printing agent's earlier 4x6 media report versus loaded 4x1 stock: the Printing owner aligned 101600 x 25400 micron/300 dpi media and the TC22 selected the real printer, fresh 4x1 media and published document; Next succeeded.
|
||||
- [ ] Complete one physical Code128 (QQQ-TEST-123456) scan, print and rescan on the TC22, then leading-zero QR. One separate synthetic HTTP Echo event reached the actual Windows spooler; physical paper and handheld print/rescan remain unconfirmed.
|
||||
- [ ] Qualify public coherent artifacts and primary Gitea CI; signing and distribution stay last by user decision. Core PR 1337 normal CI and publication are not established; do not repin default dependencies.
|
||||
|
||||
- [x] Add metadata-driven Echo acknowledgment/manual-entry hints with old defaults; 167 JVM tests, strict compilation, lint zero errors/warnings and debug build pass.
|
||||
- [ ] Qualify native Echo scan/print-acceptance with independently decoded physical output (setup and Leave against the real owner adapters are verified; source tests are not physical printing evidence).
|
||||
- [x] Verify Query artwork, Dogfooding/Query links and licenses on the normal TC22 About screen using filtered accessibility data.
|
||||
|
||||
- [x] Apply scoped copyright/import/header/formatter corrections, mechanical CI gate and Kotlin warnings-as-errors;163JVM plus46TC22 sample native cases pass. See [review](CODING-STANDARDS-REVIEW-2026-11-01.md) for the remaining formatter CI limitation.
|
||||
- [x] Add Query artwork, app version, Dogfooding/Query links and license access under Settings → About QQQ Mobile.
|
||||
- [x] Qualify real Authentik desktop nonce rejection and retained-browser A → company logout → fresh B; actual Android callback acceptance remains separate.
|
||||
@@ -87,3 +101,7 @@ Offline queue/background synchronization is deliberately outside version one.
|
||||
- [x] Add and run real-server scanner-host E2E: login, pending enrollment, approval, native app/process selection, manual scan persistence and revoked-device denial. Use an isolated app ID and database.
|
||||
- [x] Run and verify native real-server E2E on pushes and pull requests; current7de hosted checks pass.
|
||||
- [ ] Complete the final operator walkthrough on TC22 after updating the preserved demo/runtime, including outage/reconnect and real camera capture.
|
||||
|
||||
- [x] Implement named semantic process choices and qualify Echo native setup/two scans/rotation/Apps Leave onTC22 (169JVM,8guards,86documentedrequests; printing simulated).
|
||||
- [x] Real Echo host and owner adapters are composed and running in the single local server (see the Origin item above).
|
||||
- [ ] Print a physical label and rescan the exact value and type (tracked above).
|
||||
+7
-3
@@ -18,7 +18,7 @@ The host first requests `GET /qqq/v1/version` through a public client with no se
|
||||
|
||||
Compatibility is cached for the selected server's host lifetime and isolated between server origins. Missing features/extensions or Android channel, malformed discovery or unsupported versions stop startup with an explanation and retry option. Unknown optional properties and feature names are tolerated; supported enum models retain an unknown value so an added enum does not crash JSON decoding. Unknown authentication providers and required UI controls remain explicitly unsupported.
|
||||
|
||||
The app sends `X-QQQ-Client: android/qqq-mobile`. The QQQ software release and semantic API version are distinct: the local demo currently uses QQQ `4.1.0-RC.20` and advertises semantic API `1.8.0`.
|
||||
The app sends `X-QQQ-Client: android/qqq-mobile`. The QQQ software release and semantic API version are distinct: clients use discovery rather than inferring compatibility from artifact versions. Current installed-runtime and source-qualification checkpoints are recorded in [session state](SESSION-STATE.md).
|
||||
|
||||
## API operations
|
||||
|
||||
@@ -46,14 +46,14 @@ Core structured errors retain their code, message, field errors and trace ID. Fi
|
||||
| --- | --- |
|
||||
| App navigation | Server-defined apps and process links, including root processes and section app links |
|
||||
| Process lifecycle | Init, step submission, asynchronous polling, server-directed completion and backend cancellation |
|
||||
| `EDIT_FORM` | Metadata field subsets, labels, hidden/editable/required flags, text/password, Boolean, integer/long/decimal, inline possible values and validation errors; explicit null clearing is preserved |
|
||||
| `EDIT_FORM` | Metadata field subsets, labels, hidden/editable/required flags, text/password, Boolean, integer/long/decimal, inline possible values, named possible-value sources searched through the core operation `searchProcessPossibleValues` (`POST /qqq/v1/processes/{process}/possibleValues/{field}`, JSON `values`/`processValues`/`useCase: form`; contingent fields honor `possibleValueSourceDependsOn` and `possibleValueSourceDependencyBehavior`, re-query on parent change and drop stale selections) and validation errors; explicit null clearing is preserved |
|
||||
| Date/time fields | ISO text inputs with format hints and validation; no native date/time picker |
|
||||
| `VIEW_FORM` | Read-only values for the declared field subset |
|
||||
| Dynamic metadata | Updated frontend steps and updated fields applied to the active process |
|
||||
| `HELP_TEXT`, `HTML` | Plain semantic text, with text fallback for markup; no embedded web application |
|
||||
| `PROCESS_SUMMARY_RESULTS` | Status, counts and messages; link destinations displayed as text, without assuming a web route is a native action |
|
||||
| Composite/ad-hoc widgets | Existing text, button, input, image, audio, divider and nested composite controls |
|
||||
| `SCANNER` extension block | Native DataWedge/manual input; server title, instructions, labels and maximum length; standard process submission and acknowledgment |
|
||||
| `SCANNER` extension block | Trusted Zebra EMDK/manual input; server title, instructions, labels and maximum length; standard process submission and acknowledgment |
|
||||
| `RECORD_FILES_CAPTURE` extension block | Server-selected parent record; optional Record Files 1.x capability discovery; native camera/photo picker; checksummed resumable online upload and exact committed receipt |
|
||||
| Unsupported required controls | Explicit unsupported message; Next and process widget submission blocked |
|
||||
|
||||
@@ -84,3 +84,7 @@ JVM checks cover compatibility rejection before credentials, origin isolation, a
|
||||
The native E2E runner in [EMULATOR.md](EMULATOR.md) forwards the real host's requests through a test-only OpenAPI audit. Scenarios cover incompatible core major, missing extension, missing Android channel, the real supported server scan flow, additive future metadata, native administrative forms, session activity/viewing/forced logout, and actual PixelCopy image contents. The audit records operation IDs and safe request metadata, never headers or bodies. Passing runs must prove that all observed requests match published operations and rejected servers receive no credentials. Precise revision boundaries and results are tracked in [SESSION-STATE.md](SESSION-STATE.md); implementation of a check is not evidence that its latest run passed.
|
||||
|
||||
Server changes using existing supported components can reach the shared APK through metadata. New required native controls or an incompatible major contract require a shared client update and conformance tests. Customer name, theme and logo changes remain server configuration and do not require customer-specific builds. During this initial migration, update the companion server and APK together; preserve the demo database and install with `adb install -r` to retain local device enrollment.
|
||||
|
||||
## Echo scanner presentation hints
|
||||
|
||||
Mobile-scanners 1.4 adds optional SCANNER acknowledgmentLabel and manualEntryEnabled. Existing servers retain Saved scan and manual entry. Echo supplies Print queued and false, hiding manual controls because typed text has no decoder-provided symbology. Explicitly malformed labels or nonboolean flags disable the control. These presentation hints never change the matching event/scanSaved/scanId acknowledgment rules, and queue acceptance does not mean physical output completed. No application or process name selects this behavior.
|
||||
@@ -6,6 +6,8 @@ The app first requires the system's `com.symbol.emdk` shared library and enabled
|
||||
|
||||
## Lifecycle and process contract
|
||||
|
||||
When verified discovery for the current server advertises mobile-scanners 1.5 or newer within major 1, each capture can also send `rawDataBase64` and `aimIdentifier`. The EMDK callback encodes its exact `getRawData()` output once before returning; the immutable event survives HTTP retries unchanged. Older servers receive the legacy event fields. Raw input is bounded to 8192 bytes; null, empty or oversized raw input is omitted rather than truncated. These are SDK bytes, which may include framing, not a claim of original symbol codewords. Current hardware capture leaves AIM absent: no prefix inference or scanner configuration change is implemented. Decoder qualification and native raw-byte acceptance remain required for full Echo fidelity.
|
||||
|
||||
The adapter acquires the default scanner only while the server-defined SCANNER widget is visible and its Activity is resumed. HARD trigger reads are armed after IDLE, with one outstanding read. A decoded string is passed unchanged to the existing ScanEvent and semantic process submission, with its EMDK symbology and adapter-owned `zebra-emdk` source. New reads wait until the prior event is confirmed or explicitly abandoned by the existing process flow. Manual events remain `manual`. Neither source label is server-side attestation.
|
||||
|
||||
An IDLE callback with no pending read also rearms after a trigger release or timeout without a decode. If IDLE precedes the data callback, accepting that data blocks further input and cancels any read armed in between. A rapid pause/resume during an earlier asynchronous manager-open request can temporarily encounter the one-manager guard; **Reconnect scanner** retries explicitly after the prior manager has released. An unconfirmed release remains blocked until the app restarts.
|
||||
|
||||
+21
@@ -41,6 +41,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import retrofit2.Retrofit
|
||||
import java.util.TimeZone
|
||||
@@ -105,6 +106,8 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
||||
}
|
||||
response
|
||||
}
|
||||
// Last, so a caller-supplied additional header can never override the request's own origin.
|
||||
.addInterceptor(SameOriginInterceptor())
|
||||
.build()
|
||||
)
|
||||
.baseUrl(baseUrl)
|
||||
@@ -291,6 +294,24 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
||||
qqqApiServiceMap[baseUrl]!!.processCancel(processName, processUUID).close()
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Options use the semantic `{options:[{id,label}]}` response; ids stay JSON scalars so they round-trip unchanged.
|
||||
*******************************************************************************/
|
||||
override suspend fun processPossibleValues(processName: String, fieldName: String, values: Map<String, Any?>, processValues: Map<String, Any?>): List<com.kingsrook.qqq.frontend.android.core.model.QPossibleValue> {
|
||||
fun json(map: Map<String, Any?>) = JSONObject().apply { map.forEach { (key, value) -> put(key, value ?: JSONObject.NULL) } }
|
||||
val request = JSONObject().put("values", json(values)).put("processValues", json(processValues)).put("useCase", "form")
|
||||
val body = qqqApiServiceMap[baseUrl]!!.processPossibleValues(
|
||||
processName, fieldName, request.toString().toRequestBody("application/json".toMediaType())
|
||||
).use { JSONObject(it.string()) }
|
||||
// The server omits `options` when empty.
|
||||
val options = body.optJSONArray("options") ?: return emptyList()
|
||||
return (0 until options.length()).map { options.getJSONObject(it) }.map { option ->
|
||||
val id = option.get("id")
|
||||
com.kingsrook.qqq.frontend.android.core.model.QPossibleValue(
|
||||
id as? java.io.Serializable ?: id.toString(), option.optString("label", id.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Poll the exact process/job identity supplied by the server instead of assuming background completion.
|
||||
*******************************************************************************/
|
||||
|
||||
+10
@@ -164,6 +164,16 @@ interface QQQApiService {
|
||||
@Path(value = "jobUUID") jobUUID: String,
|
||||
): ProcessStepResult
|
||||
|
||||
/*******************************************************************************
|
||||
** Published operation `searchProcessPossibleValues`: JSON body {values, processValues, useCase, searchTerm, ids}.
|
||||
*******************************************************************************/
|
||||
@POST(BASE_PATH + "processes/{processName}/possibleValues/{fieldName}")
|
||||
suspend fun processPossibleValues(
|
||||
@Path(value = "processName") processName: String,
|
||||
@Path(value = "fieldName") fieldName: String,
|
||||
@Body body: RequestBody
|
||||
): ResponseBody
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancel the identified server process explicitly instead of dropping only client navigation.
|
||||
*******************************************************************************/
|
||||
|
||||
+7
@@ -123,6 +123,13 @@ interface QQQRepository {
|
||||
throw UnsupportedOperationException("Process cancellation is unavailable")
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Search a process field's server possible-value source (`searchProcessPossibleValues`) with the current form values
|
||||
** (`values`, used for dependsOn/filter inputs) and scalar process values, using the FORM use case.
|
||||
*******************************************************************************/
|
||||
suspend fun processPossibleValues(processName: String, fieldName: String, values: Map<String, Any?>, processValues: Map<String, Any?> = emptyMap()): List<com.kingsrook.qqq.frontend.android.core.model.QPossibleValue> =
|
||||
throw UnsupportedOperationException("Remote possible values are unavailable")
|
||||
|
||||
/*******************************************************************************
|
||||
** Poll the server-issued job within its process instance instead of inferring asynchronous completion.
|
||||
*******************************************************************************/
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package com.kingsrook.qqq.frontend.android.core.controllers
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/*******************************************************************************
|
||||
** Native requests have no browser to supply `Origin`, yet cookie-bearing QQQ mutations must present their own origin.
|
||||
** The value is always the canonical scheme/host/port of the actual request URL: never credentials, input or header state.
|
||||
** Install it only on clients that talk to the selected QQQ server, never on provider, logo or download clients, and
|
||||
** after any header-adding interceptor so that it has the final say.
|
||||
*******************************************************************************/
|
||||
class SameOriginInterceptor : Interceptor {
|
||||
/*******************************************************************************
|
||||
** Replace any existing `Origin` with the origin of the URL being requested; redirects stay refused by the clients.
|
||||
*******************************************************************************/
|
||||
override fun intercept(chain: Interceptor.Chain): Response =
|
||||
chain.proceed(chain.request().newBuilder().header("Origin", httpOrigin(chain.request().url)).build())
|
||||
|
||||
companion object {
|
||||
/*******************************************************************************
|
||||
** `scheme://host[:port]` with the scheme's default port omitted and IPv6 hosts bracketed.
|
||||
*******************************************************************************/
|
||||
fun httpOrigin(url: HttpUrl): String {
|
||||
val host = if (url.host.contains(':')) "[${url.host}]" else url.host
|
||||
val port = if (url.port == HttpUrl.defaultPort(url.scheme)) "" else ":${url.port}"
|
||||
return "${url.scheme}://$host$port"
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -44,6 +44,11 @@ class UiApiCompatibility(
|
||||
@Volatile
|
||||
private var verified: UiApiVersion? = null
|
||||
|
||||
/*******************************************************************************
|
||||
** Non-blocking view of an already verified result; never performs discovery, so it is safe on the UI thread.
|
||||
*******************************************************************************/
|
||||
fun verifiedVersion(): UiApiVersion? = verified
|
||||
|
||||
/*******************************************************************************
|
||||
** Call on an IO thread. Synchronization prevents competing startup requests bypassing discovery.
|
||||
*******************************************************************************/
|
||||
|
||||
+3
@@ -39,6 +39,9 @@ data class QFieldMetaData(
|
||||
val isHeavy: Boolean = false,
|
||||
val possibleValueSourceName: String? = null,
|
||||
val inlinePossibleValueSource: List<QPossibleValue>? = null,
|
||||
// Contingent fields only: DISABLE_UNTIL_SET (default when depending) or SHOW_ALL_UNTIL_SET.
|
||||
val possibleValueSourceDependsOn: List<String>? = null,
|
||||
val possibleValueSourceDependencyBehavior: String? = null,
|
||||
val displayFormat: String? = null,
|
||||
val adornments: List<FieldAdornment>? = null,
|
||||
val helpContents: List<QHelpContent>? = null,
|
||||
|
||||
+63
@@ -40,6 +40,37 @@ class NetworkQQQRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Published `searchProcessPossibleValues`: JSON body with the parent values and FORM use case; the response is
|
||||
** `{options:[{id,label}]}` and the server omits `options` entirely when nothing matches.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun processPossibleValuesUsesThePublishedJsonOperation() = runBlocking()
|
||||
{
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"options":[{"id":"4x6","label":"4 x 6 in"},{"id":7,"label":"Seven"}]}"""))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}"))
|
||||
val origin = server.url("/").toString()
|
||||
val repository = NetworkQQQRepository(origin)
|
||||
repository.setEnvironment(Environment("test", origin))
|
||||
val options = repository.processPossibleValues("echoPrint", "mediaId", mapOf("printerId" to "printer"), mapOf("echoRunId" to "run"))
|
||||
assertEquals("/qqq/v1/version", server.takeRequest().path)
|
||||
val request = server.takeRequest()
|
||||
assertEquals("POST", request.method)
|
||||
assertEquals("/qqq/v1/processes/echoPrint/possibleValues/mediaId", request.path)
|
||||
assertTrue(request.getHeader("Content-Type")!!.startsWith("application/json"))
|
||||
val body = org.json.JSONObject(request.body.readUtf8())
|
||||
assertEquals("printer", body.getJSONObject("values").getString("printerId"))
|
||||
assertEquals("run", body.getJSONObject("processValues").getString("echoRunId"))
|
||||
assertEquals("form", body.getString("useCase"))
|
||||
assertEquals(listOf("4x6", "7"), options.map { it.id.toString() })
|
||||
assertTrue("A JSON integer id must stay an integer for INTEGER fields", options[1].id is Int)
|
||||
assertEquals(listOf("4 x 6 in", "Seven"), options.map { it.label })
|
||||
assertTrue(repository.processPossibleValues("echoPrint", "mediaId", emptyMap(), emptyMap()).isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -109,6 +140,38 @@ class NetworkQQQRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Cookie-bearing native mutations (session/process POSTs) carry the exact request origin, and a caller-supplied
|
||||
** additional header can neither add a different Origin nor remove the real one. Login (no cookie yet) carries it too.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun cookieBearingPostsCarryTheExactRequestOriginEvenIfAdditionalHeadersTryToSetOne() = runBlocking()
|
||||
{
|
||||
MockWebServer().use { server ->
|
||||
server.start()
|
||||
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"type":"COMPLETE","processUUID":"process","nextStep":"result","values":{}}"""))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}"))
|
||||
val origin = server.url("/").toString()
|
||||
val repository = NetworkQQQRepository(origin, additionalHeaders = { mapOf("Origin" to "https://evil.example", "X-QQQ-Device-Id" to "test-device") })
|
||||
repository.setEnvironment(Environment("test", origin))
|
||||
repository.setSessionUUID("synthetic-session")
|
||||
repository.processStep("edit", "process", "input", mapOf("a" to "b"), 1000)
|
||||
repository.logout()
|
||||
server.takeRequest()
|
||||
// Forwarded debug servers may listen elsewhere (for example 8080): only the URL the client used matters.
|
||||
val expected = "http://${server.hostName}:${server.port}"
|
||||
listOf(server.takeRequest(), server.takeRequest()).forEach { request ->
|
||||
assertEquals("POST", request.method)
|
||||
assertEquals("sessionUUID=synthetic-session", request.getHeader("Cookie"))
|
||||
assertEquals(expected, request.getHeader("Origin"))
|
||||
assertEquals(1, request.headers.values("Origin").size)
|
||||
assertEquals("http://" + request.getHeader("Host"), request.getHeader("Origin"))
|
||||
assertEquals("test-device", request.getHeader("X-QQQ-Device-Id"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package com.kingsrook.qqq.frontend.android.core.controllers
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/*******************************************************************************
|
||||
** Native clients present the exact origin of the URL they actually request, canonicalized like a browser.
|
||||
*******************************************************************************/
|
||||
class SameOriginInterceptorTest {
|
||||
/*******************************************************************************
|
||||
** The origin comes only from scheme/host/port: a debug loopback forward keeps its device-side port
|
||||
** (whatever the server listens on behind it), defaults are omitted, and IPv6 hosts stay bracketed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun originIsTheCanonicalSchemeHostAndPortOfTheRequestUrl() {
|
||||
fun origin(url: String) = SameOriginInterceptor.httpOrigin(url.toHttpUrl())
|
||||
assertEquals("http://127.0.0.1:57832", origin("http://127.0.0.1:57832/qqq/v1/manageSession"))
|
||||
assertEquals("http://127.0.0.1", origin("http://127.0.0.1:80/"))
|
||||
assertEquals("https://qqq.example.test", origin("https://qqq.example.test/"))
|
||||
assertEquals("https://qqq.example.test", origin("https://qqq.example.test:443/path"))
|
||||
assertEquals("https://qqq.example.test:8443", origin("https://qqq.example.test:8443/"))
|
||||
assertEquals("http://qqq.example.test:443", origin("http://QQQ.Example.Test:443/"))
|
||||
assertEquals("http://[::1]:57832", origin("http://[::1]:57832/"))
|
||||
assertEquals("https://[2001:db8::1]", origin("https://[2001:db8::1]:443/"))
|
||||
assertEquals("Credentials, path, query and fragment are never part of an origin", "http://127.0.0.1:57832",
|
||||
origin("http://user:secret@127.0.0.1:57832/a/b?token=x#fragment"))
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The header the interceptor sets is final, even when an earlier layer tried to supply a different one.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun overridesAnyExistingOriginAndMatchesTheServerRequestHost() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody("{}"))
|
||||
server.start()
|
||||
val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).addInterceptor(SameOriginInterceptor()).build()
|
||||
client.newCall(Request.Builder().url(server.url("/")).header("Origin", "https://evil.example").build()).execute().close()
|
||||
val request = server.takeRequest()
|
||||
assertEquals("http://" + request.getHeader("Host"), request.getHeader("Origin"))
|
||||
assertEquals(1, request.headers.values("Origin").size)
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A redirect is still refused, so neither the cookie nor this origin reaches another server.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun refusedRedirectNeverReachesAnotherOrigin() {
|
||||
MockWebServer().use { first ->
|
||||
MockWebServer().use { other ->
|
||||
first.start()
|
||||
other.start()
|
||||
first.enqueue(MockResponse().setResponseCode(307).setHeader("Location", other.url("/stolen").toString()))
|
||||
val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).addInterceptor(SameOriginInterceptor()).build()
|
||||
client.newCall(Request.Builder().url(first.url("/")).header("Cookie", "sessionUUID=synthetic").build()).execute().use { assertEquals(307, it.code) }
|
||||
assertEquals(1, first.requestCount)
|
||||
assertEquals(0, other.requestCount)
|
||||
assertNull(other.takeRequest(100, java.util.concurrent.TimeUnit.MILLISECONDS))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -78,17 +78,25 @@ fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFro
|
||||
fun change(newValue: Any?) {
|
||||
value = newValue
|
||||
processViewModel.processValues[field.name] = newValue
|
||||
// Dependent possible-value sources (for example media for a printer) re-query, disable or drop their selection.
|
||||
processViewModel.fieldChanged(field.name)
|
||||
}
|
||||
val remote = field.possibleValueSourceName != null && field.inlinePossibleValueSource.isNullOrEmpty()
|
||||
val blocked = processViewModel.remoteOptionsBlockedReason(field)
|
||||
val reset = processViewModel.fieldResets[field.name]
|
||||
LaunchedEffect(processViewModel.stepInstanceCounter, field.name) {
|
||||
if (!processViewModel.processValues.containsKey(field.name)) processViewModel.processValues[field.name] = value
|
||||
if (field.isEditable) processViewModel.registerFormFieldName(field.name)
|
||||
if (remote) processViewModel.loadRemoteOptions(field)
|
||||
}
|
||||
// The view model cleared this selection because its controlling field changed or the choice no longer exists.
|
||||
LaunchedEffect(reset) { if (reset != null) value = processViewModel.processValues[field.name] }
|
||||
Column(Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
val options = field.inlinePossibleValueSource
|
||||
if (!options.isNullOrEmpty()) {
|
||||
val options = if (remote) processViewModel.remoteOptions[field.name].orEmpty() else field.inlinePossibleValueSource.orEmpty()
|
||||
if (remote || options.isNotEmpty()) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Text(field.label)
|
||||
OutlinedButton(onClick = { expanded = true }, enabled = enabled) {
|
||||
OutlinedButton(onClick = { expanded = true }, enabled = enabled && blocked == null) {
|
||||
Text(options.firstOrNull { it.id.toString() == value?.toString() }?.label ?: "Choose a value")
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
@@ -114,6 +122,11 @@ fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFro
|
||||
)
|
||||
}
|
||||
if (error != null) Text(error, color = MaterialTheme.colorScheme.error)
|
||||
blocked?.let { Text(it) }
|
||||
processViewModel.remoteOptionErrors[field.name]?.let { message ->
|
||||
Text(message, color = MaterialTheme.colorScheme.error)
|
||||
OutlinedButton(onClick = { processViewModel.loadRemoteOptions(field) }) { Text("Retry choices") }
|
||||
}
|
||||
semanticFieldHint(field)?.let { Text(it) }
|
||||
field.helpContents.orEmpty().forEach { Text(it.contentAsText ?: plainText(it.content)) }
|
||||
}
|
||||
|
||||
+87
@@ -34,6 +34,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository
|
||||
import com.kingsrook.qqq.frontend.android.core.model.QPossibleValue
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendStepMetaData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QProcessMetaData
|
||||
@@ -69,6 +71,86 @@ open class ProcessViewModel : ViewModel() {
|
||||
private set
|
||||
private var processComplete: Boolean by mutableStateOf(false)
|
||||
private var initializing = false
|
||||
|
||||
/** Options from server possible-value sources (`searchProcessPossibleValues`), keyed by field name. */
|
||||
val remoteOptions = mutableStateMapOf<String, List<QPossibleValue>>()
|
||||
val remoteOptionErrors = mutableStateMapOf<String, String>()
|
||||
|
||||
/** Incremented when this model clears a field's value, so the rendered control can follow. */
|
||||
val fieldResets = mutableStateMapOf<String, Int>()
|
||||
private var dependencyVersion by mutableIntStateOf(0)
|
||||
private val remoteOptionJobs = mutableMapOf<String, Job>()
|
||||
private val remoteOptionGenerations = mutableMapOf<String, Int>()
|
||||
|
||||
/*******************************************************************************
|
||||
** Fields whose choices come from a named possible-value source rather than inline metadata.
|
||||
*******************************************************************************/
|
||||
private fun isRemote(field: QFieldMetaData) = field.possibleValueSourceName != null && field.inlinePossibleValueSource.isNullOrEmpty()
|
||||
|
||||
/*******************************************************************************
|
||||
** DISABLE_UNTIL_SET (the default for contingent fields) offers no choices until every declared parent has a value.
|
||||
*******************************************************************************/
|
||||
fun remoteOptionsBlockedReason(field: QFieldMetaData): String? {
|
||||
dependencyVersion // process values are not observable state; this makes dependent controls recompose
|
||||
if (!isRemote(field) || field.possibleValueSourceDependencyBehavior == "SHOW_ALL_UNTIL_SET") return null
|
||||
val missing = field.possibleValueSourceDependsOn.orEmpty().filter { processValues[it].let { value -> value == null || value.toString().isEmpty() } }
|
||||
if (missing.isEmpty()) return null
|
||||
val labels = missing.map { name -> activeStep?.formFields?.firstOrNull { it.name == name }?.label ?: name }
|
||||
return "Choose ${labels.joinToString(" and ")} first."
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Search the field's server source with the current form values. Only the newest request may publish, so a late
|
||||
** response for a replaced parent value can never restore choices (or a selection) that no longer apply.
|
||||
*******************************************************************************/
|
||||
fun loadRemoteOptions(field: QFieldMetaData) {
|
||||
if (!isRemote(field)) return
|
||||
val generation = (remoteOptionGenerations[field.name] ?: 0) + 1
|
||||
remoteOptionGenerations[field.name] = generation
|
||||
remoteOptionJobs.remove(field.name)?.cancel()
|
||||
remoteOptionErrors.remove(field.name)
|
||||
remoteOptions.remove(field.name)
|
||||
if (remoteOptionsBlockedReason(field) != null) return
|
||||
|
||||
val formNames = activeStep?.formFields.orEmpty().map { it.name }.toSet()
|
||||
val values = formNames.filter { it != field.name }.associateWith { processValues[it] }
|
||||
val scalars = processValues.filter { (key, value) -> key !in formNames && (value is String || value is Number || value is Boolean) }
|
||||
remoteOptionJobs[field.name] = viewModelScope.launch {
|
||||
try {
|
||||
val options = qqqRepository.processPossibleValues(processName, field.name, values, scalars)
|
||||
if (remoteOptionGenerations[field.name] != generation) return@launch
|
||||
remoteOptions[field.name] = options
|
||||
val chosen = processValues[field.name]
|
||||
if (chosen != null && options.none { it.id.toString() == chosen.toString() }) clearSelection(field.name)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (remoteOptionGenerations[field.name] != generation) return@launch
|
||||
remoteOptionErrors[field.name] = semanticError(e, "The available choices could not be loaded. Try again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** After a field changes, dependents re-query (or disable) and drop selections that belonged to the old parent value.
|
||||
*******************************************************************************/
|
||||
fun fieldChanged(name: String) {
|
||||
dependencyVersion++
|
||||
for (dependent in activeStep?.formFields.orEmpty().filter { isRemote(it) && name in it.possibleValueSourceDependsOn.orEmpty() }) {
|
||||
if (processValues[dependent.name] != null) clearSelection(dependent.name)
|
||||
loadRemoteOptions(dependent)
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear a selection that no longer fits, then apply the same rule to fields depending on it.
|
||||
*******************************************************************************/
|
||||
private fun clearSelection(name: String) {
|
||||
processValues[name] = null
|
||||
fieldResets[name] = (fieldResets[name] ?: 0) + 1
|
||||
fieldChanged(name)
|
||||
}
|
||||
|
||||
private val clientWork = mutableStateMapOf<String, String>()
|
||||
val clientWorkBlockReason: String? get() = clientWork.values.firstOrNull()
|
||||
|
||||
@@ -248,6 +330,11 @@ open class ProcessViewModel : ViewModel() {
|
||||
*******************************************************************************/
|
||||
fun reset() {
|
||||
viewModelScope.coroutineContext.cancelChildren()
|
||||
remoteOptionJobs.clear()
|
||||
remoteOptionGenerations.clear()
|
||||
fieldResets.clear()
|
||||
remoteOptions.clear()
|
||||
remoteOptionErrors.clear()
|
||||
initializing = false
|
||||
unconfirmedSubmission = false
|
||||
clientWork.clear()
|
||||
|
||||
+2
-2
@@ -58,8 +58,8 @@ internal object SemanticProcess {
|
||||
QComponentType.HELP_TEXT, QComponentType.HTML, QComponentType.PROCESS_SUMMARY_RESULTS, QComponentType.VIEW_FORM -> Unit
|
||||
QComponentType.EDIT_FORM -> {
|
||||
val unsupportedField = fields(step, component).firstOrNull {
|
||||
it.type.name in setOf("BLOB", "UNKNOWN") ||
|
||||
(it.possibleValueSourceName != null && it.inlinePossibleValueSource == null)
|
||||
// Named possible-value sources are resolved through the semantic possibleValues operation.
|
||||
it.type.name in setOf("BLOB", "UNKNOWN")
|
||||
}
|
||||
if (unsupportedField != null) return "${unsupportedField.label} requires an input control unavailable in this client."
|
||||
}
|
||||
|
||||
+236
@@ -418,6 +418,37 @@ class ProcessSubmissionTest {
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The optional fidelity keys are ordinary undeclared event values; a lost response must resend all of them.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun failedScanSubmissionResendsTheSameEntireEventMapIncludingFidelityValues() = runTest()
|
||||
{
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
try {
|
||||
val repository = SubmissionRepository().apply { stepFailures = 1 }
|
||||
val model = model(repository)
|
||||
model.doInit()
|
||||
advanceUntilIdle()
|
||||
val event = mapOf(
|
||||
"barcode" to "000123", "symbology" to "LABEL-TYPE-QRCODE", "scanSource" to "zebra-emdk",
|
||||
"eventId" to "scan-1", "capturedAt" to "2026-01-01T00:00:00Z",
|
||||
"aimIdentifier" to "]Q1", "rawDataBase64" to "AAECAw=="
|
||||
)
|
||||
model.runStep(event, ProcessAcknowledgment("acknowledgedEventId", "scan-1"))
|
||||
advanceUntilIdle()
|
||||
assertTrue(model.unconfirmedSubmission)
|
||||
assertEquals(event, model.pendingStepValues)
|
||||
repository.responseValues = mapOf("acknowledgedEventId" to "scan-1")
|
||||
model.retryPendingStep()
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(event, event), repository.submissions)
|
||||
assertNull(model.pendingStepValues)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -525,6 +556,192 @@ class ProcessSubmissionTest {
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Echo-style setup: named possible-value sources are resolved through the repository, and dependent
|
||||
** choices are requested with the current parent value rather than being rendered as free text.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun namedPossibleValueSourcesLoadWithDependentValuesAndReportFailures() = runTest()
|
||||
{
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
try {
|
||||
// As published by the server: media declares its explicit dependency on printerId (default DISABLE_UNTIL_SET).
|
||||
val printer = QFieldMetaData("printerId", "Printer", type = QFieldType.STRING, isRequired = true, possibleValueSourceName = "printers")
|
||||
val media = QFieldMetaData(
|
||||
"mediaId", "Paper or label size", type = QFieldType.STRING, isRequired = true, possibleValueSourceName = "media",
|
||||
possibleValueSourceDependsOn = listOf("printerId"), possibleValueSourceDependencyBehavior = "DISABLE_UNTIL_SET"
|
||||
)
|
||||
val repository = SubmissionRepository().apply {
|
||||
inputOverride = QFrontendStepMetaData("input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), formFields = listOf(printer, media))
|
||||
}
|
||||
val model = model(repository)
|
||||
model.doInit()
|
||||
advanceUntilIdle()
|
||||
assertNull("Remote choices are supported, not an unsupported control", model.unsupportedReason())
|
||||
|
||||
model.loadRemoteOptions(printer)
|
||||
model.loadRemoteOptions(media)
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("Simulated printer"), model.remoteOptions["printerId"]?.map { it.label })
|
||||
assertEquals("Choose Printer first.", model.remoteOptionsBlockedReason(media))
|
||||
assertEquals("A blocked dependent field is not searched", listOf("printerId"), repository.possibleValueRequests.map { it.first })
|
||||
|
||||
model.processValues["printerId"] = "printer"
|
||||
model.fieldChanged("printerId")
|
||||
advanceUntilIdle()
|
||||
assertNull(model.remoteOptionsBlockedReason(media))
|
||||
assertEquals(listOf("4 x 6 in"), model.remoteOptions["mediaId"]?.map { it.label })
|
||||
assertEquals("printer", repository.possibleValueRequests.last().second["printerId"])
|
||||
assertFalse("The requested field's own value is not sent as a dependency", repository.possibleValueRequests.last().second.containsKey("mediaId"))
|
||||
|
||||
// Changing the parent drops the dependent selection immediately, and a late reply for the old parent is ignored.
|
||||
model.processValues["mediaId"] = "4x6"
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
repository.possibleValueGate = gate
|
||||
model.loadRemoteOptions(media)
|
||||
runCurrent()
|
||||
model.processValues["printerId"] = "other"
|
||||
model.fieldChanged("printerId")
|
||||
assertNull("Stale child selection is cleared when its parent changes", model.processValues["mediaId"])
|
||||
assertEquals(1, model.fieldResets["mediaId"])
|
||||
assertNull(model.remoteOptions["mediaId"])
|
||||
repository.possibleValueGate = null
|
||||
gate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
assertEquals("Only the newest request may publish", emptyList<String>(), model.remoteOptions["mediaId"]?.map { it.label })
|
||||
|
||||
// A failed load is reported without blocking Cancel, and can be retried.
|
||||
repository.possibleValueFailure = true
|
||||
model.loadRemoteOptions(media)
|
||||
advanceUntilIdle()
|
||||
assertNull(model.remoteOptions["mediaId"])
|
||||
assertNotNull(model.remoteOptionErrors["mediaId"])
|
||||
assertNull(model.clientWorkBlockReason)
|
||||
repository.possibleValueFailure = false
|
||||
model.processValues["printerId"] = "printer"
|
||||
model.loadRemoteOptions(media)
|
||||
advanceUntilIdle()
|
||||
assertNull(model.remoteOptionErrors["mediaId"])
|
||||
assertEquals(listOf("4 x 6 in"), model.remoteOptions["mediaId"]?.map { it.label })
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Setup chain printer -> media -> document, where the document is an INTEGER-keyed choice depending on both parents.
|
||||
** Ancestor changes must clear the whole stale suffix, block Next until reselection, keep the integer id typed on
|
||||
** submission, and never let a late reply for the old parents restore the old document.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun integerDocumentChoiceDependsOnPrinterAndMediaAndNeverSurvivesAParentChange() = runTest()
|
||||
{
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
try {
|
||||
fun choice(id: Int, label: String) = com.kingsrook.qqq.frontend.android.core.model.QPossibleValue(id, label)
|
||||
val printer = QFieldMetaData("printerId", "Printer", type = QFieldType.STRING, isRequired = true, possibleValueSourceName = "printers")
|
||||
val media = QFieldMetaData(
|
||||
"mediaId", "Paper or label size", type = QFieldType.STRING, isRequired = true, possibleValueSourceName = "media",
|
||||
possibleValueSourceDependsOn = listOf("printerId"), possibleValueSourceDependencyBehavior = "DISABLE_UNTIL_SET"
|
||||
)
|
||||
val document = QFieldMetaData(
|
||||
"templateId", "Document", type = QFieldType.INTEGER, isRequired = true, possibleValueSourceName = "documents",
|
||||
possibleValueSourceDependsOn = listOf("printerId", "mediaId"), possibleValueSourceDependencyBehavior = "DISABLE_UNTIL_SET"
|
||||
)
|
||||
val repository = SubmissionRepository().apply {
|
||||
inputOverride = QFrontendStepMetaData("input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), formFields = listOf(printer, media, document))
|
||||
possibleValueResolver = { field, values ->
|
||||
when (field) {
|
||||
"printerId" -> listOf(choice(1, "P1"))
|
||||
"mediaId" -> if (values["printerId"] == "p1") listOf(choice(2, "M1")) else if (values["printerId"] == "p2") listOf(choice(3, "M2")) else emptyList()
|
||||
else -> when (values["printerId"] to values["mediaId"]) {
|
||||
"p1" to "m1" -> listOf(choice(7, "Seven"), choice(9, "Nine"))
|
||||
"p1" to "m1b" -> listOf(choice(11, "Eleven"))
|
||||
"p2" to "m2" -> listOf(choice(13, "Thirteen"))
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val model = model(repository)
|
||||
model.doInit()
|
||||
advanceUntilIdle()
|
||||
listOf(printer, media, document).forEach { model.loadRemoteOptions(it) }
|
||||
advanceUntilIdle()
|
||||
assertEquals("Choose Printer and Paper or label size first.", model.remoteOptionsBlockedReason(document))
|
||||
|
||||
fun choose(name: String, value: Any?) { model.processValues[name] = value; model.fieldChanged(name); }
|
||||
choose("printerId", "p1")
|
||||
advanceUntilIdle()
|
||||
assertEquals("Choose Paper or label size first.", model.remoteOptionsBlockedReason(document))
|
||||
choose("mediaId", "m1")
|
||||
advanceUntilIdle()
|
||||
assertNull(model.remoteOptionsBlockedReason(document))
|
||||
val requested = repository.possibleValueRequests.last()
|
||||
assertEquals("documents is searched with both parent values", mapOf("printerId" to "p1", "mediaId" to "m1"), requested.second.filterKeys { it != "templateId" }.filterValues { it != null })
|
||||
assertEquals(listOf(7, 9), model.remoteOptions["templateId"]?.map { it.id })
|
||||
assertTrue("Choice ids stay true integers", model.remoteOptions["templateId"]!!.all { it.id is Int })
|
||||
|
||||
// Required: nothing selected means Next must not submit.
|
||||
model.runStep()
|
||||
advanceUntilIdle()
|
||||
assertEquals("Document is required.", model.fieldErrors["templateId"])
|
||||
assertTrue(repository.submissions.isEmpty())
|
||||
|
||||
// Changing media clears the document and Next stays blocked until it is chosen again.
|
||||
choose("templateId", 7)
|
||||
choose("mediaId", "m1b")
|
||||
assertNull("Document chosen for the old media is cleared", model.processValues["templateId"])
|
||||
assertEquals(1, model.fieldResets["templateId"])
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(11), model.remoteOptions["templateId"]?.map { it.id })
|
||||
model.runStep()
|
||||
advanceUntilIdle()
|
||||
assertEquals("Document is required.", model.fieldErrors["templateId"])
|
||||
assertTrue(repository.submissions.isEmpty())
|
||||
|
||||
// Changing the printer clears media and the document; the old document id is not an offered choice.
|
||||
choose("mediaId", "m1")
|
||||
advanceUntilIdle()
|
||||
choose("templateId", 9)
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
repository.possibleValueGate = gate
|
||||
model.loadRemoteOptions(document)
|
||||
runCurrent()
|
||||
choose("printerId", "p2")
|
||||
assertNull(model.processValues["mediaId"])
|
||||
assertNull(model.processValues["templateId"])
|
||||
assertEquals(2, model.fieldResets["templateId"])
|
||||
assertEquals("Choose Paper or label size first.", model.remoteOptionsBlockedReason(document))
|
||||
// A reply for the old printer/media that was already in flight is discarded, not republished.
|
||||
repository.possibleValueGate = null
|
||||
gate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
assertNull("Late old-parent reply cannot restore choices", model.remoteOptions["templateId"])
|
||||
assertNull("Late old-parent reply cannot restore the old document", model.processValues["templateId"])
|
||||
model.runStep()
|
||||
advanceUntilIdle()
|
||||
assertTrue(model.fieldErrors.containsKey("mediaId") && model.fieldErrors.containsKey("templateId"))
|
||||
assertTrue(repository.submissions.isEmpty())
|
||||
|
||||
// After reselection the typed integer id is what is submitted.
|
||||
choose("mediaId", "m2")
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(13), model.remoteOptions["templateId"]?.map { it.id })
|
||||
choose("templateId", model.remoteOptions["templateId"]!!.single().id)
|
||||
model.runStep()
|
||||
advanceUntilIdle()
|
||||
val submitted = repository.submissions.single()
|
||||
assertEquals("p2", submitted["printerId"])
|
||||
assertEquals("m2", submitted["mediaId"])
|
||||
assertEquals(13, submitted["templateId"])
|
||||
assertTrue("Document is submitted as an integer, not text or a decimal", submitted["templateId"] is Int)
|
||||
assertEquals("result", model.activeStepName)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -554,6 +771,10 @@ class ProcessSubmissionTest {
|
||||
var statusFailures = 0
|
||||
var statusCalls = 0
|
||||
var inputOverride: QFrontendStepMetaData? = null
|
||||
val possibleValueRequests = mutableListOf<Pair<String, Map<String, Any?>>>()
|
||||
var possibleValueFailure = false
|
||||
var possibleValueGate: CompletableDeferred<Unit>? = null
|
||||
var possibleValueResolver: ((String, Map<String, Any?>) -> List<com.kingsrook.qqq.frontend.android.core.model.QPossibleValue>)? = null
|
||||
private val input = QFrontendStepMetaData(
|
||||
"input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)),
|
||||
formFields = listOf(QFieldMetaData("eventId", "Event", type = QFieldType.STRING, isRequired = true, isHidden = hiddenEventId))
|
||||
@@ -587,6 +808,21 @@ class ProcessSubmissionTest {
|
||||
return if (asynchronous) QJobStarted("process", "job") else QJobComplete("process", responseValues, nextStep)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Printer choices are independent; media choices depend on the supplied printer.
|
||||
*******************************************************************************/
|
||||
override suspend fun processPossibleValues(processName: String, fieldName: String, values: Map<String, Any?>, processValues: Map<String, Any?>): List<com.kingsrook.qqq.frontend.android.core.model.QPossibleValue> {
|
||||
possibleValueRequests.add(fieldName to values.toMap())
|
||||
possibleValueGate?.await()
|
||||
if (possibleValueFailure) throw java.io.IOException("Choices unavailable")
|
||||
possibleValueResolver?.let { return it(fieldName, values.toMap()) }
|
||||
return when {
|
||||
fieldName == "printerId" -> listOf(com.kingsrook.qqq.frontend.android.core.model.QPossibleValue("printer", "Simulated printer"))
|
||||
values["printerId"] == "printer" -> listOf(com.kingsrook.qqq.frontend.android.core.model.QPossibleValue("4x6", "4 x 6 in"))
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
+4
-3
@@ -230,9 +230,10 @@ class SemanticProcessTest {
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun remotePossibleValuesAndUnknownInputTypesAreExplicitlyUnavailable() {
|
||||
for (unsupportedField in listOf(field.copy(possibleValueSourceName = "remote"), field.copy(type = QFieldType.UNKNOWN)))
|
||||
assertNotNull(SemanticProcess.unsupported(step.copy(formFields = listOf(unsupportedField)), emptyMap(), null, emptySet()))
|
||||
fun unknownInputTypesAreExplicitlyUnavailableButNamedPossibleValueSourcesAreRendered() {
|
||||
assertNotNull(SemanticProcess.unsupported(step.copy(formFields = listOf(field.copy(type = QFieldType.UNKNOWN))), emptyMap(), null, emptySet()))
|
||||
// Printer/media style fields load their choices through the semantic possibleValues operation.
|
||||
assertNull(SemanticProcess.unsupported(step.copy(formFields = listOf(field.copy(possibleValueSourceName = "remote"))), emptyMap(), null, emptySet()))
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -17,10 +17,13 @@ import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertTextContains
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.isEnabled
|
||||
import androidx.compose.ui.test.isPopup
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
@@ -36,6 +39,7 @@ import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.UUID
|
||||
import java.net.URI
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -60,6 +64,187 @@ class ScannerEndToEndTest {
|
||||
private lateinit var origin: String
|
||||
private lateinit var adminCookie: String
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise real Echo process/PVS persistence with explicitly simulated print acceptance.
|
||||
** No printer, production scanner ingress, screenshots or private identifiers become evidence.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun echoSelectsOnceQueuesTwoScansAndClosesOnAppsLeave() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
check(context.packageName == "io.qrun.mobile.scanner.e2e") { "Echo requires the disposable E2E package" }
|
||||
val configFile = File(context.filesDir, "e2e.json")
|
||||
val config = try { JSONObject(configFile.readText()) }
|
||||
catch(exception: Exception) { throw AssertionError("Echo fixture configuration is invalid") }
|
||||
finally { configFile.delete() }
|
||||
check(config.optString("fixtureType") == "echo-ui-e2e" && config.optBoolean("simulatedPrinting"))
|
||||
origin = config.getString("serverUrl").trimEnd('/')
|
||||
val endpoint = URI(origin)
|
||||
check(endpoint.scheme == "http" && endpoint.host == "127.0.0.1" && endpoint.port in 1..65535 &&
|
||||
endpoint.port !in setOf(57832, 51631, 18081, 18086) && endpoint.path in setOf("", "/") &&
|
||||
endpoint.userInfo == null && endpoint.query == null && endpoint.fragment == null)
|
||||
|
||||
waitFor("QQQ server URL")
|
||||
ui.onNodeWithText("QQQ server URL").performTextReplacement("$origin/")
|
||||
ui.onNodeWithText("Connect", substring = false).performClick()
|
||||
waitFor("Username")
|
||||
ui.onNodeWithText("Username").performTextReplacement("scanner")
|
||||
ui.onNodeWithText("Password").performTextReplacement(config.getString("scannerPassword"))
|
||||
ui.onNodeWithText("Password").performImeAction()
|
||||
ui.onNode(hasText("Sign in") and hasClickAction()).performScrollTo().performClick()
|
||||
waitFor("An administrator must approve this device before you can run mobile apps.")
|
||||
val device = DeviceCredentials(context, "$origin/")
|
||||
adminCookie = loginAdmin(config.getString("adminPassword"))
|
||||
runAdmin("mobileDeviceApprove", device.deviceId)
|
||||
ui.onNodeWithText("Check approval").performScrollTo().performClick()
|
||||
waitForTag("appHome.rowForApp:echo")
|
||||
val scannerCookie = loginAdmin(config.getString("scannerPassword"), "scanner")
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify persisted rows through ordinary QQQ reads under the operator's real actor/device locks.
|
||||
*******************************************************************************/
|
||||
fun rows(table: String): List<JSONObject> {
|
||||
check(table in setOf("echoScan", "echoFixtureRun"))
|
||||
val request = Request.Builder().url("$origin/qqq/v1/table/$table/query").header("Cookie", scannerCookie)
|
||||
.post("{}".toRequestBody("application/json".toMediaType()))
|
||||
device.headers().forEach { (name, value) -> request.header(name, value) }
|
||||
val records = executeJson(request.build()).getJSONArray("records")
|
||||
return (0 until records.length()).map { records.getJSONObject(it).getJSONObject("values") }
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Discover choices through the same versioned `searchProcessPossibleValues` POST the renderer uses
|
||||
** ({values, processValues, useCase:form}), so no document id or label is assumed by this harness.
|
||||
*******************************************************************************/
|
||||
fun choices(field: String, values: JSONObject): List<Pair<Any, String>> {
|
||||
val request = Request.Builder().url("$origin/qqq/v1/processes/echoPrint/possibleValues/$field").header("Cookie", scannerCookie)
|
||||
.post(JSONObject().put("values", values).put("processValues", JSONObject()).put("useCase", "form").toString().toRequestBody("application/json".toMediaType()))
|
||||
device.headers().forEach { (name, value) -> request.header(name, value) }
|
||||
val options = executeJson(request.build()).optJSONArray("options") ?: return emptyList()
|
||||
return (0 until options.length()).map { options.getJSONObject(it) }.map { it.get("id") to it.getString("label") }
|
||||
}
|
||||
val printerChoice = choices("printerId", JSONObject().put("mediaId", JSONObject.NULL).put("templateId", JSONObject.NULL)).single { it.second == "Simulated printer" }
|
||||
val mediaChoice = choices("mediaId", JSONObject().put("printerId", printerChoice.first).put("templateId", JSONObject.NULL)).single { it.second == "Simulated 4 x 6 in — 300 dpi" }
|
||||
val documents = choices("templateId", JSONObject().put("printerId", printerChoice.first).put("mediaId", mediaChoice.first))
|
||||
assertTrue("Document choices must be offered for the selected printer/media with unique labels and true JSON integer ids",
|
||||
documents.isNotEmpty() && documents.map { it.second }.toSet().size == documents.size && documents.all { it.first is Int || it.first is Long })
|
||||
// Choose a different document after the stale-selection check when more than one is offered, so restoring the old one is detectable.
|
||||
val firstDocument = documents.first()
|
||||
val finalDocument = documents.last()
|
||||
// Persisted setup column holding the chosen document id; confirm against the server fixture schema.
|
||||
val documentColumn = "templateId"
|
||||
fun assertSetupPinned(row: JSONObject) {
|
||||
assertTrue("Setup must pin the selected printer and media once", row.getString("state") == "OPEN" &&
|
||||
row.getString("printerRecordId") == "fixture-printer" && row.getString("mediaId") == "fixture-4x6")
|
||||
val persisted = row.opt(documentColumn)
|
||||
assertTrue("Setup must persist the chosen document as the same integer id the choices returned",
|
||||
persisted is Number && persisted.toLong() == (finalDocument.first as Number).toLong())
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The only enabled, unselected choice control (earlier fields already hold labels).
|
||||
*******************************************************************************/
|
||||
fun openOnlyRemainingChoice() {
|
||||
ui.waitUntil(timeoutMillis = 60_000) {
|
||||
ui.onAllNodesWithText("Choose a value").fetchSemanticsNodes().size == 1 &&
|
||||
ui.onAllNodes(hasText("Choose a value") and isEnabled()).fetchSemanticsNodes().size == 1
|
||||
}
|
||||
ui.onNodeWithText("Choose a value").performScrollTo().performClick()
|
||||
}
|
||||
|
||||
val priorRuns = rows("echoFixtureRun").map { it.getString("id") }.toSet()
|
||||
ui.onNodeWithTag("appHome.rowForApp:echo").performClick()
|
||||
waitForTag("appHome.rowForProcess:echoPrint")
|
||||
ui.onNodeWithTag("appHome.rowForProcess:echoPrint").performClick()
|
||||
waitFor("Printer")
|
||||
waitFor("Paper or label size")
|
||||
waitFor("Document")
|
||||
ui.onAllNodesWithText("Choose a value").onFirst().performScrollTo().performClick()
|
||||
waitFor("Simulated printer")
|
||||
ui.onNodeWithText("Simulated printer").performClick()
|
||||
// Media is a contingent field: it stays disabled until the server returns choices for the selected printer.
|
||||
ui.waitUntil(timeoutMillis = 60_000) { ui.onAllNodes(hasText("Choose a value") and isEnabled()).fetchSemanticsNodes().isNotEmpty() }
|
||||
ui.onAllNodesWithText("Choose a value").onFirst().performScrollTo().performClick()
|
||||
waitFor("Simulated 4 x 6 in — 300 dpi")
|
||||
ui.onNodeWithText("Simulated 4 x 6 in — 300 dpi").performClick()
|
||||
// Document depends on both printer and media; it is the only unselected control left once media holds its label.
|
||||
openOnlyRemainingChoice()
|
||||
waitFor(firstDocument.second)
|
||||
ui.onNodeWithText(firstDocument.second).performClick()
|
||||
waitFor(firstDocument.second)
|
||||
stage("echo-document-selected")
|
||||
|
||||
// Choosing a controlling field again drops every dependent selection and blocks Next until they are reselected.
|
||||
ui.onNodeWithText("Simulated printer").performScrollTo().performClick()
|
||||
ui.onNode(hasText("Simulated printer") and hasAnyAncestor(isPopup())).performClick()
|
||||
ui.waitUntil(timeoutMillis = 60_000) { ui.onAllNodesWithText("Choose a value").fetchSemanticsNodes().size == 2 }
|
||||
assertTrue("The stale document must not stay selected", ui.onAllNodesWithText(firstDocument.second).fetchSemanticsNodes().isEmpty())
|
||||
waitFor("Choose Paper or label size first.")
|
||||
ui.onNodeWithText("Next", substring = false).performClick()
|
||||
waitFor("Paper or label size is required.")
|
||||
waitFor("Document is required.")
|
||||
assertTrue("Next must not start setup without a reselected document", ui.onAllNodesWithText("Scanner ready. Press the handheld trigger.").fetchSemanticsNodes().isEmpty() &&
|
||||
rows("echoFixtureRun").none { it.getString("id") !in priorRuns && it.optString("printerRecordId").isNotBlank() })
|
||||
ui.onAllNodesWithText("Choose a value").onFirst().performScrollTo().performClick()
|
||||
waitFor("Simulated 4 x 6 in — 300 dpi")
|
||||
ui.onNodeWithText("Simulated 4 x 6 in — 300 dpi").performClick()
|
||||
openOnlyRemainingChoice()
|
||||
waitFor(finalDocument.second)
|
||||
ui.onNodeWithText(finalDocument.second).performClick()
|
||||
waitFor(finalDocument.second)
|
||||
assertTrue("Only the reselected document may remain chosen", firstDocument == finalDocument || ui.onAllNodesWithText(firstDocument.second).fetchSemanticsNodes().isEmpty())
|
||||
stage("echo-stale-document-cleared")
|
||||
ui.onNodeWithText("Next", substring = false).performClick()
|
||||
waitFor("Scanner ready. Press the handheld trigger.")
|
||||
assertTrue("Echo must not offer manual barcode input", ui.onAllNodesWithText("Manual barcode").fetchSemanticsNodes().isEmpty())
|
||||
assertTrue("Echo must not offer manual submission", ui.onAllNodesWithText("Submit barcode").fetchSemanticsNodes().isEmpty())
|
||||
val run = rows("echoFixtureRun").single { it.getString("id") !in priorRuns }
|
||||
val runId = run.getString("id")
|
||||
assertSetupPinned(run)
|
||||
stage("echo-setup-selected")
|
||||
|
||||
var receipt = ""
|
||||
for ((index, sample) in listOf("001234567890" to "LABEL-TYPE-CODE128", "000-ECHO-QR" to "QRCODE").withIndex()) {
|
||||
waitFor("Scanner ready. Press the handheld trigger.")
|
||||
context.sendBroadcast(Intent("io.qrun.mobile.scanner.SCAN").setPackage(context.packageName)
|
||||
.addCategory(Intent.CATEGORY_DEFAULT).putExtra("com.symbol.datawedge.data_string", sample.first)
|
||||
.putExtra("com.symbol.datawedge.label_type", sample.second))
|
||||
var accepted: JSONObject? = null
|
||||
waitForServer {
|
||||
accepted = rows("echoScan").singleOrNull { it.optString("runId") == runId && it.optString("barcode") == sample.first && it.optString("state") == "QUEUED" }
|
||||
accepted != null
|
||||
}
|
||||
val row = requireNotNull(accepted)
|
||||
receipt = row.getString("mobileScanId")
|
||||
waitFor("Print queued $receipt", substring = true)
|
||||
assertTrue("Accepted Echo scans must preserve exact type and approved-device attribution",
|
||||
row.getString("symbology") == sample.second && row.getString("rendererSymbology") == (if (index == 0) "code128" else "qr") &&
|
||||
row.getString("scanSource") == "e2e-fixture" && row.getString("deviceId") == device.deviceId && row.getString("actorId") == "scanner")
|
||||
assertTrue("Only a synthetic accepted job may acknowledge this fixture scan",
|
||||
row.getString("id") == receipt && row.getString("printJobId") == "fixture-job-$receipt" && row.getString("eventId").isNotBlank())
|
||||
assertTrue("Each event must create exactly one accepted Echo scan", rows("echoScan").count { it.optString("runId") == runId } == index + 1)
|
||||
assertSetupPinned(rows("echoFixtureRun").single { it.getString("id") == runId })
|
||||
stage(if (index == 0) "echo-first-queued" else "echo-second-queued")
|
||||
}
|
||||
|
||||
ui.activityRule.scenario.onActivity { OrientationPolicy.LANDSCAPE.select(it) }
|
||||
waitFor("Print queued $receipt", substring = true)
|
||||
ui.activityRule.scenario.recreate()
|
||||
waitFor("Print queued $receipt", substring = true)
|
||||
assertTrue("Recreation must retain scan phase without showing printer selection", ui.onAllNodesWithText("Choose a value").fetchSemanticsNodes().isEmpty())
|
||||
val acceptedRows = rows("echoScan").filter { it.optString("runId") == runId }
|
||||
assertTrue("Rotation/recreation must not accept another event", acceptedRows.size == 2 && acceptedRows.map { it.getString("eventId") }.toSet().size == 2 &&
|
||||
acceptedRows.map { it.getString("printJobId") }.toSet().size == 2)
|
||||
stage("echo-recreation-stable")
|
||||
ui.onNodeWithTag("topBar.menuIcon").performClick()
|
||||
ui.onNodeWithTag("drawer.home").performClick()
|
||||
waitFor("Leave process")
|
||||
ui.onNodeWithText("Leave process", substring = false).performClick()
|
||||
waitForTag("appHome.rowForApp:echo")
|
||||
waitForServer { rows("echoFixtureRun").single { it.getString("id") == runId }.getString("state") == "CLOSED" }
|
||||
assertTrue("Leaving must close this run without accepting extra print work", rows("echoScan").count { it.optString("runId") == runId } == 2)
|
||||
stage("echo-run-closed")
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Real host/native activity-result boundaries and real upload APIs, using synthetic images only.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -25,7 +25,7 @@ internal fun createScannerDriver(context: Context): ScannerDriver = object : Sca
|
||||
val data = intent.getStringExtra("com.symbol.datawedge.data_string") ?: return
|
||||
if (intent.getStringExtra("com.symbol.datawedge.decode_mode") == "multiple" || intent.hasExtra("com.symbol.datawedge.barcodes")) return
|
||||
pending = false
|
||||
listener?.scanned(data, intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN")
|
||||
listener?.scanned(ScannerCapture.of(data, intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN"))
|
||||
listener?.idle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT
|
||||
import android.os.Build
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -33,7 +34,7 @@ class DeviceApi(
|
||||
var displayName: String = ""
|
||||
private set
|
||||
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).build()
|
||||
.addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).addInterceptor(SameOriginInterceptor()).build()
|
||||
|
||||
/*******************************************************************************
|
||||
** Check enrollment on demand; approval remains a server-side decision.
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.MOBILE_SCANNERS_PATH
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException
|
||||
import java.io.IOException
|
||||
@@ -53,7 +54,7 @@ internal class MobileSessionApi(
|
||||
onOutcome: (RequestOutcome) -> Unit = {},
|
||||
) : MobileSessionTransport {
|
||||
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).build()
|
||||
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build()
|
||||
|
||||
/*******************************************************************************
|
||||
** Send the current eligible session metadata and accept only the server's bounded lease policy.
|
||||
|
||||
@@ -4,6 +4,7 @@ package io.qrun.mobile.scanner
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException
|
||||
import java.io.File
|
||||
@@ -156,7 +157,7 @@ internal class RecordFilesApi(
|
||||
private val compatibility: UiApiCompatibility, onOutcome: (RequestOutcome) -> Unit = {}
|
||||
) : RecordFilesTransport {
|
||||
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.callTimeout(30, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).build()
|
||||
.callTimeout(30, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build()
|
||||
|
||||
@Volatile
|
||||
private var captureContractVerified = false
|
||||
|
||||
@@ -7,18 +7,36 @@ import java.util.UUID
|
||||
/*******************************************************************************
|
||||
** One physical scan remains one event even when its HTTP request is retried.
|
||||
*******************************************************************************/
|
||||
data class ScanEvent(
|
||||
val barcode: String,
|
||||
val symbology: String,
|
||||
internal data class ScanEvent(
|
||||
val capture: ScannerCapture,
|
||||
val scanSource: String,
|
||||
val eventId: String = UUID.randomUUID().toString(),
|
||||
val capturedAt: String = Instant.now().toString()
|
||||
) {
|
||||
constructor(barcode: String, symbology: String, scanSource: String) :
|
||||
this(ScannerCapture.of(barcode, symbology), scanSource)
|
||||
|
||||
val barcode: String get() = capture.barcode
|
||||
val symbology: String get() = capture.symbology
|
||||
|
||||
/*******************************************************************************
|
||||
** A fixed schema prevents arbitrary intent extras becoming process inputs.
|
||||
** A fixed schema prevents arbitrary intent extras becoming process inputs. The optional fidelity
|
||||
** keys are added only for servers verified to accept them; absent values are omitted, never blank.
|
||||
*******************************************************************************/
|
||||
fun values(): Map<String, Any> = mapOf(
|
||||
"barcode" to barcode, "symbology" to symbology, "scanSource" to scanSource,
|
||||
"eventId" to eventId, "capturedAt" to capturedAt
|
||||
)
|
||||
fun values(includeFidelity: Boolean = false): Map<String, Any> {
|
||||
val values = linkedMapOf<String, Any>(
|
||||
"barcode" to barcode, "symbology" to symbology, "scanSource" to scanSource,
|
||||
"eventId" to eventId, "capturedAt" to capturedAt
|
||||
)
|
||||
if (includeFidelity) {
|
||||
capture.aimIdentifier?.let { values["aimIdentifier"] = it }
|
||||
capture.rawDataBase64?.let { values["rawDataBase64"] = it }
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scan content must not appear in logs; the event identity is an opaque correlation value.
|
||||
*******************************************************************************/
|
||||
override fun toString(): String = "ScanEvent(redacted, source=$scanSource, eventId=$eventId)"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package io.qrun.mobile.scanner
|
||||
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiVersion
|
||||
|
||||
/*******************************************************************************
|
||||
** The optional aimIdentifier/rawDataBase64 inputs exist from mobile-scanners 1.5.0; older servers
|
||||
** must receive exactly the legacy keys. Derived only from already-verified discovery data.
|
||||
*******************************************************************************/
|
||||
internal object ScanFidelityCapability {
|
||||
private val VERSION = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)")
|
||||
|
||||
/*******************************************************************************
|
||||
** Require the single registered mobile-scanners extension at its fixed path with version >=1.5.0,<2.
|
||||
*******************************************************************************/
|
||||
fun supports(version: UiApiVersion?): Boolean {
|
||||
val extension = version?.extensions?.singleOrNull { it.name == "mobile-scanners" } ?: return false
|
||||
if (extension.pathPrefix != "/qbit/mobile-scanners") return false
|
||||
val parts = VERSION.matchEntire(extension.version)?.groupValues ?: return false
|
||||
return parts[1] == "1" && (parts[2].toIntOrNull() ?: return false) >= 5 && parts[3].toIntOrNull() != null
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Discovery data from another server never grants capability to this server's widgets.
|
||||
*******************************************************************************/
|
||||
fun available(server: String, discoveredServer: String, version: UiApiVersion?): Boolean =
|
||||
server == discoveredServer && supports(version)
|
||||
}
|
||||
@@ -281,7 +281,10 @@ class ScannerActivity : ComponentActivity() {
|
||||
},
|
||||
LocalCustomWidgetRenderer provides { parameters, modifier, disabled ->
|
||||
if (parameters.widgetBlock.blockType == "SCANNER") {
|
||||
ScannerWidget(parameters, modifier, disabled)
|
||||
ScannerWidget(
|
||||
parameters, modifier, disabled,
|
||||
fidelityAvailable = { ScanFidelityCapability.available(server, compatibility.server, compatibility.verifiedVersion()) }
|
||||
)
|
||||
true
|
||||
} else if (parameters.widgetBlock.blockType == "RECORD_FILES_CAPTURE") {
|
||||
RecordPhotoWidget(parameters, recordFiles, modifier, disabled)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package io.qrun.mobile.scanner
|
||||
|
||||
import java.util.Base64
|
||||
|
||||
/*******************************************************************************
|
||||
** One immutable decoded read: the SDK's text and symbology, the SDK's raw bytes already encoded once as
|
||||
** canonical padded Base64, and an AIM identifier only when a qualified driver supplied it explicitly.
|
||||
** Raw bytes are exact SDK output (possibly with SDK framing), not a claim of complete symbol capture.
|
||||
*******************************************************************************/
|
||||
internal data class ScannerCapture(
|
||||
val barcode: String, val symbology: String, val rawDataBase64: String? = null, val aimIdentifier: String? = null
|
||||
) {
|
||||
init {
|
||||
require(aimIdentifier == null || AIM.matches(aimIdentifier)) { "Invalid AIM identifier." }
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scan content is business data and must not reach logs or crash reports.
|
||||
*******************************************************************************/
|
||||
override fun toString(): String = "ScannerCapture(redacted, raw=${if (rawDataBase64 == null) "absent" else "present"}, aim=${if (aimIdentifier == null) "absent" else "present"})"
|
||||
|
||||
/*******************************************************************************
|
||||
** Raw limits live here so every source builds captures under the same transport limits.
|
||||
*******************************************************************************/
|
||||
companion object {
|
||||
const val MAX_RAW_BYTES = 8192
|
||||
private val AIM = Regex("\\][A-Za-z][0-9A-Za-z]")
|
||||
|
||||
/*******************************************************************************
|
||||
** Encode SDK bytes once; the caller's array is never retained. Null, empty or oversize raw data is
|
||||
** omitted rather than truncated, so an optional field is either exact SDK output or absent.
|
||||
*******************************************************************************/
|
||||
fun of(barcode: String, symbology: String, rawData: ByteArray? = null, aimIdentifier: String? = null): ScannerCapture =
|
||||
ScannerCapture(
|
||||
barcode, symbology,
|
||||
if (rawData == null || rawData.isEmpty() || rawData.size > MAX_RAW_BYTES) null else Base64.getEncoder().encodeToString(rawData),
|
||||
aimIdentifier
|
||||
)
|
||||
|
||||
/*******************************************************************************
|
||||
** Manual entry has no scanner evidence, so it carries neither raw bytes nor an AIM identifier.
|
||||
*******************************************************************************/
|
||||
fun manual(barcode: String) = ScannerCapture(barcode, "MANUAL")
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ internal interface ScannerDriver {
|
||||
/*******************************************************************************
|
||||
** Preserve raw barcode data and reject callbacks after ownership, readiness or generation changes.
|
||||
*******************************************************************************/
|
||||
fun scanned(barcode: String, symbology: String)
|
||||
fun scanned(capture: ScannerCapture)
|
||||
|
||||
/*******************************************************************************
|
||||
** Distinguish ordinary empty reads from rejected multi-barcode reads without fabricating a scan.
|
||||
@@ -61,7 +61,7 @@ internal interface ScannerDriver {
|
||||
*******************************************************************************/
|
||||
internal class ScannerInputSession(
|
||||
private val createDriver: () -> ScannerDriver?,
|
||||
private val onScan: (String, String) -> Unit,
|
||||
private val onScan: (ScannerCapture) -> Unit,
|
||||
private val onStatus: (String, Boolean) -> Unit,
|
||||
private val canAcceptInput: () -> Boolean = { true },
|
||||
) {
|
||||
@@ -122,13 +122,13 @@ internal class ScannerInputSession(
|
||||
/*******************************************************************************
|
||||
** Preserve raw barcode data and reject callbacks after ownership, readiness or generation changes.
|
||||
*******************************************************************************/
|
||||
override fun scanned(barcode: String, symbology: String) {
|
||||
override fun scanned(capture: ScannerCapture) {
|
||||
if (!isCurrent() || !accepting || !armed || !canAcceptInput()) return
|
||||
armed = false
|
||||
accepting = false
|
||||
readNotice = null
|
||||
onStatus("Waiting for scan confirmation…", false)
|
||||
onScan(barcode, symbology)
|
||||
onScan(capture)
|
||||
// IDLE may precede data. Stop any read armed in that interval before accepting more input.
|
||||
try {
|
||||
if (driver === current && current.isReadPending()) current.cancelRead()
|
||||
|
||||
@@ -4,7 +4,7 @@ package io.qrun.mobile.scanner
|
||||
/*******************************************************************************
|
||||
** The optional presentation hints published by mobile-scanners 1.x.
|
||||
*******************************************************************************/
|
||||
internal data class ScannerMetaData(val manualLabel: String = "Manual barcode", val submitLabel: String = "Submit barcode", val maxLength: Int = 4096) {
|
||||
internal data class ScannerMetaData(val manualLabel: String = "Manual barcode", val submitLabel: String = "Submit barcode", val maxLength: Int = 4096, val acknowledgmentLabel: String = "Saved scan", val manualEntryEnabled: Boolean = true) {
|
||||
/*******************************************************************************
|
||||
** Centralize scanner metadata validation before the widget accepts server-provided controls.
|
||||
*******************************************************************************/
|
||||
@@ -21,10 +21,25 @@ internal data class ScannerMetaData(val manualLabel: String = "Manual barcode",
|
||||
{ "SCANNER maxLength must be an integer between 1 and 4096." }
|
||||
number.toInt()
|
||||
}
|
||||
val acknowledgmentLabel = if (!values.containsKey("acknowledgmentLabel")) "Saved scan" else {
|
||||
val label = values["acknowledgmentLabel"]
|
||||
require(label is String && label.length in 1..80 && label.isNotBlank() && label == label.trim() && label.none {
|
||||
it.isISOControl() || it.category == CharCategory.FORMAT || it.category == CharCategory.LINE_SEPARATOR ||
|
||||
it.category == CharCategory.PARAGRAPH_SEPARATOR || it == '<' || it == '>'
|
||||
}) { "SCANNER acknowledgmentLabel must be a plain, single-line label between 1 and 80 characters." }
|
||||
label
|
||||
}
|
||||
val manualEntryEnabled = if (!values.containsKey("manualEntryEnabled")) true else {
|
||||
val enabled = values["manualEntryEnabled"]
|
||||
require(enabled is Boolean) { "SCANNER manualEntryEnabled must be a boolean." }
|
||||
enabled
|
||||
}
|
||||
return ScannerMetaData(
|
||||
manualLabel = (values["manualLabel"] as? String)?.takeIf { it.isNotBlank() } ?: "Manual barcode",
|
||||
submitLabel = (values["submitLabel"] as? String)?.takeIf { it.isNotBlank() } ?: "Submit barcode",
|
||||
maxLength = maxLength,
|
||||
acknowledgmentLabel = acknowledgmentLabel,
|
||||
manualEntryEnabled = manualEntryEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed
|
||||
@Composable
|
||||
internal fun ScannerWidget(
|
||||
parameters: WidgetBlockParameters, modifier: Modifier, disabled: Boolean,
|
||||
driverFactory: (Context) -> ScannerDriver? = ::createScannerDriver
|
||||
driverFactory: (Context) -> ScannerDriver? = ::createScannerDriver,
|
||||
fidelityAvailable: () -> Boolean = { false }
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
@@ -82,7 +83,8 @@ internal fun ScannerWidget(
|
||||
/*******************************************************************************
|
||||
** Each new physical scan has an identity; retries are handled by ProcessViewModel.
|
||||
*******************************************************************************/
|
||||
fun submit(barcode: String, symbology: String, source: String): Boolean {
|
||||
fun submit(capture: ScannerCapture, source: String): Boolean {
|
||||
val barcode = capture.barcode
|
||||
if (process == null) {
|
||||
notice = "This scanner control requires an active process."
|
||||
return false
|
||||
@@ -96,9 +98,10 @@ internal fun ScannerWidget(
|
||||
return false
|
||||
}
|
||||
notice = "Sending scan…"
|
||||
val event = ScanEvent(barcode, symbology, source)
|
||||
val event = ScanEvent(capture, source)
|
||||
// Capability is read from cached verified discovery on the UI thread; it never triggers HTTP.
|
||||
process.runStep(
|
||||
event.values(), ProcessAcknowledgment(
|
||||
event.values(includeFidelity = fidelityAvailable()), ProcessAcknowledgment(
|
||||
"acknowledgedEventId", event.eventId,
|
||||
requiredValues = mapOf("scanSaved" to true), nonEmptyStringFields = setOf("scanId")
|
||||
)
|
||||
@@ -111,12 +114,12 @@ internal fun ScannerWidget(
|
||||
return accepted
|
||||
}
|
||||
|
||||
val currentSubmit by rememberUpdatedState<(String, String, String) -> Boolean>(::submit)
|
||||
val currentSubmit by rememberUpdatedState<(ScannerCapture, String) -> Boolean>(::submit)
|
||||
val input = remember(context, process, driverFactory)
|
||||
{
|
||||
ScannerInputSession(
|
||||
{ driverFactory(context) },
|
||||
{ barcode, symbology -> currentSubmit(barcode, symbology, HARDWARE_SCAN_SOURCE) },
|
||||
{ capture -> currentSubmit(capture, HARDWARE_SCAN_SOURCE) },
|
||||
{ message, reconnect -> scannerStatus = message; canReconnect = reconnect },
|
||||
canAcceptInput = { currentInputReady() })
|
||||
}
|
||||
@@ -141,21 +144,23 @@ internal fun ScannerWidget(
|
||||
Text(parameters.widgetBlock.values["title"]?.toString() ?: "Scan a barcode")
|
||||
Text(parameters.widgetBlock.values["instructions"]?.toString() ?: "Press the handheld scan trigger. Wait for server confirmation before scanning again.")
|
||||
if (parameters.values["scanSaved"] == true && notice.isEmpty()) {
|
||||
Text("Saved scan ${parameters.values["scanId"] ?: ""}")
|
||||
Text("${scannerMetaData.acknowledgmentLabel} ${parameters.values["scanId"] ?: ""}")
|
||||
}
|
||||
if (notice.isNotEmpty()) Text(notice)
|
||||
if (scannerStatus.isNotEmpty()) Text(scannerStatus)
|
||||
if (canReconnect) Button(
|
||||
enabled = ready,
|
||||
onClick = { input.reconnect() }) { Text("Reconnect scanner") }
|
||||
OutlinedTextField(
|
||||
manual, { manual = it }, label = { Text(scannerMetaData.manualLabel) }, enabled = ready, singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); keyboard?.hide() })
|
||||
)
|
||||
Button(enabled = ready && manual.isNotEmpty(), onClick = {
|
||||
focusManager.clearFocus(); keyboard?.hide()
|
||||
if (submit(manual, "MANUAL", "manual")) manual = ""
|
||||
}) { Text(scannerMetaData.submitLabel) }
|
||||
if (scannerMetaData.manualEntryEnabled) {
|
||||
OutlinedTextField(
|
||||
manual, { manual = it }, label = { Text(scannerMetaData.manualLabel) }, enabled = ready, singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); keyboard?.hide() })
|
||||
)
|
||||
Button(enabled = ready && manual.isNotEmpty(), onClick = {
|
||||
focusManager.clearFocus(); keyboard?.hide()
|
||||
if (submit(ScannerCapture.manual(manual), "manual")) manual = ""
|
||||
}) { Text(scannerMetaData.submitLabel) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,18 +30,24 @@ internal class ZebraScannerDriver(private val context: Context) : ScannerDriver
|
||||
private var opening = false
|
||||
private var ownsClaim = false
|
||||
private val dataListener = Scanner.DataListener { collection ->
|
||||
// Copy SDK-owned data now; the collection must not be read after this callback returns. AIM is never
|
||||
// inferred here: it stays absent until a qualified driver obtains it from an explicit source.
|
||||
val scans = collection?.scanData
|
||||
var capture: ScannerCapture? = null
|
||||
var multiple = false
|
||||
if (collection?.result == com.symbol.emdk.barcode.ScannerResults.SUCCESS && !scans.isNullOrEmpty()) {
|
||||
if (scans.size != 1) multiple = true
|
||||
else {
|
||||
val scan = scans.single()
|
||||
val barcode = scan?.data
|
||||
if (!barcode.isNullOrEmpty())
|
||||
capture = ScannerCapture.of(barcode, scan.labelType?.toString() ?: "UNKNOWN", scan.rawData)
|
||||
}
|
||||
}
|
||||
val delivered = capture
|
||||
main.post {
|
||||
if (!closed) {
|
||||
val scans = collection?.scanData
|
||||
if (collection?.result != com.symbol.emdk.barcode.ScannerResults.SUCCESS || scans.isNullOrEmpty())
|
||||
listener?.noData(false)
|
||||
else if (scans.size != 1) listener?.noData(true)
|
||||
else {
|
||||
val scan = scans.single()
|
||||
val barcode = scan?.data
|
||||
if (barcode.isNullOrEmpty()) listener?.noData(false)
|
||||
else listener?.scanned(barcode, scan.labelType?.toString() ?: "UNKNOWN")
|
||||
}
|
||||
if (delivered != null) listener?.scanned(delivered) else listener?.noData(multiple)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ class ScannerOverlayTest {
|
||||
**
|
||||
*******************************************************************************/
|
||||
fun emit(value: String) {
|
||||
pending = false; listener.scanned(value, "QRCODE"); listener.idle()
|
||||
pending = false; listener.scanned(ScannerCapture.of(value, "QRCODE")); listener.idle()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -328,7 +328,16 @@ class MobileSessionTest {
|
||||
val screen = server.takeRequest()
|
||||
assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/screen", screen.path)
|
||||
assertEquals(4, JSONObject(screen.body.readUtf8()).getLong("sequence"))
|
||||
assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/end", server.takeRequest().path)
|
||||
val end = server.takeRequest()
|
||||
assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/end", end.path)
|
||||
// Cookie-bearing native POSTs must present the exact origin of the URL used, like a browser.
|
||||
val expectedOrigin = origin.trimEnd('/')
|
||||
listOf(heartbeat, screen, end).forEach { request ->
|
||||
assertEquals("POST", request.method)
|
||||
assertEquals("sessionUUID=synthetic-session", request.getHeader("Cookie"))
|
||||
assertEquals(expectedOrigin, request.getHeader("Origin"))
|
||||
assertEquals(1, request.headers.values("Origin").size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,15 @@ class RecordFilesApiTest {
|
||||
val finalize = server.takeRequest()
|
||||
assertEquals(intent.requestId, finalize.getHeader("Idempotency-Key"))
|
||||
assertEquals(0L, finalize.bodySize)
|
||||
assertEquals("GET", server.takeRequest().method)
|
||||
val status = server.takeRequest()
|
||||
assertEquals("GET", status.method)
|
||||
// Every native record-file request (including the cookie-bearing mutations) presents its own exact origin.
|
||||
val expectedOrigin = origin.trimEnd('/')
|
||||
listOf(descriptor, capabilities, first, second, chunk, finalize, status).forEach {
|
||||
assertEquals(expectedOrigin, it.getHeader("Origin"))
|
||||
assertEquals(1, it.headers.values("Origin").size)
|
||||
}
|
||||
listOf(capabilities, first, second, chunk, finalize, status).forEach { assertEquals("sessionUUID=synthetic-session", it.getHeader("Cookie")) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package io.qrun.mobile.scanner
|
||||
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiExtension
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiVersion
|
||||
import java.util.Base64
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/*******************************************************************************
|
||||
** Retrying a submission must not turn it into a second captured scan.
|
||||
*******************************************************************************/
|
||||
class ScanEventTest {
|
||||
private val legacyKeys = setOf("barcode", "symbology", "scanSource", "eventId", "capturedAt")
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private fun version(scanners: String?, path: String = "/qbit/mobile-scanners") = UiApiVersion(
|
||||
"1.8.0", extensions = listOfNotNull(scanners?.let { UiApiExtension("mobile-scanners", it, path) })
|
||||
)
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -19,4 +36,126 @@ class ScanEventTest {
|
||||
assertEquals("001234", event.values()["barcode"])
|
||||
assertNotEquals(event.eventId, ScanEvent("001234", "LABEL-TYPE-CODE128", "scanner").eventId)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun oldServerGetsExactlyTheLegacyFiveKeysEvenWhenFidelityIsCaptured() {
|
||||
val event = ScanEvent(ScannerCapture.of("A", "QRCODE", byteArrayOf(1, 2), "]Q1"), "zebra-emdk")
|
||||
assertEquals(legacyKeys, event.values().keys)
|
||||
assertEquals(legacyKeys, event.values(includeFidelity = false).keys)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun fidelityValuesAreFlatStringsAndMissingKeysAreOmitted() {
|
||||
val raw = byteArrayOf(0x1d, 0x41)
|
||||
val full = ScanEvent(ScannerCapture.of("A", "QRCODE", raw, "]Q1"), "zebra-emdk").values(true)
|
||||
assertEquals("]Q1", full["aimIdentifier"])
|
||||
assertEquals(Base64.getEncoder().encodeToString(raw), full["rawDataBase64"])
|
||||
val rawOnly = ScanEvent(ScannerCapture.of("A", "QRCODE", raw), "zebra-emdk").values(true)
|
||||
assertEquals(legacyKeys + "rawDataBase64", rawOnly.keys)
|
||||
assertEquals(legacyKeys, ScanEvent(ScannerCapture.manual("A"), "manual").values(true).keys)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun retryPayloadIsUnchangedAcrossRepeatedValueBuilding() {
|
||||
val source = byteArrayOf(0, 1, 2)
|
||||
val event = ScanEvent(ScannerCapture.of("A", "QRCODE", source, "]Q1"), "zebra-emdk")
|
||||
val first = event.values(true)
|
||||
source[0] = 9
|
||||
assertEquals(first, event.values(true))
|
||||
assertEquals(first, event.copy().values(true))
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun captureCopiesInputAndAllByteValuesRoundTrip() {
|
||||
val all = ByteArray(256) { it.toByte() }
|
||||
val capture = ScannerCapture.of("A", "QRCODE", all)
|
||||
all.fill(0)
|
||||
val expected = ByteArray(256) { it.toByte() }
|
||||
assertArrayEquals(expected, Base64.getDecoder().decode(capture.rawDataBase64))
|
||||
assertEquals(capture, ScannerCapture.of("A", "QRCODE", expected))
|
||||
assertEquals(capture.hashCode(), ScannerCapture.of("A", "QRCODE", expected).hashCode())
|
||||
assertNotEquals(capture, ScannerCapture.of("A", "QRCODE", expected, "]Q1"))
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun rawLengthLimitsOmitInsteadOfTruncating() {
|
||||
assertNull(ScannerCapture.of("A", "Q", null).rawDataBase64)
|
||||
assertNull(ScannerCapture.of("A", "Q", ByteArray(0)).rawDataBase64)
|
||||
assertNull(ScannerCapture.of("A", "Q", ByteArray(8193)).rawDataBase64)
|
||||
val max = ScannerCapture.of("A", "Q", ByteArray(8192) { 1 })
|
||||
assertEquals(10924, max.rawDataBase64!!.length)
|
||||
assertEquals("AA==", ScannerCapture.of("A", "Q", ByteArray(1)).rawDataBase64)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun aimMustBeOriginalWellFormedIdentifier() {
|
||||
listOf("]Q1", "]c0", "]A0", "]z9").forEach { assertEquals(it, ScannerCapture.of("A", "Q", null, it).aimIdentifier) }
|
||||
listOf("", "Q1", "]Q", "]Q11", "]11", "]Q!", "]Q1 ", "]Qé").forEach {
|
||||
assertThrows(IllegalArgumentException::class.java) { ScannerCapture.of("A", "Q", null, it) }
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun stringFormsNeverLeakScanContent() {
|
||||
val event = ScanEvent(ScannerCapture.of("SECRET-BARCODE", "QRCODE", "SECRET-RAW".toByteArray(), "]Q1"), "zebra-emdk")
|
||||
listOf(event.toString(), event.capture.toString()).forEach {
|
||||
assertFalse(it, it.contains("SECRET"))
|
||||
assertFalse(it, it.contains(Base64.getEncoder().encodeToString("SECRET-RAW".toByteArray())))
|
||||
assertFalse(it, it.contains("]Q1"))
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun capabilityRequiresMobileScanners15WithinMajor1AtRegisteredPath() {
|
||||
assertTrue(ScanFidelityCapability.supports(version("1.5.0")))
|
||||
assertTrue(ScanFidelityCapability.supports(version("1.12.3")))
|
||||
listOf("1.4.9", "1.1.0", "2.0.0", "2.5.0", "1.5", "1.5.0-rc1", "01.5.0", "1.5.0.1", "", "1.99999999999.0").forEach {
|
||||
assertFalse(it, ScanFidelityCapability.supports(version(it)))
|
||||
}
|
||||
assertFalse(ScanFidelityCapability.supports(version("1.5.0", "/qbit/other")))
|
||||
assertFalse(ScanFidelityCapability.supports(version(null)))
|
||||
assertFalse(ScanFidelityCapability.supports(null))
|
||||
val duplicate = UiApiVersion(
|
||||
"1.8.0", extensions = listOf(
|
||||
UiApiExtension("mobile-scanners", "1.5.0", "/qbit/mobile-scanners"),
|
||||
UiApiExtension("mobile-scanners", "1.5.0", "/qbit/mobile-scanners")
|
||||
)
|
||||
)
|
||||
assertFalse(ScanFidelityCapability.supports(duplicate))
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun capabilityIsScopedToTheServerThatWasDiscovered() {
|
||||
val v = version("1.5.0")
|
||||
assertTrue(ScanFidelityCapability.available("https://a/", "https://a/", v))
|
||||
assertFalse(ScanFidelityCapability.available("https://b/", "https://a/", v))
|
||||
assertFalse(ScanFidelityCapability.available("https://a/", "https://a/", null))
|
||||
}
|
||||
}
|
||||
@@ -65,8 +65,8 @@ class ScannerInputSessionTest {
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
fun scan(value: String = "001234567890") {
|
||||
pending = false; listener.scanned(value, "QRCODE")
|
||||
fun scan(value: String = "001234567890", raw: ByteArray? = null) {
|
||||
pending = false; listener.scanned(ScannerCapture.of(value, "QRCODE", raw))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,10 +76,11 @@ class ScannerInputSessionTest {
|
||||
private class Fixture {
|
||||
val devices = mutableListOf<Device>()
|
||||
val scans = mutableListOf<Pair<String, String>>()
|
||||
val captures = mutableListOf<ScannerCapture>()
|
||||
val statuses = mutableListOf<String>()
|
||||
val session = ScannerInputSession(
|
||||
{ Device().also { devices.add(it) } },
|
||||
{ value, type -> scans.add(value to type) }, { message, _ -> statuses.add(message) })
|
||||
{ capture -> captures.add(capture); scans.add(capture.barcode to capture.symbology) }, { message, _ -> statuses.add(message) })
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
@@ -97,7 +98,7 @@ class ScannerInputSessionTest {
|
||||
@Test
|
||||
fun missingHardwareKeepsManualFallback() {
|
||||
val statuses = mutableListOf<String>()
|
||||
val session = ScannerInputSession({ null }, { _, _ -> error("Unexpected scan") }, { message, _ -> statuses.add(message) })
|
||||
val session = ScannerInputSession({ null }, { error("Unexpected scan") },{ message, _ -> statuses.add(message) })
|
||||
session.setAccepting(true)
|
||||
session.setActive(true)
|
||||
assertTrue(statuses.single().contains("manually"))
|
||||
@@ -111,7 +112,7 @@ class ScannerInputSessionTest {
|
||||
val device = Device()
|
||||
var allowed = true
|
||||
val scans = mutableListOf<String>()
|
||||
val session = ScannerInputSession({ device }, { value, _ -> scans.add(value) }, { _, _ -> }, { allowed })
|
||||
val session = ScannerInputSession({ device }, { capture -> scans.add(capture.barcode) }, { _, _ -> }, { allowed })
|
||||
session.setAccepting(true)
|
||||
session.setActive(true)
|
||||
device.idle()
|
||||
@@ -135,7 +136,7 @@ class ScannerInputSessionTest {
|
||||
fun hiddenSurfaceNeverArmsEvenWhenOldReadinessFlagIsTrue() {
|
||||
val device = Device()
|
||||
var allowed = false
|
||||
val session = ScannerInputSession({ device }, { _, _ -> error("Unexpected scan") }, { _, _ -> }, { allowed })
|
||||
val session = ScannerInputSession({ device }, { error("Unexpected scan") }, { _, _ -> }, { allowed })
|
||||
session.setAccepting(true)
|
||||
session.setActive(true)
|
||||
device.idle()
|
||||
@@ -176,6 +177,27 @@ class ScannerInputSessionTest {
|
||||
assertEquals(2, device.reads)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun rawBytesReachTheSinkUnchangedAndStaleOwnersDeliverNothing() {
|
||||
val fixture = Fixture()
|
||||
val first = fixture.start()
|
||||
first.idle()
|
||||
val raw = ByteArray(256) { it.toByte() }
|
||||
fixture.session.setActive(false)
|
||||
fixture.session.setActive(true)
|
||||
first.scan("stale", raw)
|
||||
assertTrue(fixture.captures.isEmpty())
|
||||
val second = fixture.devices.last()
|
||||
second.idle()
|
||||
second.scan("current", raw)
|
||||
assertEquals("current", fixture.captures.single().barcode)
|
||||
assertEquals(java.util.Base64.getEncoder().encodeToString(raw), fixture.captures.single().rawDataBase64)
|
||||
assertTrue(fixture.captures.single().aimIdentifier == null)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -188,7 +210,7 @@ class ScannerInputSessionTest {
|
||||
device.idle()
|
||||
assertEquals(2, device.reads)
|
||||
// Deliver the first read's data while the second read is pending.
|
||||
device.listener.scanned("001234567890", "QRCODE")
|
||||
device.listener.scanned(ScannerCapture.of("001234567890", "QRCODE"))
|
||||
assertEquals(1, device.cancels)
|
||||
assertEquals(1, fixture.scans.size)
|
||||
device.idle()
|
||||
@@ -261,7 +283,7 @@ class ScannerInputSessionTest {
|
||||
device.idle()
|
||||
device.pending = false
|
||||
device.idle()
|
||||
device.listener.scanned("001234567890", "QRCODE")
|
||||
device.listener.scanned(ScannerCapture.of("001234567890", "QRCODE"))
|
||||
device.listener.noData(false)
|
||||
device.idle()
|
||||
assertEquals(1, device.cancels)
|
||||
|
||||
@@ -15,9 +15,55 @@ class ScannerMetaDataTest {
|
||||
@Test
|
||||
fun serverDefinesLabelsAndLengthWithContractDefaults() {
|
||||
assertEquals(ScannerMetaData(), ScannerMetaData.parse(emptyMap()))
|
||||
assertEquals("Saved scan", ScannerMetaData.parse(emptyMap()).acknowledgmentLabel)
|
||||
assertEquals(true, ScannerMetaData.parse(emptyMap()).manualEntryEnabled)
|
||||
assertEquals(ScannerMetaData("Item barcode", "Save item", 30), ScannerMetaData.parse(mapOf("manualLabel" to "Item barcode", "submitLabel" to "Save item", "maxLength" to 30L, "future" to true)))
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Manual input stays available by default and can be hidden independently of receipt presentation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun manualEntryVisibilityUsesExplicitBooleanHints() {
|
||||
assertEquals(true, ScannerMetaData.parse(mapOf("manualEntryEnabled" to true)).manualEntryEnabled)
|
||||
assertEquals(false, ScannerMetaData.parse(mapOf("manualEntryEnabled" to false)).manualEntryEnabled)
|
||||
assertEquals(ScannerMetaData(acknowledgmentLabel = "Print queued", manualEntryEnabled = false),
|
||||
ScannerMetaData.parse(mapOf("acknowledgmentLabel" to "Print queued", "manualEntryEnabled" to false)))
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Supplied flags must be JSON booleans, without number or string coercion.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun explicitlyInvalidManualEntryHintsFailClosed() {
|
||||
for (value in listOf(null, 0, 1, 1.0, "true", "false", "", listOf(true), mapOf("enabled" to true))) {
|
||||
assertThrows(IllegalArgumentException::class.java) { ScannerMetaData.parse(mapOf("manualEntryEnabled" to value)) }
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A server may describe accepted work without changing the scan receipt contract.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun acknowledgmentLabelsDescribeTheAcceptedOperation() {
|
||||
assertEquals("Print queued", ScannerMetaData.parse(mapOf("acknowledgmentLabel" to "Print queued")).acknowledgmentLabel)
|
||||
assertEquals("Étiquette prête", ScannerMetaData.parse(mapOf("acknowledgmentLabel" to "Étiquette prête")).acknowledgmentLabel)
|
||||
val longestLabel = "Q".repeat(80)
|
||||
assertEquals(longestLabel, ScannerMetaData.parse(mapOf("acknowledgmentLabel" to longestLabel)).acknowledgmentLabel)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject malformed supplied hints rather than silently displaying an unrelated success label.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun explicitlyInvalidAcknowledgmentLabelsFailClosed() {
|
||||
for (value in listOf(null, 42, false, "", " ", " Print queued", "Print queued ", "Q".repeat(81),
|
||||
"Print\nqueued", "Print\rqueued", "Print\tqueued", "Print\u0000queued", "Print\u2028queued", "Print\u2029queued",
|
||||
"Print\u202Equeued", "<b>Print queued</b>")) {
|
||||
assertThrows(IllegalArgumentException::class.java) { ScannerMetaData.parse(mapOf("acknowledgmentLabel" to value)) }
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0
|
||||
// Explicit disposable native Echo acceptance; the fixture's printer/jobs are simulated.
|
||||
import { runScannerE2e } from './scanner-e2e-runner.mjs';
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 4 || args[0] !== '--serial' || args[2] !== '--fixture' || !args[1] || !args[3]) {
|
||||
throw new Error('Usage: node scripts/ci/scanner-device-echo-e2e.mjs --serial TC22_SERIAL --fixture PRIVATE_ECHO_FIXTURE.json');
|
||||
}
|
||||
await runScannerE2e({ physical: true, serial: args[1], scenario: 'echo', fixture: args[3] });
|
||||
@@ -9,6 +9,48 @@ import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { startSemanticApiProxy } from './semantic-api-proxy.mjs';
|
||||
|
||||
/*******************************************************************************
|
||||
** Admit only the explicitly simulated private Echo fixture, before any ADB call.
|
||||
*******************************************************************************/
|
||||
export function validateEchoFixtureDescriptor(fixture) {
|
||||
let url;
|
||||
try { url = new URL(fixture?.baseUrl); } catch { throw new Error('Echo requires its private isolated loopback fixture.'); }
|
||||
if (fixture.fixtureType !== 'echo-ui-e2e' || fixture.simulatedPrinting !== true ||
|
||||
url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || ['57832', '51631', '18081', '18086'].includes(url.port) ||
|
||||
url.username || url.password || url.search || url.hash || url.pathname !== '/' ||
|
||||
fixture.username !== 'scanner' || fixture.adminUsername !== 'admin' ||
|
||||
typeof fixture.password !== 'string' || fixture.password.length < 16 || typeof fixture.adminPassword !== 'string' || fixture.adminPassword.length < 16) {
|
||||
throw new Error('Echo requires fixtureType echo-ui-e2e, simulated printing and isolated private credentials; normal and printing runtimes are forbidden.');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An Echo descriptor cannot substitute an ordinary demo or another contract.
|
||||
*******************************************************************************/
|
||||
export function validateEchoFixtureContract(version, branding) {
|
||||
const extension = Array.isArray(version?.extensions) ? version.extensions.find(value => value.name === 'mobile-scanners') : null;
|
||||
if (version?.apiVersion !== '1.10.0' || extension?.version !== '1.4.0' || extension.pathPrefix !== '/qbit/mobile-scanners' ||
|
||||
branding?.displayName !== 'Echo UI E2E — simulated printing') {
|
||||
throw new Error('Echo requires the simulated Echo fixture with core UI API1.10 and mobile-scanners1.4.');
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare raw identity only internally; never return it as command evidence.
|
||||
*******************************************************************************/
|
||||
export function physicalSerialMatches(expected, output) {
|
||||
return typeof expected === 'string' && expected.length > 0 && typeof output === 'string' && output.trim() === expected;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Strip selected device and opaque identifiers from Echo reports and errors.
|
||||
*******************************************************************************/
|
||||
export function redactEchoOutput(text, serial) {
|
||||
return text.replaceAll(serial, '<DEVICE>').replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<ID>')
|
||||
.replace(/\b[0-9a-f]{32,64}\b/gi, '<ID>').replace(/\bscanner-[0-9a-f]{24}\b/gi, '<DEVICE>');
|
||||
}
|
||||
|
||||
/** Shared implementation; physical execution is exposed only by the explicit device entry point. */
|
||||
export async function runScannerE2e(options = {}) {
|
||||
const repository = resolve(fileURLToPath(new URL('../..', import.meta.url)));
|
||||
@@ -19,14 +61,18 @@ export async function runScannerE2e(options = {}) {
|
||||
const selectedScenario = options.scenario ?? process.env.E2E_SCENARIO;
|
||||
const physical = options.physical === true;
|
||||
const deviceScreen = selectedScenario === 'device-screen';
|
||||
const echo = selectedScenario === 'echo';
|
||||
if (echo && (!physical || !options.fixture || !serial || serial.startsWith('emulator-') || !/^[A-Za-z0-9._:-]+$/.test(serial))) {
|
||||
throw new Error('Echo acceptance requires an explicit TC22 serial and private Echo fixture.');
|
||||
}
|
||||
if (deviceScreen && (!physical || options.operatorConsent !== true || !options.fixture || !serial || !/^[A-Za-z0-9._:-]+$/.test(serial))) {
|
||||
throw new Error('Device-screen acceptance requires an explicit physical serial, private fixture and operator-consent opt-in.');
|
||||
}
|
||||
const fixture = options.fixture ? JSON.parse(await readFile(options.fixture, 'utf8')) : null;
|
||||
if (fixture) {
|
||||
const url = new URL(fixture.baseUrl);
|
||||
const url = echo ? validateEchoFixtureDescriptor(fixture) : new URL(fixture.baseUrl);
|
||||
const purposeValid = deviceScreen ? fixture.purpose === 'device-screen-qa' && !['57832', '51631'].includes(url.port) :
|
||||
selectedScenario === 'photos' && fixture.processName === 'mobileRecordPhotos' && fixture.tableName === 'document' && String(fixture.recordId) === '1';
|
||||
echo || selectedScenario === 'photos' && fixture.processName === 'mobileRecordPhotos' && fixture.tableName === 'document' && String(fixture.recordId) === '1';
|
||||
if (!physical || !purposeValid || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password || url.search || url.hash || url.pathname !== '/' ||
|
||||
fixture.username !== 'scanner' || fixture.adminUsername !== 'admin' ||
|
||||
typeof fixture.password !== 'string' || fixture.password.length < 16 || typeof fixture.adminPassword !== 'string' || fixture.adminPassword.length < 16) {
|
||||
@@ -41,8 +87,11 @@ export async function runScannerE2e(options = {}) {
|
||||
const stages = new Set();
|
||||
const adminPassword = fixture?.adminPassword ?? randomBytes(24).toString('base64url');
|
||||
const scannerPassword = fixture?.password ?? randomBytes(24).toString('base64url');
|
||||
const redact = (text) => text.replaceAll(adminPassword, '<REDACTED>').replaceAll(scannerPassword, '<REDACTED>');
|
||||
const run = (command, args, input) => new Promise((resolveRun, reject) => {
|
||||
const redact = (text) => {
|
||||
const safe = text.replaceAll(adminPassword, '<REDACTED>').replaceAll(scannerPassword, '<REDACTED>');
|
||||
return echo ? redactEchoOutput(safe, serial) : safe;
|
||||
};
|
||||
const run = (command, args, input, checkOutput) => new Promise((resolveRun, reject) => {
|
||||
const child = spawn(command, args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
// Native Compose instrumentation includes emulator startup and transport shutdown after the test finishes.
|
||||
const timeout = args.includes('instrument') ? (deviceScreen ? 720_000 : 300_000) : 180_000;
|
||||
@@ -68,7 +117,7 @@ export async function runScannerE2e(options = {}) {
|
||||
child.on('error', (error) => { clearTimeout(deadline); reject(error); });
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(deadline);
|
||||
if (code === 0) resolveRun(redact(output)); else reject(new Error(redact(`${command} failed (${code}): ${output}`)));
|
||||
if (code === 0) resolveRun(checkOutput ? checkOutput(output) === true : redact(output)); else reject(new Error(redact(`${command} failed (${code}): ${output}`)));
|
||||
});
|
||||
child.stdin.end(input);
|
||||
});
|
||||
@@ -76,14 +125,15 @@ export async function runScannerE2e(options = {}) {
|
||||
const grantE2eLocationPermissions = async () => {
|
||||
// These scenarios exercise scanner/navigation behavior, not the Android permission dialog.
|
||||
// Never grant permissions to the operator's normal app or the separate consent-based QA app.
|
||||
if (packageName !== 'io.qrun.mobile.scanner.e2e') return;
|
||||
if (packageName !== 'io.qrun.mobile.scanner.e2e' || echo) return;
|
||||
await adbRun('shell', 'pm', 'grant', packageName, 'android.permission.ACCESS_COARSE_LOCATION');
|
||||
await adbRun('shell', 'pm', 'grant', packageName, 'android.permission.ACCESS_FINE_LOCATION');
|
||||
};
|
||||
|
||||
const emulated = (await adbRun('shell', 'getprop', 'ro.kernel.qemu')).trim() === '1';
|
||||
if (physical) {
|
||||
if (!serial || serial.startsWith('emulator-') || emulated || (await adbRun('get-serialno')).trim() !== serial) {
|
||||
if (!serial || serial.startsWith('emulator-') || emulated ||
|
||||
!await run(adb, [...adbPrefix, 'get-serialno'], undefined, output => physicalSerialMatches(serial, output))) {
|
||||
throw new Error('Physical acceptance requires an explicitly selected non-emulator serial.');
|
||||
}
|
||||
const manufacturer = (await adbRun('shell', 'getprop', 'ro.product.manufacturer')).trim();
|
||||
@@ -114,7 +164,7 @@ export async function runScannerE2e(options = {}) {
|
||||
}
|
||||
console.log(`Native ${scenario} acceptance passed (local UI, no server).`);
|
||||
};
|
||||
const allowedScenarios = [...(physical ? ['navigation', 'photos', 'device-screen'] : []), ...Object.keys(localScenarios), 'pixel-copy', 'unsupported-api', 'missing-extension', 'missing-channel', 'compatible', 'additive', 'admin-forms', 'session-monitor'];
|
||||
const allowedScenarios = [...(physical ? ['navigation', 'photos', 'device-screen', 'echo'] : []), ...Object.keys(localScenarios), 'pixel-copy', 'unsupported-api', 'missing-extension', 'missing-channel', 'compatible', 'additive', 'admin-forms', 'session-monitor'];
|
||||
if (selectedScenario && !allowedScenarios.includes(selectedScenario)) throw new Error('Unknown native E2E scenario.');
|
||||
if (Object.hasOwn(localScenarios, selectedScenario)) {
|
||||
const temporary = [];
|
||||
@@ -172,6 +222,8 @@ export async function runScannerE2e(options = {}) {
|
||||
let reverseAdded = false;
|
||||
let screenEvidence;
|
||||
let screenArtifacts;
|
||||
let echoEvidence;
|
||||
let echoArtifacts;
|
||||
server?.on('error', (error) => { serverError = error; });
|
||||
try {
|
||||
for (const setting of physical ? [] : ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale']) {
|
||||
@@ -192,22 +244,34 @@ export async function runScannerE2e(options = {}) {
|
||||
proxy = await startSemanticApiProxy(origin);
|
||||
const apkPaths = [join(repository, `scanner-host/build/outputs/apk/${variant}/scanner-host-${variant}.apk`),
|
||||
join(repository, `scanner-host/build/outputs/apk/androidTest/${variant}/scanner-host-${variant}-androidTest.apk`)];
|
||||
if (deviceScreen) {
|
||||
if (deviceScreen || echo) {
|
||||
if (!sdk) throw new Error('ANDROID_HOME is required for APK identity verification.');
|
||||
const tool = join(sdk, 'build-tools', '35.0.0', process.platform === 'win32' ? 'aapt2.exe' : 'aapt2');
|
||||
for (const [index, apk] of apkPaths.entries()) {
|
||||
const actual = /^package: name='([^']+)'/m.exec(await run(tool, ['dump', 'badging', apk]))?.[1];
|
||||
if (actual !== packageName + (index ? '.test' : '')) throw new Error('Refusing an APK outside the disposable QA target.');
|
||||
if (actual !== packageName + (index ? '.test' : '')) throw new Error('Refusing an APK outside the exact disposable acceptance target.');
|
||||
}
|
||||
const version = await fetch(`${origin}/qqq/v1/version`, { signal: AbortSignal.timeout(10_000) }).then(response => {
|
||||
if (!response.ok) throw new Error('Fixture discovery failed.'); return response.json();
|
||||
});
|
||||
const extension = version.extensions?.find(value => value.name === 'mobile-scanners');
|
||||
if (extension?.version !== '1.2.0' || extension.pathPrefix !== '/qbit/mobile-scanners') throw new Error('Device-screen fixture must serve the qualified mobile-scanners1.2 contract.');
|
||||
screenArtifacts = await Promise.all(apkPaths.map(async (apk, index) => ({ applicationId: packageName + (index ? '.test' : ''), sha256: createHash('sha256').update(await readFile(apk)).digest('hex') })));
|
||||
screenEvidence = join(repository, `scanner-host/build/native-device-screen-${Date.now()}`);
|
||||
await mkdir(screenEvidence, { recursive: true });
|
||||
await writeFile(join(screenEvidence, 'proof.json'), JSON.stringify({ status: 'PREPARING', artifacts: screenArtifacts }, null, 2));
|
||||
if (deviceScreen) {
|
||||
if (extension?.version !== '1.2.0' || extension.pathPrefix !== '/qbit/mobile-scanners') throw new Error('Device-screen fixture must serve the qualified mobile-scanners1.2 contract.');
|
||||
screenArtifacts = await Promise.all(apkPaths.map(async (apk, index) => ({ applicationId: packageName + (index ? '.test' : ''), sha256: createHash('sha256').update(await readFile(apk)).digest('hex') })));
|
||||
screenEvidence = join(repository, `scanner-host/build/native-device-screen-${Date.now()}`);
|
||||
await mkdir(screenEvidence, { recursive: true });
|
||||
await writeFile(join(screenEvidence, 'proof.json'), JSON.stringify({ status: 'PREPARING', artifacts: screenArtifacts }, null, 2));
|
||||
} else {
|
||||
const branding = await fetch(`${origin}/qqq/v1/qbit/mobile-scanners/branding`, { signal: AbortSignal.timeout(10_000) }).then(response => {
|
||||
if (!response.ok) throw new Error('Echo fixture branding discovery failed.'); return response.json();
|
||||
});
|
||||
validateEchoFixtureContract(version, branding);
|
||||
if (['57832', '51631', '18081', '18086'].includes(String(proxy.port))) throw new Error('Refusing a protected reverse port.');
|
||||
echoArtifacts = await Promise.all(apkPaths.map(async (apk, index) => ({ applicationId: packageName + (index ? '.test' : ''), sha256: createHash('sha256').update(await readFile(apk)).digest('hex') })));
|
||||
echoEvidence = join(repository, 'scanner-host/build/native-device-e2e');
|
||||
await mkdir(echoEvidence, { recursive: true });
|
||||
await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({ status: 'PREPARING', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts }, null, 2));
|
||||
}
|
||||
}
|
||||
await adbRun('reverse', '--no-rebind', `tcp:${proxy.port}`, `tcp:${proxy.port}`);
|
||||
reverseAdded = true;
|
||||
@@ -260,12 +324,18 @@ export async function runScannerE2e(options = {}) {
|
||||
{ mode: 'compatible', name: 'admin-forms', test: 'adminFormsApproveRevokeAndCancel', requireCancel: true },
|
||||
{ mode: 'compatible', name: 'session-monitor', test: 'sessionActivityMirrorsActualScreenAndAdminEndForcesFreshLogin', requireSession: true },
|
||||
...(fixture ? [{ mode: 'compatible', name: 'photos', test: 'cameraAutoUploadsAndPendingGallerySurvivesRotationThenCleansOnRemoteLogout', requirePhotos: true }] : []),
|
||||
...(echo ? [{ mode: 'compatible', name: 'echo', test: 'echoSelectsOnceQueuesTwoScansAndClosesOnAppsLeave', requireCancel: true }] : []),
|
||||
]) {
|
||||
if (selectedScenario && selectedScenario !== (phase.name ?? phase.mode)) continue;
|
||||
proxy.setMode(phase.mode);
|
||||
await adbRun('shell', 'pm', 'clear', packageName);
|
||||
// Clearing disposable app data also resets runtime permissions on fresh Android images.
|
||||
if ((phase.name ?? phase.mode) === 'session-monitor' && packageName === 'io.qrun.mobile.scanner.e2e') {
|
||||
if (echo) {
|
||||
// Echo does not qualify GPS: keep reporting off only in this new disposable app.
|
||||
await adbRun('shell', 'run-as', packageName, 'mkdir', '-p', 'shared_prefs');
|
||||
await run(adb, [...adbPrefix, 'shell', 'run-as', packageName, 'sh', '-c', "'cat > shared_prefs/host.xml'"],
|
||||
'<?xml version="1.0" encoding="utf-8" standalone="yes" ?><map><boolean name="locationReportingEnabled" value="false" /><boolean name="locationPermissionRequested" value="true" /></map>');
|
||||
} else if ((phase.name ?? phase.mode) === 'session-monitor' && packageName === 'io.qrun.mobile.scanner.e2e') {
|
||||
// Preserve this scenario's real denied-location telemetry assertion without an OS dialog.
|
||||
await adbRun('shell', 'pm', 'revoke', packageName, 'android.permission.ACCESS_FINE_LOCATION');
|
||||
await adbRun('shell', 'pm', 'revoke', packageName, 'android.permission.ACCESS_COARSE_LOCATION');
|
||||
@@ -277,7 +347,8 @@ export async function runScannerE2e(options = {}) {
|
||||
}
|
||||
await adbRun('shell', 'run-as', packageName, 'mkdir', '-p', 'files');
|
||||
await run(adb, [...adbPrefix, 'shell', 'run-as', packageName, 'sh', '-c', "'cat > files/e2e.json'"],
|
||||
JSON.stringify({ serverUrl: `http://127.0.0.1:${proxy.port}/`, adminPassword, scannerPassword, expectedError: phase.expectedError }));
|
||||
JSON.stringify({ serverUrl: `http://127.0.0.1:${proxy.port}/`, adminPassword, scannerPassword, expectedError: phase.expectedError,
|
||||
...(echo ? { fixtureType: 'echo-ui-e2e', simulatedPrinting: true } : {}) }));
|
||||
const test = phase.test ?? (phase.expectedError ? 'incompatibleServerStopsBeforeLogin' : 'loginEnrollApproveCaptureAndRevoke');
|
||||
const output = await adbRun('shell', 'am', 'instrument', '-w', '-r',
|
||||
'-e', 'class', `io.qrun.mobile.scanner.ScannerEndToEndTest#${test}`,
|
||||
@@ -288,29 +359,40 @@ export async function runScannerE2e(options = {}) {
|
||||
throw new Error(`Native scanner E2E (${phase.mode}) failed:\n${output}`);
|
||||
}
|
||||
reports.push({ scenario: phase.name ?? phase.mode, ...proxy.verify(!phase.expectedError, Boolean(phase.requireCancel), Boolean(phase.requireSession), Boolean(phase.requirePhotos)) });
|
||||
if (echo) {
|
||||
const expectedStages = ['echo-document-selected', 'echo-stale-document-cleared', 'echo-setup-selected', 'echo-first-queued', 'echo-second-queued', 'echo-recreation-stable', 'echo-run-closed'];
|
||||
if (expectedStages.some(stage => !stages.has(stage))) throw new Error('Echo did not complete every setup/queue/recreation/cancellation stage.');
|
||||
await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({ status: 'PASSED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true,
|
||||
artifacts: echoArtifacts, completedStages: expectedStages, acceptedScans: 2, ...reports.at(-1), physicalScannerQualified: false, physicalPrintingQualified: false }, null, 2));
|
||||
}
|
||||
console.log(`Native semantic E2E passed: ${phase.name ?? phase.mode}.`);
|
||||
}
|
||||
await writeFile(join(evidence, 'semantic-contract.json'), `${JSON.stringify(reports, null, 2)}\n`);
|
||||
console.log('All native requests in the selected scenarios matched the published semantic API.');
|
||||
} catch (error) {
|
||||
if (echo && echoEvidence) await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({
|
||||
status: 'FAILED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts, completedStages: [...stages],
|
||||
}, null, 2));
|
||||
if (deviceScreen && screenEvidence) await writeFile(join(screenEvidence, 'proof.json'), JSON.stringify({
|
||||
status: 'FAILED', artifacts: screenArtifacts, completedStages: [...stages], physicalDisplayVerified: false,
|
||||
}, null, 2));
|
||||
throw error;
|
||||
} finally {
|
||||
const screenCleanupFailures = [];
|
||||
if (echo && echoEvidence && proxy) await writeFile(join(echoEvidence, 'echo-operations.json'), JSON.stringify(proxy.evidence(), null, 2))
|
||||
.catch(() => { screenCleanupFailures.push('Echo semantic evidence'); });
|
||||
if (deviceScreen && screenEvidence && proxy) await writeFile(join(screenEvidence, 'operations.json'), JSON.stringify(proxy.evidence(), null, 2))
|
||||
.catch(() => { screenCleanupFailures.push('semantic evidence'); });
|
||||
for (const [setting, previous] of animationScales) {
|
||||
if (previous === 'null') await adbRun('shell', 'settings', 'delete', 'global', setting).catch(() => {});
|
||||
else await adbRun('shell', 'settings', 'put', 'global', setting, previous).catch(() => {});
|
||||
}
|
||||
if (installedByRun.has(packageName)) await adbRun('shell', 'run-as', packageName, 'rm', '-f', 'files/e2e.json').catch(() => {});
|
||||
if (installedByRun.has(packageName)) await adbRun('shell', 'run-as', packageName, 'rm', '-f', 'files/e2e.json').catch(() => { if (echo) screenCleanupFailures.push('Echo credential file'); });
|
||||
if (deviceScreen && installedByRun.has(packageName)) await adbRun('shell', 'run-as', packageName, 'rm', '-f', 'files/qa-screen.json')
|
||||
.catch(() => { screenCleanupFailures.push('QA credential file'); });
|
||||
for (const installed of [...installedByRun].reverse()) await adbRun('uninstall', installed).catch(() => { if (deviceScreen) screenCleanupFailures.push('QA package'); });
|
||||
for (const installed of [...installedByRun].reverse()) await adbRun('uninstall', installed).catch(() => { if (deviceScreen || echo) screenCleanupFailures.push('disposable package'); });
|
||||
if (proxy) {
|
||||
if (reverseAdded) await adbRun('reverse', '--remove', `tcp:${proxy.port}`).catch(() => { if (deviceScreen) screenCleanupFailures.push('owned reverse'); });
|
||||
if (reverseAdded) await adbRun('reverse', '--remove', `tcp:${proxy.port}`).catch(() => { if (deviceScreen || echo) screenCleanupFailures.push('owned reverse'); });
|
||||
await proxy.close();
|
||||
}
|
||||
const stopped = new Promise((done) => { if (!server || server.exitCode !== null || serverError) done(); else server.once('exit', done); });
|
||||
@@ -323,7 +405,13 @@ export async function runScannerE2e(options = {}) {
|
||||
throw new Error('Refusing cleanup outside the isolated demo temporary directory.');
|
||||
}
|
||||
if (directory) await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||
if (screenCleanupFailures.length) throw new Error(`QA cleanup/evidence incomplete: ${screenCleanupFailures.join(', ')}`);
|
||||
if (screenCleanupFailures.length) {
|
||||
if (echo && echoEvidence) await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({
|
||||
status: 'CLEANUP_FAILED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts,
|
||||
completedStages: [...stages], cleanupFailures: screenCleanupFailures,
|
||||
}, null, 2));
|
||||
throw new Error(`Disposable acceptance cleanup/evidence incomplete: ${screenCleanupFailures.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { runScannerE2e, validateEchoFixtureDescriptor, validateEchoFixtureContract, physicalSerialMatches, redactEchoOutput } from './scanner-e2e-runner.mjs';
|
||||
|
||||
const fixture = {
|
||||
fixtureType: 'echo-ui-e2e', simulatedPrinting: true, baseUrl: 'http://127.0.0.1:49152/',
|
||||
username: 'scanner', password: 'synthetic-scanner-password', adminUsername: 'admin', adminPassword: 'synthetic-admin-password',
|
||||
};
|
||||
const version = { apiVersion: '1.10.0', extensions: [{ name: 'mobile-scanners', version: '1.4.0', pathPrefix: '/qbit/mobile-scanners' }] };
|
||||
const branding = { displayName: 'Echo UI E2E — simulated printing' };
|
||||
|
||||
/*******************************************************************************
|
||||
** Identity is checked before evidence redaction; only the comparison escapes.
|
||||
*******************************************************************************/
|
||||
test('raw serial matches exactly and a different physical device is refused', () => {
|
||||
const serial = 'SYNTHETIC_TC22_SERIAL';
|
||||
assert.equal(physicalSerialMatches(serial, `${serial}\r\n`), true);
|
||||
assert.equal(physicalSerialMatches(serial, 'ANOTHER_DEVICE\r\n'), false);
|
||||
assert.equal(physicalSerialMatches(serial, redactEchoOutput(`${serial}\r\n`, serial)), false);
|
||||
});
|
||||
|
||||
/*******************************************************************************
|
||||
** Serial-bearing successful output and errors remain safe to persist as reports.
|
||||
*******************************************************************************/
|
||||
test('Echo artifacts and error text contain no selected serial', () => {
|
||||
const serial = 'SYNTHETIC_TC22_SERIAL';
|
||||
const artifact = JSON.stringify({ output: redactEchoOutput(`${serial}\nOK (1 test)`, serial),
|
||||
error: redactEchoOutput(`Device ${serial} failed`, serial) });
|
||||
assert.equal(artifact.includes(serial), false);
|
||||
assert.equal(artifact.includes('<DEVICE>'), true);
|
||||
});
|
||||
|
||||
/*******************************************************************************
|
||||
** A valid descriptor is inspected without contacting a server or any device.
|
||||
*******************************************************************************/
|
||||
test('explicit simulated Echo descriptor is accepted', () => {
|
||||
assert.equal(validateEchoFixtureDescriptor(fixture).port, '49152');
|
||||
validateEchoFixtureContract(version, branding);
|
||||
});
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing scanner and printing listeners can never become this test's target.
|
||||
*******************************************************************************/
|
||||
test('all protected normal and printing ports are refused', () => {
|
||||
for (const port of [57832, 51631, 18081, 18086]) {
|
||||
assert.throws(() => validateEchoFixtureDescriptor({ ...fixture, baseUrl: `http://127.0.0.1:${port}/` }));
|
||||
}
|
||||
});
|
||||
|
||||
/*******************************************************************************
|
||||
** URL decoration cannot turn the private fixture into another host or route.
|
||||
*******************************************************************************/
|
||||
test('non-private or decorated fixture origins are refused', () => {
|
||||
for (const baseUrl of ['https://127.0.0.1:49152/', 'http://example.invalid:49152/', 'http://127.0.0.1/',
|
||||
'http://127.0.0.1:49152/path', 'http://127.0.0.1:49152/?value=1', 'http://127.0.0.1:49152/#fragment',
|
||||
'http://scanner:secret@127.0.0.1:49152/', 'not-a-url']) {
|
||||
assert.throws(() => validateEchoFixtureDescriptor({ ...fixture, baseUrl }));
|
||||
}
|
||||
});
|
||||
|
||||
/*******************************************************************************
|
||||
** Purpose, simulation and private account declarations must all match exactly.
|
||||
*******************************************************************************/
|
||||
test('wrong-purpose or incomplete credentials are refused', () => {
|
||||
for (const changed of [{ fixtureType: 'device-screen-qa' }, { simulatedPrinting: false }, { simulatedPrinting: 'true' },
|
||||
{ username: 'operator' }, { adminUsername: 'owner' }, { password: null }, { password: 'short' }, { adminPassword: 'short' }]) {
|
||||
assert.throws(() => validateEchoFixtureDescriptor({ ...fixture, ...changed }));
|
||||
}
|
||||
});
|
||||
|
||||
/*******************************************************************************
|
||||
** Another real demo or an unqualified contract cannot masquerade as this fixture.
|
||||
*******************************************************************************/
|
||||
test('contract and simulated branding must match the prepared cohort', () => {
|
||||
assert.throws(() => validateEchoFixtureContract({ ...version, apiVersion: '1.9.0' }, branding));
|
||||
assert.throws(() => validateEchoFixtureContract({ ...version, extensions: [{ ...version.extensions[0], version: '1.3.0' }] }, branding));
|
||||
assert.throws(() => validateEchoFixtureContract(version, { displayName: 'QQQ Mobile' }));
|
||||
});
|
||||
|
||||
/*******************************************************************************
|
||||
** These calls stop before reading a fixture or spawning ADB, even when selected
|
||||
** through the generic emulator entry or an incomplete physical invocation.
|
||||
*******************************************************************************/
|
||||
test('Echo requires its explicit physical entry inputs before device operations', async () => {
|
||||
await assert.rejects(runScannerE2e({ scenario: 'echo' }), /explicit TC22/);
|
||||
await assert.rejects(runScannerE2e({ scenario: 'echo', physical: true, fixture: 'unused.json' }), /explicit TC22/);
|
||||
await assert.rejects(runScannerE2e({ scenario: 'echo', physical: true, fixture: 'unused.json', serial: 'emulator-5554' }), /explicit TC22/);
|
||||
});
|
||||
Reference in new issue
Block a user